feat(net): N5 - loss observability, lossy decorator, the connected loss gate
Campaign N Slice N5 (docs/plans/2026-07-29-network-transport-campaign.md section 8 rung 3): the permanent removal of the loopback blindness that let #260 ship. Local ACE never drops a datagram, so every historical connected gate was structurally incapable of exercising the N1-N4 recovery machinery; from this slice on, tools/run-connected-loss-gate.ps1 runs the standard lifecycle route through deterministic seeded loss and passes only on proven non-zero recovery. Observability: - [net-tick] gains resend/s nak-out/s nak-in/s rej-in/s dup-drop/s parked/s reclaim/s cache= nakset= - TransportStats window deltas mirroring the acks/s cumulative-delta pattern, plus the two instantaneous depths (the unbounded-like-retail sent-packet cache watchdog and the inbound NAK set). TransportStats gains RejectsReceived (inbound RejectRetransmit packets). Counters increment unconditionally; every string is behind NetDiagnostics.ProbeNet (Code Structure Rule 5). - WorldSession.Dispose emits one cumulative [net-final] totals line so the loss gate asserts exact counters instead of reconstructing them from rounded per-second rates. - LinkStatusSnapshot.PacketLossPercentage is deliberately NOT wired: filed #261 - retail's CLinkStatusAverages formula (LinkStatusHolder::GetPacketLossPercentage @ 0x00411370) must be located first; inventing a ratio is forbidden. N4-review F3 fold-in: - Fresh reliable sends stamp Header.Iteration = the session iteration through the same shared retail header build already cited for Time (N3) and the N4 control packets: FlowQueue::TransmitNewPackets @ 0x00547A60, the stack build at 0x00547A84/0x00547AA8. The control-header rule now holds across all three send shapes (fresh reliable, ack, NAK). ACE reads neither Time nor Iteration inbound (campaign section 3) - wire-safe, and resends keep the stamp verbatim per the N1 rebuild rule. Loss injection (Transport/LossyTransportDecorator): - IWorldSessionTransport wrapper with deterministic seeded per-direction loss. Config via NetDiagnostics typed env properties read once: ACDREAM_NET_DROP_PCT (0 = off = default), ACDREAM_NET_DROP_SEED (default 1), ACDREAM_NET_DROP_DIR (out|in|both, default both). - Arming gate: NOTHING drops in either direction until the decorator has FORWARDED the first ENCRYPTED outbound datagram - parse-free check on length > 20 with EncryptedChecksum set in the LE flags word at bytes 4..8. The cleartext handshake always survives and the arming datagram is never a casualty; handshake-loss testing belongs to N6's ConnectResponse 0.333 s retransmit. - Structurally absent at 0%: WrapIfConfigured returns the raw transport - WorldSession's default factory is the only production seam and a normal run never constructs the decorator. Root-cause fix the gate immediately exposed: - The logoff-confirmation wait in Dispose processed inbound datagrams but never pumped the transport, so a lost S2C logoff confirmation was gap-detected but its healing NAK never went out. Retail's pump (Client::UseTime @ 0x00411C40 -> PacketController::UseTime @ 0x005410D0) runs until LogOffServer; the wait now sweeps per processed datagram, making the logoff wait the third covered blocking pump (after Tick and the handshake loops). A lost C2S logoff REQUEST remains unrecoverable by ACE design (arrival-driven NAK; a quiet client is never NAKed - campaign section 3 row 1), recorded in the gate header. Gates: - tools/run-connected-loss-gate.ps1 (-DropPct 2 -Seed 1): PASS vs local ACE - the first automated observation of packet loss in project history. Decorator ledger: dropped out=3 in=10 of forwarded out=183 in=496. [net-final] resends=2 nak-in=2 nak-out=6 rej-in=0 acks-out=114 acks-in=119 dup-drop=0 sanity-drop=0 cksum-fail=0 parked=9 reclaimed=0 uncached-nak=0 cache=1 nakset=0. Every injected loss healed: both ACE-driven C2S resend recovery (nak-in=2 -> resends=2) and client-driven S2C NAK recovery (parked=9 -> nak-out=6) fired on a real connected route, all six checkpoints validated, graceful logout confirmed, ACE recorded the transport Disconnect. - tools/run-connected-world-lifecycle-gate.ps1 (decorator absent): PASS - zero behavior change on the no-loss baseline; the gate now defensively clears the drop env vars. - Core.Net Release: 747/747 (737 + 10 N5: decorator determinism/direction/ arming/structural-absence/env parsing, the 5% seeded WorldSession lossy lifecycle with zero message loss both ways + ACE Headroom 256, the [net-tick] field pins, the Iteration stamps). - Full solution Release: 9,763 passed / 5 skipped / 0 failed. Test-fixture note: FakeAceTransport gains AutoAdvanceOnBlockingReceive so virtual time can move during the blocking Connect()/EnterWorld() pumps - with the clock frozen there, a dropped handshake-window datagram could never be NAK-healed (a fixture artifact, not a transport property). Campaign section 9 ledger row added (SHA recorded at N6 kickoff). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
396838bb40
commit
4e290f00d8
14 changed files with 1629 additions and 27 deletions
143
tests/AcDream.Core.Net.Tests/NetProbeTests.cs
Normal file
143
tests/AcDream.Core.Net.Tests/NetProbeTests.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
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>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue