fix(python): restore deterministic teardown for async-callback machinery#2009
Open
chaliy wants to merge 1 commit into
Open
fix(python): restore deterministic teardown for async-callback machinery#2009chaliy wants to merge 1 commit into
chaliy wants to merge 1 commit into
Conversation
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| 🔵 In progress View logs |
bashkit | c93b4ed | Jun 10 2026, 11:59 AM |
There was a problem hiding this comment.
Pull request overview
This PR restores deterministic teardown for the Python async-callback “private loop” machinery (TM-PY-030), bringing back bounded, synchronous cleanup while the interpreter is alive, while preserving “hands-off” behavior during interpreter finalization (where native thread attachment is unsafe on CPython < 3.13).
Changes:
- Implement interpreter-exit boundary tracking via an
atexit-set flag and use it to switch between deterministic teardown vs. exit-time hands-off behavior. - Make private-loop callbacks cancellable via published
asyncio.Tasks, and perform deterministic joins with the GIL released (including restoring tokio runtime drop joins while the interpreter is alive). - Add/adjust tests to assert deterministic thread/fd cleanup, bounded cancellation, and non-crashing interpreter-exit behavior; update specs to document the protocol and threat-model mitigation.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
specs/threat-model.md |
Updates TM-PY-030 to reflect deterministic teardown + exit-time crash variant and the new mitigation protocol. |
specs/python-package.md |
Documents the “Teardown determinism” contract and its interpreter-exit boundary behavior. |
crates/bashkit-python/tests/test_teardown_determinism.py |
New regression tests for deterministic joins, fd stability, bounded cancellation, and subprocess exit robustness. |
crates/bashkit-python/tests/test_async_callbacks.py |
Updates TM-PY-030 regression expectations to match “prompt teardown + completion-or-cancellation.” |
crates/bashkit-python/src/lib.rs |
Implements the teardown protocol: atexit flag, GIL-free joins, task publication/cancellation, worker joining/loop closing, engine registry for per-session loops, and Drop ordering hooks. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
PRs #2007/#2008 fixed the TM-PY-030 deadlocks and exit crash by making teardown hands-off (shutdown_background, no loop close), trading deterministic cleanup for forward progress. This restores determinism while the interpreter is alive and keeps hands-off behavior only where CPython makes determinism impossible (interpreter finalization). Protocol: - An atexit handler registered at module import sets INTERPRETER_AT_EXIT. atexit runs at the very start of Py_FinalizeEx, strictly before the phase in which native threads may no longer attach, so the flag cleanly separates 'interpreter alive' from 'process exiting'. - Each private-loop callback runs as a published asyncio.Task. Teardown cancels it via call_soon_threadsafe(task.cancel) and a closing flag rejects queued-but-unstarted items, so joins are bounded by cooperative cancellation instead of full callback duration. - PyPrivateAsyncLoop::shutdown (Drop) joins its worker thread, which closes its asyncio loop before exiting — fds freed before drop returns. - PyRuntime::drop joins the tokio blocking pool again (the pre-#2007 semantics) instead of shutdown_background. - Every join runs through join_without_gil: detach first when PyGILState_Check reports the dropping thread attached. This removes the GIL deadlock rather than avoiding the join. - Pyclass Drop impls (ScriptedTool/Bash/BashTool) cancel in-flight callbacks through an engine registry of live per-session loops before the rt field drop joins the pool. - At exit: threads skip Python entirely (flag check, no Python::attach), runtime falls back to shutdown_background, OS reclaims resources. Verification: - New tests/test_teardown_determinism.py: exact native-thread-count and fd-count stability across tool churn (joins are synchronous in drop), bounded cancellation of abandoned callbacks, and 10x subprocess interpreter-exit checks for both clean and abandoned-callback exits. - Race-sensitive suites looped 20x, concurrent stress (8 threads x 40 mixed iterations incl. timeout+drop churn) looped 10x, langgraph example 40x: zero hangs, zero aborts. - Full bashkit-python suite: 705 passed, 1 skipped. just pre-pr green.
c14b1fa to
c93b4ed
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Restores deterministic teardown for the async-callback machinery, removing the tradeoffs introduced by #2007/#2008 (which fixed the TM-PY-030 deadlocks/crash by going hands-off:
shutdown_background(), deferred loop close). Determinism is now full while the interpreter is alive; hands-off behavior remains only at interpreter finalization, where CPython itself forbids native threads from attaching (< 3.13).Protocol
atexithandler registered at module import setsINTERPRETER_AT_EXIT. atexit runs at the very start ofPy_FinalizeEx, strictly before the phase in which native threads may no longer attach — so the flag cleanly splits "interpreter alive → deterministic teardown is safe" from "exiting → hands off, OS reclaims".asyncio.Task; teardown cancels it viacall_soon_threadsafe(task.cancel), and aclosingflag rejects queued-but-unstarted items. Joins are bounded by cancellation, not by callback duration.PyPrivateAsyncLoop::shutdown(Drop) joins its worker — which closes its asyncio loop before exiting, freeing fds before drop returns.PyRuntime::dropjoins the tokio blocking pool again (pre-fix(ci): repair drift-workflow YAML and fix GIL deadlocks hanging Coverage #2007 semantics). Every join goes throughjoin_without_gil(detach first whenPyGILState_Checksays the dropping thread holds the GIL) — eliminating the deadlock instead of avoiding the join.Dropimpls cancel in-flight callbacks via an engine registry of live per-session loops before thertfield drop joins the pool (an abandoned task holds its own session Arc, so the registry is the only path to reach it).This also removes both #2008 tradeoffs: the loop is closed synchronously at teardown (no deferred-
__del__fd lifetime, noResourceWarningunder strict warning filters).Proof of determinism
New
tests/test_teardown_determinism.py:del tool; gc.collect(), across repeated churn — proves worker + runtime threads are joined synchronously in drop (/proc/self/task).CancelledError.Py_Finalize, both clean and with an abandoned in-flight callback — no SIGABRT.Adversarial verification beyond the committed tests:
test_teardown_determinism.py+test_async_callbacks.py) looped 20× — includes 400 subprocess exit checks.langgraph_async_tool.py(the fix(python): keep private-loop worker off Python during interpreter exit #2008 crasher) 40× — zero aborts.just pre-prgreen (fmt, clippy, tests, vet).One existing test updated:
test_dealloc_during_inflight_callback_does_not_deadlockasserted the abandoned callback "finishes on its own" (the old hands-off semantics); it now asserts prompt teardown plus completion-or-cancellation, which is the new contract.Known bound (documented)
Cancellation is cooperative: a callback blocking without awaiting (
time.sleepinsideasync def) can't be interrupted mid-section; teardown waits for it to reach an await point or return — with the GIL released, so it cannot deadlock. Documented inspecs/python-package.mdand the TM-PY-030 entry.Specs
specs/threat-model.md: TM-PY-030 rewritten around the deterministic teardown protocol.specs/python-package.md: new "Teardown determinism" section.Do not merge yet — awaiting full CI matrix (3.9–3.14) as the final leg of the proof.
Generated by Claude Code