16 KiB
_ria_nursery removal plan (issue #477 follow-up)
Goal: drop the secondary “run-in-actor” spawn nursery (and friends) from ActorNursery/spawn internals, now that tractor.to_actor.run() delivers one-shot semantics purely on the daemon-spawn + portal primitives.
Verified machinery map (2026-07-02, wkt @ a34aaf98)
The entire mechanism is 4 files:
runtime/_supervise.pyActorNursery.__init__(.., ria_nursery, ..)stores._ria_nursery(:202, :238); sole read isrun_in_actor()passingnursery=self._ria_nursery(:442) intostart_actor()’snursery: trio.Nursery|Noneescape-hatch param (:305, :367).._cancel_after_result_on_exit: set(:244) marks ria portals (:457)._open_and_supervise_one_cancels_all_nursery()nestsda_nursery(:609) aroundria_nursery(:622); thefinally:at the ria->da boundary (:747-766) raises collectederrors(single exc or BEG).
runtime/_portal.py._expect_result_ctx(:112) set by_submit_for_result()(:142, sole callerrun_in_actor()); consumed bywait_for_result()(:167) + deprecatedresult()(:220). TheNonebranch (:184-196) returns theNoResultsentinel (_exceptions.py:1164).
spawn/_spawn.pyexhaust_portal()(:129): awaitsportal.wait_for_result(), CATCHES+RETURNS any exc (never raises).cancel_on_completion()(:177):exhaust_portal()-> on exc-result stasherrors[uid] = result(:203) -> ALWAYSportal.cancel_actor()(:218).
spawn/_trio.py(:195-222) +spawn/_mp.py(:187-213), identical shape: after shieldedawait an._join_procs.wait(), open a per-child local nursery; IFFportal in an._cancel_after_result_on_exitstartcancel_on_completionalongsidesoft_kill(); whensoft_killreturns first,nursery.cancel_scope.cancel()reaps the result-waiter.
The load-bearing semantic (already-deferred errors)
Remote ria-child errors NEVER raise into ria_nursery:
- reaper tasks only START after
_join_procs.set()(block exit or the inner error handler), exhaust_portalswallows the exc into a return value,cancel_on_completionstashes it inerrors+ cancels that child,- the ria->da
finally:re-raises collectederrors(andan.cancel()s any daemon stragglers).
So mid-block there is NO error propagation from ria children (unless user code explicitly await portal.wait_for_result()s) — the two-nursery nesting only sequences “reap ria results BEFORE blocking on daemon join”. A single-nursery impl only needs to preserve that sequencing, not any ASAP-cancel behavior.
Target design
step A: single-nursery run_in_actor() (mechanical)
run_in_actor()spawns via the DEFAULT (_da_nursery) path — dropnursery=self._ria_nursery.- rename
._cancel_after_result_on_exit->._ria_portals: dict[portal, Actor](need the subactor ref forcancel_on_completion). - move reaper start-up OUT of the backends into
_open_and_supervise...: immediately after EACHan._join_procs.set()call-site (happy path :642, inner error handler :661), start onecancel_on_completion(portal, subactor, errors)task per ria portal intoda_nursery, then (happy path only)awaittheir completion BEFORE falling out of thetry:/finally:that raiseserrors— e.g. gather in a dedicated innertrio.open_nursery()block replacing today’sria_nurseryjoin point. - delete the membership branch + local reaper nursery from
_trio.py/_mp.py(keep thesoft_kill()call; the per-child local nursery collapses to justsoft_kill). _trio.py:310_children.pop()etc. unchanged.
step B: delete the plumbing
_open_and_supervise...: drop the innerria_nursery+ merge itsexcept BaseExceptionclassify logic into ONE handler on the (now single) nursery scope;ActorNursery.__init__loses theria_nurseryparam.start_actor()loses thenursery:escape-hatch param (the :302-304 TODO).- backends: no more
_cancel_after_result_on_exitrefs.
step C: (separate PRs) deprecate + migrate + excise
- migrate in-repo
.run_in_actor()usage toto_actor.run(): tests 46 hits/9 files (test_cancellation 15, test_infected_asyncio 10, test_spawning 8, registrar 3, adv_streaming 4, pubsub 2, rpc 1, runtime 1), examples 28 hits/13 files (debugging/* dominate), docs 20 hits/8 rst files. NOTE: many sites also use deprecatedPortal.result()/wait_for_result()— these die with_expect_result_ctx, so migration must land FIRST. - add
DeprecationWarningtorun_in_actor()(+_submit_for_result/wait_for_result). - final excision:
run_in_actor(),_submit_for_result,_expect_result_ctx,wait_for_result/result,exhaust_portal,cancel_on_completion,NoResult.
Risk register
- hard-killed ria child: today the backend-local
nursery.cancel_scope.cancel()discards a still-parked reaper when the proc dies first; a da_nursery-hosted reaper instead sees the transport break ->exhaust_portalreturns aTransportClosed-ish exc -> NEW entry inerrorsthat today gets discarded. Guard: reap-gather block must cancel remaining reapers once all ria procs are dead, or filter transport-death excs for already-cancel_calledchildren. - error-path ordering: inner handler today sets
_join_procsTHENan.cancel(); reapers race the cancel-RPC. Keep that ordering when moving reaper spawn. - debugger interplay:
maybe_wait_for_debugger()calls (:654, :730) must stay BEFORE any reap/cancel issuance. errorsdouble-entry: local body error (:646) + child’s relayed exc (via reaper) can both land for the same scenario -> BEG shape changes vs today? (today has the same dual-write sites; keep behavior identical.)- mp backend parity: mirror every
_trio.pyedit in_mp.py(identical block).
Step-A first-probe findings (2026-07-02, WIP in tree)
Step A is IMPLEMENTED (uncommitted): run_in_actor() spawns via da_nursery; new _supervise._reap_ria_portals() helper; reap awaited after happy-path _join_procs.set(); error-path runs reap CONCURRENT with an.cancel() in the shielded block; backends stripped of the membership branch + per-child reaper nursery (+ dead imports).
Probe history (trio backend): - tests/test_to_actor.py + tests/test_spawning.py: 20/20 PASS — incl. all run_in_actor() result round-trips + test_remote_error (single erroring child, body re-raise -> inner error path). - FIRST attempt ran the error-path reap CONCURRENT with an.cancel() (mimicking the old backend-side race): test_cancellation.py::test_multierror (2 erroring ria children, body re-raises one) DEADLOCKED. Root cause per the sequencing fix below: reap + cancel must NOT race at this layer (suspected ._children pop-during-iteration and/or double-cancel RPC wedge; not fully root-caused since the fix removes the race wholesale). - FIX (2nd attempt, current impl): error path SEQUENCES: (1) snapshot ria (portal, subactor) pairs (backend finallys pop ._children as procs reap), (2) await an.cancel(), (3) bounded reap over the snapshot. Bound was first 3s -> blew the fail_after deadline in test_cancel_while_childs_child_in_sync_sleep (hard- killed grandchild never relays => reaper parks the full bound). Tightened to 0.5s: anything collectable is already queued in the local ctx (relayed BEFORE the cancel); a parked reaper self-cleans (trio.Cancelled results are never stashed). - RESULT: tests/test_cancellation.py FULLY GREEN (20 passed, 1 xfailed, 77s); full-suite gate run kicked off same session (see final report/next session).
Remaining risk: on slow CI a relayed-but-undelivered error racing the 0.5s bound could drop an errors entry (BEG-shape flake); if observed, scale the bound via the cpu_perf_headroom()-style approach or peek Portal._final_result_msg/ctx queue state instead of time-bounding.
Step-B outcome (2026-07-02, done in tree)
Step A landed as 5cd190c5 (code) + 99310269 (docs). Step B implemented on top (uncommitted):
._ria_nurseryis GONE — the innerasync with (collapse_eg(), trio.open_nursery() as ria_nursery)layer in_open_and_supervise_one_cancels_all_nurseryis deleted;da_nurseryis now the single nursery for ALL subactors.ActorNursery.__init__drops theria_nurseryparam + theself._ria_nurseryattr;start_actor()drops itsnursery=escape-hatch param (usesself._da_nurserydirectly).._cancel_after_result_on_exitSTAYS — it’s the ria-child discriminator for_reap_ria_portals().
Deliberately NOT done (deferred to its own higher-risk PR, flagged with a TODO at the outer except): merging the two error handlers into one. Rationale — collapsing the empty nursery is provably behavior-preserving (a zero-task trio.open_nursery() only adds a checkpoint), whereas the inner except BaseException (swallow-into-errors) and outer except (...) (re-raise, safety-net for the inner handler’s own non-shielded awaits) have DIFFERENT semantics; merging changes error/cancel propagation and wants isolated review + its own gate. Both handlers are kept, now nested directly under the single nursery.
Why the collapse is safe: post-step-A NOTHING spawns into ria_nursery (its only reader, run_in_actor’s nursery=self._ria_nursery, was removed in A; the stored attr was never read again). So the layer was pure dead weight.
Gate (trio backend, all 0-failure): - targeted set (test_cancellation test_spawning test_local test_rpc test_to_actor) = 49 passed, 1 xfailed. - tail set (test_reg_err_types remote_exc_relay resource_cache ringbuf root_infect_asyncio root_runtime runtime shm task_broadcasting trioisms trionics/) = 63 passed, 1 skipped, 5 xfailed. - full-suite head ~73% (subdirs + test_2way..test_pubsub) = 303 passed before the known-flaky test_dynamic_pub_sub TooSlowError stall (pre-existing; same hang in the step-A full run). Suite ran slow this session (~13min vs 555s cold, likely thermal from back-to-back runs), never completing within an 800s bound — but split across the above three runs EVERY module passed under step B.
Step-B2 outcome (2026-07-02, done in tree)
Step B committed as 9201a2ed (code) + d2e812fb (docs), then branched to drop_ria_nursery. Step B2 (the deferred handler-merge) implemented on top (uncommitted):
- the two nested handlers in
_open_and_supervise_one_cancels_all_nurserycollapse to ONEexcept BaseException as _scope_err+ the existingfinally. Theouter_err/inner_errlocals go away.
Why it’s safe (trace, not hope): the OLD inner handler records errors[actor.aid.uid] as its FIRST statement (before any await). So whenever an error path runs, errors is non-empty. The OLD outer handler was only reachable via leakage from the inner handler (it catches BaseException, so nothing from the yield scope bypasses it) — and by then errors is already populated, so the finally’s raise from errors ALWAYS superseded the outer handler’s own raise. i.e. the outer raise was dead. The outer handler’s other effects (_scope_error, a 2nd debugger-wait, child-cancel) are redundant with the merged handler + finally. So one handler + finally is observably equivalent.
Residual nuance (accepted): in the rare “trio.Cancelled delivered during the non-shielded maybe_wait_for_debugger” path, the merged form may leave _cancel_called False (cancel happens after the wait), so open_nursery’s tb-hiding guard (not cancel_called and _scope_error) can show a tb it previously hid. More informative, not less; no test asserts on it.
Gate (box ran ~2.7x slow this session, load-induced TooSlowError flakiness on timing tests — NOT code; see env_cpu_throttle_masquerades_as_regression): - baseline (pre-B2 tip 9201a2ed) full suite (-k 'not dynamic_pub_sub') = 300 passed + 1 test_ext_types_over_ipc TooSlowError that passes 6/6 in isolation (4.89s). - B2 error/cancel gate (test_cancellation remote_exc_relay inter_peer_cancellation advanced_faults oob_cancellation to_actor spawning local rpc) = 71 passed, 1 xfailed (125s). - B2 full-suite run: see b2_full.log (result appended on completion). RECOMMEND a clean full-suite run on a normal-speed box before this merges.
Regression + fix: ria-reap hang (2026-07-02)
Human hit a full-suite hang on test_infected_asyncio.py::test_tractor_cancels_aio. Bisected: passes at pre-ria a34aaf98 (0.59s), hangs at B2 e617b498 (90s+). Root-caused to the STEP-A reaper hoist (5cd190c5), NOT B2 (_reap_ria_portals is byte-identical A->B2).
Bug: the test does run_in_actor(asyncio_actor) then a USER portal.cancel_actor() and exits the block cleanly -> the happy path’s await _reap_ria_portals(), which waits UNBOUNDED on cancel_on_completion -> wait_for_result(). The child was cancelled out-of-band so no final result is relayed -> parked forever. The OLD spawn-backend reaper was raced against soft_kill() (per-child nursery cancel_scope.cancel() on subproc death); the hoist dropped that race.
Fix: _reap_ria_portals() runs each cancel_on_completion() in a local nursery alongside a proc.poll() death-watch that cancels the parked reaper once the subproc exits — restoring the old race, backend-agnostic (guarded by hasattr(proc, 'poll') for a future subint handle).
Why POLL (proc.poll()) not the event-driven wait_func: the mp waiter (_spawn.proc_waiter) does wait_readable(proc.sentinel), and soft_kill() is ALREADY awaiting that same fd concurrently in the daemon nursery — a 2nd wait_readable on one fd raises trio.BusyResourceError. (trio.Process.wait() IS multi-waiter-safe, but mp has no async equivalent.) proc.poll() — the same liveness check soft_kill itself falls back to — is the conflict-free common denominator. Verified: poll-fix passes on BOTH trio and mp_spawn.
Also added a per-test anti-hang guard: wrapped test_tractor_cancels_aio’s main() in with trio.fail_after(9 * cpu_perf_headroom()) — the blessed pattern (pytest-timeout’s global cap is intentionally off; breaks trio under fork backends, see pyproject NOTE). So a future recurrence FAILS FAST instead of hanging the suite. (Several other tests in the file are still guardless — test_aio_simple_error, test_trio_error_cancels_intertask_chan, test_aio_errors_and_channel_propagates_and_closes — candidate follow-up sweep.)
Lesson: the B2 focused gate OMITTED test_infected_asyncio (and the full runs were clipped/slow), so the step-A hang slipped through. Any future ria-touching change MUST gate test_infected_asyncio explicitly.
Gate: test_tractor_cancels_aio green (trio 1.53s, mp 3.98s); fix gate (test_infected_asyncio test_cancellation test_to_actor test_spawning) = 74 passed, 3 xfailed, 0 failures.
PAUSED (2026-07-02): re-assess the reaper’s SCOPE
User’s insight (compelling — likely the real root cause of the hang, not just the missing proc-death race):
the “hoisting” of
5cd190c5was just not really done right — the hoist should have been into theto_actorscope, not_supervise.
The argument: .run_in_actor()’s result-waiting/reaping got hoisted into _supervise._reap_ria_portals (nursery-machinery scope), which has NO natural cancel-scope to bound a parked wait_for_result() — hence the awkward proc-death race + the poll-vs-proc_waiter dilemma. If the result-wait instead lived in the to_actor one-shot scope (to_actor._invoke_in_subactor()), it would sit right next to the caller’s an + a local trio task-nursery + cancel-scope (the trio.to_thread-style model #477 actually wants) — so bounding/cancelling the wait is trivial and the hang dissolves from correct scoping rather than a bolt-on race.
Follow-on to re-evaluate on resume: - should _reap_ria_portals exist AT ALL, or should result-waiting move entirely into to_actor._invoke_in_subactor()? - reimplement legacy run_in_actor() on top of to_actor.run() so _reap_ria_portals + _cancel_after_result_on_exit can be DROPPED from _supervise entirely (the true #477 simplification)? - the poll-vs-event decision is MOOT under this re-scoping.
State at pause: test_infected_asyncio anti-hang guard COMMITTED (d1fb4a1a, intentionally red w/o the fix — the user’s failing-test-first convention). The poll-based reap fix in _supervise.py is UNCOMMITTED and likely SUPERSEDED by the re-scoping — do NOT land it as-is.
Verification gate
tests/test_cancellation.py test_spawning.py test_local.py test_rpc.pyontrio+mp_spawn+mp_forkserverbackends, then full suite;tests/devx/test_debugger.pyfor risk 3.