acdream/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs

305 lines
11 KiB
C#

using AcDream.Headless.Configuration;
using AcDream.Headless.Credentials;
using AcDream.Headless.Diagnostics;
using AcDream.Headless.Platform;
using AcDream.Headless.Policies;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.Headless.Hosting;
internal sealed class HeadlessProcessHost : IDisposable
{
private readonly HeadlessSessionHost[] _sessions;
private readonly HeadlessProcessScheduler _scheduler;
private readonly HeadlessDiagnosticWriter _diagnostics;
private readonly HeadlessProcessContentOwner? _content;
private readonly HeadlessProcessResourceSampler _resources;
private int _disposeIndex;
private bool _disposed;
internal HeadlessProcessHost(
HeadlessConfiguration configuration,
HeadlessPathSet paths,
TextReader standardInput,
TextWriter diagnostics,
ILiveSessionOperations? sessionOperations = null,
TimeProvider? timeProvider = null,
IHeadlessProcessContentFactory? contentFactory = null,
HeadlessDirectCredentials? directCredentials = null)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(standardInput);
ArgumentNullException.ThrowIfNull(diagnostics);
if (configuration.Sessions.Count == 0)
{
throw new HeadlessConfigurationException(
"Headless run mode requires at least one session.");
}
if (directCredentials is not null
&& configuration.Sessions.Count != 1)
{
throw new HeadlessConfigurationException(
"Direct credentials require exactly one configured session.");
}
// Consolidated-review round (2026-08-10), NIT (a): construct the
// diagnostics writer BEFORE the audit call so its single-session
// "probes enabled" line routes through the same structured stream
// every other headless diagnostic uses, instead of a bare
// Console.WriteLine that bypassed it.
_diagnostics = new HeadlessDiagnosticWriter(diagnostics);
HeadlessStaticStateAudit.ValidateProcessIsolation(
configuration.Sessions.Count, _diagnostics);
var credentials = new HeadlessCredentialResolver(
standardInput,
paths.ConfigDirectory);
var sessions = new List<HeadlessSessionHost>(
configuration.Sessions.Count);
string[] pluginRoots =
[
Path.Combine(AppContext.BaseDirectory, "plugins"),
paths.PluginsDirectory,
];
HeadlessProcessContentOwner? content = null;
HeadlessProcessResourceSampler? resources = null;
// FA6: constructed unconditionally — cheap, and every non-gate
// session simply never reads or writes it (see the coordinator's
// own class doc).
var gateCoordinator = new FellowshipAllegianceGateCoordinator();
try
{
if (configuration.Process?.Content is { } contentDescriptor)
{
content = new HeadlessProcessContentOwner(
contentDescriptor,
message => _diagnostics.Message(
"process-content",
message),
contentFactory);
}
foreach (HeadlessSessionDescriptor? candidate
in configuration.Sessions)
{
HeadlessSessionDescriptor descriptor = candidate
?? throw new HeadlessConfigurationException(
"Headless run mode cannot contain a null session.");
if (directCredentials is not null)
{
descriptor = WithAccount(
descriptor,
directCredentials.User);
}
HeadlessCredentialSecret secret = directCredentials is null
? credentials.Resolve(
descriptor.Id,
descriptor.Credential)
: new HeadlessCredentialSecret(
"command-line",
directCredentials.Password);
HeadlessProcessContentOwner.HeadlessProcessContentLease?
contentLease = content?.AcquireLease(descriptor.Id);
try
{
sessions.Add(new HeadlessSessionHost(
descriptor,
secret,
_diagnostics,
sessionOperations,
timeProvider,
contentLease: contentLease,
gateCoordinator: gateCoordinator,
pluginRoots: pluginRoots));
}
catch
{
contentLease?.Dispose();
secret.Dispose();
throw;
}
}
_sessions = sessions.ToArray();
resources = new HeadlessProcessResourceSampler(timeProvider);
_scheduler = new HeadlessProcessScheduler(
_sessions,
timeProvider,
observation: snapshot =>
CaptureResources(
"periodic",
resources,
snapshot,
content));
_resources = resources;
_content = content;
_disposeIndex = _sessions.Length - 1;
}
catch
{
resources?.Dispose();
for (int index = sessions.Count - 1; index >= 0; index--)
sessions[index].Dispose();
content?.Dispose();
throw;
}
}
internal HeadlessSessionHost Session => _sessions.Length == 1
? _sessions[0]
: throw new InvalidOperationException(
"The process owns more than one headless session.");
internal IReadOnlyList<HeadlessSessionHost> Sessions => _sessions;
internal HeadlessSchedulerSnapshot Scheduler =>
_scheduler.CaptureSnapshot();
internal HeadlessProcessContentSnapshot? Content =>
_content?.CaptureSnapshot();
// MF-1 (Campaign OP OP7 review fix, 2026-08-11): `with` copies every
// record property that this method doesn't explicitly override, so a
// future HeadlessSessionDescriptor property can never be silently
// dropped here the way CharacterOptions previously was by the
// hand-rolled six-of-seven-property object initializer.
private static HeadlessSessionDescriptor WithAccount(
HeadlessSessionDescriptor source,
string account) =>
source with { Account = account };
internal Task<HeadlessExitCode> RunAsync(
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
// #368: Runtime's gameplay owners require ONE update thread for a
// session's whole lifetime — collision generations bind to the
// first mutating thread and refuse migration. The graphical host
// satisfies that with its game-loop thread; this dedicated thread
// is the headless equivalent. Start (the live connect
// transaction), every scheduler turn, and the post-loop captures
// all execute here. Only disposal stays on the lifecycle thread,
// which the Runtime teardown path explicitly supports (see
// ResetSessionPhysics's own doc comment).
var completion = new TaskCompletionSource<HeadlessExitCode>(
TaskCreationOptions.RunContinuationsAsynchronously);
var thread = new Thread(() =>
{
try
{
completion.SetResult(
RunOnUpdateThread(cancellationToken));
}
catch (Exception error)
{
completion.SetException(error);
}
})
{
IsBackground = true,
Name = "acdream-headless-update",
};
thread.Start();
return completion.Task;
}
private HeadlessExitCode RunOnUpdateThread(
CancellationToken cancellationToken)
{
foreach (HeadlessSessionHost session in _sessions)
{
RuntimeSessionStartResult started = session.Start();
// Campaign LA slice LA2: ProbeComplete is a SUCCESS variant, not
// a connection failure — the session already connected, reported
// its roster, and gracefully disconnected before EnterWorld (see
// LiveSessionController's probe short-circuit). Continue to the
// next configured session instead of returning ConnectionError,
// so a probe session sharing a process with play sessions never
// tears the others down. ProbeHeadlessBotPolicy already reports
// IsComplete, so the scheduler below skips this session entirely.
if (started.Status == RuntimeSessionStartStatus.ProbeComplete)
{
_diagnostics.Lifecycle(
session.SessionId,
"probed",
session.Runtime);
continue;
}
if (started.Status != RuntimeSessionStartStatus.Connected)
{
if (started.Error is { } error)
{
_diagnostics.Failure(
session.SessionId,
"start",
error);
}
return HeadlessExitCode.ConnectionError;
}
_diagnostics.Lifecycle(
session.SessionId,
"running",
session.Runtime);
}
_scheduler.RebaseDeadlinesAfterSessionStart();
CaptureResources(
"running-start",
_resources,
_scheduler.CaptureSnapshot(),
_content);
try
{
_scheduler.Run(cancellationToken);
}
catch (OperationCanceledException)
when (cancellationToken.IsCancellationRequested)
{
}
catch (Exception error)
{
_diagnostics.Failure("process", "tick", error);
return HeadlessExitCode.RuntimeError;
}
CaptureResources(
"running-stop",
_resources,
_scheduler.CaptureSnapshot(),
_content);
return _scheduler.CaptureSnapshot().FaultedSessionCount > 0
? HeadlessExitCode.RuntimeError
: HeadlessExitCode.Success;
}
public void Dispose()
{
if (_disposed)
return;
while (_disposeIndex >= 0)
{
_sessions[_disposeIndex].Dispose();
_disposeIndex--;
}
_content?.Dispose();
CaptureResources(
"disposed",
_resources,
_scheduler.CaptureSnapshot(),
_content);
_resources.Dispose();
_disposed = true;
}
private void CaptureResources(
string state,
HeadlessProcessResourceSampler resources,
HeadlessSchedulerSnapshot scheduler,
HeadlessProcessContentOwner? content)
{
HeadlessProcessResourceSnapshot snapshot = resources.Capture(
state,
_sessions,
scheduler,
content?.CaptureSnapshot());
_diagnostics.Resources(state, snapshot);
}
}