Merge branch 'fix/gwc-25-replaygap-trio'
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m10s
ci / windows-x86 (push) Successful in 1m30s
ci / portable (push) Successful in 7m39s

# Conflicts:
#	archreview/2026-07-12/remediation/00-tracking.md
#	archreview/2026-07-12/remediation/10-gateway-core.md
This commit is contained in:
Joseph Doherty
2026-08-07 05:49:13 -04:00
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
{