diff --git a/ai/conc-anal/ria_nursery_removal_plan.md b/ai/conc-anal/ria_nursery_removal_plan.md
new file mode 100644
index 000000000..bad6ded75
--- /dev/null
+++ b/ai/conc-anal/ria_nursery_removal_plan.md
@@ -0,0 +1,460 @@
+# `_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.
+
+## 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 5cd190c5 was just not really done right
+> — the hoist should have been into the `to_actor` scope,
+> 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.
+
+## RESOLVED (2026-07-06): migrate everything, remove the API
+
+The PAUSED re-assessment concluded decisively: rather than
+re-scope `_reap_ria_portals` (or bolt any hack onto it), the
+`run_in_actor()` API itself was REMOVED — its non-blocking
+"result at teardown" semantic predates streaming and confused
+more than it served. Every in-repo caller was migrated
+per-file/-group (each its own commit, each gated):
+
+- tests: `test_infected_asyncio` `test_runtime` `test_rpc`
+ `test_spawning` `test_pubsub` `test_registrar`
+ `test_cancellation` (3 groups) `test_advanced_streaming`.
+- examples: 4 non-debugging + all 8 `debugging/` REPL scripts
+ (debugger suite byte-identical green, 28p/6s).
+- docs: 8 rst pages + the `experimental/_pubsub` docstring.
+
+Migration patterns (the `run_in_actor` shape -> successor):
+
+- blocking result -> `to_actor.run(fn, an=an, ...)`
+- fire-&-forget/forever -> bg `to_actor.run()` task in a local
+ `trio` task-nursery (or `start_actor`
+ + bg `Portal.run()` when a portal
+ handle is needed)
+- concurrent fan-out -> N bg `to_actor.run()` tasks / or
+ `gather_contexts([p.open_context(..)])`
+- reap-all-error-collect -> the "collect don't cancel" pattern:
+ each one-shot catches + stashes its
+ `RemoteActorError`, group raised
+ after the task-nursery joins (see
+ `examples/debugging/multi_subactors.py`)
+- mutual-rendezvous -> peers must OUTLIVE both dialogs:
+ `start_actor()` daemons + concurrent
+ `Portal.run()`s + explicit
+ `an.cancel()` (eager one-shot reap
+ races the slower peer's dial of the
+ winner's dead sockaddr; found via
+ `test_trynamic_trio` flake).
+
+Semantic deltas (tests loosened accordingly):
+
+- teardown-reap-all BEG-of-N is GONE: local task-nurseries are
+ cancel-on-first, raced siblings' `Cancelled`s are absorbed,
+ and the runtime's `collapse_eg()` unwraps every single-member
+ group at each actor boundary — a fully-raced nested tree
+ relays a bare (annotated) `RemoteActorError` chain.
+- `test_multierror_fast_nursery` deleted (pure reap-stress);
+ `test_nested_multierrors` re-purposed as deep-tree
+ cancel-cascade stress w/ a race-tolerant shape walk.
+
+Final excision (after zero callers remained): `run_in_actor()`,
+`._cancel_after_result_on_exit`, `_reap_ria_portals()`,
+`Portal._submit_for_result/._expect_result_ctx/
+.wait_for_result()/.result()`, `exhaust_portal()`,
+`cancel_on_completion()`, `NoResult` — net -402 lines. The
+reap-hang class (unbounded `wait_for_result` in machinery
+scope) dissolves structurally: the only result-wait left lives
+in the caller's task inside its own cancel-scope; the
+`d1fb4a1a` anti-hang guard test passes by construction. The
+poll-vs-`proc_waiter` debate is moot as predicted.
+
+## Follow-up sketch: `to_actor.open_one_shot()` (run-async parity)
+
+If deferred-result parity is ever wanted, the design that needs
+NO runtime coupling, NO returned `Portal` and NO cancel-relay
+`trio.Event` machinery:
+
+ async with to_actor.open_one_shot(
+ fn, an=an, **kws,
+ ) as one_shot:
+ ... # concurrent caller work
+ val = await one_shot.wait() # optional; errors always
+ # propagate at scope exit
+
+an `@acm` that opens a private task-nursery, `start_soon`s ONE
+task running the existing blocking `run()` and stashes the
+value in a slot + sets a done-`trio.Event` (a memo, not a
+cancel relay). Cancellation = plain scope-cancel of the acm's
+nursery (the parked `Portal.run()` unwinds via `Cancelled`, the
+shielded `cancel_actor()` reap still runs); a child error
+raises into the acm scope so an un-`wait()`ed one-shot can
+never silently drop its error. i.e. the old reaper's job is
+done by scoping, not machinery. ~40 lines, all in
+`to_actor/_api.py`, zero `_supervise` involvement.
+
+## Verification gate
+
+- per-migration-commit module gates on `trio` (+ `mp_spawn`
+ spot-gates incl. `test_infected_asyncio` per the B2 lesson);
+ `tests/devx/test_debugger.py` for the REPL flows.
+- full suite on `trio` + `mp_spawn` at branch tip + CI matrix
+ via draft PR #484.
diff --git a/ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.md b/ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.md
new file mode 100644
index 000000000..7fecf36d3
--- /dev/null
+++ b/ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.md
@@ -0,0 +1,79 @@
+---
+model: claude-fable-5
+service: claude
+session: f6c84722-471a-4458-9a80-e453fea9029f
+timestamp: 2026-07-02T15:42:55Z
+git_ref: 65bf9df5
+scope: code
+substantive: true
+raw_file: 20260702T154255Z_65bf9df5_prompt_io.raw.md
+---
+
+## Prompt
+
+Driver prompt file `ai/prompt-io/prompts/issue_477.md`:
+
+> attempt to resolve
+> https://github.com/goodboy/tractor/issues/477
+> do it with /open-wkt.
+
+(plus a hard stop-for-human-review deadline of 12:50PM
+EST the same day)
+
+Issue #477 asks to factor `ActorNursery.run_in_actor()`
+(and possibly `Portal.run()`) out of the nursery
+internals into a new `tractor.to_actor` wrapper
+subpackage of "higher level one shot" single-remote-task
+APIs, adopting the `trio.to_thread`/`anyio.to_process`
+parlance, so that error collection/propagation moves up
+into the caller's local `trio` scope and the nursery's
+spawn machinery can eventually drop the
+`._ria_nursery` coupling.
+
+## Response summary
+
+First-cut `tractor.to_actor` subpkg delivering the
+one-shot API composed purely from the existing
+daemon-spawn + portal primitives (`start_actor()` +
+`Portal.run()` + `Portal.cancel_actor()`), leaving the
+legacy `.run_in_actor()` machinery untouched (formal
+deprecation deferred until in-repo usage migrates):
+
+- `to_actor.run(fn, **fn_kwargs) -> Any`: spawn a
+ subactor, schedule `fn` as its lone remote task, wait
+ on and return its result, ALWAYS reaping the subactor
+ (shield-safe `finally`). Remote errors raise in the
+ caller's task as boxed `RemoteActorError`s.
+- placement variants: `portal=` reuses a running actor
+ (no spawn/reap), `an=` spawns from a caller-managed
+ actor-nursery, neither opens a call-scoped private
+ `open_nursery()` (implicitly booting the runtime,
+ configurable via `runtime_kwargs`).
+- fail-fast validation before any spawn: non-streaming
+ async fn required; `portal=`/`an=` mutually
+ exclusive; `runtime_kwargs` rejected alongside any
+ placement opt.
+- `run_in_actor()` TODO/docstring now cross-reference
+ the successor API.
+
+## Files changed
+
+- `tractor/to_actor/__init__.py` — new subpkg,
+ re-exports `run`
+- `tractor/to_actor/_api.py` — `run()` +
+ `_invoke_in_subactor()` + `_validate_one_shot_fn()`
+- `tractor/__init__.py` — top-level `to_actor`
+ re-export
+- `tractor/runtime/_supervise.py` — comment/docstring
+ pointers from `run_in_actor()` to the successor
+- `tests/test_to_actor.py` — 11-test suite covering
+ all placement variants, error relay, the concurrent
+ worker-pool-ish pattern and arg validation
+- `examples/parallelism/to_actor_one_shots.py` —
+ runnable demo (auto-collected by
+ `test_docs_examples.py`)
+
+## Human edits
+
+None yet — pending human review (work paused before the
+12:50PM EST deadline per the driver prompt).
diff --git a/ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.raw.md b/ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.raw.md
new file mode 100644
index 000000000..0000dab88
--- /dev/null
+++ b/ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.raw.md
@@ -0,0 +1,100 @@
+---
+model: claude-fable-5
+service: claude
+timestamp: 2026-07-02T15:42:55Z
+git_ref: 65bf9df5
+diff_cmd: git diff main..wkt/to_actor_subpkg
+---
+
+# Raw AI output (diff-ref mode)
+
+All generated code is committed on the
+`wkt/to_actor_subpkg` branch; per diff-ref mode each
+file's verbatim content is reachable via the pointers
+below rather than duplicated here.
+
+## Generated files
+
+> `git diff main..wkt/to_actor_subpkg -- tractor/to_actor/__init__.py`
+
+New subpackage init: module docstring establishing the
+`trio.to_thread`/`anyio.to_process` "run it over there"
+parlance for actors, plus the single public re-export
+`run as run` from `._api`.
+
+> `git diff main..wkt/to_actor_subpkg -- tractor/to_actor/_api.py`
+
+The one-shot invocation impl, composed entirely from the
+lower level daemon-spawn + portal primitives as
+prescribed by issue #477:
+
+- `_validate_one_shot_fn()`: the `Portal.run()`
+ non-streaming-async-fn constraint checked up-front,
+ before any subactor is spawned.
+- `_invoke_in_subactor()`: `an.start_actor()` ->
+ `Portal.run()` -> always-reap via
+ `Portal.cancel_actor()` in a `finally` (the cancel
+ req's bounded wait is internally shielded so the reap
+ also runs under caller-scope cancellation).
+- `run()`: the public API. Placement options:
+ `portal=` (reuse a running actor, no spawn/reap),
+ `an=` (spawn from a caller-managed nursery), or
+ neither (private `open_nursery()` scoped to the call,
+ implicitly booting the runtime when needed, tunable
+ via pass-through `runtime_kwargs`). Spawn opts mirror
+ `ActorNursery.start_actor()`; `**fn_kwargs` are
+ relayed to the remote task. Errors raise in the
+ caller's task as boxed `RemoteActorError`s.
+ `runtime_kwargs` alongside any placement opt is a
+ hard `ValueError`, never silently ignored.
+
+> `git diff main..wkt/to_actor_subpkg -- tractor/__init__.py`
+
+Top-level `from . import to_actor as to_actor`
+re-export.
+
+> `git diff main..wkt/to_actor_subpkg -- tractor/runtime/_supervise.py`
+
+Comment/docstring-only: the `run_in_actor()` deprecation
+TODO now points at the implemented `.to_actor.run()`
+successor (checkbox ticked) and the method docstring
+gains a NOTE steering users to the new API; remaining
+TODO items are the `DeprecationWarning` emission +
+in-repo usage migration.
+
+> `git diff main..wkt/to_actor_subpkg -- tests/test_to_actor.py`
+
+11-test suite: private-nursery one-shot, implicit
+runtime boot via `runtime_kwargs`, remote-error relay to
+the caller's task (bare + caller-managed nursery),
+caller-nursery spawn, portal reuse w/o implicit reap,
+the concurrent worker-pool-ish pattern (local `trio`
+nursery x shared `an`), and the four validation
+rejections (sync fn, async-gen fn, `portal`+`an`
+combo, `runtime_kwargs`+placement combo).
+
+> `git diff main..wkt/to_actor_subpkg -- examples/parallelism/to_actor_one_shots.py`
+
+Runnable example (auto-collected by
+`test_docs_examples.py`): the fully-implicit one-shot
+plus the concurrent worker-pool-ish prime-check pattern
+against a shared caller-managed actor-nursery.
+
+## Test runs (verbatim)
+
+```
+tests/test_to_actor.py .......... [100%]
+============= 10 passed in 4.29s =============
+```
+
+Regression subset for touched modules
+(`test_local.py test_rpc.py test_spawning.py
+test_cancellation.py`):
+
+```
+38 passed, 1 xfailed, 24 warnings in 80.71s (0:01:20)
+```
+
+(warnings are pre-existing stdlib `os.fork()`
+DeprecationWarnings from the mp spawn backends, not
+introduced by this change)
diff --git a/ai/prompt-io/claude/20260702T161624Z_65bf9df5_prompt_io.md b/ai/prompt-io/claude/20260702T161624Z_65bf9df5_prompt_io.md
new file mode 100644
index 000000000..414daa2be
--- /dev/null
+++ b/ai/prompt-io/claude/20260702T161624Z_65bf9df5_prompt_io.md
@@ -0,0 +1,80 @@
+---
+model: claude-fable-5
+service: claude
+session: f4da6654-63f9-4641-b567-f200da46d697
+timestamp: 2026-07-02T16:16:24Z
+git_ref: 65bf9df5
+scope: tests
+substantive: true
+raw_file: 20260702T161624Z_65bf9df5_prompt_io.raw.md
+---
+
+## Prompt
+
+> attempt to resolve
+> https://github.com/goodboy/tractor/issues/474
+> do it with /open-wkt.
+
+Issue #474: "We need test suite for
+`.trionics.start_or_cancel()`" — dig out the use case
+from `modden` as a unit test (and provide motivation
+for upstream `trio` to possibly provide better
+porcelain around this case). The wrapper landed via
+PR #464 with no tests.
+
+## Response summary
+
+New `tests/trionics/test_taskc.py` (9 tests) covering
+`start_or_cancel()`, authored in wkt
+`start_or_cancel_tests_474`.
+
+Behaviour was first probed empirically (trio 0.29):
+the lossy `RuntimeError('child exited without calling
+task_status.started()')` only fires when the child
+exits pre-`.started()` WITHOUT propagating the ambient
+`Cancelled` — i.e. when the child (or lib code it
+calls) absorbs the cancel in a graceful-teardown
+pattern; a well-behaved child surfaces `Cancelled`
+straight out of `.start()`. The `modden`
+`progman.open_wks()` use case was reconstructed from
+`modden/runtime/progman.py` accordingly.
+
+Tests (each `use_start_or_cancel` parametrization also
+pins upstream trio's current lossy behaviour as
+wart-documentation):
+
+- `test_sibling_err_not_masked_by_startup_rte` — the
+ `modden` case: sibling error OOB-cancels the shared
+ nursery scope; with the wrapper ONLY the root-cause
+ `ValueError` escapes; bare `.start()` adds the lossy
+ RTE alongside.
+- `test_pure_oob_cancel_not_morphed_to_rte` — plain
+ ancestor `cs.cancel()`: wrapper → clean exit; bare
+ → eg-wrapped RTE.
+- `test_genuine_startup_rte_still_raised` — no
+ cancellation → protocol-bug RTE re-raised same as
+ bare.
+- `test_childs_own_rte_never_demoted_to_cancel` — a
+ child's own `RuntimeError('never got started!')` /
+ `RuntimeError(1234)` under ambient cancel is never
+ demoted to `Cancelled` (exact-msg-match + str-guard
+ regression cover).
+- `test_started_value_and_args_passthru` — happy path:
+ positional args, `name=`, `.started()` value.
+
+Verified: 9/9 pass; 0 flakes across 50 hammer runs;
+two impl mutations (checkpoint removed; guard relaxed
+to substring match) each caught by exactly the
+targeted tests; `tests/trionics/` +
+`tests/test_trioisms.py` subset green (23 passed,
+5 xfailed); ruff clean; 69-col style.
+
+## Files changed
+
+- `tests/trionics/test_taskc.py` — new
+ `start_or_cancel()` unit-test suite (gh #474).
+
+## Human edits
+
+Pending review — session paused pre-commit per user
+deadline; nothing committed as of this entry.
diff --git a/ai/prompt-io/claude/20260702T161624Z_65bf9df5_prompt_io.raw.md b/ai/prompt-io/claude/20260702T161624Z_65bf9df5_prompt_io.raw.md
new file mode 100644
index 000000000..83ffb96b2
--- /dev/null
+++ b/ai/prompt-io/claude/20260702T161624Z_65bf9df5_prompt_io.raw.md
@@ -0,0 +1,107 @@
+---
+model: claude-fable-5
+service: claude
+timestamp: 2026-07-02T16:16:24Z
+git_ref: 65bf9df5
+diff_cmd: git diff main..wkt/start_or_cancel_tests_474
+---
+
+# Raw output — gh #474 `start_or_cancel()` test suite
+
+## Generated test code
+
+> `git diff main..wkt/start_or_cancel_tests_474 -- tests/trionics/test_taskc.py`
+
+Prose summary of the generated module
+(`tests/trionics/test_taskc.py`):
+
+- module docstring framing the `trio.Nursery.start()`
+ startup-cancellation wart, the wrapper's repair, and
+ the intent that `use_start_or_cancel=False` params
+ double as upstream-trio wart-documentation (break on
+ a trio upgrade → upstream may have shipped porcelain,
+ re-audit the wrapper); cites gh #474 / PR #464 and
+ `modden`'s `progman.open_wks()` as the source use
+ case.
+- shared children: `absorbs_cancel_pre_started()` (the
+ graceful-teardown cancel-absorber which triggers the
+ lossy RTE path) + `raise_val_err()` (fast-erroring
+ sibling).
+- `test_sibling_err_not_masked_by_startup_rte`
+ (parametrized `use_start_or_cancel`): asserts eg
+ contains exactly one `ValueError` and, wrapper-case,
+ NO residual RTE (`eg.split(ValueError)` remainder is
+ `None`); bare-case, the residual RTE carries trio's
+ exact "child exited without calling" wording.
+- `test_pure_oob_cancel_not_morphed_to_rte`
+ (parametrized): wrapper-case runs clean and asserts
+ `cs.cancelled_caught`; bare-case asserts the
+ eg-wrapped RTE.
+- `test_genuine_startup_rte_still_raised`
+ (parametrized): no-cancel protocol bug → RTE with
+ trio's wording from both call forms.
+- `test_childs_own_rte_never_demoted_to_cancel`
+ (parametrized `rte_arg` in `'never got started!'`,
+ `1234`): child cancels the ambient scope then raises
+ its own RTE synchronously (no checkpoint between →
+ deterministically under-cancellation at catch time);
+ asserts the RTE survives with `args[0]` intact.
+- `test_started_value_and_args_passthru`: `.started()`
+ value, positional args and the `name=` kwarg (via
+ `trio.lowlevel.current_task().name`) all forward.
+
+## Non-code output (verbatim highlights)
+
+Behaviour probe (trio 0.29, scratchpad scripts) — the
+decision basis for the test shapes:
+
+```
+== B-sibling-err use_soc=False
+ start raised: RuntimeError('child exited without
+ calling task_status.started()')
+ top-level: ExceptionGroup([ValueError('sibling blew
+ up!'), RuntimeError('child exited without calling
+ task_status.started()')])
+== B-cs-cancel use_soc=False
+ top-level: ExceptionGroup([RuntimeError('child
+ exited without calling task_status.started()')])
+== B-sibling-err use_soc=True
+ start raised: Cancelled()
+ top-level: ExceptionGroup([ValueError('sibling blew
+ up!')])
+== B-cs-cancel use_soc=True
+ start raised: Cancelled()
+ top-level: clean return
+== own-rte-under-cancel (both) -> RTE('never got
+ started!') propagates unchanged
+```
+
+Key finding: with a WELL-BEHAVED (non-absorbing) child
+an OOB ancestor cancel surfaces `Cancelled` directly
+from `.start()` on trio 0.29 — the lossy RTE requires
+the child to absorb its cancel pre-`.started()`, which
+is what `modden`'s `open_from_wks` teardown did. Trio's
+nursery-exit wait defers cancel delivery to children,
+so all tested shapes are deterministic (0 flakes / 50
+runs).
+
+Mutation verification:
+
+```
+mutation 1 (checkpoint_if_cancelled removed):
+ FAILED test_sibling_err_not_masked_by_startup_rte[True]
+ FAILED test_pure_oob_cancel_not_morphed_to_rte[True]
+mutation 2 (guard relaxed to 'started' substring,
+ isinstance dropped):
+ FAILED test_childs_own_rte_never_demoted_to_cancel[never got started!]
+ FAILED test_childs_own_rte_never_demoted_to_cancel[1234]
+```
+
+Final runs:
+
+```
+tests/trionics/test_taskc.py: 9 passed in 0.03s
+hammer: 0/50 runs failed
+tests/trionics/ + tests/test_trioisms.py:
+ 23 passed, 5 xfailed in 3.02s
+```
diff --git a/ai/prompt-io/claude/20260702T165806Z_a34aaf98_prompt_io.md b/ai/prompt-io/claude/20260702T165806Z_a34aaf98_prompt_io.md
new file mode 100644
index 000000000..2bec83879
--- /dev/null
+++ b/ai/prompt-io/claude/20260702T165806Z_a34aaf98_prompt_io.md
@@ -0,0 +1,78 @@
+---
+model: claude-fable-5
+service: claude
+session: f6c84722-471a-4458-9a80-e453fea9029f
+timestamp: 2026-07-02T16:58:06Z
+git_ref: a34aaf98
+scope: code
+substantive: true
+raw_file: 20260702T165806Z_a34aaf98_prompt_io.raw.md
+---
+
+## Prompt
+
+Follow-up round in the same session as the
+`tractor.to_actor` landing (see
+`20260702T154255Z_65bf9df5_prompt_io.md`). After
+committing that work the user green-lit the deferred
+items:
+
+> go go go on this with what time you have left, in
+> particular see if you can get the _ria_nursery
+> removal going!
+
+then extended the deadline twice to iterate on the
+discovered hang:
+
+> continue on this up until a 12:58:30 deadline
+
+and finally chose "Commit step A now" from the
+next-steps prompt.
+
+## Response summary
+
+Step A of the `._ria_nursery` removal (issue #477): hoist
+`.run_in_actor()` result-reaping out of the spawn
+backends into the `ActorNursery` machinery so ria
+children spawn via the default daemon nursery,
+
+- new `_supervise._reap_ria_portals()` runs one
+ `_spawn.cancel_on_completion()` task per ria child
+ AFTER `._join_procs` is set; happy path awaits it
+ right after `._join_procs.set()`.
+- error path SEQUENCES: snapshot ria
+ `(portal, subactor)` pairs -> `await an.cancel()` ->
+ 0.5s-bounded reap. Two failed intermediates informed
+ this: a concurrent reap+cancel DEADLOCKED
+ `test_multierror`; a 3s bound blew
+ `test_cancel_while_childs_child_in_sync_sleep`'s
+ `fail_after` deadline.
+- backends (`spawn/_trio.py`, `spawn/_mp.py`) lose the
+ `._cancel_after_result_on_exit` membership branch,
+ per-child reaper nursery + dead imports.
+- design/probe-history doc:
+ `ai/conc-anal/ria_nursery_removal_plan.md` (from an
+ agent-verified machinery map).
+
+Verification: `test_cancellation.py` fully green
+(20 passed, 1 xfailed) incl. the previously-hung
+`test_multierror`; `test_to_actor`+`test_spawning`
+20/20; bounded full-suite gate SIGINT'd ~30s early at
+303 passed / 0 failures (user opted to commit on that
+signal, deferring the unbounded re-run to step-B
+verification).
+
+## Files changed
+
+- `tractor/runtime/_supervise.py` — `_reap_ria_portals()`
+ + two call-sites; `run_in_actor()` off the ria nursery
+- `tractor/spawn/_trio.py` — reaper branch + import drop
+- `tractor/spawn/_mp.py` — same as `_trio.py`
+- `ai/conc-anal/ria_nursery_removal_plan.md` — plan +
+ probe history
+
+## Human edits
+
+None yet — committed via the drafted
+`.claude/git_commit_msg_ria_step_a.md` (user-driven
+`git commit --edit`).
diff --git a/ai/prompt-io/claude/20260702T165806Z_a34aaf98_prompt_io.raw.md b/ai/prompt-io/claude/20260702T165806Z_a34aaf98_prompt_io.raw.md
new file mode 100644
index 000000000..172e29754
--- /dev/null
+++ b/ai/prompt-io/claude/20260702T165806Z_a34aaf98_prompt_io.raw.md
@@ -0,0 +1,55 @@
+---
+model: claude-fable-5
+service: claude
+timestamp: 2026-07-02T16:58:06Z
+git_ref: a34aaf98
+diff_cmd: git diff a34aaf98..wkt/to_actor_subpkg
+---
+
+# Raw AI output (diff-ref mode)
+
+Step-A code is committed on `wkt/to_actor_subpkg`
+directly after `a34aaf98`; per diff-ref mode the verbatim
+content is reachable via the pointers below.
+
+## Generated files
+
+> `git diff a34aaf98..wkt/to_actor_subpkg -- tractor/runtime/_supervise.py`
+
+New `_reap_ria_portals(an, errors, ria_children=None)`
+helper (one `_spawn.cancel_on_completion()` task per ria
+child under `collapse_eg()` + a local nursery);
+`run_in_actor()` drops `nursery=self._ria_nursery`; happy
+path awaits the reap right after `._join_procs.set()`;
+inner error handler snapshots ria pairs, runs
+`await an.cancel()` then a `move_on_after(0.5)`-bounded
+reap over the snapshot.
+
+> `git diff a34aaf98..wkt/to_actor_subpkg -- tractor/spawn/_trio.py`
+> `git diff a34aaf98..wkt/to_actor_subpkg -- tractor/spawn/_mp.py`
+
+Both backends: the post-`_join_procs` block collapses to
+a bare `soft_kill()` (membership branch, per-child reaper
+nursery, reaper-cancel logging and the now-unused
+`cancel_on_completion` imports all removed).
+
+> `git diff a34aaf98..wkt/to_actor_subpkg -- ai/conc-anal/ria_nursery_removal_plan.md`
+
+Agent-verified machinery map, 3-step design (A/B/C),
+probe history (deadlock -> sequencing fix -> bound
+tightening) and risk register.
+
+## Test runs (verbatim)
+
+```
+tests/test_cancellation.py: 20 passed, 1 xfailed in 77.28s
+tests/test_to_actor.py + tests/test_spawning.py: 20 passed
+full-suite (SIGINT at 540s bound, ~30s short):
+303 passed, 9 skipped, 1 xfailed, 2 xpassed in 539.42s
+```
+
+Failed intermediates (not in final diff): concurrent
+reap+`an.cancel()` deadlocked `test_multierror`; a 3s
+reap bound blew
+`test_cancel_while_childs_child_in_sync_sleep`'s
+`fail_after` deadline.
diff --git a/ai/prompt-io/claude/20260702T172233Z_5cd190c5_prompt_io.md b/ai/prompt-io/claude/20260702T172233Z_5cd190c5_prompt_io.md
new file mode 100644
index 000000000..ba1eefe93
--- /dev/null
+++ b/ai/prompt-io/claude/20260702T172233Z_5cd190c5_prompt_io.md
@@ -0,0 +1,60 @@
+---
+model: claude-opus-4-8
+service: claude
+session: c7860302-6a33-4d92-821c-0807d68be464
+timestamp: 2026-07-02T17:22:33Z
+git_ref: 5cd190c5
+scope: code
+substantive: true
+raw_file: 20260702T172233Z_5cd190c5_prompt_io.raw.md
+---
+
+## Prompt
+
+Branched session (`to_actor_subpkg_POST_WEEKLY_CUTOFF_2OPUS`,
+now on Opus 4.8) continuing the `._ria_nursery` removal after
+step A committed + pushed (`5cd190c5`/`99310269`):
+
+> ok continue where your superior left off please
+
+then, after the commit-split discussion:
+
+> yup committed and pushed so take a look ma fren.
+
+i.e. green-lighting step-B prep against the committed step-A
+tree.
+
+## Response summary
+
+Step B of the `._ria_nursery` removal (issue #477): delete
+the now-vestigial secondary nursery,
+
+- collapse the inner `async with (collapse_eg(),
+ trio.open_nursery() as ria_nursery)` layer in
+ `_open_and_supervise_one_cancels_all_nursery` — `da_nursery`
+ becomes 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.
+- `._cancel_after_result_on_exit` kept (ria-child
+ discriminator for `_reap_ria_portals()`).
+
+Verified behavior-preserving via a first-principles argument
+(zero-task nursery = a bare checkpoint) + the targeted gate
+(`test_cancellation test_spawning test_local test_rpc
+test_to_actor` = 49 passed, 1 xfailed on trio). The two
+error handlers were deliberately NOT merged — that changes
+propagation semantics and is deferred to its own PR (TODO
+left at the outer `except`).
+
+## Files changed
+
+- `tractor/runtime/_supervise.py` — collapse the ria nursery
+ layer + drop the ctor/`start_actor` params + refresh the
+ now-stale nursery comments
+
+## Human edits
+
+None yet — committed via the drafted
+`.claude/git_commit_msg_ria_step_b.md` (user-driven
+`git commit --edit`).
diff --git a/ai/prompt-io/claude/20260702T172233Z_5cd190c5_prompt_io.raw.md b/ai/prompt-io/claude/20260702T172233Z_5cd190c5_prompt_io.raw.md
new file mode 100644
index 000000000..71200fecb
--- /dev/null
+++ b/ai/prompt-io/claude/20260702T172233Z_5cd190c5_prompt_io.raw.md
@@ -0,0 +1,51 @@
+---
+model: claude-opus-4-8
+service: claude
+timestamp: 2026-07-02T17:22:33Z
+git_ref: 5cd190c5
+diff_cmd: git diff 5cd190c5..wkt/to_actor_subpkg
+---
+
+# Raw AI output (diff-ref mode)
+
+Step-B code lives on `wkt/to_actor_subpkg` after `5cd190c5`;
+per diff-ref mode the verbatim content is reachable via the
+pointer below.
+
+## Generated files
+
+> `git diff 5cd190c5..wkt/to_actor_subpkg -- tractor/runtime/_supervise.py`
+
+- `ActorNursery.__init__`: `ria_nursery` param removed;
+ `self._ria_nursery = ria_nursery` block deleted;
+ `_cancel_after_result_on_exit` comment refreshed.
+- `start_actor()`: `nursery=` param removed; body uses
+ `self._da_nursery.start(...)` directly.
+- `_open_and_supervise_one_cancels_all_nursery()`: the inner
+ `async with (collapse_eg(), trio.open_nursery() as
+ ria_nursery)` layer removed; `an = ActorNursery(actor,
+ da_nursery, errors)` constructed once under the single
+ `da_nursery`; the inner-try body de-indented one level;
+ both error handlers retained; the da-nursery lead comment
+ and the outer-`except` TODO refreshed to describe the
+ single-nursery reality + flag the (deferred) handler-merge.
+
+> `git diff 5cd190c5..wkt/to_actor_subpkg -- ai/conc-anal/ria_nursery_removal_plan.md`
+
+Added a "Step-B outcome" section (collapse rationale,
+handler-merge deferral, safety argument, gate result).
+
+## Test runs (verbatim)
+
+```
+targeted gate (trio):
+tests/test_cancellation.py tests/test_spawning.py
+tests/test_local.py tests/test_rpc.py tests/test_to_actor.py
+-> 49 passed, 1 xfailed in 88.62s
+
+signature checks:
+ActorNursery.__init__ params: ['self', 'actor', 'da_nursery', 'errors']
+start_actor has nursery param: False
+
+full-suite gate: in progress (see stepB_full.log)
+```
diff --git a/ai/prompt-io/claude/20260702T222544Z_9201a2ed_prompt_io.md b/ai/prompt-io/claude/20260702T222544Z_9201a2ed_prompt_io.md
new file mode 100644
index 000000000..bf7b08cb8
--- /dev/null
+++ b/ai/prompt-io/claude/20260702T222544Z_9201a2ed_prompt_io.md
@@ -0,0 +1,68 @@
+---
+model: claude-opus-4-8
+service: claude
+session: c7860302-6a33-4d92-821c-0807d68be464
+timestamp: 2026-07-02T22:25:44Z
+git_ref: 9201a2ed
+scope: code
+substantive: true
+raw_file: 20260702T222544Z_9201a2ed_prompt_io.raw.md
+---
+
+## Prompt
+
+Continuing on branch `drop_ria_nursery` after step B
+(`9201a2ed`). User green-lit the deferred error-handler merge:
+
+> yes, i checked us out on a new branch for the ria-tn removal;
+> work off the new wkt branch drop_ria_nursery.
+
+and (re the migration) "nope, later we'll do this", plus:
+
+> do ensure the test suite is green in the current state and
+> those going forward ya?
+
+and the standing reminder: no commits without a human ack.
+
+## Response summary
+
+Step B2 of the `._ria_nursery` removal (issue #477): merge the
+two nested error handlers in
+`_open_and_supervise_one_cancels_all_nursery` into one, now
+that the 2ndary nursery (whose `__aexit__` the outer handler
+originally guarded) is gone.
+
+Trace-driven justification (see
+`ai/conc-anal/ria_nursery_removal_plan.md` Step-B2 section):
+the old inner handler records `errors[uid]` as its FIRST
+action, so `errors` is always non-empty by the time control
+could reach the outer handler — making the outer handler's
+`raise` DEAD (always superseded by the `finally`'s
+raise-from-`errors`). Collapsing to a single
+`except BaseException as _scope_err` + the existing `finally`
+is therefore behavior-preserving.
+
+- removed the outer `try` + the
+ `except (Exception, BaseExceptionGroup, trio.Cancelled)`
+ handler; de-indented the inner handler one level.
+- dropped the now-unused `outer_err`/`inner_err` locals
+ (single `_scope_err`).
+- net ~30 lines lighter.
+
+Gated hard: baseline (pre-B2 tip) = 300 passed + 1
+load-induced `TooSlowError` flake (passes 6/6 in isolation);
+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; B2
+full-suite run following.
+
+## Files changed
+
+- `tractor/runtime/_supervise.py` — collapse the two handlers
+ into one; drop `outer_err`/`inner_err`
+
+## Human edits
+
+None yet — committed via the drafted
+`.claude/git_commit_msg_ria_b2.md` (user-driven
+`git commit --edit`).
diff --git a/ai/prompt-io/claude/20260702T222544Z_9201a2ed_prompt_io.raw.md b/ai/prompt-io/claude/20260702T222544Z_9201a2ed_prompt_io.raw.md
new file mode 100644
index 000000000..7220fb4fd
--- /dev/null
+++ b/ai/prompt-io/claude/20260702T222544Z_9201a2ed_prompt_io.raw.md
@@ -0,0 +1,55 @@
+---
+model: claude-opus-4-8
+service: claude
+timestamp: 2026-07-02T22:25:44Z
+git_ref: 9201a2ed
+diff_cmd: git diff 9201a2ed..drop_ria_nursery
+---
+
+# Raw AI output (diff-ref mode)
+
+Step-B2 code lives on `drop_ria_nursery` after `9201a2ed`; per
+diff-ref mode the verbatim content is reachable via the pointer
+below.
+
+## Generated files
+
+> `git diff 9201a2ed..drop_ria_nursery -- tractor/runtime/_supervise.py`
+
+`_open_and_supervise_one_cancels_all_nursery`:
+- removed the outer `try:` wrapper and the
+ `except (Exception, BaseExceptionGroup, trio.Cancelled) as
+ _outer_err:` safety-net handler.
+- the former inner `except BaseException` is now THE handler,
+ renamed local `_inner_err` -> `_scope_err`, de-indented one
+ level; it sets `an._scope_error`, records `errors[uid]`,
+ waits on the debugger, `_join_procs.set()`, then a shielded
+ classify/log + snapshot-ria + `an.cancel()` + 0.5s-bounded
+ `_reap_ria_portals()`. No re-raise (the `finally` raises
+ from `errors`).
+- `finally` block unchanged.
+- dropped the `outer_err`/`inner_err` local decls at fn top.
+
+(The diff is large — ~119+/149- — because de-indenting the
+handler body one level rewrites every line in the block; the
+logic delta is just "two handlers -> one".)
+
+## Test runs (verbatim)
+
+```
+baseline (pre-B2, step-B tip 9201a2ed), full suite
+(dynamic_pub_sub deselected):
+1 failed, 300 passed, 9 skipped, 2 deselected, 1 xfailed,
+2 xpassed in 1499.49s
+ -> the 1 failure = test_ext_types_over_ipc[...] trio.TooSlowError
+ (load-induced; passes 6/6 in isolation in 4.89s)
+
+B2 error/cancel gate:
+tests/test_cancellation test_remote_exc_relay
+test_inter_peer_cancellation test_advanced_faults
+test_oob_cancellation test_to_actor test_spawning test_local
+test_rpc
+-> 71 passed, 1 xfailed in 125.26s
+
+B2 full-suite run: see b2_full.log
+```
diff --git a/ai/prompt-io/claude/20260706T172818Z_ad42871e_prompt_io.md b/ai/prompt-io/claude/20260706T172818Z_ad42871e_prompt_io.md
new file mode 100644
index 000000000..bb355012d
--- /dev/null
+++ b/ai/prompt-io/claude/20260706T172818Z_ad42871e_prompt_io.md
@@ -0,0 +1,81 @@
+---
+model: claude-fable-5
+service: claude
+session: 6db64ac6-6986-4505-9343-df4ee31e67db
+timestamp: 2026-07-06T17:28:18Z
+git_ref: ad42871e
+scope: code
+substantive: true
+raw_file: 20260706T172818Z_ad42871e_prompt_io.raw.md
+---
+
+## Prompt
+
+Resuming the `drop_ria_nursery` (wkt) branch after the human's
+review pause at `ba4af0ad`, with the directive:
+
+> we need to resolve the remaining outstandings ... to finish
+> this endeavour we ideally,
+> - finish the ria-tn hoist into the machinery in `to_actor` if
+> possible.
+> - avoid supporting the `.run_in_actor()` non-blocking API
+> going forward other then if we can re-wrap `to_actor.run()`
+> per prior discussion with opus4.8.
+> - get the test harness completely green locally and in CI -
+> AND ESPECIALLY without any hangs due to the "the old
+> spawn-backend reaper raced `soft_kill()`'s scope-cancel"
+> - update all docs to reflect these changes once we're happy.
+
+plus mid-flight additions:
+
+> make sure both all the test and example code uses the naming
+> `an: ActorNursery` for variable names
+
+> where we at in terms of a potential hackaround design for
+> supporting a to_actor.run_async() ... without requiring the
+> same malarky ... with a `trio.Event` relay ... i'm totes fine
+> with rejigging the iface to NOT return a Portal as well.
+
+## Response summary
+
+Completed the entire #477 `run_in_actor` removal on
+`drop_ria_nursery` (16 commits this session), gated per-commit:
+
+- ported the remaining `test_cancellation` groups
+ (`some_cancels_all`, `nested_multierrors`+`spawn_and_error`,
+ SIGINT + sync-sleep) — one-shots become concurrent
+ `to_actor.run()` tasks in local task-nurseries; BEG-shape
+ assertions loosened for cancel-on-first + the runtime's
+ `collapse_eg()` single-member unwrap (a fully-raced nested
+ tree relays a bare annotated `RemoteActorError` chain).
+- fixed a pre-existing `UnboundLocalError` (`timeout` `match`
+ had no default arm for non-trio/MTF backends).
+- ported `test_dynamic_pub_sub`, 4 non-debugging examples, all
+ 8 `debugging/` examples (debugger suite byte-identical,
+ 28p/6s; `multi_subactors` introduces the "collect don't
+ cancel" reap-all replacement pattern), 8 docs pages + the
+ `experimental/_pubsub` docstring.
+- EXCISED the API + cluster: `run_in_actor`,
+ `_reap_ria_portals`, `_cancel_after_result_on_exit`,
+ `Portal._submit_for_result/_expect_result_ctx/
+ wait_for_result/result`, `exhaust_portal`,
+ `cancel_on_completion`, `NoResult` — net -402 lines. The
+ reap-hang class dissolves structurally (result-waits now only
+ in caller task-scope).
+- found + fixed a real migration race: mutual-rendezvous peers
+ (`test_trynamic_trio`, `a_trynamic_first_scene.py`) flaked
+ because an eagerly-reaped one-shot dies while its peer still
+ dials the registry-resolved (dead) sockaddr — such peers now
+ pin lifetimes via `start_actor()` + concurrent `Portal.run()`
+ + explicit `an.cancel()`.
+- `an: ActorNursery` naming sweep across tests/examples (±82
+ lines, scoped renames, prose untouched).
+- parked a `to_actor.open_one_shot()` design sketch (acm +
+ private task-nursery over blocking `run()`; done-Event as
+ memo not cancel-relay; no Portal) in the plan doc.
+
+## Files changed
+
+See commits `d01a2123..ad42871e` on `drop_ria_nursery`
+(tests, examples, docs, `tractor/{runtime,spawn,to_actor,msg}`
++ `_exceptions/_context/experimental`).
diff --git a/ai/prompt-io/claude/20260706T172818Z_ad42871e_prompt_io.raw.md b/ai/prompt-io/claude/20260706T172818Z_ad42871e_prompt_io.raw.md
new file mode 100644
index 000000000..b6aa9d73f
--- /dev/null
+++ b/ai/prompt-io/claude/20260706T172818Z_ad42871e_prompt_io.raw.md
@@ -0,0 +1,39 @@
+---
+model: claude-fable-5
+service: claude
+timestamp: 2026-07-06T17:28:18Z
+git_ref: ad42871e
+diff_cmd: git diff ba4af0ad..ad42871e
+---
+
+# Raw AI output (diff-ref mode)
+
+This session's output spans the 16 migration/excision commits
+`d01a2123..ad42871e` on `drop_ria_nursery`; per diff-ref mode
+the verbatim content is reachable via the pointer below.
+
+## Generated files
+
+> `git diff ba4af0ad..ad42871e`
+
+Commit-wise (each `Gate:`-footed msg documents its own module
+gate):
+
+- `d01a2123` port `test_some_cancels_all`
+- `697c6152` fix unbound `timeout` (non-trio/MTF `match` arm)
+- `fa8799d5` port `test_nested_multierrors`
+- `f11754ce` port SIGINT + sync-sleep cancel tests
+- `cb6202e3` port `test_dynamic_pub_sub`
+- `d8af5f12` port non-debugging examples
+- `a3057cb2` port debugging examples (+ `test_debugger`
+ nested-nurseries final-shape expectations)
+- `d6bed7c4` port docs (8 rst pages)
+- `07e1669e` fix stale `@pub` docstring example
+- `2a59cefb` REMOVE `run_in_actor()` + the ria reap cluster
+ (net -402 lines)
+- `a297a32a` fix mutual-rendezvous premature-reap race
+- `ad42871e` `an: ActorNursery` naming sweep
+
+Plan/design record updated in
+`ai/conc-anal/ria_nursery_removal_plan.md` (RESOLVED section +
+the `to_actor.open_one_shot()` follow-up sketch).
diff --git a/ai/prompt-io/opencode/20260804T030309Z_65bf9df5_prompt_io.md b/ai/prompt-io/opencode/20260804T030309Z_65bf9df5_prompt_io.md
new file mode 100644
index 000000000..66dd0d158
--- /dev/null
+++ b/ai/prompt-io/opencode/20260804T030309Z_65bf9df5_prompt_io.md
@@ -0,0 +1,39 @@
+---
+model: openai/gpt-5.6-sol
+service: opencode
+session: moc-teardown-completion-20260804
+timestamp: 2026-08-04T03:03:09Z
+git_ref: 65bf9df5
+scope: code
+substantive: true
+raw_file: 20260804T030309Z_65bf9df5_prompt_io.raw.md
+---
+
+## Prompt
+
+Patch Tractor's `maybe_open_context()` so the final consumer waits for
+resource `__aexit__()` completion and receives cleanup errors. Reuse
+`outcome.Outcome` for the exit result; use the smaller mutable
+`_CtxExit` holder if that makes the implementation simpler. Add and run
+the relevant existing unit tests, but do not commit or push the patch.
+After reviewing the result, simplify `_CtxExit` back to an optional
+exception because the success outcome carries no useful value.
+
+## Response summary
+
+Added an exception-backed completion handshake between
+`_Cache.run_ctx()` and the final `maybe_open_context()` consumer.
+Serialized consumer registration and final teardown under the per-key
+lock, preserving that lock for queued entrants. Added deterministic
+regressions for normal exit, cleanup errors, cancellation interactions,
+service-nursery cancellation, and teardown re-entry.
+
+## Files changed
+
+- `tractor/trionics/_mngrs.py` - publish and unwrap cached exit outcomes.
+- `tests/test_resource_cache.py` - cover completion and cancellation.
+
+## Human edits
+
+The user directed the final simplification from `outcome.Outcome` to an
+optional exception field. The patch remains uncommitted.
diff --git a/ai/prompt-io/opencode/20260804T030309Z_65bf9df5_prompt_io.raw.md b/ai/prompt-io/opencode/20260804T030309Z_65bf9df5_prompt_io.raw.md
new file mode 100644
index 000000000..5cf92f5c2
--- /dev/null
+++ b/ai/prompt-io/opencode/20260804T030309Z_65bf9df5_prompt_io.raw.md
@@ -0,0 +1,36 @@
+---
+model: openai/gpt-5.6-sol
+service: opencode
+timestamp: 2026-08-04T03:03:09Z
+git_ref: 65bf9df5
+diff_cmd: git diff HEAD~1..HEAD
+---
+
+Implemented cached-context exit completion in
+`tractor.trionics.maybe_open_context()`.
+
+> `git diff HEAD~1..HEAD -- tractor/trionics/_mngrs.py`
+
+The generated implementation adds `_CtxExit`, whose `done` event
+publishes an `outcome.Outcome[None]`. `_Cache.run_ctx()` records either
+`Value(None)` or `Error(exc)` after the resource exit attempt. The final
+MOC consumer signals `no_more_users`, waits for completion under a
+shielded cancel scope, removes the per-key lock, and unwraps the outcome
+so ordinary cleanup failures are raised at the consumer boundary.
+`trio.Cancelled`, `KeyboardInterrupt`, and `SystemExit` continue through
+the service task rather than being converted into regular cleanup
+errors.
+
+> `git diff HEAD~1..HEAD -- tests/test_resource_cache.py`
+
+The generated regressions cover successful exit blocking, cleanup-error
+delivery, final-user cancellation, cancellation combined with a cleanup
+error, and service-nursery cancellation. The existing teardown re-entry
+test now uses explicit events instead of a ten-second cleanup sleep and
+asserts that the replacement resource is a fresh cache miss.
+
+Verification:
+
+`env PYTHONPATH="$PWD" /home/goodboy/repos/tractor/py313/bin/python -m pytest tests/test_resource_cache.py`
+
+Result: `16 passed in 8.37s`.
diff --git a/ai/prompt-io/opencode/README.md b/ai/prompt-io/opencode/README.md
new file mode 100644
index 000000000..9bb17a914
--- /dev/null
+++ b/ai/prompt-io/opencode/README.md
@@ -0,0 +1,22 @@
+# AI Prompt I/O Log - OpenCode
+
+This directory tracks prompt inputs and model outputs for AI-assisted
+development using `opencode`.
+
+## Policy
+
+Prompt logging follows the [NLNet generative AI policy][nlnet-ai]. All
+substantive AI contributions are logged with:
+
+- Model name and version
+- Timestamps
+- The prompts that produced the output
+- Unedited model output (`.raw.md` files)
+
+[nlnet-ai]: https://nlnet.nl/foundation/policies/generativeAI/
+
+## Usage
+
+Entries are created by the prompt-io workflow. Human contributors remain
+accountable for all decisions. AI-generated content is never presented as
+human-authored work.
diff --git a/ai/prompt-io/prompts/issue_477.md b/ai/prompt-io/prompts/issue_477.md
new file mode 100644
index 000000000..3949e81dd
--- /dev/null
+++ b/ai/prompt-io/prompts/issue_477.md
@@ -0,0 +1,7 @@
+NOTE: you MUST pause this work at 12:50PM EST (BEFORE your weekly
+limit reset) for review by a human!
+
+---
+
+attempt to resolve https://github.com/goodboy/tractor/issues/477
+do it with /open-wkt.
diff --git a/docs/api/core.rst b/docs/api/core.rst
index 196da2d05..e57ba5c79 100644
--- a/docs/api/core.rst
+++ b/docs/api/core.rst
@@ -37,7 +37,6 @@ Spawning actors
.. autoclass:: ActorNursery
:members: start_actor,
- run_in_actor,
cancel,
cancel_called,
cancelled_caught
@@ -47,10 +46,22 @@ Spawning actors
:meth:`ActorNursery.start_actor` (daemon actor + portal) is the
blessed spawning primitive; pair it with
``Portal.open_context()`` for SC-linked remote tasks.
- :meth:`ActorNursery.run_in_actor` is a *convenience* one-shot —
- spawn, run a single task, auto-cancel after the result — slated
- to be rebuilt as a high-level wrapper, so don't design around
- it as the core model.
+
+One-shot task actors
+--------------------
+
+.. autofunction:: tractor.to_actor.run
+
+.. note::
+
+ :func:`tractor.to_actor.run` (parlance of
+ ``trio.to_thread.run_sync()`` and friends) is the
+ *convenience* one-shot — spawn, run a single task, block on
+ its result, reap — built entirely on
+ :meth:`ActorNursery.start_actor` + :meth:`Portal.run` +
+ :meth:`Portal.cancel_actor`, so don't design around it as the
+ core model. It supersedes the removed (legacy, non-blocking)
+ ``ActorNursery.run_in_actor()``.
.. deprecated:: 0.1.0a6
@@ -71,14 +82,12 @@ flowing back `exactly like trio`_.
:members: run,
run_from_ns,
open_stream_from,
- wait_for_result,
cancel_actor,
chan
.. deprecated:: 0.1.0a6
- ``Portal.result()`` warns; use :meth:`Portal.wait_for_result`.
- The str-form ``Portal.run('mod.path', 'fn_name')`` also warns;
+ The str-form ``Portal.run('mod.path', 'fn_name')`` warns;
pass a function *object* whose module is listed in the target's
``enable_modules``. ``Portal.channel`` is the legacy spelling
of :attr:`Portal.chan`.
diff --git a/docs/guide/asyncio.rst b/docs/guide/asyncio.rst
index 27e1d05fd..767983567 100644
--- a/docs/guide/asyncio.rst
+++ b/docs/guide/asyncio.rst
@@ -76,8 +76,8 @@ Just flip the flag on :meth:`tractor.ActorNursery.start_actor`:
infect_asyncio=True,
)
-The one-shot convenience ``ActorNursery.run_in_actor()`` accepts
-the same flag. The ``to_asyncio`` APIs may **only** be called from
+The one-shot convenience ``tractor.to_actor.run()`` accepts the
+same flag. The ``to_asyncio`` APIs may **only** be called from
tasks inside an infected actor; calling them anywhere else raises
a loud ``RuntimeError``. You can introspect at runtime with
``tractor.current_actor().is_infected_aio()``.
@@ -229,7 +229,7 @@ dialog, skip the channel ceremony and use
It schedules the fn as an ``asyncio.Task``, waits for completion
and hands the return value back to ``trio``; think of it as the
-cross-loop sibling of ``ActorNursery.run_in_actor()``. Errors and
+cross-loop sibling of ``tractor.to_actor.run()``. Errors and
cancellation are translated exactly as for channels.
Cross-loop errors and cancellation
diff --git a/docs/guide/cancellation.rst b/docs/guide/cancellation.rst
index 56f0d6d12..3b97b380f 100644
--- a/docs/guide/cancellation.rst
+++ b/docs/guide/cancellation.rst
@@ -64,11 +64,13 @@ What's going on here?
- three healthy actors are spawned as daemons via
:meth:`tractor.ActorNursery.start_actor`; left alone they'd
happily idle forever,
-- a fourth actor runs ``assert_err()`` via ``.run_in_actor()`` and
- promptly trips its ``assert 0``,
+- a fourth actor runs ``assert_err()`` via a blocking
+ ``tractor.to_actor.run()`` one-shot and promptly trips its
+ ``assert 0``,
- the resulting ``AssertionError`` ships back over IPC as a
- serialized error msg and re-raises *boxed* inside the nursery
- block as a :class:`tractor.RemoteActorError`,
+ serialized error msg and re-raises *boxed* right at the call
+ inside the nursery block as a
+ :class:`tractor.RemoteActorError`,
- the nursery reacts like any ``trio`` nursery would: it cancels
the three healthy siblings (graceful runtime-cancel requests,
acks awaited), reaps all four processes, then re-raises,
diff --git a/docs/guide/context.rst b/docs/guide/context.rst
index 15ffc00da..23dce9aa3 100644
--- a/docs/guide/context.rst
+++ b/docs/guide/context.rst
@@ -15,8 +15,8 @@ a single `structured concurrency`_ (SC) scope over IPC.
:alt: sequence diagram of the context handshake msg flow
Pretty much everything else is (or is slated to be) built on this
-one primitive: ``ActorNursery.run_in_actor()`` is a convenience
-for "spawn, open a context, await the result, tear down"; plain
+one primitive: ``tractor.to_actor.run()`` is a convenience for
+"spawn, run the lone task, await the result, tear down"; plain
``Portal.run()`` RPC is planned to be re-implemented on top of it;
the multi-process debugger's tree-wide REPL lock rides one. Grok
this page and the rest of the library reads as convenience
diff --git a/docs/guide/parallelism.rst b/docs/guide/parallelism.rst
index 7a6dc1c23..e688a26b3 100644
--- a/docs/guide/parallelism.rst
+++ b/docs/guide/parallelism.rst
@@ -119,15 +119,16 @@ Run a func in a process
Even a pool can be overkill; "run this one async func in a
subprocess and give me the result" is a one-liner via
-:meth:`tractor.ActorNursery.run_in_actor`,
+:func:`tractor.to_actor.run`,
.. literalinclude:: ../../examples/parallelism/single_func.py
:caption: examples/parallelism/single_func.py
:language: python
-``run_in_actor()`` is a *convenience wrapper* — spawn an actor, run
-exactly one task in it, reap on result — not the core spawning
-model (that's :meth:`tractor.ActorNursery.start_actor` plus
+``to_actor.run()`` is a *convenience wrapper* — spawn an actor,
+run exactly one task in it, block on and return its result, reap
+— not the core spawning model (that's
+:meth:`tractor.ActorNursery.start_actor` plus
:meth:`tractor.Portal.open_context`; see :doc:`/guide/context`).
But for this fire-and-collect shape it's exactly the right amount
of typing.
diff --git a/docs/guide/rpc.rst b/docs/guide/rpc.rst
index 6c5d8fa00..29cef43c0 100644
--- a/docs/guide/rpc.rst
+++ b/docs/guide/rpc.rst
@@ -80,28 +80,30 @@ One special namespace exists: ``'self'`` resolves to the remote
how internal machinery (cancel requests, registry ops) travels;
don't build your app on it.
-One-shot results: ``wait_for_result()``
----------------------------------------
-A portal returned from
-:meth:`~tractor.ActorNursery.run_in_actor` has exactly one
-"main" task running remotely; that task's ``return`` value is
-delivered as the portal's *final result*:
+One-shot subactors: ``to_actor.run()``
+--------------------------------------
+When a subactor's *entire job* is a single function call, skip
+the portal plumbing with :func:`tractor.to_actor.run`: spawn,
+run the lone task, return its result and reap the process — all
+in one blocking call:
.. code:: python
- portal = await an.run_in_actor(fib, n=10)
- final = await portal.wait_for_result()
+ final = await tractor.to_actor.run(fib, an=an, n=10)
Semantics worth knowing:
- it blocks until the remote task returns, re-raising any
- remote error in the usual boxed form.
-- once resolved it's idempotent: later calls return the same
- cached value.
-- a *daemon* portal (from ``start_actor()``) has no main task,
- so there's no final result to wait for: you'll get a warning
- plus a ``NoResult`` sentinel. Results of individual daemon
- calls come straight back from each ``await portal.run()``.
+ remote error in the usual boxed form right in the calling
+ task.
+- "placement" is composable: ``an=`` spawns from an existing
+ actor-nursery, ``portal=`` reuses an already-running actor
+ (no spawn/reap, just a ``Portal.run()``), and passing
+ neither opens a private call-scoped nursery (booting the
+ runtime if needed).
+- concurrency composes the plain ``trio`` way: schedule
+ multiple ``run()`` calls into a local task nursery (see
+ ``examples/parallelism/to_actor_one_shots.py``).
Pure RPC daemons: ``run_daemon()``
----------------------------------
diff --git a/docs/guide/spawning.rst b/docs/guide/spawning.rst
index f1bbf26f9..8f01bc1a5 100644
--- a/docs/guide/spawning.rst
+++ b/docs/guide/spawning.rst
@@ -103,19 +103,22 @@ What's going on here?
on him **forever**. Daemon lifetimes are *yours* to end; that
explicitness is the point.
-``run_in_actor()``: quick one-shot parallelism
+``to_actor.run()``: quick one-shot parallelism
----------------------------------------------
-:meth:`~tractor.ActorNursery.run_in_actor` is the convenience
-wrapper: spawn an actor, run exactly one async function in it,
-then reap the process as soon as the result arrives.
+:func:`tractor.to_actor.run` is the convenience wrapper: spawn
+an actor, run exactly one async function in it, block on the
+result, then reap the process — the distributed sibling of
+``trio.to_thread.run_sync()``.
.. code:: python
- async with tractor.open_nursery() as an:
- portal = await an.run_in_actor(burn_cpu)
+ async with (
+ tractor.open_nursery() as an,
+ trio.open_nursery() as tn,
+ ):
# burn rubber in the parent too...
- await burn_cpu()
- total = await portal.wait_for_result()
+ tn.start_soon(burn_cpu)
+ total = await tractor.to_actor.run(burn_cpu, an=an)
A few details worth knowing:
@@ -124,18 +127,21 @@ A few details worth knowing:
- the function's module is auto-added to the child's
``enable_modules`` allowlist.
- extra ``**kwargs`` are forwarded to the function itself.
-- the child is *auto-cancelled* once its "main" result lands;
- at nursery exit these run-once children are always reaped
- first (causality_ is paramount!).
+- the call blocks until the result (or error) lands and the
+ child is *auto-cancelled* (reaped) right after — so remote
+ errors raise directly in your calling task (causality_ is
+ paramount!).
+- "placement" composes: ``an=`` spawns from a caller-managed
+ actor-nursery, ``portal=`` reuses an already-running actor
+ (no spawn/reap), and passing neither opens a private
+ call-scoped nursery (booting the runtime if needed).
.. note::
- ``run_in_actor()`` is a convenience, **not** the core model.
- The source literally marks it for an eventual rebuild as
- a thin "hilevel" wrapper on top of
- :meth:`~tractor.Portal.open_context` (the modern inter-actor
- task API). Teach your fingers to use it for quick
- fire-and-collect parallelism — think a per-function
+ ``to_actor.run()`` is a convenience, **not** the core model —
+ it's built *entirely* on ``start_actor()`` + ``Portal.run()``
+ + ``Portal.cancel_actor()``. Teach your fingers to use it for
+ quick fire-and-collect parallelism — think a per-function
trio-parallel_ style one-shot — and reach for
``start_actor()`` + ``open_context()`` for anything
long-lived, stateful or streaming
@@ -145,9 +151,9 @@ Actor lifetimes and teardown order
----------------------------------
So we have two lifetime flavors:
-- **run-once** (``run_in_actor()``): lives exactly as long as
+- **one-shot** (``to_actor.run()``): lives exactly as long as
its single task; reaped the moment its result (or error)
- arrives.
+ arrives back in the (blocking) call.
- **daemon** (``start_actor()``): lives until *someone* cancels
it — an explicit ``await portal.cancel_actor()``, a bulk
``await an.cancel()``, or the one-cancels-all strategy kicking
@@ -155,11 +161,12 @@ So we have two lifetime flavors:
On a clean exit of the nursery block the teardown order is:
-1. the nursery waits on every run-once actor's final result;
- any errors from these are raised immediately so your code
- (acting as supervisor) gets first crack at handling them.
-2. then it waits on daemon actors — **indefinitely**. If you
- spawned a daemon, you own its lifetime.
+1. one-shot actors never make it to nursery exit: each is
+ reaped inside its own ``to_actor.run()`` call, any error
+ raising immediately in the calling task so your code
+ (acting as supervisor) gets first crack at handling it.
+2. the nursery then waits on daemon actors — **indefinitely**.
+ If you spawned a daemon, you own its lifetime.
When a child *is* cancelled, teardown is graceful-first per SC
discipline: the runtime sends an IPC cancel request and gives
diff --git a/docs/start/quickstart.rst b/docs/start/quickstart.rst
index fabf4d8ef..3f2e57106 100644
--- a/docs/start/quickstart.rst
+++ b/docs/start/quickstart.rst
@@ -43,24 +43,20 @@ Run it::
What's going on here?
- ``trio.run(main)`` starts the **root actor**; the ``tractor``
- runtime boots *implicitly* inside ``tractor.open_nursery()``
+ runtime boots *implicitly* inside ``tractor.to_actor.run()``
whenever it isn't already up. No special entrypoint, no
framework takeover - it's just a ``trio`` app,
- inside ``main()`` a *subactor* is spawned via
- ``ActorNursery.run_in_actor()`` and told to run exactly one
+ ``tractor.to_actor.run()`` and told to run exactly one
function: ``cellar_door()``,
-- you get back a ``Portal``: your handle for invoking tasks in
- the new process's (separate!) memory domain. We lean on it
- much harder in the next section,
- the subactor, *some_linguist*, boots a fresh ``trio.run()`` in
a **new process** and executes ``cellar_door()`` as its *main
task* (note the child proving it is *not* the root with
``tractor.is_root_process()``), then ships the return value
back over IPC,
-- the parent grabs that *final result* with
- ``await portal.wait_for_result()``, much like you'd expect
- from a "future" - except causality is preserved: the nursery
- block only exits once the child is *done*, dead, and reaped.
+- the call *blocks* until that final result arrives, then
+ returns it - causality is preserved: your task only proceeds
+ once the child is *done*, dead, and reaped.
.. margin:: Just need a worker pool?
@@ -71,19 +67,22 @@ What's going on here?
.. note::
- ``run_in_actor()`` is the *convenience* wrapper: one-shot
+ ``to_actor.run()`` (parlance of ``trio.to_thread`` and
+ friends) is the *convenience* wrapper: one-shot
spawn-run-reap semantics for when a subactor's entire job is
a single function call. The core primitives are
- ``ActorNursery.start_actor()`` (next up) paired with
+ ``ActorNursery.start_actor()`` (next up) — which hands you
+ a ``Portal``, your handle for invoking tasks in the new
+ process's (separate!) memory domain — paired with
``Portal.open_context()`` for full, SC-linked cross-actor
dialogs - see :doc:`/guide/context`.
Daemon actors and RPC
---------------------
-A ``run_in_actor()``-spawned actor terminates when its main task
-returns. But often you want long-lived *daemon* actors instead:
-spawned once, then serving (allowlisted) RPC requests until told
-otherwise. That's ``start_actor()``:
+A ``to_actor.run()`` one-shot subactor terminates when its lone
+task returns. But often you want long-lived *daemon* actors
+instead: spawned once, then serving (allowlisted) RPC requests
+until told otherwise. That's ``start_actor()``:
.. literalinclude:: ../../examples/actor_spawning_and_causality_with_daemon.py
:caption: examples/actor_spawning_and_causality_with_daemon.py
@@ -91,9 +90,9 @@ otherwise. That's ``start_actor()``:
Two lifetime rules to internalize:
-- a ``run_in_actor()`` actor lives exactly as long as its main
- task; the nursery waits for that function (and thus the
- process) to complete before unblocking,
+- a ``to_actor.run()`` one-shot actor lives exactly as long as
+ its lone task; the call blocks until that function (and thus
+ the process) completes,
- a ``start_actor()`` actor *lives forever* - an RPC daemon the
nursery will happily wait on **indefinitely** - until some
task explicitly cancels it via ``Portal.cancel_actor()`` (as
diff --git a/examples/a_trynamic_first_scene.py b/examples/a_trynamic_first_scene.py
index 05d61ba9a..27d01ff0f 100644
--- a/examples/a_trynamic_first_scene.py
+++ b/examples/a_trynamic_first_scene.py
@@ -21,23 +21,35 @@ async def main():
"""Main tractor entry point, the "master" process (for now
acts as the "director").
"""
- async with tractor.open_nursery() as n:
+ async with tractor.open_nursery() as an:
print("Alright... Action!")
- donny = await n.run_in_actor(
- say_hello,
- name='donny',
- # arguments are always named
- other_actor='gretchen',
- )
- gretchen = await n.run_in_actor(
- say_hello,
- name='gretchen',
- other_actor='donny',
- )
- print(await gretchen.wait_for_result())
- print(await donny.wait_for_result())
- print("CUTTTT CUUTT CUT!!! Donny!! You're supposed to say...")
+ # both actors wait on (then dial!) the *other*, so each
+ # must outlive both hellos: spawn as daemons, run the
+ # hellos concurrently, reap only once both complete.
+ portals: dict[str, tractor.Portal] = {
+ name: await an.start_actor(
+ name,
+ enable_modules=[__name__],
+ )
+ for name in ('donny', 'gretchen')
+ }
+
+ async def run_and_print(name: str, other_actor: str):
+ print(
+ await portals[name].run(
+ say_hello,
+ other_actor=other_actor,
+ )
+ )
+
+ async with trio.open_nursery() as tn:
+ tn.start_soon(run_and_print, 'donny', 'gretchen')
+ tn.start_soon(run_and_print, 'gretchen', 'donny')
+
+ await an.cancel()
+
+ print("CUTTTT CUUTT CUT!!! Donny!! You're supposed to say...")
if __name__ == '__main__':
diff --git a/examples/actor_spawning_and_causality.py b/examples/actor_spawning_and_causality.py
index 2232ae3ea..00dc645c6 100644
--- a/examples/actor_spawning_and_causality.py
+++ b/examples/actor_spawning_and_causality.py
@@ -10,17 +10,14 @@ async def cellar_door():
async def main():
"""The main ``tractor`` routine.
"""
- async with tractor.open_nursery() as n:
-
- portal = await n.run_in_actor(
+ # spawn a subactor, run ``cellar_door()`` as its lone task,
+ # block until its result arrives and the subactor is reaped.
+ print(
+ await tractor.to_actor.run(
cellar_door,
name='some_linguist',
)
-
- # The ``async with`` will unblock here since the 'some_linguist'
- # actor has completed its main task ``cellar_door``.
-
- print(await portal.wait_for_result())
+ )
if __name__ == '__main__':
diff --git a/examples/actor_spawning_and_causality_with_daemon.py b/examples/actor_spawning_and_causality_with_daemon.py
index b052871bf..2e6824c9e 100644
--- a/examples/actor_spawning_and_causality_with_daemon.py
+++ b/examples/actor_spawning_and_causality_with_daemon.py
@@ -12,9 +12,9 @@ async def movie_theatre_question():
async def main():
"""The main ``tractor`` routine.
"""
- async with tractor.open_nursery() as n:
+ async with tractor.open_nursery() as an:
- portal = await n.start_actor(
+ portal = await an.start_actor(
'frank',
# enable the actor to run funcs from this current module
enable_modules=[__name__],
diff --git a/examples/asynchronous_generators.py b/examples/asynchronous_generators.py
index b037b0850..237794a6b 100644
--- a/examples/asynchronous_generators.py
+++ b/examples/asynchronous_generators.py
@@ -15,9 +15,9 @@ async def stream_forever() -> AsyncIterator[int]:
async def main():
- async with tractor.open_nursery() as n:
+ async with tractor.open_nursery() as an:
- portal = await n.start_actor(
+ portal = await an.start_actor(
'donny',
enable_modules=[__name__],
)
diff --git a/examples/debugging/multi_nested_subactors_error_up_through_nurseries.py b/examples/debugging/multi_nested_subactors_error_up_through_nurseries.py
index 6cfce50f0..fe50d9a49 100644
--- a/examples/debugging/multi_nested_subactors_error_up_through_nurseries.py
+++ b/examples/debugging/multi_nested_subactors_error_up_through_nurseries.py
@@ -1,3 +1,5 @@
+from functools import partial
+
import trio
import tractor
@@ -21,25 +23,36 @@ async def breakpoint_forever():
async def spawn_until(depth=0):
""""A nested nursery that triggers another ``NameError``.
"""
- async with tractor.open_nursery() as n:
+ async with (
+ tractor.open_nursery() as an,
+ trio.open_nursery() as tn,
+ ):
if depth < 1:
- await n.run_in_actor(breakpoint_forever)
-
- p = await n.run_in_actor(
- name_error,
- name='name_error'
+ tn.start_soon(
+ partial(
+ tractor.to_actor.run,
+ breakpoint_forever,
+ an=an,
+ )
)
+
await trio.sleep(0.5)
# rx and propagate error from child
- await p.result()
+ await tractor.to_actor.run(
+ name_error,
+ an=an,
+ name='name_error',
+ )
else:
# recusrive call to spawn another process branching layer of
- # the tree
+ # the tree; blocks (up) each level until the leaf's
+ # `name_error` relays through.
depth -= 1
- await n.run_in_actor(
+ await tractor.to_actor.run(
spawn_until,
+ an=an,
depth=depth,
name=f'spawn_until_{depth}',
)
@@ -65,35 +78,34 @@ async def main():
└─ python -m tractor._child --uid ('spawn_until_0', 'de918e6d ...)
"""
- async with tractor.open_nursery(
- debug_mode=True,
- loglevel='pdb',
- ) as n:
-
- # spawn both actors
- portal = await n.run_in_actor(
- spawn_until,
- depth=3,
- name='spawner0',
+ async with (
+ tractor.open_nursery(
+ debug_mode=True,
+ loglevel='pdb',
+ ) as an,
+ trio.open_nursery() as tn,
+ ):
+ # spawn both spawner trees as concurrent one-shots; the
+ # first tree's (relayed) error cancels the other.
+ tn.start_soon(
+ partial(
+ tractor.to_actor.run,
+ spawn_until,
+ an=an,
+ depth=3,
+ name='spawner0',
+ )
)
- portal1 = await n.run_in_actor(
- spawn_until,
- depth=4,
- name='spawner1',
+ tn.start_soon(
+ partial(
+ tractor.to_actor.run,
+ spawn_until,
+ an=an,
+ depth=4,
+ name='spawner1',
+ )
)
- # TODO: test this case as well where the parent don't see
- # the sub-actor errors by default and instead expect a user
- # ctrl-c to kill the root.
- with trio.move_on_after(3):
- await trio.sleep_forever()
-
- # gah still an issue here.
- await portal.result()
-
- # should never get here
- await portal1.result()
-
if __name__ == '__main__':
trio.run(main)
diff --git a/examples/debugging/multi_subactor_root_errors.py b/examples/debugging/multi_subactor_root_errors.py
index 31bb7dd16..5aa3a4fff 100644
--- a/examples/debugging/multi_subactor_root_errors.py
+++ b/examples/debugging/multi_subactor_root_errors.py
@@ -15,12 +15,12 @@ async def name_error():
async def spawn_error():
""""A nested nursery that triggers another ``NameError``.
"""
- async with tractor.open_nursery() as n:
- portal = await n.run_in_actor(
+ async with tractor.open_nursery() as an:
+ return await tractor.to_actor.run(
name_error,
+ an=an,
name='name_error_1',
- )
- return await portal.result()
+ )
async def main():
@@ -38,29 +38,36 @@ async def main():
- root actor should then fail on assert
- program termination
"""
- async with tractor.open_nursery(
- debug_mode=True,
- loglevel='devx',
- ) as n:
-
- # spawn both actors
- portal = await n.run_in_actor(
- name_error,
- name='name_error',
+ async with (
+ tractor.open_nursery(
+ debug_mode=True,
+ loglevel='devx',
+ ) as an,
+ trio.open_nursery() as tn,
+ ):
+ # spawn both actors..
+ portal = await an.start_actor(
+ 'name_error',
+ enable_modules=[__name__],
)
- portal1 = await n.run_in_actor(
- spawn_error,
- name='spawn_error',
+ portal1 = await an.start_actor(
+ 'spawn_error',
+ enable_modules=[__name__],
)
+ # ..and bg-schedule their erroring tasks.
+ tn.start_soon(portal.run, name_error)
+ tn.start_soon(portal1.run, spawn_error)
+
+ # yield to the bg tasks so both RPC requests are
+ # submitted (and start crashing) before the root's own
+ # error below (the legacy `run_in_actor()` submitted
+ # in-line with each spawn).
+ await trio.sleep(0.5)
+
# trigger a root actor error
assert 0
- # attempt to collect results (which raises error in parent)
- # still has some issues where the parent seems to get stuck
- await portal.result()
- await portal1.result()
-
if __name__ == '__main__':
trio.run(main)
diff --git a/examples/debugging/multi_subactors.py b/examples/debugging/multi_subactors.py
index 57634cc39..63ab54041 100644
--- a/examples/debugging/multi_subactors.py
+++ b/examples/debugging/multi_subactors.py
@@ -17,12 +17,12 @@ async def name_error():
async def spawn_error():
""""A nested nursery that triggers another ``NameError``.
"""
- async with tractor.open_nursery() as n:
- portal = await n.run_in_actor(
+ async with tractor.open_nursery() as an:
+ return await tractor.to_actor.run(
name_error,
+ an=an,
name='name_error_1',
)
- return await portal.result()
async def main():
@@ -36,17 +36,39 @@ async def main():
`-python -m tractor._child --uid ('spawn_error', '52ee14a5 ...)
`-python -m tractor._child --uid ('name_error', '3391222c ...)
"""
+ errors: list[BaseException] = []
+
async with tractor.open_nursery(
debug_mode=True,
# loglevel='runtime',
- ) as n:
-
- # Spawn both actors, don't bother with collecting results
- # (would result in a different debugger outcome due to parent's
- # cancellation).
- await n.run_in_actor(breakpoint_forever)
- await n.run_in_actor(name_error)
- await n.run_in_actor(spawn_error)
+ ) as an:
+
+ async def run_and_collect(fn):
+ '''
+ One-shot whose (boxed) error is stashed instead of
+ raised so a sibling's crash never cancels the others
+ before they've had their own debugger sessions (the
+ "collect all errors" the legacy `run_in_actor()` API
+ did implicitly at nursery teardown).
+
+ '''
+ try:
+ await tractor.to_actor.run(fn, an=an)
+ except tractor.RemoteActorError as rae:
+ errors.append(rae)
+
+ # Spawn all one-shot task actors, collecting (vs.
+ # raising) their errors.
+ async with trio.open_nursery() as tn:
+ tn.start_soon(run_and_collect, breakpoint_forever)
+ tn.start_soon(run_and_collect, name_error)
+ tn.start_soon(run_and_collect, spawn_error)
+
+ if errors:
+ raise BaseExceptionGroup(
+ 'multi_subactors errored!',
+ errors,
+ )
if __name__ == '__main__':
diff --git a/examples/debugging/open_ctx_modnofound.py b/examples/debugging/open_ctx_modnofound.py
index 181295aa1..450687e24 100644
--- a/examples/debugging/open_ctx_modnofound.py
+++ b/examples/debugging/open_ctx_modnofound.py
@@ -21,8 +21,8 @@ async def main() -> None:
async with tractor.open_nursery(
debug_mode=True,
- ) as n:
- portal = await n.start_actor(
+ ) as an:
+ portal = await an.start_actor(
'ctx_child',
# XXX: we don't enable the current module in order
diff --git a/examples/debugging/per_actor_debug.py b/examples/debugging/per_actor_debug.py
index c1bf5cab0..189fb45e2 100644
--- a/examples/debugging/per_actor_debug.py
+++ b/examples/debugging/per_actor_debug.py
@@ -6,14 +6,14 @@ async def die():
async def main():
- async with tractor.open_nursery() as tn:
+ async with tractor.open_nursery() as an:
- debug_actor = await tn.start_actor(
+ debug_actor = await an.start_actor(
'debugged_boi',
enable_modules=[__name__],
debug_mode=True,
)
- crash_boi = await tn.start_actor(
+ crash_boi = await an.start_actor(
'crash_boi',
enable_modules=[__name__],
# debug_mode=True,
diff --git a/examples/debugging/root_cancelled_but_child_is_in_tty_lock.py b/examples/debugging/root_cancelled_but_child_is_in_tty_lock.py
index 93daa33b8..083a7fb35 100644
--- a/examples/debugging/root_cancelled_but_child_is_in_tty_lock.py
+++ b/examples/debugging/root_cancelled_but_child_is_in_tty_lock.py
@@ -1,3 +1,5 @@
+from functools import partial
+
import trio
import tractor
@@ -10,14 +12,14 @@ async def name_error():
async def spawn_until(depth=0):
""""A nested nursery that triggers another ``NameError``.
"""
- async with tractor.open_nursery() as n:
+ async with tractor.open_nursery() as an:
if depth < 1:
- # await n.run_in_actor('breakpoint_forever', breakpoint_forever)
- await n.run_in_actor(name_error)
+ await tractor.to_actor.run(name_error, an=an)
else:
depth -= 1
- await n.run_in_actor(
+ await tractor.to_actor.run(
spawn_until,
+ an=an,
depth=depth,
name=f'spawn_until_{depth}',
)
@@ -37,28 +39,33 @@ async def main():
└─ python -m tractor._child --uid ('name_error', '6c2733b8 ...)
'''
- async with tractor.open_nursery(
- debug_mode=True,
- enable_transports=['uds'], # TODO, apss this via osenv?
- loglevel='devx', # XXX, required for test!
- ) as n:
+ async with (
+ tractor.open_nursery(
+ debug_mode=True,
+ enable_transports=['uds'], # TODO, apss this via osenv?
+ loglevel='devx', # XXX, required for test!
+ ) as an,
+ trio.open_nursery() as tn,
+ ):
+ # spawn the deeper tree in the bg..
+ tn.start_soon(
+ partial(
+ tractor.to_actor.run,
+ spawn_until,
+ an=an,
+ depth=1,
+ name='spawner1',
+ )
+ )
- # spawn both actors
- portal = await n.run_in_actor(
+ # ..while blocking on the shallow (faster to fail) tree
+ # whose propagated error triggers nursery cancellation.
+ await tractor.to_actor.run(
spawn_until,
+ an=an,
depth=0,
name='spawner0',
)
- portal1 = await n.run_in_actor(
- spawn_until,
- depth=1,
- name='spawner1',
- )
-
- # nursery cancellation should be triggered due to propagated
- # error from child.
- await portal.result()
- await portal1.result()
if __name__ == '__main__':
diff --git a/examples/debugging/root_timeout_while_child_crashed.py b/examples/debugging/root_timeout_while_child_crashed.py
index 4dfc699da..043cb5c71 100644
--- a/examples/debugging/root_timeout_while_child_crashed.py
+++ b/examples/debugging/root_timeout_while_child_crashed.py
@@ -13,17 +13,24 @@ async def main():
simultaneously.
'''
- async with tractor.open_nursery(
- debug_mode=True,
- # loglevel='debug' # ?XXX required?
- ) as n:
-
- # spawn both actors
- portal = await n.run_in_actor(key_error)
+ async with (
+ tractor.open_nursery(
+ debug_mode=True,
+ # loglevel='debug' # ?XXX required?
+ ) as an,
+ trio.open_nursery() as tn,
+ ):
+ # spawn the actor..
+ portal = await an.start_actor(
+ 'key_error',
+ enable_modules=[__name__],
+ )
print(
f'Child is up @ {portal.chan.aid.reprol()}'
)
-
+ # ..then schedule its erroring task in the bg while the
+ # root blocks below.
+ tn.start_soon(portal.run, key_error)
# XXX: originally a bug caused by this is where root would enter
# the debugger and clobber the tty used by the repl even though
diff --git a/examples/debugging/shielded_pause.py b/examples/debugging/shielded_pause.py
index e6df907ca..5a8c50e77 100644
--- a/examples/debugging/shielded_pause.py
+++ b/examples/debugging/shielded_pause.py
@@ -74,11 +74,11 @@ async def cancelled_before_pause(
async def main():
async with tractor.open_nursery(
debug_mode=True,
- ) as n:
- portal: tractor.Portal = await n.run_in_actor(
+ ) as an:
+ await tractor.to_actor.run(
cancelled_before_pause,
+ an=an,
)
- await portal.wait_for_result()
# ensure the same works in the root actor!
await pm_on_cancelled()
diff --git a/examples/debugging/subactor_bp_in_ctx.py b/examples/debugging/subactor_bp_in_ctx.py
index 5bfff3311..36d4b2b32 100644
--- a/examples/debugging/subactor_bp_in_ctx.py
+++ b/examples/debugging/subactor_bp_in_ctx.py
@@ -58,8 +58,8 @@ async def main():
debug_mode=True,
enable_transports=[tpt],
loglevel='devx',
- ) as n:
- p = await n.start_actor(
+ ) as an:
+ p = await an.start_actor(
'bp_boi',
enable_modules=[__name__],
)
diff --git a/examples/debugging/subactor_breakpoint.py b/examples/debugging/subactor_breakpoint.py
index 67a5b7e0f..e3a4f250d 100644
--- a/examples/debugging/subactor_breakpoint.py
+++ b/examples/debugging/subactor_breakpoint.py
@@ -17,12 +17,14 @@ async def main():
async with tractor.open_nursery(
debug_mode=True,
loglevel='cancel',
- ) as n:
+ ) as an:
- portal = await n.run_in_actor(
+ # parks awaiting a result which only arrives once the
+ # user quits (`BdbQuit`s) the child's REPL loop.
+ await tractor.to_actor.run(
breakpoint_forever,
+ an=an,
)
- await portal.wait_for_result()
if __name__ == '__main__':
diff --git a/examples/debugging/subactor_error.py b/examples/debugging/subactor_error.py
index fabdcedbd..95c1fe12b 100644
--- a/examples/debugging/subactor_error.py
+++ b/examples/debugging/subactor_error.py
@@ -12,16 +12,12 @@ async def main():
) as an:
# TODO: ideally the REPL arrives at this frame in the parent,
- # ABOVE the @api_frame of `Portal.run_in_actor()` (which
- # should eventually not even be a portal method ... XD)
+ # ABOVE the @api_frame of `to_actor.run()` ..
# await tractor.pause()
- p: tractor.Portal = await an.run_in_actor(name_error)
- # with this style, should raise on this line
- await p.wait_for_result()
-
- # with this alt style should raise at `open_nusery()`
- # return await p.wait_for_result()
+ # the one-shot blocks on the subactor's result so the
+ # boxed `NameError` raises right here.
+ await tractor.to_actor.run(name_error, an=an)
if __name__ == '__main__':
diff --git a/examples/debugging/sync_bp.py b/examples/debugging/sync_bp.py
index 8c4ba6e96..c22f0fffc 100644
--- a/examples/debugging/sync_bp.py
+++ b/examples/debugging/sync_bp.py
@@ -90,7 +90,7 @@ async def main() -> None:
# TODO: 3 sub-actor usage cases:
# -[x] via a `.open_context()`
- # -[ ] via a `.run_in_actor()` call
+ # -[ ] via a `to_actor.run()` call
# -[ ] via a `.run()`
# -[ ] via a `.to_thread.run_sync()` in subactor
async with p.open_context(
diff --git a/examples/infected_asyncio_echo_server.py b/examples/infected_asyncio_echo_server.py
index e8d29dc33..e3ff2a09d 100644
--- a/examples/infected_asyncio_echo_server.py
+++ b/examples/infected_asyncio_echo_server.py
@@ -50,8 +50,8 @@ async def trio_to_aio_echo_server(
async def main():
- async with tractor.open_nursery() as n:
- p = await n.start_actor(
+ async with tractor.open_nursery() as an:
+ p = await an.start_actor(
'aio_server',
enable_modules=[__name__],
infect_asyncio=True,
diff --git a/examples/integration/open_context_and_sleep.py b/examples/integration/open_context_and_sleep.py
index 4c2db3e2c..f54b90aec 100644
--- a/examples/integration/open_context_and_sleep.py
+++ b/examples/integration/open_context_and_sleep.py
@@ -29,9 +29,9 @@ async def main() -> None:
))
await proc.wait()
# await trio.sleep_forever()
- # async with tractor.open_nursery() as n:
+ # async with tractor.open_nursery() as an:
- # portal = await n.start_actor(
+ # portal = await an.start_actor(
# 'rpc_server',
# enable_modules=[__name__],
# )
diff --git a/examples/parallelism/concurrent_actors_primes.py b/examples/parallelism/concurrent_actors_primes.py
index 748861e62..e5b32359e 100644
--- a/examples/parallelism/concurrent_actors_primes.py
+++ b/examples/parallelism/concurrent_actors_primes.py
@@ -55,7 +55,7 @@ async def worker_pool(workers=4):
Yes, the workers stay alive (and ready for work) until you close
the context.
"""
- async with tractor.open_nursery() as tn:
+ async with tractor.open_nursery() as an:
portals = []
snd_chan, recv_chan = trio.open_memory_channel(len(PRIMES))
@@ -65,7 +65,7 @@ async def worker_pool(workers=4):
# this starts a new sub-actor (process + trio runtime) and
# stores it's "portal" for later use to "submit jobs" (ugh).
portals.append(
- await tn.start_actor(
+ await an.start_actor(
f'worker_{i}',
enable_modules=[__name__],
)
@@ -80,10 +80,10 @@ async def _map(
async def send_result(func, value, portal):
await snd_chan.send((value, await portal.run(func, n=value)))
- async with trio.open_nursery() as n:
+ async with trio.open_nursery() as tn:
for value, portal in zip(sequence, itertools.cycle(portals)):
- n.start_soon(
+ tn.start_soon(
send_result,
worker_func,
value,
@@ -98,7 +98,7 @@ async def send_result(func, value, portal):
yield _map
# tear down all "workers" on pool close
- await tn.cancel()
+ await an.cancel()
async def main():
diff --git a/examples/parallelism/single_func.py b/examples/parallelism/single_func.py
index c4ea29e37..f99bb8c7c 100644
--- a/examples/parallelism/single_func.py
+++ b/examples/parallelism/single_func.py
@@ -25,17 +25,15 @@ async def burn_cpu():
async def main():
- async with tractor.open_nursery() as n:
+ async with trio.open_nursery() as tn:
- portal = await n.run_in_actor(burn_cpu)
+ # burn rubber in the parent too
+ tn.start_soon(burn_cpu)
- # burn rubber in the parent too
- await burn_cpu()
+ # run the same func as the lone task in a subactor,
+ # block on (and collect) its result
+ pid = await tractor.to_actor.run(burn_cpu)
- # wait on result from target function
- pid = await portal.wait_for_result()
-
- # end of nursery block
print(f"Collected subproc {pid}")
diff --git a/examples/parallelism/to_actor_one_shots.py b/examples/parallelism/to_actor_one_shots.py
new file mode 100644
index 000000000..5edc6a21a
--- /dev/null
+++ b/examples/parallelism/to_actor_one_shots.py
@@ -0,0 +1,83 @@
+'''
+`tractor.to_actor.run()`: one-shot single-task subactor
+invocation, the SC-parallelism sibling of
+`trio.to_thread.run_sync()` (and `anyio.to_process`).
+
+Each call spawns a subactor, schedules the async fn as
+its lone remote task, waits on the result and reaps the
+subactor. Concurrency composes the plain `trio` way:
+schedule multiple one-shot calls in a local task nursery
+against a shared actor-nursery; any remote error raises
+directly in the task which scheduled it.
+
+'''
+import math
+
+import tractor
+import trio
+
+
+async def is_prime(
+ n: int,
+) -> bool:
+ if n < 2:
+ return False
+ if n == 2:
+ return True
+ if n % 2 == 0:
+ return False
+
+ sqrt_n = int(math.floor(math.sqrt(n)))
+ for i in range(3, sqrt_n + 1, 2):
+ if n % i == 0:
+ return False
+ return True
+
+
+async def main() -> None:
+
+ # fully implicit one-shot: boots the actor-runtime,
+ # spawns a subactor, runs the task, reaps the
+ # subactor, tears the runtime back down.
+ assert await tractor.to_actor.run(
+ is_prime,
+ n=2,
+ )
+
+ # the "worker-pool-ish" pattern from the original
+ # `concurrent.futures` example: one subactor per
+ # input, all concurrent, results and errors
+ # collected by caller-side tasks.
+ results: dict[int, bool] = {}
+
+ async def check(
+ an: tractor.ActorNursery,
+ n: int,
+ i: int,
+ ) -> None:
+ results[n] = await tractor.to_actor.run(
+ is_prime,
+ an=an,
+ name=f'prime_checker_{i}',
+ n=n,
+ )
+
+ inputs: list[int] = [
+ 7,
+ 8,
+ 3691,
+ 3693,
+ ]
+ async with (
+ tractor.open_nursery() as an,
+ trio.open_nursery() as tn,
+ ):
+ for i, n in enumerate(inputs):
+ tn.start_soon(check, an, n, i)
+
+ for n, prime in sorted(results.items()):
+ print(f'{n} is prime: {prime}')
+
+
+if __name__ == '__main__':
+ trio.run(main)
diff --git a/examples/remote_error_propagation.py b/examples/remote_error_propagation.py
index aa2a73b4c..db9eb9b5d 100644
--- a/examples/remote_error_propagation.py
+++ b/examples/remote_error_propagation.py
@@ -7,19 +7,20 @@ async def assert_err():
async def main():
- async with tractor.open_nursery() as n:
+ async with tractor.open_nursery() as an:
real_actors = []
for i in range(3):
- real_actors.append(await n.start_actor(
+ real_actors.append(await an.start_actor(
f'actor_{i}',
enable_modules=[__name__],
))
- # start one actor that will fail immediately
- await n.run_in_actor(assert_err)
+ # run one one-shot task actor that will fail immediately;
+ # its error raises right here in the caller's task..
+ await tractor.to_actor.run(assert_err, an=an)
- # should error here with a ``RemoteActorError`` containing
- # an ``AssertionError`` and all the other actors have been cancelled
+ # ..as a ``RemoteActorError`` containing an ``AssertionError``
+ # and all the other actors have been cancelled
if __name__ == '__main__':
diff --git a/examples/rpc_bidir_streaming.py b/examples/rpc_bidir_streaming.py
index c961bf201..eb4f03cd0 100644
--- a/examples/rpc_bidir_streaming.py
+++ b/examples/rpc_bidir_streaming.py
@@ -31,9 +31,9 @@ async def simple_rpc(
async def main() -> None:
- async with tractor.open_nursery() as n:
+ async with tractor.open_nursery() as an:
- portal = await n.start_actor(
+ portal = await an.start_actor(
'rpc_server',
enable_modules=[__name__],
)
diff --git a/tests/devx/test_debugger.py b/tests/devx/test_debugger.py
index dfcf36d83..e2b803aa8 100644
--- a/tests/devx/test_debugger.py
+++ b/tests/devx/test_debugger.py
@@ -849,43 +849,36 @@ def test_multi_nested_subactors_error_through_nurseries(
break
# boxed source errors
+ #
+ # NB post-#477 (`to_actor.run()` one-shots in local
+ # task-nurseries) the final relay is the LAST-released
+ # (leaf) REPL's error chain: it wins each level's
+ # relay-vs-cancel race so every level's single-member
+ # group gets unwrapped by the runtime's `collapse_eg()`
+ # (annotated at each actor boundary) while the sibling
+ # tree ('spawner1') is cancelled + absorbed. The legacy
+ # `run_in_actor()` teardown-reap instead grouped BOTH the
+ # `name_error` and bp-quit chains into the final dump
+ # (the previously-unexplained "extra" patterns).
expect_patts: list[str] = [
- "NameError: name 'doggypants' is not defined",
"tractor._exceptions.RemoteActorError:",
- "('name_error'",
- # first level subtrees
- # "tractor._exceptions.RemoteActorError: ('spawner0'",
- "src_uid=('spawner0'",
-
- # "tractor._exceptions.RemoteActorError: ('spawner1'",
-
- # propagation of errors up through nested subtrees
- # "tractor._exceptions.RemoteActorError: ('spawn_until_0'",
- # "tractor._exceptions.RemoteActorError: ('spawn_until_1'",
- # "tractor._exceptions.RemoteActorError: ('spawn_until_2'",
- # ^-NOTE-^ old RAE repr, new one is below with a field
- # showing the src actor's uid.
- "src_uid=('spawn_until_2'",
+ # each level's unwrapped-single-member-group
+ # annotation + the first-level subtree's boundary
+ # footer.
+ "( ^^^ this exc was collapsed from a group ^^^ )",
+ "------ ('spawner0'",
]
- # XXX, I HAVE NO IDEA why these patts only show on the
- # `trio`-spawner but it seems to have something to do with
- # what gets dumped in prior-prompt latches somehow??
- # TODO for claude, explain and or work through how this is
- # happening but ONLY WHEN RUN FROM THE TEST, bc when i try to
- # run the test script manually the correct output ALWAYS seems
- # to be in the last `str(child.before.decode())` output !?!?
if (
not is_forking_spawner
and
last_send_char == 'q'
):
expect_patts += [
- # expect the pdb-quit exc.
+ # expect the pdb-quit exc relayed from the leaf's
+ # bp-loop child.
"bdb.BdbQuit",
- # BUT WHY these dude!?
- "src_uid=('spawn_until_0'",
- "relay_uid=('spawn_until_1'",
+ "src_uid=('breakpoint_forever'",
]
assert_before(
diff --git a/tests/discovery/test_registrar.py b/tests/discovery/test_registrar.py
index 73f7e265a..e384c22ba 100644
--- a/tests/discovery/test_registrar.py
+++ b/tests/discovery/test_registrar.py
@@ -46,9 +46,9 @@ async def test_reg_then_unreg(
async with tractor.open_nursery(
registry_addrs=[reg_addr],
- ) as n:
+ ) as an:
- portal = await n.start_actor('actor', enable_modules=[__name__])
+ portal = await an.start_actor('actor', enable_modules=[__name__])
uid = portal.channel.aid.uid
async with tractor.get_registry(reg_addr) as aportal:
@@ -62,7 +62,7 @@ async def test_reg_then_unreg(
# XXX: can we figure out what the listen addr will be?
assert sockaddrs
- await n.cancel() # tear down nursery
+ await an.cancel() # tear down nursery
await trio.sleep(0.1)
assert uid not in aportal.actor._registry
@@ -89,9 +89,9 @@ async def test_reg_then_unreg_maddr(
async with tractor.open_nursery(
registry_addrs=[maddr_str],
- ) as n:
+ ) as an:
- portal = await n.start_actor(
+ portal = await an.start_actor(
'actor_maddr',
enable_modules=[__name__],
)
@@ -105,7 +105,7 @@ async def test_reg_then_unreg_maddr(
sockaddrs = actor._registry[uid]
assert sockaddrs
- await n.cancel()
+ await an.cancel()
await trio.sleep(0.1)
assert uid not in aportal.actor._registry
@@ -152,23 +152,37 @@ async def test_trynamic_trio(
for the directed subs.
'''
- async with tractor.open_nursery() as n:
+ async with tractor.open_nursery() as an:
print("Alright... Action!")
- donny = await n.run_in_actor(
- ria_fn,
- other_actor='gretchen',
- reg_addr=reg_addr,
- name='donny',
- )
- gretchen = await n.run_in_actor(
- ria_fn,
- other_actor='donny',
- reg_addr=reg_addr,
- name='gretchen',
- )
- print(await gretchen.result())
- print(await donny.result())
+ # donny + gretchen each wait on (then dial!) the *other*, so
+ # both actors must OUTLIVE both hellos: spawn as daemons and
+ # only reap after both tasks complete. NB a pair of eagerly
+ # reaped `to_actor.run()` one-shots races: the first to
+ # finish dies while the other may still be dialing its
+ # registry-resolved (now dead) sockaddr -> conn-refused.
+ portals: dict[str, tractor.Portal] = {
+ name: await an.start_actor(
+ name,
+ enable_modules=[__name__],
+ )
+ for name in ('donny', 'gretchen')
+ }
+
+ async def _direct(this_name: str, other_actor: str):
+ res = await portals[this_name].run(
+ ria_fn,
+ other_actor=other_actor,
+ reg_addr=reg_addr,
+ )
+ print(res)
+
+ async with trio.open_nursery() as tn:
+ tn.start_soon(_direct, 'donny', 'gretchen')
+ tn.start_soon(_direct, 'gretchen', 'donny')
+
+ # both hellos have completed; reap the thespians.
+ await an.cancel()
print("CUTTTT CUUTT CUT!!?! Donny!! You're supposed to say...")
@@ -270,13 +284,15 @@ async def spawn_and_check_registry(
portals = {}
for i in range(3):
name = f'a{i}'
- if with_streaming:
- portals[name] = await an.start_actor(
- name=name, enable_modules=[__name__])
-
- else: # no streaming
- portals[name] = await an.run_in_actor(
- trio.sleep_forever, name=name)
+ # a daemon subactor is alive + registered
+ # without a "main" task; the streaming
+ # branch below uses the module funcs, the
+ # non-streaming case just needs it up (was
+ # `run_in_actor(trio.sleep_forever)`).
+ portals[name] = await an.start_actor(
+ name=name,
+ enable_modules=[__name__],
+ )
# wait on last actor to come up
async with tractor.wait_for_actor(name):
diff --git a/tests/test_advanced_streaming.py b/tests/test_advanced_streaming.py
index 3e9fa3f54..8d5b7d1b1 100644
--- a/tests/test_advanced_streaming.py
+++ b/tests/test_advanced_streaming.py
@@ -3,6 +3,7 @@
'''
from collections import Counter
+from functools import partial
import itertools
import platform
from typing import Type
@@ -173,8 +174,8 @@ def test_dynamic_pub_sub(
# test. Picked backend-aware: under `trio` backend spawn is
# cheap (~1s for `cpus` actors) but fork-based backends pay
# a per-spawn cost (forkserver round-trip + IPC peer-handshake)
- # that can stack up over `cpus - 1` sequential `n.run_in_actor()`
- # calls — especially on UDS under cross-pytest contention
+ # that can stack up over the `cpus - 1` one-shot
+ # (`to_actor.run()`) spawns — especially on UDS under cross-pytest contention
# (#451 / #452). 4s was flaking right at the edge under fork
# backends — bumped to 8s with diag-snapshot-on-timeout via
# `fail_after_w_trace` so a borderline run still fails loud
@@ -214,33 +215,55 @@ async def main():
f'enter `fail_after_w_trace({fail_after_s})` scope'
)
try:
- async with tractor.open_nursery(
- registry_addrs=[reg_addr],
- debug_mode=debug_mode,
- ) as n:
+ async with (
+ tractor.open_nursery(
+ registry_addrs=[reg_addr],
+ debug_mode=debug_mode,
+ ) as an,
+ # bg-schedules the forever-streaming
+ # one-shots below; the user-cancel raise
+ # cancels them all, each reaping its
+ # subactor via `to_actor.run()`'s
+ # (shielded) `Portal.cancel_actor()`.
+ trio.open_nursery() as tn,
+ ):
test_log.cancel(
'test_dynamic_pub_sub: '
'actor nursery opened'
)
# name of this actor will be same as target func
- await n.run_in_actor(publisher)
+ tn.start_soon(
+ partial(
+ tractor.to_actor.run,
+ publisher,
+ an=an,
+ )
+ )
for i, sub in zip(
range(cpus - 2),
itertools.cycle(_registry.keys())
):
- await n.run_in_actor(
- consumer,
- name=f'consumer_{sub}',
- subs=[sub],
+ tn.start_soon(
+ partial(
+ tractor.to_actor.run,
+ consumer,
+ an=an,
+ name=f'consumer_{sub}',
+ subs=[sub],
+ )
)
# make one dynamic subscriber
- await n.run_in_actor(
- consumer,
- name='consumer_dynamic',
- subs=list(_registry.keys()),
+ tn.start_soon(
+ partial(
+ tractor.to_actor.run,
+ consumer,
+ an=an,
+ name='consumer_dynamic',
+ subs=list(_registry.keys()),
+ )
)
# block until "cancelled by user"
@@ -347,10 +370,10 @@ async def main():
timeout = 4
with trio.move_on_after(timeout):
- async with tractor.open_nursery() as n:
+ async with tractor.open_nursery() as an:
# name of this actor will be same as target func
- portal = await n.start_actor(
+ portal = await an.start_actor(
'dual_tasks',
enable_modules=[__name__]
)
@@ -413,9 +436,9 @@ def test_sigint_both_stream_types():
async def main():
with trio.fail_after(timeout):
- async with tractor.open_nursery() as n:
+ async with tractor.open_nursery() as an:
# name of this actor will be same as target func
- portal = await n.start_actor(
+ portal = await an.start_actor(
'2_way',
enable_modules=[__name__]
)
@@ -528,8 +551,8 @@ async def main():
async with tractor.open_nursery(
debug_mode=debug_mode,
- ) as tn:
- p: tractor.Portal = await tn.start_actor(
+ ) as an:
+ p: tractor.Portal = await an.start_actor(
'inf_streamer',
enable_modules=[__name__],
)
diff --git a/tests/test_cancellation.py b/tests/test_cancellation.py
index bff4c0c27..54a679817 100644
--- a/tests/test_cancellation.py
+++ b/tests/test_cancellation.py
@@ -2,6 +2,7 @@
Cancellation and error propagation
"""
+from functools import partial
import os
import signal
import platform
@@ -16,6 +17,10 @@
tractor_test,
)
from tractor._testing.trace import FailAfterWTraceFactory
+from tractor.trionics import (
+ collapse_eg,
+ gather_contexts,
+)
from .conftest import no_windows
@@ -68,6 +73,23 @@ async def assert_err(delay=0):
assert 0
+@tractor.context
+async def assert_err_ctx(
+ ctx: tractor.Context,
+ delay: float = 0,
+) -> None:
+ '''
+ `@context` shim around `assert_err()` so the multi-actor error
+ tests can fan-out one-shot erroring subactors via
+ `Portal.open_context()` + `gather_contexts()` instead of the
+ removed `ActorNursery.run_in_actor()` (#477).
+
+ '''
+ await ctx.started()
+ await trio.sleep(delay)
+ assert 0
+
+
async def sleep_forever():
await trio.sleep_forever()
@@ -104,24 +126,19 @@ def test_remote_error(
async def main():
async with tractor.open_nursery(
registry_addrs=[reg_addr],
- ) as nursery:
-
- # on a remote type error caused by bad input args
- # this should raise directly which means we **don't** get
- # an exception group outside the nursery since the error
- # here and the far end task error are one in the same?
- portal = await nursery.run_in_actor(
- assert_err,
- name='errorer',
- **args
- )
+ ) as an:
- # get result(s) from main task
+ # `to_actor.run()` blocks on the one-shot's result and
+ # raises the remote error directly here in the caller's
+ # task (a bad-arg `TypeError` likewise relays as a
+ # `RemoteActorError`).
try:
- # this means the root actor will also raise a local
- # parent task error and thus an eg will propagate out
- # of this actor nursery.
- await portal.result()
+ await tractor.to_actor.run(
+ assert_err,
+ an=an,
+ name='errorer',
+ **args
+ )
except tractor.RemoteActorError as err:
assert err.boxed_type == errtype
print("Look Maa that actor failed hard, hehh")
@@ -162,113 +179,48 @@ def test_multierror(
set_fork_aware_capture, #: Callable,
):
'''
- Verify we raise a ``BaseExceptionGroup`` out of a nursery where
- more then one actor errors.
+ Verify concurrent one-shot subactors erroring propagate a remote
+ error out of the `gather_contexts()` fan-out — grouped as a
+ `BaseExceptionGroup`, or (under cancel-on-first, where the 2nd
+ errorer is cancelled before relaying its own exc) collapsed to a
+ single `RemoteActorError`.
+
+ NB the legacy `run_in_actor()` reaped *all* children at nursery
+ teardown so this always yielded a BEG-of-N; the `to_actor`
+ fan-out is cancel-on-first, so accept either shape.
'''
async def main():
async with tractor.open_nursery(
registry_addrs=[reg_addr],
- ) as nursery:
-
- await nursery.run_in_actor(assert_err, name='errorer1')
- portal2 = await nursery.run_in_actor(assert_err, name='errorer2')
-
- # get result(s) from main task
- try:
- await portal2.result()
- except tractor.RemoteActorError as err:
- assert err.boxed_type is AssertionError
- print("Look Maa that first actor failed hard, hehh")
- raise
-
- # here we should get a ``BaseExceptionGroup`` containing exceptions
- # from both subactors
-
- with pytest.raises(BaseExceptionGroup):
- trio.run(main)
-
+ ) as an:
-@pytest.mark.parametrize(
- 'delay',
- (0, 0.5),
- ids='delays={}'.format,
-)
-@pytest.mark.parametrize(
- 'num_subactors',
- range(25, 26),
- ids= 'num_subs={}'.format,
-)
-def test_multierror_fast_nursery(
- reg_addr: tuple,
- start_method: str,
- num_subactors: int,
- delay: float,
- set_fork_aware_capture,
- fail_after_w_trace: FailAfterWTraceFactory,
-):
- '''
- Verify we raise a ``BaseExceptionGroup`` out of a nursery where
- more then one actor errors and also with a delay before failure
- to test failure during an ongoing spawning.
-
- '''
- async def main():
- # budget = 2× natural trio-backend cascade time for
- # 25 errorer subactors (~14s observed). on-timeout
- # diag snapshot → if the cancel cascade hangs
- # (observed under MTF backend with N>=14 errorer
- # subactors) we get a fresh ptree/wchan/py-spy dump
- # on disk INSTEAD of an opaque pytest timeout-kill.
- # See `tractor/_testing/trace.py` for the helper.
- async with fail_after_w_trace(30.0):
- async with tractor.open_nursery(
- registry_addrs=[reg_addr],
- ) as nursery:
-
- for i in range(num_subactors):
- await nursery.run_in_actor(
- assert_err,
- name=f'errorer{i}',
- delay=delay
- )
+ portals = [
+ await an.start_actor(
+ f'errorer{i}',
+ enable_modules=[__name__],
+ )
+ for i in range(2)
+ ]
+
+ # both one-shot subactors error concurrently, so the
+ # `gather_contexts()` task-nursery collects them into a
+ # `BaseExceptionGroup` (was two non-blocking
+ # `run_in_actor()`s reaped at nursery teardown).
+ async with gather_contexts(
+ mngrs=[
+ p.open_context(assert_err_ctx)
+ for p in portals
+ ],
+ ):
+ pass
- # with pytest.raises(trio.MultiError) as exc_info:
- # NOTE, `trio.TooSlowError` from `fail_after_w_trace`
- # bubbles UN-wrapped if `open_nursery.__aexit__` never
- # gets re-entered; wrapped inside a `BaseExceptionGroup`
- # if it did. Accept both shapes so the matcher itself
- # doesn't lie about *what* failed.
- with pytest.raises(
- (BaseExceptionGroup, trio.TooSlowError),
- ) as exc_info:
+ with pytest.raises((
+ BaseExceptionGroup,
+ tractor.RemoteActorError,
+ )):
trio.run(main)
- if isinstance(exc_info.value, trio.TooSlowError):
- pytest.fail(
- f'cancel cascade hung past 12s '
- f'(num_subactors={num_subactors}, delay={delay}); '
- f'see stderr for `fail_after_w_trace` snapshot path'
- )
-
- assert exc_info.type == ExceptionGroup
- err = exc_info.value
- exceptions = err.exceptions
-
- if len(exceptions) == 2:
- # sometimes oddly now there's an embedded BrokenResourceError ?
- for exc in exceptions:
- excs = getattr(exc, 'exceptions', None)
- if excs:
- exceptions = excs
- break
-
- assert len(exceptions) == num_subactors
-
- for exc in exceptions:
- assert isinstance(exc, tractor.RemoteActorError)
- assert exc.boxed_type is AssertionError
-
async def do_nothing():
pass
@@ -296,16 +248,16 @@ async def spawn_actor():
'''
async with tractor.open_nursery(
registry_addrs=[reg_addr],
- ) as nursery:
+ ) as an:
- portal = await nursery.start_actor(
+ portal = await an.start_actor(
'nothin', enable_modules=[__name__],
)
assert (await portal.run(do_nothing)) is None
if mechanism == 'nursery_cancel':
# would hang otherwise
- await nursery.cancel()
+ await an.cancel()
else:
raise mechanism
@@ -337,8 +289,8 @@ async def test_cancel_infinite_streamer(
trio.fail_after(4),
trio.move_on_after(1) as cancel_scope
):
- async with tractor.open_nursery() as n:
- portal = await n.start_actor(
+ async with tractor.open_nursery() as an:
+ portal = await an.start_actor(
'donny',
enable_modules=[__name__],
)
@@ -351,36 +303,36 @@ async def test_cancel_infinite_streamer(
# we support trio's cancellation system
assert cancel_scope.cancelled_caught
- assert n.cancel_called
+ assert an.cancel_called
@pytest.mark.parametrize(
'num_actors_and_errs',
[
- # daemon actors sit idle while single task actors error out
+ # daemon actors sit idle while one-shot task actors error out
(1, tractor.RemoteActorError, AssertionError, (assert_err, {}), None),
(2, BaseExceptionGroup, AssertionError, (assert_err, {}), None),
(3, BaseExceptionGroup, AssertionError, (assert_err, {}), None),
- # 1 daemon actor errors out while single task actors sleep forever
+ # 1 daemon actor errors out while one-shot task actors sleep forever
(3, tractor.RemoteActorError, AssertionError, (sleep_forever, {}),
(assert_err, {}, True)),
- # daemon actors error out after brief delay while single task
+ # daemon actors error out after brief delay while one-shot task
# actors complete quickly
(3, tractor.RemoteActorError, AssertionError,
(do_nuthin, {}), (assert_err, {'delay': 1}, True)),
- # daemon complete quickly delay while single task
+ # daemon complete quickly delay while one-shot task
# actors error after brief delay
(3, BaseExceptionGroup, AssertionError,
(assert_err, {'delay': 1}), (do_nuthin, {}, False)),
],
ids=[
- '1_run_in_actor_fails',
- '2_run_in_actors_fail',
- '3_run_in_actors_fail',
+ '1_one_shot_fails',
+ '2_one_shots_fail',
+ '3_one_shots_fail',
'1_daemon_actors_fail',
- '1_daemon_actors_fail_all_run_in_actors_dun_quick',
- 'no_daemon_actors_fail_all_run_in_actors_sleep_then_fail',
+ '1_daemon_actors_fail_all_one_shots_dun_quick',
+ 'no_daemon_actors_fail_all_one_shots_sleep_then_fail',
],
)
@tractor_test(
@@ -399,12 +351,22 @@ async def test_some_cancels_all(
This is the first and only supervisory strategy at the moment.
+ One-shot subactors run as concurrent `to_actor.run()` tasks
+ in a local task-nursery so their errors raise WHILE the
+ actor-nursery block is still open (vs the legacy
+ `run_in_actor()` teardown-reap); the first error cancels the
+ sibling one-shots (whose `trio.Cancelled`s the task-nursery
+ absorbs) so the group shape is 1..num_actors
+ `RemoteActorError`s depending on relay-vs-cancel timing —
+ with `collapse_eg()` unwrapping the deterministic
+ single-error cases to a bare `RemoteActorError`.
+
'''
(
num_actors,
first_err,
err_type,
- ria_func,
+ one_shot_func,
da_func,
) = num_actors_and_errs
try:
@@ -418,51 +380,64 @@ async def test_some_cancels_all(
enable_modules=[__name__],
))
- func, kwargs = ria_func
- riactor_portals = []
- for i in range(num_actors):
- # start actor(s) that will fail immediately
- riactor_portals.append(
- await an.run_in_actor(
- func,
- name=f'actor_{i}',
- **kwargs
+ func, kwargs = one_shot_func
+ async with (
+ collapse_eg(),
+ trio.open_nursery() as tn,
+ ):
+ for i in range(num_actors):
+ # schedule one-shot task actor(s); errors
+ # raise into this task-nursery scope.
+ tn.start_soon(
+ partial(
+ tractor.to_actor.run,
+ func,
+ an=an,
+ name=f'actor_{i}',
+ **kwargs,
+ )
)
- )
- if da_func:
- func, kwargs, expect_error = da_func
- for portal in dactor_portals:
- # if this function fails then we should error here
- # and the nursery should teardown all other actors
- try:
- await portal.run(func, **kwargs)
-
- except tractor.RemoteActorError as err:
- assert err.boxed_type == err_type
- # we only expect this first error to propogate
- # (all other daemons are cancelled before they
- # can be scheduled)
- num_actors = 1
- # reraise so nursery teardown is triggered
- raise
- else:
- if expect_error:
- pytest.fail(
- "Deamon call should fail at checkpoint?")
-
- # should error here with a ``RemoteActorError`` or ``MultiError``
-
- except first_err as _err:
+ if da_func:
+ func, kwargs, expect_error = da_func
+ for portal in dactor_portals:
+ # if this function fails then we should error
+ # here and the nursery should teardown all
+ # other actors
+ try:
+ await portal.run(func, **kwargs)
+
+ except tractor.RemoteActorError as err:
+ assert err.boxed_type == err_type
+ # we only expect this first error to propogate
+ # (all other daemons are cancelled before they
+ # can be scheduled)
+ num_actors = 1
+ # reraise so nursery teardown is triggered
+ raise
+ else:
+ if expect_error:
+ pytest.fail(
+ "Deamon call should fail at checkpoint?")
+
+ # should error here with a `RemoteActorError` or a beg of them
+
+ except (
+ BaseExceptionGroup,
+ tractor.RemoteActorError,
+ ) as _err:
err = _err
if isinstance(err, BaseExceptionGroup):
- assert len(err.exceptions) == num_actors
+ # only the concurrent multi-error cases can group; the
+ # relay-vs-cancel race means anywhere from 1 (all
+ # siblings cancelled before relaying) up to all
+ # `num_actors` errors may populate the group.
+ assert first_err is BaseExceptionGroup
+ assert 1 <= len(err.exceptions) <= num_actors
for exc in err.exceptions:
- if isinstance(exc, tractor.RemoteActorError):
- assert exc.boxed_type == err_type
- else:
- assert isinstance(exc, trio.Cancelled)
- elif isinstance(err, tractor.RemoteActorError):
+ assert isinstance(exc, tractor.RemoteActorError)
+ assert exc.boxed_type == err_type
+ else:
assert err.boxed_type == err_type
assert an.cancel_called is True
@@ -475,8 +450,20 @@ async def spawn_and_error(
breadth: int,
depth: int,
) -> None:
+ '''
+ Recursively spawn a breadth-wide level of erroring one-shot
+ subactors as concurrent `to_actor.run()` tasks; the leaf level
+ errors ~simultaneously and each level's task-nursery groups
+ whatever `RemoteActorError`s relay before the first one's
+ cancel wins, boxing the (`ExceptionGroup`-shaped) group into
+ this actor's own relayed error.
+
+ '''
name = tractor.current_actor().name
- async with tractor.open_nursery() as nursery:
+ async with (
+ tractor.open_nursery() as an,
+ trio.open_nursery() as tn,
+ ):
for i in range(breadth):
if depth > 0:
@@ -496,7 +483,14 @@ async def spawn_and_error(
kwargs = {
'name': f'{name}_errorer_{i}',
}
- await nursery.run_in_actor(*args, **kwargs)
+ tn.start_soon(
+ partial(
+ tractor.to_actor.run,
+ *args,
+ an=an,
+ **kwargs,
+ )
+ )
# NOTE: `main_thread_forkserver` capture-fd hang class is no
@@ -538,7 +532,11 @@ async def test_nested_multierrors(
depth: int,
):
'''
- Test that failed actor sets are wrapped in `BaseExceptionGroup`s.
+ Test that a nested tree of concurrently failing one-shot
+ subactors tears down cleanly, relaying (whatever subset of)
+ the leaf `AssertionError`s (that win the per-level
+ relay-vs-cancel race) re-boxed/grouped at each actor
+ boundary.
Parametrized over recursion `depth ∈ {1, 3}`:
@@ -588,6 +586,13 @@ async def test_nested_multierrors(
# fork-spawn jitter + UDS-contention widens both `t1` and
# `t2` further.
#
+ # NB post-#477 (`to_actor.run()` fan-out in a local
+ # task-nursery) a race-tripped sibling's `Cancelled` is
+ # ABSORBED by the task-nursery instead of landing in the
+ # group — the raced case now shows as a *smaller* BEG, so
+ # this marker should consistently `xpass`; drop it once CI
+ # confirms.
+ #
# With `strict=False` the clean-cascade cases (most
# depth=1 runs, rare depth=3 runs) report as `xpassed`
# while the race-tripped cases report as `xfailed` —
@@ -672,6 +677,14 @@ async def test_nested_multierrors(
timeout = 16
case ('main_thread_forkserver', 3):
timeout = 30
+ # any other fork-based backend (`mp_spawn` et al) pays
+ # the same per-spawn round-trip costs as MTF so rides
+ # its budgets; without a default arm `timeout` is left
+ # unbound -> `UnboundLocalError` at the scaling below.
+ case (_, 1):
+ timeout = 16
+ case (_, 3):
+ timeout = 30
# inflate the budget by the throttle headroom probed above so
# a slow box doesn't masquerade as a deadline regression.
@@ -684,67 +697,82 @@ async def test_nested_multierrors(
async with fail_after_w_trace(timeout):
try:
- async with tractor.open_nursery() as nursery:
+ async with (
+ tractor.open_nursery() as an,
+ trio.open_nursery() as tn,
+ ):
for i in range(subactor_breadth):
- await nursery.run_in_actor(
- spawn_and_error,
- name=f'spawner_{i}',
- breadth=subactor_breadth,
- depth=depth,
+ tn.start_soon(
+ partial(
+ tractor.to_actor.run,
+ spawn_and_error,
+ an=an,
+ name=f'spawner_{i}',
+ breadth=subactor_breadth,
+ depth=depth,
+ )
)
- except BaseExceptionGroup as err:
- assert len(err.exceptions) == subactor_breadth
- for subexc in err.exceptions:
-
- # verify first level actor errors are wrapped as remote
- if _friggin_windows:
-
+ except (
+ BaseExceptionGroup,
+ tractor.RemoteActorError,
+ ) as err:
+ # group membership is bounded by the relay-vs-cancel
+ # race: the first spawner-tree's error cancels its
+ # siblings, whose own errors only group when relayed
+ # first; a fully-raced tree even collapses (via the
+ # runtime's own `collapse_eg()` unwrapping each level's
+ # single-member group) to a bare `RemoteActorError`
+ # re-boxing the leaf `AssertionError` at every actor
+ # boundary. The deterministic exact-breadth nested-BEG
+ # was the legacy `run_in_actor()` reap-all-at-teardown.
+ subexcs: list[BaseException] = (
+ err.exceptions
+ if isinstance(err, BaseExceptionGroup)
+ else [err]
+ )
+ assert 1 <= len(subexcs) <= subactor_breadth
+ for subexc in subexcs:
+ if (
+ _friggin_windows
+ and
+ isinstance(subexc, trio.Cancelled)
+ ):
# windows is often too slow and cancellation seems
# to happen before an actor is spawned
- if isinstance(subexc, trio.Cancelled):
- continue
-
- elif isinstance(subexc, tractor.RemoteActorError):
- # on windows it seems we can't exactly be sure wtf
- # will happen..
- assert subexc.boxed_type in (
- tractor.RemoteActorError,
- trio.Cancelled,
- BaseExceptionGroup,
- )
-
- elif isinstance(subexc, BaseExceptionGroup):
- for subsub in subexc.exceptions:
-
- if subsub in (tractor.RemoteActorError,):
- subsub = subsub.boxed_type
-
- assert type(subsub) in (
- trio.Cancelled,
- BaseExceptionGroup,
- )
- else:
- assert isinstance(subexc, tractor.RemoteActorError)
-
- if depth > 0 and subactor_breadth > 1:
- # XXX not sure what's up with this..
- # on windows sometimes spawning is just too slow and
- # we get back the (sent) cancel signal instead
- if _friggin_windows:
- if isinstance(subexc, tractor.RemoteActorError):
- assert subexc.boxed_type in (
- BaseExceptionGroup,
- tractor.RemoteActorError
- )
- else:
- assert isinstance(subexc, BaseExceptionGroup)
- else:
- assert subexc.boxed_type is ExceptionGroup
- else:
- assert subexc.boxed_type in (
- tractor.RemoteActorError,
- trio.Cancelled
+ continue
+
+ assert isinstance(subexc, tractor.RemoteActorError)
+
+ accepted: tuple[Type[BaseException], ...] = (
+ # ≥2 sub-tree errors relayed before the
+ # cancel-cascade won → grouped per-level.
+ ExceptionGroup,
+ # every level collapsed down to its lone
+ # relayed (leaf) error.
+ AssertionError,
+ # a mid-level spawner relays an
+ # already-boxed (collapsed) leaf chain,
+ # re-boxing the `RemoteActorError` itself.
+ tractor.RemoteActorError,
+ # under heavy load a runtime-internal reap
+ # deadline can inject a `trio.Cancelled`
+ # into a child's group before relay (the
+ # same class the depth=3 throttle-xfail
+ # covers) upgrading it from an
+ # `ExceptionGroup`.
+ BaseExceptionGroup,
+ )
+ if _friggin_windows:
+ # on windows it seems we can't exactly be
+ # sure wtf will happen..
+ accepted += (
+ trio.Cancelled,
)
+ assert subexc.boxed_type in accepted
+ else:
+ pytest.fail(
+ 'Should have raised a (grouped) `RemoteActorError`?'
+ )
@no_windows
@@ -764,8 +792,8 @@ async def main():
with trio.fail_after(2):
async with tractor.open_nursery(
registry_addrs=[reg_addr],
- ) as tn:
- await tn.start_actor('sucka')
+ ) as an:
+ await an.start_actor('sucka')
if 'mp' in start_method:
time.sleep(0.1)
os.kill(pid, signal.SIGINT)
@@ -809,11 +837,13 @@ async def spawn_and_sleep_forever(
):
async with tractor.open_nursery(
registry_addrs=[reg_addr],
- ) as tn:
+ ) as an:
+ # just keep a set of (daemon) subactors alive for the
+ # SIGINT to cancel (was 3 `run_in_actor(sleep_forever)`
+ # one-shots — a daemon needs no "main" task to idle).
for i in range(3):
- await tn.run_in_actor(
- sleep_forever,
- name='namesucka',
+ await an.start_actor(
+ f'namesucka_{i}',
)
task_status.started()
await trio.sleep_forever()
@@ -854,8 +884,11 @@ async def spin_for(period=3):
async def spawn_sub_with_sync_blocking_task():
async with tractor.open_nursery() as an:
print('starting sync blocking subactor..\n')
- await an.run_in_actor(
+ # one-shot: parks HERE awaiting the sync-sleeping
+ # grandchild's result until cancelled from above.
+ await tractor.to_actor.run(
spin_for,
+ an=an,
name='sleeper',
)
print('exiting first subactor layer..\n')
@@ -961,10 +994,18 @@ async def main():
debug_mode=debug_mode,
registry_addrs=[reg_addr],
) as an,
+ trio.open_nursery() as tn,
):
- await an.run_in_actor(
- spawn_sub_with_sync_blocking_task,
- name='sync_blocking_sub',
+ # bg one-shot: parks on the middle actor's result
+ # (itself parked on the sync-sleeping grandchild)
+ # until the `assert 0` below cancels this scope.
+ tn.start_soon(
+ partial(
+ tractor.to_actor.run,
+ spawn_sub_with_sync_blocking_task,
+ an=an,
+ name='sync_blocking_sub',
+ )
)
await trio.sleep(1)
@@ -1013,8 +1054,8 @@ async def main():
start = time.time()
try:
async with trio.open_nursery() as nurse:
- async with tractor.open_nursery() as tn:
- p = await tn.start_actor(
+ async with tractor.open_nursery() as an:
+ p = await an.start_actor(
'fast_boi',
enable_modules=[__name__],
)
diff --git a/tests/test_child_manages_service_nursery.py b/tests/test_child_manages_service_nursery.py
index 820d9ca04..6c797e6cb 100644
--- a/tests/test_child_manages_service_nursery.py
+++ b/tests/test_child_manages_service_nursery.py
@@ -156,8 +156,8 @@ def test_actor_managed_trio_nursery_task_error_cancels_aio(
async def main():
# cancel the nursery shortly after boot
- async with tractor.open_nursery() as n:
- p = await n.start_actor(
+ async with tractor.open_nursery() as an:
+ p = await an.start_actor(
'nursery_mngr',
infect_asyncio=asyncio_mode, # TODO, is this enabling debug mode?
enable_modules=[__name__],
diff --git a/tests/test_infected_asyncio.py b/tests/test_infected_asyncio.py
index bdb1b8521..e366b6667 100644
--- a/tests/test_infected_asyncio.py
+++ b/tests/test_infected_asyncio.py
@@ -24,6 +24,7 @@
current_actor,
Actor,
to_asyncio,
+ to_actor,
RemoteActorError,
ContextCancelled,
)
@@ -110,8 +111,9 @@ async def main():
registry_addrs=[reg_addr],
debug_mode=debug_mode,
) as an:
- await an.run_in_actor(
+ await to_actor.run(
trio_cancels_single_aio_task,
+ an=an,
infect_asyncio=True,
)
@@ -157,6 +159,28 @@ async def asyncio_actor(
raise
+@tractor.context
+async def sleep_forever_aio_ctx(
+ ctx: tractor.Context,
+ expect_err: str = 'trio.Cancelled',
+) -> None:
+ '''
+ `@context` shim so a parent can spawn a forever-sleeping
+ infected-`asyncio` task via `Portal.open_context()` and cancel it
+ (via `Portal.cancel_actor()` or an enclosing `trio` cancel scope),
+ asserting the graceful `trio.Cancelled` teardown.
+
+ Replaces the legacy `ActorNursery.run_in_actor()` spawn the
+ aio-cancel tests below used to rely on (removed with #477).
+
+ '''
+ await ctx.started()
+ await asyncio_actor(
+ target='aio_sleep_forever',
+ expect_err=expect_err,
+ )
+
+
def test_aio_simple_error(
reg_addr: tuple[str, int],
debug_mode: bool,
@@ -172,8 +196,9 @@ async def main():
registry_addrs=[reg_addr],
debug_mode=debug_mode,
) as an:
- await an.run_in_actor(
+ await to_actor.run(
asyncio_actor,
+ an=an,
target='sleep_and_err',
expect_err='AssertionError',
infect_asyncio=True,
@@ -207,18 +232,34 @@ def test_tractor_cancels_aio(
'''
async def main():
- async with tractor.open_nursery(
- debug_mode=debug_mode,
- registry_addrs=[reg_addr],
- ) as an:
- portal = await an.run_in_actor(
- asyncio_actor,
- target='aio_sleep_forever',
- expect_err='trio.Cancelled',
- infect_asyncio=True,
- )
- # cancel the entire remote runtime
- await portal.cancel_actor()
+ # anti-hang wall-clock cap: a per-test `trio.fail_after`
+ # is the blessed guard here since `pytest-timeout`'s
+ # global cap is intentionally off (see the `pyproject`
+ # NOTE — it breaks trio under fork backends). Generous +
+ # CPU-headroom-scaled bc this is an anti-hang guard, not
+ # a perf assertion; a wedged ria-reaper once hung this
+ # test forever (the `._ria_nursery`-removal regression).
+ from .conftest import cpu_perf_headroom
+ with trio.fail_after(9 * cpu_perf_headroom()):
+ async with tractor.open_nursery(
+ debug_mode=debug_mode,
+ registry_addrs=[reg_addr],
+ ) as an:
+ p: tractor.Portal = await an.start_actor(
+ 'aio_daemon',
+ enable_modules=[__name__],
+ infect_asyncio=True,
+ )
+ async with (
+ # `.cancel_actor()` below tears the ctx down
+ expect_ctxc(yay=True),
+ p.open_context(
+ sleep_forever_aio_ctx,
+ ) as (ctx, first),
+ ):
+ # cancel the entire remote runtime while its
+ # infected-`asyncio` task sleeps forever
+ await p.cancel_actor()
trio.run(main)
@@ -236,13 +277,19 @@ async def main():
with trio.move_on_after(1):
async with tractor.open_nursery(
registry_addrs=[reg_addr],
- ) as tn:
- await tn.run_in_actor(
- asyncio_actor,
- target='aio_sleep_forever',
- expect_err='trio.Cancelled',
+ ) as an:
+ p: tractor.Portal = await an.start_actor(
+ 'aio_daemon',
+ enable_modules=[__name__],
infect_asyncio=True,
)
+ async with p.open_context(
+ sleep_forever_aio_ctx,
+ ) as (ctx, first):
+ # block until the enclosing `move_on_after`
+ # cancels this `trio` scope, tearing down the
+ # infected-aio task via ctx cancellation
+ await trio.sleep_forever()
trio.run(main)
@@ -392,17 +439,16 @@ async def main():
async with tractor.open_nursery(
registry_addrs=[reg_addr],
) as an:
- p: tractor.Portal = await an.run_in_actor(
- asyncio_actor,
- target='aio_cancel',
- expect_err='tractor.to_asyncio.AsyncioCancelled',
- infect_asyncio=True,
- )
- # NOTE: normally the `an.__aexit__()` waits on the
- # portal's result but we do it explicitly here
- # to avoid indent levels.
+ # `to_actor.run()` blocks on the one-shot's result and
+ # relays the remote error here in the caller's task.
with trio.fail_after(1 + delay):
- await p.wait_for_result()
+ await to_actor.run(
+ asyncio_actor,
+ an=an,
+ target='aio_cancel',
+ expect_err='tractor.to_asyncio.AsyncioCancelled',
+ infect_asyncio=True,
+ )
with pytest.raises(
expected_exception=(RemoteActorError, ExceptionGroup),
@@ -603,13 +649,13 @@ async def main():
async with tractor.open_nursery(
registry_addrs=[reg_addr],
) as an:
- portal = await an.run_in_actor(
+ # should raise RAE diectly
+ await to_actor.run(
stream_from_aio,
+ an=an,
infect_asyncio=True,
fan_out=fan_out,
)
- # should raise RAE diectly
- await portal.result()
trio.run(main)
@@ -622,13 +668,13 @@ async def main():
async with tractor.open_nursery(
registry_addrs=[reg_addr],
) as an:
- portal = await an.run_in_actor(
+ # should trigger remote actor error
+ await to_actor.run(
stream_from_aio,
+ an=an,
trio_raise_err=True,
infect_asyncio=True,
)
- # should trigger remote actor error
- await portal.result()
with pytest.raises(RemoteActorError) as excinfo:
trio.run(main)
@@ -658,14 +704,14 @@ async def main():
# enable_stack_on_sig=True,
registry_addrs=[reg_addr],
) as an:
- portal = await an.run_in_actor(
+ # should raise RAE diectly
+ print('waiting on final infected subactor result..')
+ res: None = await to_actor.run(
stream_from_aio,
+ an=an,
trio_exit_early=True,
infect_asyncio=True,
)
- # should raise RAE diectly
- print('waiting on final infected subactor result..')
- res: None = await portal.wait_for_result()
assert res is None
print(f'infected subactor returned result: {res!r}\n')
@@ -709,15 +755,15 @@ async def main():
debug_mode=debug_mode,
# enable_stack_on_sig=True,
) as an:
- portal = await an.run_in_actor(
+ # should raise RAE diectly
+ print('waiting on final infected subactor result..')
+ res: None = await to_actor.run(
stream_from_aio,
+ an=an,
infect_asyncio=True,
trio_exit_early=False,
aio_exit_early=True,
)
- # should raise RAE diectly
- print('waiting on final infected subactor result..')
- res: None = await portal.wait_for_result()
assert res is None
print(f'infected subactor returned result: {res!r}\n')
@@ -749,17 +795,19 @@ async def main():
registry_addrs=[reg_addr],
debug_mode=debug_mode,
) as an:
- portal = await an.run_in_actor(
+ # should trigger RAE directly, not an eg.
+ await to_actor.run(
stream_from_aio,
+ an=an,
aio_raise_err=True,
infect_asyncio=True,
)
- # should trigger RAE directly, not an eg.
- await portal.result()
with pytest.raises(
- # NOTE: bc we directly wait on `Portal.result()` instead
- # of capturing it inside the `ActorNursery` machinery.
+ # NOTE: bc `to_actor.run()` blocks on + relays the result
+ # in the caller's task (not captured inside the
+ # `ActorNursery` teardown machinery) we get a direct RAE,
+ # not an eg.
expected_exception=RemoteActorError,
) as excinfo:
trio.run(main)
diff --git a/tests/test_inter_peer_cancellation.py b/tests/test_inter_peer_cancellation.py
index e0bf5dab2..05cea5dca 100644
--- a/tests/test_inter_peer_cancellation.py
+++ b/tests/test_inter_peer_cancellation.py
@@ -163,12 +163,12 @@ def test_do_not_swallow_error_before_started_by_remote_contextcancelled(
async def main():
async with tractor.open_nursery(
debug_mode=debug_mode,
- ) as n:
- portal = await n.start_actor(
+ ) as an:
+ portal = await an.start_actor(
'errorer',
enable_modules=[__name__],
)
- await n.start_actor(
+ await an.start_actor(
'sleeper',
enable_modules=[__name__],
)
diff --git a/tests/test_log_sys.py b/tests/test_log_sys.py
index 3870e825d..1894bd1fb 100644
--- a/tests/test_log_sys.py
+++ b/tests/test_log_sys.py
@@ -2,16 +2,19 @@
`tractor.log`-wrapping unit tests.
'''
+import logging
from pathlib import Path
import shutil
from types import ModuleType
import pytest
import tractor
+import trio
from tractor import (
_code_load,
log,
)
+from tractor.ipc import _chan
def test_root_pkg_not_duplicated_in_logger_name():
@@ -222,6 +225,88 @@ def test_add_log_level_pluggable():
delattr(log.StackLevelAdapter, name.lower())
+@pytest.mark.parametrize(
+ 'suppression',
+ [
+ 'level',
+ 'logger',
+ 'global',
+ ],
+)
+def test_log_guard_skips_payload_formatting(
+ monkeypatch: pytest.MonkeyPatch,
+ suppression: str,
+):
+ '''
+ Suppressed transport logs must not render payloads.
+
+ The original hot-path guard compared only the effective logger
+ level. A logger disabled through its `Logger.disabled` flag or
+ the global `logging.disable()` threshold could therefore still
+ call `pformat()` before `Logger.isEnabledFor()` discarded the
+ record.
+
+ Exercise effective-level, per-logger, and global suppression
+ independently. A poisoned `_chan.pformat()` proves rendering is
+ skipped, while the fake transport proves `Channel.send()` still
+ transmits the original payload and traceback-hiding flag.
+
+ '''
+ sent: list[tuple[object, bool]] = []
+
+ class FakeTransport:
+ async def send(
+ self,
+ payload: object,
+ hide_tb: bool = False,
+ ) -> None:
+ sent.append((payload, hide_tb))
+
+ def fail_pformat(payload: object) -> str:
+ raise AssertionError(
+ f'suppressed log rendered payload: {payload!r}'
+ )
+
+ chan_log = log.get_logger(
+ name=f'guard_test.{suppression}',
+ )
+ std_log = chan_log.logger
+ orig_level: int = std_log.level
+ orig_disable: int = logging.root.manager.disable
+ transport_level: int = log.CUSTOM_LEVELS['TRANSPORT']
+
+ monkeypatch.setattr(_chan, 'log', chan_log)
+ monkeypatch.setattr(_chan, 'pformat', fail_pformat)
+ try:
+ logging.disable(logging.NOTSET)
+ std_log.setLevel(transport_level)
+
+ if suppression == 'level':
+ std_log.setLevel(logging.INFO)
+ elif suppression == 'logger':
+ monkeypatch.setattr(std_log, 'disabled', True)
+ else:
+ logging.disable(logging.CRITICAL)
+
+ assert not chan_log.isEnabledFor(transport_level)
+
+ transport = FakeTransport()
+ chan = _chan.Channel(transport=transport)
+ payload = object()
+
+ async def send_payload() -> None:
+ await chan.send(
+ payload,
+ hide_tb=True,
+ )
+
+ trio.run(send_payload)
+ assert sent == [(payload, True)]
+ finally:
+ std_log.setLevel(orig_level)
+ logging.disable(orig_disable)
+
+
# TODO, moar tests against existing feats:
# ------ - ------
# - [ ] color settings?
diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py
index 1bf8563a6..27f2058d1 100644
--- a/tests/test_pubsub.py
+++ b/tests/test_pubsub.py
@@ -139,9 +139,9 @@ async def test_required_args(callwith_expecterror):
with pytest.raises(err):
await func(**kwargs)
else:
- async with tractor.open_nursery() as n:
+ async with tractor.open_nursery() as an:
- portal = await n.start_actor(
+ portal = await an.start_actor(
name='pubber',
enable_modules=[__name__],
)
@@ -176,33 +176,55 @@ def test_multi_actor_subs_arbiter_pub(
async def main():
- async with tractor.open_nursery(
- registry_addrs=[reg_addr],
- enable_modules=[__name__],
- ) as n:
+ async with (
+ tractor.open_nursery(
+ registry_addrs=[reg_addr],
+ enable_modules=[__name__],
+ ) as an,
+ trio.open_nursery() as tn,
+ ):
name = 'root'
if pub_actor == 'streamer':
# start the publisher as a daemon
- master_portal = await n.start_actor(
+ master_portal = await an.start_actor(
'streamer',
enable_modules=[__name__],
)
name = 'streamer'
- even_portal = await n.run_in_actor(
- subs,
- which=['even'],
- name='evens',
- pub_actor_name=name
+ # spawn the two subscriber actors as daemons and run
+ # `subs()` on each as a background task (was the legacy
+ # `run_in_actor()`); keep the portals for the explicit
+ # `cancel_actor()` teardown below. Each runner swallows
+ # the teardown error that `cancel_actor()` relays.
+ async def _run_subs(
+ portal: tractor.Portal,
+ which: list[str],
+ ) -> None:
+ try:
+ await portal.run(
+ subs,
+ which=which,
+ pub_actor_name=name,
+ )
+ except (
+ tractor.RemoteActorError,
+ tractor.ContextCancelled,
+ ):
+ pass # expected once we `cancel_actor()` below
+
+ even_portal = await an.start_actor(
+ 'evens',
+ enable_modules=[__name__],
)
- odd_portal = await n.run_in_actor(
- subs,
- which=['odd'],
- name='odds',
- pub_actor_name=name
+ odd_portal = await an.start_actor(
+ 'odds',
+ enable_modules=[__name__],
)
+ tn.start_soon(_run_subs, even_portal, ['even'])
+ tn.start_soon(_run_subs, odd_portal, ['odd'])
async with tractor.wait_for_actor('evens'):
# block until 2nd actor is initialized
@@ -257,6 +279,9 @@ async def main():
else:
await master_portal.cancel_actor()
+ # drop the bg `subs()` runners now the subs are cancelled
+ tn.cancel_scope.cancel()
+
trio.run(main)
@@ -269,9 +294,9 @@ async def main():
async with tractor.open_nursery(
registry_addrs=[reg_addr],
enable_modules=[__name__],
- ) as n:
+ ) as an:
- portal = await n.start_actor(
+ portal = await an.start_actor(
'streamer',
enable_modules=[__name__],
)
diff --git a/tests/test_resource_cache.py b/tests/test_resource_cache.py
index 049c0a6b9..55c636585 100644
--- a/tests/test_resource_cache.py
+++ b/tests/test_resource_cache.py
@@ -9,6 +9,7 @@
import pytest
import trio
+from trio.testing import wait_all_tasks_blocked
import tractor
from tractor.trionics import (
maybe_open_context,
@@ -94,6 +95,232 @@ async def enter_cached_mngr(name: str):
trio.run(main)
+def test_last_moc_user_waits_for_resource_exit():
+ '''
+ Verify the final user cannot return before resource teardown.
+
+ Previously the final `maybe_open_context()` user only signalled
+ `_Cache.run_ctx()` through its `no_more_users` event. The user
+ then returned while the service task was still running the
+ resource's `__aexit__()`, so callers could observe stale external
+ state immediately after their `async with` block.
+
+ The resource sets `exit_started` before blocking on
+ `allow_exit`. The user task must remain inside MOC until the test
+ releases that deterministic checkpoint and `__aexit__()` sets
+ `exit_finished`.
+
+ '''
+ async def main():
+ exit_started = trio.Event()
+ allow_exit = trio.Event()
+ exit_finished = trio.Event()
+ user_returned = trio.Event()
+
+ @acm
+ async def open_resource():
+ try:
+ yield
+ finally:
+ exit_started.set()
+ await allow_exit.wait()
+ exit_finished.set()
+
+ async def use_resource():
+ async with maybe_open_context(open_resource):
+ pass
+
+ assert exit_finished.is_set()
+ user_returned.set()
+
+ async with (
+ tractor.open_root_actor(),
+ trio.open_nursery() as tn,
+ ):
+ tn.start_soon(use_resource)
+ await exit_started.wait()
+ assert not user_returned.is_set()
+ allow_exit.set()
+ await user_returned.wait()
+
+ trio.run(main)
+
+
+def test_moc_delivers_resource_exit_error():
+ '''
+ Verify a resource exit error reaches the final MOC user.
+
+ Previously `_Cache.run_ctx()` executed the cached resource's
+ `__aexit__()` after the final user had returned. An exit failure
+ therefore surfaced later through the actor service nursery rather
+ than at the user's `async with maybe_open_context()` boundary.
+
+ This resource raises a unique `ResourceExitError` during exit.
+ Catching that exact instance around MOC proves the service task
+ delivered the failure to the final user without replacing it.
+
+ '''
+ class ResourceExitError(Exception):
+ pass
+
+ exit_error = ResourceExitError('resource exit failed')
+
+ async def main():
+ @acm
+ async def open_resource():
+ yield
+ raise exit_error
+
+ async with tractor.open_root_actor():
+ with pytest.raises(ResourceExitError) as exc_info:
+ async with maybe_open_context(open_resource):
+ pass
+
+ assert exc_info.value is exit_error
+
+ trio.run(main)
+
+
+def test_moc_final_user_cancellation_waits_for_exit():
+ '''
+ Verify final-user cancellation still waits for successful exit.
+
+ Previously cancellation escaped the final MOC user immediately
+ after it signalled `_Cache.run_ctx()`, leaving resource exit to
+ finish later in the actor service task. This violated the context
+ manager boundary even when cleanup itself succeeded.
+
+ The consumer cancels its own scope while holding the sole cached
+ resource. The resource sets `exit_finished` from its `finally`
+ block, and the consumer checks that event immediately after its
+ cancel scope catches `trio.Cancelled`. This proves MOC's
+ completion wait is shielded without suppressing the original
+ cancellation.
+
+ '''
+ async def main():
+ exit_finished = trio.Event()
+
+ @acm
+ async def open_resource():
+ try:
+ yield
+ finally:
+ exit_finished.set()
+
+ async with tractor.open_root_actor():
+ with trio.CancelScope() as cs:
+ async with maybe_open_context(open_resource):
+ cs.cancel()
+ await trio.sleep_forever()
+
+ assert cs.cancelled_caught
+ assert exit_finished.is_set()
+
+ trio.run(main)
+
+
+def test_moc_exit_error_masks_final_user_cancellation():
+ '''
+ Verify cleanup errors survive final-user cancellation.
+
+ A cancelled final user previously signalled `no_more_users` and
+ propagated `trio.Cancelled` before `_Cache.run_ctx()` completed
+ resource exit. If `__aexit__()` then failed, its error was
+ detached from the API call which caused teardown.
+
+ The consumer cancels its own scope at a deterministic checkpoint
+ inside MOC. Resource exit raises `ResourceExitError`; observing
+ that exact error outside the cancel scope proves MOC shields the
+ completion wait and applies normal context-manager masking, where
+ a cleanup failure replaces the active cancellation.
+
+ '''
+ class ResourceExitError(Exception):
+ pass
+
+ exit_error = ResourceExitError('resource exit failed')
+
+ async def main():
+ @acm
+ async def open_resource():
+ yield
+ raise exit_error
+
+ async with tractor.open_root_actor():
+ with pytest.raises(ResourceExitError) as exc_info:
+ with trio.CancelScope() as cs:
+ async with maybe_open_context(open_resource):
+ cs.cancel()
+ await trio.sleep_forever()
+
+ assert exc_info.value is exit_error
+
+ trio.run(main)
+
+
+def test_moc_service_nursery_cancellation_completes_exit():
+ '''
+ Verify service-nursery cancellation cannot strand a final user.
+
+ `_Cache.run_ctx()` and an MOC consumer may share a
+ caller-provided service nursery. Cancelling that nursery
+ interrupts the service task's `no_more_users` wait and the
+ consumer body together. A shielded final-user wait would deadlock
+ if `run_ctx()` failed to publish completion while propagating its
+ own `trio.Cancelled`.
+
+ The outer task waits for resource entry, then cancels the exact
+ nursery containing both tasks. The resource shields one cleanup
+ checkpoint and sets `exit_finished`; observing both it and
+ `service_finished` proves cancellation propagated normally while
+ MOC's completion handshake terminated deterministically.
+
+ '''
+ async def main():
+ resource_entered = trio.Event()
+ exit_finished = trio.Event()
+ service_finished = trio.Event()
+ service_tn: trio.Nursery|None = None
+
+ @acm
+ async def open_resource():
+ try:
+ resource_entered.set()
+ yield
+ finally:
+ with trio.CancelScope(shield=True):
+ await trio.lowlevel.checkpoint()
+ exit_finished.set()
+
+ async def use_resource(tn: trio.Nursery):
+ async with maybe_open_context(
+ open_resource,
+ tn=tn,
+ ):
+ await trio.sleep_forever()
+
+ async def run_service():
+ nonlocal service_tn
+
+ async with trio.open_nursery() as tn:
+ service_tn = tn
+ tn.start_soon(use_resource, tn)
+
+ service_finished.set()
+
+ async with trio.open_nursery() as outer_tn:
+ outer_tn.start_soon(run_service)
+ await resource_entered.wait()
+ assert service_tn is not None
+ service_tn.cancel_scope.cancel()
+ await service_finished.wait()
+
+ assert exit_finished.is_set()
+
+ trio.run(main)
+
+
@tractor.context
async def streamer(
ctx: tractor.Context,
@@ -548,43 +775,52 @@ def test_moc_reentry_during_teardown(
loglevel: str,
):
'''
- Reproduce the piker `open_cached_client('kraken')` race:
-
- - same `acm_func`, NO kwargs (identical `ctx_key`)
- - multiple tasks share the cached resource
- - all users exit -> teardown starts
- - a NEW task enters during `_Cache.run_ctx.__aexit__`
- - `values[ctx_key]` is gone (popped in inner finally)
- but `resources[ctx_key]` still exists (outer finally
- hasn't run yet bc the acm cleanup has checkpoints)
- - old code: `assert not resources.get(ctx_key)` FIRES
-
- This models the real-world scenario where `brokerd.kraken`
- tasks concurrently call `open_cached_client('kraken')`
- (same `acm_func`, empty kwargs, shared `ctx_key`) and
- the teardown/re-entry race triggers intermittently.
+ Reproduce re-entry while an identical cached context exits.
+
+ - multiple tasks use the same `acm_func` with no kwargs,
+ producing an identical `ctx_key`;
+ - all users leave and the final user starts resource teardown;
+ - `_Cache.run_ctx()` removes the cached value and resource entry
+ before entering the resource's blocking `__aexit__()` body;
+ - a new task attempts to enter that same `ctx_key` during exit;
+ - the per-key lock keeps that entrant queued until exit
+ completes;
+ - the entrant then receives a fresh cache miss and resource.
+
+ Without teardown sharing the registration lock, re-entry could
+ race resource replacement while the prior generation was still
+ exiting. The final user could also return before that exit
+ completed.
+
+ The first resource generation signals `in_aexit` and waits on
+ `allow_aexit`. The re-entry task signals `reentry_started` and
+ blocks inside MOC; only after `wait_all_tasks_blocked()` confirms
+ that ordering does the coordinator release cleanup. The entrant
+ must then receive a fresh cache miss. `first_done` additionally
+ proves the first MOC user observed completed teardown before
+ returning.
'''
async def main():
in_aexit = trio.Event()
+ allow_aexit = trio.Event()
+ reentry_started = trio.Event()
+ generation: int = 0
@acm
async def cached_client():
'''
- Simulates `kraken.api.get_client()`:
- - no params (all callers share one `ctx_key`)
- - slow-ish cleanup to widen the race window
- between `values.pop()` and `resources.pop()`
- inside `_Cache.run_ctx`.
+ Simulate a no-argument `kraken.api.get_client()`.
'''
+ nonlocal generation
+
+ generation += 1
+ resource_generation: int = generation
yield 'the-client'
- # Signal that we're in __aexit__ — at this
- # point `values` has already been popped by
- # `run_ctx`'s inner finally, but `resources`
- # is still alive (outer finally hasn't run).
- in_aexit.set()
- await trio.sleep(10)
+ if resource_generation == 1:
+ in_aexit.set()
+ await allow_aexit.wait()
first_done = trio.Event()
@@ -598,16 +834,25 @@ async def use_and_exit():
async def reenter_during_teardown():
'''
Wait for the acm's `__aexit__` to start (meaning
- `values` is popped but `resources` still exists),
- then re-enter — triggering the assert.
+ the cached value is no longer available), then re-enter.
'''
await in_aexit.wait()
+
+ # Tell the coordinator this task is about to enter MOC.
+ # `Event.set()` is not a checkpoint. Though `async with`
+ # awaits MOC's `__aenter__()`, its async generator runs
+ # synchronously until the held per-key `lock.acquire()`
+ # actually suspends this task.
+ reentry_started.set()
async with maybe_open_context(
cached_client,
) as (cache_hit, value):
+ assert not cache_hit
assert value == 'the-client'
+ await first_done.wait()
+
with trio.fail_after(5):
async with (
tractor.open_root_actor(
@@ -619,5 +864,15 @@ async def reenter_during_teardown():
):
tn.start_soon(use_and_exit)
tn.start_soon(reenter_during_teardown)
+ await reentry_started.wait()
+
+ # Wait until the re-entry task is queued on MOC's
+ # per-key lock while `_Cache.run_ctx()` remains
+ # blocked in the first generation's `__aexit__()`.
+ # Only then release cleanup, making the intended
+ # enter-during-sibling-exit ordering deterministic.
+ await wait_all_tasks_blocked()
+ assert not first_done.is_set()
+ allow_aexit.set()
trio.run(main)
diff --git a/tests/test_ringbuf.py b/tests/test_ringbuf.py
index e55a87b90..9db53f582 100644
--- a/tests/test_ringbuf.py
+++ b/tests/test_ringbuf.py
@@ -131,9 +131,12 @@ async def main():
child_read_shm,
**common_kwargs,
total_bytes=total_bytes,
- ) as (sctx, _sent),
+ ) as (rctx, _sent),
):
- await recv_p.result()
+ # ctx-acm exits await each child task's
+ # `Return` (the prior `recv_p.result()` here
+ # was a daemon-portal no-op).
+ pass
await send_p.cancel_actor()
await recv_p.cancel_actor()
diff --git a/tests/test_rpc.py b/tests/test_rpc.py
index 6e2b414ce..a033b6af6 100644
--- a/tests/test_rpc.py
+++ b/tests/test_rpc.py
@@ -107,12 +107,13 @@ async def main():
# do that if actually debugging subactor but keep it
# disabled for the test.
# debug_mode=True,
- ) as n:
+ ) as an:
actor = tractor.current_actor()
assert actor.is_registrar
- await n.run_in_actor(
+ await tractor.to_actor.run(
sleep_back_actor,
+ an=an,
actor_name=subactor_requests_to,
name='subactor',
diff --git a/tests/test_runtime.py b/tests/test_runtime.py
index 782af81ee..6ea970b3f 100644
--- a/tests/test_runtime.py
+++ b/tests/test_runtime.py
@@ -78,13 +78,12 @@ async def test_lifetime_stack_wipes_tmpfile(
async with tractor.open_nursery(
loglevel=loglevel,
) as an:
- await ( # inlined `tractor.Portal`
- await an.run_in_actor(
- crash_and_clean_tmpdir,
- tmp_file_path=path,
- error=error_in_child,
- )
- ).result()
+ await tractor.to_actor.run(
+ crash_and_clean_tmpdir,
+ an=an,
+ tmp_file_path=path,
+ error=error_in_child,
+ )
except (
tractor.RemoteActorError,
BaseExceptionGroup,
diff --git a/tests/test_spawning.py b/tests/test_spawning.py
index 6237282e4..ce9db1154 100644
--- a/tests/test_spawning.py
+++ b/tests/test_spawning.py
@@ -48,9 +48,11 @@ async def spawn(
actor: tractor.Actor = tractor.current_actor()
assert actor.is_registrar == should_be_root
- # spawns subproc here
- portal: tractor.Portal = await an.run_in_actor(
- fn=spawn,
+ # recursively spawn this same `spawn()` fn as the lone
+ # task of a one-shot child subactor and get its result.
+ result = await tractor.to_actor.run(
+ spawn,
+ an=an,
# spawning args
name='sub-actor',
@@ -62,16 +64,6 @@ async def spawn(
data=data_to_pass_down,
reg_addr=reg_addr,
)
-
- assert len(an._children) == 1
- assert (
- portal.channel.aid.uid
- in
- tractor.current_actor().ipc_server._peers
- )
-
- # get result from child subactor
- result = await portal.result()
assert result == 10
return result
else:
@@ -79,7 +71,7 @@ async def spawn(
return 10
-def test_run_in_actor_same_func_in_child(
+def test_to_actor_run_same_func_in_child(
reg_addr: tuple,
debug_mode: bool,
):
@@ -159,21 +151,16 @@ async def test_most_beautiful_word(
async with tractor.open_nursery(
debug_mode=debug_mode,
) as an:
- portal = await an.run_in_actor(
+ res: Any = await tractor.to_actor.run(
cellar_door,
+ an=an,
return_value=return_value,
name='some_linguist',
)
-
- res: Any = await portal.wait_for_result()
assert res == return_value
- # The ``async with`` will unblock here since the 'some_linguist'
- # actor has completed its main task ``cellar_door``.
-
- # this should pull the cached final result already captured during
- # the nursery block exit.
- res: Any = await portal.wait_for_result()
- assert res == return_value
+ # The ``async with`` unblocks here — the 'some_linguist'
+ # one-shot actor completed its lone task ``cellar_door`` and
+ # was reaped by `to_actor.run()`.
print(res)
@@ -215,9 +202,10 @@ async def main():
start_method=start_method,
registry_addrs=[reg_addr],
- ) as tn:
- await tn.run_in_actor(
+ ) as an:
+ await tractor.to_actor.run(
check_loglevel,
+ an=an,
loglevel=level,
level=level,
)
@@ -267,11 +255,11 @@ async def check_parent_main_inheritance(
return has_data
-def test_run_in_actor_can_skip_parent_main_inheritance(
+def test_to_actor_run_can_skip_parent_main_inheritance(
start_method: str, # <- only support on `trio` backend rn.
):
'''
- Verify ``inherit_parent_main=False`` on ``run_in_actor()``
+ Verify ``inherit_parent_main=False`` on ``to_actor.run()``
prevents parent ``__main__`` data from reaching the child.
'''
@@ -284,21 +272,21 @@ async def main():
async with tractor.open_nursery(start_method='trio') as an:
# Default: child receives parent __main__ bootstrap data
- replaying = await an.run_in_actor(
+ await tractor.to_actor.run(
check_parent_main_inheritance,
+ an=an,
name='replaying-parent-main',
expect_inherited=True,
)
- await replaying.result()
# Opt-out: child gets no parent __main__ data
- isolated = await an.run_in_actor(
+ await tractor.to_actor.run(
check_parent_main_inheritance,
+ an=an,
name='isolated-parent-main',
inherit_parent_main=False,
expect_inherited=False,
)
- await isolated.result()
trio.run(main)
diff --git a/tests/test_to_actor.py b/tests/test_to_actor.py
new file mode 100644
index 000000000..f8aba6da0
--- /dev/null
+++ b/tests/test_to_actor.py
@@ -0,0 +1,271 @@
+'''
+`tractor.to_actor`: one-shot single-remote-task API suite.
+
+Verifies the "spiritual successor" to (and replacement of)
+the removed legacy `ActorNursery.run_in_actor()`; see
+https://github.com/goodboy/tractor/issues/477
+
+'''
+from functools import partial
+
+import pytest
+import trio
+import tractor
+from tractor import (
+ RemoteActorError,
+ to_actor,
+)
+from tractor._testing import tractor_test
+
+
+async def add_one(
+ n: int,
+) -> int:
+ return n + 1
+
+
+async def raise_value_error() -> None:
+ raise ValueError('kaboom')
+
+
+@tractor_test
+async def test_one_shot_in_private_nursery(
+ start_method: str,
+ debug_mode: bool,
+):
+ '''
+ No `an`/`portal` provided: a private actor-nursery
+ is opened (and torn down) scoped to just the call.
+
+ '''
+ assert await to_actor.run(
+ add_one,
+ n=1,
+ ) == 2
+
+
+def test_one_shot_boots_implicit_runtime(
+ reg_addr: tuple,
+ start_method: str,
+ loglevel: str,
+):
+ '''
+ Outside any actor-runtime `to_actor.run()` boots one
+ implicitly (just like bare `open_nursery()` usage)
+ configured via pass-through `runtime_kwargs`.
+
+ '''
+ async def main() -> None:
+ assert tractor.current_actor(
+ err_on_no_runtime=False,
+ ) is None
+ result = await to_actor.run(
+ add_one,
+ n=41,
+ runtime_kwargs=dict(
+ registry_addrs=[reg_addr],
+ start_method=start_method,
+ loglevel=loglevel,
+ ),
+ )
+ assert result == 42
+
+ trio.run(main)
+
+
+@tractor_test
+async def test_remote_error_relayed_to_caller_task(
+ start_method: str,
+ debug_mode: bool,
+):
+ '''
+ A remote task error is raised directly in the
+ caller's task as a boxed `RemoteActorError` instead
+ of surfacing at actor-nursery teardown as with the
+ removed legacy `.run_in_actor()` API.
+
+ '''
+ with pytest.raises(RemoteActorError) as excinfo:
+ await to_actor.run(raise_value_error)
+
+ assert excinfo.value.boxed_type is ValueError
+
+
+@tractor_test
+async def test_spawn_from_caller_nursery(
+ start_method: str,
+ debug_mode: bool,
+):
+ '''
+ Pass a caller-managed `an: ActorNursery` for the
+ spawn; the subactor is still one-shot reaped by the
+ time the call returns.
+
+ '''
+ async with tractor.open_nursery() as an:
+ assert await to_actor.run(
+ add_one,
+ an=an,
+ n=10,
+ ) == 11
+
+
+@tractor_test
+async def test_remote_error_from_caller_nursery(
+ start_method: str,
+ debug_mode: bool,
+):
+ '''
+ With a caller-managed `an` the remote error also
+ surfaces in the caller's task, INSIDE the nursery
+ block, allowing inline (supervision-style) handling.
+
+ '''
+ async with tractor.open_nursery() as an:
+ with pytest.raises(RemoteActorError) as excinfo:
+ await to_actor.run(
+ raise_value_error,
+ an=an,
+ )
+
+ assert excinfo.value.boxed_type is ValueError
+
+
+@tractor_test
+async def test_reuse_existing_actor_via_portal(
+ start_method: str,
+ debug_mode: bool,
+):
+ '''
+ Pass `portal=` to schedule the one-shot task in an
+ already-running actor; no spawn, no implicit reap.
+
+ '''
+ async with tractor.open_nursery() as an:
+ portal: tractor.Portal = await an.start_actor(
+ 'one_shot_worker',
+ enable_modules=[__name__],
+ )
+ for i in range(3):
+ assert await to_actor.run(
+ add_one,
+ portal=portal,
+ n=i,
+ ) == i + 1
+
+ # still alive: caller owns the actor's lifetime.
+ await portal.cancel_actor()
+
+
+@tractor_test
+async def test_concurrent_one_shots_from_task_nursery(
+ start_method: str,
+ debug_mode: bool,
+):
+ '''
+ The worker-pool-ish pattern from #477: concurrency
+ is composed with a plain (caller-side) `trio` task
+ nursery scheduling multiple one-shot calls against
+ a shared caller-managed actor-nursery; error
+ collection thus lives entirely in caller-code.
+
+ '''
+ results: dict[int, int] = {}
+
+ async def one_shot(
+ an: tractor.ActorNursery,
+ i: int,
+ ) -> None:
+ results[i] = await to_actor.run(
+ add_one,
+ an=an,
+ name=f'one_shot_{i}',
+ n=i,
+ )
+
+ async with (
+ tractor.open_nursery() as an,
+ trio.open_nursery() as tn,
+ ):
+ for i in range(4):
+ tn.start_soon(one_shot, an, i)
+
+ assert results == {
+ i: i + 1 for i in range(4)
+ }
+
+
+def test_rejects_sync_fn():
+ '''
+ Non-async callables error BEFORE any spawn (or even
+ runtime-boot) happens.
+
+ '''
+ def not_async() -> None:
+ ...
+
+ with pytest.raises(TypeError):
+ trio.run(
+ partial(
+ to_actor.run,
+ not_async,
+ )
+ )
+
+
+def test_rejects_streaming_fn():
+ '''
+ Async-gen (streaming) fns are not one-shot-able,
+ same constraint as `Portal.run()`.
+
+ '''
+ async def agen():
+ yield 1
+
+ with pytest.raises(TypeError):
+ trio.run(
+ partial(
+ to_actor.run,
+ agen,
+ )
+ )
+
+
+def test_rejects_portal_and_an_combo():
+ '''
+ `portal=` and `an=` are mutually exclusive
+ placement options.
+
+ '''
+ with pytest.raises(ValueError):
+ trio.run(
+ partial(
+ to_actor.run,
+ add_one,
+ portal=object(),
+ an=object(),
+ n=1,
+ )
+ )
+
+
+def test_rejects_runtime_kwargs_with_placement():
+ '''
+ `runtime_kwargs` only applies when the call opens
+ its own private actor-nursery; passing it alongside
+ a placement opt is an error, never silently
+ ignored.
+
+ '''
+ with pytest.raises(ValueError):
+ trio.run(
+ partial(
+ to_actor.run,
+ add_one,
+ an=object(),
+ runtime_kwargs=dict(
+ loglevel='cancel',
+ ),
+ n=1,
+ )
+ )
diff --git a/tests/trionics/test_taskc.py b/tests/trionics/test_taskc.py
new file mode 100644
index 000000000..0c59f6df7
--- /dev/null
+++ b/tests/trionics/test_taskc.py
@@ -0,0 +1,333 @@
+'''
+`tractor.trionics._taskc.start_or_cancel()` unit tests.
+
+`trio.Nursery.start()` collapses an out-of-band (ancestor)
+cancellation into a lossy,
+
+ `RuntimeError('child exited without calling
+ task_status.started()')`
+
+whenever the started child exits pre-`.started()` WITHOUT
+propagating the ambient `trio.Cancelled`; a common outcome
+when the child (or any lib code it calls) runs a graceful
+teardown which absorbs the cancel and returns early. Our
+`start_or_cancel()` wrapper re-surfaces the real in-flight
+cancellation in that case so the true root error/cancel
+propagates to the `.start()` caller instead.
+
+These tests verify both that repair AND document upstream
+`trio`'s current lossy behaviour via the
+`use_start_or_cancel=False` parametrizations; if a `trio`
+upgrade breaks one of THOSE cases it likely means upstream
+shipped better startup-cancellation porcelain and our
+wrapper deserves a re-audit!
+
+The core use case was dug out of `modden`'s
+`progman.open_wks()` program-spawn machinery as per gh
+issue #474; the wrapper landed originally via gh PR #464.
+
+'''
+import pytest
+import trio
+from trio import TaskStatus
+
+from tractor.trionics import start_or_cancel
+
+
+async def absorbs_cancel_pre_started(
+ task_status: TaskStatus[None] = trio.TASK_STATUS_IGNORED,
+):
+ '''
+ Swallow the ambient (ancestor-scope) cancel and return
+ early, a naughty-but-realistic graceful-teardown pattern
+ and the exact shape which causes `trio.Nursery.start()`
+ to raise its lossy startup `RuntimeError` in place of
+ the real `trio.Cancelled`.
+
+ '''
+ try:
+ await trio.sleep_forever()
+ except trio.Cancelled:
+ return
+
+
+async def raise_val_err():
+ '''
+ Sibling task which blows up (fast) thus OOB-cancelling
+ the shared parent-nursery's cancel-scope.
+
+ '''
+ await trio.lowlevel.checkpoint()
+ raise ValueError('sibling blew up!')
+
+
+@pytest.mark.parametrize(
+ 'use_start_or_cancel',
+ [
+ True,
+ False,
+ ],
+)
+def test_sibling_err_not_masked_by_startup_rte(
+ use_start_or_cancel: bool,
+):
+ '''
+ The `modden.runtime.progman` use case: a sibling task
+ errors while the `.start()`-ed child is still
+ pre-`.started()`, OOB-cancelling the shared nursery
+ scope; the child absorbs its cancel (graceful teardown)
+ and exits early.
+
+ - with `start_or_cancel()` the in-flight cancellation
+ is re-surfaced as the real `trio.Cancelled` (then
+ absorbed by the cancelled nursery scope) so ONLY the
+ root-cause sibling error escapes the nursery.
+
+ - with a bare `.start()`, upstream `trio` (currently)
+ also delivers its lossy startup `RuntimeError`
+ alongside, obscuring that the child was in fact
+ cancelled due to the sibling's error.
+
+ `cancelled_at_start` records that the wrapper's own await
+ raises `Cancelled`, rather than merely relying on the
+ nursery's eventual exception-group shape.
+
+ '''
+ cancelled_at_start: list[bool] = []
+
+ async def main():
+ async with trio.open_nursery() as tn:
+ tn.start_soon(raise_val_err)
+ if use_start_or_cancel:
+ try:
+ await start_or_cancel(
+ tn,
+ absorbs_cancel_pre_started,
+ )
+ except trio.Cancelled:
+ cancelled_at_start.append(True)
+ raise
+ else:
+ await tn.start(absorbs_cancel_pre_started)
+
+ with pytest.raises(ExceptionGroup) as excinfo:
+ trio.run(main)
+
+ eg: ExceptionGroup = excinfo.value
+ val_eg, rest_eg = eg.split(ValueError)
+ assert len(val_eg.exceptions) == 1
+
+ if use_start_or_cancel:
+ assert cancelled_at_start == [True]
+ # the re-surfaced `Cancelled` is absorbed by the
+ # (sibling-error cancelled) nursery scope leaving
+ # NO startup-noise, just the root cause.
+ assert rest_eg is None
+ else:
+ # the `trio` wart: a lossy startup RTE rides along
+ # with (and distracts from) the root cause.
+ rte = rest_eg.exceptions[0]
+ assert isinstance(rte, RuntimeError)
+ assert 'child exited without calling' in rte.args[0]
+
+
+@pytest.mark.parametrize(
+ 'use_start_or_cancel',
+ [
+ True,
+ False,
+ ],
+)
+def test_pure_oob_cancel_not_morphed_to_rte(
+ use_start_or_cancel: bool,
+):
+ '''
+ A plain (error-free) ancestor `CancelScope.cancel()`
+ fired while the (cancel-absorbing) child is still
+ pre-`.started()`:
+
+ - `start_or_cancel()` re-surfaces the `Cancelled` so
+ the cancelled scope exits CLEAN, no error at all.
+
+ - a bare `.start()` (currently) morphs the plain
+ cancel into an (eg-wrapped) startup `RuntimeError`.
+
+ `cancelled_at_start` proves cancellation interrupts the
+ wrapper call itself before the cancelled scope exits.
+
+ '''
+ cancelled_at_start: list[bool] = []
+
+ async def main():
+ with trio.CancelScope() as cs:
+ async with trio.open_nursery() as tn:
+
+ async def canceller():
+ await trio.lowlevel.checkpoint()
+ cs.cancel()
+
+ tn.start_soon(canceller)
+ if use_start_or_cancel:
+ try:
+ await start_or_cancel(
+ tn,
+ absorbs_cancel_pre_started,
+ )
+ except trio.Cancelled:
+ cancelled_at_start.append(True)
+ raise
+ else:
+ await tn.start(
+ absorbs_cancel_pre_started,
+ )
+
+ assert cs.cancelled_caught
+
+ if use_start_or_cancel:
+ trio.run(main)
+ assert cancelled_at_start == [True]
+ else:
+ with pytest.raises(ExceptionGroup) as excinfo:
+ trio.run(main)
+
+ rte = excinfo.value.exceptions[0]
+ assert isinstance(rte, RuntimeError)
+ assert 'child exited without calling' in rte.args[0]
+
+
+@pytest.mark.parametrize(
+ 'use_start_or_cancel',
+ [
+ True,
+ False,
+ ],
+)
+def test_genuine_startup_rte_still_raised(
+ use_start_or_cancel: bool,
+):
+ '''
+ Absent ANY in-flight cancellation, a child exiting
+ cleanly without calling `task_status.started()` is a
+ genuine startup-protocol bug; `start_or_cancel()` must
+ re-raise the resulting `RuntimeError` exactly like a
+ bare `.start()` does.
+
+ '''
+ async def exits_wo_started(
+ task_status: TaskStatus[None] = (
+ trio.TASK_STATUS_IGNORED
+ ),
+ ):
+ await trio.lowlevel.checkpoint()
+
+ async def main():
+ async with trio.open_nursery() as tn:
+ with pytest.raises(RuntimeError) as excinfo:
+ if use_start_or_cancel:
+ await start_or_cancel(
+ tn,
+ exits_wo_started,
+ )
+ else:
+ await tn.start(exits_wo_started)
+
+ rte = excinfo.value
+ assert (
+ 'child exited without calling'
+ in
+ rte.args[0]
+ )
+
+ trio.run(main)
+
+
+@pytest.mark.parametrize(
+ 'rte_arg',
+ [
+ # Broad substring matches would wrongly demote either
+ # child-owned error to `Cancelled` under cancellation.
+ 'never got started!',
+ 'child exited without calling user hook',
+ # non-`str` first-arg edge; must not `TypeError`
+ # inside the wrapper's msg-match guard.
+ 1234,
+ ],
+)
+def test_childs_own_rte_never_demoted_to_cancel(
+ rte_arg: str|int,
+):
+ '''
+ A child's OWN `RuntimeError`, one which merely smells
+ like `trio`'s startup wording (or carries a non-`str`
+ first arg), raised under ambient cancellation must NOT
+ be demoted to a `trio.Cancelled` by the exact-msg-match
+ guard inside `start_or_cancel()`; the real error must
+ always propagate to the caller as the sole exception-group
+ leaf, preserving object identity.
+
+ '''
+ child_rte = RuntimeError(rte_arg)
+
+ async def cancels_cs_then_raises(
+ task_status: TaskStatus[None] = (
+ trio.TASK_STATUS_IGNORED
+ ),
+ ):
+ # cancel the ambient (ancestor) scope then raise
+ # sync-ly, no checkpoint between, so the child
+ # deterministically dies with ITS error while the
+ # caller is under effective cancellation.
+ cs.cancel()
+ raise child_rte
+
+ cs = trio.CancelScope()
+
+ async def main():
+ with cs:
+ async with trio.open_nursery() as tn:
+ await start_or_cancel(
+ tn,
+ cancels_cs_then_raises,
+ )
+
+ with pytest.raises(ExceptionGroup) as excinfo:
+ trio.run(main)
+
+ assert excinfo.value.exceptions == (child_rte,)
+
+
+def test_started_value_and_args_passthru():
+ '''
+ Happy path: positional args, the `name=` kwarg and the
+ `.started(value)`-delivered value all pass through
+ `start_or_cancel()` identically to a bare `.start()`.
+
+ '''
+ async def echo_started(
+ *args,
+ task_status: TaskStatus[tuple] = (
+ trio.TASK_STATUS_IGNORED
+ ),
+ ):
+ task_name: str = trio.lowlevel.current_task().name
+ task_status.started((
+ args,
+ task_name,
+ ))
+
+ async def main():
+ async with trio.open_nursery() as tn:
+ (
+ args,
+ task_name,
+ ) = await start_or_cancel(
+ tn,
+ echo_started,
+ 'chillin',
+ 10,
+ name='doggy',
+ )
+ assert args == ('chillin', 10)
+ assert task_name == 'doggy'
+
+ trio.run(main)
diff --git a/tractor/__init__.py b/tractor/__init__.py
index 287ac9404..6c7a6634e 100644
--- a/tractor/__init__.py
+++ b/tractor/__init__.py
@@ -62,6 +62,7 @@
post_mortem as post_mortem,
)
from . import msg as msg
+from . import to_actor as to_actor
from ._root import (
run_daemon as run_daemon,
open_root_actor as open_root_actor,
diff --git a/tractor/_context.py b/tractor/_context.py
index d538e3619..1aa372cfd 100644
--- a/tractor/_context.py
+++ b/tractor/_context.py
@@ -780,7 +780,7 @@ def _maybe_cancel_and_set_remote_error(
# `Portal.open_context()` has been opened since it's
# assumed that other portal APIs like,
# - `Portal.run()`,
- # - `ActorNursery.run_in_actor()`
+ # - `to_actor.run()`
# do their own error checking at their own call points and
# result processing.
diff --git a/tractor/_exceptions.py b/tractor/_exceptions.py
index d838544bb..5686533ab 100644
--- a/tractor/_exceptions.py
+++ b/tractor/_exceptions.py
@@ -1161,10 +1161,6 @@ def from_src_exc(
)
-class NoResult(RuntimeError):
- "No final result is expected for this actor"
-
-
class ModuleNotExposed(ModuleNotFoundError):
"The requested module is not exposed for RPC"
diff --git a/tractor/experimental/_pubsub.py b/tractor/experimental/_pubsub.py
index c9bf13d62..76c2cea0f 100644
--- a/tractor/experimental/_pubsub.py
+++ b/tractor/experimental/_pubsub.py
@@ -221,16 +221,19 @@ async def pub_service(get_topics):
import tractor
async with tractor.open_nursery() as n:
- portal = n.run_in_actor(
+ portal = await n.start_actor(
'publisher', # actor name
- partial( # func to execute in it
+ enable_modules=[__name__],
+ )
+ async with portal.open_stream_from(
+ partial( # func to execute in it
pub_service,
topics=('clicks', 'users'),
task_name='source1',
)
- )
- async for value in await portal.result():
- print(f"Subscriber received {value}")
+ ) as stream:
+ async for value in stream:
+ print(f"Subscriber received {value}")
Here, you don't need to provide the ``ctx`` argument since the
diff --git a/tractor/ipc/_chan.py b/tractor/ipc/_chan.py
index 10a800e47..a2450585a 100644
--- a/tractor/ipc/_chan.py
+++ b/tractor/ipc/_chan.py
@@ -198,9 +198,6 @@ async def from_addr(
# assert transport.raddr == addr
chan = Channel(transport=transport)
- # ?TODO, compact this into adapter level-methods?
- # -[ ] would avoid extra repr-calcs if level not active?
- # |_ how would the `calc_if_level` look though? func?
if log.at_least_level('runtime'):
from tractor.devx import (
pformat as _pformat,
@@ -325,10 +322,12 @@ async def send(
'''
__tracebackhide__: bool = hide_tb
try:
- log.transport(
- '=> send IPC msg:\n\n'
- f'{pformat(payload)}\n'
- )
+ if log.at_least_level('transport'):
+ # don't materialize the payload repr if not necessary
+ log.transport(
+ '=> send IPC msg:\n\n'
+ f'{pformat(payload)}\n'
+ )
# assert self._transport # but why typing?
await self._transport.send(
payload,
diff --git a/tractor/ipc/_transport.py b/tractor/ipc/_transport.py
index 0a38d14d7..dd3be1790 100644
--- a/tractor/ipc/_transport.py
+++ b/tractor/ipc/_transport.py
@@ -309,7 +309,10 @@ async def _iter_packets(self) -> AsyncGenerator[dict, None]:
log.transport(f'received header {size}') # type: ignore
msg_bytes: bytes = await self.recv_stream.receive_exactly(size)
- log.transport(f"received {msg_bytes}") # type: ignore
+ if log.at_least_level('transport'):
+ log.transport( # type: ignore
+ f'received {msg_bytes}'
+ )
try:
# NOTE: lookup the `trio.Task.context`'s var for
# the current `MsgCodec`.
diff --git a/tractor/log.py b/tractor/log.py
index 6c8c9bc8a..a87dd7c19 100644
--- a/tractor/log.py
+++ b/tractor/log.py
@@ -111,9 +111,7 @@ def at_least_level(
if isinstance(level, str):
level: int = CUSTOM_LEVELS[level.upper()]
- if log.getEffectiveLevel() <= level:
- return True
- return False
+ return log.isEnabledFor(level)
# TODO, compare with using a "filter" instead?
diff --git a/tractor/msg/_ops.py b/tractor/msg/_ops.py
index a134306f7..1b342ca6c 100644
--- a/tractor/msg/_ops.py
+++ b/tractor/msg/_ops.py
@@ -306,15 +306,15 @@ def decode_pld(
):
try:
pld: PayloadT = self._pld_dec.decode(pld)
- log.runtime(
- f'Decoded payload for\n'
- # f'\n'
- f'{msg}\n'
- # ^TODO?, ideally just render with `,
- # pld={decode}` in the `msg.pformat()`??
- f'where, '
- f'{type(msg).__name__}.pld={pld!r}\n'
- )
+ if log.at_least_level('runtime'):
+ # don't materialize the payload repr if not necessary
+ log.runtime(
+ f'Decoded payload for\n'
+ f'\n'
+ f'{msg}\n'
+ f'where, '
+ f'{type(msg).__name__}.pld={pld!r}\n'
+ )
return pld
except TypeError as typerr:
__tracebackhide__: bool = False
diff --git a/tractor/msg/types.py b/tractor/msg/types.py
index 4f3e33cc9..6fe775a43 100644
--- a/tractor/msg/types.py
+++ b/tractor/msg/types.py
@@ -304,12 +304,11 @@ class Start(
It is called by all the following public APIs:
- - `ActorNursery.run_in_actor()`
+ - `to_actor.run()`
- `Portal.run()`
`|_.run_from_ns()`
`|_.open_stream_from()`
- `|_._submit_for_result()`
- `Context.open_context()`
diff --git a/tractor/runtime/_portal.py b/tractor/runtime/_portal.py
index 738599ac1..6ee20ed7e 100644
--- a/tractor/runtime/_portal.py
+++ b/tractor/runtime/_portal.py
@@ -50,13 +50,11 @@
from ..log import get_logger
from ..msg import (
# Error,
- PayloadMsg,
NamespacePath,
Return,
)
from .._exceptions import (
ActorTooSlowError,
- NoResult,
TransportClosed,
)
from .._context import (
@@ -102,14 +100,6 @@ def __init__(
) -> None:
self._chan: Channel = channel
- # during the portal's lifetime
- self._final_result_pld: Any|None = None
- self._final_result_msg: PayloadMsg|None = None
-
- # When set to a ``Context`` (when _submit_for_result is called)
- # it is expected that ``result()`` will be awaited at some
- # point.
- self._expect_result_ctx: Context|None = None
self._streams: set[MsgStream] = set()
# TODO, this should be PRIVATE (and never used publicly)! since it's just
@@ -137,102 +127,6 @@ def channel(self) -> Channel:
)
return self.chan
- # TODO: factor this out into a `.highlevel` API-wrapper that uses
- # a single `.open_context()` call underneath.
- async def _submit_for_result(
- self,
- ns: str,
- func: str,
- **kwargs
- ) -> None:
-
- if self._expect_result_ctx is not None:
- raise RuntimeError(
- 'A pending main result has already been submitted'
- )
-
- self._expect_result_ctx: Context = await self.actor.start_remote_task(
- self.channel,
- nsf=NamespacePath(f'{ns}:{func}'),
- kwargs=kwargs,
- portal=self,
- )
-
- # TODO: we should deprecate this API right? since if we remove
- # `.run_in_actor()` (and instead move it to a `.highlevel`
- # wrapper api (around a single `.open_context()` call) we don't
- # really have any notion of a "main" remote task any more?
- #
- # @api_frame
- async def wait_for_result(
- self,
- hide_tb: bool = True,
- ) -> Any:
- '''
- Return the final result delivered by a `Return`-msg from the
- remote peer actor's "main" task's `return` statement.
-
- '''
- __tracebackhide__: bool = hide_tb
- # Check for non-rpc errors slapped on the
- # channel for which we always raise
- exc = self.channel._exc
- if exc:
- raise exc
-
- # not expecting a "main" result
- if self._expect_result_ctx is None:
- peer_id: str = f'{self.channel.aid.reprol()!r}'
- log.warning(
- f'Portal to peer {peer_id} will not deliver a final result?\n'
- f'\n'
- f'Context.result() can only be called by the parent of '
- f'a sub-actor when it was spawned with '
- f'`ActorNursery.run_in_actor()`'
- f'\n'
- f'Further this `ActorNursery`-method-API will deprecated in the'
- f'near fututre!\n'
- )
- return NoResult
-
- # expecting a "main" result
- assert self._expect_result_ctx
-
- if self._final_result_msg is None:
- try:
- (
- self._final_result_msg,
- self._final_result_pld,
- ) = await self._expect_result_ctx._pld_rx.recv_msg(
- ipc=self._expect_result_ctx,
- expect_msg=Return,
- )
- except BaseException as err:
- # TODO: wrap this into `@api_frame` optionally with
- # some kinda filtering mechanism like log levels?
- __tracebackhide__: bool = False
- raise err
-
- return self._final_result_pld
-
- # TODO: factor this out into a `.highlevel` API-wrapper that uses
- # a single `.open_context()` call underneath.
- async def result(
- self,
- *args,
- **kwargs,
- ) -> Any|Exception:
- typname: str = type(self).__name__
- log.warning(
- f'`{typname}.result()` is DEPRECATED!\n'
- f'\n'
- f'Use `{typname}.wait_for_result()` instead!\n'
- )
- return await self.wait_for_result(
- *args,
- **kwargs,
- )
-
async def _cancel_streams(self):
# terminate all locally running async generator
# IPC calls
diff --git a/tractor/runtime/_rpc.py b/tractor/runtime/_rpc.py
index 6c0fb32a9..d2c9ea306 100644
--- a/tractor/runtime/_rpc.py
+++ b/tractor/runtime/_rpc.py
@@ -1003,20 +1003,18 @@ async def process_messages(
task_status.started(loop_cs)
async for msg in chan:
- log.transport( # type: ignore
- f'IPC msg from peer\n'
- f'<= {chan.aid.reprol()}\n\n'
-
- # TODO: use of the pprinting of structs is
- # FRAGILE and should prolly not be
- #
- # avoid fmting depending on loglevel for perf?
- # -[ ] specifically `pretty_struct.pformat()` sub-call..?
- # - how to only log-level-aware actually call this?
- # -[ ] use `.msg.pretty_struct` here now instead!
- # f'{pretty_struct.pformat(msg)}\n'
- f'{msg}\n'
- )
+ if log.at_least_level('transport'):
+ log.transport( # type: ignore
+ f'IPC msg from peer\n'
+ f'<= {chan.aid.reprol()}\n\n'
+
+ # TODO: pretty-printing structs is FRAGILE;
+ # -[ ] add a non-raising log formatter with
+ # native-repr fallback before using
+ # `.msg.pretty_struct` here.
+ # f'{pretty_struct.pformat(msg)}\n'
+ f'{msg}\n'
+ )
match msg:
# msg for an ongoing IPC ctx session, deliver msg to
@@ -1262,11 +1260,12 @@ async def process_messages(
log.exception(message)
raise RuntimeError(message)
- log.transport(
- 'Waiting on next IPC msg from\n'
- f'peer: {chan.aid.reprol()}\n'
- f'|_{chan}\n'
- )
+ if log.at_least_level('transport'):
+ log.transport(
+ 'Waiting on next IPC msg from\n'
+ f'peer: {chan.aid.reprol()}\n'
+ f'|_{chan}\n'
+ )
# END-OF `async for`:
# IPC disconnected via `trio.EndOfChannel`, likely
diff --git a/tractor/runtime/_supervise.py b/tractor/runtime/_supervise.py
index f5c5e786d..2cb0600b7 100644
--- a/tractor/runtime/_supervise.py
+++ b/tractor/runtime/_supervise.py
@@ -20,7 +20,6 @@
"""
from contextlib import asynccontextmanager as acm
from functools import partial
-import inspect
from typing import (
TYPE_CHECKING,
)
@@ -199,7 +198,6 @@ def __init__(
self,
# TODO: maybe def these as fields of a struct looking type?
actor: Actor,
- ria_nursery: trio.Nursery,
da_nursery: trio.Nursery,
errors: dict[tuple[str, str], BaseException],
@@ -233,16 +231,6 @@ def __init__(
# and syncing purposes to any actor opened nurseries.
self._implicit_runtime_started: bool = False
- # TODO: remove the `.run_in_actor()` API and thus this 2ndary
- # nursery when that API get's moved outside this primitive!
- self._ria_nursery = ria_nursery
-
- # TODO, factor this into a .hilevel api!
- #
- # portals spawned with ``run_in_actor()`` are
- # cancelled when their "main" result arrives
- self._cancel_after_result_on_exit: set = set()
-
# trio.Nursery-like cancel (request) statuses
self._cancelled_caught: bool = False
self._cancel_called: bool = False
@@ -298,11 +286,6 @@ async def start_actor(
debug_mode: bool|None = None,
infect_asyncio: bool = False,
inherit_parent_main: bool = True,
-
- # TODO: ideally we can rm this once we no longer have
- # a `._ria_nursery` since the dependent APIs have been
- # removed!
- nursery: trio.Nursery|None = None,
proc_kwargs: dict[str, typing.Any] | None = None,
) -> Portal:
@@ -364,10 +347,8 @@ async def start_actor(
# start a task to spawn a process
# blocks until process has been started and a portal setup
- nursery: trio.Nursery = nursery or self._da_nursery
-
# XXX: the type ignore is actually due to a `mypy` bug
- return await nursery.start( # type: ignore
+ return await self._da_nursery.start( # type: ignore
partial(
_spawn.new_proc,
name,
@@ -382,80 +363,6 @@ async def start_actor(
)
)
- # TODO: DEPRECATE THIS:
- # -[ ] impl instead as a hilevel wrapper on
- # top of a `@context` style invocation.
- # |_ dynamic @context decoration on child side
- # |_ implicit `Portal.open_context() as (ctx, first):`
- # and `return first` on parent side.
- # |_ mention how it's similar to `trio-parallel` API?
- # -[ ] use @api_frame on the wrapper
- async def run_in_actor(
- self,
-
- fn: typing.Callable,
- *,
-
- name: str | None = None,
- bind_addrs: UnwrappedAddress|None = None,
- rpc_module_paths: list[str] | None = None,
- enable_modules: list[str] | None = None,
- loglevel: str | None = None, # set log level per subactor
- infect_asyncio: bool = False,
- inherit_parent_main: bool = True,
- proc_kwargs: dict[str, typing.Any] | None = None,
-
- **kwargs, # explicit args to ``fn``
-
- ) -> Portal:
- '''
- Spawn a new actor, run a lone task, then terminate the actor and
- return its result.
-
- Actors spawned using this method are kept alive at nursery teardown
- until the task spawned by executing ``fn`` completes at which point
- the actor is terminated.
-
- '''
- __runtimeframe__: int = 1 # noqa
- mod_path: str = fn.__module__
-
- if name is None:
- # use the explicit function name if not provided
- name = fn.__name__
-
- proc_kwargs = dict(proc_kwargs or {})
- portal: Portal = await self.start_actor(
- name,
- enable_modules=[mod_path] + (
- enable_modules or rpc_module_paths or []
- ),
- bind_addrs=bind_addrs,
- loglevel=loglevel,
- # use the run_in_actor nursery
- nursery=self._ria_nursery,
- infect_asyncio=infect_asyncio,
- inherit_parent_main=inherit_parent_main,
- proc_kwargs=proc_kwargs
- )
-
- # XXX: don't allow stream funcs
- if not (
- inspect.iscoroutinefunction(fn) and
- not getattr(fn, '_tractor_stream_function', False)
- ):
- raise TypeError(f'{fn} must be an async function!')
-
- # this marks the actor to be cancelled after its portal result
- # is retreived, see logic in `open_nursery()` below.
- self._cancel_after_result_on_exit.add(portal)
- await portal._submit_for_result(
- mod_path,
- fn.__name__,
- **kwargs
- )
- return portal
-
# @api_frame
async def cancel(
self,
@@ -584,167 +491,118 @@ async def _open_and_supervise_one_cancels_all_nursery(
# normally don't need to show user by default
__tracebackhide__: bool = hide_tb
- outer_err: BaseException|None = None
- inner_err: BaseException|None = None
-
# the collection of errors retreived from spawned sub-actors
errors: dict[tuple[str, str], BaseException] = {}
- # This is the outermost level "deamon actor" nursery. It is awaited
- # **after** the below inner "run in actor nursery". This allows for
- # handling errors that are generated by the inner nursery in
- # a supervisor strategy **before** blocking indefinitely to wait for
- # actors spawned in "daemon mode" (aka started using
- # `ActorNursery.start_actor()`).
-
- # errors from this daemon actor nursery bubble up to caller
+ # The single "daemon actor" nursery into which ALL subactors
+ # are spawned; one-shot (`to_actor.run()`) subactors are
+ # result-waited and reaped in their caller's own task-scope
+ # (see the #477 `.run_in_actor()`/`._ria_nursery` removal);
+ # errors from this nursery bubble up to the caller.
async with (
collapse_eg(),
trio.open_nursery() as da_nursery,
):
+ an = ActorNursery(
+ actor,
+ da_nursery,
+ errors
+ )
try:
- # This is the inner level "run in actor" nursery. It is
- # awaited first since actors spawned in this way (using
- # `ActorNusery.run_in_actor()`) are expected to only
- # return a single result and then complete (i.e. be canclled
- # gracefully). Errors collected from these actors are
- # immediately raised for handling by a supervisor strategy.
- # As such if the strategy propagates any error(s) upwards
- # the above "daemon actor" nursery will be notified.
- async with (
- collapse_eg(),
- trio.open_nursery() as ria_nursery,
- ):
- an = ActorNursery(
- actor,
- ria_nursery,
- da_nursery,
- errors
- )
- try:
- # spawning of actors happens in the caller's scope
- # after we yield upwards
- yield an
-
- # When we didn't error in the caller's scope,
- # signal all process-monitor-tasks to conduct
- # the "hard join phase".
- log.runtime(
- 'Waiting on subactors to complete:\n'
- f'>}} {len(an._children)}\n'
- )
- an._join_procs.set()
-
- except BaseException as _inner_err:
- inner_err = _inner_err
- errors[actor.aid.uid] = inner_err
-
- # If we error in the root but the debugger is
- # engaged we don't want to prematurely kill (and
- # thus clobber access to) the local tty since it
- # will make the pdb repl unusable.
- # Instead try to wait for pdb to be released before
- # tearing down.
- await debug.maybe_wait_for_debugger(
- child_in_debug=an._at_least_one_child_in_debug
- )
-
- # if the caller's scope errored then we activate our
- # one-cancels-all supervisor strategy (don't
- # worry more are coming).
- an._join_procs.set()
-
- # XXX NOTE XXX: hypothetically an error could
- # be raised and then a cancel signal shows up
- # slightly after in which case the `else:`
- # block here might not complete? For now,
- # shield both.
- with trio.CancelScope(shield=True):
- etype: type = type(inner_err)
- if etype in (
- trio.Cancelled,
- KeyboardInterrupt,
- ) or (
- is_multi_cancelled(inner_err)
- ):
- log.cancel(
- f'Actor-nursery cancelled by {etype}\n\n'
-
- f'{current_actor().aid.uid}\n'
- f' |_{an}\n\n'
-
- # TODO: show tb str?
- # f'{tb_str}'
- )
- elif etype in {
- ContextCancelled,
- }:
- log.cancel(
- 'Actor-nursery caught remote cancellation\n'
- '\n'
- f'{inner_err.tb_str}'
- )
- else:
- log.exception(
- 'Nursery errored with:\n'
-
- # TODO: same thing as in
- # `._invoke()` to compute how to
- # place this div-line in the
- # middle of the above msg
- # content..
- # -[ ] prolly helper-func it too
- # in our `.log` module..
- # '------ - ------'
- )
-
- # cancel all subactors
- await an.cancel()
-
- # ria_nursery scope end
-
- # TODO: this is the handler around the ``.run_in_actor()``
- # nursery. Ideally we can drop this entirely in the future as
- # the whole ``.run_in_actor()`` API should be built "on top of"
- # this lower level spawn-request-cancel "daemon actor" API where
- # a local in-actor task nursery is used with one-to-one task
- # + `await Portal.run()` calls and the results/errors are
- # handled directly (inline) and errors by the local nursery.
- except (
- Exception,
- BaseExceptionGroup,
- trio.Cancelled
- ) as _outer_err:
- outer_err = _outer_err
-
- an._scope_error = outer_err or inner_err
-
- # XXX: yet another guard before allowing the cancel
- # sequence in case a (single) child is in debug.
+ # spawning of actors happens in the caller's scope
+ # after we yield upwards
+ yield an
+
+ # When we didn't error in the caller's scope,
+ # signal all process-monitor-tasks to conduct
+ # the "hard join phase".
+ log.runtime(
+ 'Waiting on subactors to complete:\n'
+ f'>}} {len(an._children)}\n'
+ )
+ an._join_procs.set()
+
+ # Single one-cancels-all handler for the (now single)
+ # daemon nursery. Pre-#477 a 2ndary `._ria_nursery`
+ # required a separate *outer* handler to catch errors
+ # bubbling from its task-reaping `__aexit__`; with that
+ # nursery gone this lone handler covers every scope
+ # error. NB: we deliberately do NOT re-raise here — the
+ # `finally` below raises the collected `errors` (as a
+ # single exc or `BaseExceptionGroup`), which already
+ # superseded the old outer handler's `raise` anyway
+ # since `errors` is populated (below) before any await.
+ except BaseException as _scope_err:
+ an._scope_error = _scope_err
+ errors[actor.aid.uid] = _scope_err
+
+ # If we error in the root but the debugger is
+ # engaged we don't want to prematurely kill (and
+ # thus clobber access to) the local tty since it
+ # will make the pdb repl unusable.
+ # Instead try to wait for pdb to be released before
+ # tearing down.
await debug.maybe_wait_for_debugger(
child_in_debug=an._at_least_one_child_in_debug
)
- # If actor-local error was raised while waiting on
- # ".run_in_actor()" actors then we also want to cancel all
- # remaining sub-actors (due to our lone strategy:
- # one-cancels-all).
- if an._children:
- log.cancel(
- 'Actor-nursery cancelling due error type:\n'
- f'{outer_err}\n'
- )
- with trio.CancelScope(shield=True):
- await an.cancel()
- raise
+ # if the caller's scope errored then we activate our
+ # one-cancels-all supervisor strategy (don't
+ # worry more are coming).
+ an._join_procs.set()
+
+ # XXX NOTE XXX: hypothetically an error could
+ # be raised and then a cancel signal shows up
+ # slightly after in which case the `else:`
+ # block here might not complete? For now,
+ # shield both.
+ with trio.CancelScope(shield=True):
+ etype: type = type(_scope_err)
+ if etype in (
+ trio.Cancelled,
+ KeyboardInterrupt,
+ ) or (
+ is_multi_cancelled(_scope_err)
+ ):
+ log.cancel(
+ f'Actor-nursery cancelled by {etype}\n\n'
+
+ f'{current_actor().aid.uid}\n'
+ f' |_{an}\n\n'
+
+ # TODO: show tb str?
+ # f'{tb_str}'
+ )
+ elif etype in {
+ ContextCancelled,
+ }:
+ log.cancel(
+ 'Actor-nursery caught remote cancellation\n'
+ '\n'
+ f'{_scope_err.tb_str}'
+ )
+ else:
+ log.exception(
+ 'Nursery errored with:\n'
+
+ # TODO: same thing as in
+ # `._invoke()` to compute how to
+ # place this div-line in the
+ # middle of the above msg
+ # content..
+ # -[ ] prolly helper-func it too
+ # in our `.log` module..
+ # '------ - ------'
+ )
+
+ # cancel all subactors
+ await an.cancel()
finally:
- # No errors were raised while awaiting ".run_in_actor()"
- # actors but those actors may have returned remote errors as
- # results (meaning they errored remotely and have relayed
- # those errors back to this parent actor). The errors are
- # collected in ``errors`` so cancel all actors, summarize
- # all errors and re-raise.
+ # an error was stashed by the handler above (or by
+ # a spawn task via the shared `errors` dict) so
+ # cancel any remaining subactors, summarize and
+ # re-raise.
if errors:
if an._children:
with trio.CancelScope(shield=True):
diff --git a/tractor/spawn/_mp.py b/tractor/spawn/_mp.py
index d0c8af322..90cc83c92 100644
--- a/tractor/spawn/_mp.py
+++ b/tractor/spawn/_mp.py
@@ -48,7 +48,6 @@
# by `try_set_start_method()` after module load time.
from . import _spawn
from ._spawn import (
- cancel_on_completion,
proc_waiter,
soft_kill,
)
@@ -187,30 +186,14 @@ async def mp_proc(
with trio.CancelScope(shield=True):
await actor_nursery._join_procs.wait()
- async with trio.open_nursery() as nursery:
- if portal in actor_nursery._cancel_after_result_on_exit:
- nursery.start_soon(
- cancel_on_completion,
- portal,
- subactor,
- errors
- )
-
- # This is a "soft" (cancellable) join/reap which
- # will remote cancel the actor on a ``trio.Cancelled``
- # condition.
- await soft_kill(
- proc,
- proc_waiter,
- portal
- )
-
- # cancel result waiter that may have been spawned in
- # tandem if not done already
- log.warning(
- "Cancelling existing result waiter task for "
- f"{subactor.aid.uid}")
- nursery.cancel_scope.cancel()
+ # This is a "soft" (cancellable) join/reap which
+ # will remote cancel the actor on a ``trio.Cancelled``
+ # condition.
+ await soft_kill(
+ proc,
+ proc_waiter,
+ portal
+ )
finally:
# hard reap sequence
diff --git a/tractor/spawn/_spawn.py b/tractor/spawn/_spawn.py
index c0218c300..501ee5a28 100644
--- a/tractor/spawn/_spawn.py
+++ b/tractor/spawn/_spawn.py
@@ -126,98 +126,6 @@ def try_set_start_method(
return _ctx
-async def exhaust_portal(
-
- portal: Portal,
- actor: Actor
-
-) -> Any:
- '''
- Pull final result from portal (assuming it has one).
-
- If the main task is an async generator do our best to consume
- what's left of it.
- '''
- __tracebackhide__ = True
- try:
- log.debug(
- f'Waiting on final result from {actor.aid.uid}'
- )
-
- # XXX: streams should never be reaped here since they should
- # always be established and shutdown using a context manager api
- final: Any = await portal.wait_for_result()
-
- except (
- Exception,
- BaseExceptionGroup,
- ) as err:
- # we reraise in the parent task via a ``BaseExceptionGroup``
- return err
-
- except trio.Cancelled as err:
- # lol, of course we need this too ;P
- # TODO: merge with above?
- log.warning(
- 'Cancelled portal result waiter task:\n'
- f'uid: {portal.channel.aid}\n'
- f'error: {err}\n'
- )
- return err
-
- else:
- log.debug(
- f'Returning final result from portal:\n'
- f'uid: {portal.channel.aid}\n'
- f'result: {final}\n'
- )
- return final
-
-
-async def cancel_on_completion(
-
- portal: Portal,
- actor: Actor,
- errors: dict[tuple[str, str], Exception],
-
-) -> None:
- '''
- Cancel actor gracefully once its "main" portal's
- result arrives.
-
- Should only be called for actors spawned via the
- `Portal.run_in_actor()` API.
-
- => and really this API will be deprecated and should be
- re-implemented as a `.hilevel.one_shot_task_nursery()`..)
-
- '''
- # if this call errors we store the exception for later
- # in ``errors`` which will be reraised inside
- # an exception group and we still send out a cancel request
- result: Any|Exception = await exhaust_portal(
- portal,
- actor,
- )
- if isinstance(result, Exception):
- errors[actor.aid.uid]: Exception = result
- log.cancel(
- 'Cancelling subactor runtime due to error:\n\n'
- f'Portal.cancel_actor() => {portal.channel.aid}\n\n'
- f'error: {result}\n'
- )
-
- else:
- log.runtime(
- 'Cancelling subactor gracefully:\n\n'
- f'Portal.cancel_actor() => {portal.channel.aid}\n\n'
- f'result: {result}\n'
- )
-
- # cancel the process now that we have a final result
- await portal.cancel_actor()
-
-
async def hard_kill(
proc: trio.Process,
@@ -461,8 +369,8 @@ async def new_proc(
# NOTE: bottom-of-module to avoid a circular import since the
-# backend submodules pull `cancel_on_completion`/`soft_kill`/
-# `hard_kill`/`proc_waiter` from this module.
+# backend submodules pull `soft_kill`/`hard_kill`/`proc_waiter`
+# from this module.
from ._trio import trio_proc
from ._mp import mp_proc
diff --git a/tractor/spawn/_trio.py b/tractor/spawn/_trio.py
index 7d47175a6..072c0a6af 100644
--- a/tractor/spawn/_trio.py
+++ b/tractor/spawn/_trio.py
@@ -50,7 +50,6 @@
pretty_struct,
)
from ._spawn import (
- cancel_on_completion,
hard_kill,
soft_kill,
)
@@ -195,31 +194,14 @@ async def trio_proc(
with trio.CancelScope(shield=True):
await actor_nursery._join_procs.wait()
- async with trio.open_nursery() as nursery:
- if portal in actor_nursery._cancel_after_result_on_exit:
- nursery.start_soon(
- cancel_on_completion,
- portal,
- subactor,
- errors
- )
-
- # This is a "soft" (cancellable) join/reap which
- # will remote cancel the actor on a ``trio.Cancelled``
- # condition.
- await soft_kill(
- proc,
- trio.Process.wait, # XXX, uses `pidfd_open()` below.
- portal
- )
-
- # cancel result waiter that may have been spawned in
- # tandem if not done already
- log.cancel(
- 'Cancelling portal result reaper task\n'
- f'c)> {subactor.aid.reprol()!r}\n'
- )
- nursery.cancel_scope.cancel()
+ # This is a "soft" (cancellable) join/reap which
+ # will remote cancel the actor on a ``trio.Cancelled``
+ # condition.
+ await soft_kill(
+ proc,
+ trio.Process.wait, # XXX, uses `pidfd_open()` below.
+ portal
+ )
finally:
# XXX NOTE XXX: The "hard" reap since no actor zombies are
diff --git a/tractor/to_actor/__init__.py b/tractor/to_actor/__init__.py
new file mode 100644
index 000000000..4c39ec309
--- /dev/null
+++ b/tractor/to_actor/__init__.py
@@ -0,0 +1,33 @@
+# tractor: distributed structured concurrency.
+# 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 .
+
+'''
+`tractor.to_actor`: high-level "one-shot" remote-task APIs.
+
+Adopts the "run it over there" parlance from analogous
+(sibling-library) APIs like `trio.to_thread` and
+`anyio.to_process` but for SC-supervised actors: spawn (or
+reuse) a subactor, schedule a single remote task, wait on
+its result and (when the call owns the subactor) reap it.
+
+The "spiritual successor" to (and replacement of) the removed
+legacy `ActorNursery.run_in_actor()` API; see
+https://github.com/goodboy/tractor/issues/477
+
+'''
+from ._api import (
+ run as run,
+)
diff --git a/tractor/to_actor/_api.py b/tractor/to_actor/_api.py
new file mode 100644
index 000000000..07c406886
--- /dev/null
+++ b/tractor/to_actor/_api.py
@@ -0,0 +1,226 @@
+# tractor: distributed structured concurrency.
+# 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 .
+
+'''
+One-shot remote-task invocation built on spawn-and-portal
+primitives.
+
+Implemented (as prescribed by #477) entirely "on top of"
+the lower level daemon-actor spawn + portal APIs,
+
+- `ActorNursery.start_actor()` for (daemon-style) subactor
+ spawning,
+- `Portal.run()` for scheduling the lone remote task and
+ waiting on its result,
+- `Portal.cancel_actor()` for reaping the subactor once
+ that result (or error) arrives,
+
+such that error collection and propagation happens in the
+*caller's task* (and thus whatever `trio` nursery/scope
+encloses it) instead of inside the actor-nursery's
+spawn-machinery nurseries as with the (now removed) legacy
+`ActorNursery.run_in_actor()` API.
+
+'''
+from __future__ import annotations
+import inspect
+from typing import (
+ Any,
+ Callable,
+ TYPE_CHECKING,
+)
+
+from ..runtime._supervise import (
+ ActorNursery,
+ open_nursery,
+)
+
+if TYPE_CHECKING:
+ from ..discovery._addr import UnwrappedAddress
+ from ..runtime._portal import Portal
+
+
+def _validate_one_shot_fn(
+ fn: Callable,
+) -> None:
+ '''
+ Ensure `fn` is a non-streaming async function, raise
+ a `TypeError` otherwise.
+
+ The same constraint enforced by `Portal.run()` but
+ checked up-front, BEFORE any subactor is spawned.
+
+ '''
+ if not (
+ inspect.iscoroutinefunction(fn)
+ and
+ not getattr(
+ fn,
+ '_tractor_stream_function',
+ False,
+ )
+ ):
+ raise TypeError(
+ f'{fn!r} must be a non-streaming async '
+ f'function!'
+ )
+
+
+async def _invoke_in_subactor(
+ an: ActorNursery,
+ fn: Callable,
+ name: str,
+ spawn_kwargs: dict[str, Any],
+ fn_kwargs: dict[str, Any],
+) -> Any:
+ '''
+ Spawn a (daemon) subactor via `an.start_actor()`,
+ schedule `fn` as its lone remote task via
+ `Portal.run()` and, ALWAYS, reap the subactor once
+ that task's result (or error) has been delivered.
+
+ '''
+ portal: Portal = await an.start_actor(
+ name,
+ **spawn_kwargs,
+ )
+ try:
+ return await portal.run(
+ fn,
+ **fn_kwargs,
+ )
+ finally:
+ # one-shot semantics: the subactor's lifetime is
+ # bound to its lone task's completion; the
+ # cancel-req's bounded wait is shielded
+ # internally (see `Portal.cancel_actor()`) so
+ # this reap also runs when the caller's scope
+ # was itself cancelled.
+ await portal.cancel_actor()
+
+
+async def run(
+ fn: Callable,
+ *,
+
+ # actor "placement": reuse an already-running peer
+ # via its `portal`, spawn a fresh subactor from
+ # a caller-managed `an: ActorNursery`, or, when
+ # neither is provided, open a private actor-nursery
+ # (implicitly booting the actor-runtime as needed)
+ # scoped to just this call.
+ portal: Portal|None = None,
+ an: ActorNursery|None = None,
+
+ # subactor spawn opts passed (mostly) verbatim to
+ # `ActorNursery.start_actor()`; unused when `portal`
+ # is provided.
+ name: str|None = None,
+ bind_addrs: list[UnwrappedAddress]|None = None,
+ enable_modules: list[str]|None = None,
+ loglevel: str|None = None,
+ debug_mode: bool|None = None,
+ infect_asyncio: bool = False,
+ inherit_parent_main: bool = True,
+ proc_kwargs: dict[str, Any]|None = None,
+
+ # passed verbatim to the private `open_nursery()`
+ # (and in turn any implicit `open_root_actor()`)
+ # when NO `an`/`portal` is provided.
+ runtime_kwargs: dict[str, Any]|None = None,
+
+ **fn_kwargs, # explicit (keyword) args to `fn`
+
+) -> Any:
+ '''
+ Run the async `fn` as the lone task in a (new)
+ subactor, block waiting on its result and return it;
+ the distributed-parallelism equivalent of
+ `trio.to_thread.run_sync()`.
+
+ Unlike the removed legacy `.run_in_actor()` (which
+ returned a `Portal` whose result was only collected
+ at actor-nursery teardown) this is a plain "call and
+ wait" primitive: any remote error is raised HERE, in
+ the caller's task. Concurrency is composed the usual
+ `trio` way by scheduling multiple `run()` calls in
+ a local task nursery, ideally against a shared
+ caller-managed `an: ActorNursery` (see the test
+ suite for the canonical worker-pool-ish pattern).
+
+ '''
+ __runtimeframe__: int = 1 # noqa
+ _validate_one_shot_fn(fn)
+
+ if (
+ runtime_kwargs
+ and
+ (
+ an is not None
+ or
+ portal is not None
+ )
+ ):
+ raise ValueError(
+ '`runtime_kwargs` only applies when this '
+ 'call opens its own private actor-nursery '
+ '(no `an`/`portal` provided)!'
+ )
+
+ if portal is not None:
+ if an is not None:
+ raise ValueError(
+ 'Pass at most ONE of `portal` or `an`, '
+ 'not both!'
+ )
+ return await portal.run(
+ fn,
+ **fn_kwargs,
+ )
+
+ name: str = name or fn.__name__
+ spawn_kwargs: dict[str, Any] = dict(
+ enable_modules=(
+ [fn.__module__]
+ +
+ (enable_modules or [])
+ ),
+ bind_addrs=bind_addrs,
+ loglevel=loglevel,
+ debug_mode=debug_mode,
+ infect_asyncio=infect_asyncio,
+ inherit_parent_main=inherit_parent_main,
+ proc_kwargs=proc_kwargs,
+ )
+ if an is not None:
+ return await _invoke_in_subactor(
+ an,
+ fn,
+ name,
+ spawn_kwargs,
+ fn_kwargs,
+ )
+
+ async with open_nursery(
+ **(runtime_kwargs or {}),
+ ) as an:
+ return await _invoke_in_subactor(
+ an,
+ fn,
+ name,
+ spawn_kwargs,
+ fn_kwargs,
+ )
diff --git a/tractor/trionics/_mngrs.py b/tractor/trionics/_mngrs.py
index d4f986c35..1304e50e4 100644
--- a/tractor/trionics/_mngrs.py
+++ b/tractor/trionics/_mngrs.py
@@ -198,6 +198,16 @@ async def gather_contexts(
# Further potential examples of interest:
# https://gist.github.com/njsmith/cf6fc0a97f53865f2c671659c88c1798#file-cache-py-L8
+class _CtxExit:
+ '''
+ Completion state for a cached context's shared exit.
+
+ '''
+ def __init__(self) -> None:
+ self.done = trio.Event()
+ self.error: Exception|None = None
+
+
class _Cache:
'''
Globally (actor-processs scoped) cached, task access to
@@ -213,7 +223,11 @@ class _Cache:
values: dict[Any, Any] = {}
resources: dict[
Hashable,
- tuple[trio.Nursery, trio.Event]
+ tuple[
+ trio.Nursery,
+ trio.Event,
+ _CtxExit,
+ ],
] = {}
# nurseries: dict[int, trio.Nursery] = {}
no_more_users: trio.Event|None = None
@@ -223,18 +237,38 @@ async def run_ctx(
cls,
mng,
ctx_key: tuple,
+ ctx_exit: _CtxExit,
task_status: trio.TaskStatus[T] = trio.TASK_STATUS_IGNORED,
) -> None:
- async with mng as value:
- _, no_more_users = cls.resources[ctx_key]
- cls.values[ctx_key] = value
- task_status.started(value)
- try:
- await no_more_users.wait()
- finally:
- value = cls.values.pop(ctx_key)
- cls.resources.pop(ctx_key)
+ entered: bool = False
+ try:
+ async with mng as value:
+ entered = True
+ (
+ _,
+ no_more_users,
+ _,
+ ) = cls.resources[ctx_key]
+ cls.values[ctx_key] = value
+ task_status.started(value)
+ try:
+ await no_more_users.wait()
+ finally:
+ cls.values.pop(ctx_key)
+ cls.resources.pop(ctx_key)
+
+ except Exception as exc:
+ if not entered:
+ raise
+
+ # Deliver regular `__aexit__()` failures to the final
+ # consumer instead of raising into the service nursery.
+ ctx_exit.error = exc
+
+ finally:
+ if entered:
+ ctx_exit.done.set()
class _UnresolvedCtx:
@@ -281,9 +315,10 @@ async def maybe_open_context(
)
# yielded output
- # sentinel = object()
yielded: Any = _UnresolvedCtx
user_registered: bool = False
+ ctx_exit: _CtxExit|None = None
+ exit_error: Exception|None = None
# Lock resource acquisition around task racing / ``trio``'s
# scheduler protocol.
@@ -300,7 +335,6 @@ async def maybe_open_context(
] = trio.StrictFIFOLock()
header: str = 'Allocated NEW lock for @acm_func,\n'
else:
- await trio.lowlevel.checkpoint()
header: str = 'Reusing OLD lock for @acm_func,\n'
log.debug(
@@ -368,7 +402,6 @@ async def maybe_open_context(
resources = _Cache.resources
entry: tuple|None = resources.get(ctx_key)
if entry:
- service_tn, ev = entry
raise RuntimeError(
f'Caching resources ALREADY exist?!\n'
f'ctx_key={ctx_key!r}\n'
@@ -376,12 +409,18 @@ async def maybe_open_context(
f'task: {task}\n'
)
- resources[ctx_key] = (service_tn, trio.Event())
+ ctx_exit = _CtxExit()
+ resources[ctx_key] = (
+ service_tn,
+ trio.Event(),
+ ctx_exit,
+ )
try:
yielded: Any = await service_tn.start(
_Cache.run_ctx,
mngr,
ctx_key,
+ ctx_exit,
)
except BaseException:
# If `run_ctx` (wrapping the acm's `__aenter__`)
@@ -427,6 +466,11 @@ async def maybe_open_context(
raise taskc
else:
# XXX, cached-entry-path
+ (
+ _,
+ _,
+ ctx_exit,
+ ) = _Cache.resources[ctx_key]
_Cache.users[ctx_key] += 1
user_registered = True
log.debug(
@@ -445,44 +489,57 @@ async def maybe_open_context(
)
finally:
- if lock.locked():
- stats: trio.LockStatistics = lock.statistics()
- owner: trio.Task|None = stats.owner
- log.error(
- f'Lock never released by last owner={owner!r} !?\n'
- f'{stats}\n'
- f'\n'
- f'task={task!r}\n'
- f'ctx_key={ctx_key!r}\n'
- f'acm_func={acm_func}\n'
-
- )
-
if user_registered:
- _Cache.users[ctx_key] -= 1
-
- if yielded is not _UnresolvedCtx:
- # if no more consumers, teardown the client
- if _Cache.users[ctx_key] <= 0:
- log.debug(
- f'De-allocating @acm-func entry\n'
- f'ctx_key={ctx_key!r}\n'
- f'acm_func={acm_func!r}\n'
- )
-
- # XXX: if we're cancelled we the entry may have never
- # been entered since the nursery task was killed.
- # _, no_more_users = _Cache.resources[ctx_key]
- entry = _Cache.resources.get(ctx_key)
- if entry:
- _, no_more_users = entry
- no_more_users.set()
-
- maybe_lock = _Cache.locks.pop(
- ctx_key,
- None,
- )
- if maybe_lock is None:
- log.error(
- f'Resource lock for {ctx_key} ALREADY POPPED?'
- )
+ # Serialize user registration and teardown under the same
+ # per-key lock so no entrant can acquire a resource after
+ # its final user has committed to exiting it.
+ with trio.CancelScope(shield=True):
+ await lock.acquire()
+ try:
+ _Cache.users[ctx_key] -= 1
+
+ # If no consumers remain, keep entrants queued
+ # until the cached context has completely exited.
+ if _Cache.users[ctx_key] <= 0:
+ log.debug(
+ f'De-allocating @acm-func entry\n'
+ f'ctx_key={ctx_key!r}\n'
+ f'acm_func={acm_func!r}\n'
+ )
+
+ # XXX: if we're cancelled, the entry may
+ # have never been entered since the nursery
+ # task was killed.
+ entry = _Cache.resources.get(ctx_key)
+ if entry:
+ (
+ _,
+ no_more_users,
+ ctx_exit,
+ ) = entry
+ no_more_users.set()
+
+ assert ctx_exit is not None
+ await ctx_exit.done.wait()
+ exit_error = ctx_exit.error
+
+ # A queued entrant already holds a reference
+ # to this lock. Keep it registered until that
+ # task has acquired and released it.
+ stats = lock.statistics()
+ if not stats.tasks_waiting:
+ maybe_lock = _Cache.locks.get(ctx_key)
+ if maybe_lock is lock:
+ _Cache.locks.pop(ctx_key)
+ else:
+ log.error(
+ f'Resource lock for {ctx_key} '
+ f'was replaced before teardown?'
+ )
+ finally:
+ lock.release()
+
+ if exit_error is not None:
+ # Always re-raise a regular `__aexit__()` error at the
+ # final consumer's context boundary.
+ raise exit_error
diff --git a/tractor/trionics/_taskc.py b/tractor/trionics/_taskc.py
index 7cb5fff63..2079179b5 100644
--- a/tractor/trionics/_taskc.py
+++ b/tractor/trionics/_taskc.py
@@ -349,7 +349,10 @@ async def start_or_cancel(
# demote it to a `Cancelled`, losing the real error. The
# `isinstance` guard also avoids a `TypeError` when
# `rte.args[0]` isn't a `str`.
- 'child exited without calling' in rte.args[0]
+ rte.args[0] == (
+ 'child exited without calling '
+ 'task_status.started()'
+ )
):
# re-raises the in-flight `trio.Cancelled` IFF we're
# under effective cancellation; else a cheap no-op and
diff --git a/uv.lock b/uv.lock
index 744b110e9..701661555 100644
--- a/uv.lock
+++ b/uv.lock
@@ -308,11 +308,11 @@ wheels = [
[[package]]
name = "idna"
-version = "3.10"
+version = "3.18"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
@@ -900,11 +900,11 @@ wheels = [
[[package]]
name = "setuptools"
-version = "82.0.1"
+version = "83.0.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" },
]
[[package]]