305 lines
11 KiB
C#
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);
|
|
}
|
|
}
|