Merge branch 'claude/quirky-payne-46a2e6' into claude/latest-commits-cb0c8f

This commit is contained in:
Erik 2026-08-10 22:20:11 +02:00
commit 629d83411d
5 changed files with 274 additions and 32 deletions

View file

@ -156,10 +156,44 @@ internal sealed class HeadlessProcessHost : IDisposable
Credential = source.Credential,
};
internal async Task<HeadlessExitCode> RunAsync(
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();
@ -189,8 +223,7 @@ internal sealed class HeadlessProcessHost : IDisposable
try
{
await _scheduler.RunAsync(cancellationToken)
.ConfigureAwait(false);
_scheduler.Run(cancellationToken);
}
catch (OperationCanceledException)
when (cancellationToken.IsCancellationRequested)

View file

@ -147,8 +147,31 @@ internal sealed class HeadlessProcessScheduler
_observationPeriodTicks);
}
internal async Task RunAsync(CancellationToken cancellationToken)
/// <summary>
/// Drives every session's deadlines on the CALLING thread until
/// cancellation or policy completion, returning normally in both
/// cases. The caller must dedicate one thread for a process's whole
/// run: Runtime's gameplay owners (collision generations foremost,
/// via <c>RuntimePhysicsState.EnsureCollisionMutationThread</c>) bind
/// to the first mutating thread and refuse migration, and an awaited
/// timer loop in a SynchronizationContext-free host resumes on
/// arbitrary ThreadPool workers — which tripped that guard whenever a
/// collision generation spanned two waits (#368). The waits below go
/// through one rearmed <see cref="TimeProvider"/> timer signalling an
/// event, so the loop never leaves its thread. A stale timer callback
/// from an abandoned wait can set the event early; that only costs
/// one extra pass over the deadline math, which re-sleeps.
/// </summary>
internal void Run(CancellationToken cancellationToken)
{
using var wake = new ManualResetEventSlim(false);
using ITimer timer = _timeProvider.CreateTimer(
static state => ((ManualResetEventSlim)state!).Set(),
wake,
Timeout.InfiniteTimeSpan,
Timeout.InfiniteTimeSpan);
WaitHandle[] waitHandles =
[wake.WaitHandle, cancellationToken.WaitHandle];
while (!cancellationToken.IsCancellationRequested
&& HasActiveSession())
{
@ -169,11 +192,9 @@ internal sealed class HeadlessProcessScheduler
: _timeProvider.GetElapsedTime(now, deadline);
delay = NormalizeTimerDelay(delay);
Interlocked.Increment(ref _waitCount);
await Task.Delay(
delay,
_timeProvider,
cancellationToken)
.ConfigureAwait(false);
wake.Reset();
timer.Change(delay, Timeout.InfiniteTimeSpan);
WaitHandle.WaitAny(waitHandles);
}
}