acdream/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
Erik 41b408f3e6 fix(headless): #365 — collision-admission-open window drove the first-entry conductor into a permanent seal refusal
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>
2026-08-10 11:34:36 +02:00

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);
}
}