fix(GWC-25,CLI-35,CLI-36): make the empty-ring ReplayGap resumable end to end

An empty replay ring reported oldest_available_sequence = 0 even when gap was
true. Clients follow the documented after_worker_sequence = oldest - 1 formula,
so an unsigned client computed ulong.MaxValue: the follow-up resume replayed
nothing, reported no gap, and the live filter dropped every subsequent event —
a silently dead stream in the headline detach-and-resume scenario, reachable on
default config once ReplayRetentionSeconds (300) age-evicts the ring.

GWC-25: SessionEventDistributor.RegisterWithReplay's empty-ring branch now
reports _highestSequenceSeen + 1 — the next sequence that can possibly be
delivered — when gap is true, so oldest - 1 lands exactly on the highest
observed sequence and the resume delivers everything newer. Still 0 when there
is no gap, where the field is meaningless and never emitted. Nothing is lost:
the evicted interval was unrecoverable either way, and the sentinel's job is to
say "re-snapshot".

CLI-35: the Python CLI fed every stream item into MessageToDict, which raised on
the ReplayGap dataclass and aborted the command after consuming the stream. A
new _event_row helper renders a gap as {"replayGap": {...}} — the same camelCase
shape the Rust CLI emits — and leaves proto events on the existing path.

CLI-36: the Go CLI formatted result.Event on every row, but the library
deliberately clears Event on a gap, so text mode printed
"0 MX_EVENT_FAMILY_UNSPECIFIED" and JSON mode an empty object, discarding the
resume cursors. The loop now branches on result.IsReplayGap() and renders the
typed row in both modes, counting it toward -limit like any other row. The JSON
row's cursors are typed by hand rather than marshalled with protojson: the
proto3 JSON mapping renders 64-bit integers as strings ("7") while the Rust and
Python CLIs emit numbers (7), so going through protojson would have made Go the
only canonical CLI with a different value type.

Docs in the same change: docs/Sessions.md documents the empty-ring sentinel
value and that oldest - 1 is the universal resume formula in both the retained
and fully-evicted cases; docs/CrossLanguageSmokeMatrix.md gains a per-CLI
gap-rendering table covering both client findings, and records exactly what is
and is not comparable across CLIs (same keys and numeric cursors for Rust/Go/
Python; quoted cursors for .NET/Java; differing key order, whitespace, and
container), so a matrix runner compares parsed values rather than raw bytes.

Tests, all written red first and each reproducing its defect verbatim:
- SessionEventDistributorTests: RegisterWithReplayReportsNextDeliverableSequence
  WhenRingEmptiedByAge, ...WithRetentionDisabled, and
  ResumeUsingSentinelFormulaAfterEmptyRingGapDeliversLiveEvents.
- GatewayEndToEndReconnectReplayTests.ReconnectAfterFullAgeEvictionResumesWith
  SentinelFormula — fake-worker e2e resume walk on a fake clock; the fixture now
  takes a retention window and a TimeProvider.
- clients/python test_stream_events_renders_replay_gap.
- clients/go TestRunStreamEventsPrintsReplayGap.

GWC-25's ReplayGap.oldest_available_sequence proto-comment amendment is
deliberately deferred to the later codegen wave (see the tracker change log): it
is comment-only but triggers the full five-client regen fan-out.
This commit is contained in:
Joseph Doherty
2026-08-07 05:37:27 -04:00
parent ead921cace
commit 44b8e37900
12 changed files with 596 additions and 28 deletions
@@ -390,10 +390,15 @@ public sealed class SessionEventDistributor : IAsyncDisposable
/// <see cref="TryGetReplayFrom"/> gap semantics.
/// </param>
/// <param name="oldestAvailableSequence">
/// The oldest worker sequence still retained and replayable. <c>0</c> when nothing is
/// retained. Meaningful to the caller only when <paramref name="gap"/> is
/// <see langword="true"/> (it populates the ReplayGap sentinel's
/// <c>oldest_available_sequence</c>).
/// The resume anchor reported to a gapped client: the oldest worker sequence still
/// retained and replayable, or — when age/capacity eviction has emptied the ring
/// entirely — the next sequence that can possibly be delivered (highest observed + 1).
/// Either way the client's documented
/// <c>after_worker_sequence = oldest_available_sequence - 1</c> formula yields a cursor
/// that resumes without dropping live events. <c>0</c> when <paramref name="gap"/> is
/// <see langword="false"/>, where the value is meaningless and never emitted. Meaningful
/// to the caller only when <paramref name="gap"/> is <see langword="true"/> (it populates
/// the ReplayGap sentinel's <c>oldest_available_sequence</c>).
/// </param>
/// <param name="liveResumeSequence">
/// The worker sequence the live channel must resume strictly after: the highest
@@ -463,7 +468,20 @@ public sealed class SessionEventDistributor : IAsyncDisposable
if (_replayCount == 0)
{
gap = _anyEventSeen && afterSequence < _highestSequenceSeen;
oldestAvailableSequence = 0; // meaningful only when gap == true; 0 here since nothing is retained
// GWC-25: nothing is retained, but a gapped client still needs a usable resume
// anchor. The documented client formula is
// after_worker_sequence = oldest_available_sequence - 1, so reporting 0 here made
// an unsigned client compute ulong.MaxValue: the follow-up resume then replayed
// nothing and reported no gap (MaxValue is below no real sequence, in this branch
// and in the retained branch's wrap guard alike), and the caller's live filter
// (sequence > liveResumeSequence) dropped every subsequent event — a silently
// dead stream. Reporting the next sequence that can possibly
// be delivered (highest observed + 1) makes oldest - 1 land exactly on the
// highest observed sequence, so the resume delivers everything newer. Nothing is
// recoverable either way; the sentinel's job is to say "re-snapshot".
// Still 0 when gap == false, where the field is documented as meaningless.
oldestAvailableSequence = gap ? _highestSequenceSeen + 1 : 0;
}
else
{