acdream/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs
Erik db9ad53c1c docs: Campaign LA — pinned launch-contract schema COMMITTED into plan LA1
The LA3 Opus review process note was right: the contract both sides
implement lived only in orchestrator prompts, which is exactly the drift
mode the pin exists to prevent (and it produced the paths-key CRITICAL).
The schema, field rules, probe-mode discriminator, and status vocabulary
are now a binding plan section; amendments change this text first,
implementations second. Ledger: LA3 fix round dispatched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 16:04:32 +02:00

127 lines
4.3 KiB
C#

using System.Net;
using AcDream.App.Rendering;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests.Net;
public sealed class LiveSessionShutdownIntegrationTests
{
[Fact]
public void ReentrantShutdownRetainsDependentsUntilRuntimeDisposeCompletes()
{
var operations = new TestOperations();
var host = new TestHost();
var controller = new LiveSessionController(operations);
controller.Start(
new LiveSessionConnectOptions(
true,
"127.0.0.1",
9000,
"user",
"password"),
host);
bool dependentDisposed = false;
var shutdown = new ResourceShutdownTransaction(
new ResourceShutdownStage("session lifetime",
[
new("live session", () =>
{
controller.Dispose();
if (!controller.IsDisposalComplete)
{
throw new InvalidOperationException(
"live-session disposal is still deferred");
}
}),
]),
new ResourceShutdownStage("session dependents",
[
new("streamer", () => dependentDisposed = true),
]));
operations.OnTick = () =>
{
Assert.Throws<AggregateException>(shutdown.CompleteOrThrow);
Assert.Equal(0, shutdown.CurrentStage);
Assert.False(dependentDisposed);
};
controller.Tick();
Assert.True(controller.IsDisposalComplete);
Assert.False(dependentDisposed);
shutdown.CompleteOrThrow();
Assert.True(shutdown.IsComplete);
Assert.True(dependentDisposed);
}
private sealed class TestOperations : ILiveSessionOperations
{
public Action? OnTick { get; set; }
public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port);
public WorldSession CreateSession(IPEndPoint endpoint) =>
new(endpoint, new TestTransport());
public void Connect(WorldSession session, string user, string password) { }
public CharacterList.Parsed GetCharacters(WorldSession session) =>
new(
0u,
[new CharacterList.Character(0x50000001u, "Ready", 0u)],
[],
11,
"Runtime",
true,
true);
public void EnterWorld(WorldSession session, int activeCharacterIndex) { }
public void Tick(WorldSession session) => OnTick?.Invoke();
public void DisposeSession(WorldSession session) => session.Dispose();
}
private sealed class TestHost : ILiveSessionLifecycleHost
{
public LiveSessionBinding BindSession(WorldSession session) =>
new(
session,
activateCommands: static () => { },
deactivateCommands: static () => { },
detachEvents: static () => { });
public void ResetSessionState(
RuntimeGenerationToken retiringGeneration) { }
public void ReportConnecting(string host, int port, string user) { }
public void ReportConnected() { }
public void ReportRoster(LiveSessionRosterReport roster) { }
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { }
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) { }
public void DetachSession(WorldSession session) { }
}
private sealed class TestTransport : IWorldSessionTransport
{
public void Send(ReadOnlySpan<byte> datagram) { }
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
public int Receive(
Span<byte> destination,
TimeSpan timeout,
out IPEndPoint? from)
{
from = null;
return -1;
}
public ValueTask<NetReceiveResult> ReceiveAsync(
Memory<byte> destination,
CancellationToken cancellationToken) =>
throw new OperationCanceledException(cancellationToken);
public void Dispose() { }
}
}