acdream/tests/AcDream.Core.Net.Tests/Transport/LossyLink.cs
Erik 7e9134b4d1 test(net): N0 - ACE-behaviour double, virtual clock, lossy link
Campaign N slice N0 (docs/plans/2026-07-29-network-transport-campaign.md):
the referee that slices N1-N5 are graded against, test-project only, zero
production changes.

- VirtualClock: Stopwatch-shaped deterministic time source (fixed 100 ns
  ticks) that N1 will inject behind the production TransportClock.
- AceCryptoModel: verbatim port of ACE CryptoSystem Search/ConsumeKey over
  our IsaacRandom - 256-key window, parked-key set, Headroom/OrphanCount
  diagnostics (CryptoSystem.cs:8-49 cited per method).
- AceSessionModel: transport-free ACE NetworkSession over raw datagrams,
  every rule cited to NetworkSession.cs - CRC-before-everything silent
  drop, cleartext-NAK early return (no timeout refresh, :283-308),
  60 s timeout refresh (:329-331), exact-equality ack dedup exemption
  (:342-347), desired+2 NAK trigger with 1 s limit (:351-363), >window
  AbnormalSequenceReceived (:393-397), the :474-476 watermark hole,
  ack-value cache prune (:663-673), fragment gate (:532-543), seq>=2
  caching (:730), Retransmission-flag resends with the ORIGINAL IssacXor
  (:675-686), RejectRetransmit, 2 s cleartext cumulative ack, 20 s
  TimeSync, EchoResponse, 120 s cache prune (:251-262). ACE's raw
  wrap-unsafe comparisons are modeled bug-for-bug, not fixed.
- LossyLink: deterministic drop/reorder/seeded-loss fault injector, pure
  data structure.
- FakeAceTransport: IWorldSessionTransport binding a REAL WorldSession to
  the model through the link, with the handshake scripted (ConnectRequest
  reusing the negotiation fixture layout, CharacterList, ServerReady,
  logoff confirmation) - genuine Connect/EnterWorld/Tick/Dispose with no
  sockets.
- 19 new tests pin the double, including
  CleartextNonAckAdvancesWatermark_TheAceHole (the self-induced wedge
  behind scope rows TS-57/TS-58/AP-125), re-key = permanent orphan,
  unrequested-resend window burn, the 115-id NAK cap boundary, and a
  full no-socket session lifecycle with both ISAAC streams verified
  aligned end-to-end.

Core.Net suite: 678 passed / 0 failed (659 existing + 19 new).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:07:13 +02:00

168 lines
5.8 KiB
C#

namespace AcDream.Core.Net.Tests.Transport;
internal enum LinkDirection
{
ClientToServer,
ServerToClient,
}
/// <summary>
/// Deterministic datagram fault injector between two endpoints. Pure data
/// structure — no sockets, no threads, no wall clock. Callers push each
/// datagram through <see cref="Transmit"/> and deliver whatever comes back,
/// in order.
///
/// <para>
/// Fault evaluation order per datagram (first match wins):
/// scheduled per-index drops (<see cref="DropAt"/>) → one-shot drop budget
/// (<see cref="DropNext"/>) → persistent predicates (<see cref="Drop"/>) →
/// seeded random loss (<see cref="RandomLoss"/>). A surviving datagram is
/// then subject to an armed <see cref="Reorder"/>: the next survivor is held
/// back and delivered immediately AFTER the survivor that follows it
/// (adjacent swap). Datagram bytes are copied on entry, so callers may pass
/// stack-allocated spans.
/// </para>
/// </summary>
internal sealed class LossyLink
{
private sealed class DirectionState
{
public int TransmitIndex;
public int PendingDropCount;
public readonly HashSet<int> DropIndices = new();
public readonly List<Func<int, byte[], bool>> DropPredicates = new();
public Random? LossRandom;
public double LossProbability;
public bool ReorderArmed;
public byte[]? Held;
public int Dropped;
public int Delivered;
}
private readonly DirectionState _clientToServer = new();
private readonly DirectionState _serverToClient = new();
private DirectionState State(LinkDirection direction) =>
direction == LinkDirection.ClientToServer ? _clientToServer : _serverToClient;
/// <summary>Drop the next <paramref name="count"/> datagrams in <paramref name="direction"/>.</summary>
public void DropNext(LinkDirection direction, int count = 1)
{
ArgumentOutOfRangeException.ThrowIfNegative(count);
State(direction).PendingDropCount += count;
}
/// <summary>Drop the datagram with the given per-direction transmit index (0-based).</summary>
public void DropAt(LinkDirection direction, int transmitIndex) =>
State(direction).DropIndices.Add(transmitIndex);
/// <summary>
/// Drop every datagram matching <paramref name="predicate"/> (persistent;
/// receives the per-direction transmit index and the datagram bytes).
/// </summary>
public void Drop(LinkDirection direction, Func<int, byte[], bool> predicate) =>
State(direction).DropPredicates.Add(predicate);
/// <summary>
/// Swap the next two surviving datagrams: the next survivor is held and
/// released right after the survivor that follows it.
/// </summary>
public void Reorder(LinkDirection direction) =>
State(direction).ReorderArmed = true;
/// <summary>
/// Enable seeded random loss: each surviving datagram is dropped with
/// <paramref name="probability"/> using <c>Random(seed)</c> — fully
/// deterministic for a given seed + transmit sequence.
/// </summary>
public void RandomLoss(LinkDirection direction, double probability, int seed)
{
ArgumentOutOfRangeException.ThrowIfNegative(probability);
ArgumentOutOfRangeException.ThrowIfGreaterThan(probability, 1.0);
DirectionState state = State(direction);
state.LossProbability = probability;
state.LossRandom = new Random(seed);
}
public int TransmitCount(LinkDirection direction) => State(direction).TransmitIndex;
public int DroppedCount(LinkDirection direction) => State(direction).Dropped;
public int DeliveredCount(LinkDirection direction) => State(direction).Delivered;
/// <summary>
/// Push one datagram through the link. Returns the datagrams to deliver
/// now, in order (0, 1, or 2 entries — 2 when a held reordered datagram
/// is released).
/// </summary>
public IReadOnlyList<byte[]> Transmit(LinkDirection direction, ReadOnlySpan<byte> datagram)
{
DirectionState state = State(direction);
int index = state.TransmitIndex++;
byte[] copy = datagram.ToArray();
bool drop = state.DropIndices.Remove(index);
if (!drop && state.PendingDropCount > 0)
{
state.PendingDropCount--;
drop = true;
}
if (!drop)
{
foreach (Func<int, byte[], bool> predicate in state.DropPredicates)
{
if (predicate(index, copy))
{
drop = true;
break;
}
}
}
if (!drop
&& state.LossRandom is not null
&& state.LossRandom.NextDouble() < state.LossProbability)
{
drop = true;
}
if (drop)
{
state.Dropped++;
return Array.Empty<byte[]>();
}
if (state.ReorderArmed)
{
state.ReorderArmed = false;
state.Held = copy;
return Array.Empty<byte[]>();
}
if (state.Held is not null)
{
byte[] held = state.Held;
state.Held = null;
state.Delivered += 2;
return new[] { copy, held };
}
state.Delivered++;
return new[] { copy };
}
/// <summary>
/// Force-release a datagram held by <see cref="Reorder"/> that nothing
/// followed (it would otherwise be stuck forever). Returns the held
/// datagram or an empty list.
/// </summary>
public IReadOnlyList<byte[]> DrainHeld(LinkDirection direction)
{
DirectionState state = State(direction);
if (state.Held is null)
return Array.Empty<byte[]>();
byte[] held = state.Held;
state.Held = null;
state.Delivered++;
return new[] { held };
}
}