[Bugfix] Headless step()/advance() never opens the #219 frame-lock window — mcrfpy.lock() from a background thread blocks forever #398
Labels
No labels
Alpha Release Requirement
Bugfix
Demo Target
Documentation
Major Feature
Minor Feature
priority:tier1-active
priority:tier2-foundation
priority:tier3-future
priority:tier4-deferred
Refactoring & Cleanup
system:animation
system:documentation
system:grid
system:input
system:performance
system:procgen
system:python-binding
system:rendering
system:ui-hierarchy
Tiny Feature
workflow:blocked
workflow:needs-benchmark
workflow:needs-documentation
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
john/McRogueFace#398
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
mcrfpy.lock()(#219) is documented as the way a background Python thread hands mutations back to the engine safely. It works in windowed mode and deadlocks in headless mode.Cause
FrameLock::acquire()waits on a condition variable with no timeout. The only thing that signals it isopenWindow(), and that is called from exactly one place:src/GameEngine.cpp:510— insidedoFrame(), afterdisplay().The headless clock does not go through that path.
mcrfpy.step()/mcrfpy.advance()both funnel intoGameEngine::runSimFrame()(src/GameEngine.cpp:954), which deliberately skips render and input — and therefore also skipped the safe window. A background thread that enterswith mcrfpy.lock():under astep()-driven main loop parks inacquire()and never wakes.Reproduction (pre-fix)
Pre-fix output:
steps: 4951148 acquired: False— 4.95 million frames, never acquired.Post-fix output:
steps: 1 acquired: True.Why it matters
Headless is the mode embedders drive a world from (simulators, agent environments, CI). It is precisely the mode where a bus/worker thread needs to hand mutations back, and it was the one mode where the documented primitive could not work. Nothing caught it because
mcrfpy.lock()had essentially no coverage — see #395; the single existing use is awith mcrfpy.lock(): passsmoke poke intests/fuzz/, which no-ops on the main thread and so never exercisedacquire()at all.Fix
Open the safe window once per simulation frame in
runSimFrame(), mirroringdoFrame(): after the frame's work, beforecurrentFrame++, guarded byhasWaiting()(one relaxed atomic load per frame when no threads are waiting), with the GIL released acrosscloseWindow().Gated by
tests/integration/background_thread_network_test.py, which asserts a background thread's locked mutation actually lands while the main thread steps. That test fails on the pre-fix binary.Related
mcrfpy.lock()/ FrameLock coverage (this is an instance of the gap that issue describes)step()/advance()simulation clock this interacts withFixed on branch
sim/integration, commit1523533(not yet on master).runSimFrame()now opens the safe window once per simulation frame — after the frame's work, beforecurrentFrame++, mirroringdoFrame()— guarded byhasWaiting(), with the GIL released acrosscloseWindow().A dedicated regression test was added alongside the integration test named in the issue body:
tests/regression/issue_398_headless_frame_lock_test.py. It is the minimal statement of the bug (one background thread, onemcrfpy.lock(), one assignment, astep()-driven main loop — no sockets, no asyncio), so it stays a lock regression rather than becoming a second copy oftests/integration/background_thread_network_test.py.A/B verified by rebuilding the pre-fix
GameEngine.cppand re-running both tests:issue_398_headless_frame_lock_test.pybackground_thread_network_test.pyb77aeec)step()calls, lock never opened, thread still parked inacquire()target.x == 0.0, thread never exitedselectors.select()Full suite 625/625.