feat(net): N6 - ConnectResponse retransmit + fragment assembler eviction

Campaign N Slice N6, the final implementation slice.

ConnectResponse handshake retransmit:
- While the connection is unconfirmed, the Connect character-list pump
  resends the IDENTICAL cleartext ConnectResponse (same sequence 1, same
  cookie, the one encoded datagram - no new outbound state) on retail's
  strict 0.333333333 s gate. Retail: ClientNet::ProcessConnection
  @ 0x00545450, case cs_ConnectionRequestAcked @ 0x0054547B (the constant
  load at 0x00545481; the mask-0x41 strictly-greater x87 test at
  0x0054548C); ClientNet::SendConnectAck @ 0x005440F0 re-stamps
  lastSentHandshake_ (0x00544102) and rebuilds the same cookie packet.
- Confirmation = the first checksum-valid post-negotiation packet whose
  header lacks the ConnectRequest flag: retail's cs_ConnectionRequestAcked
  -> cs_Connected edge (ClientNet::ProcessPacket @ 0x00545100, the 0x40000
  exclusion at 0x0054514E, SetConnectionState(..., 5) at 0x00545160).
- The cadence rides the TransportClock (virtual-clock testable through
  TransportClockSource); the Connect deadline stays wall-clock.
- ACE safety pinned against the N0 model: a duplicate while still
  AuthConnectResponse re-routes idempotently through NetworkManager's
  pre-route; after acceptance CheckState clause 2 drops it pre-CRC at
  zero keystream cost.
- Pre-N6, one lost ConnectResponse was a hang to the Connect deadline;
  the N5 decorator deliberately arms after this window, so nothing
  covered it.

FragmentAssembler eviction (divergence register row AD-52):
- Partials evict 60 s after their last ACCEPTED fragment; the stamp
  refreshes on every new fragment (retail's re-stamp rule,
  ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00), so a merely-slow partial
  can never age out - 60 s is a floor, not a tunable. Swept from
  ReliableTransport.Sweep on retail's 5 s flush cadence
  (Indicator::FlushTimedOutEphInfo @ 0x0054A3D0, the gate at 0x0054A3DC;
  per-entry ArrivedEphInfo::fTimedOut @ 0x0054AE30). N4's RejectRetransmit
  abandonment made an unrecoverable partial a REACHABLE permanent state;
  the TTL reclaims it.
- A 64-entry completed-sequence ring drops late duplicate fragments of
  already-completed messages instead of allocating a fresh partial that
  can never complete (the completed-then-duplicate leak).

Fold-ins:
- N5 review LOW-5: NetProbeTests + LossyTransportDecoratorTests (the
  static NetDiagnostics / Console.SetOut mutators) share one
  DisableParallelization xunit collection so they never run alongside
  classes constructing WorldSession.
- Campaign section 9: N6 ledger row recorded; N5 row verified carrying
  4e290f00.

Gates: 757 Core.Net Release tests green (10 new); full solution Release
green (0 failures / 5 skips); connected lifecycle gate PASS; the
N5-strengthened connected loss gate PASS on its first live run (2%/seed 1:
dropped out=3 in=10, resends=1 nak-in=1 nak-out=5, cksum-fail=0
sanity-drop=0 uncached-nak=0).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-29 17:20:12 +02:00
parent 3899ebe0fd
commit f9c5e47e7f
10 changed files with 691 additions and 19 deletions

View file

@ -201,4 +201,126 @@ public class FragmentAssemblerTests
Assert.Equal(new byte[] { 1, 2 }, message.ToArray());
Assert.Equal(0, assembler.PartialCount);
}
// =====================================================================
// Campaign N Slice N6 — age-based eviction + the completed ring.
// Retail shape: Indicator::FlushTimedOutEphInfo @ 0x0054A3D0 (5 s flush
// gate) over entries re-stamped on every update (ArrivedEphInfo::
// UpdateNetBlobID @ 0x0054AE00); the 60 s TTL and the 64-entry
// completed-sequence ring are the AD-52 adaptations.
// =====================================================================
private static BorrowedMessageFragment MakeBorrowed(
uint sequence, ushort count, ushort index, byte[] payload, ushort queue = 7)
=> new(MakeFrag(sequence, count, index, payload, queue).Header, payload);
[Fact]
public void SweepExpired_EvictsAgedPartial_KeepsFresh()
{
double now = 0;
var assembler = new FragmentAssembler(() => now);
Assert.False(assembler.TryIngest(MakeBorrowed(1, 2, 0, [0xA1]), out _, out _));
now = 30;
Assert.False(assembler.TryIngest(MakeBorrowed(2, 3, 0, [0xB1]), out _, out _));
Assert.Equal(2, assembler.PartialCount);
// At 60.0 exactly the first partial is AT the floor, not past it —
// an eviction floor, never an eager cutoff.
now = 60;
Assert.Equal(0, assembler.SweepExpired());
Assert.Equal(2, assembler.PartialCount);
// Past the floor: the aged partial goes, the fresh one stays.
now = 61;
Assert.Equal(1, assembler.SweepExpired());
Assert.Equal(1, assembler.PartialCount);
// The surviving partial still completes normally.
Assert.False(assembler.TryIngest(MakeBorrowed(2, 3, 1, [0xB2]), out _, out _));
Assert.True(assembler.TryIngest(
MakeBorrowed(2, 3, 2, [0xB3]),
out ReadOnlyMemory<byte> message,
out _));
Assert.Equal(new byte[] { 0xB1, 0xB2, 0xB3 }, message.ToArray());
Assert.Equal(0, assembler.PartialCount);
}
[Fact]
public void SweepExpired_SlowButAlivePartial_RefreshesOnEachNewFragment()
{
double now = 0;
var assembler = new FragmentAssembler(() => now);
Assert.False(assembler.TryIngest(MakeBorrowed(5, 3, 0, [1]), out _, out _));
now = 50;
Assert.False(assembler.TryIngest(MakeBorrowed(5, 3, 1, [2]), out _, out _));
// 61 s after creation but only 11 s after the last ACCEPTED
// fragment: the re-stamp rule (retail ArrivedEphInfo::
// UpdateNetBlobID @ 0x0054AE00) keeps a slow-but-alive partial.
now = 61;
Assert.Equal(0, assembler.SweepExpired());
Assert.Equal(1, assembler.PartialCount);
// A DUPLICATE of an already-held index adds nothing and must not
// refresh the stamp: 61 s after the last new fragment, it goes.
now = 100;
Assert.False(assembler.TryIngest(MakeBorrowed(5, 3, 1, [2]), out _, out _));
now = 111.5;
Assert.Equal(1, assembler.SweepExpired());
Assert.Equal(0, assembler.PartialCount);
}
[Fact]
public void TryIngest_LateDuplicateOfCompletedMessage_DropsWithoutRepartialing()
{
var assembler = new FragmentAssembler();
Assert.False(assembler.TryIngest(MakeBorrowed(9, 2, 0, [1]), out _, out _));
Assert.True(assembler.TryIngest(MakeBorrowed(9, 2, 1, [2]), out _, out _));
Assert.Equal(0, assembler.PartialCount);
// The pre-N6 leak: this late duplicate allocated a fresh partial
// that could never complete. Now it drops via the completed ring.
Assert.False(assembler.TryIngest(MakeBorrowed(9, 2, 0, [1]), out _, out _));
Assert.Equal(0, assembler.PartialCount);
}
[Fact]
public void Ingest_LateDuplicateOfCompletedMessage_DropsWithoutRepartialing()
{
var assembler = new FragmentAssembler();
Assert.Null(assembler.Ingest(MakeFrag(9, 2, 0, [1]), out _));
Assert.NotNull(assembler.Ingest(MakeFrag(9, 2, 1, [2]), out _));
Assert.Equal(0, assembler.PartialCount);
Assert.Null(assembler.Ingest(MakeFrag(9, 2, 0, [1]), out _));
Assert.Equal(0, assembler.PartialCount);
}
[Fact]
public void CompletedRing_IsBounded_OldestSequenceIsForgotten()
{
var assembler = new FragmentAssembler();
// Complete ring-size + 1 multi-fragment messages; sequence 0 is a
// legitimate value (ACE's fragment sequences start at 0).
for (uint seq = 0; seq <= 64; seq++)
{
Assert.False(assembler.TryIngest(MakeBorrowed(seq, 2, 0, [1]), out _, out _));
Assert.True(assembler.TryIngest(MakeBorrowed(seq, 2, 1, [2]), out _, out _));
}
// Sequence 0 was pushed out of the 64-entry ring: its late
// duplicate re-partials (the documented bound — memory stays
// bounded and the TTL sweep reclaims the stragglers).
Assert.False(assembler.TryIngest(MakeBorrowed(0, 2, 0, [1]), out _, out _));
Assert.Equal(1, assembler.PartialCount);
// The newest completion is still remembered.
Assert.False(assembler.TryIngest(MakeBorrowed(64, 2, 0, [1]), out _, out _));
Assert.Equal(1, assembler.PartialCount);
}
}