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:
parent
3899ebe0fd
commit
f9c5e47e7f
10 changed files with 691 additions and 19 deletions
|
|
@ -13,6 +13,7 @@ namespace AcDream.Core.Net.Tests;
|
|||
/// AllocatesNothingOnceThePoolWarms</c>) — counters increment
|
||||
/// unconditionally; string work is probe-gated.
|
||||
/// </summary>
|
||||
[Collection(NetProcessStaticsCollection.Name)]
|
||||
public sealed class NetProbeTests
|
||||
{
|
||||
[Fact]
|
||||
|
|
|
|||
25
tests/AcDream.Core.Net.Tests/NetProcessStaticsCollection.cs
Normal file
25
tests/AcDream.Core.Net.Tests/NetProcessStaticsCollection.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
namespace AcDream.Core.Net.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign N Slice N6 fold-in (N5 review LOW-5): test classes that mutate
|
||||
/// process-global state — the <c>NetDiagnostics</c> typed toggles
|
||||
/// (<c>ProbeNet</c>, <c>NetDropPercent</c>, ...) or <c>Console.SetOut</c> —
|
||||
/// must never run in parallel with any test constructing a
|
||||
/// <c>WorldSession</c>, whose production paths read those statics (the
|
||||
/// public constructor's <c>LossyTransportDecorator.WrapIfConfigured</c>
|
||||
/// seam, the probe-gated <c>[net-tick]</c>/<c>[net-final]</c> console
|
||||
/// emission in <c>Tick</c>/<c>Dispose</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// <c>DisableParallelization = true</c> makes xunit run this collection's
|
||||
/// classes strictly serially, AFTER every parallel collection has finished
|
||||
/// — never alongside anything. Apply
|
||||
/// <c>[Collection(NetProcessStaticsCollection.Name)]</c> to any new test
|
||||
/// class that touches those statics.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name, DisableParallelization = true)]
|
||||
public sealed class NetProcessStaticsCollection
|
||||
{
|
||||
public const string Name = "net process statics";
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,289 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using AcDream.Core.Net.Cryptography;
|
||||
using AcDream.Core.Net.Packets;
|
||||
using AcDream.Core.Net.Transport;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign N Slice N6 — the ConnectResponse handshake retransmit.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail: while a connection sits in <c>cs_ConnectionRequestAcked</c>,
|
||||
/// <c>ClientNet::ProcessConnection @ 0x00545450</c> (case 0 at 0x0054547B)
|
||||
/// re-sends the ConnectResponse every 0.333333333 s — strictly-greater gate
|
||||
/// on <c>lastSentHandshake_</c> (mask-0x41 x87 test at 0x0054548C) —
|
||||
/// through <c>ClientNet::SendConnectAck @ 0x005440F0</c>, which re-stamps
|
||||
/// the clock (0x00544102) and rebuilds the same cookie packet. The resend
|
||||
/// stops when the first successfully processed non-ConnectRequest packet
|
||||
/// promotes the connection to <c>cs_Connected</c>
|
||||
/// (<c>ClientNet::ProcessPacket @ 0x00545100</c>, the state set at
|
||||
/// 0x00545160).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Clocking: the retry cadence rides the TransportClock, so these tests
|
||||
/// drive it on VIRTUAL time (<c>TransportClockSource</c> +
|
||||
/// <c>AutoAdvanceOnBlockingReceive</c>); only the Connect deadline is
|
||||
/// wall-clock. Before N6 the first test hung to that deadline — a lost
|
||||
/// ConnectResponse was an unconditional Connect failure, and the N5 loss
|
||||
/// decorator deliberately arms AFTER the handshake window, so nothing
|
||||
/// covered it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ConnectResponseRetransmitTests
|
||||
{
|
||||
/// <summary>THE N6 conformance test: the first ConnectResponse datagram
|
||||
/// dies on the wire; the 0.333 s retry lands; the session completes.
|
||||
/// Exactly one retry — confirmation (the first decoded server packet)
|
||||
/// stops the cadence.</summary>
|
||||
[Fact]
|
||||
public void Connect_FirstConnectResponseDropped_RetryHealsHandshake()
|
||||
{
|
||||
var fake = new FakeAceTransport
|
||||
{
|
||||
AutoAdvanceOnBlockingReceive = TimeSpan.FromMilliseconds(200),
|
||||
};
|
||||
int connectResponsesSent = 0;
|
||||
fake.Link.Drop(LinkDirection.ClientToServer, (_, datagram) =>
|
||||
{
|
||||
if (!IsConnectResponse(datagram))
|
||||
return false;
|
||||
connectResponsesSent++;
|
||||
return connectResponsesSent == 1; // only the FIRST one dies
|
||||
});
|
||||
int accepted = 0;
|
||||
fake.Model.ConnectResponseAccepted += () => accepted++;
|
||||
|
||||
using var session = new WorldSession(
|
||||
new IPEndPoint(IPAddress.Loopback, 9000),
|
||||
fake);
|
||||
session.TransportClockSource =
|
||||
(fake.Clock.GetTimestamp, fake.Clock.Frequency);
|
||||
|
||||
session.Connect("testaccount", "testpassword", TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.Equal(WorldSession.State.InCharacterSelect, session.CurrentState);
|
||||
Assert.NotNull(session.Characters);
|
||||
// Original + exactly one retry, and none after confirmation (the
|
||||
// CharacterList response follows the accepted retry immediately).
|
||||
Assert.Equal(2, connectResponsesSent);
|
||||
Assert.Equal(1, accepted);
|
||||
// The retry arrived while ACE was still AuthConnectResponse — the
|
||||
// NetworkManager pre-route accepted it; nothing was state-dropped
|
||||
// and no keystream/CRC cost was paid anywhere.
|
||||
Assert.Equal(0, fake.Model.StateDropCount);
|
||||
Assert.Equal(0, fake.Model.CrcDropCount);
|
||||
Assert.Equal(0, fake.Model.DuplicateDropCount);
|
||||
Assert.Equal(256, fake.Model.Crypto.Headroom);
|
||||
}
|
||||
|
||||
/// <summary>A clean handshake sends exactly ONE ConnectResponse: with
|
||||
/// the virtual clock frozen (no auto-advance) the strict 0.333 s gate
|
||||
/// can never open, and confirmation lands on the first pump.</summary>
|
||||
[Fact]
|
||||
public void Connect_CleanHandshake_SendsExactlyOneConnectResponse()
|
||||
{
|
||||
var fake = new FakeAceTransport();
|
||||
int connectResponsesSent = 0;
|
||||
fake.Link.Drop(LinkDirection.ClientToServer, (_, datagram) =>
|
||||
{
|
||||
if (IsConnectResponse(datagram))
|
||||
connectResponsesSent++;
|
||||
return false; // tap only
|
||||
});
|
||||
|
||||
using var session = new WorldSession(
|
||||
new IPEndPoint(IPAddress.Loopback, 9000),
|
||||
fake);
|
||||
session.TransportClockSource =
|
||||
(fake.Clock.GetTimestamp, fake.Clock.Frequency);
|
||||
|
||||
session.Connect("testaccount", "testpassword", TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.NotNull(session.Characters);
|
||||
Assert.Equal(1, connectResponsesSent);
|
||||
Assert.Equal(0, fake.Model.StateDropCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The duplicate-in-flight scenario: ACE accepted the ConnectResponse
|
||||
/// but its S2C responses died, so the unconfirmed client keeps
|
||||
/// retrying. Every duplicate lands harmlessly (ACE's
|
||||
/// <c>Session.CheckState</c> clause 2 drops it pre-CRC at zero
|
||||
/// keystream cost), and the dropped responses heal through the normal
|
||||
/// N2/N4 gap-walk → NAK → cached-retransmit path once ACE's 2 s ack
|
||||
/// reveals the gap.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Connect_ServerResponsesLost_RetriesDropHarmlessly_SessionHeals()
|
||||
{
|
||||
var fake = new FakeAceTransport
|
||||
{
|
||||
AutoAdvanceOnBlockingReceive = TimeSpan.FromMilliseconds(200),
|
||||
};
|
||||
// S2C transmit index 0 is the ConnectRequest; indices 1 and 2 are
|
||||
// the TimeSync + CharacterList responses to the accepted
|
||||
// ConnectResponse. Drop both responses.
|
||||
fake.Link.DropAt(LinkDirection.ServerToClient, 1);
|
||||
fake.Link.DropAt(LinkDirection.ServerToClient, 2);
|
||||
int connectResponsesSent = 0;
|
||||
fake.Link.Drop(LinkDirection.ClientToServer, (_, datagram) =>
|
||||
{
|
||||
if (IsConnectResponse(datagram))
|
||||
connectResponsesSent++;
|
||||
return false; // tap only
|
||||
});
|
||||
int accepted = 0;
|
||||
fake.Model.ConnectResponseAccepted += () => accepted++;
|
||||
|
||||
using var session = new WorldSession(
|
||||
new IPEndPoint(IPAddress.Loopback, 9000),
|
||||
fake);
|
||||
session.TransportClockSource =
|
||||
(fake.Clock.GetTimestamp, fake.Clock.Frequency);
|
||||
|
||||
session.Connect("testaccount", "testpassword", TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.NotNull(session.Characters);
|
||||
// The first ConnectResponse was accepted; at least one retry went
|
||||
// out while the client sat unconfirmed, and every one of them was
|
||||
// state-dropped by the model without side effects.
|
||||
Assert.Equal(1, accepted);
|
||||
Assert.True(connectResponsesSent >= 2,
|
||||
$"expected retries, saw {connectResponsesSent}");
|
||||
Assert.True(fake.Model.StateDropCount >= 1,
|
||||
$"expected CheckState drops, saw {fake.Model.StateDropCount}");
|
||||
Assert.Equal(0, fake.Model.CrcDropCount);
|
||||
Assert.Equal(0, fake.Model.DuplicateDropCount);
|
||||
// The dropped TimeSync/CharacterList healed via the client NAK →
|
||||
// ACE cached-retransmit path, with the parked-key discipline intact.
|
||||
Assert.True(session.Transport!.Stats.NaksSent >= 1);
|
||||
Assert.True(session.Transport.Stats.KeysParked >= 2);
|
||||
Assert.Equal(0, session.Transport.Stats.ChecksumFailures);
|
||||
Assert.Equal(256, fake.Model.Crypto.Headroom);
|
||||
}
|
||||
|
||||
/// <summary>Model-level pin of the ACE-safety claim: a duplicate
|
||||
/// ConnectResponse AFTER acceptance is dropped by CheckState clause 2
|
||||
/// (Session.cs:98-99 / NetworkManager.cs:60-66) before CRC — harmless,
|
||||
/// stateless, zero keystream cost.</summary>
|
||||
[Fact]
|
||||
public void AceModel_DuplicateConnectResponse_AfterAcceptance_DropsViaCheckState()
|
||||
{
|
||||
var clock = new VirtualClock();
|
||||
var model = new AceSessionModel(
|
||||
clock,
|
||||
FakeAceTransport.DefaultClientSeed,
|
||||
FakeAceTransport.DefaultServerSeed,
|
||||
FakeAceTransport.DefaultClientId,
|
||||
FakeAceTransport.DefaultCookie);
|
||||
int accepted = 0;
|
||||
model.ConnectResponseAccepted += () => accepted++;
|
||||
|
||||
byte[] cookieBody = new byte[8];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(
|
||||
cookieBody, FakeAceTransport.DefaultCookie);
|
||||
byte[] connectResponse = PacketCodec.Encode(
|
||||
new PacketHeader
|
||||
{
|
||||
Sequence = 1,
|
||||
Flags = PacketHeaderFlags.ConnectResponse,
|
||||
Id = 0,
|
||||
},
|
||||
cookieBody,
|
||||
null);
|
||||
|
||||
// AuthenticationHandler moves the session to AuthConnectResponse
|
||||
// when the ConnectRequest goes out.
|
||||
model.SendConnectRequest();
|
||||
Assert.Equal(AceSessionState.AuthConnectResponse, model.State);
|
||||
|
||||
model.Receive(connectResponse);
|
||||
Assert.Equal(AceSessionState.AuthConnected, model.State);
|
||||
Assert.Equal(1, accepted);
|
||||
Assert.Equal(0, model.StateDropCount);
|
||||
|
||||
// The duplicate: CheckState clause 2 drops it pre-CRC.
|
||||
model.Receive(connectResponse);
|
||||
Assert.Equal(AceSessionState.AuthConnected, model.State);
|
||||
Assert.Equal(1, accepted);
|
||||
Assert.Equal(1, model.StateDropCount);
|
||||
Assert.Equal(0, model.CrcDropCount);
|
||||
Assert.Equal(0, model.DuplicateDropCount);
|
||||
Assert.Equal(256, model.Crypto.Headroom);
|
||||
}
|
||||
|
||||
private static bool IsConnectResponse(byte[] datagram) =>
|
||||
datagram.Length >= PacketHeader.Size
|
||||
&& (BinaryPrimitives.ReadUInt32LittleEndian(datagram.AsSpan(4))
|
||||
& (uint)PacketHeaderFlags.ConnectResponse) != 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign N Slice N6 — the transport end of the fragment-assembler
|
||||
/// eviction: <see cref="ReliableTransport.Sweep"/> runs
|
||||
/// <see cref="FragmentAssembler.SweepExpired"/> on retail's 5 s flush
|
||||
/// cadence (<c>Indicator::FlushTimedOutEphInfo @ 0x0054A3D0</c>).
|
||||
/// </summary>
|
||||
public sealed class ReliableTransportAssemblerSweepTests
|
||||
{
|
||||
[Fact]
|
||||
public void Sweep_EvictsAgedPartial_KeepsFreshOne()
|
||||
{
|
||||
var clock = new VirtualClock();
|
||||
var assembler = new FragmentAssembler(() => clock.Seconds);
|
||||
var transport = new ReliableTransport(
|
||||
MakeIsaac(0x11AA22BBu),
|
||||
MakeIsaac(0x33CC44DDu),
|
||||
0x1234,
|
||||
1,
|
||||
_ => { },
|
||||
new TransportClock(clock.GetTimestamp, clock.Frequency),
|
||||
assembler: assembler);
|
||||
|
||||
// Park a partial at t=0.
|
||||
IngestPartial(assembler, sequence: 10);
|
||||
Assert.Equal(1, assembler.PartialCount);
|
||||
|
||||
// Well under the TTL: sweeps run (5 s cadence) but evict nothing.
|
||||
clock.Advance(TimeSpan.FromSeconds(30));
|
||||
transport.Sweep();
|
||||
Assert.Equal(1, assembler.PartialCount);
|
||||
|
||||
// Park a second partial at t=58, then cross the first one's TTL.
|
||||
clock.Advance(TimeSpan.FromSeconds(28));
|
||||
IngestPartial(assembler, sequence: 11);
|
||||
clock.Advance(TimeSpan.FromSeconds(3.5)); // t = 61.5
|
||||
transport.Sweep();
|
||||
|
||||
Assert.Equal(1, assembler.PartialCount); // 10 evicted, 11 kept
|
||||
transport.Dispose();
|
||||
}
|
||||
|
||||
private static void IngestPartial(FragmentAssembler assembler, uint sequence)
|
||||
{
|
||||
var header = new MessageFragmentHeader
|
||||
{
|
||||
Sequence = sequence,
|
||||
Id = 0x80000000u,
|
||||
Count = 2,
|
||||
Index = 0,
|
||||
TotalSize = (ushort)(MessageFragmentHeader.Size + 1),
|
||||
Queue = 7,
|
||||
};
|
||||
byte[] payload = { 0x42 };
|
||||
Assert.False(assembler.TryIngest(
|
||||
new BorrowedMessageFragment(header, payload),
|
||||
out _,
|
||||
out _));
|
||||
}
|
||||
|
||||
private static IsaacRandom MakeIsaac(uint seed)
|
||||
{
|
||||
Span<byte> seedBytes = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(seedBytes, seed);
|
||||
return new IsaacRandom(seedBytes);
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ namespace AcDream.Core.Net.Tests.Transport;
|
|||
/// bidirectional loss over the N0 ACE double with zero message loss and the
|
||||
/// 256-key crypto window intact.
|
||||
/// </summary>
|
||||
[Collection(AcDream.Core.Net.Tests.NetProcessStaticsCollection.Name)]
|
||||
public sealed class LossyTransportDecoratorTests
|
||||
{
|
||||
// =====================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue