acdream/tests/AcDream.Core.Net.Tests/NetProbeTests.cs
Erik f9c5e47e7f 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>
2026-07-29 17:20:12 +02:00

144 lines
4.7 KiB
C#

using System.Net;
using AcDream.Core.Net.Tests.Transport;
namespace AcDream.Core.Net.Tests;
/// <summary>
/// Campaign N Slice N5 — the <c>[net-tick]</c> loss-observability extension.
/// The line shape is pinned through the extracted formatter (no wall-clock
/// window needed), and one real probe-on session proves the once-per-second
/// emission path carries the new fields end-to-end. The probe-off
/// steady-state cost is covered by the existing zero-alloc send-path test
/// (<c>OutboundReliableTransportTests.SendGameMessage_SteadyState_
/// AllocatesNothingOnceThePoolWarms</c>) — counters increment
/// unconditionally; string work is probe-gated.
/// </summary>
[Collection(NetProcessStaticsCollection.Name)]
public sealed class NetProbeTests
{
[Fact]
public void FormatNetTickLine_CarriesTheN5TransportFields()
{
string line = WorldSession.FormatNetTickLine(
windowSeconds: 2.0,
processed: 10,
queueDepth: 3,
budgetBreaks: 1,
maxGapMs: 17.4,
sends: 8,
acks: 2,
resends: 4,
naksOut: 6,
naksIn: 8,
rejsIn: 2,
dupDrops: 10,
parked: 12,
reclaimed: 2,
cacheDepth: 5,
nakSetDepth: 7,
WorldSession.State.InWorld);
Assert.Equal(
"[net-tick] in/s=5 q=3 budget-breaks=1 maxgap=17ms out/s=4"
+ " acks/s=1 resend/s=2 nak-out/s=3 nak-in/s=4 rej-in/s=1"
+ " dup-drop/s=5 parked/s=6 reclaim/s=1 cache=5 nakset=7"
+ " st=InWorld",
line);
}
[Fact]
public void ProbeOn_EmitsTheExtendedNetTickLine_OncePerSecond()
{
bool savedProbe = NetDiagnostics.ProbeNet;
TextWriter savedOut = Console.Out;
var captured = new LockedStringWriter();
var fake = new FakeAceTransport();
var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
fake);
try
{
NetDiagnostics.ProbeNet = true;
Console.SetOut(captured);
session.Connect(
"testaccount", "testpassword", TimeSpan.FromSeconds(10));
session.EnterWorld(0, TimeSpan.FromSeconds(10));
// The probe window is one REAL second of Tick cadence.
DateTime deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline
&& !captured.Snapshot().Contains(
"[net-tick]", StringComparison.Ordinal))
{
session.Tick();
Thread.Sleep(25);
}
}
finally
{
session.Dispose();
Console.SetOut(savedOut);
NetDiagnostics.ProbeNet = savedProbe;
}
string output = captured.Snapshot();
string tickLine = output
.Split('\n')
.First(l => l.Contains("[net-tick]", StringComparison.Ordinal));
foreach (string field in new[]
{
"resend/s=", "nak-out/s=", "nak-in/s=", "rej-in/s=",
"dup-drop/s=", "parked/s=", "reclaim/s=", "cache=", "nakset=",
})
{
Assert.Contains(field, tickLine, StringComparison.Ordinal);
}
// Dispose also emitted the cumulative [net-final] totals the
// connected loss gate parses.
Assert.Contains("[net-final] resends=", output, StringComparison.Ordinal);
Assert.Contains(" nak-out=", output, StringComparison.Ordinal);
Assert.Contains(" nak-in=", output, StringComparison.Ordinal);
}
/// <summary>
/// Console capture that is safe to snapshot while other threads write:
/// xunit runs test classes in parallel and any of them may hit
/// <c>Console.WriteLine</c> while this test holds the console. A plain
/// <see cref="StringWriter"/> snapshot races its own writers
/// (<c>StringBuilder.ToString</c> mid-append throws).
/// </summary>
private sealed class LockedStringWriter : TextWriter
{
private readonly System.Text.StringBuilder _buffer = new();
private readonly object _gate = new();
public override System.Text.Encoding Encoding =>
System.Text.Encoding.Unicode;
public override void Write(char value)
{
lock (_gate)
{
_buffer.Append(value);
}
}
public override void Write(string? value)
{
lock (_gate)
{
_buffer.Append(value);
}
}
public string Snapshot()
{
lock (_gate)
{
return _buffer.ToString();
}
}
}
}