With a seminal patch fixing `trio`'s `WakeupSocketpair.drain()` which
can busy-loop due to lack of handling `EOF`.
New `tractor.trionics.patches` subpkg housing defensive monkey-patches
for upstream `trio` bugs we've encountered while running `tractor`
— particularly as of recent, fork-survival edge cases that haven't been
filed/fixed upstream yet. Each patch is idempotent, version-gated via
`is_needed()`, and carries a `# REMOVE WHEN:` marker pointing at the
upstream release whose adoption allows deletion.
Subpkg layout + per-patch contract documented in
`tractor/trionics/patches/README.md` — `apply()` / `is_needed()`
/ `repro()` API, registry pattern via `_PATCHES` in `__init__.py`,
single-call entry point `apply_all()`.
First patch, `_wakeup_socketpair`:
- `trio`'s `WakeupSocketpair.drain()` loops on `recv(64KB)` and exits
ONLY on `BlockingIOError`, NEVER on `recv() == b''` (peer-closed FIN).
- under `fork()`-spawning backends the COW-inherited socketpair fds
& `_close_inherited_fds()` teardown can leave a `WakeupSocketpair`
instance whose write-end is closed, and `drain()` then **spins forever
in C with no Python checkpoints**,
- this obviously burns 100% CPU and no signal delivery.
Standalone repro:
from trio._core._wakeup_socketpair import WakeupSocketpair
ws = WakeupSocketpair()
ws.write_sock.close()
ws.drain() # spins forever
Patch is one-line — break the drain loop on b'' EOF.
Manifested as two distinct test failures:
- `tests/test_multi_program.py::test_register_duplicate_name` hung at
100% CPU on the busy-loop directly (fork child's worker thread)
- `tests/test_infected_asyncio.py::test_aio_simple_error` Mode-A
deadlock — busy-loop wedged trio's scheduler inside `start_guest_run`,
both threads parked in `epoll_wait`, no TCP connect-back to parent
ever happened.
Same patch fixes both. Restored 99.7% pass rate on full
suite under `--spawn-backend=main_thread_forkserver`
(was hanging indefinitely before).
Wired into `tractor._child._actor_child_main` via `apply_all()` BEFORE
any trio runtime init. Harmless on non-fork backends.
Conc-anal write-ups, including strace + py-spy evidence:
- `ai/conc-anal/trio_wakeup_socketpair_busy_loop_under_fork_issue.md`
- `ai/conc-anal/infected_asyncio_under_main_thread_forkserver_hang_issue.md`
Regression tests in `tests/trionics/test_patches.py`: each test asserts
(a) the bug exists pre-patch (or is fixed upstream — skip cleanly), (b)
the patch fixes it with a SIGALRM wall-clock cap so a regression hangs
loud instead of silently.
TODO:
- [ ] file the upstream `python-trio/trio` issue + PR.
- [ ] use the `repro()` callable in `_wakeup_socketpair.py` IS the issue
body's evidence section.
(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
(cherry picked from commit
|
||
|---|---|---|
| .. | ||
| README.md | ||
| __init__.py | ||
| _wakeup_socketpair.py | ||
README.md
tractor.trionics.patches
Defensive monkey-patches for bugs in trio itself.
What goes here
- Bugs in upstream
triothat we’ve encountered while runningtractorand need to work around until upstream releases a fix. - Each patch fixes EXACTLY one trio internal — no multi-bug omnibus patches.
What does NOT go here
- Bugs in
tractor’s own code (those get fixed in-tree, in the offending tractor module). - Bugs in
asyncio,pytest, the stdlib, etc. (file separatetractor.<lib>.patchessubpkgs as needed). - Workarounds for behavior we disagree with but that isn’t a bug per se. If trio’s API does what it says on the tin, we don’t override it here.
Per-patch contract
Every _<topic>.py module in this directory MUST expose:
apply() -> bool— apply the patch. Idempotent (safe to call multiple times). Version-gated — must consultis_needed()and skip when False. ReturnsTrueif patched this call,Falseif skipped.is_needed() -> bool— does upstream still need patching? Today most patches returnTrueunconditionally, but as upstream releases land each should gate onVersion(trio.__version__) < Version('X.Y.Z'). When the gated version is released, the patch can be DELETED entirely.repro() -> None— minimal demonstration of the bug. Used by the regression test suite to assert (a) the upstream bug still exists, (b) our patch fixes it. Should be tight enough that calling it post-apply()returns cleanly within a few hundred milliseconds — tests wrap it with a wall-clock cap.
Each module’s docstring MUST contain:
- Problem: what trio does wrong + the trigger conditions (e.g. “fork-spawn backend, peer-closed socketpair, etc.”)
- Fix: the one-line (ideally) patch
- Repro: the standalone snippet
repro()implements - Upstream: link to filed issue/PR (or
TODO: file) - REMOVE WHEN:
trio>=X.Y.Zships the upstream fix
Adding a patch
- Create
_<topic>.pywith theapply/is_needed/reproAPI. - Register it in
__init__.py::_PATCHES. - Add a regression test in
tests/trionics/test_patches.pythat usesrepro()to assert pre/post-patch behavior with a wall-clock cap. - File the upstream issue/PR. Add the link to your module’s
Upstream:and# REMOVE WHEN:lines.
Removing a patch (when upstream releases the fix)
- Confirm the upstream-fixed
trioversion is the minimum we depend on, OR keep the version-gate inis_needed()if we still support older trio. - If we’ve fully bumped past the broken versions:
- Delete
_<topic>.py - Remove the entry from
__init__.py::_PATCHES - Delete the corresponding test in
tests/trionics/test_patches.py - Bump the conc-anal doc with a “FIXED” header
- Delete
Calling
from tractor.trionics.patches import apply_all
apply_all()Currently invoked from tractor._child._actor_child_main before _trio_main so every spawned subactor gets patched. The root actor’s entry could opt in too if a patch turns out to bite the root (none do today).