tractor/tractor/trionics/patches/__init__.py

85 lines
2.7 KiB
Python
Raw Normal View History

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 0ef549fadb6b95d717457301f3470305dee1f01a) (factored: dropped spawn-backend-only paths: ai/conc-anal/infected_asyncio_under_main_thread_forkserver_hang_issue.md)
2026-06-10 00:23:26 +00:00
# tractor: structured concurrent "actors".
# Copyright 2018-eternity Tyler Goodlet.
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
'''
Defensive monkey-patches for `trio` internals.
Every patch in this package fixes a bug in `trio` itself
that we've encountered while running `tractor` — usually
a fork-survival edge case that upstream `trio` hasn't
filed/fixed yet. Each patch is:
- **idempotent** safe to call multiple times
- **version-gated** checks `trio.__version__` and skips
itself if upstream has shipped the fix
- **scoped** only modifies the specific trio internal
it's targeting; no broad side effects
- **removable** every patch carries a `# REMOVE WHEN:`
marker in its docstring pointing at the upstream PR
whose release allows us to drop it
Add a new patch by:
1. Create `tractor/trionics/patches/_<topic>.py` exposing
the `apply()` / `is_needed()` / `repro()` API
contract.
2. Import it in this `__init__.py` and add an entry to
`_PATCHES`.
3. Document upstream-fix-tracking in the module
docstring's `# REMOVE WHEN:` line.
4. Add a regression test in
`tests/trionics/test_patches.py` that uses the
patch's `repro()` to assert the bug exists + the
patch fixes it.
Calling `apply_all()` from a tractor entry point (e.g.
`tractor._child._actor_child_main`) applies every
registered patch + returns `{patch_name: applied?}` so
callers can log/assert as needed.
'''
from typing import Callable
from . import _wakeup_socketpair
_PATCHES: list[tuple[str, Callable[[], bool]]] = [
(
'trio_wakeup_socketpair_drain_eof',
_wakeup_socketpair.apply,
),
]
def apply_all() -> dict[str, bool]:
'''
Apply every registered patch. Idempotent calling
twice is fine, second call's dict will be all
`False`.
Returns `{patch_name: applied?}`:
- `True` patch was applied THIS call (inaugural
apply, or first-call-since-process-start).
- `False` skipped (already applied OR upstream fix
detected via `is_needed() == False`).
'''
results: dict[str, bool] = {}
for name, applier in _PATCHES:
results[name] = applier()
return results