[Bugfix] FrameLock::acquire() deadlocks the whole interpreter with two background threads (GIL/mutex lock-order inversion) #412
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#412
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?
Two background threads using
mcrfpy.lock()concurrently hang the entire process. One thread is fine. Two is a hard deadlock that takes the GIL with it, so the main thread, every timer and every other thread stop too. No error, no log line, no timeout —FrameLock's condition variable has no deadline (#398).Found by the contention coverage added for #395. Fixed in the same commit.
Reproduction
The defect
Textbook ABBA:
cv.wait, so it holdsmtx, and then blocks inPy_END_ALLOW_THREADSacquiring the GIL;acquire()holding the GIL and blocks instd::unique_lockonmtx.Neither can proceed, and because B holds the GIL, the interpreter stops with them.
Confirmed by backtrace on the wedged process rather than by inspection:
It needs exactly two threads and entirely ordinary usage. It survived because nothing in the suite had ever put two threads on this barrier at once — which is the whole of #395's point.
The fix
Release the GIL before taking
mtxand re-acquire it only aftermtxis dropped:No thread now holds
mtxwhile blocking on the GIL, so the cycle cannot form.release()andopenWindow()still takemtxwhile holding the GIL, but they never wait for the GIL, so they are only ever the second edge; the two GIL-free holders (acquire()andcloseWindow()) both dropmtxinsidecv.wait(), so neither holds it for long.Verified after the fix at N = 1, 2, 4 and 8 threads: every worker completes.
Coverage added with the fix
tests/integration/frame_lock_stress_test.py— 8 workers x 40 iterations, mutual exclusion, fairness, per-acquire wait distribution, nesting.tests/integration/frame_lock_soak_test.py— 3 workers under sustained load, drift in wait time and frame time, allocator-block growth.Both run their load in a subprocess child with a heartbeat watchdog, deliberately: a wedged GIL cannot be detected from inside the process it has wedged, so an in-process deadline would simply never run. That is how this defect was caught at all, and it is why the machinery stays.
Related: #395, #398, #410.