Root cause (measured live via ACDREAM_PROBE_PARK=1): HeadlessSessionWorldProjection drove the first-entry conductor unconditionally, including while HeadlessCollisionNeighborhood's own 3x3 publication plan held a genuinely open RuntimeCollisionAdmission for the local player's landblock. Every TrySealCollisionEvaluationAuthority attempt during that window failed (IsCollisionEvaluationPrefixAdmissible false) and retried forever without recovering — measured verdict: "seal-refused" repeating with no preceding [rearm] verdict= line (the operation never even reached the AwaitingCell park). This is the diagnosis doc's "structural half" mechanism; no evidence of the "circular HasOldPrefixPlacementDebt" hypothesis was observed, so that shape was not needed. Step 1 (enabler): HeadlessStaticStateAudit.ValidateProcessIsolation now takes sessionCount and only refuses process-global physics probes for sessionCount > 1 — its own multi-root-attribution rationale never applied to a single session, and it was blocking the exact probe built to diagnose this class of stall. Step 3a (root cause): new IHeadlessCollisionNeighborhood.IsQuiescent gates ProjectSpawn/ProjectPosition/PumpFirstEntry's conductor-drive calls — the conductor is never driven while the neighborhood's own publication owns collision authority for that tick. Step 4 (defense-in-depth): HeadlessLocalPlayerFrameHost.CanAdvancePlayer now requires Controller.CanExecuteLiveMovement instead of just a non-null controller — the headless-only gap that turned the (now-fixed) hydration stall into a hard crash reaching SuspendObjectUpdate on a dormant controller. RuntimeLocalPlayerFrameController's three shared entry points gained the same guard, contract-preserving for the graphical host. Verified end-to-end against live ACE (jump-probe policy, three runs): hydration succeeds cleanly (136 entities load vs. 0 before), no seal-refused spam, no crash from the original bug, graceful logout every time. Full airborne-transition confirmation is blocked by a separate, newly-discovered, pre-existing defect filed as #368 (the headless scheduler's Task.Delay(...).ConfigureAwait(false) tick loop can resume on a different ThreadPool thread mid collision-generation, tripping EnsureCollisionMutationThread) — explicitly out of scope here, not mentioned anywhere in the #365 diagnosis, and unsafe to fix without graphical-host verification this session was constrained not to perform. New tests: the real-admission hydration test (fails on the pre-Step-3a tree, verified by temporarily reverting the three gates and confirming failure, then restoring), the PumpFirstEntry quiescence-gate test, the CanAdvancePlayer publication-lifecycle test, the dormant-controller sabotage tests for RuntimeLocalPlayerFrameController, and the audit single/multi-session tests. RuntimeLocalPlayerPhysicsPublicationStateTests is untouched. Full Release suite: 12,343 passed / 4 skipped / 0 failed (baseline ~12,330/4 plus 11 new tests). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
243 lines
8.3 KiB
C#
243 lines
8.3 KiB
C#
using AcDream.Headless.Configuration;
|
|
using AcDream.Headless.Credentials;
|
|
using AcDream.Headless.Diagnostics;
|
|
using AcDream.Headless.Platform;
|
|
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.");
|
|
}
|
|
HeadlessStaticStateAudit.ValidateProcessIsolation(
|
|
configuration.Sessions.Count);
|
|
|
|
_diagnostics = new HeadlessDiagnosticWriter(diagnostics);
|
|
var credentials = new HeadlessCredentialResolver(
|
|
standardInput,
|
|
paths.ConfigDirectory);
|
|
var sessions = new List<HeadlessSessionHost>(
|
|
configuration.Sessions.Count);
|
|
HeadlessProcessContentOwner? content = null;
|
|
HeadlessProcessResourceSampler? resources = null;
|
|
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));
|
|
}
|
|
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();
|
|
|
|
private static HeadlessSessionDescriptor WithAccount(
|
|
HeadlessSessionDescriptor source,
|
|
string account) =>
|
|
new()
|
|
{
|
|
Id = source.Id,
|
|
Endpoint = source.Endpoint,
|
|
Account = account,
|
|
Character = source.Character,
|
|
Policy = source.Policy,
|
|
Credential = source.Credential,
|
|
};
|
|
|
|
internal async Task<HeadlessExitCode> RunAsync(
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
foreach (HeadlessSessionHost session in _sessions)
|
|
{
|
|
RuntimeSessionStartResult started = session.Start();
|
|
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
|
|
{
|
|
await _scheduler.RunAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
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);
|
|
}
|
|
}
|