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

@ -209,7 +209,8 @@ reached — no `seal-refused` spam, no crash from the original bug) and the
session exits gracefully every time (`[session] graceful logout confirmed`,
zero leases at disposal). Full `airborne-transition True` confirmation is
blocked by a SEPARATE, newly-discovered, pre-existing defect — see #368 below
— not by anything in this issue's scope. A diagnostic-only run with #368's
— not by anything in this issue's scope. (2026-08-10 update: #368 is CLOSED
at `b7f59923`; the airborne residual survived that fix and is now #370.) A diagnostic-only run with #368's
guard temporarily neutralized (never shipped, reverted before commit)
confirmed the #365 fix produces the correct behavior once past that unrelated
blocker: full hydration, the jump-probe policy running to completion, exit
@ -241,9 +242,33 @@ Environment). `HeadlessDiagnosticWriter.Failure` now emits `errorDetail`
## #368 — Headless scheduler's async tick loop can run collision-generation calls on different threads, tripping `EnsureCollisionMutationThread`
**Status:** OPEN — filed 2026-08-10 during #365's end-to-end verification.
Explicitly OUT OF SCOPE for #365 — orthogonal mechanism, not mentioned
anywhere in that diagnosis.
**Status:** CLOSED 2026-08-10 — fixed in `b7f59923`. One dedicated update
thread (`acdream-headless-update`, spawned by `HeadlessProcessHost.RunAsync`)
now owns the whole session-side lifecycle: `Start()` (the live connect
transaction, where the first collision-mutating call can already happen),
every scheduler turn, and the post-loop resource captures.
`HeadlessProcessScheduler.Run(CancellationToken)` replaced `RunAsync` — the
same deadline math and counters, but fully synchronous on the calling
thread, with waits going through one rearmed `TimeProvider` timer
signalling an event instead of `await Task.Delay`, so the loop never leaves
its thread. `EnsureCollisionMutationThread` is untouched — the invariant it
guards is real, and the headless host now satisfies it the same way the
graphical host's game-loop thread does. Zero shared Runtime changes, so the
graphical host is unaffected by construction. Evidence: new
`ProcessHostRunsStartAndEveryTickOnOneDedicatedUpdateThread` test (RED
pre-fix — Start ran on the caller's thread, ticks migrated to pool
workers), Headless suite 97/97, full Release suite 12,554 / 4 skips / 0
failures, and three live jump-probe runs against local ACE that each
crossed `[wake] begin gen=1` — the exact point all three pre-fix runs
quarantined — with zero faults, 204205 hydrated entities, policy
completion, ACE-confirmed graceful logout, converged disposed samples, and
exit 0. The jump-airborne timeout this issue carried as an open question
persists 3/3 on the fixed tree — the threading-artifact hypothesis is
refuted; split off as #370.
Filed 2026-08-10 during #365's end-to-end verification. Explicitly OUT OF
SCOPE for #365 — orthogonal mechanism, not mentioned anywhere in that
diagnosis.
**Symptom:** a real headless run against live ACE (`jump-probe` policy,
`ACDREAM_PROBE_PARK=1`) that survives long enough for the local player's own
@ -292,21 +317,62 @@ directly, never through the real `HeadlessProcessScheduler.RunAsync` await
loop — so none of them exercise this path either. A genuine coverage gap,
not a regression from a specific commit.
**NOT fixed here.** A proper fix needs its own investigation (e.g. a
dedicated single update thread for the scheduler's tick loop, or redesigning
the thread-affinity invariant for verified multi-thread-safe callers) and
must be verified against the GRAPHICAL host too — out of reach for the
session that found this (constrained not to launch the graphical client).
Do not "fix" this by loosening or removing `EnsureCollisionMutationThread`'s
check — it guards a real invariant elsewhere in the physics/collision
system, and a workaround shape here is exactly what CLAUDE.md's
no-workarounds rule forbids without explicit approval.
**Why the dedicated-thread shape (not an invariant redesign):** the guard
is only the ENFORCER — Runtime's single-update-thread contract is
documented all over (`RuntimeEntityDirectory` "single update-thread
authority", `RuntimeLocalPlayerPhysicsPublicationState` "this single
Runtime update thread", `RuntimePlacementProjectionChannel`), mostly
without enforcement. An async tick loop violates the whole contract, not
one check; weakening the check would have silenced the one place that
noticed while leaving every unenforced assumption exposed, and would have
degraded the guard for the graphical host where migration is always a bug.
The dedicated thread fixes the entire class. Start() had to move onto the
same thread too: the first collision-mutating call can land during
connect, and a caller-thread Start would bind the guard there and trip the
very first dedicated tick. Disposal legitimately stays on the lifecycle
thread — `ResetSessionPhysics`'s doc comment designs for exactly that, and
every prior graceful-teardown run (including the quarantined ones)
exercised it.
**Repro:** run the `jump-probe` policy against local ACE with
**Repro (historical):** run the `jump-probe` policy against local ACE with
`ACDREAM_PROBE_PARK=1` for long enough that the local player's own landblock
collision generation spans more than a couple of scheduler ticks (the
default case against a real DAT-loaded landblock).
## #370 — Headless jump-probe: the released jump never registers as airborne (proven NOT a threading artifact)
**Status:** OPEN — filed 2026-08-10 during #368's fix verification.
**Symptom:** with #368 fixed (one dedicated update thread, thread
migration provably gone — the new affinity test pins it), the `jump-probe`
policy reaches `[jump-probe] releasing jump (fire)` and then reports
`TIMEOUT waiting for airborne after jump fire -- the released jump never
registered as airborne.` Reproduced 3/3 on the fixed tree at
`lb=0x1134FFFF`; the #365 session saw the identical timeout in its
guard-neutralized diagnostic run at `lb=0x0904FFFF`
(`docs/research/2026-08-10-365-headless-hydration-diagnosis.md` §8), so it
is location-independent and pre-existing. The run still exits 0 with
ACE-confirmed graceful logout — the probe treats its timeout as
completion, so nothing quarantines.
**What this refutes:** the #365 OUTCOME's hypothesis that the timeout was
"plausibly a downstream artifact of the same unsynchronized-thread
condition" — threads are now single and the timeout persists unchanged.
This is a distinct defect (or probe-expectation gap) in the headless jump
path: either the policy's charge/release never becomes a real jump on the
wire/local motion, or the airborne signal the policy polls is never set on
the headless projection. Everything before the fire provably works
(hydration 204205 entities, `local player present`, movement owner
publication per #365's fix).
**Where to start:** `JumpProbeHeadlessBotPolicy` (what it reads as
"airborne"), the J5.4 `RuntimeLocalPlayerMovementState` jump intent/outbound
cadence path, and whether the graphical host's airborne transition has a
presentation-side dependency the headless projection lacks.
**Repro:** #368's recipe (jump-probe vs local ACE, `ACDREAM_PROBE_PARK=1`);
the timeout fires seconds after `releasing jump (fire)`.
## #369 — Unconfirmed whether retail's floating chat windows share the main window's currently-selected talk-focus channel
**Status:** OPEN — filed 2026-08-10, Campaign CH slice CH6b (register row

View file

@ -447,3 +447,16 @@ Every real (unmodified) run's session tore down gracefully
(`[session] graceful logout confirmed`, zero entities/leases at the final
`disposed` sample) regardless of which way it exited — `testaccount` was
never left in a stuck state by this work.
## 9. ADDENDUM (2026-08-10, #368 fix session)
#368 is CLOSED at `b7f59923`: one dedicated headless update thread now owns
Start, every scheduler turn, and the post-loop captures; the scheduler loop
is synchronous with TimeProvider-timer event waits (no `Task.Delay`
resumption migration; zero shared Runtime changes). Three live jump-probe
runs on the fixed tree each crossed `[wake] begin gen=1` cleanly with
204205 hydrated entities and graceful exits. The §8 open question is now
answered: the `airborne-transition True` timeout PERSISTS 3/3 with threading
provably single, so the "downstream artifact of the unsynchronized-thread
condition" hypothesis is refuted — the residual is a distinct pre-existing
defect, filed as #370.

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

View file

@ -1,3 +1,5 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
@ -198,7 +200,7 @@ public sealed class HeadlessProcessSchedulerTests
}
[Fact]
public async Task SystemTimerCadenceDoesNotBusyLoopBetweenTurns()
public void SystemTimerCadenceDoesNotBusyLoopBetweenTurns()
{
var operations = new FixtureSessionOperations();
using HeadlessSessionHost session =
@ -214,14 +216,10 @@ public sealed class HeadlessProcessSchedulerTests
using var cancellation =
new CancellationTokenSource(TimeSpan.FromMilliseconds(250));
try
{
await scheduler.RunAsync(cancellation.Token);
}
catch (OperationCanceledException)
when (cancellation.IsCancellationRequested)
{
}
// Run executes on the calling thread and returns normally when
// the token cancels (#368 moved thread ownership to the process
// host; the scheduler seam itself is synchronous).
scheduler.Run(cancellation.Token);
HeadlessSchedulerSnapshot snapshot =
scheduler.CaptureSnapshot();
@ -440,6 +438,63 @@ public sealed class HeadlessProcessSchedulerTests
}
}
[Fact]
public async Task ProcessHostRunsStartAndEveryTickOnOneDedicatedUpdateThread()
{
// #368: Runtime's gameplay owners (RuntimePhysicsState's collision
// generations foremost) require one update thread for a session's
// whole lifetime — the graphical host provides its game-loop thread;
// the headless host must provide an equivalent. Start() (the live
// connect transaction) and every subsequent tick must share one
// thread that is NOT the RunAsync caller's, across real timer waits.
var configuration = new HeadlessConfiguration
{
Version = 1,
Sessions =
[
Descriptor(
"update-thread",
"update-thread-stdin"),
],
};
var operations = new ThreadRecordingSessionOperations();
using var diagnostics = new StringWriter();
using var host = new HeadlessProcessHost(
configuration,
HeadlessPathSet.Resolve(new HeadlessPathOverrides()),
new System.IO.StringReader(
"update-thread-password" + Environment.NewLine),
diagnostics,
operations);
using var cancellation = new CancellationTokenSource();
int callerThread = Environment.CurrentManagedThreadId;
Task<HeadlessExitCode> run = host.RunAsync(cancellation.Token);
// Occupy the calling thread until ticks have crossed several timer
// waits — pre-fix, resumptions migrate to other pool threads while
// this thread is provably unavailable to them. The await happens
// only after the measurement window closes.
var stopwatch = Stopwatch.StartNew();
while (operations.TickCount < 5
&& stopwatch.Elapsed < TimeSpan.FromSeconds(10))
{
Thread.Sleep(1);
}
cancellation.Cancel();
HeadlessExitCode result = await run;
Assert.Equal(HeadlessExitCode.Success, result);
Assert.True(
operations.TickCount >= 5,
$"Expected at least 5 ticks, observed {operations.TickCount}.");
int updateThread = operations.ConnectThreadId;
Assert.NotEqual(0, updateThread);
Assert.NotEqual(callerThread, updateThread);
Assert.All(
operations.TickThreadIds,
id => Assert.Equal(updateThread, id));
}
[Fact]
public void ThrowingPolicyQuarantinesOnlyItsOwnSession()
{
@ -609,6 +664,60 @@ public sealed class HeadlessProcessSchedulerTests
}
}
private sealed class ThreadRecordingSessionOperations
: ILiveSessionOperations
{
private int _connectThreadId;
private readonly ConcurrentQueue<int> _tickThreadIds = new();
internal int ConnectThreadId =>
Volatile.Read(ref _connectThreadId);
internal int TickCount => _tickThreadIds.Count;
internal IReadOnlyCollection<int> TickThreadIds => _tickThreadIds;
public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port);
public WorldSession CreateSession(IPEndPoint endpoint) =>
new(endpoint);
public void Connect(
WorldSession session,
string user,
string password) =>
Volatile.Write(
ref _connectThreadId,
Environment.CurrentManagedThreadId);
public CharacterList.Parsed GetCharacters(
WorldSession session) =>
new(
0u,
[
new CharacterList.Character(
0x50000001u,
"Headless",
0u),
],
[],
11,
"account",
true,
true);
public void EnterWorld(
WorldSession session,
int activeCharacterIndex)
{
}
public void Tick(WorldSession session) =>
_tickThreadIds.Enqueue(Environment.CurrentManagedThreadId);
public void DisposeSession(WorldSession session) =>
session.Dispose();
}
private abstract class FixturePolicy : IHeadlessBotPolicy
{
public virtual bool IsComplete => false;