tractor/tractor/trionics/patches
Gud Boi 98fdaf343d Add `tractor.trionics.patches` subpkg + first fix
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 0ef549fadb)
(factored: dropped spawn-backend-only paths: ai/conc-anal/infected_asyncio_under_main_thread_forkserver_hang_issue.md)
2026-06-17 17:39:44 -04:00
..
README.md Add `tractor.trionics.patches` subpkg + first fix 2026-06-17 17:39:44 -04:00
__init__.py Add `tractor.trionics.patches` subpkg + first fix 2026-06-17 17:39:44 -04:00
_wakeup_socketpair.py Add `tractor.trionics.patches` subpkg + first fix 2026-06-17 17:39:44 -04:00

README.md

tractor.trionics.patches

Defensive monkey-patches for bugs in trio itself.

What goes here

  • Bugs in upstream trio that weve encountered while running tractor and 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 tractors own code (those get fixed in-tree, in the offending tractor module).
  • Bugs in asyncio, pytest, the stdlib, etc. (file separate tractor.<lib>.patches subpkgs as needed).
  • Workarounds for behavior we disagree with but that isnt a bug per se. If trios API does what it says on the tin, we dont 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 consult is_needed() and skip when False. Returns True if patched this call, False if skipped.

  • is_needed() -> bool — does upstream still need patching? Today most patches return True unconditionally, but as upstream releases land each should gate on Version(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 modules 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.Z ships the upstream fix

Adding a patch

  1. Create _<topic>.py with the apply / is_needed / repro API.
  2. Register it in __init__.py::_PATCHES.
  3. Add a regression test in tests/trionics/test_patches.py that uses repro() to assert pre/post-patch behavior with a wall-clock cap.
  4. File the upstream issue/PR. Add the link to your modules Upstream: and # REMOVE WHEN: lines.

Removing a patch (when upstream releases the fix)

  1. Confirm the upstream-fixed trio version is the minimum we depend on, OR keep the version-gate in is_needed() if we still support older trio.
  2. If weve 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

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 actors entry could opt in too if a patch turns out to bite the root (none do today).