feat(net): N4 - client NAK emission + RejectRetransmit reclaim
Campaign N slice N4 completes the AckNakScheduler NAK branch and closes the ACE cleartext-reject keystream hazard - the slice that makes S2C loss actually RECOVER. NAK emission (SharedNet::EnqueueNaks @ 0x00543BD0): - One cleartext exact-flags RequestRetransmit per sweep behind the STRICT 0.6 s gate on the ONE shared timestamp (the x87 0x41-mask test at 0x00543C03 proceeds only on strictly-greater; the ack's gate stays >=). Never an ack in a NAK sweep; a NAK delays the next ack by 2.0 s and vice versa (landmine #7). - Body = u32 count + ids ascending, capped at 114 (ReceiverData::GetNaks @ 0x005490C0, cap 0x72; the m_cbData = 4*count+4 store at 0x00543C3E); header Sequence borrowed from highestIDSent_ without incrementing; cleartext or ACE ignores it (landmine #6, NetworkSession.cs:283-284) - and a NAK never refreshes ACE's 60 s timeout. - Control-header rule decided once for BOTH ack and NAK: Time = the interval id, Iteration = the session iteration, matching retail's shared header build (FlowQueue::TransmitNewPackets @ 0x00547A60, the stack build at 0x00547A84). ACE reads neither field inbound. - Gate ticks now round instead of truncate: 0.6 has no exact double form, and truncation opened the strict gate exactly AT the boundary. RejectRetransmit reclaim (divergence register AD-51, ACE adaptation): - ACE's RejectRetransmit consumes a FRESH sequence, cleartext, with NO keystream word, and is cached (ACE NetworkSession.cs:299-304, :722-725, :743-748) - the one place ACE breaks retail's gap-walk invariant that every missing id was word-bearing (retail cleartext always borrows live sequences). Unhandled, the gap walk parks a word for the reject's id and the inbound stream runs permanently one word ahead - the N2 desync class reintroduced through the reject path. - Fix: on a VALIDATED cleartext reject, InboundSequenceTracker removes the mis-park, shifts every later-drawn parked word down one position (per-word draw ordinals; ascending wrap-safe id <=> ascending draw order), and pools the excess word, consumed lowest-draw-order-first ahead of fresh ISAAC draws. Exact for any number of interleaved rejects in ANY arrival order - a plain reclaim FIFO is not: a reject arriving after a higher encrypted arrival crosses the parked chain, and two out-of-order rejects pool their excess words out of draw order (both orderings pinned by tests). - Reject BODY ids keep N2's discard: word-bearing server-side, consumed-in-place. The pool is provably empty against retail servers. N3 advisories folded (all five): honest transitional-state wording (the empty N3 NAK branch could silently disconnect a loopback session at ACE's 60 s timeout, witness [net-tick] acks/s=0), the ReceiverData::SharedInit @ 0x00548EF0 (from Init @ 0x00548FA0) citation, the FlowQueue::Empty pump-order wording (TransmitNaks -> TransmitAcks -> TransmitNewPackets with the interval increment LAST @ 0x00548A9D; our clock-first Sweep is cosmetic vs ACE), the Time/Iteration rule above, and the stale WorldSession budget-break comment rewritten to the sweep reality. Tests: 737 Core.Net green (14 new in NakEmissionTests + updated N3 pins): strict-gate boundary, shared timestamp both directions, NAK-xor-ack exclusivity, full wire-shape + 114-cap pins, model-served retransmission round trip, five tracker reclaim proofs, the 130 s virtual prune -> fresh-sequence reject system test (victim abandoned, later traffic decodes, pool drains to zero), 10 s long-loss survival (NAKs on the gate cadence, zero acks, heal inside the window), and the capstone soak: 2% seeded bidirectional loss x 10,000 messages -> zero message loss both ways, ACE crypto headroom 256 at convergence, every ledger drained (cache at the single watermark entry - retail's Flush prunes STRICTLY below the ack). Full solution Release: 9,758 passed / 5 skipped. Connected world-lifecycle gate PASS (logs/connected-world-gate-20260729-150238); canonical nine-stop soak PASS (logs/connected-r6-soak-20260729-150856). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
e9686401bc
commit
852a59e388
10 changed files with 1467 additions and 109 deletions
|
|
@ -20,6 +20,7 @@ public sealed class AckNakSchedulerTests
|
|||
private const uint ClientSeed = 0x11AA22BBu;
|
||||
private const uint ServerSeed = 0x33CC44DDu;
|
||||
private const uint ClientId = 0x1234u;
|
||||
private const ushort SessionIteration = 0x0007;
|
||||
private const ulong Cookie = 0xFEEDFACECAFEBABEUL;
|
||||
|
||||
// =====================================================================
|
||||
|
|
@ -35,17 +36,25 @@ public sealed class AckNakSchedulerTests
|
|||
Admit(transport, 3u);
|
||||
|
||||
// No ack before 2.0 s — the gate armed at transport construction
|
||||
// (ReceiverData::Init @ 0x00548EF0 stamps timeStamp_ = cur_time).
|
||||
// (ReceiverData::SharedInit @ 0x00548EF0, reached from
|
||||
// ReceiverData::Init @ 0x00548FA0, stamps timeStamp_ = cur_time).
|
||||
transport.Sweep();
|
||||
clock.Advance(TimeSpan.FromSeconds(1.99));
|
||||
transport.Sweep();
|
||||
Assert.Empty(sent);
|
||||
|
||||
// Exactly one at the boundary (the retail compare is >=, the x87
|
||||
// `& 1` status test at 0x00543B3D).
|
||||
// `& 1` status test at 0x00543B3D). Header.Time carries the
|
||||
// interval id at emission (N4 control-header rule): 2.0 s of
|
||||
// 0.5 s intervals on top of the initial id 1 → 5.
|
||||
clock.Advance(TimeSpan.FromSeconds(0.01));
|
||||
transport.Sweep();
|
||||
AssertAckShape(Assert.Single(sent), expectedSequence: 1u, expectedValue: 3u);
|
||||
Assert.Equal((ushort)5, transport.Clock.IntervalId);
|
||||
AssertAckShape(
|
||||
Assert.Single(sent),
|
||||
expectedSequence: 1u,
|
||||
expectedValue: 3u,
|
||||
expectedTime: transport.Clock.IntervalId);
|
||||
Assert.Equal(1, transport.Stats.AcksSent);
|
||||
|
||||
// The gate reset: silent until the next 2.0 s elapses.
|
||||
|
|
@ -61,7 +70,11 @@ public sealed class AckNakSchedulerTests
|
|||
clock.Advance(TimeSpan.FromSeconds(0.01));
|
||||
transport.Sweep();
|
||||
Assert.Equal(2, sent.Count);
|
||||
AssertAckShape(sent[1], expectedSequence: 1u, expectedValue: 5u);
|
||||
AssertAckShape(
|
||||
sent[1],
|
||||
expectedSequence: 1u,
|
||||
expectedValue: 5u,
|
||||
expectedTime: transport.Clock.IntervalId);
|
||||
Assert.Equal(2, transport.Stats.AcksSent);
|
||||
}
|
||||
|
||||
|
|
@ -126,23 +139,37 @@ public sealed class AckNakSchedulerTests
|
|||
Admit(transport, 4u); // gap walk parks id 3
|
||||
Assert.Equal(1, transport.Inbound.NakCount);
|
||||
|
||||
// 2.0 s elapses with the NAK set non-empty: the NAK branch owns the
|
||||
// sweep and (until N4 emits RequestRetransmit there) NOTHING goes
|
||||
// out — never an ack while ids are parked (§2.3's mutual
|
||||
// exclusivity; the N2 ledger row shows why acking here would let
|
||||
// ACE prune the lost id from its S2C cache before the NAK).
|
||||
// 2.5 s elapses with the NAK set non-empty: the NAK branch owns the
|
||||
// sweep — ONE RequestRetransmit goes out and never an ack while ids
|
||||
// are parked (§2.3's mutual exclusivity; the N2 ledger row shows why
|
||||
// acking here would let ACE prune the lost id from its S2C cache
|
||||
// before the NAK). The immediate second sweep is silenced by the
|
||||
// freshly stamped shared timestamp.
|
||||
clock.Advance(TimeSpan.FromSeconds(2.5));
|
||||
transport.Sweep();
|
||||
transport.Sweep();
|
||||
Assert.Empty(sent);
|
||||
byte[] nak = Assert.Single(sent);
|
||||
Assert.Equal(
|
||||
(uint)PacketHeaderFlags.RequestRetransmit,
|
||||
(uint)PacketHeader.Unpack(nak).Flags);
|
||||
Assert.Equal(0, transport.Stats.AcksSent);
|
||||
Assert.Equal(1, transport.Stats.NaksSent);
|
||||
sent.Clear();
|
||||
|
||||
// The missing packet arrives (late delivery), clearing the set —
|
||||
// the ack resumes at the next gate, which is long since due.
|
||||
// the ack resumes once 2.0 s elapse past the NAK's stamp of the
|
||||
// SHARED timestamp (landmine #7: a NAK delays the next ack).
|
||||
Admit(transport, 3u);
|
||||
Assert.Equal(0, transport.Inbound.NakCount);
|
||||
transport.Sweep();
|
||||
AssertAckShape(Assert.Single(sent), expectedSequence: 1u, expectedValue: 4u);
|
||||
Assert.Empty(sent);
|
||||
clock.Advance(TimeSpan.FromSeconds(2.0));
|
||||
transport.Sweep();
|
||||
AssertAckShape(
|
||||
Assert.Single(sent),
|
||||
expectedSequence: 1u,
|
||||
expectedValue: 4u,
|
||||
expectedTime: transport.Clock.IntervalId);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
|
|
@ -175,7 +202,8 @@ public sealed class AckNakSchedulerTests
|
|||
AssertAckShape(
|
||||
Assert.Single(sent),
|
||||
expectedSequence: 1u,
|
||||
expectedValue: 51u);
|
||||
expectedValue: 51u,
|
||||
expectedTime: transport.Clock.IntervalId);
|
||||
Assert.Equal(1, transport.Stats.AcksSent);
|
||||
}
|
||||
|
||||
|
|
@ -343,6 +371,7 @@ public sealed class AckNakSchedulerTests
|
|||
MakeIsaac(ClientSeed),
|
||||
MakeIsaac(ServerSeed),
|
||||
(ushort)ClientId,
|
||||
SessionIteration,
|
||||
datagram => sent.Add(datagram.ToArray()),
|
||||
new TransportClock(
|
||||
virtualClock.GetTimestamp,
|
||||
|
|
@ -364,12 +393,15 @@ public sealed class AckNakSchedulerTests
|
|||
/// uint compare fails if ANY extra bit is ORed in (landmine #5) —
|
||||
/// cleartext (decodes with a null keystream), 4-byte little-endian
|
||||
/// body carrying the watermark, borrowed header sequence, session
|
||||
/// client id, <c>Time</c>/<c>Iteration</c> zero.
|
||||
/// client id, and the N4 control-header rule: <c>Time</c> = the
|
||||
/// interval id at emission and <c>Iteration</c> = the session
|
||||
/// iteration (retail's shared header build at 0x00547A84).
|
||||
/// </summary>
|
||||
private static void AssertAckShape(
|
||||
byte[] datagram,
|
||||
uint expectedSequence,
|
||||
uint expectedValue)
|
||||
uint expectedValue,
|
||||
ushort expectedTime)
|
||||
{
|
||||
Assert.Equal(PacketHeader.Size + sizeof(uint), datagram.Length);
|
||||
PacketHeader header = PacketHeader.Unpack(datagram);
|
||||
|
|
@ -378,8 +410,8 @@ public sealed class AckNakSchedulerTests
|
|||
(uint)header.Flags);
|
||||
Assert.Equal(expectedSequence, header.Sequence);
|
||||
Assert.Equal((ushort)ClientId, header.Id);
|
||||
Assert.Equal((ushort)0, header.Time);
|
||||
Assert.Equal((ushort)0, header.Iteration);
|
||||
Assert.Equal(expectedTime, header.Time);
|
||||
Assert.Equal(SessionIteration, header.Iteration);
|
||||
Assert.Equal((ushort)sizeof(uint), header.DataSize);
|
||||
Assert.Equal(
|
||||
expectedValue,
|
||||
|
|
|
|||
|
|
@ -101,9 +101,14 @@ public sealed class InboundSequenceTrackerTests
|
|||
uint w11 = shadow.Next();
|
||||
|
||||
// 10 arrives corrupt: admission consumed w10, verification failed,
|
||||
// the session re-parks the consumed key (step 5) and drops.
|
||||
Assert.Equal(w10, Admitted(tracker, 10));
|
||||
tracker.ReparkKey(10, w10);
|
||||
// the session re-parks the consumed key (step 5) — carrying the
|
||||
// admission's draw order, so the AD-51 reclaim can still place the
|
||||
// word in the stream — and drops.
|
||||
InboundSequenceTracker.Admission corrupt =
|
||||
tracker.Admit(10, encrypted: true);
|
||||
Assert.False(corrupt.Drop);
|
||||
Assert.Equal(w10, corrupt.VerifyKey);
|
||||
tracker.ReparkKey(10, w10, corrupt.VerifyKeyDrawOrder);
|
||||
Assert.Equal(1, tracker.NakCount);
|
||||
Assert.Equal(1, stats.KeysParked);
|
||||
|
||||
|
|
|
|||
1017
tests/AcDream.Core.Net.Tests/Transport/NakEmissionTests.cs
Normal file
1017
tests/AcDream.Core.Net.Tests/Transport/NakEmissionTests.cs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue