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>
144 lines
4.7 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|
|
}
|