221 lines
7.8 KiB
Markdown
221 lines
7.8 KiB
Markdown
|
|
---
|
||
|
|
model: claude-opus-4-7[1m]
|
||
|
|
service: claude
|
||
|
|
timestamp: 2026-04-18T04:25:26Z
|
||
|
|
git_ref: 26fb820
|
||
|
|
diff_cmd: git diff 26fb820~1..26fb820
|
||
|
|
---
|
||
|
|
|
||
|
|
Code generated in this turn was committed as `26fb8206`
|
||
|
|
("Fix subint destroy race via dedicated OS thread").
|
||
|
|
Per diff-ref mode, per-file code is captured via the
|
||
|
|
pointer below; the rest is verbatim non-code output
|
||
|
|
— test-session diagnostics, CPython-internals
|
||
|
|
reasoning, and the design synthesis that's worth
|
||
|
|
keeping alongside the code since it informs the
|
||
|
|
still-pending Phase B.3 hard-kill work.
|
||
|
|
|
||
|
|
## Per-file generated content
|
||
|
|
|
||
|
|
### `tractor/spawn/_subint.py` (modified, +110/-84 LOC)
|
||
|
|
|
||
|
|
> `git diff 26fb820~1..26fb820 -- tractor/spawn/_subint.py`
|
||
|
|
|
||
|
|
Rewrites the subint driver-thread strategy:
|
||
|
|
- replaces `trio.to_thread.run_sync(_interpreters.exec, ...)`
|
||
|
|
with a plain `threading.Thread(target=_subint_target,
|
||
|
|
daemon=False)` so the OS thread truly exits after
|
||
|
|
`_interpreters.exec()` returns
|
||
|
|
- captures a `trio.lowlevel.current_trio_token()` at
|
||
|
|
`subint_proc` entry; the driver thread signals
|
||
|
|
completion back via `trio.from_thread.run_sync(
|
||
|
|
subint_exited.set, trio_token=...)`
|
||
|
|
- swallows `trio.RunFinishedError` in the signal path
|
||
|
|
for the case where the parent trio loop has already
|
||
|
|
exited (process teardown)
|
||
|
|
- in the teardown `finally` off-loads the sync
|
||
|
|
`driver_thread.join()` call to `trio.to_thread.run_sync`
|
||
|
|
(a cache thread w/ no subint tstate — so no cache
|
||
|
|
conflict) to wait for the driver thread to fully
|
||
|
|
exit before calling `_interpreters.destroy()`
|
||
|
|
|
||
|
|
## Non-code output (verbatim) — the CPython-internals research
|
||
|
|
|
||
|
|
### What went wrong before this commit
|
||
|
|
|
||
|
|
Under `--spawn-backend=subint` on py3.14, most single
|
||
|
|
tests passed but longer runs hung intermittently. The
|
||
|
|
position of the hang moved between runs (test #22 on
|
||
|
|
one run, test #53 on another) suggesting a timing-
|
||
|
|
dependent race rather than a deterministic bug.
|
||
|
|
|
||
|
|
`faulthandler.dump_traceback_later()` eventually
|
||
|
|
caught a stack with the main thread blocked in
|
||
|
|
`_interpreters.destroy(interp_id)` at `_subint.py:293`.
|
||
|
|
Only 2 threads were alive:
|
||
|
|
- main thread waiting in `_interpreters.destroy()`
|
||
|
|
- one idle trio thread-cache worker in
|
||
|
|
`trio._core._thread_cache._work`
|
||
|
|
|
||
|
|
No subint was still running (`_interpreters.list_all()`
|
||
|
|
showed only the main interp). A resource-tracker
|
||
|
|
pytest fixture confirmed threads/subints did NOT
|
||
|
|
accumulate across tests — this was not a leak but a
|
||
|
|
specific "destroy blocks on cached thread w/ stale
|
||
|
|
tstate" race.
|
||
|
|
|
||
|
|
### Why the race exists
|
||
|
|
|
||
|
|
`trio.to_thread.run_sync` uses a thread *cache* to
|
||
|
|
avoid OS-thread creation overhead. When the sync
|
||
|
|
callable returns, the OS thread is NOT terminated —
|
||
|
|
it's parked in `_thread_cache._work` waiting for the
|
||
|
|
next job. CPython's subinterpreter implementation
|
||
|
|
attaches a **tstate** (thread-state object) to each
|
||
|
|
OS thread that ever entered a subint via
|
||
|
|
`_interpreters.exec()`. That tstate is released
|
||
|
|
lazily — either when the thread picks up a new job
|
||
|
|
(which re-attaches a new tstate, evicting the old
|
||
|
|
one) or when the thread truly exits.
|
||
|
|
|
||
|
|
`_interpreters.destroy(interp_id)` waits for *all*
|
||
|
|
tstates associated w/ that subint to be released
|
||
|
|
before it can proceed. If the cached worker is idle
|
||
|
|
holding the stale tstate, destroy blocks indefinitely.
|
||
|
|
Whether the race manifests depends on timing — if
|
||
|
|
the cached thread happens to pick up another job
|
||
|
|
quickly, destroy unblocks; if it sits idle, we hang.
|
||
|
|
|
||
|
|
### Why a dedicated `threading.Thread` fixes it
|
||
|
|
|
||
|
|
A plain `threading.Thread(target=_subint_target,
|
||
|
|
daemon=False)` runs its target once and exits. When
|
||
|
|
the target returns, OS-thread teardown (`_bootstrap_inner`
|
||
|
|
→ `_bootstrap`) fires and CPython releases the
|
||
|
|
tstate for that thread. `_interpreters.destroy()`
|
||
|
|
then has no blocker.
|
||
|
|
|
||
|
|
### Diagnostic tactics that actually helped
|
||
|
|
|
||
|
|
1. `faulthandler.dump_traceback_later(n, repeat=False,
|
||
|
|
file=open(path, 'w'))` for captured stack dumps on
|
||
|
|
hang. Critically, pipe to a `file=` not stderr —
|
||
|
|
pytest captures stderr weirdly and the dump is
|
||
|
|
easy to miss.
|
||
|
|
2. A resource-tracker autouse fixture printing
|
||
|
|
per-test `threading.active_count()` +
|
||
|
|
`len(_interpreters.list_all())` deltas → ruled out
|
||
|
|
leak-accumulation theories quickly.
|
||
|
|
3. Running the hanging test *solo* vs in-suite —
|
||
|
|
when solo passes but in-suite hangs, you know
|
||
|
|
it's a cross-test state-transfer bug rather than
|
||
|
|
a test-internal bug.
|
||
|
|
|
||
|
|
### Design synthesis — SIGINT + subints + SC
|
||
|
|
|
||
|
|
The user and I walked through the cancellation
|
||
|
|
semantics of PEP 684/734 subinterpreters in detail.
|
||
|
|
Key findings we want to preserve:
|
||
|
|
|
||
|
|
**Signal delivery in subints (stdlib limitation).**
|
||
|
|
CPython's signal machinery only delivers signals
|
||
|
|
(SIGINT included) to the *main thread of the main
|
||
|
|
interpreter*. Subints cannot install signal handlers
|
||
|
|
that will ever fire. This is an intentional design
|
||
|
|
choice in PEP 684 and not expected to change. For
|
||
|
|
tractor's subint actors, this means:
|
||
|
|
|
||
|
|
- Ctrl-C never reaches a subint directly.
|
||
|
|
- `trio.run()` running on a worker thread (as we do
|
||
|
|
for subints) already skips SIGINT handler install
|
||
|
|
because `signal.signal()` raises on non-main
|
||
|
|
threads.
|
||
|
|
- The only cancellation surface into a subint is
|
||
|
|
our IPC `Portal.cancel_actor()`.
|
||
|
|
|
||
|
|
**Legacy-mode subints share the main GIL** (which
|
||
|
|
our impl uses since `msgspec` lacks PEP 684 support
|
||
|
|
per `jcrist/msgspec#563`). This means a stuck subint
|
||
|
|
thread can starve the parent's trio loop during
|
||
|
|
cancellation — the parent can't even *start* its
|
||
|
|
teardown handling until the subint yields the GIL.
|
||
|
|
|
||
|
|
**Failure modes identified for Phase B.3 audit:**
|
||
|
|
|
||
|
|
1. Portal cancel lands cleanly → subint unwinds →
|
||
|
|
thread exits → destroy succeeds. (Happy path.)
|
||
|
|
2. IPC channel is already broken when we try to
|
||
|
|
send cancel (e.g., `test_ipc_channel_break_*`)
|
||
|
|
→ cancel raises `BrokenResourceError` → subint
|
||
|
|
keeps running unaware → parent hangs waiting for
|
||
|
|
`subint_exited`. This is what breaks
|
||
|
|
`test_advanced_faults.py` under subint.
|
||
|
|
3. Subint is stuck in non-checkpointing Python code
|
||
|
|
→ portal-cancel msg queued but never processed.
|
||
|
|
4. Subint is in a shielded cancel scope when cancel
|
||
|
|
arrives → delay until shield exits.
|
||
|
|
|
||
|
|
**Current teardown has a shield-bug too:**
|
||
|
|
`trio.CancelScope(shield=True)` wrapping the `finally`
|
||
|
|
block absorbs Ctrl-C, so even when the user tries
|
||
|
|
to break out they can't. This is the reason
|
||
|
|
`test_ipc_channel_break_during_stream[break_parent-...
|
||
|
|
no_msgstream_aclose]` locks up unkillable.
|
||
|
|
|
||
|
|
**B.3 hard-kill fix plan (next commit):**
|
||
|
|
|
||
|
|
1. Bound `driver_thread.join()` with
|
||
|
|
`trio.move_on_after(HARD_KILL_TIMEOUT)`.
|
||
|
|
2. If it times out, log a warning naming the
|
||
|
|
`interp_id` and switch the driver thread to
|
||
|
|
`daemon=True` mode (not actually possible after
|
||
|
|
start — so instead create as daemon=True upfront
|
||
|
|
and accept the tradeoff of proc-exit not waiting
|
||
|
|
for a stuck subint).
|
||
|
|
3. Best-effort `_interpreters.destroy()`; catch the
|
||
|
|
`InterpreterError` if the subint is still running.
|
||
|
|
4. Document that the leak is real and the only
|
||
|
|
escape hatch we have without upstream cooperation.
|
||
|
|
|
||
|
|
**Test plan for Phase B.3:**
|
||
|
|
|
||
|
|
New `tests/test_subint_cancellation.py` covering:
|
||
|
|
- SIGINT at spawn
|
||
|
|
- SIGINT mid-portal-RPC
|
||
|
|
- SIGINT during shielded section in subint
|
||
|
|
- Dead-channel cancel (mirror of `test_ipc_channel_
|
||
|
|
break_during_stream` minimized)
|
||
|
|
- Non-checkpointing subint (tight `while True` in
|
||
|
|
user code)
|
||
|
|
- Per-test `pytest-timeout`-style bounds so the
|
||
|
|
tests visibly fail instead of wedging the runner
|
||
|
|
|
||
|
|
### Sanity-check output (verbatim terminal excerpts)
|
||
|
|
|
||
|
|
Post-fix single-test validation:
|
||
|
|
```
|
||
|
|
1 passed, 1 warning in 1.06s
|
||
|
|
```
|
||
|
|
(same test that was hanging pre-fix:
|
||
|
|
`test_parent_cancels[...cancel_method=ctx-...False]`)
|
||
|
|
|
||
|
|
Full `tests/test_context_stream_semantics.py`
|
||
|
|
under subint:
|
||
|
|
```
|
||
|
|
61 passed, 1 warning in 100.35s (0:01:40)
|
||
|
|
```
|
||
|
|
and a clean-cache re-run:
|
||
|
|
```
|
||
|
|
61 passed, 1 warning in 100.82s (0:01:40)
|
||
|
|
```
|
||
|
|
|
||
|
|
No regressions on trio backend (same subset):
|
||
|
|
```
|
||
|
|
69 passed, 1 skipped, 3 warnings in 89.19s
|
||
|
|
```
|
||
|
|
|
||
|
|
### Commit msg
|
||
|
|
|
||
|
|
Also AI-drafted via `/commit-msg` + `rewrap.py
|
||
|
|
--width 67`. See `git log -1 26fb820`.
|