Compare commits

...

50 Commits

Author SHA1 Message Date
Gud Boi bcf794637c Add `open_taskman()` design + #485 prompt brief
Two new provenance/planning docs kicking off issue #485 — the
`to_actor.open_taskman()` deferred/non-blocking successor to
the `run_in_actor()` removed by #477/#484.

`ai/conc-anal/to_actor_taskman_design.md` (self-contained
hand-off): the core principle (every non-blocking remote-task
invocation needs an explicitly-owned scope, so NO
`Portal.run_soon()` — the scope-owning `tm` IS the API), the
three prior-art sources to mine (`hilevel_serman` `ServiceMngr`,
PR #363 `trionics` taskman, `piker` `Services`), the
`hilevel_serman` rebase + convergence map (incl. the known
`portal.wait_for_result()` breakage at `_service.py:292`), the
parked-holder core + `run_soon()` API, the
`one_cancels_all`/`collect` strategies + `wait`/`cancel` exit
policies, resolved design items, and the PR A/B/C sequencing.

`ai/prompt-io/prompts/issue_485.md`: the kickoff brief for
PR A — required reading, prior art, + house rules.

Prompt-IO: ai/prompt-io/prompts/issue_485.md

(this patch was generated in some part by `claude-code` using `claude-opus-4-8` (`anthropic`))
2026-08-12 20:07:32 -04:00
Gud Boi 6e2ce2ba2f Doc the #477 migration outcome + one-shot-acm sketch
Fold the endeavour's resolution into the plan doc + log the
session per prompt-io policy,

- `ria_nursery_removal_plan.md`: RESOLVED section — migrate
  everything, remove the API; the migration-pattern table
  (blocking / fire-and-forget / fan-out / collect-don't-cancel
  / mutual-rendezvous), the semantic deltas (cancel-on-first +
  `collapse_eg()` chain collapse vs the old teardown-reap BEG),
  the excision inventory and the structural dissolution of the
  reap-hang class.
- adds the `to_actor.open_one_shot()` follow-up sketch: an
  `@acm` + private task-nursery over the existing blocking
  `run()` — done-`trio.Event` as a result memo (NOT a
  cancel-relay), no `Portal` in the iface, errors always
  propagate at scope exit; zero `_supervise` coupling.
- prompt-io entry `20260706T172818Z_ad42871e` (+ raw diff-ref
  companion) covering commits `d01a2123..ad42871e`.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 238625b422 Name every `ActorNursery` binding `an` in tests/examples
Convention sweep (user req): all `tractor.open_nursery()`
bindings in test + example code use `an: ActorNursery` (`n`,
`nursery` + several tractor-nurseries confusingly named `tn`
are renamed); `trio.open_nursery()` bindings stay `tn` (incl.
`concurrent_actors_primes.py`'s inner trio nursery, renamed
`n` -> `tn` to match).

Purely mechanical, function-scoped renames — prose "nursery"/
"an" in docstrings/comments untouched; func-arg kwargs like
`portal.run(func, n=value)` untouched.

Gate: renamed test modules green on `trio`; full debugger suite
(28p/6s) + example-runner (21p) green.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 3c86fd8b69 Fix mutual-rendezvous premature-reap race (#477)
The `test_trynamic_trio` + `a_trynamic_first_scene.py` migration
to paired `to_actor.run()` one-shots carries a race the legacy
`run_in_actor()` shape never had: donny + gretchen each
`wait_for_actor()` (then DIAL) the *other*, but a one-shot is
reaped the instant its own hello returns — so the slower peer
can resolve the winner's registry entry and connect to an
already-dead sockaddr -> `ConnectionRefusedError` boxed as a
`RemoteActorError` (or a reg-wait `TooSlowError`), flaking
~1-in-3 standalone runs.

Mutual-rendezvous peers must OUTLIVE both dialogs, so pin the
lifetimes explicitly: `start_actor()` both as daemons, run both
hellos concurrently via bg `Portal.run()` tasks, then reap with
`an.cancel()` only after the task-nursery joins. (The legacy
teardown-reap provided this pinning implicitly — one of the
few places its semantics were ever actually relied upon.)

Gate: `-k trynamic` standalone x8 green (was flaking); full
`test_registrar` module + the example-runner green.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 057f0702e5 Remove `run_in_actor()` + the ria reap cluster
The final excision of #477: with zero in-repo callers left (all
tests/examples/docs migrated to `to_actor.run()` et al) the
entire legacy one-shot machinery drops out,

- `runtime/_supervise.py`: `ActorNursery.run_in_actor()`, the
  `._cancel_after_result_on_exit` portal-set and the
  `_reap_ria_portals()` teardown-reaper (both its happy-path
  block-exit call AND the error-path snapshot + 0.5s-bounded
  collection) are deleted — one-shot result-waiting now lives
  entirely in the caller's task via `to_actor.run()`, whose
  enclosing cancel-scope bounds the wait by construction (the
  correct-scoping fix for the unbounded-reap hang class; the
  `d1fb4a1a` guard test now passes structurally).
- `runtime/_portal.py`: `Portal._submit_for_result()`,
  `._expect_result_ctx`, `._final_result_msg/_pld`,
  `.wait_for_result()` + the deprecated `.result()` alias are
  gone — a `Portal` no longer has any "main result" notion.
  NB `Context.wait_for_result()` is a different (very alive)
  API and is untouched.
- `spawn/_spawn.py`: `exhaust_portal()` +
  `cancel_on_completion()` (the reaper tasks) deleted; backend
  comment sweeps in `_trio.py`/`_mp.py`.
- `_exceptions.py`: the `NoResult` sentinel dies with its lone
  reader.
- `tests/test_ringbuf.py`: drop a daemon-portal `.result()`
  call that was already a warn + `NoResult` no-op (the ctx-acm
  exit does the real result-wait); unshadow the 2nd `sctx` as
  `rctx`.
- comment/docstring x-ref sweeps: `msg/types.py`,
  `_context.py`, `to_actor/`, `tests/test_to_actor.py`.

Gate: `test_to_actor test_spawning test_cancellation
test_infected_asyncio test_local test_rpc` = 81 passed,
3 xfailed on `trio`; +`test_ringbuf` = 70 passed, 3 skipped,
3 xfailed on `mp_spawn`.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 0936461ebb Fix stale `@pub` docstring example in `experimental`
The `_pubsub.pub` decorator's usage example predates several API
generations: ancient positional-arg-order `run_in_actor()` (a
missing `await` too) plus the deprecated `portal.result()` — and
`run_in_actor()` never allowed streaming funcs anyway. Show the
canonical `start_actor()` + `Portal.open_stream_from()`
consumption instead (#477 removal sweep).

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 23b6997b92 Port docs off `run_in_actor` + `Portal.wait_for_result`
The 8-page docs sweep of the #477 removal, ahead of the API's
excision,

- `start/quickstart.rst`: the first-actor-tree walkthrough now
  narrates the (migrated) `to_actor.run()` example — no portal
  in hand until the daemon section introduces `start_actor()`.
- `guide/spawning.rst`: the one-shot section becomes
  `to_actor.run()` (blocking call, placement opts, "built on the
  primitives" note); lifetime/teardown rules update — one-shots
  never make it to nursery exit since each is reaped inside its
  own call.
- `guide/rpc.rst`: the `wait_for_result()` section (an API that
  dies with the reap cluster, incl. the `NoResult` sentinel)
  becomes a `to_actor.run()` one-shot section.
- `api/core.rst`: drop `run_in_actor`/`wait_for_result` from the
  autodoc member lists, drop the `Portal.result()` deprecation
  note, add a "One-shot task actors" `tractor.to_actor.run`
  autodoc section.
- `guide/{asyncio,context,cancellation,parallelism}.rst`:
  mention swaps to the successor API.

Gate: `make -C docs html` builds clean; `to_actor.run` autodoc
renders in `api/core.html`.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 81f72ef746 Port debugging examples off `run_in_actor`
The 8 `examples/debugging/` scripts driven by the pexpect'd
`test_debugger.py` REPL-flows (#477 removal),

- blocking one-shots (`subactor_error`, `subactor_breakpoint`,
  `shielded_pause`): straight `to_actor.run(fn, an=an)` — the
  boxed error/`BdbQuit` raises in the root's task.
- `multi_subactors`: introduces the "collect all errors" pattern
  — each one-shot catches + stashes its `RemoteActorError` (vs
  raising) so no child's crash cancels its siblings before
  they've had their own REPL sessions, then a
  `BaseExceptionGroup` of the lot raises at the end; preserves
  the legacy teardown-reap REPL flow exactly (28/28 debugger
  suite unchanged).
- `multi_nested_subactors_error_up_through_nurseries` +
  `root_cancelled_but_child_is_in_tty_lock`: recursive
  `spawn_until` levels each block on their one-shot child; the
  parallel spawner-trees run as bg task-nursery one-shots where
  the first tree's error cancels the other.
- `multi_subactor_root_errors` +
  `root_timeout_while_child_crashed`: `start_actor()` + bg
  `Portal.run()` tasks so the root's own error/timeout races the
  already-crashed children, same as before.
- `sync_bp`: TODO-comment x-ref update only.

`test_debugger.py`: the nested-nurseries test's final-output
patterns update to the new relay shape — the LAST-released leaf
REPL's error chain wins each level's relay-vs-cancel race and
relays as a `collapse_eg()`-annotated collapsed chain, while the
sibling tree is cancelled + absorbed. (The legacy teardown-reap
grouped BOTH the `name_error` and bp-quit chains — explaining
the previously-mysterious "extra" `src_uid`/`relay_uid` patterns
noted in the old TODO.)

Gate: `tests/devx/test_debugger.py` = 28 passed, 6 skipped —
identical to the pre-migration baseline.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi cd85b9947e Port non-debugging examples off `run_in_actor`
4 example scripts of the #477 removal sweep, each exercised by
`test_docs_examples.py`,

- `actor_spawning_and_causality.py`: the simplest possible
  `to_actor.run()` demo — private call-scoped nursery, block on
  and print the one-shot's result.
- `remote_error_propagation.py`: blocking `to_actor.run(an=n)`
  raises the boxed `AssertionError` in the caller's task,
  cancelling the sibling daemons.
- `parallelism/single_func.py`: bg-burn a core in the parent via
  a local task-nursery while the one-shot burns (and returns
  from) a subactor.
- `a_trynamic_first_scene.py`: donny + gretchen wait on each
  *other* so their one-shots run concurrently in a local
  task-nursery against a shared `an` (mirrors the migrated
  `test_trynamic_trio`).

Gate: all 4 green via the example-runner suite.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 59cd73bd8b Port `test_dynamic_pub_sub` off `run_in_actor`
The known-flaky dynamic pubsub test's 3 fire-and-forget spawn
sites (#477 removal),

- the forever-streaming `publisher` + N `consumer` one-shots now
  bg-schedule as `to_actor.run(fn, an=n)` tasks in a local `trio`
  task-nursery (`publisher`'s rendezvous name still derives from
  `fn.__name__`).
- the simulated user-cancel raise (`KeyboardInterrupt` /
  `TooSlowError` params) cancels the task-nursery, each one-shot
  reaping its subactor via `to_actor.run()`'s shielded
  `Portal.cancel_actor()`; `_run_and_match()`'s existing
  `BaseExceptionGroup.split()` walk covers the (possibly nested)
  relay shapes unchanged.
- spawns now issue concurrently rather than sequentially —
  comment on the fork-backend budget updated to match.

Gate: both params x4 runs green on `trio` + x1 on `mp_spawn`;
full module green.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi b9ee783285 Port SIGINT + sync-sleep cancel tests off `run_in_actor`
Final `test_cancellation.py` group of the `run_in_actor` removal
(#477) — cancel-mechanics tests, so clean conversions,

- `test_cancel_via_SIGINT_other_task`: the 3 keep-alive
  `run_in_actor(sleep_forever)` one-shots become plain
  `start_actor()` daemons (an idle daemon needs no "main" task,
  and no longer shares a single dup'd `namesucka` name).
- `spawn_sub_with_sync_blocking_task`: the middle layer's spawn
  becomes a blocking `to_actor.run(spin_for, an=an)` which parks
  awaiting the sync-sleeping grandchild's result until cancelled
  from above.
- `test_cancel_while_childs_child_in_sync_sleep`: the
  fire-and-forget middle-actor spawn becomes a bg
  `to_actor.run()` task in a local task-nursery; the root's
  `assert 0` cancels it, driving the same
  graceful-cancel-then-zombie-reap cascade on the sync-blocked
  grandchild. The `man_cancel_outer` xfail param is unchanged.

Zero live `run_in_actor()` call-sites remain in this suite.

Gate: full `test_cancellation.py` module green on both `trio`
(18p/1xf) + `mp_spawn` (18p/1xf).

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 721b25b2b5 Port `test_nested_multierrors` off `run_in_actor`
Third `test_cancellation.py` group of the `run_in_actor` removal
(#477),

- `spawn_and_error` fans out each level's erroring one-shots as
  concurrent `to_actor.run(fn, an=an)` tasks in a local `trio`
  task-nursery (recursing per spawner subactor), as does the
  test-body's top-level spawner loop.
- the deterministic exact-breadth nested-BEG shape dies with the
  legacy teardown-reap: each level now groups whatever subset of
  sub-tree errors relay before the first one's cancel wins, and
  a single-member group gets unwrapped by the runtime's own
  `collapse_eg()` at every actor boundary — so a fully-raced
  tree relays a bare `RemoteActorError` chain.
- loosen the shape walk accordingly: accept a lone
  `RemoteActorError` or a 1..breadth group whose members box
  `ExceptionGroup` (multi-relay), `AssertionError` (collapsed
  leaf chain), `RemoteActorError` (re-boxed collapsed chain) or
  `BaseExceptionGroup` (runtime reap-deadline `Cancelled`
  upgrade); fold the windows-only tolerances into the same walk.
- raced sibling `trio.Cancelled`s are now ABSORBED by the
  task-nursery instead of landing in the group, so the MTF
  shape-mismatch xfail should consistently xpass — note added to
  drop the marker once CI confirms.
- add an `else: pytest.fail()` so a silently-clean tree can no
  longer pass.

Gate: both depths green on `trio` (10 consecutive runs) +
`mp_spawn`.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 60b816a847 Fix unbound `timeout` under non-trio/MTF backends
`test_nested_multierrors`'s backend/depth budget `match` only
carries arms for the `trio` + `main_thread_forkserver` spawn
backends, so running under any other (e.g. `mp_spawn`) leaves
`timeout` unbound and crashes with an `UnboundLocalError` at the
headroom-scaling below. Add default per-depth arms riding the MTF
budgets (same per-spawn round-trip cost class).

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 230b726685 Port `test_some_cancels_all` off `run_in_actor`
Second `test_cancellation.py` group of the `run_in_actor` removal
(#477),

- one-shot subactors now run as concurrent `to_actor.run(fn,
  an=an)` tasks in a local `trio` task-nursery, so their errors
  raise WHILE the actor-nursery block is open (vs the legacy
  teardown-reap) and the first error cancels sibling one-shots.
- wrap the task-nursery in `collapse_eg()` so the deterministic
  single-error cases still surface a bare `RemoteActorError`.
- loosen the group-shape assertion: the relay-vs-cancel race
  populates anywhere from 1 to `num_actors` `RemoteActorError`s
  (the exact-`num_actors` BEG was `run_in_actor`'s
  reap-all-at-teardown); group members are always
  `RemoteActorError` now since sibling `trio.Cancelled`s are
  absorbed by the task-nursery.
- move the daemon-portal call loop inside the task-nursery body
  so the sleep-forever one-shot case is cancelled by the daemon
  error raise.
- rename the `*run_in_actor*` param ids to `*one_shot*`.

Gate: 6 passed on both `trio` + `mp_spawn` backends.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 4c615377d2 Doc ria-reap hang fix + paused reaper re-scope
Append two sections to the ria-removal plan capturing the
2026-07-02 hang episode + the resulting design pivot.

Regression writeup: the full-suite hang on
`test_tractor_cancels_aio` root-caused to the step-A reaper
hoist (`5cd190c5`), not the B2 handler merge. The happy-path
`_reap_ria_portals()` parks unbounded on `wait_for_result()`
after a user `portal.cancel_actor()`; the old spawn-backend
reaper raced `soft_kill()`'s scope-cancel, the hoist dropped
it. Records the `proc.poll()` death-watch fix + why poll (not
the event `wait_func`) bc `soft_kill` already awaits
`proc.sentinel` (a 2nd `wait_readable` -> `BusyResourceError`).

Pause writeup: user's insight that the hoist landed in the
wrong scope — result-waiting belongs in the `to_actor`
one-shot scope (`_invoke_in_subactor()`), beside `an` + a
local task-nursery + cancel-scope, where bounding the wait is
trivial + the hang dissolves. So the poll fix is likely
SUPERSEDED (flagged do-not-land); the anti-hang guard commit
(`d1fb4a1a`) stays red-first per the failing-test convention.

(this patch was generated in some part by `claude-code` using
`claude-opus-4-8` (`anthropic`))
2026-08-12 20:06:27 -04:00
Gud Boi c1f92cae8e Port `test_cancellation` multierror cluster off `run_in_actor`
First group of the `test_cancellation.py` `run_in_actor` removal
(#477),

- `test_remote_error` -> blocking `to_actor.run()` (single erroring
  one-shot; a bad-arg `TypeError` still relays as a
  `RemoteActorError`).
- `test_multierror` -> concurrent fan-out via
  `gather_contexts([p.open_context(assert_err_ctx) ...])` over
  `start_actor()` portals. NB `gather_contexts` is cancel-on-first
  so the 2nd errorer is usually cancelled before relaying its own
  exc and the pair collapses to a single `RemoteActorError` (vs the
  legacy reap-all-at-teardown `BEG`-of-N) — the assertion now
  accepts either shape.
- delete `test_multierror_fast_nursery` — a 25-actor stress test of
  `run_in_actor`'s teardown-reap; no analogous surface under the
  `to_actor` fan-out.
- add an `assert_err_ctx` `@context` shim for the `open_context`
  fan-out.

Remaining `test_cancellation` groups (some_cancels_all, nested,
SIGINT, sync-blocking) still on `run_in_actor` — ported next.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi b1ede9aae7 Port `test_registrar` off `run_in_actor`
Two sites migrated (#477 removal),

- `test_trynamic_trio`: donny + gretchen each wait on the *other*
  to register, so they must run CONCURRENTLY — was two
  non-blocking `run_in_actor()`s awaited after; now two
  `to_actor.run()` one-shots scheduled into a local `trio`
  task-nursery.
- the unregister-on-cancel cluster test: its non-streaming branch
  spawned `run_in_actor(trio.sleep_forever)` purely to keep each
  subactor alive + registered — a `start_actor()` daemon does that
  without a "main" task, so the spawn loop collapses to the same
  `start_actor()` the streaming branch already used.

Suite: 16 passed.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 7fbbd65ad5 Port `test_pubsub` off `run_in_actor`
`test_multi_actor_subs_arbiter_pub` used `run_in_actor()` to spawn
two forever-ish subscriber actors and hold their portals for a
later `cancel_actor()` (its `.result()` was commented out exactly
because `subs()` never cleanly returns). That deferred-spawn +
cancel shape isn't a blocking `to_actor.run()`, so convert to the
successor primitives (#477 removal),

- `start_actor()` per subscriber — keeps the portal for the
  existing `cancel_actor()` teardown,
- run `subs()` on each via a background `Portal.run()` task in a
  local `trio` nursery so both subscribe concurrently with the
  test's `wait_for_actor` / topic checks,
- each bg runner swallows the `RemoteActorError`/`ContextCancelled`
  that `cancel_actor()` relays; a trailing `tn.cancel_scope.cancel()`
  drops any lingering runner.

Suite: 8 passed.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 4fc7a0006f Port `test_spawning` off `run_in_actor`
Migrate all 4 sites to blocking `tractor.to_actor.run()` (#477
removal),

- rename the two API-named tests to `test_to_actor_run_*`
  (`same_func_in_child`, `can_skip_parent_main_inheritance`) —
  they exercise the same spawn / `inherit_parent_main` path via
  the successor API.
- the recursive `spawn()` helper drops its white-box
  `an._children` / portal-`_peers` asserts (which probed
  `run_in_actor`'s portal + nursery-tracking internals);
  `to_actor.run()` returns the result and reaps internally, so
  keep the user-facing `result == 10` check.
- `test_most_beautiful_word` drops the 2nd `wait_for_result()`
  (the legacy result-cache re-fetch) — `to_actor.run()` delivers
  the value once, no cache.

Suite: 9 passed.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 5416568108 Port `test_rpc` off `run_in_actor`
Sole call-site: `run_in_actor(sleep_back_actor, ...)` ->
blocking `tractor.to_actor.run(..., an=n, ...)` (#477 removal).
The RPC-callback subactor is awaited in-caller instead of
reaped at nursery teardown; `name=`/`enable_modules=` map to
`to_actor.run()`'s same-named params, the rest to `**fn_kwargs`.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 3a715a0c89 Port `test_runtime` off `run_in_actor`
Sole call-site: an inlined `run_in_actor(...).result()` ->
blocking `tractor.to_actor.run(fn, an=an, ...)` (#477 removal).
Behaviour identical — the one-shot's result/error is awaited
in the caller's task rather than reaped at nursery teardown;
the enclosing `move_on_after` still cancels the sub in the
`error_in_child=False` case.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 60847b8808 Port `test_infected_asyncio` off `run_in_actor`
First test-file of the #477 `.run_in_actor()` removal (blocking
`to_actor.run()` is the successor; the legacy non-blocking one-shot
is dropped, not replaced). All 9 call-sites migrated,

- blocking result/error/streaming-result tests -> `to_actor.run(fn,
  an=an, ...)`; the "streaming" ones stream aio<->trio INSIDE the
  subactor so the caller only awaits the final result.
- forever-task + cancel tests (`test_tractor_cancels_aio`,
  `test_trio_cancels_aio`) -> `start_actor()` +
  `Portal.open_context()` + cancel — can't block on a
  never-returning task. Adds a small `sleep_forever_aio_ctx`
  `@context` shim.
- greens the red `test_tractor_cancels_aio` anti-hang guard from
  the prior commit: under the correctly-scoped API the wait is
  bounded by the caller's cancel scope, so the hang is structurally
  gone — not patched.

Suite: 34 passed, 2 xfailed (trio backend).

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi af81b98fe6 Add anti-hang `fail_after` cap to aio-cancel test
Wrap `test_tractor_cancels_aio`'s `main()` in a
`trio.fail_after(9 * cpu_perf_headroom())` so a wedged remote
runtime can't hang the test forever. This is the blessed
anti-hang guard here bc `pytest-timeout`'s global cap is
intentionally off (it breaks `trio` under the fork backends,
per the `pyproject` NOTE).

The cap is generous + CPU-headroom-scaled bc it's an anti-hang
guard, not a perf assertion. Motivated by the
`._ria_nursery`-removal regression where a wedged ria-reaper
once hung this exact test indefinitely.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 65ef08129f Merge the supervise error handlers into one
Step B2 of the `._ria_nursery` removal (issue #477; see
`ai/conc-anal/ria_nursery_removal_plan.md`). With the 2ndary
nursery gone (step B), the two nested error handlers in
`_open_and_supervise_one_cancels_all_nursery` collapse to one,

- the outer `except (Exception, BaseExceptionGroup,
  trio.Cancelled)` existed to catch errors bubbling from the
  old `._ria_nursery.__aexit__` reaper-group; that nursery no
  longer exists.
- trace shows the outer handler's `raise` was already DEAD: the
  inner handler records `errors[uid]` as its first action, so
  `errors` is always non-empty by the time anything could reach
  the outer handler, and the `finally`'s raise-from-`errors`
  always superseded the outer `raise`.
- so fold both into a single `except BaseException as
  _scope_err` guarding the lone daemon nursery; the `finally`
  (unchanged) still raises the collected `errors` as a single
  exc or `BaseExceptionGroup`.
- drop the now-unused `outer_err`/`inner_err` locals.

Behaviour-preserving (net ~30 lines lighter); the big diff is
the one-level de-indent of the handler body. The two remaining
`maybe_wait_for_debugger()` guards collapse to the single
pre-teardown wait.

Prompt-IO: ai/prompt-io/claude/20260702T222544Z_9201a2ed_prompt_io.md

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi e8b0a816c5 Doc step-B2 handler-merge + prompt-io
Split from the step-B2 code commit to keep the runtime diff
free of `ai/` meta noise,

- `ai/conc-anal/ria_nursery_removal_plan.md`: add a "Step-B2
  outcome" section — the dead-outer-`raise` trace, why the
  merge is behavior-preserving, and the gate results.
- `ai/prompt-io/claude/20260702T222544Z_9201a2ed_*`: NLNet
  provenance (log + unedited raw) for the step-B2 work.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi ff599a2fd4 Drop the vestigial `._ria_nursery`
Step B of the `._ria_nursery` removal (issue #477; see
`ai/conc-anal/ria_nursery_removal_plan.md`). With step A having
rerouted `.run_in_actor()` children onto the daemon nursery,
the 2ndary "run-in-actor" nursery spawns nothing and its stored
ref is never read — pure dead weight,

- collapse the inner `async with trio.open_nursery() as
  ria_nursery` layer in
  `_open_and_supervise_one_cancels_all_nursery`; `da_nursery` is
  now the single nursery for ALL subactors.
- `ActorNursery.__init__` loses the `ria_nursery` param + the
  `self._ria_nursery` attr; `start_actor()` loses its `nursery=`
  escape-hatch (spawns via `self._da_nursery` directly).
- `._cancel_after_result_on_exit` stays — still the ria-child
  discriminator for `_reap_ria_portals()`.

Behavior-preserving: a zero-task `trio.open_nursery()` only adds
a checkpoint. The two error handlers are KEPT (now nested under
the single nursery); merging them changes error/cancel
propagation and is deferred to its own PR (TODO left at the
outer `except`).

Prompt-IO: ai/prompt-io/claude/20260702T172233Z_5cd190c5_prompt_io.md

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi a9309e41f3 Doc step-B outcome + prompt-io
Split from the step-B code commit to keep the runtime diff
free of `ai/` meta noise,

- `ai/conc-anal/ria_nursery_removal_plan.md`: add a "Step-B
  outcome" section — the empty-nursery collapse, why it's
  behavior-preserving, the deliberate handler-merge deferral,
  and the targeted-gate result.
- `ai/prompt-io/claude/20260702T172233Z_5cd190c5_*`: NLNet
  provenance (log + unedited raw) for the step-B work.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 8eb59fe79b Hoist ria-reaping out of the spawn backends
Step A of the `._ria_nursery` removal (issue #477 follow-up, see
`ai/conc-anal/ria_nursery_removal_plan.md`): `.run_in_actor()`
children now spawn via the default daemon nursery and their
result-reaping moves up into the `ActorNursery` machinery,

- new `_supervise._reap_ria_portals()`: one
  `_spawn.cancel_on_completion()` task per ria child, run AFTER
  `._join_procs` is set — replacing the per-child reaper task the
  backends formerly spawned (keyed off
  `._cancel_after_result_on_exit` membership) which required
  routing such children into `._ria_nursery`.
- happy path: reap awaited right after `._join_procs.set()`,
  preserving "collect ria results before daemon join" sequencing.
- error path: snapshot ria `(portal, subactor)` pairs (backend
  `finally`s pop `._children` as procs reap), `await an.cancel()`,
  THEN a 0.5s-bounded reap over the snapshot; anything collectable
  is already queued in the local ctx and a parked reaper
  self-cleans (`trio.Cancelled` results are never stashed). NB: a
  concurrent reap+cancel variant deadlocked `test_multierror` and
  a 3s bound blew `test_cancel_while_childs_child_in_sync_sleep`'s
  deadline — deats in the plan doc's probe history.
- `spawn/_trio.py` + `spawn/_mp.py`: drop the membership branch,
  per-child reaper nursery + now-unused `cancel_on_completion`
  imports; the join phase is a bare `soft_kill()`.

`._ria_nursery` is now vestigial (zero spawn users): step B
deletes it + `start_actor()`'s `nursery=` escape hatch and merges
the supervisor's two error handlers.

Prompt-IO: ai/prompt-io/claude/20260702T165806Z_a34aaf98_prompt_io.md

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi c800dfe32d Add `_ria_nursery` removal plan + step-A prompt-io
Split from the step-A code commit to keep the runtime diff
free of `ai/` meta noise,

- `ai/conc-anal/ria_nursery_removal_plan.md`: agent-verified
  machinery map + 3-step (A/B/C) design + probe history
  (reap-relocation deadlock -> sequencing fix -> bound
  tighten) + risk register for the `._ria_nursery` excision.
- `ai/prompt-io/claude/20260702T165806Z_a34aaf98_*`: NLNet
  provenance (log + unedited raw) for the step-A work.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 452c59bbe8 Add `to_actor` one-shot parallelism example
Demo both flavors of the new API in a runnable script
(auto-collected by `test_docs_examples.py`),

- the fully-implicit one-shot which boots (and tears down) the
  actor-runtime around a single `to_actor.run()` call,
- the concurrent "worker-pool-ish" prime-check pattern: a local
  `trio` task nursery scheduling one-shots against a shared
  caller-managed `an`, mirroring (in miniature) the neighboring
  `concurrent_actors_primes.py` example per issue #477.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi 2cbdf1d0d8 Add `tests/test_to_actor.py` one-shot API suite
Cover every placement variant + failure mode of the new
`to_actor.run()`,

- private-nursery one-shot + implicit runtime boot via pass-through
  `runtime_kwargs`,
- remote-error relay to the caller's task (bare and inside a
  caller-managed `an`) as boxed `RemoteActorError`s,
- caller-nursery spawn + portal-reuse w/o implicit reap,
- the concurrent "worker-pool-ish" pattern: a local `trio` task
  nursery scheduling one-shots against a shared `an`,
- the 4 pre-spawn validation rejections (sync fn, async-gen fn,
  `portal`+`an` combo, `runtime_kwargs`+placement combo).

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Gud Boi cfb1b42140 Add `tractor.to_actor` one-shot task API subpkg
First cut at the `to_thread`/`to_process`-style "run it over there"
wrapper layer from issue #477: a single-remote-task invocation API
decoupled from the `ActorNursery` spawn machinery, composed purely
from the lower level daemon-actor + portal primitives,

- `to_actor.run(fn, **fn_kwargs)` spawns a subactor via
  `ActorNursery.start_actor()`, schedules `fn` as its lone task
  with `Portal.run()` and ALWAYS reaps it via a `finally`-scoped
  `Portal.cancel_actor()` (whose bounded cancel-req wait is
  internally shielded so the reap also runs under caller-scope
  cancellation).
- remote errors raise directly in the caller's task as boxed
  `RemoteActorError`s, moving error collection/propagation up into
  whatever local `trio` scope encloses the call.
- "placement" opts: `portal=` reuses a running actor (no
  spawn/reap), `an=` spawns from a caller-managed actor-nursery,
  neither opens a private call-scoped `open_nursery()` (implicitly
  booting the runtime, tunable via pass-through `runtime_kwargs`).
- fail-fast validation BEFORE any spawn: non-streaming async fn
  only (same constraint as `Portal.run()`), `portal=`/`an=` mutual
  exclusion and no `runtime_kwargs` alongside a placement opt.

Also,
- x-ref the successor API from `.run_in_actor()`'s deprecation TODO
  + docstring; emitting a formal `DeprecationWarning` waits on
  migrating in-repo usage.
- log prompt-io provenance per NLNet policy incl. the driver prompt
  file.

Prompt-IO: ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.md

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 20:06:27 -04:00
Bd 3ad7e7e5dc
Merge pull request #459 from goodboy/dependabot/uv/idna-3.15
Bump idna from 3.10 to 3.18
2026-08-12 19:55:40 -04:00
dependabot[bot] 4b4cc76263 Bump idna from 3.10 to 3.18
Bumps [idna](https://github.com/kjd/idna) from 3.10 to 3.18.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.md)
- [Commits](https://github.com/kjd/idna/compare/v3.10...v3.18)

---
updated-dependencies:
- dependency-name: idna
  dependency-version: '3.18'
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-12 19:35:17 -04:00
Bd 99f9beccb2
Merge pull request #487 from goodboy/dependabot/uv/setuptools-83.0.0
Bump setuptools from 82.0.1 to 83.0.0
2026-08-12 17:13:22 -04:00
dependabot[bot] 5c4d42c7a7 Bump setuptools from 82.0.1 to 83.0.0
Bumps [setuptools](https://github.com/pypa/setuptools) from 82.0.1 to 83.0.0.
- [Release notes](https://github.com/pypa/setuptools/releases)
- [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst)
- [Commits](https://github.com/pypa/setuptools/compare/v82.0.1...v83.0.0)

---
updated-dependencies:
- dependency-name: setuptools
  dependency-version: 83.0.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-12 16:48:08 -04:00
Bd d887603a1b
Merge pull request #479 from goodboy/wkt/start_or_cancel_tests_474
Add `.trionics.start_or_cancel()` test suite
2026-08-12 16:43:33 -04:00
Gud Boi ae67e2f429 Assert cancellation at the startup boundary
Replace the pre-start wall-clock sleep with an indefinite
checkpoint so cancellation ordering cannot race a timer in slow CI.

Record that `Cancelled` escapes each `start_or_cancel()` await
before the enclosing nursery or cancel scope handles it.

Review: PR #479 (goodboy)
https://github.com/goodboy/tractor/pull/479

(this patch was generated in some part by `opencode` using
`gpt-5.6-sol` (`openai`))
2026-08-12 15:45:30 -04:00
Gud Boi 1c7d0c7f3e Match Trio's startup error exactly
Compare the complete canonical `Nursery.start()` protocol error
before re-surfacing ambient cancellation. Preserve child-owned
`RuntimeError` objects whose messages only resemble Trio's wording.

Cover the colliding prefix and assert the original error remains the
exception group's sole leaf.

Review: PR #479 (goodboy)
https://github.com/goodboy/tractor/pull/479

(this patch was generated in some part by `opencode` using
`gpt-5.6-sol` (`openai`))
2026-08-12 15:45:23 -04:00
Gud Boi 203a0f7e1f Add `.trionics.start_or_cancel()` test suite
Resolve #474 with a new `tests/trionics/test_taskc.py` (9
tests) covering the `trio.Nursery.start()` wrapper landed in
PR #464, incl. the `modden.runtime.progman.open_wks()` use
case dug out as a minimal repro.

Deats,
- the lossy `RuntimeError('child exited without calling
  task_status.started()')` only fires when the child absorbs
  its ambient cancel pre-`.started()` (graceful-teardown
  pattern); a well-behaved child surfaces `Cancelled` direct
  from `.start()` on `trio` 0.29 - verified empirically 1st.
- `test_sibling_err_not_masked_by_startup_rte`: the `modden`
  case; ONLY the root-cause sibling `ValueError` escapes the
  nursery with the wrapper vs. bare-`.start()`'s lossy
  riding-along startup-RTE noise.
- `test_pure_oob_cancel_not_morphed_to_rte`: plain ancestor
  `cs.cancel()` exits clean vs. bare's eg-wrapped RTE.
- `test_genuine_startup_rte_still_raised`: sans cancellation
  the protocol-bug RTE re-raises same as bare.
- `test_childs_own_rte_never_demoted_to_cancel`: exact-msg +
  `isinstance`-guard regression cover; a child's own
  `RuntimeError('never got started!')`/`RuntimeError(1234)`
  never demotes to a `Cancelled`.
- `test_started_value_and_args_passthru`: positional args,
  `name=` and `.started()`-value forwarding.

Also,
- `use_start_or_cancel=False` params pin upstream `trio`'s
  current lossy behaviour as wart-documentation: a break on
  a `trio` upgrade likely means new upstream porcelain and
  the wrapper deserves a re-audit.
- verified 0 flakes over 50 hammer runs + 2 impl mutations
  each caught by exactly the targeted tests.

Prompt-IO: ai/prompt-io/claude/20260702T161624Z_65bf9df5_prompt_io.md
(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-08-12 14:15:31 -04:00
Bd 92c737ad83
Merge pull request #458 from mahmoudhas/fix/guard-hot-path-log-rendering
Guard hot-path log calls to avoid payload rendering when disabled
2026-08-12 14:08:47 -04:00
Gud Boi 935c8cf656 Guard receive-path transport rendering
Skip raw packet, decoded message, peer, and channel formatting when
transport logging is disabled. Keep message processing and wire
reads outside the guards so logging controls never affect IPC flow.

Also narrow the remaining pretty-struct TODO to require a
non-raising formatter with native-repr fallback.

(this patch was generated in some part by `opencode` using
`gpt-5.6-sol` (`openai`))
2026-08-12 13:48:40 -04:00
Gud Boi 84ec895150 Drop resolved channel log-guard TODO
Remove the stale `Channel.from_addr()` design note now that its
`at_least_level()` guard avoids inactive pretty-rendering work.

(this patch was generated in some part by `opencode` using
`gpt-5.6-sol` (`openai`))
2026-08-12 12:51:43 -04:00
Gud Boi 67280c2898 Honor log disable controls in hot-path guards
Use `Logger.isEnabledFor()` in `at_least_level()` so logger-local
and global disable controls short-circuit payload rendering.

Add `Channel.send()` coverage for effective-level, per-logger, and
global suppression while ensuring transport remains unchanged.

Caught-during: review remediation
Found-via: `/run-tests` test_log_guard_skips_payload_formatting

Review: PR #458 (goodboy)
https://github.com/goodboy/tractor/pull/458#issuecomment-5258207470

(this patch was generated in some part by `opencode` using
`gpt-5.6-sol` (`openai`))
2026-08-11 21:56:27 -04:00
root 0e11ff7e9d Guard hot-path log calls to avoid payload rendering when disabled
Wrap `log.transport()` in `Channel.send()` and `log.runtime()` in
`PldRx.decode_pld()` with `log.at_least_level()` checks so that
expensive `pformat(payload)` / `repr(msg)` / `repr(pld)` calls are
skipped entirely when the respective log level is not active.

Previously the f-string arguments were eagerly evaluated before being
passed to the log method, even though `StackLevelAdapter.log()` would
then discard the message internally via its own `isEnabledFor()` check.
On high-frequency IPC paths this caused `pformat` to dominate CPU
usage (~60-70 %) as reported in #455.

This also restores the full diagnostic output (msg type, decoded
payload) that was temporarily commented out in 0373164 as a stopgap.

Resolves #455
2026-08-11 20:41:20 -04:00
mahmoud 148a098ca6 avoid format on the hot send path 2026-08-11 20:41:20 -04:00
Bd 83b3488455
Merge pull request #488 from goodboy/wkt/moc_teardown_completion
Wait for ctx exit in `maybe_open_context()`
2026-08-11 12:16:15 -04:00
Gud Boi daa661aba3 Clarify `maybe_open_context()` teardown notes
Drop the stale sentinel experiment and fix the cancellation-path
comment. Document that cached regular `__aexit__()` failures are
always re-raised at the final consumer boundary.

Review: PR #488 (goodboy)
https://github.com/goodboy/tractor/pull/488

(this patch was generated in some part by `opencode` using
`gpt-5.6-sol` (`openai`))
2026-08-11 11:38:43 -04:00
Gud Boi 55ec3dbf51 Drop unused `_Cache` teardown bindings
Remove the unused `value` assignment after cache eviction and skip
unpacking stale resource state before raising its invariant error.

Review: PR #488 (Copilot)
https://github.com/goodboy/tractor/pull/488#pullrequestreview-4850557500

(this patch was generated in some part by `opencode` using
`gpt-5.6-sol` (`openai`))
2026-08-07 22:24:27 -04:00
Gud Boi a753625fc6 Wait for ctx exit in `maybe_open_context()`
Block the final user on its cached resource's `__aexit__()` and
raise regular cleanup errors at that user's ctx boundary.

Deats,
- serialize user registration and teardown under each cache-key lock
- keep queued entrants on the same lock through resource replacement
- preserve `Cancelled`, `KeyboardInterrupt`, and `SystemExit` flow
- cover successful, failing, cancelled, and re-entry teardown paths

Prompt-IO: ai/prompt-io/opencode/20260804T030309Z_65bf9df5_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-03 23:45:42 -04:00
86 changed files with 4191 additions and 1296 deletions

View File

@ -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.

View File

@ -0,0 +1,255 @@
# `to_actor.open_taskman()` design + impl plan (issue #485)
Self-contained hand-off doc: everything needed to implement the
tracking issue https://github.com/goodboy/tractor/issues/485
without prior session context. Read alongside the (shorter)
issue body; where they differ THIS doc is the more detailed and
the issue is the contract.
## Context (why this exists)
Issue #477 removed the legacy non-blocking
`ActorNursery.run_in_actor()` (see PR #484, branch
`drop_ria_nursery`, + `ria_nursery_removal_plan.md` in this
dir): its "result at nursery-teardown" semantic required an
internal reap-cluster whose unowned result-waits produced an
unbounded-hang class. The blocking successor `to_actor.run()`
(PR #481) covers one-shots; the *deferred/non-blocking* shape
now needs a properly scoped home — an explicit supervision
scope for dynamically spawned remote tasks over a (flat)
subactor cluster.
Core principle distilled from the #477 arc: **every
non-blocking remote-task invocation must have an
explicitly-owned enclosing scope**. Hence NO `Portal.run_soon()`
public method (a connection handle must not own task lifetimes)
— the scope-owning object *is* the API.
## Prior art (all three MUST be mined)
1. **`hilevel_serman` branch** (tip `93d161bf`, 2024-12,
4 commits): `tractor/hilevel/{__init__,_service.py}` — the
`piker.service.Services` port. `open_service_mngr()`
(actor-global singleton acm stacking `open_nursery()` +
`trio.open_nursery()`), `ServiceMngr` dataclass w/
`.start_service_task()` (local bg task w/ cs+done-event),
`.start_service_ctx()` (bg-task-supervised remote ctx via
`_open_and_supervise_service_ctx()`), `.start_service()`
(spawn actor + service ctx), `.cancel_service[_task]()`.
2. **PR #363** (`oco_supervisor_prototype`, OPEN): the
`trionics` "taskman" prototype — a `trio.Nursery`-like
per-task-scope-manager via user-defined
single-yield-generator hooks. Mine for the per-task
scope-hook shape + naming; its in-code ref appears (typo'd
as "#346") in `_service.py`'s
"TODO, unify this interface with our `TaskManager` PR!".
3. **`piker.service._mngr.Services`** + the
`piker.data.feed.open_feed()` `gather_contexts()` pyramid:
the production usage this design must be able to replace.
Also related in-tree: `trionics.gather_contexts(mngrs, tn=None)`
(the static fan-out cousin) and the `to_actor.open_one_shot()`
single-task sketch in `ria_nursery_removal_plan.md`.
## `hilevel_serman` rebase + convergence map
Recon (as of `drop_ria_nursery` @ `c62f93a8`):
- 266 commits behind `main`; `tractor/hilevel/` is a NEW dir so
NO file-level merge conflicts expected.
- imports only top-level names (`ActorNursery`, `Context`,
`Portal`, `current_actor`, `ContextCancelled`, `log`) — all
still exported post-subsystem-reorg. Rebase = API-drift work.
- **KNOWN BREAKAGE**: `_service.py:292` awaits
`portal.wait_for_result()` inside
`_open_and_supervise_service_ctx()` — that API was DELETED by
PR #484 (`2a59cefb`). Fix: drop the call; the tuple-return
becomes just `ctx_res` (the `Portal` "main result" notion no
longer exists; the ctx result is the only result).
- `.uid` usages (`canceller != portal.chan.uid`) may want the
newer `.aid.uid` spelling — check against current `Channel`.
- `log.info(f'`pikerd` service ...')` strings still say
"pikerd" — de-piker-ify while touching.
Convergence: which `ServiceMngr` piece informs which taskman
piece,
| `hilevel_serman` | taskman |
|---|---|
| `_open_and_supervise_service_ctx()` | the parked-holder task (core primitive, factor + share) |
| `.start_service_ctx()` | `tm.run_soon()` (ctx-endpoint flavor) |
| `.start_service_task()` | (local-task variant; out of scope for taskman, stays service-only) |
| `.start_service()` | `open_worker_pool()`-ish spawn+run compose |
| `.cancel_service[_task]()` | `ctx.cancel()` / `tm.cancel()` |
| singleton `open_service_mngr()` | NOT copied — taskman is plain-scoped, no singleton |
Key semantic DELTA vs `ServiceMngr`: the service holder's
`finally: await portal.cancel_actor()` couples ACTOR lifetime
to ctx lifetime. The taskman must NOT do this — in `an=`
(cluster) placement actors are reused across tasks and their
lifetime belongs to the caller's `ActorNursery`/pool layer.
Actor-reaping stays out of the holder task entirely.
## The design
### API surface
```python
async with to_actor.open_taskman(
# placement (pass at most one; both None -> private
# call-scoped `open_nursery()` matching `to_actor.run()`)
an: ActorNursery|None = None,
portal: Portal|None = None,
# error strategy: 'one_cancels_all' (default) | 'collect'
strategy: str = 'one_cancels_all',
# exit policy: 'wait' (default, trio-nursery-like) |
# 'cancel' (service-style)
on_exit: str = 'wait',
) as tm:
ctx: Context = await tm.run_soon(
fn, # plain async fn OR @tractor.context ep
ptl=None, # explicit worker override (BYO balancing)
name=None, # task name, default fn.__name__ (+ dedup)
**fn_kwargs,
)
...
res = await ctx.wait_for_result() # optional; caller-only
```
- `tm.tasks: dict[str, Context]` registry; name collision ->
`ValueError`.
- `tm.cancel()`: graceful per-ctx `ctx.cancel()` sweep then
holder-release; NO raw `CancelScope` exposure (the rejected
`manage_portal() as cs` idea invites hard-cancel where
graceful-then-escalate is the SC-blessed order).
- cluster balancing when `an=`-placed: `round_robin` default
over `an`'s portals; `ptl=` per-call override.
### The parked-holder core (per `run_soon()`)
One holder task spawned into the tm's PRIVATE trio nursery:
```python
async def _holder(...):
async with portal.open_context(fn, **kws) as (ctx, first):
started_ps.started(ctx) # hand ctx out to caller
await release_evt.wait() # park — NEVER on the result
# acm exit drains/collects the ctx outcome; any error
# raises HERE into the tm scope
```
- **discipline rule**: the caller is the SOLE result consumer
(`ctx.wait_for_result()`); the holder never touches it. This
avoids the two-awaiters-on-one-stream shape that plagued
`run_in_actor()` internals (`._expect_result_ctx`).
- an un-`wait()`ed task's error still propagates: ctx exit-drain
raises into the holder -> tm nursery -> tm scope. Nothing can
be silently dropped.
- release triggers: task's remote completion (see impl note
below), `ctx.cancel()`, `tm.cancel()`, tm scope exit.
- impl note: "park until done OR released" wants
`await ctx._scope...`-ish completion OR simply parking on the
event and letting exit-drain block on a still-running task
per the `on_exit='wait'` policy. Start simple: park on the
event; `on_exit='wait'` -> tm exit sets events then the acm
exits naturally block until each remote task completes;
`on_exit='cancel'` -> `ctx.cancel()` each live task first.
### Endpoint flavors
`run_soon()` accepts BOTH,
- `@tractor.context` endpoints -> `portal.open_context()`
(full streaming; the holder shape above).
- plain async fns -> the `Portal.run()`-style
`Actor.start_remote_task()` ctx (one-way protocol; no
`ctx.started()` handshake so `first` is None-ish). Dispatch
on the `_tractor_context_function`/`_tractor_stream_function`
markers (see `to_actor._api._validate_one_shot_fn()` +
`Portal.run()` for the existing dispatch precedents).
This kills the `@context`-shim friction the #477 migration
exposed (`assert_err_ctx` etc. in `test_cancellation.py`).
### Supervision strategies
- `one_cancels_all` (default): holder error propagates through
the tm nursery -> siblings cancelled — plain trio semantics.
- `collect`: each holder catches its task's
`RemoteActorError`, stashes it, keeps siblings running; at tm
exit raise the lot as a `BaseExceptionGroup` (single error
collapses per the runtime `collapse_eg()` convention). This
resurrects the deterministic reap-all `BEG`-of-N *opt-in*:
`test_multierror`-class assertions can re-tighten and the
collect-don't-cancel boilerplate in
`examples/debugging/multi_subactors.py` becomes a one-liner.
- future (NOT PR A): restart strategies (`one_for_one`...) —
the Erlang-supervisor roadmap finally has a natural home.
### Resolved design items (w/ reasoning)
1. **exit-policy default = `wait`**: matches `trio.Nursery`
block-exit semantics (least surprise for the "task nursery"
framing); service-style forever-tasks opt into
`on_exit='cancel'` (what `piker`'s `Services` effectively
does) or use `tm.cancel()`.
2. **`collect` returns nothing extra**: errors group-raise at
exit; VALUES stay per-ctx (`ctx.wait_for_result()`) — the tm
carries error *policy*, not result aggregation (keeps the
surface minimal; a gather-style values helper can wrap later).
3. **teardown ordering**: tm ctx-drain/cancel completes before
any actor teardown *structurally*`open_taskman(an=an)`
nests inside the `an` block so acm exit order guarantees it;
the holder never actor-cancels (see the ServiceMngr delta
above).
4. **`hilevel_serman` inclusion**: rebase it as PR A's opening
commits (it's self-contained, +618 lines, no conflicts, one
API fix) and factor the shared supervised-ctx core so
`ServiceMngr` + taskman diverge only in policy
(actor-coupled + singleton vs plain-scoped). If the rebase
fights back, fall back to pattern-mining only and leave
`hilevel_serman` for its own PR — do NOT let it block the
taskman.
## PR plan
- **PR A** (`to_actor/_taskman.py` MVP):
1. rebase `hilevel_serman` -> current `main`-line (fix the
`portal.wait_for_result()` breakage, de-piker-ify strs).
2. factor the parked-holder core; implement `ActorTaskMngr` +
`open_taskman()` + `run_soon()` per above.
3. test suite `tests/test_taskman.py` mirroring
`test_to_actor.py`'s structure: placement variants, both
endpoint flavors, both strategies, both exit policies,
`tm.cancel()`, error-relay + collect-BEG shapes,
name-collision, cluster round-robin + `ptl=` override.
4. docs: extend the one-shot sections
(`docs/guide/{spawning,rpc}.rst`, `docs/api/core.rst`).
- **PR B**: `open_worker_pool(count=, names=, modules=)`
(absorbs #172) + reimplement/deprecate
`_clustering.open_actor_cluster()` atop; port
`test_trynamic_trio` + `a_trynamic_first_scene.py` (the
mutual-rendezvous boilerplate from `a297a32a`) +
`multi_subactors` collect pattern + re-tighten
`test_multierror`-class assertions; add
`examples/parallelism/taskman_pool.py`.
- **PR C** (stretch, `piker`-side): swap
`Services.start_service_task()` internals (and eventually the
`open_feed()` pyramid) onto the upstream taskman.
## Verification gates (repo conventions)
- per-commit module gates on the `trio` backend + `mp_spawn`
spot-gates; ALWAYS include `tests/test_infected_asyncio.py`
in any gate touching spawn/supervise machinery (the #477
lesson).
- `tests/devx/test_debugger.py` must stay byte-identical if any
`examples/debugging/` file is touched.
- full suite (`uv run pytest tests/` w/
`UV_PROJECT_ENVIRONMENT=py313`) green on `trio` before PR;
NB the *full-session* `mp_spawn` run mass-fails pre-existing
(`KeyError` in `an.cancel()`; unexercised by CI's trio-only
matrix) — do not chase it here.
- docs `uv run --group docs make -C docs html` clean.

View File

@ -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).

View File

@ -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)

View File

@ -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.

View File

@ -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
```

View File

@ -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`).

View File

@ -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.

View File

@ -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`).

View File

@ -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)
```

View File

@ -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`).

View File

@ -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
```

View File

@ -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`).

View File

@ -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).

View File

@ -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.

View File

@ -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`.

View File

@ -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.

View File

@ -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.

View File

@ -0,0 +1,36 @@
attempt to resolve (PR A of)
https://github.com/goodboy/tractor/issues/485
do it with /open-wkt (suggested wkt name: `taskman_485`).
REQUIRED READING before writing any code,
- the full design + impl plan (self-contained hand-off doc):
`ai/conc-anal/to_actor_taskman_design.md`
* it resolves the open design items, carries the
`hilevel_serman` rebase/convergence map (incl. the known
`portal.wait_for_result()` breakage at `_service.py:292`)
and defines the PR A/B/C sequencing — follow it.
- the issue body (the contract + task list): #485
- prior art to derive from,
* branch `hilevel_serman` (tip `93d161bf`): the
`tractor/hilevel/_service.py` `ServiceMngr` port — rebase
it first per the doc.
* PR #363 (`oco_supervisor_prototype`): the `trionics`
taskman prototype.
* `ria_nursery_removal_plan.md` (same dir as the design doc)
for the #477 background + the `open_one_shot()` sketch this
generalizes.
house rules (also see the repo/global CLAUDE.md + skills),
- NEVER `git commit`/`push` without an explicit human ack in
the moment; per-file/-group commits for bisectability, with
a failing test landing BEFORE its fix (red -> green).
- gate per-commit: module tests on `trio` + `mp_spawn`
spot-gates, ALWAYS incl. `tests/test_infected_asyncio.py`
for spawn/supervise-touching changes; full suite green
before PR (`UV_PROJECT_ENVIRONMENT=py313 uv run pytest`).
- all `ActorNursery` bindings are named `an:`; `trio` nursery
bindings `tn:`.
- log prompt-io per the NLNet policy (`/prompt-io` skill) and
update the design doc's outcome as you land steps.

View File

@ -37,7 +37,6 @@ Spawning actors
.. autoclass:: ActorNursery .. autoclass:: ActorNursery
:members: start_actor, :members: start_actor,
run_in_actor,
cancel, cancel,
cancel_called, cancel_called,
cancelled_caught cancelled_caught
@ -47,10 +46,22 @@ Spawning actors
:meth:`ActorNursery.start_actor` (daemon actor + portal) is the :meth:`ActorNursery.start_actor` (daemon actor + portal) is the
blessed spawning primitive; pair it with blessed spawning primitive; pair it with
``Portal.open_context()`` for SC-linked remote tasks. ``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 One-shot task actors
to be rebuilt as a high-level wrapper, so don't design around --------------------
it as the core model.
.. 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 .. deprecated:: 0.1.0a6
@ -71,14 +82,12 @@ flowing back `exactly like trio`_.
:members: run, :members: run,
run_from_ns, run_from_ns,
open_stream_from, open_stream_from,
wait_for_result,
cancel_actor, cancel_actor,
chan chan
.. deprecated:: 0.1.0a6 .. deprecated:: 0.1.0a6
``Portal.result()`` warns; use :meth:`Portal.wait_for_result`. The str-form ``Portal.run('mod.path', 'fn_name')`` warns;
The str-form ``Portal.run('mod.path', 'fn_name')`` also warns;
pass a function *object* whose module is listed in the target's pass a function *object* whose module is listed in the target's
``enable_modules``. ``Portal.channel`` is the legacy spelling ``enable_modules``. ``Portal.channel`` is the legacy spelling
of :attr:`Portal.chan`. of :attr:`Portal.chan`.

View File

@ -76,8 +76,8 @@ Just flip the flag on :meth:`tractor.ActorNursery.start_actor`:
infect_asyncio=True, infect_asyncio=True,
) )
The one-shot convenience ``ActorNursery.run_in_actor()`` accepts The one-shot convenience ``tractor.to_actor.run()`` accepts the
the same flag. The ``to_asyncio`` APIs may **only** be called from same flag. The ``to_asyncio`` APIs may **only** be called from
tasks inside an infected actor; calling them anywhere else raises tasks inside an infected actor; calling them anywhere else raises
a loud ``RuntimeError``. You can introspect at runtime with a loud ``RuntimeError``. You can introspect at runtime with
``tractor.current_actor().is_infected_aio()``. ``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 It schedules the fn as an ``asyncio.Task``, waits for completion
and hands the return value back to ``trio``; think of it as the 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. cancellation are translated exactly as for channels.
Cross-loop errors and cancellation Cross-loop errors and cancellation

View File

@ -64,11 +64,13 @@ What's going on here?
- three healthy actors are spawned as daemons via - three healthy actors are spawned as daemons via
:meth:`tractor.ActorNursery.start_actor`; left alone they'd :meth:`tractor.ActorNursery.start_actor`; left alone they'd
happily idle forever, happily idle forever,
- a fourth actor runs ``assert_err()`` via ``.run_in_actor()`` and - a fourth actor runs ``assert_err()`` via a blocking
promptly trips its ``assert 0``, ``tractor.to_actor.run()`` one-shot and promptly trips its
``assert 0``,
- the resulting ``AssertionError`` ships back over IPC as a - the resulting ``AssertionError`` ships back over IPC as a
serialized error msg and re-raises *boxed* inside the nursery serialized error msg and re-raises *boxed* right at the call
block as a :class:`tractor.RemoteActorError`, inside the nursery block as a
:class:`tractor.RemoteActorError`,
- the nursery reacts like any ``trio`` nursery would: it cancels - the nursery reacts like any ``trio`` nursery would: it cancels
the three healthy siblings (graceful runtime-cancel requests, the three healthy siblings (graceful runtime-cancel requests,
acks awaited), reaps all four processes, then re-raises, acks awaited), reaps all four processes, then re-raises,

View File

@ -15,8 +15,8 @@ a single `structured concurrency`_ (SC) scope over IPC.
:alt: sequence diagram of the context handshake msg flow :alt: sequence diagram of the context handshake msg flow
Pretty much everything else is (or is slated to be) built on this Pretty much everything else is (or is slated to be) built on this
one primitive: ``ActorNursery.run_in_actor()`` is a convenience one primitive: ``tractor.to_actor.run()`` is a convenience for
for "spawn, open a context, await the result, tear down"; plain "spawn, run the lone task, await the result, tear down"; plain
``Portal.run()`` RPC is planned to be re-implemented on top of it; ``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 the multi-process debugger's tree-wide REPL lock rides one. Grok
this page and the rest of the library reads as convenience this page and the rest of the library reads as convenience

View File

@ -119,15 +119,16 @@ Run a func in a process
Even a pool can be overkill; "run this one async func in a Even a pool can be overkill; "run this one async func in a
subprocess and give me the result" is a one-liner via 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 .. literalinclude:: ../../examples/parallelism/single_func.py
:caption: examples/parallelism/single_func.py :caption: examples/parallelism/single_func.py
:language: python :language: python
``run_in_actor()`` is a *convenience wrapper* — spawn an actor, run ``to_actor.run()`` is a *convenience wrapper* — spawn an actor,
exactly one task in it, reap on result — not the core spawning run exactly one task in it, block on and return its result, reap
model (that's :meth:`tractor.ActorNursery.start_actor` plus — not the core spawning model (that's
:meth:`tractor.ActorNursery.start_actor` plus
:meth:`tractor.Portal.open_context`; see :doc:`/guide/context`). :meth:`tractor.Portal.open_context`; see :doc:`/guide/context`).
But for this fire-and-collect shape it's exactly the right amount But for this fire-and-collect shape it's exactly the right amount
of typing. of typing.

View File

@ -80,28 +80,30 @@ One special namespace exists: ``'self'`` resolves to the remote
how internal machinery (cancel requests, registry ops) travels; how internal machinery (cancel requests, registry ops) travels;
don't build your app on it. don't build your app on it.
One-shot results: ``wait_for_result()`` One-shot subactors: ``to_actor.run()``
--------------------------------------- --------------------------------------
A portal returned from When a subactor's *entire job* is a single function call, skip
:meth:`~tractor.ActorNursery.run_in_actor` has exactly one the portal plumbing with :func:`tractor.to_actor.run`: spawn,
"main" task running remotely; that task's ``return`` value is run the lone task, return its result and reap the process — all
delivered as the portal's *final result*: in one blocking call:
.. code:: python .. code:: python
portal = await an.run_in_actor(fib, n=10) final = await tractor.to_actor.run(fib, an=an, n=10)
final = await portal.wait_for_result()
Semantics worth knowing: Semantics worth knowing:
- it blocks until the remote task returns, re-raising any - it blocks until the remote task returns, re-raising any
remote error in the usual boxed form. remote error in the usual boxed form right in the calling
- once resolved it's idempotent: later calls return the same task.
cached value. - "placement" is composable: ``an=`` spawns from an existing
- a *daemon* portal (from ``start_actor()``) has no main task, actor-nursery, ``portal=`` reuses an already-running actor
so there's no final result to wait for: you'll get a warning (no spawn/reap, just a ``Portal.run()``), and passing
plus a ``NoResult`` sentinel. Results of individual daemon neither opens a private call-scoped nursery (booting the
calls come straight back from each ``await portal.run()``. 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()`` Pure RPC daemons: ``run_daemon()``
---------------------------------- ----------------------------------

View File

@ -103,19 +103,22 @@ What's going on here?
on him **forever**. Daemon lifetimes are *yours* to end; that on him **forever**. Daemon lifetimes are *yours* to end; that
explicitness is the point. 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 :func:`tractor.to_actor.run` is the convenience wrapper: spawn
wrapper: spawn an actor, run exactly one async function in it, an actor, run exactly one async function in it, block on the
then reap the process as soon as the result arrives. result, then reap the process — the distributed sibling of
``trio.to_thread.run_sync()``.
.. code:: python .. code:: python
async with tractor.open_nursery() as an: async with (
portal = await an.run_in_actor(burn_cpu) tractor.open_nursery() as an,
trio.open_nursery() as tn,
):
# burn rubber in the parent too... # burn rubber in the parent too...
await burn_cpu() tn.start_soon(burn_cpu)
total = await portal.wait_for_result() total = await tractor.to_actor.run(burn_cpu, an=an)
A few details worth knowing: 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 - the function's module is auto-added to the child's
``enable_modules`` allowlist. ``enable_modules`` allowlist.
- extra ``**kwargs`` are forwarded to the function itself. - extra ``**kwargs`` are forwarded to the function itself.
- the child is *auto-cancelled* once its "main" result lands; - the call blocks until the result (or error) lands and the
at nursery exit these run-once children are always reaped child is *auto-cancelled* (reaped) right after — so remote
first (causality_ is paramount!). 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:: .. note::
``run_in_actor()`` is a convenience, **not** the core model. ``to_actor.run()`` is a convenience, **not** the core model —
The source literally marks it for an eventual rebuild as it's built *entirely* on ``start_actor()`` + ``Portal.run()``
a thin "hilevel" wrapper on top of + ``Portal.cancel_actor()``. Teach your fingers to use it for
:meth:`~tractor.Portal.open_context` (the modern inter-actor quick fire-and-collect parallelism — think a per-function
task API). Teach your fingers to use it for quick
fire-and-collect parallelism — think a per-function
trio-parallel_ style one-shot — and reach for trio-parallel_ style one-shot — and reach for
``start_actor()`` + ``open_context()`` for anything ``start_actor()`` + ``open_context()`` for anything
long-lived, stateful or streaming long-lived, stateful or streaming
@ -145,9 +151,9 @@ Actor lifetimes and teardown order
---------------------------------- ----------------------------------
So we have two lifetime flavors: 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) its single task; reaped the moment its result (or error)
arrives. arrives back in the (blocking) call.
- **daemon** (``start_actor()``): lives until *someone* cancels - **daemon** (``start_actor()``): lives until *someone* cancels
it — an explicit ``await portal.cancel_actor()``, a bulk it — an explicit ``await portal.cancel_actor()``, a bulk
``await an.cancel()``, or the one-cancels-all strategy kicking ``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: On a clean exit of the nursery block the teardown order is:
1. the nursery waits on every run-once actor's final result; 1. one-shot actors never make it to nursery exit: each is
any errors from these are raised immediately so your code reaped inside its own ``to_actor.run()`` call, any error
(acting as supervisor) gets first crack at handling them. raising immediately in the calling task so your code
2. then it waits on daemon actors — **indefinitely**. If you (acting as supervisor) gets first crack at handling it.
spawned a daemon, you own its lifetime. 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 When a child *is* cancelled, teardown is graceful-first per SC
discipline: the runtime sends an IPC cancel request and gives discipline: the runtime sends an IPC cancel request and gives

View File

@ -43,24 +43,20 @@ Run it::
What's going on here? What's going on here?
- ``trio.run(main)`` starts the **root actor**; the ``tractor`` - ``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 whenever it isn't already up. No special entrypoint, no
framework takeover - it's just a ``trio`` app, framework takeover - it's just a ``trio`` app,
- inside ``main()`` a *subactor* is spawned via - 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()``, 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 - the subactor, *some_linguist*, boots a fresh ``trio.run()`` in
a **new process** and executes ``cellar_door()`` as its *main a **new process** and executes ``cellar_door()`` as its *main
task* (note the child proving it is *not* the root with task* (note the child proving it is *not* the root with
``tractor.is_root_process()``), then ships the return value ``tractor.is_root_process()``), then ships the return value
back over IPC, back over IPC,
- the parent grabs that *final result* with - the call *blocks* until that final result arrives, then
``await portal.wait_for_result()``, much like you'd expect returns it - causality is preserved: your task only proceeds
from a "future" - except causality is preserved: the nursery once the child is *done*, dead, and reaped.
block only exits once the child is *done*, dead, and reaped.
.. margin:: Just need a worker pool? .. margin:: Just need a worker pool?
@ -71,19 +67,22 @@ What's going on here?
.. note:: .. 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 spawn-run-reap semantics for when a subactor's entire job is
a single function call. The core primitives are 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 ``Portal.open_context()`` for full, SC-linked cross-actor
dialogs - see :doc:`/guide/context`. dialogs - see :doc:`/guide/context`.
Daemon actors and RPC Daemon actors and RPC
--------------------- ---------------------
A ``run_in_actor()``-spawned actor terminates when its main task A ``to_actor.run()`` one-shot subactor terminates when its lone
returns. But often you want long-lived *daemon* actors instead: task returns. But often you want long-lived *daemon* actors
spawned once, then serving (allowlisted) RPC requests until told instead: spawned once, then serving (allowlisted) RPC requests
otherwise. That's ``start_actor()``: until told otherwise. That's ``start_actor()``:
.. literalinclude:: ../../examples/actor_spawning_and_causality_with_daemon.py .. literalinclude:: ../../examples/actor_spawning_and_causality_with_daemon.py
:caption: 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: Two lifetime rules to internalize:
- a ``run_in_actor()`` actor lives exactly as long as its main - a ``to_actor.run()`` one-shot actor lives exactly as long as
task; the nursery waits for that function (and thus the its lone task; the call blocks until that function (and thus
process) to complete before unblocking, the process) completes,
- a ``start_actor()`` actor *lives forever* - an RPC daemon the - a ``start_actor()`` actor *lives forever* - an RPC daemon the
nursery will happily wait on **indefinitely** - until some nursery will happily wait on **indefinitely** - until some
task explicitly cancels it via ``Portal.cancel_actor()`` (as task explicitly cancels it via ``Portal.cancel_actor()`` (as

View File

@ -21,23 +21,35 @@ async def main():
"""Main tractor entry point, the "master" process (for now """Main tractor entry point, the "master" process (for now
acts as the "director"). acts as the "director").
""" """
async with tractor.open_nursery() as n: async with tractor.open_nursery() as an:
print("Alright... Action!") print("Alright... Action!")
donny = await n.run_in_actor( # both actors wait on (then dial!) the *other*, so each
say_hello, # must outlive both hellos: spawn as daemons, run the
name='donny', # hellos concurrently, reap only once both complete.
# arguments are always named portals: dict[str, tractor.Portal] = {
other_actor='gretchen', name: await an.start_actor(
) name,
gretchen = await n.run_in_actor( enable_modules=[__name__],
say_hello, )
name='gretchen', for name in ('donny', 'gretchen')
other_actor='donny', }
)
print(await gretchen.wait_for_result()) async def run_and_print(name: str, other_actor: str):
print(await donny.wait_for_result()) print(
print("CUTTTT CUUTT CUT!!! Donny!! You're supposed to say...") 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__': if __name__ == '__main__':

View File

@ -10,17 +10,14 @@ async def cellar_door():
async def main(): async def main():
"""The main ``tractor`` routine. """The main ``tractor`` routine.
""" """
async with tractor.open_nursery() as n: # spawn a subactor, run ``cellar_door()`` as its lone task,
# block until its result arrives and the subactor is reaped.
portal = await n.run_in_actor( print(
await tractor.to_actor.run(
cellar_door, cellar_door,
name='some_linguist', 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__': if __name__ == '__main__':

View File

@ -12,9 +12,9 @@ async def movie_theatre_question():
async def main(): async def main():
"""The main ``tractor`` routine. """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', 'frank',
# enable the actor to run funcs from this current module # enable the actor to run funcs from this current module
enable_modules=[__name__], enable_modules=[__name__],

View File

@ -15,9 +15,9 @@ async def stream_forever() -> AsyncIterator[int]:
async def main(): 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', 'donny',
enable_modules=[__name__], enable_modules=[__name__],
) )

View File

@ -1,3 +1,5 @@
from functools import partial
import trio import trio
import tractor import tractor
@ -21,25 +23,36 @@ async def breakpoint_forever():
async def spawn_until(depth=0): async def spawn_until(depth=0):
""""A nested nursery that triggers another ``NameError``. """"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: if depth < 1:
await n.run_in_actor(breakpoint_forever) tn.start_soon(
partial(
p = await n.run_in_actor( tractor.to_actor.run,
name_error, breakpoint_forever,
name='name_error' an=an,
)
) )
await trio.sleep(0.5) await trio.sleep(0.5)
# rx and propagate error from child # rx and propagate error from child
await p.result() await tractor.to_actor.run(
name_error,
an=an,
name='name_error',
)
else: else:
# recusrive call to spawn another process branching layer of # 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 depth -= 1
await n.run_in_actor( await tractor.to_actor.run(
spawn_until, spawn_until,
an=an,
depth=depth, depth=depth,
name=f'spawn_until_{depth}', name=f'spawn_until_{depth}',
) )
@ -65,35 +78,34 @@ async def main():
python -m tractor._child --uid ('spawn_until_0', 'de918e6d ...) python -m tractor._child --uid ('spawn_until_0', 'de918e6d ...)
""" """
async with tractor.open_nursery( async with (
debug_mode=True, tractor.open_nursery(
loglevel='pdb', debug_mode=True,
) as n: loglevel='pdb',
) as an,
# spawn both actors trio.open_nursery() as tn,
portal = await n.run_in_actor( ):
spawn_until, # spawn both spawner trees as concurrent one-shots; the
depth=3, # first tree's (relayed) error cancels the other.
name='spawner0', tn.start_soon(
partial(
tractor.to_actor.run,
spawn_until,
an=an,
depth=3,
name='spawner0',
)
) )
portal1 = await n.run_in_actor( tn.start_soon(
spawn_until, partial(
depth=4, tractor.to_actor.run,
name='spawner1', 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__': if __name__ == '__main__':
trio.run(main) trio.run(main)

View File

@ -15,12 +15,12 @@ async def name_error():
async def spawn_error(): async def spawn_error():
""""A nested nursery that triggers another ``NameError``. """"A nested nursery that triggers another ``NameError``.
""" """
async with tractor.open_nursery() as n: async with tractor.open_nursery() as an:
portal = await n.run_in_actor( return await tractor.to_actor.run(
name_error, name_error,
an=an,
name='name_error_1', name='name_error_1',
) )
return await portal.result()
async def main(): async def main():
@ -38,29 +38,36 @@ async def main():
- root actor should then fail on assert - root actor should then fail on assert
- program termination - program termination
""" """
async with tractor.open_nursery( async with (
debug_mode=True, tractor.open_nursery(
loglevel='devx', debug_mode=True,
) as n: loglevel='devx',
) as an,
trio.open_nursery() as tn,
):
# spawn both actors..
portal = await an.start_actor(
'name_error',
enable_modules=[__name__],
)
portal1 = await an.start_actor(
'spawn_error',
enable_modules=[__name__],
)
# spawn both actors # ..and bg-schedule their erroring tasks.
portal = await n.run_in_actor( tn.start_soon(portal.run, name_error)
name_error, tn.start_soon(portal1.run, spawn_error)
name='name_error',
) # yield to the bg tasks so both RPC requests are
portal1 = await n.run_in_actor( # submitted (and start crashing) before the root's own
spawn_error, # error below (the legacy `run_in_actor()` submitted
name='spawn_error', # in-line with each spawn).
) await trio.sleep(0.5)
# trigger a root actor error # trigger a root actor error
assert 0 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__': if __name__ == '__main__':
trio.run(main) trio.run(main)

View File

@ -17,12 +17,12 @@ async def name_error():
async def spawn_error(): async def spawn_error():
""""A nested nursery that triggers another ``NameError``. """"A nested nursery that triggers another ``NameError``.
""" """
async with tractor.open_nursery() as n: async with tractor.open_nursery() as an:
portal = await n.run_in_actor( return await tractor.to_actor.run(
name_error, name_error,
an=an,
name='name_error_1', name='name_error_1',
) )
return await portal.result()
async def main(): async def main():
@ -36,17 +36,39 @@ async def main():
`-python -m tractor._child --uid ('spawn_error', '52ee14a5 ...) `-python -m tractor._child --uid ('spawn_error', '52ee14a5 ...)
`-python -m tractor._child --uid ('name_error', '3391222c ...) `-python -m tractor._child --uid ('name_error', '3391222c ...)
""" """
errors: list[BaseException] = []
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=True, debug_mode=True,
# loglevel='runtime', # loglevel='runtime',
) as n: ) as an:
# Spawn both actors, don't bother with collecting results async def run_and_collect(fn):
# (would result in a different debugger outcome due to parent's '''
# cancellation). One-shot whose (boxed) error is stashed instead of
await n.run_in_actor(breakpoint_forever) raised so a sibling's crash never cancels the others
await n.run_in_actor(name_error) before they've had their own debugger sessions (the
await n.run_in_actor(spawn_error) "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__': if __name__ == '__main__':

View File

@ -21,8 +21,8 @@ async def main() -> None:
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=True, debug_mode=True,
) as n: ) as an:
portal = await n.start_actor( portal = await an.start_actor(
'ctx_child', 'ctx_child',
# XXX: we don't enable the current module in order # XXX: we don't enable the current module in order

View File

@ -6,14 +6,14 @@ async def die():
async def main(): 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', 'debugged_boi',
enable_modules=[__name__], enable_modules=[__name__],
debug_mode=True, debug_mode=True,
) )
crash_boi = await tn.start_actor( crash_boi = await an.start_actor(
'crash_boi', 'crash_boi',
enable_modules=[__name__], enable_modules=[__name__],
# debug_mode=True, # debug_mode=True,

View File

@ -1,3 +1,5 @@
from functools import partial
import trio import trio
import tractor import tractor
@ -10,14 +12,14 @@ async def name_error():
async def spawn_until(depth=0): async def spawn_until(depth=0):
""""A nested nursery that triggers another ``NameError``. """"A nested nursery that triggers another ``NameError``.
""" """
async with tractor.open_nursery() as n: async with tractor.open_nursery() as an:
if depth < 1: if depth < 1:
# await n.run_in_actor('breakpoint_forever', breakpoint_forever) await tractor.to_actor.run(name_error, an=an)
await n.run_in_actor(name_error)
else: else:
depth -= 1 depth -= 1
await n.run_in_actor( await tractor.to_actor.run(
spawn_until, spawn_until,
an=an,
depth=depth, depth=depth,
name=f'spawn_until_{depth}', name=f'spawn_until_{depth}',
) )
@ -37,28 +39,33 @@ async def main():
python -m tractor._child --uid ('name_error', '6c2733b8 ...) python -m tractor._child --uid ('name_error', '6c2733b8 ...)
''' '''
async with tractor.open_nursery( async with (
debug_mode=True, tractor.open_nursery(
enable_transports=['uds'], # TODO, apss this via osenv? debug_mode=True,
loglevel='devx', # XXX, required for test! enable_transports=['uds'], # TODO, apss this via osenv?
) as n: 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 # ..while blocking on the shallow (faster to fail) tree
portal = await n.run_in_actor( # whose propagated error triggers nursery cancellation.
await tractor.to_actor.run(
spawn_until, spawn_until,
an=an,
depth=0, depth=0,
name='spawner0', 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__': if __name__ == '__main__':

View File

@ -13,17 +13,24 @@ async def main():
simultaneously. simultaneously.
''' '''
async with tractor.open_nursery( async with (
debug_mode=True, tractor.open_nursery(
# loglevel='debug' # ?XXX required? debug_mode=True,
) as n: # loglevel='debug' # ?XXX required?
) as an,
# spawn both actors trio.open_nursery() as tn,
portal = await n.run_in_actor(key_error) ):
# spawn the actor..
portal = await an.start_actor(
'key_error',
enable_modules=[__name__],
)
print( print(
f'Child is up @ {portal.chan.aid.reprol()}' 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 # XXX: originally a bug caused by this is where root would enter
# the debugger and clobber the tty used by the repl even though # the debugger and clobber the tty used by the repl even though

View File

@ -74,11 +74,11 @@ async def cancelled_before_pause(
async def main(): async def main():
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=True, debug_mode=True,
) as n: ) as an:
portal: tractor.Portal = await n.run_in_actor( await tractor.to_actor.run(
cancelled_before_pause, cancelled_before_pause,
an=an,
) )
await portal.wait_for_result()
# ensure the same works in the root actor! # ensure the same works in the root actor!
await pm_on_cancelled() await pm_on_cancelled()

View File

@ -58,8 +58,8 @@ async def main():
debug_mode=True, debug_mode=True,
enable_transports=[tpt], enable_transports=[tpt],
loglevel='devx', loglevel='devx',
) as n: ) as an:
p = await n.start_actor( p = await an.start_actor(
'bp_boi', 'bp_boi',
enable_modules=[__name__], enable_modules=[__name__],
) )

View File

@ -17,12 +17,14 @@ async def main():
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=True, debug_mode=True,
loglevel='cancel', 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, breakpoint_forever,
an=an,
) )
await portal.wait_for_result()
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -12,16 +12,12 @@ async def main():
) as an: ) as an:
# TODO: ideally the REPL arrives at this frame in the parent, # TODO: ideally the REPL arrives at this frame in the parent,
# ABOVE the @api_frame of `Portal.run_in_actor()` (which # ABOVE the @api_frame of `to_actor.run()` ..
# should eventually not even be a portal method ... XD)
# await tractor.pause() # await tractor.pause()
p: tractor.Portal = await an.run_in_actor(name_error)
# with this style, should raise on this line # the one-shot blocks on the subactor's result so the
await p.wait_for_result() # boxed `NameError` raises right here.
await tractor.to_actor.run(name_error, an=an)
# with this alt style should raise at `open_nusery()`
# return await p.wait_for_result()
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -90,7 +90,7 @@ async def main() -> None:
# TODO: 3 sub-actor usage cases: # TODO: 3 sub-actor usage cases:
# -[x] via a `.open_context()` # -[x] via a `.open_context()`
# -[ ] via a `.run_in_actor()` call # -[ ] via a `to_actor.run()` call
# -[ ] via a `.run()` # -[ ] via a `.run()`
# -[ ] via a `.to_thread.run_sync()` in subactor # -[ ] via a `.to_thread.run_sync()` in subactor
async with p.open_context( async with p.open_context(

View File

@ -50,8 +50,8 @@ async def trio_to_aio_echo_server(
async def main(): async def main():
async with tractor.open_nursery() as n: async with tractor.open_nursery() as an:
p = await n.start_actor( p = await an.start_actor(
'aio_server', 'aio_server',
enable_modules=[__name__], enable_modules=[__name__],
infect_asyncio=True, infect_asyncio=True,

View File

@ -29,9 +29,9 @@ async def main() -> None:
)) ))
await proc.wait() await proc.wait()
# await trio.sleep_forever() # 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', # 'rpc_server',
# enable_modules=[__name__], # enable_modules=[__name__],
# ) # )

View File

@ -55,7 +55,7 @@ async def worker_pool(workers=4):
Yes, the workers stay alive (and ready for work) until you close Yes, the workers stay alive (and ready for work) until you close
the context. the context.
""" """
async with tractor.open_nursery() as tn: async with tractor.open_nursery() as an:
portals = [] portals = []
snd_chan, recv_chan = trio.open_memory_channel(len(PRIMES)) 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 # this starts a new sub-actor (process + trio runtime) and
# stores it's "portal" for later use to "submit jobs" (ugh). # stores it's "portal" for later use to "submit jobs" (ugh).
portals.append( portals.append(
await tn.start_actor( await an.start_actor(
f'worker_{i}', f'worker_{i}',
enable_modules=[__name__], enable_modules=[__name__],
) )
@ -80,10 +80,10 @@ async def worker_pool(workers=4):
async def send_result(func, value, portal): async def send_result(func, value, portal):
await snd_chan.send((value, await portal.run(func, n=value))) 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)): for value, portal in zip(sequence, itertools.cycle(portals)):
n.start_soon( tn.start_soon(
send_result, send_result,
worker_func, worker_func,
value, value,
@ -98,7 +98,7 @@ async def worker_pool(workers=4):
yield _map yield _map
# tear down all "workers" on pool close # tear down all "workers" on pool close
await tn.cancel() await an.cancel()
async def main(): async def main():

View File

@ -25,17 +25,15 @@ async def burn_cpu():
async def main(): 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 # run the same func as the lone task in a subactor,
await burn_cpu() # 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}") print(f"Collected subproc {pid}")

View File

@ -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)

View File

@ -7,19 +7,20 @@ async def assert_err():
async def main(): async def main():
async with tractor.open_nursery() as n: async with tractor.open_nursery() as an:
real_actors = [] real_actors = []
for i in range(3): for i in range(3):
real_actors.append(await n.start_actor( real_actors.append(await an.start_actor(
f'actor_{i}', f'actor_{i}',
enable_modules=[__name__], enable_modules=[__name__],
)) ))
# start one actor that will fail immediately # run one one-shot task actor that will fail immediately;
await n.run_in_actor(assert_err) # 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 # ..as a ``RemoteActorError`` containing an ``AssertionError``
# an ``AssertionError`` and all the other actors have been cancelled # and all the other actors have been cancelled
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -31,9 +31,9 @@ async def simple_rpc(
async def main() -> None: 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', 'rpc_server',
enable_modules=[__name__], enable_modules=[__name__],
) )

View File

@ -849,43 +849,36 @@ def test_multi_nested_subactors_error_through_nurseries(
break break
# boxed source errors # 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] = [ expect_patts: list[str] = [
"NameError: name 'doggypants' is not defined",
"tractor._exceptions.RemoteActorError:", "tractor._exceptions.RemoteActorError:",
"('name_error'",
# first level subtrees # each level's unwrapped-single-member-group
# "tractor._exceptions.RemoteActorError: ('spawner0'", # annotation + the first-level subtree's boundary
"src_uid=('spawner0'", # footer.
"( ^^^ this exc was collapsed from a group ^^^ )",
# "tractor._exceptions.RemoteActorError: ('spawner1'", "------ ('spawner0'",
# 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'",
] ]
# 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 ( if (
not is_forking_spawner not is_forking_spawner
and and
last_send_char == 'q' last_send_char == 'q'
): ):
expect_patts += [ expect_patts += [
# expect the pdb-quit exc. # expect the pdb-quit exc relayed from the leaf's
# bp-loop child.
"bdb.BdbQuit", "bdb.BdbQuit",
# BUT WHY these dude!? "src_uid=('breakpoint_forever'",
"src_uid=('spawn_until_0'",
"relay_uid=('spawn_until_1'",
] ]
assert_before( assert_before(

View File

@ -46,9 +46,9 @@ async def test_reg_then_unreg(
async with tractor.open_nursery( async with tractor.open_nursery(
registry_addrs=[reg_addr], 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 uid = portal.channel.aid.uid
async with tractor.get_registry(reg_addr) as aportal: 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? # XXX: can we figure out what the listen addr will be?
assert sockaddrs assert sockaddrs
await n.cancel() # tear down nursery await an.cancel() # tear down nursery
await trio.sleep(0.1) await trio.sleep(0.1)
assert uid not in aportal.actor._registry assert uid not in aportal.actor._registry
@ -89,9 +89,9 @@ async def test_reg_then_unreg_maddr(
async with tractor.open_nursery( async with tractor.open_nursery(
registry_addrs=[maddr_str], registry_addrs=[maddr_str],
) as n: ) as an:
portal = await n.start_actor( portal = await an.start_actor(
'actor_maddr', 'actor_maddr',
enable_modules=[__name__], enable_modules=[__name__],
) )
@ -105,7 +105,7 @@ async def test_reg_then_unreg_maddr(
sockaddrs = actor._registry[uid] sockaddrs = actor._registry[uid]
assert sockaddrs assert sockaddrs
await n.cancel() await an.cancel()
await trio.sleep(0.1) await trio.sleep(0.1)
assert uid not in aportal.actor._registry assert uid not in aportal.actor._registry
@ -152,23 +152,37 @@ async def test_trynamic_trio(
for the directed subs. for the directed subs.
''' '''
async with tractor.open_nursery() as n: async with tractor.open_nursery() as an:
print("Alright... Action!") print("Alright... Action!")
donny = await n.run_in_actor( # donny + gretchen each wait on (then dial!) the *other*, so
ria_fn, # both actors must OUTLIVE both hellos: spawn as daemons and
other_actor='gretchen', # only reap after both tasks complete. NB a pair of eagerly
reg_addr=reg_addr, # reaped `to_actor.run()` one-shots races: the first to
name='donny', # finish dies while the other may still be dialing its
) # registry-resolved (now dead) sockaddr -> conn-refused.
gretchen = await n.run_in_actor( portals: dict[str, tractor.Portal] = {
ria_fn, name: await an.start_actor(
other_actor='donny', name,
reg_addr=reg_addr, enable_modules=[__name__],
name='gretchen', )
) for name in ('donny', 'gretchen')
print(await gretchen.result()) }
print(await donny.result())
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...") print("CUTTTT CUUTT CUT!!?! Donny!! You're supposed to say...")
@ -270,13 +284,15 @@ async def spawn_and_check_registry(
portals = {} portals = {}
for i in range(3): for i in range(3):
name = f'a{i}' name = f'a{i}'
if with_streaming: # a daemon subactor is alive + registered
portals[name] = await an.start_actor( # without a "main" task; the streaming
name=name, enable_modules=[__name__]) # branch below uses the module funcs, the
# non-streaming case just needs it up (was
else: # no streaming # `run_in_actor(trio.sleep_forever)`).
portals[name] = await an.run_in_actor( portals[name] = await an.start_actor(
trio.sleep_forever, name=name) name=name,
enable_modules=[__name__],
)
# wait on last actor to come up # wait on last actor to come up
async with tractor.wait_for_actor(name): async with tractor.wait_for_actor(name):

View File

@ -3,6 +3,7 @@ Advanced streaming patterns using bidirectional streams and contexts.
''' '''
from collections import Counter from collections import Counter
from functools import partial
import itertools import itertools
import platform import platform
from typing import Type from typing import Type
@ -173,8 +174,8 @@ def test_dynamic_pub_sub(
# test. Picked backend-aware: under `trio` backend spawn is # test. Picked backend-aware: under `trio` backend spawn is
# cheap (~1s for `cpus` actors) but fork-based backends pay # cheap (~1s for `cpus` actors) but fork-based backends pay
# a per-spawn cost (forkserver round-trip + IPC peer-handshake) # a per-spawn cost (forkserver round-trip + IPC peer-handshake)
# that can stack up over `cpus - 1` sequential `n.run_in_actor()` # that can stack up over the `cpus - 1` one-shot
# calls — especially on UDS under cross-pytest contention # (`to_actor.run()`) spawns — especially on UDS under cross-pytest contention
# (#451 / #452). 4s was flaking right at the edge under fork # (#451 / #452). 4s was flaking right at the edge under fork
# backends — bumped to 8s with diag-snapshot-on-timeout via # backends — bumped to 8s with diag-snapshot-on-timeout via
# `fail_after_w_trace` so a borderline run still fails loud # `fail_after_w_trace` so a borderline run still fails loud
@ -214,33 +215,55 @@ def test_dynamic_pub_sub(
f'enter `fail_after_w_trace({fail_after_s})` scope' f'enter `fail_after_w_trace({fail_after_s})` scope'
) )
try: try:
async with tractor.open_nursery( async with (
registry_addrs=[reg_addr], tractor.open_nursery(
debug_mode=debug_mode, registry_addrs=[reg_addr],
) as n: 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_log.cancel(
'test_dynamic_pub_sub: ' 'test_dynamic_pub_sub: '
'actor nursery opened' 'actor nursery opened'
) )
# name of this actor will be same as target func # 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( for i, sub in zip(
range(cpus - 2), range(cpus - 2),
itertools.cycle(_registry.keys()) itertools.cycle(_registry.keys())
): ):
await n.run_in_actor( tn.start_soon(
consumer, partial(
name=f'consumer_{sub}', tractor.to_actor.run,
subs=[sub], consumer,
an=an,
name=f'consumer_{sub}',
subs=[sub],
)
) )
# make one dynamic subscriber # make one dynamic subscriber
await n.run_in_actor( tn.start_soon(
consumer, partial(
name='consumer_dynamic', tractor.to_actor.run,
subs=list(_registry.keys()), consumer,
an=an,
name='consumer_dynamic',
subs=list(_registry.keys()),
)
) )
# block until "cancelled by user" # block until "cancelled by user"
@ -347,10 +370,10 @@ def test_reqresp_ontopof_streaming():
timeout = 4 timeout = 4
with trio.move_on_after(timeout): 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 # name of this actor will be same as target func
portal = await n.start_actor( portal = await an.start_actor(
'dual_tasks', 'dual_tasks',
enable_modules=[__name__] enable_modules=[__name__]
) )
@ -413,9 +436,9 @@ def test_sigint_both_stream_types():
async def main(): async def main():
with trio.fail_after(timeout): 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 # name of this actor will be same as target func
portal = await n.start_actor( portal = await an.start_actor(
'2_way', '2_way',
enable_modules=[__name__] enable_modules=[__name__]
) )
@ -528,8 +551,8 @@ def test_local_task_fanout_from_stream(
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=debug_mode, debug_mode=debug_mode,
) as tn: ) as an:
p: tractor.Portal = await tn.start_actor( p: tractor.Portal = await an.start_actor(
'inf_streamer', 'inf_streamer',
enable_modules=[__name__], enable_modules=[__name__],
) )

View File

@ -2,6 +2,7 @@
Cancellation and error propagation Cancellation and error propagation
""" """
from functools import partial
import os import os
import signal import signal
import platform import platform
@ -16,6 +17,10 @@ from tractor._testing import (
tractor_test, tractor_test,
) )
from tractor._testing.trace import FailAfterWTraceFactory from tractor._testing.trace import FailAfterWTraceFactory
from tractor.trionics import (
collapse_eg,
gather_contexts,
)
from .conftest import no_windows from .conftest import no_windows
@ -68,6 +73,23 @@ async def assert_err(delay=0):
assert 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(): async def sleep_forever():
await trio.sleep_forever() await trio.sleep_forever()
@ -104,24 +126,19 @@ def test_remote_error(
async def main(): async def main():
async with tractor.open_nursery( async with tractor.open_nursery(
registry_addrs=[reg_addr], registry_addrs=[reg_addr],
) as nursery: ) as an:
# on a remote type error caused by bad input args # `to_actor.run()` blocks on the one-shot's result and
# this should raise directly which means we **don't** get # raises the remote error directly here in the caller's
# an exception group outside the nursery since the error # task (a bad-arg `TypeError` likewise relays as a
# here and the far end task error are one in the same? # `RemoteActorError`).
portal = await nursery.run_in_actor(
assert_err,
name='errorer',
**args
)
# get result(s) from main task
try: try:
# this means the root actor will also raise a local await tractor.to_actor.run(
# parent task error and thus an eg will propagate out assert_err,
# of this actor nursery. an=an,
await portal.result() name='errorer',
**args
)
except tractor.RemoteActorError as err: except tractor.RemoteActorError as err:
assert err.boxed_type == errtype assert err.boxed_type == errtype
print("Look Maa that actor failed hard, hehh") print("Look Maa that actor failed hard, hehh")
@ -162,114 +179,49 @@ def test_multierror(
set_fork_aware_capture, #: Callable, set_fork_aware_capture, #: Callable,
): ):
''' '''
Verify we raise a ``BaseExceptionGroup`` out of a nursery where Verify concurrent one-shot subactors erroring propagate a remote
more then one actor errors. 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 def main():
async with tractor.open_nursery( async with tractor.open_nursery(
registry_addrs=[reg_addr], registry_addrs=[reg_addr],
) as nursery: ) as an:
await nursery.run_in_actor(assert_err, name='errorer1') portals = [
portal2 = await nursery.run_in_actor(assert_err, name='errorer2') await an.start_actor(
f'errorer{i}',
enable_modules=[__name__],
)
for i in range(2)
]
# get result(s) from main task # both one-shot subactors error concurrently, so the
try: # `gather_contexts()` task-nursery collects them into a
await portal2.result() # `BaseExceptionGroup` (was two non-blocking
except tractor.RemoteActorError as err: # `run_in_actor()`s reaped at nursery teardown).
assert err.boxed_type is AssertionError async with gather_contexts(
print("Look Maa that first actor failed hard, hehh") mngrs=[
raise p.open_context(assert_err_ctx)
for p in portals
],
):
pass
# here we should get a ``BaseExceptionGroup`` containing exceptions with pytest.raises((
# from both subactors BaseExceptionGroup,
tractor.RemoteActorError,
with pytest.raises(BaseExceptionGroup): )):
trio.run(main) trio.run(main)
@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
)
# 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:
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(): async def do_nothing():
pass pass
@ -296,16 +248,16 @@ def test_cancel_single_subactor(
''' '''
async with tractor.open_nursery( async with tractor.open_nursery(
registry_addrs=[reg_addr], registry_addrs=[reg_addr],
) as nursery: ) as an:
portal = await nursery.start_actor( portal = await an.start_actor(
'nothin', enable_modules=[__name__], 'nothin', enable_modules=[__name__],
) )
assert (await portal.run(do_nothing)) is None assert (await portal.run(do_nothing)) is None
if mechanism == 'nursery_cancel': if mechanism == 'nursery_cancel':
# would hang otherwise # would hang otherwise
await nursery.cancel() await an.cancel()
else: else:
raise mechanism raise mechanism
@ -337,8 +289,8 @@ async def test_cancel_infinite_streamer(
trio.fail_after(4), trio.fail_after(4),
trio.move_on_after(1) as cancel_scope trio.move_on_after(1) as cancel_scope
): ):
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', 'donny',
enable_modules=[__name__], enable_modules=[__name__],
) )
@ -351,36 +303,36 @@ async def test_cancel_infinite_streamer(
# we support trio's cancellation system # we support trio's cancellation system
assert cancel_scope.cancelled_caught assert cancel_scope.cancelled_caught
assert n.cancel_called assert an.cancel_called
@pytest.mark.parametrize( @pytest.mark.parametrize(
'num_actors_and_errs', '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), (1, tractor.RemoteActorError, AssertionError, (assert_err, {}), None),
(2, BaseExceptionGroup, AssertionError, (assert_err, {}), None), (2, BaseExceptionGroup, AssertionError, (assert_err, {}), None),
(3, 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, {}), (3, tractor.RemoteActorError, AssertionError, (sleep_forever, {}),
(assert_err, {}, True)), (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 # actors complete quickly
(3, tractor.RemoteActorError, AssertionError, (3, tractor.RemoteActorError, AssertionError,
(do_nuthin, {}), (assert_err, {'delay': 1}, True)), (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 # actors error after brief delay
(3, BaseExceptionGroup, AssertionError, (3, BaseExceptionGroup, AssertionError,
(assert_err, {'delay': 1}), (do_nuthin, {}, False)), (assert_err, {'delay': 1}), (do_nuthin, {}, False)),
], ],
ids=[ ids=[
'1_run_in_actor_fails', '1_one_shot_fails',
'2_run_in_actors_fail', '2_one_shots_fail',
'3_run_in_actors_fail', '3_one_shots_fail',
'1_daemon_actors_fail', '1_daemon_actors_fail',
'1_daemon_actors_fail_all_run_in_actors_dun_quick', '1_daemon_actors_fail_all_one_shots_dun_quick',
'no_daemon_actors_fail_all_run_in_actors_sleep_then_fail', 'no_daemon_actors_fail_all_one_shots_sleep_then_fail',
], ],
) )
@tractor_test( @tractor_test(
@ -399,12 +351,22 @@ async def test_some_cancels_all(
This is the first and only supervisory strategy at the moment. 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, num_actors,
first_err, first_err,
err_type, err_type,
ria_func, one_shot_func,
da_func, da_func,
) = num_actors_and_errs ) = num_actors_and_errs
try: try:
@ -418,51 +380,64 @@ async def test_some_cancels_all(
enable_modules=[__name__], enable_modules=[__name__],
)) ))
func, kwargs = ria_func func, kwargs = one_shot_func
riactor_portals = [] async with (
for i in range(num_actors): collapse_eg(),
# start actor(s) that will fail immediately trio.open_nursery() as tn,
riactor_portals.append( ):
await an.run_in_actor( for i in range(num_actors):
func, # schedule one-shot task actor(s); errors
name=f'actor_{i}', # raise into this task-nursery scope.
**kwargs tn.start_soon(
partial(
tractor.to_actor.run,
func,
an=an,
name=f'actor_{i}',
**kwargs,
)
) )
)
if da_func: if da_func:
func, kwargs, expect_error = da_func func, kwargs, expect_error = da_func
for portal in dactor_portals: for portal in dactor_portals:
# if this function fails then we should error here # if this function fails then we should error
# and the nursery should teardown all other actors # here and the nursery should teardown all
try: # other actors
await portal.run(func, **kwargs) try:
await portal.run(func, **kwargs)
except tractor.RemoteActorError as err: except tractor.RemoteActorError as err:
assert err.boxed_type == err_type assert err.boxed_type == err_type
# we only expect this first error to propogate # we only expect this first error to propogate
# (all other daemons are cancelled before they # (all other daemons are cancelled before they
# can be scheduled) # can be scheduled)
num_actors = 1 num_actors = 1
# reraise so nursery teardown is triggered # reraise so nursery teardown is triggered
raise raise
else: else:
if expect_error: if expect_error:
pytest.fail( pytest.fail(
"Deamon call should fail at checkpoint?") "Deamon call should fail at checkpoint?")
# should error here with a ``RemoteActorError`` or ``MultiError`` # should error here with a `RemoteActorError` or a beg of them
except first_err as _err: except (
BaseExceptionGroup,
tractor.RemoteActorError,
) as _err:
err = _err err = _err
if isinstance(err, BaseExceptionGroup): 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: for exc in err.exceptions:
if isinstance(exc, tractor.RemoteActorError): assert isinstance(exc, tractor.RemoteActorError)
assert exc.boxed_type == err_type assert exc.boxed_type == err_type
else: else:
assert isinstance(exc, trio.Cancelled)
elif isinstance(err, tractor.RemoteActorError):
assert err.boxed_type == err_type assert err.boxed_type == err_type
assert an.cancel_called is True assert an.cancel_called is True
@ -475,8 +450,20 @@ async def spawn_and_error(
breadth: int, breadth: int,
depth: int, depth: int,
) -> None: ) -> 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 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): for i in range(breadth):
if depth > 0: if depth > 0:
@ -496,7 +483,14 @@ async def spawn_and_error(
kwargs = { kwargs = {
'name': f'{name}_errorer_{i}', '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 # NOTE: `main_thread_forkserver` capture-fd hang class is no
@ -538,7 +532,11 @@ async def test_nested_multierrors(
depth: int, 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}`: Parametrized over recursion `depth {1, 3}`:
@ -588,6 +586,13 @@ async def test_nested_multierrors(
# fork-spawn jitter + UDS-contention widens both `t1` and # fork-spawn jitter + UDS-contention widens both `t1` and
# `t2` further. # `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 # With `strict=False` the clean-cascade cases (most
# depth=1 runs, rare depth=3 runs) report as `xpassed` # depth=1 runs, rare depth=3 runs) report as `xpassed`
# while the race-tripped cases report as `xfailed` — # while the race-tripped cases report as `xfailed` —
@ -672,6 +677,14 @@ async def test_nested_multierrors(
timeout = 16 timeout = 16
case ('main_thread_forkserver', 3): case ('main_thread_forkserver', 3):
timeout = 30 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 # inflate the budget by the throttle headroom probed above so
# a slow box doesn't masquerade as a deadline regression. # 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): async with fail_after_w_trace(timeout):
try: 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): for i in range(subactor_breadth):
await nursery.run_in_actor( tn.start_soon(
spawn_and_error, partial(
name=f'spawner_{i}', tractor.to_actor.run,
breadth=subactor_breadth, spawn_and_error,
depth=depth, an=an,
name=f'spawner_{i}',
breadth=subactor_breadth,
depth=depth,
)
) )
except BaseExceptionGroup as err: except (
assert len(err.exceptions) == subactor_breadth BaseExceptionGroup,
for subexc in err.exceptions: tractor.RemoteActorError,
) as err:
# verify first level actor errors are wrapped as remote # group membership is bounded by the relay-vs-cancel
if _friggin_windows: # 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 # windows is often too slow and cancellation seems
# to happen before an actor is spawned # to happen before an actor is spawned
if isinstance(subexc, trio.Cancelled): continue
continue
elif isinstance(subexc, tractor.RemoteActorError): assert 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): accepted: tuple[Type[BaseException], ...] = (
for subsub in subexc.exceptions: # ≥2 sub-tree errors relayed before the
# cancel-cascade won → grouped per-level.
if subsub in (tractor.RemoteActorError,): ExceptionGroup,
subsub = subsub.boxed_type # every level collapsed down to its lone
# relayed (leaf) error.
assert type(subsub) in ( AssertionError,
trio.Cancelled, # a mid-level spawner relays an
BaseExceptionGroup, # already-boxed (collapsed) leaf chain,
) # re-boxing the `RemoteActorError` itself.
else: tractor.RemoteActorError,
assert isinstance(subexc, tractor.RemoteActorError) # under heavy load a runtime-internal reap
# deadline can inject a `trio.Cancelled`
if depth > 0 and subactor_breadth > 1: # into a child's group before relay (the
# XXX not sure what's up with this.. # same class the depth=3 throttle-xfail
# on windows sometimes spawning is just too slow and # covers) upgrading it from an
# we get back the (sent) cancel signal instead # `ExceptionGroup`.
if _friggin_windows: BaseExceptionGroup,
if isinstance(subexc, tractor.RemoteActorError): )
assert subexc.boxed_type in ( if _friggin_windows:
BaseExceptionGroup, # on windows it seems we can't exactly be
tractor.RemoteActorError # sure wtf will happen..
) accepted += (
else: trio.Cancelled,
assert isinstance(subexc, BaseExceptionGroup)
else:
assert subexc.boxed_type is ExceptionGroup
else:
assert subexc.boxed_type in (
tractor.RemoteActorError,
trio.Cancelled
) )
assert subexc.boxed_type in accepted
else:
pytest.fail(
'Should have raised a (grouped) `RemoteActorError`?'
)
@no_windows @no_windows
@ -764,8 +792,8 @@ def test_cancel_via_SIGINT(
with trio.fail_after(2): with trio.fail_after(2):
async with tractor.open_nursery( async with tractor.open_nursery(
registry_addrs=[reg_addr], registry_addrs=[reg_addr],
) as tn: ) as an:
await tn.start_actor('sucka') await an.start_actor('sucka')
if 'mp' in start_method: if 'mp' in start_method:
time.sleep(0.1) time.sleep(0.1)
os.kill(pid, signal.SIGINT) os.kill(pid, signal.SIGINT)
@ -809,11 +837,13 @@ def test_cancel_via_SIGINT_other_task(
): ):
async with tractor.open_nursery( async with tractor.open_nursery(
registry_addrs=[reg_addr], 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): for i in range(3):
await tn.run_in_actor( await an.start_actor(
sleep_forever, f'namesucka_{i}',
name='namesucka',
) )
task_status.started() task_status.started()
await trio.sleep_forever() await trio.sleep_forever()
@ -854,8 +884,11 @@ async def spin_for(period=3):
async def spawn_sub_with_sync_blocking_task(): async def spawn_sub_with_sync_blocking_task():
async with tractor.open_nursery() as an: async with tractor.open_nursery() as an:
print('starting sync blocking subactor..\n') 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, spin_for,
an=an,
name='sleeper', name='sleeper',
) )
print('exiting first subactor layer..\n') print('exiting first subactor layer..\n')
@ -961,10 +994,18 @@ def test_cancel_while_childs_child_in_sync_sleep(
debug_mode=debug_mode, debug_mode=debug_mode,
registry_addrs=[reg_addr], registry_addrs=[reg_addr],
) as an, ) as an,
trio.open_nursery() as tn,
): ):
await an.run_in_actor( # bg one-shot: parks on the middle actor's result
spawn_sub_with_sync_blocking_task, # (itself parked on the sync-sleeping grandchild)
name='sync_blocking_sub', # 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) await trio.sleep(1)
@ -1013,8 +1054,8 @@ def test_fast_graceful_cancel_when_spawn_task_in_soft_proc_wait_for_daemon(
start = time.time() start = time.time()
try: try:
async with trio.open_nursery() as nurse: async with trio.open_nursery() as nurse:
async with tractor.open_nursery() as tn: async with tractor.open_nursery() as an:
p = await tn.start_actor( p = await an.start_actor(
'fast_boi', 'fast_boi',
enable_modules=[__name__], enable_modules=[__name__],
) )

View File

@ -156,8 +156,8 @@ def test_actor_managed_trio_nursery_task_error_cancels_aio(
async def main(): async def main():
# cancel the nursery shortly after boot # cancel the nursery shortly after boot
async with tractor.open_nursery() as n: async with tractor.open_nursery() as an:
p = await n.start_actor( p = await an.start_actor(
'nursery_mngr', 'nursery_mngr',
infect_asyncio=asyncio_mode, # TODO, is this enabling debug mode? infect_asyncio=asyncio_mode, # TODO, is this enabling debug mode?
enable_modules=[__name__], enable_modules=[__name__],

View File

@ -24,6 +24,7 @@ from tractor import (
current_actor, current_actor,
Actor, Actor,
to_asyncio, to_asyncio,
to_actor,
RemoteActorError, RemoteActorError,
ContextCancelled, ContextCancelled,
) )
@ -110,8 +111,9 @@ def test_trio_cancels_aio_on_actor_side(
registry_addrs=[reg_addr], registry_addrs=[reg_addr],
debug_mode=debug_mode, debug_mode=debug_mode,
) as an: ) as an:
await an.run_in_actor( await to_actor.run(
trio_cancels_single_aio_task, trio_cancels_single_aio_task,
an=an,
infect_asyncio=True, infect_asyncio=True,
) )
@ -157,6 +159,28 @@ async def asyncio_actor(
raise 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( def test_aio_simple_error(
reg_addr: tuple[str, int], reg_addr: tuple[str, int],
debug_mode: bool, debug_mode: bool,
@ -172,8 +196,9 @@ def test_aio_simple_error(
registry_addrs=[reg_addr], registry_addrs=[reg_addr],
debug_mode=debug_mode, debug_mode=debug_mode,
) as an: ) as an:
await an.run_in_actor( await to_actor.run(
asyncio_actor, asyncio_actor,
an=an,
target='sleep_and_err', target='sleep_and_err',
expect_err='AssertionError', expect_err='AssertionError',
infect_asyncio=True, infect_asyncio=True,
@ -207,18 +232,34 @@ def test_tractor_cancels_aio(
''' '''
async def main(): async def main():
async with tractor.open_nursery( # anti-hang wall-clock cap: a per-test `trio.fail_after`
debug_mode=debug_mode, # is the blessed guard here since `pytest-timeout`'s
registry_addrs=[reg_addr], # global cap is intentionally off (see the `pyproject`
) as an: # NOTE — it breaks trio under fork backends). Generous +
portal = await an.run_in_actor( # CPU-headroom-scaled bc this is an anti-hang guard, not
asyncio_actor, # a perf assertion; a wedged ria-reaper once hung this
target='aio_sleep_forever', # test forever (the `._ria_nursery`-removal regression).
expect_err='trio.Cancelled', from .conftest import cpu_perf_headroom
infect_asyncio=True, with trio.fail_after(9 * cpu_perf_headroom()):
) async with tractor.open_nursery(
# cancel the entire remote runtime debug_mode=debug_mode,
await portal.cancel_actor() 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) trio.run(main)
@ -236,13 +277,19 @@ def test_trio_cancels_aio(
with trio.move_on_after(1): with trio.move_on_after(1):
async with tractor.open_nursery( async with tractor.open_nursery(
registry_addrs=[reg_addr], registry_addrs=[reg_addr],
) as tn: ) as an:
await tn.run_in_actor( p: tractor.Portal = await an.start_actor(
asyncio_actor, 'aio_daemon',
target='aio_sleep_forever', enable_modules=[__name__],
expect_err='trio.Cancelled',
infect_asyncio=True, 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) trio.run(main)
@ -392,17 +439,16 @@ def test_aio_cancelled_from_aio_causes_trio_cancelled(
async with tractor.open_nursery( async with tractor.open_nursery(
registry_addrs=[reg_addr], registry_addrs=[reg_addr],
) as an: ) as an:
p: tractor.Portal = await an.run_in_actor( # `to_actor.run()` blocks on the one-shot's result and
asyncio_actor, # relays the remote error here in the caller's task.
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.
with trio.fail_after(1 + delay): 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( with pytest.raises(
expected_exception=(RemoteActorError, ExceptionGroup), expected_exception=(RemoteActorError, ExceptionGroup),
@ -603,13 +649,13 @@ def test_basic_interloop_channel_stream(
async with tractor.open_nursery( async with tractor.open_nursery(
registry_addrs=[reg_addr], registry_addrs=[reg_addr],
) as an: ) as an:
portal = await an.run_in_actor( # should raise RAE diectly
await to_actor.run(
stream_from_aio, stream_from_aio,
an=an,
infect_asyncio=True, infect_asyncio=True,
fan_out=fan_out, fan_out=fan_out,
) )
# should raise RAE diectly
await portal.result()
trio.run(main) trio.run(main)
@ -622,13 +668,13 @@ def test_trio_error_cancels_intertask_chan(
async with tractor.open_nursery( async with tractor.open_nursery(
registry_addrs=[reg_addr], registry_addrs=[reg_addr],
) as an: ) as an:
portal = await an.run_in_actor( # should trigger remote actor error
await to_actor.run(
stream_from_aio, stream_from_aio,
an=an,
trio_raise_err=True, trio_raise_err=True,
infect_asyncio=True, infect_asyncio=True,
) )
# should trigger remote actor error
await portal.result()
with pytest.raises(RemoteActorError) as excinfo: with pytest.raises(RemoteActorError) as excinfo:
trio.run(main) trio.run(main)
@ -658,14 +704,14 @@ def test_trio_closes_early_causes_aio_checkpoint_raise(
# enable_stack_on_sig=True, # enable_stack_on_sig=True,
registry_addrs=[reg_addr], registry_addrs=[reg_addr],
) as an: ) 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, stream_from_aio,
an=an,
trio_exit_early=True, trio_exit_early=True,
infect_asyncio=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 assert res is None
print(f'infected subactor returned result: {res!r}\n') print(f'infected subactor returned result: {res!r}\n')
@ -709,15 +755,15 @@ def test_aio_exits_early_relays_AsyncioTaskExited(
debug_mode=debug_mode, debug_mode=debug_mode,
# enable_stack_on_sig=True, # enable_stack_on_sig=True,
) as an: ) 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, stream_from_aio,
an=an,
infect_asyncio=True, infect_asyncio=True,
trio_exit_early=False, trio_exit_early=False,
aio_exit_early=True, 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 assert res is None
print(f'infected subactor returned result: {res!r}\n') print(f'infected subactor returned result: {res!r}\n')
@ -749,17 +795,19 @@ def test_aio_errors_and_channel_propagates_and_closes(
registry_addrs=[reg_addr], registry_addrs=[reg_addr],
debug_mode=debug_mode, debug_mode=debug_mode,
) as an: ) as an:
portal = await an.run_in_actor( # should trigger RAE directly, not an eg.
await to_actor.run(
stream_from_aio, stream_from_aio,
an=an,
aio_raise_err=True, aio_raise_err=True,
infect_asyncio=True, infect_asyncio=True,
) )
# should trigger RAE directly, not an eg.
await portal.result()
with pytest.raises( with pytest.raises(
# NOTE: bc we directly wait on `Portal.result()` instead # NOTE: bc `to_actor.run()` blocks on + relays the result
# of capturing it inside the `ActorNursery` machinery. # in the caller's task (not captured inside the
# `ActorNursery` teardown machinery) we get a direct RAE,
# not an eg.
expected_exception=RemoteActorError, expected_exception=RemoteActorError,
) as excinfo: ) as excinfo:
trio.run(main) trio.run(main)

View File

@ -163,12 +163,12 @@ def test_do_not_swallow_error_before_started_by_remote_contextcancelled(
async def main(): async def main():
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=debug_mode, debug_mode=debug_mode,
) as n: ) as an:
portal = await n.start_actor( portal = await an.start_actor(
'errorer', 'errorer',
enable_modules=[__name__], enable_modules=[__name__],
) )
await n.start_actor( await an.start_actor(
'sleeper', 'sleeper',
enable_modules=[__name__], enable_modules=[__name__],
) )

View File

@ -2,16 +2,19 @@
`tractor.log`-wrapping unit tests. `tractor.log`-wrapping unit tests.
''' '''
import logging
from pathlib import Path from pathlib import Path
import shutil import shutil
from types import ModuleType from types import ModuleType
import pytest import pytest
import tractor import tractor
import trio
from tractor import ( from tractor import (
_code_load, _code_load,
log, log,
) )
from tractor.ipc import _chan
def test_root_pkg_not_duplicated_in_logger_name(): def test_root_pkg_not_duplicated_in_logger_name():
@ -222,6 +225,88 @@ def test_add_log_level_pluggable():
delattr(log.StackLevelAdapter, name.lower()) 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: # TODO, moar tests against existing feats:
# ------ - ------ # ------ - ------
# - [ ] color settings? # - [ ] color settings?

View File

@ -139,9 +139,9 @@ async def test_required_args(callwith_expecterror):
with pytest.raises(err): with pytest.raises(err):
await func(**kwargs) await func(**kwargs)
else: 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', name='pubber',
enable_modules=[__name__], enable_modules=[__name__],
) )
@ -176,33 +176,55 @@ def test_multi_actor_subs_arbiter_pub(
async def main(): async def main():
async with tractor.open_nursery( async with (
registry_addrs=[reg_addr], tractor.open_nursery(
enable_modules=[__name__], registry_addrs=[reg_addr],
) as n: enable_modules=[__name__],
) as an,
trio.open_nursery() as tn,
):
name = 'root' name = 'root'
if pub_actor == 'streamer': if pub_actor == 'streamer':
# start the publisher as a daemon # start the publisher as a daemon
master_portal = await n.start_actor( master_portal = await an.start_actor(
'streamer', 'streamer',
enable_modules=[__name__], enable_modules=[__name__],
) )
name = 'streamer' name = 'streamer'
even_portal = await n.run_in_actor( # spawn the two subscriber actors as daemons and run
subs, # `subs()` on each as a background task (was the legacy
which=['even'], # `run_in_actor()`); keep the portals for the explicit
name='evens', # `cancel_actor()` teardown below. Each runner swallows
pub_actor_name=name # 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( odd_portal = await an.start_actor(
subs, 'odds',
which=['odd'], enable_modules=[__name__],
name='odds',
pub_actor_name=name
) )
tn.start_soon(_run_subs, even_portal, ['even'])
tn.start_soon(_run_subs, odd_portal, ['odd'])
async with tractor.wait_for_actor('evens'): async with tractor.wait_for_actor('evens'):
# block until 2nd actor is initialized # block until 2nd actor is initialized
@ -257,6 +279,9 @@ def test_multi_actor_subs_arbiter_pub(
else: else:
await master_portal.cancel_actor() await master_portal.cancel_actor()
# drop the bg `subs()` runners now the subs are cancelled
tn.cancel_scope.cancel()
trio.run(main) trio.run(main)
@ -269,9 +294,9 @@ def test_single_subactor_pub_multitask_subs(
async with tractor.open_nursery( async with tractor.open_nursery(
registry_addrs=[reg_addr], registry_addrs=[reg_addr],
enable_modules=[__name__], enable_modules=[__name__],
) as n: ) as an:
portal = await n.start_actor( portal = await an.start_actor(
'streamer', 'streamer',
enable_modules=[__name__], enable_modules=[__name__],
) )

View File

@ -9,6 +9,7 @@ from typing import Awaitable
import pytest import pytest
import trio import trio
from trio.testing import wait_all_tasks_blocked
import tractor import tractor
from tractor.trionics import ( from tractor.trionics import (
maybe_open_context, maybe_open_context,
@ -94,6 +95,232 @@ def test_resource_only_entered_once(key_on):
trio.run(main) 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 @tractor.context
async def streamer( async def streamer(
ctx: tractor.Context, ctx: tractor.Context,
@ -548,43 +775,52 @@ def test_moc_reentry_during_teardown(
loglevel: str, loglevel: str,
): ):
''' '''
Reproduce the piker `open_cached_client('kraken')` race: Reproduce re-entry while an identical cached context exits.
- same `acm_func`, NO kwargs (identical `ctx_key`) - multiple tasks use the same `acm_func` with no kwargs,
- multiple tasks share the cached resource producing an identical `ctx_key`;
- all users exit -> teardown starts - all users leave and the final user starts resource teardown;
- a NEW task enters during `_Cache.run_ctx.__aexit__` - `_Cache.run_ctx()` removes the cached value and resource entry
- `values[ctx_key]` is gone (popped in inner finally) before entering the resource's blocking `__aexit__()` body;
but `resources[ctx_key]` still exists (outer finally - a new task attempts to enter that same `ctx_key` during exit;
hasn't run yet bc the acm cleanup has checkpoints) - the per-key lock keeps that entrant queued until exit
- old code: `assert not resources.get(ctx_key)` FIRES completes;
- the entrant then receives a fresh cache miss and resource.
This models the real-world scenario where `brokerd.kraken` Without teardown sharing the registration lock, re-entry could
tasks concurrently call `open_cached_client('kraken')` race resource replacement while the prior generation was still
(same `acm_func`, empty kwargs, shared `ctx_key`) and exiting. The final user could also return before that exit
the teardown/re-entry race triggers intermittently. 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(): async def main():
in_aexit = trio.Event() in_aexit = trio.Event()
allow_aexit = trio.Event()
reentry_started = trio.Event()
generation: int = 0
@acm @acm
async def cached_client(): async def cached_client():
''' '''
Simulates `kraken.api.get_client()`: Simulate a no-argument `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`.
''' '''
nonlocal generation
generation += 1
resource_generation: int = generation
yield 'the-client' yield 'the-client'
# Signal that we're in __aexit__ — at this if resource_generation == 1:
# point `values` has already been popped by in_aexit.set()
# `run_ctx`'s inner finally, but `resources` await allow_aexit.wait()
# is still alive (outer finally hasn't run).
in_aexit.set()
await trio.sleep(10)
first_done = trio.Event() first_done = trio.Event()
@ -598,16 +834,25 @@ def test_moc_reentry_during_teardown(
async def reenter_during_teardown(): async def reenter_during_teardown():
''' '''
Wait for the acm's `__aexit__` to start (meaning Wait for the acm's `__aexit__` to start (meaning
`values` is popped but `resources` still exists), the cached value is no longer available), then re-enter.
then re-enter triggering the assert.
''' '''
await in_aexit.wait() 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( async with maybe_open_context(
cached_client, cached_client,
) as (cache_hit, value): ) as (cache_hit, value):
assert not cache_hit
assert value == 'the-client' assert value == 'the-client'
await first_done.wait()
with trio.fail_after(5): with trio.fail_after(5):
async with ( async with (
tractor.open_root_actor( tractor.open_root_actor(
@ -619,5 +864,15 @@ def test_moc_reentry_during_teardown(
): ):
tn.start_soon(use_and_exit) tn.start_soon(use_and_exit)
tn.start_soon(reenter_during_teardown) 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) trio.run(main)

View File

@ -131,9 +131,12 @@ def test_ringbuf(
child_read_shm, child_read_shm,
**common_kwargs, **common_kwargs,
total_bytes=total_bytes, 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 send_p.cancel_actor()
await recv_p.cancel_actor() await recv_p.cancel_actor()

View File

@ -107,12 +107,13 @@ def test_rpc_errors(
# do that if actually debugging subactor but keep it # do that if actually debugging subactor but keep it
# disabled for the test. # disabled for the test.
# debug_mode=True, # debug_mode=True,
) as n: ) as an:
actor = tractor.current_actor() actor = tractor.current_actor()
assert actor.is_registrar assert actor.is_registrar
await n.run_in_actor( await tractor.to_actor.run(
sleep_back_actor, sleep_back_actor,
an=an,
actor_name=subactor_requests_to, actor_name=subactor_requests_to,
name='subactor', name='subactor',

View File

@ -78,13 +78,12 @@ async def test_lifetime_stack_wipes_tmpfile(
async with tractor.open_nursery( async with tractor.open_nursery(
loglevel=loglevel, loglevel=loglevel,
) as an: ) as an:
await ( # inlined `tractor.Portal` await tractor.to_actor.run(
await an.run_in_actor( crash_and_clean_tmpdir,
crash_and_clean_tmpdir, an=an,
tmp_file_path=path, tmp_file_path=path,
error=error_in_child, error=error_in_child,
) )
).result()
except ( except (
tractor.RemoteActorError, tractor.RemoteActorError,
BaseExceptionGroup, BaseExceptionGroup,

View File

@ -48,9 +48,11 @@ async def spawn(
actor: tractor.Actor = tractor.current_actor() actor: tractor.Actor = tractor.current_actor()
assert actor.is_registrar == should_be_root assert actor.is_registrar == should_be_root
# spawns subproc here # recursively spawn this same `spawn()` fn as the lone
portal: tractor.Portal = await an.run_in_actor( # task of a one-shot child subactor and get its result.
fn=spawn, result = await tractor.to_actor.run(
spawn,
an=an,
# spawning args # spawning args
name='sub-actor', name='sub-actor',
@ -62,16 +64,6 @@ async def spawn(
data=data_to_pass_down, data=data_to_pass_down,
reg_addr=reg_addr, 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 assert result == 10
return result return result
else: else:
@ -79,7 +71,7 @@ async def spawn(
return 10 return 10
def test_run_in_actor_same_func_in_child( def test_to_actor_run_same_func_in_child(
reg_addr: tuple, reg_addr: tuple,
debug_mode: bool, debug_mode: bool,
): ):
@ -159,21 +151,16 @@ async def test_most_beautiful_word(
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=debug_mode, debug_mode=debug_mode,
) as an: ) as an:
portal = await an.run_in_actor( res: Any = await tractor.to_actor.run(
cellar_door, cellar_door,
an=an,
return_value=return_value, return_value=return_value,
name='some_linguist', name='some_linguist',
) )
res: Any = await portal.wait_for_result()
assert res == return_value assert res == return_value
# The ``async with`` will unblock here since the 'some_linguist' # The ``async with`` unblocks here — the 'some_linguist'
# actor has completed its main task ``cellar_door``. # one-shot actor completed its lone task ``cellar_door`` and
# was reaped by `to_actor.run()`.
# 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
print(res) print(res)
@ -215,9 +202,10 @@ def test_loglevel_propagated_to_subactor(
start_method=start_method, start_method=start_method,
registry_addrs=[reg_addr], registry_addrs=[reg_addr],
) as tn: ) as an:
await tn.run_in_actor( await tractor.to_actor.run(
check_loglevel, check_loglevel,
an=an,
loglevel=level, loglevel=level,
level=level, level=level,
) )
@ -267,11 +255,11 @@ async def check_parent_main_inheritance(
return has_data 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. 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. prevents parent ``__main__`` data from reaching the child.
''' '''
@ -284,21 +272,21 @@ def test_run_in_actor_can_skip_parent_main_inheritance(
async with tractor.open_nursery(start_method='trio') as an: async with tractor.open_nursery(start_method='trio') as an:
# Default: child receives parent __main__ bootstrap data # Default: child receives parent __main__ bootstrap data
replaying = await an.run_in_actor( await tractor.to_actor.run(
check_parent_main_inheritance, check_parent_main_inheritance,
an=an,
name='replaying-parent-main', name='replaying-parent-main',
expect_inherited=True, expect_inherited=True,
) )
await replaying.result()
# Opt-out: child gets no parent __main__ data # Opt-out: child gets no parent __main__ data
isolated = await an.run_in_actor( await tractor.to_actor.run(
check_parent_main_inheritance, check_parent_main_inheritance,
an=an,
name='isolated-parent-main', name='isolated-parent-main',
inherit_parent_main=False, inherit_parent_main=False,
expect_inherited=False, expect_inherited=False,
) )
await isolated.result()
trio.run(main) trio.run(main)

View File

@ -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,
)
)

View File

@ -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)

View File

@ -62,6 +62,7 @@ from .devx import (
post_mortem as post_mortem, post_mortem as post_mortem,
) )
from . import msg as msg from . import msg as msg
from . import to_actor as to_actor
from ._root import ( from ._root import (
run_daemon as run_daemon, run_daemon as run_daemon,
open_root_actor as open_root_actor, open_root_actor as open_root_actor,

View File

@ -780,7 +780,7 @@ class Context:
# `Portal.open_context()` has been opened since it's # `Portal.open_context()` has been opened since it's
# assumed that other portal APIs like, # assumed that other portal APIs like,
# - `Portal.run()`, # - `Portal.run()`,
# - `ActorNursery.run_in_actor()` # - `to_actor.run()`
# do their own error checking at their own call points and # do their own error checking at their own call points and
# result processing. # result processing.

View File

@ -1161,10 +1161,6 @@ class TransportClosed(Exception):
) )
class NoResult(RuntimeError):
"No final result is expected for this actor"
class ModuleNotExposed(ModuleNotFoundError): class ModuleNotExposed(ModuleNotFoundError):
"The requested module is not exposed for RPC" "The requested module is not exposed for RPC"

View File

@ -221,16 +221,19 @@ def pub(
import tractor import tractor
async with tractor.open_nursery() as n: async with tractor.open_nursery() as n:
portal = n.run_in_actor( portal = await n.start_actor(
'publisher', # actor name '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, pub_service,
topics=('clicks', 'users'), topics=('clicks', 'users'),
task_name='source1', task_name='source1',
) )
) ) as stream:
async for value in await portal.result(): async for value in stream:
print(f"Subscriber received {value}") print(f"Subscriber received {value}")
Here, you don't need to provide the ``ctx`` argument since the Here, you don't need to provide the ``ctx`` argument since the

View File

@ -198,9 +198,6 @@ class Channel:
# assert transport.raddr == addr # assert transport.raddr == addr
chan = Channel(transport=transport) 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'): if log.at_least_level('runtime'):
from tractor.devx import ( from tractor.devx import (
pformat as _pformat, pformat as _pformat,
@ -325,10 +322,12 @@ class Channel:
''' '''
__tracebackhide__: bool = hide_tb __tracebackhide__: bool = hide_tb
try: try:
log.transport( if log.at_least_level('transport'):
'=> send IPC msg:\n\n' # don't materialize the payload repr if not necessary
f'{pformat(payload)}\n' log.transport(
) '=> send IPC msg:\n\n'
f'{pformat(payload)}\n'
)
# assert self._transport # but why typing? # assert self._transport # but why typing?
await self._transport.send( await self._transport.send(
payload, payload,

View File

@ -309,7 +309,10 @@ class MsgpackTransport(MsgTransport):
log.transport(f'received header {size}') # type: ignore log.transport(f'received header {size}') # type: ignore
msg_bytes: bytes = await self.recv_stream.receive_exactly(size) 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: try:
# NOTE: lookup the `trio.Task.context`'s var for # NOTE: lookup the `trio.Task.context`'s var for
# the current `MsgCodec`. # the current `MsgCodec`.

View File

@ -111,9 +111,7 @@ def at_least_level(
if isinstance(level, str): if isinstance(level, str):
level: int = CUSTOM_LEVELS[level.upper()] level: int = CUSTOM_LEVELS[level.upper()]
if log.getEffectiveLevel() <= level: return log.isEnabledFor(level)
return True
return False
# TODO, compare with using a "filter" instead? # TODO, compare with using a "filter" instead?

View File

@ -306,15 +306,15 @@ class PldRx(Struct):
): ):
try: try:
pld: PayloadT = self._pld_dec.decode(pld) pld: PayloadT = self._pld_dec.decode(pld)
log.runtime( if log.at_least_level('runtime'):
f'Decoded payload for\n' # don't materialize the payload repr if not necessary
# f'\n' log.runtime(
f'{msg}\n' f'Decoded payload for\n'
# ^TODO?, ideally just render with `, f'\n'
# pld={decode}` in the `msg.pformat()`?? f'{msg}\n'
f'where, ' f'where, '
f'{type(msg).__name__}.pld={pld!r}\n' f'{type(msg).__name__}.pld={pld!r}\n'
) )
return pld return pld
except TypeError as typerr: except TypeError as typerr:
__tracebackhide__: bool = False __tracebackhide__: bool = False

View File

@ -304,12 +304,11 @@ class Start(
It is called by all the following public APIs: It is called by all the following public APIs:
- `ActorNursery.run_in_actor()` - `to_actor.run()`
- `Portal.run()` - `Portal.run()`
`|_.run_from_ns()` `|_.run_from_ns()`
`|_.open_stream_from()` `|_.open_stream_from()`
`|_._submit_for_result()`
- `Context.open_context()` - `Context.open_context()`

View File

@ -50,13 +50,11 @@ from ..ipc import Channel
from ..log import get_logger from ..log import get_logger
from ..msg import ( from ..msg import (
# Error, # Error,
PayloadMsg,
NamespacePath, NamespacePath,
Return, Return,
) )
from .._exceptions import ( from .._exceptions import (
ActorTooSlowError, ActorTooSlowError,
NoResult,
TransportClosed, TransportClosed,
) )
from .._context import ( from .._context import (
@ -102,14 +100,6 @@ class Portal:
) -> None: ) -> None:
self._chan: Channel = channel 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() self._streams: set[MsgStream] = set()
# TODO, this should be PRIVATE (and never used publicly)! since it's just # TODO, this should be PRIVATE (and never used publicly)! since it's just
@ -137,102 +127,6 @@ class Portal:
) )
return self.chan 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): async def _cancel_streams(self):
# terminate all locally running async generator # terminate all locally running async generator
# IPC calls # IPC calls

View File

@ -1003,20 +1003,18 @@ async def process_messages(
task_status.started(loop_cs) task_status.started(loop_cs)
async for msg in chan: async for msg in chan:
log.transport( # type: ignore if log.at_least_level('transport'):
f'IPC msg from peer\n' log.transport( # type: ignore
f'<= {chan.aid.reprol()}\n\n' f'IPC msg from peer\n'
f'<= {chan.aid.reprol()}\n\n'
# TODO: use of the pprinting of structs is # TODO: pretty-printing structs is FRAGILE;
# FRAGILE and should prolly not be # -[ ] add a non-raising log formatter with
# # native-repr fallback before using
# avoid fmting depending on loglevel for perf? # `.msg.pretty_struct` here.
# -[ ] specifically `pretty_struct.pformat()` sub-call..? # f'{pretty_struct.pformat(msg)}\n'
# - how to only log-level-aware actually call this? f'{msg}\n'
# -[ ] use `.msg.pretty_struct` here now instead! )
# f'{pretty_struct.pformat(msg)}\n'
f'{msg}\n'
)
match msg: match msg:
# msg for an ongoing IPC ctx session, deliver msg to # msg for an ongoing IPC ctx session, deliver msg to
@ -1262,11 +1260,12 @@ async def process_messages(
log.exception(message) log.exception(message)
raise RuntimeError(message) raise RuntimeError(message)
log.transport( if log.at_least_level('transport'):
'Waiting on next IPC msg from\n' log.transport(
f'peer: {chan.aid.reprol()}\n' 'Waiting on next IPC msg from\n'
f'|_{chan}\n' f'peer: {chan.aid.reprol()}\n'
) f'|_{chan}\n'
)
# END-OF `async for`: # END-OF `async for`:
# IPC disconnected via `trio.EndOfChannel`, likely # IPC disconnected via `trio.EndOfChannel`, likely

View File

@ -20,7 +20,6 @@
""" """
from contextlib import asynccontextmanager as acm from contextlib import asynccontextmanager as acm
from functools import partial from functools import partial
import inspect
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
) )
@ -199,7 +198,6 @@ class ActorNursery:
self, self,
# TODO: maybe def these as fields of a struct looking type? # TODO: maybe def these as fields of a struct looking type?
actor: Actor, actor: Actor,
ria_nursery: trio.Nursery,
da_nursery: trio.Nursery, da_nursery: trio.Nursery,
errors: dict[tuple[str, str], BaseException], errors: dict[tuple[str, str], BaseException],
@ -233,16 +231,6 @@ class ActorNursery:
# and syncing purposes to any actor opened nurseries. # and syncing purposes to any actor opened nurseries.
self._implicit_runtime_started: bool = False 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 # trio.Nursery-like cancel (request) statuses
self._cancelled_caught: bool = False self._cancelled_caught: bool = False
self._cancel_called: bool = False self._cancel_called: bool = False
@ -298,11 +286,6 @@ class ActorNursery:
debug_mode: bool|None = None, debug_mode: bool|None = None,
infect_asyncio: bool = False, infect_asyncio: bool = False,
inherit_parent_main: bool = True, 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, proc_kwargs: dict[str, typing.Any] | None = None,
) -> Portal: ) -> Portal:
@ -364,10 +347,8 @@ class ActorNursery:
# start a task to spawn a process # start a task to spawn a process
# blocks until process has been started and a portal setup # 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 # 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( partial(
_spawn.new_proc, _spawn.new_proc,
name, name,
@ -382,80 +363,6 @@ class ActorNursery:
) )
) )
# 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 # @api_frame
async def cancel( async def cancel(
self, self,
@ -584,167 +491,118 @@ async def _open_and_supervise_one_cancels_all_nursery(
# normally don't need to show user by default # normally don't need to show user by default
__tracebackhide__: bool = hide_tb __tracebackhide__: bool = hide_tb
outer_err: BaseException|None = None
inner_err: BaseException|None = None
# the collection of errors retreived from spawned sub-actors # the collection of errors retreived from spawned sub-actors
errors: dict[tuple[str, str], BaseException] = {} errors: dict[tuple[str, str], BaseException] = {}
# This is the outermost level "deamon actor" nursery. It is awaited # The single "daemon actor" nursery into which ALL subactors
# **after** the below inner "run in actor nursery". This allows for # are spawned; one-shot (`to_actor.run()`) subactors are
# handling errors that are generated by the inner nursery in # result-waited and reaped in their caller's own task-scope
# a supervisor strategy **before** blocking indefinitely to wait for # (see the #477 `.run_in_actor()`/`._ria_nursery` removal);
# actors spawned in "daemon mode" (aka started using # errors from this nursery bubble up to the caller.
# `ActorNursery.start_actor()`).
# errors from this daemon actor nursery bubble up to caller
async with ( async with (
collapse_eg(), collapse_eg(),
trio.open_nursery() as da_nursery, trio.open_nursery() as da_nursery,
): ):
an = ActorNursery(
actor,
da_nursery,
errors
)
try: try:
# This is the inner level "run in actor" nursery. It is # spawning of actors happens in the caller's scope
# awaited first since actors spawned in this way (using # after we yield upwards
# `ActorNusery.run_in_actor()`) are expected to only yield an
# 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, # When we didn't error in the caller's scope,
# signal all process-monitor-tasks to conduct # signal all process-monitor-tasks to conduct
# the "hard join phase". # the "hard join phase".
log.runtime( log.runtime(
'Waiting on subactors to complete:\n' 'Waiting on subactors to complete:\n'
f'>}} {len(an._children)}\n' f'>}} {len(an._children)}\n'
) )
an._join_procs.set() an._join_procs.set()
except BaseException as _inner_err: # Single one-cancels-all handler for the (now single)
inner_err = _inner_err # daemon nursery. Pre-#477 a 2ndary `._ria_nursery`
errors[actor.aid.uid] = inner_err # 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 # If we error in the root but the debugger is
# engaged we don't want to prematurely kill (and # engaged we don't want to prematurely kill (and
# thus clobber access to) the local tty since it # thus clobber access to) the local tty since it
# will make the pdb repl unusable. # will make the pdb repl unusable.
# Instead try to wait for pdb to be released before # Instead try to wait for pdb to be released before
# tearing down. # 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.
await debug.maybe_wait_for_debugger( await debug.maybe_wait_for_debugger(
child_in_debug=an._at_least_one_child_in_debug child_in_debug=an._at_least_one_child_in_debug
) )
# If actor-local error was raised while waiting on # if the caller's scope errored then we activate our
# ".run_in_actor()" actors then we also want to cancel all # one-cancels-all supervisor strategy (don't
# remaining sub-actors (due to our lone strategy: # worry more are coming).
# one-cancels-all). an._join_procs.set()
if an._children:
log.cancel( # XXX NOTE XXX: hypothetically an error could
'Actor-nursery cancelling due error type:\n' # be raised and then a cancel signal shows up
f'{outer_err}\n' # slightly after in which case the `else:`
) # block here might not complete? For now,
with trio.CancelScope(shield=True): # shield both.
await an.cancel() with trio.CancelScope(shield=True):
raise 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: finally:
# No errors were raised while awaiting ".run_in_actor()" # an error was stashed by the handler above (or by
# actors but those actors may have returned remote errors as # a spawn task via the shared `errors` dict) so
# results (meaning they errored remotely and have relayed # cancel any remaining subactors, summarize and
# those errors back to this parent actor). The errors are # re-raise.
# collected in ``errors`` so cancel all actors, summarize
# all errors and re-raise.
if errors: if errors:
if an._children: if an._children:
with trio.CancelScope(shield=True): with trio.CancelScope(shield=True):

View File

@ -48,7 +48,6 @@ from ._entry import _mp_main
# by `try_set_start_method()` after module load time. # by `try_set_start_method()` after module load time.
from . import _spawn from . import _spawn
from ._spawn import ( from ._spawn import (
cancel_on_completion,
proc_waiter, proc_waiter,
soft_kill, soft_kill,
) )
@ -187,30 +186,14 @@ async def mp_proc(
with trio.CancelScope(shield=True): with trio.CancelScope(shield=True):
await actor_nursery._join_procs.wait() await actor_nursery._join_procs.wait()
async with trio.open_nursery() as nursery: # This is a "soft" (cancellable) join/reap which
if portal in actor_nursery._cancel_after_result_on_exit: # will remote cancel the actor on a ``trio.Cancelled``
nursery.start_soon( # condition.
cancel_on_completion, await soft_kill(
portal, proc,
subactor, proc_waiter,
errors portal
) )
# 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()
finally: finally:
# hard reap sequence # hard reap sequence

View File

@ -126,98 +126,6 @@ def try_set_start_method(
return _ctx 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( async def hard_kill(
proc: trio.Process, proc: trio.Process,
@ -461,8 +369,8 @@ async def new_proc(
# NOTE: bottom-of-module to avoid a circular import since the # NOTE: bottom-of-module to avoid a circular import since the
# backend submodules pull `cancel_on_completion`/`soft_kill`/ # backend submodules pull `soft_kill`/`hard_kill`/`proc_waiter`
# `hard_kill`/`proc_waiter` from this module. # from this module.
from ._trio import trio_proc from ._trio import trio_proc
from ._mp import mp_proc from ._mp import mp_proc

View File

@ -50,7 +50,6 @@ from tractor.msg import (
pretty_struct, pretty_struct,
) )
from ._spawn import ( from ._spawn import (
cancel_on_completion,
hard_kill, hard_kill,
soft_kill, soft_kill,
) )
@ -195,31 +194,14 @@ async def trio_proc(
with trio.CancelScope(shield=True): with trio.CancelScope(shield=True):
await actor_nursery._join_procs.wait() await actor_nursery._join_procs.wait()
async with trio.open_nursery() as nursery: # This is a "soft" (cancellable) join/reap which
if portal in actor_nursery._cancel_after_result_on_exit: # will remote cancel the actor on a ``trio.Cancelled``
nursery.start_soon( # condition.
cancel_on_completion, await soft_kill(
portal, proc,
subactor, trio.Process.wait, # XXX, uses `pidfd_open()` below.
errors portal
) )
# 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()
finally: finally:
# XXX NOTE XXX: The "hard" reap since no actor zombies are # XXX NOTE XXX: The "hard" reap since no actor zombies are

View File

@ -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 <https://www.gnu.org/licenses/>.
'''
`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,
)

View File

@ -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 <https://www.gnu.org/licenses/>.
'''
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,
)

View File

@ -198,6 +198,16 @@ async def gather_contexts(
# Further potential examples of interest: # Further potential examples of interest:
# https://gist.github.com/njsmith/cf6fc0a97f53865f2c671659c88c1798#file-cache-py-L8 # 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: class _Cache:
''' '''
Globally (actor-processs scoped) cached, task access to Globally (actor-processs scoped) cached, task access to
@ -213,7 +223,11 @@ class _Cache:
values: dict[Any, Any] = {} values: dict[Any, Any] = {}
resources: dict[ resources: dict[
Hashable, Hashable,
tuple[trio.Nursery, trio.Event] tuple[
trio.Nursery,
trio.Event,
_CtxExit,
],
] = {} ] = {}
# nurseries: dict[int, trio.Nursery] = {} # nurseries: dict[int, trio.Nursery] = {}
no_more_users: trio.Event|None = None no_more_users: trio.Event|None = None
@ -223,18 +237,38 @@ class _Cache:
cls, cls,
mng, mng,
ctx_key: tuple, ctx_key: tuple,
ctx_exit: _CtxExit,
task_status: trio.TaskStatus[T] = trio.TASK_STATUS_IGNORED, task_status: trio.TaskStatus[T] = trio.TASK_STATUS_IGNORED,
) -> None: ) -> None:
async with mng as value: entered: bool = False
_, no_more_users = cls.resources[ctx_key] try:
cls.values[ctx_key] = value async with mng as value:
task_status.started(value) entered = True
try: (
await no_more_users.wait() _,
finally: no_more_users,
value = cls.values.pop(ctx_key) _,
cls.resources.pop(ctx_key) ) = 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: class _UnresolvedCtx:
@ -281,9 +315,10 @@ async def maybe_open_context(
) )
# yielded output # yielded output
# sentinel = object()
yielded: Any = _UnresolvedCtx yielded: Any = _UnresolvedCtx
user_registered: bool = False user_registered: bool = False
ctx_exit: _CtxExit|None = None
exit_error: Exception|None = None
# Lock resource acquisition around task racing / ``trio``'s # Lock resource acquisition around task racing / ``trio``'s
# scheduler protocol. # scheduler protocol.
@ -300,7 +335,6 @@ async def maybe_open_context(
] = trio.StrictFIFOLock() ] = trio.StrictFIFOLock()
header: str = 'Allocated NEW lock for @acm_func,\n' header: str = 'Allocated NEW lock for @acm_func,\n'
else: else:
await trio.lowlevel.checkpoint()
header: str = 'Reusing OLD lock for @acm_func,\n' header: str = 'Reusing OLD lock for @acm_func,\n'
log.debug( log.debug(
@ -368,7 +402,6 @@ async def maybe_open_context(
resources = _Cache.resources resources = _Cache.resources
entry: tuple|None = resources.get(ctx_key) entry: tuple|None = resources.get(ctx_key)
if entry: if entry:
service_tn, ev = entry
raise RuntimeError( raise RuntimeError(
f'Caching resources ALREADY exist?!\n' f'Caching resources ALREADY exist?!\n'
f'ctx_key={ctx_key!r}\n' f'ctx_key={ctx_key!r}\n'
@ -376,12 +409,18 @@ async def maybe_open_context(
f'task: {task}\n' f'task: {task}\n'
) )
resources[ctx_key] = (service_tn, trio.Event()) ctx_exit = _CtxExit()
resources[ctx_key] = (
service_tn,
trio.Event(),
ctx_exit,
)
try: try:
yielded: Any = await service_tn.start( yielded: Any = await service_tn.start(
_Cache.run_ctx, _Cache.run_ctx,
mngr, mngr,
ctx_key, ctx_key,
ctx_exit,
) )
except BaseException: except BaseException:
# If `run_ctx` (wrapping the acm's `__aenter__`) # If `run_ctx` (wrapping the acm's `__aenter__`)
@ -427,6 +466,11 @@ async def maybe_open_context(
raise taskc raise taskc
else: else:
# XXX, cached-entry-path # XXX, cached-entry-path
(
_,
_,
ctx_exit,
) = _Cache.resources[ctx_key]
_Cache.users[ctx_key] += 1 _Cache.users[ctx_key] += 1
user_registered = True user_registered = True
log.debug( log.debug(
@ -445,44 +489,57 @@ async def maybe_open_context(
) )
finally: 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: if user_registered:
_Cache.users[ctx_key] -= 1 # 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 yielded is not _UnresolvedCtx: # If no consumers remain, keep entrants queued
# if no more consumers, teardown the client # until the cached context has completely exited.
if _Cache.users[ctx_key] <= 0: if _Cache.users[ctx_key] <= 0:
log.debug( log.debug(
f'De-allocating @acm-func entry\n' f'De-allocating @acm-func entry\n'
f'ctx_key={ctx_key!r}\n' f'ctx_key={ctx_key!r}\n'
f'acm_func={acm_func!r}\n' f'acm_func={acm_func!r}\n'
) )
# XXX: if we're cancelled we the entry may have never # XXX: if we're cancelled, the entry may
# been entered since the nursery task was killed. # have never been entered since the nursery
# _, no_more_users = _Cache.resources[ctx_key] # task was killed.
entry = _Cache.resources.get(ctx_key) entry = _Cache.resources.get(ctx_key)
if entry: if entry:
_, no_more_users = entry (
no_more_users.set() _,
no_more_users,
ctx_exit,
) = entry
no_more_users.set()
maybe_lock = _Cache.locks.pop( assert ctx_exit is not None
ctx_key, await ctx_exit.done.wait()
None, exit_error = ctx_exit.error
)
if maybe_lock is None: # A queued entrant already holds a reference
log.error( # to this lock. Keep it registered until that
f'Resource lock for {ctx_key} ALREADY POPPED?' # 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

View File

@ -349,7 +349,10 @@ async def start_or_cancel(
# demote it to a `Cancelled`, losing the real error. The # demote it to a `Cancelled`, losing the real error. The
# `isinstance` guard also avoids a `TypeError` when # `isinstance` guard also avoids a `TypeError` when
# `rte.args[0]` isn't a `str`. # `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 # re-raises the in-flight `trio.Cancelled` IFF we're
# under effective cancellation; else a cheap no-op and # under effective cancellation; else a cheap no-op and

12
uv.lock
View File

@ -308,11 +308,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.10" version = "3.18"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -900,11 +900,11 @@ wheels = [
[[package]] [[package]]
name = "setuptools" name = "setuptools"
version = "82.0.1" version = "83.0.0"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]