acdream/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs
Erik daf28bfec5
Some checks failed
CI / linux-portable (push) Successful in 3m21s
CI / windows-gate (push) Failing after 4m57s
CI / release (push) Has been skipped
test: revert Core.Net serialization; widen the virtual-clock harness patience instead
Serializing AcDream.Core.Net.Tests to fix a Linux starvation REGRESSED Windows,
which had been green: Core.Net went from 1000 passed in 7 s (run 154) to
999/1000 in 17 s (run 155), taking down LossSoak_TwoPercentBidirectional_
ZeroMessageLoss_LedgersConverge, a test that had never failed. That trade trans-
ferred the flake between platforms rather than fixing anything, so it is
reverted: no xunit.runner.json, no csproj change.

The actual fragility is narrower than it looked — exactly ONE test uses
real-time waits (PausedSelector_SeededDroppedServerReady_RecoversOnIdleSweep),
and its harness drives a VIRTUAL clock while asserting on 2 s wall-clock
windows. Those windows are patience for background work, not part of the
assertion, and 2 s only ever encoded 'the machine is idle'. They now share a
60 s HarnessPatience constant.

Nothing about what the test verifies changes: recovery must still occur, a
genuine failure to NAK still fails, and a real hang is still bounded. Campaign N
transport code is untouched.

Local: 1000/1000 in 6 s under the gate filter.
2026-08-19 13:59:42 +02:00

293 lines
12 KiB
C#

using System.Buffers.Binary;
using System.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
using AcDream.Core.Net.Transport;
namespace AcDream.Core.Net.Tests.Transport;
/// <summary>
/// Tests of the N0 harness plumbing: the deterministic <see cref="LossyLink"/>
/// fault injector and the <see cref="FakeAceTransport"/> that binds a REAL
/// <see cref="WorldSession"/> to the <see cref="AceSessionModel"/> with no
/// sockets anywhere.
/// </summary>
public sealed class FakeAceTransportTests
{
/// <summary>
/// Wall-clock allowance for background transport work in these
/// virtual-clock harnesses. Generous on purpose: it bounds a hang without
/// encoding an assumption that the machine is idle.
/// </summary>
private static readonly TimeSpan HarnessPatience = TimeSpan.FromSeconds(60);
// ---- LossyLink ----
[Fact]
public void LossyLink_DropNextAndDropAt_DropDeterministically()
{
var link = new LossyLink();
link.DropNext(LinkDirection.ClientToServer);
link.DropAt(LinkDirection.ClientToServer, 2);
Assert.Empty(link.Transmit(LinkDirection.ClientToServer, new byte[] { 1 })); // index 0: DropNext
Assert.Single(link.Transmit(LinkDirection.ClientToServer, new byte[] { 2 })); // index 1
Assert.Empty(link.Transmit(LinkDirection.ClientToServer, new byte[] { 3 })); // index 2: DropAt
Assert.Single(link.Transmit(LinkDirection.ClientToServer, new byte[] { 4 })); // index 3
Assert.Equal(4, link.TransmitCount(LinkDirection.ClientToServer));
Assert.Equal(2, link.DroppedCount(LinkDirection.ClientToServer));
Assert.Equal(2, link.DeliveredCount(LinkDirection.ClientToServer));
// Directions are independent.
Assert.Equal(0, link.TransmitCount(LinkDirection.ServerToClient));
}
[Fact]
public void LossyLink_PredicateDrop_IsPersistent()
{
var link = new LossyLink();
link.Drop(LinkDirection.ServerToClient, (_, datagram) => datagram[0] == 0xAA);
Assert.Empty(link.Transmit(LinkDirection.ServerToClient, new byte[] { 0xAA }));
Assert.Single(link.Transmit(LinkDirection.ServerToClient, new byte[] { 0xBB }));
Assert.Empty(link.Transmit(LinkDirection.ServerToClient, new byte[] { 0xAA }));
Assert.Equal(2, link.DroppedCount(LinkDirection.ServerToClient));
}
[Fact]
public void LossyLink_Reorder_SwapsAdjacentDatagrams()
{
var link = new LossyLink();
link.Reorder(LinkDirection.ClientToServer);
Assert.Empty(link.Transmit(LinkDirection.ClientToServer, new byte[] { 1 })); // held
IReadOnlyList<byte[]> delivered =
link.Transmit(LinkDirection.ClientToServer, new byte[] { 2 });
Assert.Equal(2, delivered.Count);
Assert.Equal(2, delivered[0][0]); // the follower first
Assert.Equal(1, delivered[1][0]); // then the held one
// A held datagram with no follower can be force-released.
link.Reorder(LinkDirection.ClientToServer);
Assert.Empty(link.Transmit(LinkDirection.ClientToServer, new byte[] { 3 }));
IReadOnlyList<byte[]> drained = link.DrainHeld(LinkDirection.ClientToServer);
Assert.Equal(3, Assert.Single(drained)[0]);
}
[Fact]
public void LossyLink_SeededRandomLoss_IsDeterministic()
{
var first = new LossyLink();
var second = new LossyLink();
first.RandomLoss(LinkDirection.ClientToServer, probability: 0.5, seed: 42);
second.RandomLoss(LinkDirection.ClientToServer, probability: 0.5, seed: 42);
for (int i = 0; i < 100; i++)
{
byte[] datagram = { (byte)i };
Assert.Equal(
first.Transmit(LinkDirection.ClientToServer, datagram).Count,
second.Transmit(LinkDirection.ClientToServer, datagram).Count);
}
// At 50% over 100 datagrams both outcomes occur.
Assert.True(first.DroppedCount(LinkDirection.ClientToServer) > 0);
Assert.True(first.DeliveredCount(LinkDirection.ClientToServer) > 0);
}
// ---- FakeAceTransport end-to-end ----
/// <summary>
/// The N0 goal made concrete: a genuine <c>Connect()</c> /
/// <c>EnterWorld()</c> / <c>Tick()</c> / <c>Dispose()</c> lifecycle runs
/// against the ACE-behaviour model with zero sockets — including both
/// ISAAC streams staying aligned end-to-end and retail's graceful-logout
/// order at teardown.
/// </summary>
[Fact]
public void RealWorldSession_HandshakeEnterWorldTickAndGracefulLogout_NoSockets()
{
var transport = new FakeAceTransport();
var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
transport);
try
{
session.Connect("testaccount", "testpassword", TimeSpan.FromSeconds(10));
Assert.Equal(WorldSession.State.InCharacterSelect, session.CurrentState);
Assert.NotNull(session.Characters);
CharacterList.Character character = Assert.Single(session.Characters!.Characters);
Assert.Equal(FakeAceTransport.DefaultCharacterName, character.Name);
Assert.Equal(FakeAceTransport.DefaultAccountName, session.Characters.AccountName);
var messages = new List<string>();
session.ServerMessageReceived += m => messages.Add(m.Message);
session.EnterWorld(0, TimeSpan.FromSeconds(10));
Assert.Equal(WorldSession.State.InWorld, session.CurrentState);
// A world message flows model → link → async receive loop →
// Tick() → typed event.
transport.Model.EnqueueGameMessage(
BuildServerMessage("hello acdream"),
GameMessageGroup.UIQueue);
transport.PumpServer();
DateTime deadline = DateTime.UtcNow.AddSeconds(10);
while (messages.Count == 0 && DateTime.UtcNow < deadline)
{
session.Tick();
Thread.Sleep(5);
}
Assert.Equal("hello acdream", Assert.Single(messages));
// The model saw the genuine ordered client stream, and neither
// direction desynced its ISAAC keystream.
Assert.Equal(
new[]
{
CharacterEnterWorld.EnterWorldRequestOpcode,
CharacterEnterWorld.EnterWorldOpcode,
},
transport.Model.DispatchedMessages.Select(ReadOpcode).ToArray());
Assert.Equal(0, transport.Model.CrcDropCount);
Assert.Equal(0, transport.Model.DuplicateDropCount);
Assert.Equal(256, transport.Model.Crypto.Headroom);
}
finally
{
session.Dispose();
}
// Dispose ran retail's graceful order — 0xF653 request, the model's
// scripted confirmation, then the transport Disconnect that
// terminates the model exactly like ACE's session teardown.
Assert.Equal(WorldSession.State.Disconnected, session.CurrentState);
Assert.True(transport.Model.IsTerminated);
Assert.Equal(
AceTerminationReason.PacketHeaderDisconnect,
transport.Model.TerminationReason);
Assert.Equal(
CharacterLogOff.Opcode,
ReadOpcode(transport.Model.DispatchedMessages[^1]));
Assert.Equal(0, transport.Model.CrcDropCount);
}
[Fact]
public async Task PausedSelector_SeededDroppedServerReady_RecoversOnIdleSweep()
{
var fake = new FakeAceTransport();
// Random(~13) yields 4, 92, 55, 54: at 50%, only the first
// post-arm inbound datagram (ServerReady) is dropped; the follower,
// recovered resend, and graceful-logoff confirmation all land.
var lossy = new LossyTransportDecorator(
fake,
dropPercent: 50,
seed: 13,
NetDropDirection.In);
var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
lossy)
{
TransportClockSource =
(fake.Clock.GetTimestamp, fake.Clock.Frequency),
};
using var enterRequest = new ManualResetEventSlim();
fake.Model.MessageDispatched += body =>
{
if (ReadOpcode(body)
== CharacterEnterWorld.EnterWorldRequestOpcode)
{
enterRequest.Set();
}
};
try
{
session.Connect(
FakeAceTransport.DefaultAccountName,
"testpassword",
TimeSpan.FromSeconds(5));
session.StartCharacterSelectionReceive();
// Graphical selector frames continue before the user enters.
for (int i = 0; i < 3; i++)
{
fake.Clock.Advance(TimeSpan.FromMilliseconds(50));
session.Tick();
}
Task gapDriver = Task.Run(() =>
{
// These two windows are wall-clock patience for a harness whose
// CLOCK is virtual, not part of what the test verifies. Two
// seconds was enough on an idle dev box but not on a loaded CI
// runner, where this test took 37-42 s and failed while passing
// 5/5 in ~350 ms in isolation. Widening the window changes no
// assertion — recovery must still happen, and a genuine failure
// to NAK still fails the test, just later.
Assert.True(enterRequest.Wait(HarnessPatience));
// This later sequenced packet passes the seeded loss gate,
// exposing the missing ServerReady and parking behind it.
fake.EnqueueServerGameMessage(
BuildServerMessage("post-ready follower"),
GameMessageGroup.UIQueue);
Assert.True(SpinWait.SpinUntil(
() => session.Transport?.Inbound.NakCount > 0,
HarnessPatience));
// No datagram follows this virtual-time edge. Recovery now
// requires paused EnterWorld's independent periodic sweep.
fake.Clock.Advance(TimeSpan.FromSeconds(1));
});
session.EnterWorld(0, TimeSpan.FromSeconds(5));
await gapDriver;
Assert.Equal(WorldSession.State.InWorld, session.CurrentState);
Assert.Equal(1, lossy.InboundDropped);
Assert.True(session.Transport!.Stats.NaksSent > 0);
Assert.True(fake.Model.RetransmitsServed > 0);
Assert.Equal(0, session.Transport.Inbound.NakCount);
Assert.Equal(0, session.Transport.Outbound.PendingResendCount);
Assert.Equal(
new[]
{
CharacterEnterWorld.EnterWorldRequestOpcode,
CharacterEnterWorld.EnterWorldOpcode,
},
fake.Model.DispatchedMessages.Select(ReadOpcode).ToArray());
Assert.Equal(256, fake.Model.Crypto.Headroom);
Assert.Equal(0, fake.Model.Crypto.OrphanCount);
Assert.Equal(0, fake.Model.CrcDropCount);
Assert.False(fake.Model.IsTerminated);
}
finally
{
session.Dispose();
}
Assert.Equal(WorldSession.State.Disconnected, session.CurrentState);
Assert.True(fake.Model.IsTerminated);
Assert.Equal(
AceTerminationReason.PacketHeaderDisconnect,
fake.Model.TerminationReason);
Assert.Equal(
CharacterLogOff.Opcode,
ReadOpcode(fake.Model.DispatchedMessages[^1]));
Assert.Equal(256, fake.Model.Crypto.Headroom);
Assert.Equal(0, fake.Model.Crypto.OrphanCount);
}
private static uint ReadOpcode(byte[] messageBody) =>
BinaryPrimitives.ReadUInt32LittleEndian(messageBody);
private static byte[] BuildServerMessage(string text)
{
var writer = new PacketWriter(64);
writer.WriteUInt32(ServerMessage.Opcode); // 0xF7E0
writer.WriteString16L(text);
writer.WriteUInt32(1); // ChatMessageType
return writer.ToArray();
}
}