merge: Campaign LA LA2 - probe and idle review-closed
# Conflicts: # docs/plans/2026-08-14-launcher-campaign.md # src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
This commit is contained in:
commit
e01b2cd12f
16 changed files with 1361 additions and 69 deletions
|
|
@ -1,5 +1,6 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
|
|
@ -218,17 +219,217 @@ public sealed class HeadlessSessionHostTests
|
|||
// writer is a permanent no-op with no configured path.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: a probe-mode session's status stream reports
|
||||
/// started/connected/characterList and then converges straight to
|
||||
/// exited(reason:"probe", code:0) — never enteredWorld — and the
|
||||
/// underlying operations fake proves EnterWorld was literally never
|
||||
/// called (not merely that no wire message happened to arrive).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode()
|
||||
public void ProbeSessionEmitsRosterThenExitsSuccessfullyWithoutEnteringWorld()
|
||||
{
|
||||
string statusPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-headless-probe-status-{Guid.NewGuid():N}.jsonl");
|
||||
try
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var diagnosticsOutput = new StringWriter();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
ProbeDescriptor(statusFile: statusPath),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(diagnosticsOutput),
|
||||
operations);
|
||||
|
||||
RuntimeSessionStartResult started = host.Start();
|
||||
Assert.Equal(RuntimeSessionStartStatus.ProbeComplete, started.Status);
|
||||
Assert.Equal(0, operations.EnterWorldCallCount);
|
||||
Assert.False(host.Runtime.Session.IsInWorld);
|
||||
|
||||
host.Dispose();
|
||||
|
||||
Assert.Equal(0, operations.EnterWorldCallCount);
|
||||
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
|
||||
|
||||
string[] lines = File.ReadAllLines(statusPath);
|
||||
string[] eventNames = lines
|
||||
.Select(line => JsonDocument.Parse(line)
|
||||
.RootElement.GetProperty("e").GetString()!)
|
||||
.ToArray();
|
||||
Assert.DoesNotContain("enteredWorld", eventNames);
|
||||
Assert.Contains("characterList", eventNames);
|
||||
Assert.Contains("exited", eventNames);
|
||||
Assert.True(
|
||||
Array.IndexOf(eventNames, "characterList")
|
||||
< Array.IndexOf(eventNames, "exited"),
|
||||
"characterList must land before the terminal exited event.");
|
||||
|
||||
using JsonDocument exitedDoc = JsonDocument.Parse(
|
||||
lines[Array.IndexOf(eventNames, "exited")]);
|
||||
Assert.Equal(0, exitedDoc.RootElement.GetProperty("code").GetInt32());
|
||||
Assert.Equal(
|
||||
"probe",
|
||||
exitedDoc.RootElement.GetProperty("reason").GetString());
|
||||
|
||||
string contents = File.ReadAllText(statusPath);
|
||||
Assert.DoesNotContain("password", contents, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(statusPath))
|
||||
File.Delete(statusPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: <see cref="HeadlessProcessHost.RunOnUpdateThread"/>
|
||||
/// maps a ProbeComplete start to <see cref="HeadlessExitCode.Success"/>
|
||||
/// (0) rather than <see cref="HeadlessExitCode.ConnectionError"/> — a
|
||||
/// single-session probe-only process must exit cleanly and promptly
|
||||
/// without ever needing SIGINT/cancellation, because
|
||||
/// ProbeHeadlessBotPolicy reports IsComplete immediately.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ProcessHostMapsProbeCompleteStartToSuccessExitCode()
|
||||
{
|
||||
var configuration = new HeadlessConfiguration
|
||||
{
|
||||
Version = 1,
|
||||
Sessions =
|
||||
[
|
||||
ProbeDescriptor(
|
||||
provider: HeadlessCredentialProviderKind.StandardInput,
|
||||
credentialReference: "probe-password"),
|
||||
],
|
||||
};
|
||||
HeadlessPathSet paths = HeadlessPathSet.Resolve(
|
||||
new HeadlessPathOverrides());
|
||||
using var diagnostics = new StringWriter();
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var host = new HeadlessProcessHost(
|
||||
configuration,
|
||||
paths,
|
||||
new System.IO.StringReader("probe-password" + Environment.NewLine),
|
||||
diagnostics,
|
||||
operations);
|
||||
// Deliberately NOT cancelled — a probe-only process must return on
|
||||
// its own; a hang here would mean the scheduler never recognized
|
||||
// the probe session as already complete.
|
||||
using var cancellation = new CancellationTokenSource(
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
HeadlessExitCode result = await host.RunAsync(cancellation.Token);
|
||||
|
||||
Assert.Equal(HeadlessExitCode.Success, result);
|
||||
Assert.Equal(0, operations.EnterWorldCallCount);
|
||||
Assert.False(cancellation.IsCancellationRequested);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA LA2 review fix: configured probe intent is not proof of a
|
||||
/// completed probe. If the connected session produces no CharacterList,
|
||||
/// Runtime returns NoCharacters, the process returns ConnectionError, and
|
||||
/// the sole terminal status event reports that same non-success instead of
|
||||
/// the former false code-0/reason-probe pair.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ProbeWithoutRosterReportsTheProcessConnectionErrorExactlyOnce()
|
||||
{
|
||||
string statusPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-headless-probe-no-roster-{Guid.NewGuid():N}.jsonl");
|
||||
try
|
||||
{
|
||||
var configuration = new HeadlessConfiguration
|
||||
{
|
||||
Version = 1,
|
||||
Sessions =
|
||||
[
|
||||
ProbeDescriptor(
|
||||
provider: HeadlessCredentialProviderKind.StandardInput,
|
||||
credentialReference: "probe-password",
|
||||
statusFile: statusPath),
|
||||
],
|
||||
};
|
||||
var operations = new FixtureSessionOperations
|
||||
{
|
||||
Characters = null,
|
||||
};
|
||||
using var diagnostics = new StringWriter();
|
||||
using var host = new HeadlessProcessHost(
|
||||
configuration,
|
||||
HeadlessPathSet.Resolve(new HeadlessPathOverrides()),
|
||||
new System.IO.StringReader(
|
||||
"probe-password" + Environment.NewLine),
|
||||
diagnostics,
|
||||
operations);
|
||||
|
||||
HeadlessExitCode result = await host.RunAsync(
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(HeadlessExitCode.ConnectionError, result);
|
||||
Assert.Equal(0, operations.EnterWorldCallCount);
|
||||
Assert.Equal(1, operations.DisposedSessionCount);
|
||||
|
||||
host.Dispose();
|
||||
host.Dispose();
|
||||
|
||||
string[] lines = File.ReadAllLines(statusPath);
|
||||
JsonElement[] events = lines
|
||||
.Select(static line =>
|
||||
JsonDocument.Parse(line).RootElement.Clone())
|
||||
.ToArray();
|
||||
Assert.Equal(
|
||||
["started", "connected", "disconnected", "exited"],
|
||||
events.Select(static item =>
|
||||
item.GetProperty("e").GetString()));
|
||||
Assert.DoesNotContain(
|
||||
events,
|
||||
static item =>
|
||||
item.GetProperty("e").GetString() == "characterList");
|
||||
JsonElement exited = Assert.Single(
|
||||
events,
|
||||
static item => item.GetProperty("e").GetString() == "exited");
|
||||
Assert.Equal(
|
||||
(int)result,
|
||||
exited.GetProperty("code").GetInt32());
|
||||
Assert.Equal(
|
||||
"connection-error",
|
||||
exited.GetProperty("reason").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(statusPath))
|
||||
File.Delete(statusPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: a probe session completing must not tear down
|
||||
/// a sibling play session sharing the same process — the process exit
|
||||
/// code is 0 only once every configured session has succeeded (the
|
||||
/// probe counts as success the instant it completes; the play session
|
||||
/// keeps running until cancellation).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ProbeSessionSharingAProcessDoesNotTearDownASiblingPlaySession()
|
||||
{
|
||||
var configuration = new HeadlessConfiguration
|
||||
{
|
||||
Version = 1,
|
||||
Sessions =
|
||||
[
|
||||
ProbeDescriptor(
|
||||
"probe-sibling",
|
||||
provider: HeadlessCredentialProviderKind.StandardInput,
|
||||
credentialReference: "probe-password"),
|
||||
Descriptor(
|
||||
HeadlessCredentialProviderKind.StandardInput,
|
||||
"stdin-bot"),
|
||||
"play-password"),
|
||||
],
|
||||
};
|
||||
HeadlessPathSet paths = HeadlessPathSet.Resolve(
|
||||
|
|
@ -239,20 +440,144 @@ public sealed class HeadlessSessionHostTests
|
|||
configuration,
|
||||
paths,
|
||||
new System.IO.StringReader(
|
||||
"process-password" + Environment.NewLine),
|
||||
"probe-password" + Environment.NewLine
|
||||
+ "play-password" + Environment.NewLine),
|
||||
diagnostics,
|
||||
operations);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
HeadlessExitCode result =
|
||||
await host.RunAsync(cancellation.Token);
|
||||
HeadlessExitCode result = await host.RunAsync(cancellation.Token);
|
||||
|
||||
Assert.Equal(HeadlessExitCode.Success, result);
|
||||
Assert.True(host.Session.Runtime.Session.IsInWorld);
|
||||
Assert.DoesNotContain(
|
||||
"process-password",
|
||||
diagnostics.ToString());
|
||||
Assert.Equal(2, host.Sessions.Count);
|
||||
HeadlessSessionHost probeSession = Assert.Single(
|
||||
host.Sessions,
|
||||
s => s.SessionId == "probe-sibling");
|
||||
HeadlessSessionHost playSession = Assert.Single(
|
||||
host.Sessions,
|
||||
s => s.SessionId == "bot");
|
||||
Assert.False(probeSession.Runtime.Session.IsInWorld);
|
||||
Assert.True(playSession.Runtime.Session.IsInWorld);
|
||||
Assert.False(playSession.IsFaulted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: the configured <c>idle</c> policy follows the
|
||||
/// normal play shape (selector + policy, mode absent), enters world, and
|
||||
/// remains non-terminal through real scheduler turns until cancellation.
|
||||
/// Cancellation stops the process loop; the owning host's ordinary
|
||||
/// disposal transaction then performs graceful session teardown. Status
|
||||
/// events must describe those boundaries truthfully and remain exactly
|
||||
/// once even when disposal is repeated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task IdlePolicyEntersWorldRunsUntilCancellationAndConvergesExactlyOnce()
|
||||
{
|
||||
string statusPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-headless-idle-status-{Guid.NewGuid():N}.jsonl");
|
||||
try
|
||||
{
|
||||
var configuration = new HeadlessConfiguration
|
||||
{
|
||||
Version = 1,
|
||||
Sessions =
|
||||
[
|
||||
Descriptor(
|
||||
HeadlessCredentialProviderKind.StandardInput,
|
||||
"stdin-bot",
|
||||
statusFile: statusPath),
|
||||
],
|
||||
};
|
||||
HeadlessPathSet paths = HeadlessPathSet.Resolve(
|
||||
new HeadlessPathOverrides());
|
||||
using var diagnostics = new StringWriter();
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var host = new HeadlessProcessHost(
|
||||
configuration,
|
||||
paths,
|
||||
new System.IO.StringReader(
|
||||
"process-password" + Environment.NewLine),
|
||||
diagnostics,
|
||||
operations);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
|
||||
Task<HeadlessExitCode> run = host.RunAsync(cancellation.Token);
|
||||
var timeout = Stopwatch.StartNew();
|
||||
while (operations.TickCallCount < 3
|
||||
&& !run.IsCompleted
|
||||
&& timeout.Elapsed < TimeSpan.FromSeconds(10))
|
||||
{
|
||||
await Task.Delay(5);
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
operations.TickCallCount >= 3,
|
||||
$"Expected at least 3 idle scheduler turns, observed {operations.TickCallCount}.");
|
||||
Assert.False(run.IsCompleted);
|
||||
Assert.Equal(1, operations.EnterWorldCallCount);
|
||||
Assert.Equal("Headless", host.Session.ActiveCharacterName);
|
||||
Assert.True(host.Session.Runtime.Session.IsInWorld);
|
||||
Assert.False(host.Session.IsPolicyComplete);
|
||||
Assert.Equal(
|
||||
["started", "connected", "characterList", "enteredWorld"],
|
||||
ReadStatusEventNames(statusPath));
|
||||
|
||||
cancellation.Cancel();
|
||||
HeadlessExitCode result = await run.WaitAsync(
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.Equal(HeadlessExitCode.Success, result);
|
||||
// RunAsync owns scheduling, not the host lifetime. The session
|
||||
// remains honestly connected until its owner disposes it.
|
||||
Assert.True(host.Session.Runtime.Session.IsInWorld);
|
||||
Assert.Equal(
|
||||
["started", "connected", "characterList", "enteredWorld"],
|
||||
ReadStatusEventNames(statusPath));
|
||||
|
||||
host.Dispose();
|
||||
host.Dispose();
|
||||
|
||||
Assert.True(host.Session.Runtime.CaptureOwnership().IsConverged);
|
||||
Assert.Equal(1, operations.DisposedSessionCount);
|
||||
string[] lines = File.ReadAllLines(statusPath);
|
||||
string[] eventNames = ReadStatusEventNames(statusPath);
|
||||
Assert.Equal(
|
||||
[
|
||||
"started", "connected", "characterList", "enteredWorld",
|
||||
"disconnected", "exited",
|
||||
],
|
||||
eventNames);
|
||||
|
||||
using JsonDocument disconnected = JsonDocument.Parse(
|
||||
lines[Array.IndexOf(eventNames, "disconnected")]);
|
||||
Assert.Equal(
|
||||
"stopped",
|
||||
disconnected.RootElement.GetProperty("reason").GetString());
|
||||
|
||||
using JsonDocument exited = JsonDocument.Parse(
|
||||
lines[Array.IndexOf(eventNames, "exited")]);
|
||||
JsonElement exit = exited.RootElement;
|
||||
Assert.Equal(0, exit.GetProperty("code").GetInt32());
|
||||
string? exitReason = exit.GetProperty("reason").GetString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(exitReason));
|
||||
Assert.NotEqual("fault", exitReason);
|
||||
Assert.NotEqual("probe", exitReason);
|
||||
Assert.DoesNotContain(
|
||||
"process-password",
|
||||
File.ReadAllText(statusPath),
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"process-password",
|
||||
diagnostics.ToString(),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(statusPath))
|
||||
File.Delete(statusPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -2153,6 +2478,41 @@ public sealed class HeadlessSessionHostTests
|
|||
StatusFile = statusFile,
|
||||
};
|
||||
|
||||
/// <summary>Campaign LA slice LA2: a probe-mode descriptor — mode
|
||||
/// "probe", <c>Character</c>/<c>Policy</c> both omitted per the pinned
|
||||
/// contract shape <see cref="HeadlessConfigurationLoader"/> enforces.</summary>
|
||||
private static HeadlessSessionDescriptor ProbeDescriptor(
|
||||
string id = "probe-bot",
|
||||
HeadlessCredentialProviderKind provider =
|
||||
HeadlessCredentialProviderKind.Environment,
|
||||
string credentialReference = "PROBE_PASSWORD",
|
||||
string? statusFile = null) => new()
|
||||
{
|
||||
Id = id,
|
||||
Endpoint = new HeadlessEndpointDescriptor
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 9000,
|
||||
},
|
||||
Account = "account",
|
||||
Mode = HeadlessSessionMode.Probe,
|
||||
Credential = new HeadlessCredentialReference
|
||||
{
|
||||
Provider = provider,
|
||||
Reference = credentialReference,
|
||||
},
|
||||
StatusFile = statusFile,
|
||||
};
|
||||
|
||||
private static string[] ReadStatusEventNames(string path) =>
|
||||
File.ReadAllLines(path)
|
||||
.Select(static line =>
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(line);
|
||||
return document.RootElement.GetProperty("e").GetString()!;
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
private static void HydrateGroundedPlayer(GameRuntime runtime)
|
||||
{
|
||||
const uint player = 0x50000002u;
|
||||
|
|
@ -2794,11 +3154,34 @@ public sealed class HeadlessSessionHostTests
|
|||
|
||||
private sealed class FixtureSessionOperations : ILiveSessionOperations
|
||||
{
|
||||
private int _enterWorldCallCount;
|
||||
private int _tickCallCount;
|
||||
|
||||
public List<WorldSession> Sessions { get; } = [];
|
||||
public int CreatedSessionCount { get; private set; }
|
||||
public int DisposedSessionCount { get; private set; }
|
||||
public string? LastUser { get; private set; }
|
||||
public string? LastPassword { get; private set; }
|
||||
public int EnterWorldCallCount =>
|
||||
Volatile.Read(ref _enterWorldCallCount);
|
||||
public int TickCallCount => Volatile.Read(ref _tickCallCount);
|
||||
public CharacterList.Parsed? Characters { get; init; } = new(
|
||||
0u,
|
||||
[
|
||||
new CharacterList.Character(
|
||||
0x50000001u,
|
||||
"Other",
|
||||
0u),
|
||||
new CharacterList.Character(
|
||||
0x50000002u,
|
||||
"Headless",
|
||||
0u),
|
||||
],
|
||||
[],
|
||||
11,
|
||||
"account",
|
||||
true,
|
||||
true);
|
||||
|
||||
public IPEndPoint ResolveEndpoint(string host, int port) =>
|
||||
new(IPAddress.Loopback, port);
|
||||
|
|
@ -2820,34 +3203,19 @@ public sealed class HeadlessSessionHostTests
|
|||
LastPassword = password;
|
||||
}
|
||||
|
||||
public CharacterList.Parsed GetCharacters(
|
||||
WorldSession session) =>
|
||||
new(
|
||||
0u,
|
||||
[
|
||||
new CharacterList.Character(
|
||||
0x50000001u,
|
||||
"Other",
|
||||
0u),
|
||||
new CharacterList.Character(
|
||||
0x50000002u,
|
||||
"Headless",
|
||||
0u),
|
||||
],
|
||||
[],
|
||||
11,
|
||||
"account",
|
||||
true,
|
||||
true);
|
||||
public CharacterList.Parsed? GetCharacters(
|
||||
WorldSession session) => Characters;
|
||||
|
||||
public void EnterWorld(
|
||||
WorldSession session,
|
||||
int activeCharacterIndex)
|
||||
{
|
||||
Interlocked.Increment(ref _enterWorldCallCount);
|
||||
}
|
||||
|
||||
public void Tick(WorldSession session)
|
||||
{
|
||||
Interlocked.Increment(ref _tickCallCount);
|
||||
}
|
||||
|
||||
public void DisposeSession(WorldSession session)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue