From 15a303b7040875e21e4d7afe78fac6854d82ce27 Mon Sep 17 00:00:00 2001 From: AnkleBreaker Cowork Date: Tue, 25 Aug 2026 02:54:17 +0200 Subject: [PATCH] [FIX] Heartbeat : the 600s ceiling was above the server's 300s session window, so it under-billed peak CCU max_heartbeat_interval_s was 600. The server's SESSION_WINDOW_MS is 300s, and Unity and Godot both clamp to 240. WHY THAT IS A BILLING BUG, NOT A CADENCE PREFERENCE. computePeakConcurrency merges consecutive beats into ONE live span only while the gap stays <= the session window. At 600s a player online continuously for an hour produces six isolated instants instead of one span. Concurrent players' spans stop overlapping, the monthly PEAK CCU comes out low, and the studio is invoiced on the low figure. An Unreal or dedicated-server integrator raising the interval to cut telemetry cost - explicitly permitted by the header's own comment - silently reduced their own bill and the product's revenue together. This is bit-for-bit the defect the Unity SDK shipped in 0.19.3 (MAX_HEARTBEAT_INTERVAL_SECONDS = 600f). Tombstack carries tests/heartbeat-cadence.test.ts to stop it recurring, and its docblock names this exact failure - but it read the C# source and ONLY the C# source, so this SDK kept 600 with a green suite on both sides. That guard now parses the ceiling out of this repo's public header as well, and asserts Unity, Godot and native agree on one number. Changed here: the enforcing std::clamp in src/client.cpp and the range documented on options.heartbeat_interval_s in the public header. A caller that passes more than 240 now gets 240; the default (60s) and floor (15s) are untouched, and no signature or ABI changes. NOT TESTED IN THIS REPO, stated plainly rather than implied. max_heartbeat_interval_s is translation-unit local and Client exposes no accessor for the resolved interval, so asserting it would mean widening internals for the test - a larger change than the fix. The contract an integrator reads IS the header, and that is now pinned from Tombstack. A follow-up that exposes the resolved interval would let tests/ cover it directly. FOLLOW-UP FLAGGED, NOT SILENTLY CARRIED: the version is duplicated as a literal in src/tombstone_api.cpp (`sdk_version`) and in project(... VERSION ...). That duplication has already shipped two wrong versions - the changelog records this string pinned at "0.1.0" once and stuck at "0.8.0" once. Both are bumped to 0.9.2 here, and both now carry a comment saying so; deriving it from PROJECT_VERSION is the real fix. --- CHANGELOG.md | 19 +++++++++++++++++++ CMakeLists.txt | 5 ++++- include/tombstone/tombstone.h | 6 +++++- src/client.cpp | 13 ++++++++++++- src/tombstone_api.cpp | 5 ++++- 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 106cd94..12c4481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,25 @@ All notable changes to the Tombstack Native SDK (the `tombstone_*` C ABI and the `tombstone` library name are stable — Tombstack is the product name). +## [0.9.2] - 2026-08-25 + +### Fixed — heartbeat ceiling lowered to 240s (behaviour change; no API/ABI change) + +- **`options.heartbeat_interval_s` is now clamped to `[15, 240]`, was `[15, 600]`.** The ceiling is a + BILLING constant, not a preference. The server merges consecutive beats into one live session only + while the gap stays under its 300s session window, so a 600s cadence shattered a continuously-online + player into isolated instants: concurrent sessions stopped overlapping, monthly **peak CCU** read low, + and the studio was invoiced on the low figure. An integrator raising the interval to cut telemetry + cost — explicitly permitted by the header's own comment — silently reduced their own bill. + + This is bit-for-bit the defect the Unity SDK shipped in 0.19.3. The Tombstack repo's + `tests/heartbeat-cadence.test.ts` was written to stop it recurring, but read the C# source **only**, + so this SDK kept 600 with a green suite. That guard now parses the ceiling out of this repo's public + header as well, and asserts all three SDKs (Unity, Godot, native) agree. + + A caller that passed a value above 240 gets 240 instead. Nothing else about the cadence changes; the + default (60s) and the floor (15s) are untouched. + ## [0.9.1] - 2026-07-20 ### Hardening (production audit; no API/ABI change) diff --git a/CMakeLists.txt b/CMakeLists.txt index f9fb155..407ae18 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,8 @@ cmake_minimum_required(VERSION 3.16) -project(tombstone VERSION 0.9.1 LANGUAGES C CXX) +# NOTE: this version is DUPLICATED as a literal in src/tombstone_api.cpp (`sdk_version`), which is what +# tombstone_version() returns. Bump both together — 0.9.1's changelog records that the reported version +# was once stuck at 0.8.0 for exactly this reason. Deriving it from PROJECT_VERSION is the real fix. +project(tombstone VERSION 0.9.2 LANGUAGES C CXX) option(TOMBSTONE_BUILD_STATIC "Also build a static tombstone library" OFF) option(TOMBSTONE_BUILD_EXAMPLES "Build the example programs" ON) diff --git a/include/tombstone/tombstone.h b/include/tombstone/tombstone.h index 3dec3b6..a5f3e0d 100644 --- a/include/tombstone/tombstone.h +++ b/include/tombstone/tombstone.h @@ -104,7 +104,11 @@ typedef struct tombstone_options { * "staging", "dev"; clamped to 64). NULL/empty = unset (the server * defaults to "production"). tombstone_set_environment() overrides this. */ const char *environment; - /** Seconds between session heartbeats; clamped to [15, 600]. 0 = default (60). */ + /** Seconds between session heartbeats; clamped to [15, 240]. 0 = default (60). + * The ceiling is a BILLING constant, not a preference: the server merges + * consecutive beats into one live session only while the gap stays under its + * 300s window, so a slower cadence shatters a continuous session into isolated + * instants and the studio's monthly peak-CCU invoice comes out low. */ int heartbeat_interval_s; /** Nonzero (default) = emit periodic session heartbeats. */ int enable_heartbeats; diff --git a/src/client.cpp b/src/client.cpp index 3a9f34d..c4fc293 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -31,7 +31,18 @@ constexpr const char *metrics_batch_path = "/api/v1/ingest/metrics:batch"; constexpr const char *pull_requests_path = "/api/v1/pull-requests"; constexpr int min_heartbeat_interval_s = 15; -constexpr int max_heartbeat_interval_s = 600; +// 240, NOT 600 — the server's session window is 300s and this is a BILLING constant. +// +// computePeakConcurrency merges consecutive beats into one live span only while the gap is <= that +// window. At 600s a player online for an hour produces six isolated instants instead of one span, so +// concurrent players stop overlapping, the monthly PEAK CCU comes out low, and the studio is invoiced +// on the low figure. Unity shipped exactly this (0.19.3, MAX_HEARTBEAT_INTERVAL_SECONDS = 600f) and +// the Tombstack repo carries tests/heartbeat-cadence.test.ts to stop it recurring — but that guard read +// the C# source only, which is why this side kept 600. It now reads the C header's range as well. +// +// The 20% headroom is deliberate: at exactly the window each beat expires as the next arrives, so any +// scheduling jitter or upload latency opens a gap. +constexpr int max_heartbeat_interval_s = 240; constexpr int default_heartbeat_interval_s = 60; constexpr const char *identity_file_name = "identity"; diff --git a/src/tombstone_api.cpp b/src/tombstone_api.cpp index 305385a..c3d151a 100644 --- a/src/tombstone_api.cpp +++ b/src/tombstone_api.cpp @@ -12,7 +12,10 @@ namespace { -constexpr const char *sdk_version = "0.9.1"; +// Kept in lockstep with project(... VERSION ...) in CMakeLists.txt BY HAND, and that has failed TWICE: +// the changelog records this string pinned at "0.1.0" once and stuck at "0.8.0" once. Deriving it from +// PROJECT_VERSION is the real fix; until then, bump both or ship a third wrong version. +constexpr const char *sdk_version = "0.9.2"; // The process-wide client. Entry points snapshot the shared_ptr under the // mutex and call outside it, so a long flush() neither blocks other calls nor