# `_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.py`
  - `ActorNursery.__init__(.., ria_nursery, ..)` stores
    `._ria_nursery` (:202, :238); sole read is
    `run_in_actor()` passing `nursery=self._ria_nursery`
    (:442) into `start_actor()`'s `nursery:
    trio.Nursery|None` escape-hatch param (:305, :367).
  - `._cancel_after_result_on_exit: set` (:244) marks ria
    portals (:457).
  - `_open_and_supervise_one_cancels_all_nursery()` nests
    `da_nursery` (:609) around `ria_nursery` (:622); the
    `finally:` at the ria->da boundary (:747-766) raises
    collected `errors` (single exc or BEG).
- `runtime/_portal.py`
  - `._expect_result_ctx` (:112) set by `_submit_for_result()`
    (:142, sole caller `run_in_actor()`); consumed by
    `wait_for_result()` (:167) + deprecated `result()` (:220).
    The `None` branch (:184-196) returns the `NoResult`
    sentinel (`_exceptions.py:1164`).
- `spawn/_spawn.py`
  - `exhaust_portal()` (:129): awaits
    `portal.wait_for_result()`, CATCHES+RETURNS any exc
    (never raises).
  - `cancel_on_completion()` (:177): `exhaust_portal()` ->
    on exc-result stash `errors[uid] = result` (:203) ->
    ALWAYS `portal.cancel_actor()` (:218).
- `spawn/_trio.py` (:195-222) + `spawn/_mp.py` (:187-213),
  identical shape: after shielded
  `await an._join_procs.wait()`, open a per-child local
  nursery; IFF `portal in an._cancel_after_result_on_exit`
  start `cancel_on_completion` alongside `soft_kill()`; when
  `soft_kill` returns 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`:

1. reaper tasks only START after `_join_procs.set()` (block
   exit or the inner error handler),
2. `exhaust_portal` swallows the exc into a return value,
3. `cancel_on_completion` stashes it in `errors` + cancels
   that child,
4. the ria->da `finally:` re-raises collected `errors` (and
   `an.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 — drop `nursery=self._ria_nursery`.
- rename `._cancel_after_result_on_exit` ->
  `._ria_portals: dict[portal, Actor]` (need the subactor ref
  for `cancel_on_completion`).
- move reaper start-up OUT of the backends into
  `_open_and_supervise...`: immediately after EACH
  `an._join_procs.set()` call-site (happy path :642, inner
  error handler :661), start one
  `cancel_on_completion(portal, subactor, errors)` task per
  ria portal into `da_nursery`, then (happy path only)
  `await` their completion BEFORE falling out of the
  `try:`/`finally:` that raises `errors` — e.g. gather in a
  dedicated inner `trio.open_nursery()` block replacing
  today's `ria_nursery` join point.
- delete the membership branch + local reaper nursery from
  `_trio.py`/`_mp.py` (keep the `soft_kill()` call; the
  per-child local nursery collapses to just `soft_kill`).
- `_trio.py:310` `_children.pop()` etc. unchanged.

### step B: delete the plumbing

- `_open_and_supervise...`: drop the inner
  `ria_nursery` + merge its `except BaseException` classify
  logic into ONE handler on the (now single) nursery scope;
  `ActorNursery.__init__` loses the `ria_nursery` param.
- `start_actor()` loses the `nursery:` escape-hatch param
  (the :302-304 TODO).
- backends: no more `_cancel_after_result_on_exit` refs.

### step C: (separate PRs) deprecate + migrate + excise

- migrate in-repo `.run_in_actor()` usage to
  `to_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 deprecated
  `Portal.result()`/`wait_for_result()` — these die with
  `_expect_result_ctx`, so migration must land FIRST.
- add `DeprecationWarning` to `run_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

1. 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_portal` returns a `TransportClosed`-ish exc ->
   NEW entry in `errors` that 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_called` children.
2. error-path ordering: inner handler today sets
   `_join_procs` THEN `an.cancel()`; reapers race the
   cancel-RPC. Keep that ordering when moving reaper spawn.
3. debugger interplay: `maybe_wait_for_debugger()` calls
   (:654, :730) must stay BEFORE any reap/cancel issuance.
4. `errors` double-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.)
5. mp backend parity: mirror every `_trio.py` edit 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
  `finally`s 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_nursery` is GONE — the inner
  `async with (collapse_eg(), trio.open_nursery() as
  ria_nursery)` layer in
  `_open_and_supervise_one_cancels_all_nursery` is deleted;
  `da_nursery` is now the single nursery for ALL subactors.
- `ActorNursery.__init__` drops the `ria_nursery` param +
  the `self._ria_nursery` attr; `start_actor()` drops its
  `nursery=` escape-hatch param (uses `self._da_nursery`
  directly).
- `._cancel_after_result_on_exit` STAYS — 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_nursery` collapse to
  ONE `except BaseException as _scope_err` + the existing
  `finally`. The `outer_err`/`inner_err` locals 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.

## Verification gate

- `tests/test_cancellation.py test_spawning.py test_local.py
  test_rpc.py` on `trio` + `mp_spawn` + `mp_forkserver`
  backends, then full suite; `tests/devx/test_debugger.py`
  for risk 3.
