fix(render): #350 — the shadow ledger's lifetime counters are 64-bit; the 2h42m overflow is closed

RenderDeltaApplyResult's nine counters accumulate for the lifetime of
a world generation (the cumulative sum is never reset in place) yet
were int where every sibling lifetime counter in the same class was
already ulong/long. A 2h42m single-generation session (login to crash
with zero portals) overflowed one through ordinary per-tick churn from
SynchronizeActiveSources' two call sites per frame. Introduced
0eb66485 (2026-07-24); first reached by the vendor buy gate because
parking at a shop for hours produced the project's first multi-hour
unbroken generation. The vendor materializer was exonerated by routing
analysis (shop-item Ingest/Remove reaches inventory deltas only, never
the render journal).

Widened to long; the per-tick builder stays int and widens implicitly;
the arithmetic stays checked. All 68 render-shadow tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 10:29:39 +02:00
parent 3c9fc57adb
commit 58c8de264e
2 changed files with 41 additions and 10 deletions

View file

@ -24,6 +24,32 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #350 — Render-shadow ledger overflow after 2h42m: lifetime int counters in a never-reset accumulator
**Status:** FIXED IN TREE 2026-08-08 (pending clean-room + landing).
**Evidence:** `vendor-buy-gate.log` ~1606 — checked OverflowException in
`RenderSceneShadowRuntime.Add` from `UpdateFrameOrchestrator.Tick`, exit 82,
2h42m into a SINGLE world generation (login 20:28 -> crash 23:10, one
`[world-reveal] generation=1`, no portal).
**Mechanism (investigated, vendor coupling REFUTED):**
`_cumulativeApply` sums nine int fields once per tick for the lifetime of a
world generation and is never reset in place (production has zero Clear()
call sites; only generation replacement constructs a fresh runtime).
`SynchronizeActiveSources` feeds per-entity churn from TWO call sites per
frame; at uncapped frame rates a long session's ordinary churn crosses
int.MaxValue. Introduced `0eb66485` (2026-07-24) with the class's OTHER
lifetime counters already ulong/long — only these nine were undersized.
The vendor materializer was exonerated by routing analysis: shop-item
Ingest/Remove reaches inventory deltas only, never the render journal;
the correlation was that buy-gate testing produced the first multi-hour
single-generation soak.
**Fix:** widen `RenderDeltaApplyResult`'s nine fields to long (the
per-tick builder stays int and widens implicitly); arithmetic stays
checked. No clamp, no reset-behavior change — the counters are
legitimately unbounded lifetime telemetry that was simply undersized.
## #348 — Render-loop death by Win32 cursor-handle exhaustion: Silk recreates the native cursor on every alternation
**Status:** FIX IN TREE (2026-08-08) pending the vendor-gate relaunch.

View file

@ -401,18 +401,23 @@ internal readonly record struct RenderSceneIndexCounts(
};
}
// #350: these counters accumulate for the LIFETIME of a world generation
// (RenderSceneShadowRuntime._cumulativeApply is never reset in place), so
// they are 64-bit like the class's other lifetime counters — a 2h42m
// single-generation session overflowed the original int through ordinary
// per-tick churn. The arithmetic stays checked.
internal readonly record struct RenderDeltaApplyResult(
int Applied,
int Registered,
int Updated,
int Replaced,
int Unregistered,
int RejectedGeneration,
int RejectedOutOfOrderSequence,
int RejectedStaleIncarnation,
int RejectedMissing)
long Applied,
long Registered,
long Updated,
long Replaced,
long Unregistered,
long RejectedGeneration,
long RejectedOutOfOrderSequence,
long RejectedStaleIncarnation,
long RejectedMissing)
{
public int Rejected =>
public long Rejected =>
RejectedGeneration
+ RejectedOutOfOrderSequence
+ RejectedStaleIncarnation