Compare commits
39 Commits
1d59f1963c
...
b645f7fa8c
| Author | SHA1 | Date |
|---|---|---|
|
|
b645f7fa8c | |
|
|
f8488401be | |
|
|
2fdf53dd9f | |
|
|
13b47d07c6 | |
|
|
47bd1a2eb6 | |
|
|
a15c0ccf3e | |
|
|
d737158e2a | |
|
|
52b953fce8 | |
|
|
e26a6160c7 | |
|
|
e468a81b27 | |
|
|
0ac9fa1c0c | |
|
|
321c72e950 | |
|
|
02a3e8de1c | |
|
|
1d0cb0d20c | |
|
|
a9b16d9936 | |
|
|
09341c2014 | |
|
|
e016709041 | |
|
|
040fac3bdb | |
|
|
8bb950ac45 | |
|
|
29057adc89 | |
|
|
55c2f9b1c1 | |
|
|
38fdfb47a0 | |
|
|
19c9eaa32a | |
|
|
c6c6cd0303 | |
|
|
80d86818ee | |
|
|
250396d7ac | |
|
|
e71c6ae588 | |
|
|
19c4b3dbd9 | |
|
|
ed216172ba | |
|
|
557065d8b5 | |
|
|
b38efed753 | |
|
|
51a2b7a4f8 | |
|
|
96e4934573 | |
|
|
754458ee7d | |
|
|
4d07965a2a | |
|
|
bf06b4f854 | |
|
|
4151b9569a | |
|
|
8d04af0d73 | |
|
|
a3cd448612 |
|
|
@ -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.
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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`).
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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`).
|
||||||
|
|
@ -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)
|
||||||
|
```
|
||||||
|
|
@ -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`).
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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`).
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: pr481-review-fixes-p1-20260818
|
||||||
|
timestamp: 2026-08-18T03:15:32Z
|
||||||
|
git_ref: 4151b956
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260818T031532Z_4151b956_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Address the approved review findings on PR #481, but work
|
||||||
|
iteratively: implement and verify one finding at a time, prepare a
|
||||||
|
separate `/commit-plan` after each fix, and stop for the human commit
|
||||||
|
before starting the next finding. Begin with the P1 per-child
|
||||||
|
lifecycle issue. Also publish the already-approved review findings
|
||||||
|
against the reviewed PR head before editing.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Published the approved non-approving review at head `4151b956`, then
|
||||||
|
implemented only the P1 lifecycle fix. Owned one-shot actors now use
|
||||||
|
a child-specific cancellation and process-reap handshake, including
|
||||||
|
hard escalation for unacknowledged cancellation and deterministic
|
||||||
|
bookkeeping removal before `to_actor.run()` returns.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/runtime/_supervise.py` - coordinate child-specific cancel
|
||||||
|
and reap.
|
||||||
|
- `tractor/spawn/_trio.py` - wait on the Trio child's reap request.
|
||||||
|
- `tractor/spawn/_mp.py` - wait on the multiprocessing child's reap
|
||||||
|
request.
|
||||||
|
- `tractor/spawn/_spawn.py` - publish monitor completion centrally.
|
||||||
|
- `tractor/to_actor/_api.py` - await owned-child process reaping.
|
||||||
|
- `tests/test_to_actor.py` - cover cleanup, escalation, and startup
|
||||||
|
ordering.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
None - the generated P1 patch remains uncommitted for human review.
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-18T03:15:32Z
|
||||||
|
git_ref: 4151b956
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Implemented only the P1 lifecycle finding from the approved PR #481
|
||||||
|
review, preserving the requested one-fix-at-a-time commit boundary.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/runtime/_supervise.py`
|
||||||
|
|
||||||
|
Added per-child reap request/completion events to `ActorNursery`, a
|
||||||
|
shielded child-specific cancel-and-reap operation, late-registration
|
||||||
|
latching for nursery teardown, and cancellation escalation that waits
|
||||||
|
for debugger release before using non-ignorable process termination.
|
||||||
|
The nursery-wide cancellation path snapshots child records before
|
||||||
|
checkpointing so concurrent one-shot cleanup cannot invalidate its
|
||||||
|
iteration.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/spawn/_trio.py`
|
||||||
|
|
||||||
|
Changed Trio child monitors to wait on their per-child reap requests.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/spawn/_mp.py`
|
||||||
|
|
||||||
|
Changed multiprocessing child monitors to wait on their per-child reap
|
||||||
|
requests.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/spawn/_spawn.py`
|
||||||
|
|
||||||
|
Ensured every backend publishes child-reap completion after its process
|
||||||
|
monitor exits.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/to_actor/_api.py`
|
||||||
|
|
||||||
|
Changed owned one-shot cleanup to await child-specific process joining
|
||||||
|
and bookkeeping removal instead of treating the cancel RPC as reaping.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tests/test_to_actor.py`
|
||||||
|
|
||||||
|
Added regressions for immediate caller-managed nursery cleanup, failed
|
||||||
|
cancel acknowledgement escalation, and child registration after a
|
||||||
|
latched nursery-wide teardown request.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
`pytest -q tests/test_to_actor.py tests/test_cancellation.py tests/test_spawning.py tests/discovery/test_multi_program.py`
|
||||||
|
|
||||||
|
Result: `46 passed, 1 xfailed, 3 xpassed`.
|
||||||
|
|
||||||
|
`pytest -q tests/test_to_actor.py --tpt-proto uds`
|
||||||
|
|
||||||
|
Result: `13 passed`.
|
||||||
|
|
||||||
|
`pytest -q tests/test_to_actor.py --spawn-backend mp_spawn --tpt-proto tcp`
|
||||||
|
|
||||||
|
Result: `13 passed`.
|
||||||
|
|
||||||
|
One broad verification run was mistakenly launched in parallel with
|
||||||
|
the UDS and `mp_spawn` actor suites. It timed out
|
||||||
|
`test_remote_error_from_caller_nursery`; the node passed immediately
|
||||||
|
in isolation and the complete broad selection then passed serially.
|
||||||
|
The failure was classified as concurrent test-session interference,
|
||||||
|
not accepted as a passing boundary result.
|
||||||
|
|
||||||
|
Ruff, Python compilation, and `git diff --check` passed for the changed
|
||||||
|
boundary. Ruff's existing `_trio.py` F401 finding was reproduced at the
|
||||||
|
unmodified PR head and excluded from attribution to this patch.
|
||||||
|
|
||||||
|
No source files were staged, committed, pushed, or used for review
|
||||||
|
replies. The previously approved top-level review was published before
|
||||||
|
the fix at reviewed head `4151b956`.
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: ses_3e4c90d3eafeqHEtRYSIHgHhpA
|
||||||
|
timestamp: 2026-08-18T19:30:01Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260818T193001Z_bf06b4f8_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Fix cancellation during an indeterminate transport write without
|
||||||
|
allowing a second RPC msg to corrupt the stream. Keep the transport
|
||||||
|
fix separate from the higher-level `to_actor` remediation and explain
|
||||||
|
the length-prefixed protocol rather than using unexplained "framed
|
||||||
|
send" terminology.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Close a msgpack transport stream when cancellation interrupts its
|
||||||
|
length-prefixed `send_all()` operation. The stream can no longer be
|
||||||
|
safely reused because Trio cannot report how many bytes were written.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/ipc/_transport.py` - close an interrupted send stream.
|
||||||
|
- `tests/ipc/test_each_tpt.py` - cover cancellation during the write.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human required this transport edge-case fix to land as its own
|
||||||
|
behavioral commit with a detailed message. During staged review, the
|
||||||
|
human also rejected the unexplained "framed send" wording and asked
|
||||||
|
for terminology tied directly to the actual transport operation.
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-18T19:30:01Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Prospective review found that cancellation can interrupt
|
||||||
|
`MsgpackTransport.send()` after `send_all()` writes only part of its
|
||||||
|
length-prefixed msg. Sending a cancellation request afterward can
|
||||||
|
append another msg to the indeterminate stream and desynchronize the
|
||||||
|
peer decoder.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/ipc/_transport.py tests/ipc/test_each_tpt.py`
|
||||||
|
|
||||||
|
Close the stream under a cancellation shield when `send_all()` is
|
||||||
|
cancelled. Cover the behavior with a fake stream that checkpoints
|
||||||
|
inside the write and records forced closure.
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: ses_3e4c90d3eafeqHEtRYSIHgHhpA
|
||||||
|
timestamp: 2026-08-18T19:30:02Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260818T193002Z_bf06b4f8_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Distill repeated `Actor._contexts.pop()` machinery into a wrapper like
|
||||||
|
the RPC-task registration helper so future teardown sites do not keep
|
||||||
|
reconstructing the context-registry key independently. Preserve the
|
||||||
|
existing lifecycle-specific cleanup behavior.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Add idempotent `Actor._drop_context()` registry removal keyed from the
|
||||||
|
context's own channel and CID. Use it for caller context teardown and
|
||||||
|
the strict callee-side RPC deregistration path.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/runtime/_runtime.py` - own context-registry removal.
|
||||||
|
- `tractor/runtime/_rpc.py` - use the helper for callee teardown.
|
||||||
|
- `tractor/_context.py` - use the helper after caller teardown.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human identified the repeated registry-pop code and requested a
|
||||||
|
central primitive analogous to `_register_rpc_task()`. The agent first
|
||||||
|
suggested an async helper that also closed receive channels; the final
|
||||||
|
design was narrowed to registry removal only so each lifecycle owner
|
||||||
|
retains its existing closure, debugger, shielding, and error policy.
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-18T19:30:02Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Repeated teardown sites reconstruct the `Actor._contexts` registry
|
||||||
|
key from a portal channel and context ID before popping it. Add an
|
||||||
|
idempotent actor-owned helper deriving the key from the context itself,
|
||||||
|
then route caller and callee context teardown through that helper.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/runtime/_runtime.py tractor/runtime/_rpc.py tractor/_context.py`
|
||||||
|
|
||||||
|
Keep receive-channel closure and cancellation shielding in each
|
||||||
|
lifecycle owner so the helper centralizes registry machinery without
|
||||||
|
changing their teardown ordering.
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: ses_3e4c90d3eafeqHEtRYSIHgHhpA
|
||||||
|
timestamp: 2026-08-18T19:30:03Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260818T193003Z_bf06b4f8_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Cancel a remote task when its caller is cancelled after `Start`
|
||||||
|
publication but before startup acknowledgement. Keep cancellation
|
||||||
|
bounded, prevent its private `_cancel_task` RPC from recursively
|
||||||
|
cancelling itself and preserve public target kwargs unchanged.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Add private portal startup policy, use it for non-recursive context
|
||||||
|
cancellation and clean caller-side startup state under a shield.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/runtime/_portal.py` - separate private startup policy.
|
||||||
|
- `tractor/_context.py` - disable recursion for cancellation RPCs.
|
||||||
|
- `tractor/runtime/_runtime.py` - clean cancelled task startup.
|
||||||
|
- `tests/test_context_stream_semantics.py` - control cancellation
|
||||||
|
between `Start` publication and acknowledgement.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human required this cancellation behavior to remain a distinct
|
||||||
|
commit from general startup failures and from the public `to_actor`
|
||||||
|
API. The human also requested that its runtime comment describe the
|
||||||
|
actual length-prefixed transport guarantee and concrete `_cancel_task`
|
||||||
|
operation rather than referring to an unnamed wrapper.
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-18T19:30:03Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Cancellation while `Actor.start_remote_task()` waits for `StartAck`
|
||||||
|
can strand its caller-side context and leave the remote task running.
|
||||||
|
Make one bounded cleanup request, remove local startup state and close
|
||||||
|
its receive channel.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/runtime/_runtime.py tractor/runtime/_portal.py tractor/_context.py tests/test_context_stream_semantics.py`
|
||||||
|
|
||||||
|
Separate private startup-cancellation policy from public target kwargs
|
||||||
|
using `Portal._run_from_ns()`. Have `Context.cancel()` disable recursive
|
||||||
|
startup cancellation for its own `_cancel_task` RPC. Exercise
|
||||||
|
cancellation after `Start` publication and prove the caller-owned actor
|
||||||
|
remains reusable without leaked contexts.
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: ses_3e4c90d3eafeqHEtRYSIHgHhpA
|
||||||
|
timestamp: 2026-08-18T19:30:04Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260818T193004Z_bf06b4f8_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Release caller-side context state for every remote-task startup failure,
|
||||||
|
not only local cancellation. Preserve the remote error, avoid unsafe
|
||||||
|
follow-up sends and prove pre-publication serialization failures leave
|
||||||
|
a reused portal healthy.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Extend remote-task startup cleanup across send, acknowledgement and
|
||||||
|
validation errors. Track completed publication, perform only safe
|
||||||
|
best-effort cancellation and deterministically remove local state.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/runtime/_runtime.py` - clean every startup failure path.
|
||||||
|
- `tests/test_context_stream_semantics.py` - cover authorization and
|
||||||
|
serialization failures before context entry.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human accepted the discovered edge-case fixes but required general
|
||||||
|
startup cleanup to land separately from cancellation cleanup, transport
|
||||||
|
integrity and the public API. This boundary preserves that behavioral
|
||||||
|
distinction and its dedicated commit-message rationale.
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-18T19:30:04Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
`Actor.start_remote_task()` inserts a context before sending `Start`,
|
||||||
|
but startup errors other than cancellation escape without removing or
|
||||||
|
closing that caller state. Serialization errors, acknowledgement
|
||||||
|
timeouts, malformed acknowledgements and remote authorization errors
|
||||||
|
can therefore leak context-registry entries.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/runtime/_runtime.py tests/test_context_stream_semantics.py`
|
||||||
|
|
||||||
|
Cover the complete send, acknowledgement and validation phase with
|
||||||
|
exceptional cleanup. Attempt remote cancellation only when publication
|
||||||
|
is known complete or protocol-safe, and always release local state.
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: ses_3e4c90d3eafeqHEtRYSIHgHhpA
|
||||||
|
timestamp: 2026-08-18T19:30:05Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260818T193005Z_bf06b4f8_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Replace abandoned `Portal.run()` one-shots with a static linked-context
|
||||||
|
endpoint. Follow Trio positional-call semantics, use partials for target
|
||||||
|
keywords, preserve Python 3.14 Placeholder behavior, keep target lookup
|
||||||
|
behind the RPC allowlist and support private, nursery and portal
|
||||||
|
placement.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Use `Portal.open_context()` and `Context.wait_for_result()` for one-shot
|
||||||
|
tasks. Normalize every partial layer, validate signatures locally and
|
||||||
|
send target namespace/function components separately to the authorized
|
||||||
|
remote resolver. Retain the client-side function in its `NamespacePath`
|
||||||
|
so `to_tuple()` does not re-import it. Owned actors enable the declaring
|
||||||
|
`_api.__name__` directly; caller-owned portals opt in through the public
|
||||||
|
`to_actor.MODULE` alias.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/to_actor/_api.py` - implement linked one-shot calls.
|
||||||
|
- `tractor/to_actor/__init__.py` - export `MODULE`.
|
||||||
|
- `tractor/msg/ptr.py` - retain refs created by `from_ref()`.
|
||||||
|
- `tests/test_to_actor.py` - cover the public API and authorization.
|
||||||
|
- `examples/parallelism/to_actor_one_shots.py` - use positional inputs.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human rejected nested target-kwargs configuration and selected
|
||||||
|
Trio-style positional inputs plus `functools.partial()`. During staged
|
||||||
|
review the human required a Python 3.14 compatibility comment rather
|
||||||
|
than removing Placeholder support, requested separate namespace and
|
||||||
|
function inputs, preserved `_get_rpc_func(ns: str, funcname: str)`
|
||||||
|
authorization, renamed `RPC_MODULE` to `MODULE`, rejected global module
|
||||||
|
exposure and deferred speculative nursery/module-list helpers to the
|
||||||
|
`open_taskman()` design line. The human also required this public API
|
||||||
|
to land only after its lower-level safety dependencies. In final staged
|
||||||
|
review, the human required `_invoke_from_portal()` to use
|
||||||
|
`NamespacePath.to_tuple()` with the already-held function ref and
|
||||||
|
required internal actor setup to use `_api.__name__` directly, keeping
|
||||||
|
`to_actor.MODULE` solely as the public importer-facing alias.
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-18T19:30:05Z
|
||||||
|
git_ref: bf06b4f8
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement `to_actor.run()` with Trio-style positional target arguments,
|
||||||
|
`functools.partial` keyword and Python 3.14 Placeholder binding, and a
|
||||||
|
static context endpoint that links remote results, errors and caller
|
||||||
|
cancellation.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/to_actor/_api.py tractor/to_actor/__init__.py`
|
||||||
|
|
||||||
|
Resolve target functions through `Actor._get_rpc_func()` so module
|
||||||
|
authorization remains authoritative. Automatically expose the helper
|
||||||
|
module for actors owned by `to_actor.run()` and document explicit
|
||||||
|
exposure for a caller-owned portal.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tests/test_to_actor.py examples/parallelism/to_actor_one_shots.py`
|
||||||
|
|
||||||
|
Cover placement modes, argument binding, nested partials, caller-linked
|
||||||
|
cancellation, remote errors and module authorization.
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: ses_3e4c90d3eafeqHEtRYSIHgHhpA
|
||||||
|
timestamp: 2026-08-19T02:07:57Z
|
||||||
|
git_ref: b38efed7
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260819T020757Z_b38efed7_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Resolve the remaining P3 review finding before landing PR #481:
|
||||||
|
`runtime_kwargs={}` must not be silently accepted alongside either
|
||||||
|
`an=` or `portal=` merely because the dict is falsey. Keep this as its
|
||||||
|
own final review-remediation commit.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Treat any non-`None` `runtime_kwargs` value as provided when validating
|
||||||
|
placement. Cover both placement APIs with empty and configured dicts,
|
||||||
|
proving the error is raised locally before actor startup.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `tractor/to_actor/_api.py` - validate option presence explicitly.
|
||||||
|
- `tests/test_to_actor.py` - cover four invalid option combinations.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
No direct line edits. The human accepted the P3 finding, required it to
|
||||||
|
remain separate from the five P2 behavioral commits and prioritized it
|
||||||
|
before the final PR #484 integration rebase and PR #481 landing steps.
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-19T02:07:57Z
|
||||||
|
git_ref: b38efed7
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix the final PR #481 review finding: `runtime_kwargs` is mutually
|
||||||
|
exclusive with both caller placement options whenever it is provided,
|
||||||
|
including an empty dict.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- tractor/to_actor/_api.py tests/test_to_actor.py`
|
||||||
|
|
||||||
|
Use an explicit `is not None` check rather than dict truthiness. Expand
|
||||||
|
the validation regression across `an=` and `portal=`, each with empty
|
||||||
|
and configured runtime kwargs, so every invalid combination fails
|
||||||
|
before actor runtime startup.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- Trio/TCP: `23 passed`
|
||||||
|
- Trio/UDS: `23 passed`
|
||||||
|
- `mp_spawn`/TCP: `23 passed`
|
||||||
|
- Ruff and `git diff --check`: clean
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
session: 76c5d31c-5a2f-4503-9b16-410ee7f4fab3
|
||||||
|
timestamp: 2026-08-19T18:46:40Z
|
||||||
|
git_ref: 481ba003
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260819T184640Z_481ba003_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Rebase PR #484 onto final PR #481, migrate every affected one-shot call
|
||||||
|
to the new positional target API and continue through downstream tests,
|
||||||
|
examples and documentation review.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Converted stale target keyword calls to target partials so previously
|
||||||
|
named inputs remain explicit while placement/runtime controls stay
|
||||||
|
direct. Updated error expectations for local signature validation and
|
||||||
|
linked remote error propagation, then corrected docs which still
|
||||||
|
described the removed one-shot implementation. Linked spawning and
|
||||||
|
context lifecycle prose to the corresponding API methods and detailed
|
||||||
|
context guide.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `docs/api/core.rst` - describe linked one-shot context execution.
|
||||||
|
- `docs/guide/rpc.rst` - update placement and target call semantics.
|
||||||
|
- `docs/guide/spawning.rst` - document positional target inputs.
|
||||||
|
- `examples/debugging/multi_nested_subactors_error_up_through_nurseries.py` - migrate nested actor target inputs.
|
||||||
|
- `examples/debugging/root_cancelled_but_child_is_in_tty_lock.py` - preserve named recursive target inputs with partials.
|
||||||
|
- `tests/test_advanced_streaming.py` - migrate streaming target inputs.
|
||||||
|
- `tests/test_cancellation.py` - migrate calls and tighten errors.
|
||||||
|
- `tests/test_infected_asyncio.py` - bind asyncio target options.
|
||||||
|
- `tests/test_rpc.py` - migrate RPC target argument binding.
|
||||||
|
- `tests/test_runtime.py` - preserve named runtime target inputs.
|
||||||
|
- `tests/test_spawning.py` - preserve named spawning target inputs.
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
The human selected the stack order and final PR #481 base, asked the
|
||||||
|
agent to continue after each diagnostic step and required a complete
|
||||||
|
commit plan after independently force-pushing the rebased history.
|
||||||
|
After reviewing the migration, the human required every formerly named
|
||||||
|
target input to remain visibly named through `functools.partial()`
|
||||||
|
rather than becoming positional. These were human-directed agent edits;
|
||||||
|
the human also required plain `start_actor()` and `open_context()`
|
||||||
|
references in the spawning and RPC guides to link to their API methods
|
||||||
|
and the detailed context guide, then clarified that `to_actor.run()`
|
||||||
|
already uses the full context API while `Portal.run()` should share
|
||||||
|
linked lifecycle machinery without necessarily delegating through
|
||||||
|
`Portal.open_context()` or adding a `Started` message. The human made
|
||||||
|
no direct source-line edits.
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
---
|
||||||
|
model: openai/gpt-5.6-sol
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-08-19T18:46:40Z
|
||||||
|
git_ref: 481ba003
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
Migrate PR #484's downstream one-shot calls to PR #481's final
|
||||||
|
`tractor.to_actor.run()` contract after the stack rebase.
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- docs examples tests`
|
||||||
|
|
||||||
|
Pass target arguments positionally and bind target keyword-only inputs
|
||||||
|
with `functools.partial()`. Keep placement and runtime controls as
|
||||||
|
direct `to_actor.run()` keywords. Update the invalid-target-argument
|
||||||
|
test to expect local signature binding before actor startup and require
|
||||||
|
direct `RemoteActorError` propagation from linked one-shots.
|
||||||
|
|
||||||
|
Update API and guide prose to describe positional target inputs,
|
||||||
|
linked `Portal.open_context()` execution and per-child reaping instead
|
||||||
|
of the removed `Portal.run()` and target-`**kwargs` conventions.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- core and migrated runtime batches: `97 passed`
|
||||||
|
- discovery and related lifecycle batch: `33 passed, 1 skipped`
|
||||||
|
- changed executable examples: `9 passed`
|
||||||
|
- mapped debugger cases: `12 passed, 6 skipped`
|
||||||
|
- Ruff, compilation and `git diff --check`: clean
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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`, a linked
|
||||||
|
:meth:`Portal.open_context` call and per-child cancellation/reaping,
|
||||||
|
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`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -80,28 +80,36 @@ 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)
|
from functools import partial
|
||||||
final = await portal.wait_for_result()
|
|
||||||
|
final = await tractor.to_actor.run(
|
||||||
|
partial(fib, n=10),
|
||||||
|
an=an,
|
||||||
|
)
|
||||||
|
|
||||||
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 linked
|
||||||
plus a ``NoResult`` sentinel. Results of individual daemon
|
:meth:`~tractor.Portal.open_context` call; see the
|
||||||
calls come straight back from each ``await portal.run()``.
|
:doc:`context guide </guide/context>`), and passing neither
|
||||||
|
opens a private call-scoped nursery (booting the runtime if needed).
|
||||||
|
- concurrency composes the plain ``trio`` way: schedule
|
||||||
|
multiple ``run()`` calls into a local task nursery (see
|
||||||
|
``examples/parallelism/to_actor_one_shots.py``).
|
||||||
|
|
||||||
Pure RPC daemons: ``run_daemon()``
|
Pure RPC daemons: ``run_daemon()``
|
||||||
----------------------------------
|
----------------------------------
|
||||||
|
|
@ -147,7 +155,8 @@ call tears down the entire sub-tree — SC, transitively.
|
||||||
|
|
||||||
When to graduate to ``Context``
|
When to graduate to ``Context``
|
||||||
-------------------------------
|
-------------------------------
|
||||||
``portal.run()`` is great for one-shot, request-response calls.
|
The :meth:`~tractor.Portal.run` method is great for one-shot,
|
||||||
|
request-response calls.
|
||||||
Reach for :meth:`~tractor.Portal.open_context` with an
|
Reach for :meth:`~tractor.Portal.open_context` with an
|
||||||
``@tractor.context`` endpoint as soon as you want:
|
``@tractor.context`` endpoint as soon as you want:
|
||||||
|
|
||||||
|
|
@ -160,10 +169,15 @@ Reach for :meth:`~tractor.Portal.open_context` with an
|
||||||
:meth:`~tractor.Portal.cancel_actor` nukes the **entire**
|
:meth:`~tractor.Portal.cancel_actor` nukes the **entire**
|
||||||
remote runtime and its process.
|
remote runtime and its process.
|
||||||
|
|
||||||
In fact the source plans for ``Portal.run()`` itself to be
|
:func:`tractor.to_actor.run` already enters the full
|
||||||
rebuilt on top of ``open_context()`` — contexts *are* the core
|
:meth:`~tractor.Portal.open_context` lifecycle. The older
|
||||||
inter-actor protocol. Take the full tour in
|
:meth:`~tractor.Portal.run` path instead uses the ``Context`` returned
|
||||||
:doc:`/guide/context`.
|
by the lower-level ``Actor.start_remote_task()`` directly, avoiding a
|
||||||
|
``Started`` handshake but owning less lifecycle machinery. A follow-up
|
||||||
|
should factor their shared linked-task lifecycle without requiring
|
||||||
|
``Portal.run()`` to delegate through the public context API or add
|
||||||
|
another wire message. Take the full tour in
|
||||||
|
:doc:`the context guide </guide/context>`.
|
||||||
|
|
||||||
.. seealso::
|
.. seealso::
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,31 +91,34 @@ somebody-ing:
|
||||||
|
|
||||||
What's going on here?
|
What's going on here?
|
||||||
|
|
||||||
- ``start_actor('frank', enable_modules=[__name__])`` forks off
|
- :meth:`~tractor.ActorNursery.start_actor` forks off
|
||||||
a new process, boots a ``tractor`` runtime inside it, and
|
a new process, boots a ``tractor`` runtime inside it, and
|
||||||
allows it to serve functions from the current module (see the
|
allows it to serve functions from the current module (see the
|
||||||
allowlist section below).
|
allowlist section below).
|
||||||
- each ``await portal.run(...)`` schedules a *new* task in
|
- each :meth:`~tractor.Portal.run` call schedules a *new* task in
|
||||||
frank's task tree and waits on its result — the full RPC story
|
frank's task tree and waits on its result — the full RPC story
|
||||||
lives in :doc:`/guide/rpc`.
|
lives in :doc:`/guide/rpc`.
|
||||||
- frank has no main task to complete, so without the final
|
- frank has no main task to complete, so without the final
|
||||||
``await portal.cancel_actor()`` the nursery block would wait
|
:meth:`~tractor.Portal.cancel_actor` call the nursery block would
|
||||||
on him **forever**. Daemon lifetimes are *yours* to end; that
|
wait on him **forever**. Daemon lifetimes are *yours* to end;
|
||||||
explicitness is the point.
|
that explicitness is the point.
|
||||||
|
|
||||||
``run_in_actor()``: quick one-shot parallelism
|
``to_actor.run()``: quick one-shot parallelism
|
||||||
----------------------------------------------
|
----------------------------------------------
|
||||||
:meth:`~tractor.ActorNursery.run_in_actor` is the convenience
|
: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:
|
||||||
|
|
||||||
|
|
@ -123,43 +126,52 @@ A few details worth knowing:
|
||||||
``name='something_cuter'``.
|
``name='something_cuter'``.
|
||||||
- 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.
|
- target arguments are positional; use ``functools.partial()``
|
||||||
- the child is *auto-cancelled* once its "main" result lands;
|
to bind target keyword arguments. Keywords passed directly to
|
||||||
at nursery exit these run-once children are always reaped
|
``run()`` configure actor placement and spawning.
|
||||||
first (causality_ is paramount!).
|
- the call blocks until the result (or error) lands and the
|
||||||
|
child is *auto-cancelled* (reaped) right after — so remote
|
||||||
|
errors raise directly in your calling task (causality_ is
|
||||||
|
paramount!).
|
||||||
|
- "placement" composes: ``an=`` spawns from a caller-managed
|
||||||
|
actor-nursery, ``portal=`` reuses an already-running actor
|
||||||
|
(no spawn/reap), and passing neither opens a private
|
||||||
|
call-scoped nursery (booting the runtime if needed).
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
``run_in_actor()`` is a convenience, **not** the core model.
|
:func:`tractor.to_actor.run` is a convenience, **not** the core
|
||||||
The source literally marks it for an eventual rebuild as
|
model — it's built *entirely* on
|
||||||
a thin "hilevel" wrapper on top of
|
:meth:`~tractor.ActorNursery.start_actor` plus a linked
|
||||||
:meth:`~tractor.Portal.open_context` (the modern inter-actor
|
:meth:`~tractor.Portal.open_context` call and per-child
|
||||||
task API). Teach your fingers to use it for quick
|
cancellation/reaping. Teach your fingers to use it for quick
|
||||||
fire-and-collect parallelism — think a per-function
|
fire-and-collect parallelism — think a per-function trio-parallel_
|
||||||
trio-parallel_ style one-shot — and reach for
|
style one-shot — and reach for
|
||||||
``start_actor()`` + ``open_context()`` for anything
|
:meth:`~tractor.ActorNursery.start_actor` plus
|
||||||
long-lived, stateful or streaming
|
:meth:`~tractor.Portal.open_context` for anything long-lived,
|
||||||
(:doc:`/guide/context`).
|
stateful or streaming; see :doc:`/guide/context`.
|
||||||
|
|
||||||
Actor lifetimes and teardown order
|
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** (:meth:`~tractor.ActorNursery.start_actor`): lives
|
||||||
it — an explicit ``await portal.cancel_actor()``, a bulk
|
until *someone* cancels it — an explicit
|
||||||
``await an.cancel()``, or the one-cancels-all strategy kicking
|
:meth:`~tractor.Portal.cancel_actor`, a bulk
|
||||||
in on error.
|
:meth:`~tractor.ActorNursery.cancel`, or the one-cancels-all
|
||||||
|
strategy kicking in on error.
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -21,22 +21,34 @@ 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,
|
||||||
|
enable_modules=[__name__],
|
||||||
)
|
)
|
||||||
gretchen = await n.run_in_actor(
|
for name in ('donny', 'gretchen')
|
||||||
|
}
|
||||||
|
|
||||||
|
async def run_and_print(name: str, other_actor: str):
|
||||||
|
print(
|
||||||
|
await portals[name].run(
|
||||||
say_hello,
|
say_hello,
|
||||||
name='gretchen',
|
other_actor=other_actor,
|
||||||
other_actor='donny',
|
|
||||||
)
|
)
|
||||||
print(await gretchen.wait_for_result())
|
)
|
||||||
print(await donny.wait_for_result())
|
|
||||||
|
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...")
|
print("CUTTTT CUUTT CUT!!! Donny!! You're supposed to say...")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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__':
|
||||||
|
|
|
||||||
|
|
@ -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__],
|
||||||
|
|
|
||||||
|
|
@ -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__],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
|
|
||||||
|
|
@ -21,26 +23,39 @@ 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(
|
||||||
|
partial(
|
||||||
spawn_until,
|
spawn_until,
|
||||||
depth=depth,
|
depth=depth,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
name=f'spawn_until_{depth}',
|
name=f'spawn_until_{depth}',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -65,34 +80,37 @@ 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 (
|
||||||
|
tractor.open_nursery(
|
||||||
debug_mode=True,
|
debug_mode=True,
|
||||||
loglevel='pdb',
|
loglevel='pdb',
|
||||||
) as n:
|
) as an,
|
||||||
|
trio.open_nursery() as tn,
|
||||||
# spawn both actors
|
):
|
||||||
portal = await n.run_in_actor(
|
# spawn both spawner trees as concurrent one-shots; the
|
||||||
|
# first tree's (relayed) error cancels the other.
|
||||||
|
tn.start_soon(
|
||||||
|
partial(
|
||||||
|
tractor.to_actor.run,
|
||||||
|
partial(
|
||||||
spawn_until,
|
spawn_until,
|
||||||
depth=3,
|
depth=3,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
name='spawner0',
|
name='spawner0',
|
||||||
)
|
)
|
||||||
portal1 = await n.run_in_actor(
|
)
|
||||||
|
tn.start_soon(
|
||||||
|
partial(
|
||||||
|
tractor.to_actor.run,
|
||||||
|
partial(
|
||||||
spawn_until,
|
spawn_until,
|
||||||
depth=4,
|
depth=4,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
name='spawner1',
|
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__':
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
tractor.open_nursery(
|
||||||
debug_mode=True,
|
debug_mode=True,
|
||||||
loglevel='devx',
|
loglevel='devx',
|
||||||
) as n:
|
) 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)
|
||||||
|
|
|
||||||
|
|
@ -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__':
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
|
|
||||||
|
|
@ -10,15 +12,17 @@ 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(
|
||||||
|
partial(
|
||||||
spawn_until,
|
spawn_until,
|
||||||
depth=depth,
|
depth=depth,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
name=f'spawn_until_{depth}',
|
name=f'spawn_until_{depth}',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -37,28 +41,37 @@ 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 (
|
||||||
|
tractor.open_nursery(
|
||||||
debug_mode=True,
|
debug_mode=True,
|
||||||
enable_transports=['uds'], # TODO, apss this via osenv?
|
enable_transports=['uds'], # TODO, apss this via osenv?
|
||||||
loglevel='devx', # XXX, required for test!
|
loglevel='devx', # XXX, required for test!
|
||||||
) as n:
|
) as an,
|
||||||
|
trio.open_nursery() as tn,
|
||||||
# spawn both actors
|
):
|
||||||
portal = await n.run_in_actor(
|
# spawn the deeper tree in the bg..
|
||||||
spawn_until,
|
tn.start_soon(
|
||||||
depth=0,
|
partial(
|
||||||
name='spawner0',
|
tractor.to_actor.run,
|
||||||
)
|
partial(
|
||||||
portal1 = await n.run_in_actor(
|
|
||||||
spawn_until,
|
spawn_until,
|
||||||
depth=1,
|
depth=1,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
name='spawner1',
|
name='spawner1',
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# nursery cancellation should be triggered due to propagated
|
# ..while blocking on the shallow (faster to fail) tree
|
||||||
# error from child.
|
# whose propagated error triggers nursery cancellation.
|
||||||
await portal.result()
|
await tractor.to_actor.run(
|
||||||
await portal1.result()
|
partial(
|
||||||
|
spawn_until,
|
||||||
|
depth=0,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
|
name='spawner0',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
|
|
@ -13,17 +13,24 @@ async def main():
|
||||||
simultaneously.
|
simultaneously.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
async with tractor.open_nursery(
|
async with (
|
||||||
|
tractor.open_nursery(
|
||||||
debug_mode=True,
|
debug_mode=True,
|
||||||
# loglevel='debug' # ?XXX required?
|
# loglevel='debug' # ?XXX required?
|
||||||
) as n:
|
) as an,
|
||||||
|
trio.open_nursery() as tn,
|
||||||
# spawn both actors
|
):
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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__],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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__':
|
||||||
|
|
|
||||||
|
|
@ -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__':
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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__],
|
||||||
# )
|
# )
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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
|
# burn rubber in the parent too
|
||||||
await burn_cpu()
|
tn.start_soon(burn_cpu)
|
||||||
|
|
||||||
# wait on result from target function
|
# run the same func as the lone task in a subactor,
|
||||||
pid = await portal.wait_for_result()
|
# block on (and collect) its result
|
||||||
|
pid = await tractor.to_actor.run(burn_cpu)
|
||||||
|
|
||||||
# end of nursery block
|
|
||||||
print(f"Collected subproc {pid}")
|
print(f"Collected subproc {pid}")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
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,
|
||||||
|
n,
|
||||||
|
an=an,
|
||||||
|
name=f'prime_checker_{i}',
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
@ -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__':
|
||||||
|
|
|
||||||
|
|
@ -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__],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
portals: dict[str, tractor.Portal] = {
|
||||||
|
name: await an.start_actor(
|
||||||
|
name,
|
||||||
|
enable_modules=[__name__],
|
||||||
)
|
)
|
||||||
gretchen = await n.run_in_actor(
|
for name in ('donny', 'gretchen')
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _direct(this_name: str, other_actor: str):
|
||||||
|
res = await portals[this_name].run(
|
||||||
ria_fn,
|
ria_fn,
|
||||||
other_actor='donny',
|
other_actor=other_actor,
|
||||||
reg_addr=reg_addr,
|
reg_addr=reg_addr,
|
||||||
name='gretchen',
|
|
||||||
)
|
)
|
||||||
print(await gretchen.result())
|
print(res)
|
||||||
print(await donny.result())
|
|
||||||
|
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
|
||||||
|
# without a "main" task; the streaming
|
||||||
|
# branch below uses the module funcs, the
|
||||||
|
# non-streaming case just needs it up (was
|
||||||
|
# `run_in_actor(trio.sleep_forever)`).
|
||||||
portals[name] = await an.start_actor(
|
portals[name] = await an.start_actor(
|
||||||
name=name, enable_modules=[__name__])
|
name=name,
|
||||||
|
enable_modules=[__name__],
|
||||||
else: # no streaming
|
)
|
||||||
portals[name] = await an.run_in_actor(
|
|
||||||
trio.sleep_forever, name=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):
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,65 @@ import trio
|
||||||
import tractor
|
import tractor
|
||||||
from tractor import Actor
|
from tractor import Actor
|
||||||
from tractor.discovery import _addr
|
from tractor.discovery import _addr
|
||||||
|
from tractor.ipc._transport import MsgpackTransport
|
||||||
from tractor.runtime import _state
|
from tractor.runtime import _state
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancelled_transport_send_closes_stream():
|
||||||
|
'''
|
||||||
|
Discard a transport after cancellation interrupts a framed send.
|
||||||
|
|
||||||
|
Trio's `SendStream.send_all()` may write an arbitrary frame prefix
|
||||||
|
before raising `Cancelled`. Sending another IPC msg afterward
|
||||||
|
would append a second frame and desynchronize the peer decoder.
|
||||||
|
The fake stream checkpoints after recording send entry; cancelling
|
||||||
|
its nursery deterministically interrupts that unknown-publication
|
||||||
|
window. Its close assertion proves the transport is made unusable
|
||||||
|
before another framed msg can be attempted.
|
||||||
|
|
||||||
|
'''
|
||||||
|
class PartialSendStream:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.send_entered = trio.Event()
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def send_all(
|
||||||
|
self,
|
||||||
|
data: bytes,
|
||||||
|
) -> None:
|
||||||
|
assert data
|
||||||
|
self.send_entered.set()
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
stream = PartialSendStream()
|
||||||
|
transport = object.__new__(MsgpackTransport)
|
||||||
|
transport.stream = stream
|
||||||
|
transport._send_lock = trio.StrictFIFOLock()
|
||||||
|
|
||||||
|
async with trio.open_nursery() as tn:
|
||||||
|
tn.start_soon(
|
||||||
|
transport.send,
|
||||||
|
tractor.msg.Start(
|
||||||
|
ns=__name__,
|
||||||
|
func='add_one',
|
||||||
|
kwargs={'n': 1},
|
||||||
|
uid=('root', 'test'),
|
||||||
|
cid='partial-send',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await stream.send_entered.wait()
|
||||||
|
tn.cancel_scope.cancel()
|
||||||
|
|
||||||
|
assert stream.closed
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def bindspace_dir_str() -> str:
|
def bindspace_dir_str() -> str:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,59 @@ 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 (
|
||||||
|
tractor.open_nursery(
|
||||||
registry_addrs=[reg_addr],
|
registry_addrs=[reg_addr],
|
||||||
debug_mode=debug_mode,
|
debug_mode=debug_mode,
|
||||||
) as n:
|
) 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(
|
||||||
|
partial(
|
||||||
|
tractor.to_actor.run,
|
||||||
|
partial(
|
||||||
consumer,
|
consumer,
|
||||||
name=f'consumer_{sub}',
|
|
||||||
subs=[sub],
|
subs=[sub],
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
|
name=f'consumer_{sub}',
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# make one dynamic subscriber
|
# make one dynamic subscriber
|
||||||
await n.run_in_actor(
|
tn.start_soon(
|
||||||
|
partial(
|
||||||
|
tractor.to_actor.run,
|
||||||
|
partial(
|
||||||
consumer,
|
consumer,
|
||||||
name='consumer_dynamic',
|
|
||||||
subs=list(_registry.keys()),
|
subs=list(_registry.keys()),
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
|
name='consumer_dynamic',
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# block until "cancelled by user"
|
# block until "cancelled by user"
|
||||||
|
|
@ -347,10 +374,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 +440,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 +555,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__],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
@ -82,7 +104,7 @@ async def do_nuthin():
|
||||||
[
|
[
|
||||||
# expected to be thrown in assert_err
|
# expected to be thrown in assert_err
|
||||||
({}, AssertionError),
|
({}, AssertionError),
|
||||||
# argument mismatch raised in _invoke()
|
# argument mismatch rejected locally before spawn
|
||||||
({'unexpected': 10}, TypeError)
|
({'unexpected': 10}, TypeError)
|
||||||
],
|
],
|
||||||
ids=['no_args', 'unexpected_args'],
|
ids=['no_args', 'unexpected_args'],
|
||||||
|
|
@ -104,57 +126,38 @@ 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. Invalid target args fail local signature binding
|
||||||
# here and the far end task error are one in the same?
|
# before any one-shot actor is spawned.
|
||||||
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
|
partial(assert_err, **args),
|
||||||
# of this actor nursery.
|
an=an,
|
||||||
await portal.result()
|
name='errorer',
|
||||||
|
)
|
||||||
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")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# ensure boxed errors
|
# Invalid args never cross the process boundary.
|
||||||
if args:
|
if args:
|
||||||
with pytest.raises(tractor.RemoteActorError) as excinfo:
|
with pytest.raises(errtype):
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# The linked one-shot raises the child's boxed error
|
||||||
|
# directly in this caller task.
|
||||||
|
with pytest.raises(
|
||||||
|
tractor.RemoteActorError,
|
||||||
|
) as excinfo:
|
||||||
trio.run(main)
|
trio.run(main)
|
||||||
|
|
||||||
assert excinfo.value.boxed_type == errtype
|
assert excinfo.value.boxed_type == errtype
|
||||||
|
|
||||||
else:
|
|
||||||
# the root task will also error on the `Portal.result()`
|
|
||||||
# call so we expect an error from there AND the child.
|
|
||||||
# |_ tho seems like on new `trio` this doesn't always
|
|
||||||
# happen?
|
|
||||||
with pytest.raises((
|
|
||||||
BaseExceptionGroup,
|
|
||||||
tractor.RemoteActorError,
|
|
||||||
)) as excinfo:
|
|
||||||
trio.run(main)
|
|
||||||
|
|
||||||
# ensure boxed errors are `errtype`
|
|
||||||
err: BaseException = excinfo.value
|
|
||||||
if isinstance(err, BaseExceptionGroup):
|
|
||||||
suberrs: list[BaseException] = err.exceptions
|
|
||||||
else:
|
|
||||||
suberrs: list[BaseException] = [err]
|
|
||||||
|
|
||||||
for exc in suberrs:
|
|
||||||
assert exc.boxed_type == errtype
|
|
||||||
|
|
||||||
|
|
||||||
def test_multierror(
|
def test_multierror(
|
||||||
reg_addr: tuple[str, int],
|
reg_addr: tuple[str, int],
|
||||||
|
|
@ -162,113 +165,48 @@ 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}',
|
||||||
# get result(s) from main task
|
enable_modules=[__name__],
|
||||||
try:
|
|
||||||
await portal2.result()
|
|
||||||
except tractor.RemoteActorError as err:
|
|
||||||
assert err.boxed_type is AssertionError
|
|
||||||
print("Look Maa that first actor failed hard, hehh")
|
|
||||||
raise
|
|
||||||
|
|
||||||
# here we should get a ``BaseExceptionGroup`` containing exceptions
|
|
||||||
# from both subactors
|
|
||||||
|
|
||||||
with pytest.raises(BaseExceptionGroup):
|
|
||||||
trio.run(main)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
'delay',
|
|
||||||
(0, 0.5),
|
|
||||||
ids='delays={}'.format,
|
|
||||||
)
|
)
|
||||||
@pytest.mark.parametrize(
|
for i in range(2)
|
||||||
'num_subactors',
|
]
|
||||||
range(25, 26),
|
|
||||||
ids= 'num_subs={}'.format,
|
# both one-shot subactors error concurrently, so the
|
||||||
)
|
# `gather_contexts()` task-nursery collects them into a
|
||||||
def test_multierror_fast_nursery(
|
# `BaseExceptionGroup` (was two non-blocking
|
||||||
reg_addr: tuple,
|
# `run_in_actor()`s reaped at nursery teardown).
|
||||||
start_method: str,
|
async with gather_contexts(
|
||||||
num_subactors: int,
|
mngrs=[
|
||||||
delay: float,
|
p.open_context(assert_err_ctx)
|
||||||
set_fork_aware_capture,
|
for p in portals
|
||||||
fail_after_w_trace: FailAfterWTraceFactory,
|
],
|
||||||
):
|
):
|
||||||
'''
|
pass
|
||||||
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.
|
|
||||||
|
|
||||||
'''
|
with pytest.raises((
|
||||||
async def main():
|
BaseExceptionGroup,
|
||||||
# budget = 2× natural trio-backend cascade time for
|
tractor.RemoteActorError,
|
||||||
# 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)
|
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 +234,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 +275,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 +289,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 +337,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,23 +366,29 @@ 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 (
|
||||||
|
collapse_eg(),
|
||||||
|
trio.open_nursery() as tn,
|
||||||
|
):
|
||||||
for i in range(num_actors):
|
for i in range(num_actors):
|
||||||
# start actor(s) that will fail immediately
|
# schedule one-shot task actor(s); errors
|
||||||
riactor_portals.append(
|
# raise into this task-nursery scope.
|
||||||
await an.run_in_actor(
|
tn.start_soon(
|
||||||
func,
|
partial(
|
||||||
|
tractor.to_actor.run,
|
||||||
|
partial(func, **kwargs),
|
||||||
|
an=an,
|
||||||
name=f'actor_{i}',
|
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
|
||||||
|
# other actors
|
||||||
try:
|
try:
|
||||||
await portal.run(func, **kwargs)
|
await portal.run(func, **kwargs)
|
||||||
|
|
||||||
|
|
@ -451,18 +405,24 @@ async def test_some_cancels_all(
|
||||||
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,19 +435,33 @@ 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:
|
||||||
|
|
||||||
args = (
|
args = (
|
||||||
|
partial(
|
||||||
spawn_and_error,
|
spawn_and_error,
|
||||||
|
breadth=breadth,
|
||||||
|
depth=depth - 1,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
kwargs = {
|
kwargs = {
|
||||||
'name': f'spawner_{i}_depth_{depth}',
|
'name': f'spawner_{i}_depth_{depth}',
|
||||||
'breadth': breadth,
|
|
||||||
'depth': depth - 1,
|
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
args = (
|
args = (
|
||||||
|
|
@ -496,7 +470,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 +519,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 +573,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 +664,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,66 +684,83 @@ 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(
|
||||||
|
partial(
|
||||||
|
tractor.to_actor.run,
|
||||||
|
partial(
|
||||||
spawn_and_error,
|
spawn_and_error,
|
||||||
name=f'spawner_{i}',
|
|
||||||
breadth=subactor_breadth,
|
breadth=subactor_breadth,
|
||||||
depth=depth,
|
depth=depth,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
|
name=f'spawner_{i}',
|
||||||
)
|
)
|
||||||
except BaseExceptionGroup as err:
|
)
|
||||||
assert len(err.exceptions) == subactor_breadth
|
except (
|
||||||
for subexc in err.exceptions:
|
BaseExceptionGroup,
|
||||||
|
tractor.RemoteActorError,
|
||||||
# verify first level actor errors are wrapped as remote
|
) as err:
|
||||||
if _friggin_windows:
|
# group membership is bounded by the relay-vs-cancel
|
||||||
|
# race: the first spawner-tree's error cancels its
|
||||||
|
# siblings, whose own errors only group when relayed
|
||||||
|
# first; a fully-raced tree even collapses (via the
|
||||||
|
# runtime's own `collapse_eg()` unwrapping each level's
|
||||||
|
# single-member group) to a bare `RemoteActorError`
|
||||||
|
# re-boxing the leaf `AssertionError` at every actor
|
||||||
|
# boundary. The deterministic exact-breadth nested-BEG
|
||||||
|
# was the legacy `run_in_actor()` reap-all-at-teardown.
|
||||||
|
subexcs: list[BaseException] = (
|
||||||
|
err.exceptions
|
||||||
|
if isinstance(err, BaseExceptionGroup)
|
||||||
|
else [err]
|
||||||
|
)
|
||||||
|
assert 1 <= len(subexcs) <= subactor_breadth
|
||||||
|
for subexc in subexcs:
|
||||||
|
if (
|
||||||
|
_friggin_windows
|
||||||
|
and
|
||||||
|
isinstance(subexc, trio.Cancelled)
|
||||||
|
):
|
||||||
# windows is often too slow and cancellation seems
|
# 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):
|
|
||||||
# on windows it seems we can't exactly be sure wtf
|
|
||||||
# will happen..
|
|
||||||
assert subexc.boxed_type in (
|
|
||||||
tractor.RemoteActorError,
|
|
||||||
trio.Cancelled,
|
|
||||||
BaseExceptionGroup,
|
|
||||||
)
|
|
||||||
|
|
||||||
elif isinstance(subexc, BaseExceptionGroup):
|
|
||||||
for subsub in subexc.exceptions:
|
|
||||||
|
|
||||||
if subsub in (tractor.RemoteActorError,):
|
|
||||||
subsub = subsub.boxed_type
|
|
||||||
|
|
||||||
assert type(subsub) in (
|
|
||||||
trio.Cancelled,
|
|
||||||
BaseExceptionGroup,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
assert isinstance(subexc, tractor.RemoteActorError)
|
assert isinstance(subexc, tractor.RemoteActorError)
|
||||||
|
|
||||||
if depth > 0 and subactor_breadth > 1:
|
accepted: tuple[Type[BaseException], ...] = (
|
||||||
# XXX not sure what's up with this..
|
# ≥2 sub-tree errors relayed before the
|
||||||
# on windows sometimes spawning is just too slow and
|
# cancel-cascade won → grouped per-level.
|
||||||
# we get back the (sent) cancel signal instead
|
ExceptionGroup,
|
||||||
if _friggin_windows:
|
# every level collapsed down to its lone
|
||||||
if isinstance(subexc, tractor.RemoteActorError):
|
# relayed (leaf) error.
|
||||||
assert subexc.boxed_type in (
|
AssertionError,
|
||||||
BaseExceptionGroup,
|
# a mid-level spawner relays an
|
||||||
tractor.RemoteActorError
|
# already-boxed (collapsed) leaf chain,
|
||||||
)
|
# re-boxing the `RemoteActorError` itself.
|
||||||
else:
|
|
||||||
assert isinstance(subexc, BaseExceptionGroup)
|
|
||||||
else:
|
|
||||||
assert subexc.boxed_type is ExceptionGroup
|
|
||||||
else:
|
|
||||||
assert subexc.boxed_type in (
|
|
||||||
tractor.RemoteActorError,
|
tractor.RemoteActorError,
|
||||||
trio.Cancelled
|
# under heavy load a runtime-internal reap
|
||||||
|
# deadline can inject a `trio.Cancelled`
|
||||||
|
# into a child's group before relay (the
|
||||||
|
# same class the depth=3 throttle-xfail
|
||||||
|
# covers) upgrading it from an
|
||||||
|
# `ExceptionGroup`.
|
||||||
|
BaseExceptionGroup,
|
||||||
|
)
|
||||||
|
if _friggin_windows:
|
||||||
|
# on windows it seems we can't exactly be
|
||||||
|
# sure wtf will happen..
|
||||||
|
accepted += (
|
||||||
|
trio.Cancelled,
|
||||||
|
)
|
||||||
|
assert subexc.boxed_type in accepted
|
||||||
|
else:
|
||||||
|
pytest.fail(
|
||||||
|
'Should have raised a (grouped) `RemoteActorError`?'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -764,8 +781,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 +826,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 +873,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,11 +983,19 @@ 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
|
||||||
|
# (itself parked on the sync-sleeping grandchild)
|
||||||
|
# until the `assert 0` below cancels this scope.
|
||||||
|
tn.start_soon(
|
||||||
|
partial(
|
||||||
|
tractor.to_actor.run,
|
||||||
spawn_sub_with_sync_blocking_task,
|
spawn_sub_with_sync_blocking_task,
|
||||||
|
an=an,
|
||||||
name='sync_blocking_sub',
|
name='sync_blocking_sub',
|
||||||
)
|
)
|
||||||
|
)
|
||||||
await trio.sleep(1)
|
await trio.sleep(1)
|
||||||
|
|
||||||
if man_cancel_outer:
|
if man_cancel_outer:
|
||||||
|
|
@ -1013,8 +1043,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__],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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__],
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ sync-opening a ``tractor.Context`` beforehand.
|
||||||
'''
|
'''
|
||||||
from itertools import count
|
from itertools import count
|
||||||
import math
|
import math
|
||||||
|
from pathlib import Path
|
||||||
import platform
|
import platform
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -75,6 +76,37 @@ from tractor._testing import (
|
||||||
_state: bool = False
|
_state: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _non_registration_contexts(
|
||||||
|
actor: Actor,
|
||||||
|
) -> dict[tuple, str]:
|
||||||
|
return {
|
||||||
|
key: str(ctx._nsf)
|
||||||
|
for key, ctx in actor._contexts.items()
|
||||||
|
if str(ctx._nsf) != (
|
||||||
|
'tractor.discovery._registry:'
|
||||||
|
'Registrar.register_actor'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def startup_cancel_target(
|
||||||
|
ctx: Context,
|
||||||
|
started_path: str,
|
||||||
|
cancelled_path: str,
|
||||||
|
) -> None:
|
||||||
|
Path(started_path).touch()
|
||||||
|
try:
|
||||||
|
await ctx.started()
|
||||||
|
await trio.sleep_forever()
|
||||||
|
finally:
|
||||||
|
Path(cancelled_path).touch()
|
||||||
|
|
||||||
|
|
||||||
|
async def return_one() -> int:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
@tractor.context
|
@tractor.context
|
||||||
async def too_many_starteds(
|
async def too_many_starteds(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
|
|
@ -168,6 +200,168 @@ async def assert_state(value: bool):
|
||||||
assert _state == value
|
assert _state == value
|
||||||
|
|
||||||
|
|
||||||
|
@tractor_test
|
||||||
|
async def test_cancel_during_context_startup(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
start_method: str,
|
||||||
|
debug_mode: bool,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Cancel a context after sending `Start` but before its ack.
|
||||||
|
|
||||||
|
`Portal.open_context()` allocates its caller-side `Context` while
|
||||||
|
entering the async context manager. Cancellation used to strand
|
||||||
|
that local context and leave the remote target running. The patched
|
||||||
|
`Channel.send()` publishes `Start`, then blocks before
|
||||||
|
`Actor.start_remote_task()` can await `StartAck`. Cancelling the
|
||||||
|
caller proves cleanup issues one bounded, non-recursive cancel RPC,
|
||||||
|
stops the target and removes both helper contexts. A subsequent
|
||||||
|
RPC proves the caller-owned actor remains usable.
|
||||||
|
|
||||||
|
'''
|
||||||
|
started_path = tmp_path / 'startup_started'
|
||||||
|
cancelled_path = tmp_path / 'startup_cancelled'
|
||||||
|
start_sent = trio.Event()
|
||||||
|
original_send = tractor.Channel.send
|
||||||
|
|
||||||
|
async def delay_after_start(
|
||||||
|
chan: tractor.Channel,
|
||||||
|
payload: object,
|
||||||
|
hide_tb: bool = False,
|
||||||
|
) -> None:
|
||||||
|
await original_send(
|
||||||
|
chan,
|
||||||
|
payload,
|
||||||
|
hide_tb=hide_tb,
|
||||||
|
)
|
||||||
|
if isinstance(payload, tractor.msg.Start):
|
||||||
|
if payload.func == 'startup_cancel_target':
|
||||||
|
start_sent.set()
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
async def open_target(
|
||||||
|
portal: tractor.Portal,
|
||||||
|
) -> None:
|
||||||
|
async with portal.open_context(
|
||||||
|
startup_cancel_target,
|
||||||
|
started_path=str(started_path),
|
||||||
|
cancelled_path=str(cancelled_path),
|
||||||
|
):
|
||||||
|
raise AssertionError('context startup should be cancelled')
|
||||||
|
|
||||||
|
async with tractor.open_nursery() as an:
|
||||||
|
actor = tractor.current_actor()
|
||||||
|
portal: tractor.Portal = await an.start_actor(
|
||||||
|
'startup_cancel_worker',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
contexts_before = _non_registration_contexts(actor)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tractor.Channel,
|
||||||
|
'send',
|
||||||
|
delay_after_start,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with trio.open_nursery() as tn:
|
||||||
|
tn.start_soon(open_target, portal)
|
||||||
|
with trio.fail_after(5):
|
||||||
|
await start_sent.wait()
|
||||||
|
while not started_path.exists():
|
||||||
|
await trio.sleep(0.01)
|
||||||
|
tn.cancel_scope.cancel()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tractor.Channel,
|
||||||
|
'send',
|
||||||
|
original_send,
|
||||||
|
)
|
||||||
|
assert cancelled_path.exists()
|
||||||
|
assert _non_registration_contexts(actor) == contexts_before
|
||||||
|
assert await portal.run_from_ns(
|
||||||
|
__name__,
|
||||||
|
'return_one',
|
||||||
|
) == 1
|
||||||
|
assert _non_registration_contexts(actor) == contexts_before
|
||||||
|
await portal.cancel_actor()
|
||||||
|
|
||||||
|
|
||||||
|
@tractor_test
|
||||||
|
async def test_start_serialization_error_cleans_context(
|
||||||
|
start_method: str,
|
||||||
|
debug_mode: bool,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Deallocate caller state when `Start` can not be serialized.
|
||||||
|
|
||||||
|
`Actor.start_remote_task()` registers its caller-side `Context`
|
||||||
|
before encoding the request. An unsupported argument used to raise
|
||||||
|
`MsgTypeError` before publication while leaking that registry
|
||||||
|
entry. Comparing the context registry around the failed start
|
||||||
|
proves cleanup, and a following valid context proves no bytes
|
||||||
|
reached or damaged the reused portal's transport.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async with tractor.open_nursery() as an:
|
||||||
|
actor = tractor.current_actor()
|
||||||
|
portal: tractor.Portal = await an.start_actor(
|
||||||
|
'serialization_error_worker',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
contexts_before = _non_registration_contexts(actor)
|
||||||
|
with pytest.raises(tractor.MsgTypeError):
|
||||||
|
async with portal.open_context(
|
||||||
|
simple_setup_teardown,
|
||||||
|
data=object(),
|
||||||
|
):
|
||||||
|
raise AssertionError('invalid `Start` was accepted')
|
||||||
|
|
||||||
|
assert _non_registration_contexts(actor) == contexts_before
|
||||||
|
async with portal.open_context(
|
||||||
|
simple_setup_teardown,
|
||||||
|
data=1,
|
||||||
|
) as (ctx, started):
|
||||||
|
assert started == 2
|
||||||
|
assert await ctx.wait_for_result() == 'yo'
|
||||||
|
|
||||||
|
assert _non_registration_contexts(actor) == contexts_before
|
||||||
|
await portal.cancel_actor()
|
||||||
|
|
||||||
|
|
||||||
|
@tractor_test
|
||||||
|
async def test_start_module_error_cleans_context(
|
||||||
|
start_method: str,
|
||||||
|
debug_mode: bool,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Deallocate caller state after a remote startup rejection.
|
||||||
|
|
||||||
|
A target actor without this test module rejects the requested
|
||||||
|
context before sending `StartAck`. That remote
|
||||||
|
`ModuleNotExposed` used to escape startup validation while leaving
|
||||||
|
the caller context registered. The boxed error and before/after
|
||||||
|
registry comparison prove the remote failure remains visible and
|
||||||
|
local startup state is released.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async with tractor.open_nursery() as an:
|
||||||
|
actor = tractor.current_actor()
|
||||||
|
portal: tractor.Portal = await an.start_actor(
|
||||||
|
'module_error_worker',
|
||||||
|
)
|
||||||
|
contexts_before = _non_registration_contexts(actor)
|
||||||
|
with pytest.raises(tractor.RemoteActorError) as excinfo:
|
||||||
|
async with portal.open_context(
|
||||||
|
simple_setup_teardown,
|
||||||
|
data=1,
|
||||||
|
):
|
||||||
|
raise AssertionError('unexposed context was started')
|
||||||
|
|
||||||
|
assert excinfo.value.boxed_type is tractor.ModuleNotExposed
|
||||||
|
assert _non_registration_contexts(actor) == contexts_before
|
||||||
|
await portal.cancel_actor()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
'error_parent',
|
'error_parent',
|
||||||
[False, ValueError, KeyboardInterrupt],
|
[False, ValueError, KeyboardInterrupt],
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ The hipster way to force SC onto the stdlib's "async": 'infection mode'.
|
||||||
import asyncio
|
import asyncio
|
||||||
import builtins
|
import builtins
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
# from functools import partial
|
from functools import partial
|
||||||
import itertools
|
import itertools
|
||||||
import importlib
|
import importlib
|
||||||
import os
|
import os
|
||||||
|
|
@ -36,6 +36,7 @@ from tractor import (
|
||||||
current_actor,
|
current_actor,
|
||||||
Actor,
|
Actor,
|
||||||
to_asyncio,
|
to_asyncio,
|
||||||
|
to_actor,
|
||||||
RemoteActorError,
|
RemoteActorError,
|
||||||
ContextCancelled,
|
ContextCancelled,
|
||||||
)
|
)
|
||||||
|
|
@ -122,8 +123,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -169,6 +171,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,
|
||||||
|
|
@ -184,10 +208,13 @@ 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(
|
||||||
|
partial(
|
||||||
asyncio_actor,
|
asyncio_actor,
|
||||||
target='sleep_and_err',
|
target='sleep_and_err',
|
||||||
expect_err='AssertionError',
|
expect_err='AssertionError',
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
infect_asyncio=True,
|
infect_asyncio=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -219,18 +246,34 @@ def test_tractor_cancels_aio(
|
||||||
|
|
||||||
'''
|
'''
|
||||||
async def main():
|
async def main():
|
||||||
|
# anti-hang wall-clock cap: a per-test `trio.fail_after`
|
||||||
|
# is the blessed guard here since `pytest-timeout`'s
|
||||||
|
# global cap is intentionally off (see the `pyproject`
|
||||||
|
# NOTE — it breaks trio under fork backends). Generous +
|
||||||
|
# CPU-headroom-scaled bc this is an anti-hang guard, not
|
||||||
|
# a perf assertion; a wedged ria-reaper once hung this
|
||||||
|
# test forever (the `._ria_nursery`-removal regression).
|
||||||
|
from .conftest import cpu_perf_headroom
|
||||||
|
with trio.fail_after(9 * cpu_perf_headroom()):
|
||||||
async with tractor.open_nursery(
|
async with tractor.open_nursery(
|
||||||
debug_mode=debug_mode,
|
debug_mode=debug_mode,
|
||||||
registry_addrs=[reg_addr],
|
registry_addrs=[reg_addr],
|
||||||
) as an:
|
) as an:
|
||||||
portal = await an.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,
|
||||||
)
|
)
|
||||||
# cancel the entire remote runtime
|
async with (
|
||||||
await portal.cancel_actor()
|
# `.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)
|
||||||
|
|
||||||
|
|
@ -248,13 +291,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)
|
||||||
|
|
||||||
|
|
@ -404,17 +453,20 @@ 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
|
||||||
|
# relays the remote error here in the caller's task.
|
||||||
|
with trio.fail_after(1 + delay):
|
||||||
|
await to_actor.run(
|
||||||
|
partial(
|
||||||
asyncio_actor,
|
asyncio_actor,
|
||||||
target='aio_cancel',
|
target='aio_cancel',
|
||||||
expect_err='tractor.to_asyncio.AsyncioCancelled',
|
expect_err=(
|
||||||
|
'tractor.to_asyncio.AsyncioCancelled'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
infect_asyncio=True,
|
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):
|
|
||||||
await p.wait_for_result()
|
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
expected_exception=(RemoteActorError, ExceptionGroup),
|
expected_exception=(RemoteActorError, ExceptionGroup),
|
||||||
|
|
@ -615,13 +667,15 @@ 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(
|
|
||||||
stream_from_aio,
|
|
||||||
infect_asyncio=True,
|
|
||||||
fan_out=fan_out,
|
|
||||||
)
|
|
||||||
# should raise RAE diectly
|
# should raise RAE diectly
|
||||||
await portal.result()
|
await to_actor.run(
|
||||||
|
partial(
|
||||||
|
stream_from_aio,
|
||||||
|
fan_out=fan_out,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
|
infect_asyncio=True,
|
||||||
|
)
|
||||||
|
|
||||||
trio.run(main)
|
trio.run(main)
|
||||||
|
|
||||||
|
|
@ -634,13 +688,15 @@ 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(
|
||||||
|
partial(
|
||||||
stream_from_aio,
|
stream_from_aio,
|
||||||
trio_raise_err=True,
|
trio_raise_err=True,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
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)
|
||||||
|
|
@ -670,14 +726,16 @@ 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(
|
|
||||||
stream_from_aio,
|
|
||||||
trio_exit_early=True,
|
|
||||||
infect_asyncio=True,
|
|
||||||
)
|
|
||||||
# should raise RAE diectly
|
# should raise RAE diectly
|
||||||
print('waiting on final infected subactor result..')
|
print('waiting on final infected subactor result..')
|
||||||
res: None = await portal.wait_for_result()
|
res: None = await to_actor.run(
|
||||||
|
partial(
|
||||||
|
stream_from_aio,
|
||||||
|
trio_exit_early=True,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
|
infect_asyncio=True,
|
||||||
|
)
|
||||||
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')
|
||||||
|
|
||||||
|
|
@ -721,15 +779,17 @@ 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(
|
|
||||||
stream_from_aio,
|
|
||||||
infect_asyncio=True,
|
|
||||||
trio_exit_early=False,
|
|
||||||
aio_exit_early=True,
|
|
||||||
)
|
|
||||||
# should raise RAE diectly
|
# should raise RAE diectly
|
||||||
print('waiting on final infected subactor result..')
|
print('waiting on final infected subactor result..')
|
||||||
res: None = await portal.wait_for_result()
|
res: None = await to_actor.run(
|
||||||
|
partial(
|
||||||
|
stream_from_aio,
|
||||||
|
trio_exit_early=False,
|
||||||
|
aio_exit_early=True,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
|
infect_asyncio=True,
|
||||||
|
)
|
||||||
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')
|
||||||
|
|
||||||
|
|
@ -761,17 +821,21 @@ 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(
|
||||||
|
partial(
|
||||||
stream_from_aio,
|
stream_from_aio,
|
||||||
aio_raise_err=True,
|
aio_raise_err=True,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -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__],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
tractor.open_nursery(
|
||||||
registry_addrs=[reg_addr],
|
registry_addrs=[reg_addr],
|
||||||
enable_modules=[__name__],
|
enable_modules=[__name__],
|
||||||
) as n:
|
) 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()` on each as a background task (was the legacy
|
||||||
|
# `run_in_actor()`); keep the portals for the explicit
|
||||||
|
# `cancel_actor()` teardown below. Each runner swallows
|
||||||
|
# the teardown error that `cancel_actor()` relays.
|
||||||
|
async def _run_subs(
|
||||||
|
portal: tractor.Portal,
|
||||||
|
which: list[str],
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
await portal.run(
|
||||||
subs,
|
subs,
|
||||||
which=['even'],
|
which=which,
|
||||||
name='evens',
|
pub_actor_name=name,
|
||||||
pub_actor_name=name
|
|
||||||
)
|
)
|
||||||
odd_portal = await n.run_in_actor(
|
except (
|
||||||
subs,
|
tractor.RemoteActorError,
|
||||||
which=['odd'],
|
tractor.ContextCancelled,
|
||||||
name='odds',
|
):
|
||||||
pub_actor_name=name
|
pass # expected once we `cancel_actor()` below
|
||||||
|
|
||||||
|
even_portal = await an.start_actor(
|
||||||
|
'evens',
|
||||||
|
enable_modules=[__name__],
|
||||||
)
|
)
|
||||||
|
odd_portal = await an.start_actor(
|
||||||
|
'odds',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
tn.start_soon(_run_subs, even_portal, ['even'])
|
||||||
|
tn.start_soon(_run_subs, odd_portal, ['odd'])
|
||||||
|
|
||||||
async with tractor.wait_for_actor('evens'):
|
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__],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -143,9 +143,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()
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ related API and error checks.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
import itertools
|
import itertools
|
||||||
|
from functools import partial
|
||||||
from unittest.mock import (
|
from unittest.mock import (
|
||||||
AsyncMock,
|
AsyncMock,
|
||||||
Mock,
|
Mock,
|
||||||
|
|
@ -234,23 +235,26 @@ 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(
|
||||||
|
partial(
|
||||||
sleep_back_actor,
|
sleep_back_actor,
|
||||||
actor_name=subactor_requests_to,
|
actor_name=subactor_requests_to,
|
||||||
|
func_name=funcname,
|
||||||
|
func_defined=bool(func_defined),
|
||||||
|
exposed_mods=exposed_mods,
|
||||||
|
reg_addr=reg_addr,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
|
|
||||||
name='subactor',
|
name='subactor',
|
||||||
|
|
||||||
# function from the local exposed module space
|
# Function from the local exposed module space the
|
||||||
# the subactor will invoke when it RPCs back to this actor
|
# subactor invokes when it RPCs back to this actor.
|
||||||
func_name=funcname,
|
|
||||||
exposed_mods=exposed_mods,
|
|
||||||
func_defined=True if func_defined else False,
|
|
||||||
enable_modules=subactor_exposed_mods,
|
enable_modules=subactor_exposed_mods,
|
||||||
reg_addr=reg_addr,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
Verifying internal runtime state and undocumented extras.
|
Verifying internal runtime state and undocumented extras.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from functools import partial
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -83,13 +84,14 @@ 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(
|
partial(
|
||||||
crash_and_clean_tmpdir,
|
crash_and_clean_tmpdir,
|
||||||
tmp_file_path=path,
|
tmp_file_path=path,
|
||||||
error=error_in_child,
|
error=error_in_child,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
)
|
)
|
||||||
).result()
|
|
||||||
except (
|
except (
|
||||||
tractor.RemoteActorError,
|
tractor.RemoteActorError,
|
||||||
BaseExceptionGroup,
|
BaseExceptionGroup,
|
||||||
|
|
|
||||||
|
|
@ -48,30 +48,22 @@ 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(
|
||||||
|
partial(
|
||||||
|
spawn,
|
||||||
|
should_be_root=False,
|
||||||
|
data=data_to_pass_down,
|
||||||
|
reg_addr=reg_addr,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
|
|
||||||
# spawning args
|
# spawning args
|
||||||
name='sub-actor',
|
name='sub-actor',
|
||||||
enable_modules=[__name__],
|
enable_modules=[__name__],
|
||||||
|
|
||||||
# passed to a subactor-recursive RPC invoke
|
|
||||||
# of this same `spawn()` fn.
|
|
||||||
should_be_root=False,
|
|
||||||
data=data_to_pass_down,
|
|
||||||
reg_addr=reg_addr,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(an._children) == 1
|
|
||||||
assert (
|
|
||||||
portal.channel.aid.uid
|
|
||||||
in
|
|
||||||
tractor.current_actor().ipc_server._peers
|
|
||||||
)
|
|
||||||
|
|
||||||
# get result from child subactor
|
|
||||||
result = await portal.result()
|
|
||||||
assert result == 10
|
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,18 @@ 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(
|
||||||
|
partial(
|
||||||
cellar_door,
|
cellar_door,
|
||||||
return_value=return_value,
|
return_value=return_value,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
name='some_linguist',
|
name='some_linguist',
|
||||||
)
|
)
|
||||||
|
|
||||||
res: Any = await portal.wait_for_result()
|
|
||||||
assert res == return_value
|
|
||||||
# The ``async with`` will unblock here since the 'some_linguist'
|
|
||||||
# actor has completed its main task ``cellar_door``.
|
|
||||||
|
|
||||||
# this should pull the cached final result already captured during
|
|
||||||
# the nursery block exit.
|
|
||||||
res: Any = await portal.wait_for_result()
|
|
||||||
assert res == return_value
|
assert res == return_value
|
||||||
|
# The ``async with`` unblocks here — the 'some_linguist'
|
||||||
|
# one-shot actor completed its lone task ``cellar_door`` and
|
||||||
|
# was reaped by `to_actor.run()`.
|
||||||
print(res)
|
print(res)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -215,11 +204,14 @@ 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(
|
||||||
|
partial(
|
||||||
check_loglevel,
|
check_loglevel,
|
||||||
loglevel=level,
|
|
||||||
level=level,
|
level=level,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
|
loglevel=level,
|
||||||
)
|
)
|
||||||
|
|
||||||
trio.run(main)
|
trio.run(main)
|
||||||
|
|
@ -267,11 +259,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 +276,25 @@ 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(
|
||||||
|
partial(
|
||||||
check_parent_main_inheritance,
|
check_parent_main_inheritance,
|
||||||
name='replaying-parent-main',
|
|
||||||
expect_inherited=True,
|
expect_inherited=True,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
|
name='replaying-parent-main',
|
||||||
)
|
)
|
||||||
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(
|
||||||
|
partial(
|
||||||
check_parent_main_inheritance,
|
check_parent_main_inheritance,
|
||||||
|
expect_inherited=False,
|
||||||
|
),
|
||||||
|
an=an,
|
||||||
name='isolated-parent-main',
|
name='isolated-parent-main',
|
||||||
inherit_parent_main=False,
|
inherit_parent_main=False,
|
||||||
expect_inherited=False,
|
|
||||||
)
|
)
|
||||||
await isolated.result()
|
|
||||||
|
|
||||||
trio.run(main)
|
trio.run(main)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,720 @@
|
||||||
|
'''
|
||||||
|
`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
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import trio
|
||||||
|
import tractor
|
||||||
|
from tractor import (
|
||||||
|
RemoteActorError,
|
||||||
|
to_actor,
|
||||||
|
)
|
||||||
|
from tractor._testing import tractor_test
|
||||||
|
from tractor.msg import ptr as msgptr
|
||||||
|
from tractor.msg.ptr import NamespacePath
|
||||||
|
from tractor.to_actor import _api as to_actor_api
|
||||||
|
|
||||||
|
|
||||||
|
async def add_one(
|
||||||
|
n: int,
|
||||||
|
) -> int:
|
||||||
|
return n + 1
|
||||||
|
|
||||||
|
|
||||||
|
async def raise_value_error() -> None:
|
||||||
|
raise ValueError('kaboom')
|
||||||
|
|
||||||
|
|
||||||
|
async def echo_control_names(
|
||||||
|
value: int,
|
||||||
|
/,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
portal: str,
|
||||||
|
an: str,
|
||||||
|
runtime_kwargs: str,
|
||||||
|
) -> dict[str, int|str]:
|
||||||
|
return {
|
||||||
|
'value': value,
|
||||||
|
'name': name,
|
||||||
|
'portal': portal,
|
||||||
|
'an': an,
|
||||||
|
'runtime_kwargs': runtime_kwargs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_task_cancellation(
|
||||||
|
started_path: str,
|
||||||
|
cancelled_path: str,
|
||||||
|
) -> None:
|
||||||
|
Path(started_path).touch()
|
||||||
|
try:
|
||||||
|
await trio.sleep_forever()
|
||||||
|
finally:
|
||||||
|
Path(cancelled_path).touch()
|
||||||
|
|
||||||
|
|
||||||
|
async def echo_startup_control(
|
||||||
|
_cancel_on_startup: str,
|
||||||
|
) -> str:
|
||||||
|
return _cancel_on_startup
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_args(
|
||||||
|
*args: object,
|
||||||
|
) -> tuple[object, ...]:
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_call(
|
||||||
|
*args: object,
|
||||||
|
**kwargs: object,
|
||||||
|
) -> tuple[tuple[object, ...], dict[str, object]]:
|
||||||
|
return args, kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def _non_registration_contexts(
|
||||||
|
actor: tractor.Actor,
|
||||||
|
) -> dict[tuple, str]:
|
||||||
|
return {
|
||||||
|
key: str(ctx._nsf)
|
||||||
|
for key, ctx in actor._contexts.items()
|
||||||
|
if str(ctx._nsf) != (
|
||||||
|
'tractor.discovery._registry:'
|
||||||
|
'Registrar.register_actor'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_namespace_path_retains_target_ref(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Reuse the client-side target ref when splitting its namespace path.
|
||||||
|
|
||||||
|
`NamespacePath.from_ref()` previously discarded `add_one`, so
|
||||||
|
`to_tuple()` imported and resolved the just-created string again.
|
||||||
|
Replacing `resolve_name()` with a failure proves the retained ref
|
||||||
|
supplies the tuple without a redundant lookup. The public module
|
||||||
|
alias assertion also keeps internal `_api.__name__` authoritative.
|
||||||
|
|
||||||
|
'''
|
||||||
|
target = NamespacePath.from_ref(add_one)
|
||||||
|
|
||||||
|
def fail_resolve(name: str) -> object:
|
||||||
|
raise AssertionError(f'unexpected lookup for {name!r}')
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
msgptr,
|
||||||
|
'resolve_name',
|
||||||
|
fail_resolve,
|
||||||
|
)
|
||||||
|
assert target.to_tuple() == (
|
||||||
|
add_one.__module__,
|
||||||
|
add_one.__name__,
|
||||||
|
)
|
||||||
|
assert to_actor.MODULE == to_actor_api.__name__
|
||||||
|
assert not hasattr(to_actor_api, 'MODULE')
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
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,
|
||||||
|
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.
|
||||||
|
|
||||||
|
Previously `to_actor.run()` treated an actor-runtime cancel ack
|
||||||
|
as process reaping, so the call returned while the child monitor
|
||||||
|
and its `ActorNursery._children` record remained alive until the
|
||||||
|
entire nursery exited. The assertion inside the still-open
|
||||||
|
nursery proves child-process joining and record removal now
|
||||||
|
complete before the one-shot call returns.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async with tractor.open_nursery() as an:
|
||||||
|
assert await to_actor.run(
|
||||||
|
add_one,
|
||||||
|
10,
|
||||||
|
an=an,
|
||||||
|
) == 11
|
||||||
|
assert not an._children
|
||||||
|
|
||||||
|
|
||||||
|
@tractor_test
|
||||||
|
async def test_cancel_ack_failure_hard_reaps_child(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
start_method: str,
|
||||||
|
debug_mode: bool,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Escalate a failed cancel acknowledgement and reap the child.
|
||||||
|
|
||||||
|
`Portal.cancel_actor()` can return `False` when its transport is
|
||||||
|
already closed without confirming runtime cancellation. The old
|
||||||
|
one-shot path ignored that result, released the nursery-wide join
|
||||||
|
gate and then waited forever for a still-running process. This
|
||||||
|
test forces that exact result without cancelling the actor, caps
|
||||||
|
the call to detect the former hang and verifies the child monitor
|
||||||
|
removes its `ActorNursery._children` record before returning.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def cancel_without_ack(
|
||||||
|
portal: tractor.Portal,
|
||||||
|
timeout: float|None = None,
|
||||||
|
raise_on_timeout: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
assert raise_on_timeout
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tractor.Portal,
|
||||||
|
'cancel_actor',
|
||||||
|
cancel_without_ack,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with tractor.open_nursery() as an:
|
||||||
|
with trio.fail_after(5):
|
||||||
|
assert await to_actor.run(
|
||||||
|
add_one,
|
||||||
|
20,
|
||||||
|
an=an,
|
||||||
|
) == 21
|
||||||
|
assert not an._children
|
||||||
|
|
||||||
|
|
||||||
|
def test_late_child_reap_registration_is_released():
|
||||||
|
'''
|
||||||
|
Preserve a nursery-wide reap request across child startup.
|
||||||
|
|
||||||
|
A child monitor can checkpoint while connecting to its parent as
|
||||||
|
the surrounding `ActorNursery` begins teardown. Previously the
|
||||||
|
nursery signalled only already-registered child events, so a
|
||||||
|
monitor registering afterward waited forever. This models that
|
||||||
|
ordering by publishing the nursery-wide request first and proves
|
||||||
|
the later per-child event inherits its set state immediately.
|
||||||
|
|
||||||
|
'''
|
||||||
|
an = object.__new__(tractor.ActorNursery)
|
||||||
|
an._join_procs = trio.Event()
|
||||||
|
an._child_reap_requests = {}
|
||||||
|
an._child_reaped = {}
|
||||||
|
|
||||||
|
an._join_procs.set()
|
||||||
|
reap_request, _ = an._register_child_reap(
|
||||||
|
('late_child', 'uid'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert reap_request.is_set()
|
||||||
|
|
||||||
|
|
||||||
|
@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.
|
||||||
|
|
||||||
|
The low-level `Portal.run_from_ns()` assertion also proves its
|
||||||
|
target kwargs remain separate from the private startup-cancel
|
||||||
|
policy used by context cleanup.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async with tractor.open_nursery() as an:
|
||||||
|
actor = tractor.current_actor()
|
||||||
|
portal: tractor.Portal = await an.start_actor(
|
||||||
|
'one_shot_worker',
|
||||||
|
enable_modules=[
|
||||||
|
__name__,
|
||||||
|
to_actor.MODULE,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
contexts_before = _non_registration_contexts(actor)
|
||||||
|
for i in range(3):
|
||||||
|
assert await to_actor.run(
|
||||||
|
add_one,
|
||||||
|
i,
|
||||||
|
portal=portal,
|
||||||
|
) == i + 1
|
||||||
|
|
||||||
|
assert await portal.run_from_ns(
|
||||||
|
__name__,
|
||||||
|
'echo_startup_control',
|
||||||
|
_cancel_on_startup='target_value',
|
||||||
|
) == 'target_value'
|
||||||
|
assert _non_registration_contexts(actor) == contexts_before
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
i,
|
||||||
|
an=an,
|
||||||
|
name=f'one_shot_{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_partial_placeholder_normalization(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Preserve Python 3.14 `functools.partial` placeholder semantics.
|
||||||
|
|
||||||
|
The test environment runs Python 3.13, so this installs an identity
|
||||||
|
sentinel matching Python 3.14's `functools.Placeholder` API.
|
||||||
|
Interleaved placeholders prove call-time positional arguments are
|
||||||
|
merged in order. Undersupply and a mismatched final target
|
||||||
|
signature both fail locally before actor runtime startup.
|
||||||
|
|
||||||
|
'''
|
||||||
|
placeholder = object()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
to_actor_api.functools,
|
||||||
|
'Placeholder',
|
||||||
|
placeholder,
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
fn = partial(
|
||||||
|
collect_args,
|
||||||
|
placeholder,
|
||||||
|
2,
|
||||||
|
placeholder,
|
||||||
|
)
|
||||||
|
normalized_fn, args, kwargs = to_actor_api._normalize_call(
|
||||||
|
fn,
|
||||||
|
(1, 3, 4),
|
||||||
|
)
|
||||||
|
assert normalized_fn is collect_args
|
||||||
|
assert args == (1, 2, 3, 4)
|
||||||
|
assert kwargs == {}
|
||||||
|
|
||||||
|
with pytest.raises(TypeError, match='Not enough positional'):
|
||||||
|
to_actor_api._normalize_call(fn, (1,))
|
||||||
|
|
||||||
|
with pytest.raises(TypeError, match='too many positional'):
|
||||||
|
to_actor_api._normalize_call(
|
||||||
|
partial(add_one, 1),
|
||||||
|
(2,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_partial_normalization():
|
||||||
|
'''
|
||||||
|
Flatten every retained `functools.partial` layer before RPC.
|
||||||
|
|
||||||
|
CPython normally combines nested partials, but preserves the inner
|
||||||
|
object when it has instance attributes. Unwrapping only the outer
|
||||||
|
layer left a non-namespace-addressable partial as the RPC target.
|
||||||
|
The custom attribute triggers that retained shape; the assertions
|
||||||
|
prove positional ordering and outer-keyword precedence match a
|
||||||
|
direct nested-partial call.
|
||||||
|
|
||||||
|
'''
|
||||||
|
inner = partial(
|
||||||
|
collect_call,
|
||||||
|
1,
|
||||||
|
label='inner',
|
||||||
|
)
|
||||||
|
inner.note = 'retain this partial layer'
|
||||||
|
outer = partial(
|
||||||
|
inner,
|
||||||
|
2,
|
||||||
|
label='outer',
|
||||||
|
)
|
||||||
|
|
||||||
|
fn, args, kwargs = to_actor_api._normalize_call(
|
||||||
|
outer,
|
||||||
|
(3,),
|
||||||
|
)
|
||||||
|
assert fn is collect_call
|
||||||
|
assert args == (1, 2, 3)
|
||||||
|
assert kwargs == {'label': 'outer'}
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
1,
|
||||||
|
portal=object(),
|
||||||
|
an=object(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'placement',
|
||||||
|
['an', 'portal'],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'runtime_kwargs',
|
||||||
|
[
|
||||||
|
{},
|
||||||
|
{'loglevel': 'cancel'},
|
||||||
|
],
|
||||||
|
ids=['empty', 'configured'],
|
||||||
|
)
|
||||||
|
def test_rejects_runtime_kwargs_with_placement(
|
||||||
|
placement: str,
|
||||||
|
runtime_kwargs: dict,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
`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. In particular, an empty dict still means the
|
||||||
|
caller provided this mutually exclusive option; testing
|
||||||
|
both placement modes prevents truthiness checks from
|
||||||
|
accepting it before any actor runtime is started.
|
||||||
|
|
||||||
|
'''
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
trio.run(
|
||||||
|
partial(
|
||||||
|
to_actor.run,
|
||||||
|
add_one,
|
||||||
|
1,
|
||||||
|
**{
|
||||||
|
placement: object(),
|
||||||
|
'runtime_kwargs': runtime_kwargs,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tractor_test
|
||||||
|
async def test_trio_style_args_and_partial_kwargs(
|
||||||
|
start_method: str,
|
||||||
|
debug_mode: bool,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Forward positional args and partial-bound keyword arguments.
|
||||||
|
|
||||||
|
The original API captured every keyword matching an actor
|
||||||
|
control, so ordinary target parameters such as `name`, `portal`,
|
||||||
|
`an` and `runtime_kwargs` could not be called. This test uses a
|
||||||
|
positional-only target argument plus all colliding keyword names.
|
||||||
|
Binding the target keywords with `functools.partial()` proves the
|
||||||
|
Trio-style calling convention keeps target inputs separate from
|
||||||
|
actor controls.
|
||||||
|
|
||||||
|
'''
|
||||||
|
fn = partial(
|
||||||
|
echo_control_names,
|
||||||
|
name='target_name',
|
||||||
|
portal='target_portal',
|
||||||
|
an='target_an',
|
||||||
|
runtime_kwargs='target_runtime_kwargs',
|
||||||
|
)
|
||||||
|
async with tractor.open_nursery() as an:
|
||||||
|
result = await to_actor.run(
|
||||||
|
fn,
|
||||||
|
42,
|
||||||
|
an=an,
|
||||||
|
name='actor_name',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
'value': 42,
|
||||||
|
'name': 'target_name',
|
||||||
|
'portal': 'target_portal',
|
||||||
|
'an': 'target_an',
|
||||||
|
'runtime_kwargs': 'target_runtime_kwargs',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@tractor_test
|
||||||
|
async def test_portal_task_cancelled_with_local_caller(
|
||||||
|
tmp_path: Path,
|
||||||
|
start_method: str,
|
||||||
|
debug_mode: bool,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Couple a reused portal's remote task to its local caller.
|
||||||
|
|
||||||
|
The former `Portal.run()` path abandoned its remote task when the
|
||||||
|
local `to_actor.run()` caller was cancelled. The target writes
|
||||||
|
one file after starting and another from its cancellation
|
||||||
|
`finally`. Cancelling the local task nursery and observing the
|
||||||
|
second file proves `Portal.open_context()` propagated
|
||||||
|
cancellation before the caller exited. A subsequent call proves
|
||||||
|
the caller-owned actor was not cancelled with that task.
|
||||||
|
|
||||||
|
'''
|
||||||
|
started_path = tmp_path / 'started'
|
||||||
|
cancelled_path = tmp_path / 'cancelled'
|
||||||
|
|
||||||
|
async with tractor.open_nursery() as an:
|
||||||
|
actor = tractor.current_actor()
|
||||||
|
portal: tractor.Portal = await an.start_actor(
|
||||||
|
'context_worker',
|
||||||
|
enable_modules=[
|
||||||
|
__name__,
|
||||||
|
to_actor.MODULE,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
contexts_before = _non_registration_contexts(actor)
|
||||||
|
|
||||||
|
async with trio.open_nursery() as tn:
|
||||||
|
tn.start_soon(
|
||||||
|
partial(
|
||||||
|
to_actor.run,
|
||||||
|
mark_task_cancellation,
|
||||||
|
str(started_path),
|
||||||
|
str(cancelled_path),
|
||||||
|
portal=portal,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with trio.fail_after(5):
|
||||||
|
while not started_path.exists():
|
||||||
|
await trio.sleep(0.01)
|
||||||
|
tn.cancel_scope.cancel()
|
||||||
|
|
||||||
|
assert cancelled_path.exists()
|
||||||
|
assert _non_registration_contexts(actor) == contexts_before
|
||||||
|
assert await to_actor.run(
|
||||||
|
add_one,
|
||||||
|
1,
|
||||||
|
portal=portal,
|
||||||
|
) == 2
|
||||||
|
assert _non_registration_contexts(actor) == contexts_before
|
||||||
|
|
||||||
|
await portal.cancel_actor()
|
||||||
|
|
||||||
|
|
||||||
|
@tractor_test
|
||||||
|
async def test_context_trampoline_preserves_module_allowlist(
|
||||||
|
start_method: str,
|
||||||
|
debug_mode: bool,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Keep target resolution behind the actor's RPC module allowlist.
|
||||||
|
|
||||||
|
Loading the target with `NamespacePath.load_ref()` would silently
|
||||||
|
bypass the actor's existing module-exposure boundary. This actor
|
||||||
|
exposes only the trusted trampoline, not the test module; the
|
||||||
|
boxed `ModuleNotExposed` proves the trampoline delegates target
|
||||||
|
resolution to `Actor._get_rpc_func()`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async with tractor.open_nursery() as an:
|
||||||
|
actor = tractor.current_actor()
|
||||||
|
portal: tractor.Portal = await an.start_actor(
|
||||||
|
'restricted_context_worker',
|
||||||
|
enable_modules=[to_actor.MODULE],
|
||||||
|
)
|
||||||
|
contexts_before = _non_registration_contexts(actor)
|
||||||
|
with pytest.raises(RemoteActorError) as excinfo:
|
||||||
|
await to_actor.run(
|
||||||
|
add_one,
|
||||||
|
1,
|
||||||
|
portal=portal,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert excinfo.value.boxed_type is tractor.ModuleNotExposed
|
||||||
|
assert _non_registration_contexts(actor) == contexts_before
|
||||||
|
await portal.cancel_actor()
|
||||||
|
|
||||||
|
|
||||||
|
@tractor_test
|
||||||
|
async def test_portal_requires_context_trampoline(
|
||||||
|
start_method: str,
|
||||||
|
debug_mode: bool,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Require explicit trampoline exposure on a caller-owned actor.
|
||||||
|
|
||||||
|
Automatically exposing the module in every actor weakens the RPC
|
||||||
|
allowlist for actors that never use `to_actor.run()`. A portal to
|
||||||
|
such an actor instead fails with the usual `ModuleNotExposed`,
|
||||||
|
naming the module callers must opt into.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async with tractor.open_nursery() as an:
|
||||||
|
actor = tractor.current_actor()
|
||||||
|
portal: tractor.Portal = await an.start_actor(
|
||||||
|
'no_context_trampoline_worker',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
contexts_before = _non_registration_contexts(actor)
|
||||||
|
with pytest.raises(RemoteActorError) as excinfo:
|
||||||
|
await to_actor.run(
|
||||||
|
add_one,
|
||||||
|
1,
|
||||||
|
portal=portal,
|
||||||
|
)
|
||||||
|
|
||||||
|
err = excinfo.value
|
||||||
|
assert err.boxed_type is tractor.ModuleNotExposed
|
||||||
|
assert to_actor.MODULE in str(err)
|
||||||
|
assert _non_registration_contexts(actor) == contexts_before
|
||||||
|
await portal.cancel_actor()
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
@ -1108,10 +1108,11 @@ class Context:
|
||||||
# NOTE: we're telling the far end actor to cancel a task
|
# NOTE: we're telling the far end actor to cancel a task
|
||||||
# corresponding to *this actor*. The far end local channel
|
# corresponding to *this actor*. The far end local channel
|
||||||
# instance is passed to `Actor._cancel_task()` implicitly.
|
# instance is passed to `Actor._cancel_task()` implicitly.
|
||||||
await self._portal.run_from_ns(
|
await self._portal._run_from_ns(
|
||||||
'self',
|
'self',
|
||||||
'_cancel_task',
|
'_cancel_task',
|
||||||
cid=cid,
|
kwargs={'cid': cid},
|
||||||
|
cancel_on_startup=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
if cs.cancelled_caught:
|
if cs.cancelled_caught:
|
||||||
|
|
@ -2625,10 +2626,7 @@ async def open_context_from_portal(
|
||||||
f'uid: {uid}\n'
|
f'uid: {uid}\n'
|
||||||
f'cid: {ctx.cid}\n'
|
f'cid: {ctx.cid}\n'
|
||||||
)
|
)
|
||||||
portal.actor._contexts.pop(
|
portal.actor._drop_context(ctx)
|
||||||
(uid, ctx.cid),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# XXX revert to prior IPC-task-ctx scope
|
# XXX revert to prior IPC-task-ctx scope
|
||||||
_ctxvar_Context.reset(prior_ctx_tok)
|
_ctxvar_Context.reset(prior_ctx_tok)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -221,15 +221,18 @@ 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
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
async with portal.open_stream_from(
|
||||||
partial( # func to execute in it
|
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}")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -499,6 +499,12 @@ class MsgpackTransport(MsgTransport):
|
||||||
size: bytes = struct.pack("<I", len(bytes_data))
|
size: bytes = struct.pack("<I", len(bytes_data))
|
||||||
try:
|
try:
|
||||||
return await self.stream.send_all(size + bytes_data)
|
return await self.stream.send_all(size + bytes_data)
|
||||||
|
except trio.Cancelled:
|
||||||
|
# `send_all()` may have written a partial frame. The
|
||||||
|
# stream can not safely carry another framed msg.
|
||||||
|
with trio.CancelScope(shield=True):
|
||||||
|
await self.stream.aclose()
|
||||||
|
raise
|
||||||
except (
|
except (
|
||||||
trio.BrokenResourceError,
|
trio.BrokenResourceError,
|
||||||
trio.ClosedResourceError,
|
trio.ClosedResourceError,
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,9 @@ class NamespacePath(str):
|
||||||
) -> NamespacePath:
|
) -> NamespacePath:
|
||||||
|
|
||||||
fqnp: tuple[str, str] = cls._mk_fqnp(ref)
|
fqnp: tuple[str, str] = cls._mk_fqnp(ref)
|
||||||
return cls(':'.join(fqnp))
|
nsp = cls(':'.join(fqnp))
|
||||||
|
nsp._ref = ref
|
||||||
|
return nsp
|
||||||
|
|
||||||
def to_tuple(
|
def to_tuple(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -306,12 +306,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()`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -379,6 +273,38 @@ class Portal:
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def _run_from_ns(
|
||||||
|
self,
|
||||||
|
namespace_path: str,
|
||||||
|
function_name: str,
|
||||||
|
kwargs: dict[str, Any],
|
||||||
|
cancel_on_startup: bool = True,
|
||||||
|
) -> Any:
|
||||||
|
'''
|
||||||
|
Run a namespace target with local startup policy controls.
|
||||||
|
|
||||||
|
'''
|
||||||
|
nsf = NamespacePath(
|
||||||
|
f'{namespace_path}:{function_name}'
|
||||||
|
)
|
||||||
|
ctx: Context = await self.actor.start_remote_task(
|
||||||
|
chan=self.channel,
|
||||||
|
nsf=nsf,
|
||||||
|
kwargs=kwargs,
|
||||||
|
portal=self,
|
||||||
|
cancel_on_startup=cancel_on_startup,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return await ctx._pld_rx.recv_pld(
|
||||||
|
ipc=ctx,
|
||||||
|
expect_msg=Return,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self.actor._drop_context(ctx)
|
||||||
|
if not ctx._rx_chan._closed:
|
||||||
|
with trio.CancelScope(shield=True):
|
||||||
|
await ctx._rx_chan.aclose()
|
||||||
|
|
||||||
# TODO: do we still need this for low level `Actor`-runtime
|
# TODO: do we still need this for low level `Actor`-runtime
|
||||||
# method calls or can we also remove it?
|
# method calls or can we also remove it?
|
||||||
async def run_from_ns(
|
async def run_from_ns(
|
||||||
|
|
@ -404,18 +330,10 @@ class Portal:
|
||||||
|
|
||||||
'''
|
'''
|
||||||
__runtimeframe__: int = 1 # noqa
|
__runtimeframe__: int = 1 # noqa
|
||||||
nsf = NamespacePath(
|
return await self._run_from_ns(
|
||||||
f'{namespace_path}:{function_name}'
|
namespace_path,
|
||||||
)
|
function_name,
|
||||||
ctx: Context = await self.actor.start_remote_task(
|
|
||||||
chan=self.channel,
|
|
||||||
nsf=nsf,
|
|
||||||
kwargs=kwargs,
|
kwargs=kwargs,
|
||||||
portal=self,
|
|
||||||
)
|
|
||||||
return await ctx._pld_rx.recv_pld(
|
|
||||||
ipc=ctx,
|
|
||||||
expect_msg=Return,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: factor this out into a `.highlevel` API-wrapper that uses
|
# TODO: factor this out into a `.highlevel` API-wrapper that uses
|
||||||
|
|
|
||||||
|
|
@ -879,10 +879,8 @@ async def _invoke(
|
||||||
# don't pop the local context until we know the
|
# don't pop the local context until we know the
|
||||||
# associated child isn't in debug any more
|
# associated child isn't in debug any more
|
||||||
await debug.maybe_wait_for_debugger()
|
await debug.maybe_wait_for_debugger()
|
||||||
ctx: Context = actor._contexts.pop((
|
dropped_ctx: Context|None = actor._drop_context(ctx)
|
||||||
chan.aid.uid,
|
assert dropped_ctx is ctx
|
||||||
cid,
|
|
||||||
))
|
|
||||||
|
|
||||||
logmeth: Callable = log.runtime
|
logmeth: Callable = log.runtime
|
||||||
merr: Exception|None = ctx.maybe_error
|
merr: Exception|None = ctx.maybe_error
|
||||||
|
|
|
||||||
|
|
@ -600,14 +600,21 @@ class Actor:
|
||||||
# - cancel_rpc_tasks(),
|
# - cancel_rpc_tasks(),
|
||||||
# - _cancel_task(),
|
# - _cancel_task(),
|
||||||
#
|
#
|
||||||
def _get_rpc_func(self, ns, funcname):
|
def _get_rpc_func(
|
||||||
|
self,
|
||||||
|
ns: str,
|
||||||
|
funcname: str,
|
||||||
|
):
|
||||||
'''
|
'''
|
||||||
Try to lookup and return a target RPC func from the
|
Try to lookup and return a target RPC func from the
|
||||||
post-fork enabled module set.
|
post-fork enabled module set.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
try:
|
try:
|
||||||
return getattr(self._mods[ns], funcname)
|
return getattr(
|
||||||
|
self._mods[ns],
|
||||||
|
funcname,
|
||||||
|
)
|
||||||
except KeyError as err:
|
except KeyError as err:
|
||||||
mne = ModuleNotExposed(*err.args)
|
mne = ModuleNotExposed(*err.args)
|
||||||
|
|
||||||
|
|
@ -753,6 +760,23 @@ class Actor:
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
def _drop_context(
|
||||||
|
self,
|
||||||
|
ctx: Context,
|
||||||
|
) -> Context|None:
|
||||||
|
'''
|
||||||
|
Remove `ctx` from this actor's IPC context registry.
|
||||||
|
|
||||||
|
Teardown paths can converge after normal return, cancellation
|
||||||
|
or startup failure, so registry removal is idempotent.
|
||||||
|
|
||||||
|
'''
|
||||||
|
peer_uid: tuple[str, str] = ctx.chan.aid.uid
|
||||||
|
return self._contexts.pop(
|
||||||
|
(peer_uid, ctx.cid),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
async def start_remote_task(
|
async def start_remote_task(
|
||||||
self,
|
self,
|
||||||
chan: Channel,
|
chan: Channel,
|
||||||
|
|
@ -767,6 +791,7 @@ class Actor:
|
||||||
allow_overruns: bool = False,
|
allow_overruns: bool = False,
|
||||||
load_nsf: bool = False,
|
load_nsf: bool = False,
|
||||||
ack_timeout: float = float('inf'),
|
ack_timeout: float = float('inf'),
|
||||||
|
cancel_on_startup: bool = True,
|
||||||
|
|
||||||
) -> Context:
|
) -> Context:
|
||||||
'''
|
'''
|
||||||
|
|
@ -818,13 +843,17 @@ class Actor:
|
||||||
|
|
||||||
f'{pretty_struct.pformat(msg)}'
|
f'{pretty_struct.pformat(msg)}'
|
||||||
)
|
)
|
||||||
|
start_published: bool = False
|
||||||
|
try:
|
||||||
await chan.send(msg)
|
await chan.send(msg)
|
||||||
|
start_published = True
|
||||||
|
|
||||||
# NOTE wait on first `StartAck` response msg and validate;
|
# NOTE wait on first `StartAck` response msg and validate;
|
||||||
# this should be immediate and does not (yet) wait for the
|
# this should be immediate and does not (yet) wait for the
|
||||||
# remote child task to sync via `Context.started()`.
|
# remote child task to sync via `Context.started()`.
|
||||||
with trio.fail_after(ack_timeout):
|
with trio.fail_after(ack_timeout):
|
||||||
first_msg: msgtypes.StartAck = await ctx._rx_chan.receive()
|
first_msg: msgtypes.StartAck = await ctx._rx_chan.receive()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
functype: str = first_msg.functype
|
functype: str = first_msg.functype
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
|
|
@ -836,9 +865,45 @@ class Actor:
|
||||||
'context',
|
'context',
|
||||||
):
|
):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'Invalid `StartAck.functype: str = {first_msg!r}` ??'
|
f'Invalid `StartAck.functype: str = '
|
||||||
|
f'{first_msg!r}` ??'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except BaseException as startup_err:
|
||||||
|
with trio.CancelScope(shield=True):
|
||||||
|
# `MsgpackTransport.send()` closes its stream when
|
||||||
|
# cancellation interrupts the length-prefixed write
|
||||||
|
# because an unknown prefix may already be sent. A
|
||||||
|
# connected channel means cancellation happened before
|
||||||
|
# that write or after it completed, so `_cancel_task`
|
||||||
|
# is protocol-safe (and a no-op if `Start` was unsent).
|
||||||
|
if (
|
||||||
|
cancel_on_startup
|
||||||
|
and
|
||||||
|
(
|
||||||
|
start_published
|
||||||
|
or (
|
||||||
|
isinstance(startup_err, trio.Cancelled)
|
||||||
|
and
|
||||||
|
chan.connected()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
await ctx.cancel()
|
||||||
|
except BaseException as cancel_err:
|
||||||
|
log.warning(
|
||||||
|
'Failed to cancel RPC task during '
|
||||||
|
'startup?\n'
|
||||||
|
f'{cancel_err!r}\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
self._drop_context(ctx)
|
||||||
|
if not ctx._rx_chan._closed:
|
||||||
|
await ctx._rx_chan.aclose()
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
ctx._remote_func_type = functype
|
ctx._remote_func_type = functype
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
@ -90,7 +89,7 @@ async def _try_cancel_then_kill(
|
||||||
Sends a graceful actor-runtime cancel-RPC via
|
Sends a graceful actor-runtime cancel-RPC via
|
||||||
`Portal.cancel_actor(raise_on_timeout=True)`. If the bounded-wait
|
`Portal.cancel_actor(raise_on_timeout=True)`. If the bounded-wait
|
||||||
expires before the peer ack's, `ActorTooSlowError` is raised and
|
expires before the peer ack's, `ActorTooSlowError` is raised and
|
||||||
we escalate via `proc.terminate()` (SIGTERM) per SC-discipline:
|
we escalate via `proc.kill()` per SC-discipline:
|
||||||
|
|
||||||
graceful cancel-req -> bounded wait -> hard-kill
|
graceful cancel-req -> bounded wait -> hard-kill
|
||||||
|
|
||||||
|
|
@ -102,11 +101,9 @@ async def _try_cancel_then_kill(
|
||||||
the wider write-up.
|
the wider write-up.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
# XXX, do NOT escalate to `proc.terminate()` while ANY of
|
# XXX, delay hard-kill escalation while any debugger guard
|
||||||
# the following are true — SIGTERM-ing a sub would tear
|
# below is active. Killing the sub immediately would tear down
|
||||||
# down its sub-tree including any descendant proxying
|
# its tree and clobber an actor proxying a REPL session:
|
||||||
# stdio to/from a REPL-locked actor, clobbering the user's
|
|
||||||
# debug session:
|
|
||||||
#
|
#
|
||||||
# - `Lock.ctx_in_debug is not None`: most precise — some
|
# - `Lock.ctx_in_debug is not None`: most precise — some
|
||||||
# actor in the tree is currently REPL-locked. Set in the
|
# actor in the tree is currently REPL-locked. Set in the
|
||||||
|
|
@ -122,7 +119,7 @@ async def _try_cancel_then_kill(
|
||||||
# child.
|
# child.
|
||||||
#
|
#
|
||||||
# - `debug_mode_active`: this nursery has at least one
|
# - `debug_mode_active`: this nursery has at least one
|
||||||
# child started with an explicit `debug_mode=` arg
|
# child started with an explicit `debug_mode=True` arg
|
||||||
# (`ActorNursery._at_least_one_child_in_debug`). Catches
|
# (`ActorNursery._at_least_one_child_in_debug`). Catches
|
||||||
# the case where root is NOT in debug-mode but a
|
# the case where root is NOT in debug-mode but a
|
||||||
# nursery-direct child opted in.
|
# nursery-direct child opted in.
|
||||||
|
|
@ -132,36 +129,57 @@ async def _try_cancel_then_kill(
|
||||||
# mutated by per-child `debug_mode=True`). ORing covers
|
# mutated by per-child `debug_mode=True`). ORing covers
|
||||||
# every flavor without false-positively skipping
|
# every flavor without false-positively skipping
|
||||||
# legitimate hard-kill paths in non-debug trees.
|
# legitimate hard-kill paths in non-debug trees.
|
||||||
if (
|
debug_protected: bool = (
|
||||||
debug.Lock.ctx_in_debug is not None
|
debug.Lock.ctx_in_debug is not None
|
||||||
or
|
or
|
||||||
_state._runtime_vars.get('_debug_mode', False)
|
_state._runtime_vars.get('_debug_mode', False)
|
||||||
or
|
or
|
||||||
debug_mode_active
|
debug_mode_active
|
||||||
):
|
)
|
||||||
await portal.cancel_actor()
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await portal.cancel_actor(raise_on_timeout=True)
|
cancelled: bool = await portal.cancel_actor(
|
||||||
|
raise_on_timeout=not debug_protected,
|
||||||
|
)
|
||||||
|
if not cancelled:
|
||||||
|
if debug_protected:
|
||||||
|
await debug.maybe_wait_for_debugger(
|
||||||
|
child_in_debug=(
|
||||||
|
debug_mode_active
|
||||||
|
or
|
||||||
|
debug.Lock.ctx_in_debug is not None
|
||||||
|
),
|
||||||
|
header_msg=(
|
||||||
|
'Delaying subproc hard-reap while '
|
||||||
|
'debugger locked..\n'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
peer_id: str = portal.channel.aid.reprol()
|
||||||
|
raise ActorTooSlowError(
|
||||||
|
f'Peer {peer_id} disconnected before '
|
||||||
|
f'acknowledging its `Actor.cancel()` RPC'
|
||||||
|
)
|
||||||
|
|
||||||
except ActorTooSlowError as too_slow:
|
except ActorTooSlowError as too_slow:
|
||||||
log.error(
|
log.error(
|
||||||
f'Cancel-ack TIMED OUT for sub-actor\n'
|
f'Cancel-ack TIMED OUT for sub-actor\n'
|
||||||
f' uid: {subactor.aid.reprol()!r}\n'
|
f' uid: {subactor.aid.reprol()!r}\n'
|
||||||
f' reason: {too_slow}\n'
|
f' reason: {too_slow}\n'
|
||||||
f'-> escalating to `proc.terminate()` (hard-kill)\n'
|
f'-> escalating to `proc.kill()` (hard-reap)\n'
|
||||||
)
|
)
|
||||||
# XXX, the `subint` backend stores an `int` interp-id in the
|
# XXX, the `subint` backend stores an `int` interp-id in the
|
||||||
# `proc` slot (not a `Process`), so it has no `.terminate()`.
|
# `proc` slot (not a `Process`), so it has no `.kill()`.
|
||||||
# Guard here so a cancel-ack timeout doesn't `AttributeError`
|
# Guard here so a cancel-ack timeout doesn't `AttributeError`
|
||||||
# once that backend lands; its hard-kill path is a TODO.
|
# once that backend lands; its hard-kill path is a TODO.
|
||||||
if hasattr(proc, 'terminate'):
|
if hasattr(proc, 'kill'):
|
||||||
proc.terminate()
|
if proc.poll() is None:
|
||||||
|
proc.kill()
|
||||||
else:
|
else:
|
||||||
log.error(
|
log.error(
|
||||||
f'Cannot hard-kill sub-actor — backend proc-handle '
|
f'Cannot hard-kill sub-actor — backend proc-handle '
|
||||||
f'{proc!r} ({type(proc).__name__!r}) has no '
|
f'{proc!r} ({type(proc).__name__!r}) has no '
|
||||||
f'`.terminate()`!\n'
|
f'`.kill()`!\n'
|
||||||
f' uid: {subactor.aid.reprol()!r}\n'
|
f' uid: {subactor.aid.reprol()!r}\n'
|
||||||
f'TODO: per-backend cancel-escalation.\n'
|
f'TODO: per-backend cancel-escalation.\n'
|
||||||
)
|
)
|
||||||
|
|
@ -199,7 +217,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],
|
||||||
|
|
||||||
|
|
@ -220,6 +237,14 @@ class ActorNursery:
|
||||||
] = {}
|
] = {}
|
||||||
|
|
||||||
self._join_procs = trio.Event()
|
self._join_procs = trio.Event()
|
||||||
|
self._child_reap_requests: dict[
|
||||||
|
tuple[str, str],
|
||||||
|
trio.Event,
|
||||||
|
] = {}
|
||||||
|
self._child_reaped: dict[
|
||||||
|
tuple[str, str],
|
||||||
|
trio.Event,
|
||||||
|
] = {}
|
||||||
self._at_least_one_child_in_debug: bool = False
|
self._at_least_one_child_in_debug: bool = False
|
||||||
self.errors = errors
|
self.errors = errors
|
||||||
self._scope_error: BaseException|None = None
|
self._scope_error: BaseException|None = None
|
||||||
|
|
@ -233,16 +258,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
|
||||||
|
|
@ -284,6 +299,79 @@ class ActorNursery:
|
||||||
# self._cancelled_caught
|
# self._cancelled_caught
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _register_child_reap(
|
||||||
|
self,
|
||||||
|
uid: tuple[str, str],
|
||||||
|
) -> tuple[trio.Event, trio.Event]:
|
||||||
|
'''
|
||||||
|
Register a child monitor's process-reap events.
|
||||||
|
|
||||||
|
'''
|
||||||
|
reap_request = trio.Event()
|
||||||
|
reaped = trio.Event()
|
||||||
|
self._child_reap_requests[uid] = reap_request
|
||||||
|
self._child_reaped[uid] = reaped
|
||||||
|
if self._join_procs.is_set():
|
||||||
|
reap_request.set()
|
||||||
|
return reap_request, reaped
|
||||||
|
|
||||||
|
def _request_reap_all(self) -> None:
|
||||||
|
'''
|
||||||
|
Release every child monitor into its process-join phase.
|
||||||
|
|
||||||
|
'''
|
||||||
|
self._join_procs.set()
|
||||||
|
for reap_request in tuple(
|
||||||
|
self._child_reap_requests.values()
|
||||||
|
):
|
||||||
|
reap_request.set()
|
||||||
|
|
||||||
|
def _mark_child_reaped(
|
||||||
|
self,
|
||||||
|
uid: tuple[str, str],
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Publish completed child-process teardown to its waiter.
|
||||||
|
|
||||||
|
'''
|
||||||
|
self._children.pop(uid, None)
|
||||||
|
self._child_reap_requests.pop(uid, None)
|
||||||
|
reaped: trio.Event|None = self._child_reaped.pop(
|
||||||
|
uid,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if reaped is not None:
|
||||||
|
reaped.set()
|
||||||
|
|
||||||
|
async def _cancel_and_reap_child(
|
||||||
|
self,
|
||||||
|
portal: Portal,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Cancel, join and unregister one nursery-owned child.
|
||||||
|
|
||||||
|
'''
|
||||||
|
uid: tuple[str, str] = portal.channel.aid.uid
|
||||||
|
child_entry = self._children.get(uid)
|
||||||
|
if child_entry is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
subactor, proc, _ = child_entry
|
||||||
|
reap_request: trio.Event = self._child_reap_requests[uid]
|
||||||
|
reaped: trio.Event = self._child_reaped[uid]
|
||||||
|
|
||||||
|
with trio.CancelScope(shield=True):
|
||||||
|
try:
|
||||||
|
await _try_cancel_then_kill(
|
||||||
|
portal,
|
||||||
|
proc,
|
||||||
|
subactor,
|
||||||
|
self._at_least_one_child_in_debug,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
reap_request.set()
|
||||||
|
await reaped.wait()
|
||||||
|
|
||||||
async def start_actor(
|
async def start_actor(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
|
|
@ -298,11 +386,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:
|
||||||
|
|
@ -333,7 +416,7 @@ class ActorNursery:
|
||||||
# allow setting debug policy per actor
|
# allow setting debug policy per actor
|
||||||
if debug_mode is not None:
|
if debug_mode is not None:
|
||||||
_rtv['_debug_mode'] = debug_mode
|
_rtv['_debug_mode'] = debug_mode
|
||||||
self._at_least_one_child_in_debug = True
|
self._at_least_one_child_in_debug |= debug_mode
|
||||||
|
|
||||||
enable_modules = list(enable_modules or [])
|
enable_modules = list(enable_modules or [])
|
||||||
proc_kwargs = dict(proc_kwargs or {})
|
proc_kwargs = dict(proc_kwargs or {})
|
||||||
|
|
@ -364,10 +447,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 +463,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,
|
||||||
|
|
@ -478,7 +485,7 @@ class ActorNursery:
|
||||||
|
|
||||||
# TODO: impl a repr for spawn more compact
|
# TODO: impl a repr for spawn more compact
|
||||||
# then `._children`..
|
# then `._children`..
|
||||||
children: dict = self._children
|
children: tuple = tuple(self._children.values())
|
||||||
child_count: int = len(children)
|
child_count: int = len(children)
|
||||||
msg: str = f'Cancelling actor nursery with {child_count} children\n'
|
msg: str = f'Cancelling actor nursery with {child_count} children\n'
|
||||||
|
|
||||||
|
|
@ -497,7 +504,7 @@ class ActorNursery:
|
||||||
subactor,
|
subactor,
|
||||||
proc,
|
proc,
|
||||||
portal,
|
portal,
|
||||||
) in children.values():
|
) in children:
|
||||||
|
|
||||||
# TODO: are we ever even going to use this or
|
# TODO: are we ever even going to use this or
|
||||||
# is the spawning backend responsible for such
|
# is the spawning backend responsible for such
|
||||||
|
|
@ -517,7 +524,9 @@ class ActorNursery:
|
||||||
await event.wait()
|
await event.wait()
|
||||||
|
|
||||||
# channel/portal should now be up
|
# channel/portal should now be up
|
||||||
_, _, portal = children[subactor.aid.uid]
|
_, _, portal = self._children[
|
||||||
|
subactor.aid.uid
|
||||||
|
]
|
||||||
|
|
||||||
# XXX should be impossible to get here
|
# XXX should be impossible to get here
|
||||||
# unless method was called from within
|
# unless method was called from within
|
||||||
|
|
@ -564,14 +573,14 @@ class ActorNursery:
|
||||||
subactor,
|
subactor,
|
||||||
proc,
|
proc,
|
||||||
portal,
|
portal,
|
||||||
) in children.values():
|
) in children:
|
||||||
log.warning(f"Hard killing process {proc}")
|
log.warning(f"Hard killing process {proc}")
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
else:
|
else:
|
||||||
self._cancelled_caught
|
self._cancelled_caught
|
||||||
|
|
||||||
# mark ourselves as having (tried to have) cancelled all subactors
|
# mark ourselves as having (tried to have) cancelled all subactors
|
||||||
self._join_procs.set()
|
self._request_reap_all()
|
||||||
|
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
|
|
@ -584,40 +593,20 @@ 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,
|
||||||
):
|
|
||||||
try:
|
|
||||||
# This is the inner level "run in actor" nursery. It is
|
|
||||||
# awaited first since actors spawned in this way (using
|
|
||||||
# `ActorNusery.run_in_actor()`) are expected to only
|
|
||||||
# return a single result and then complete (i.e. be canclled
|
|
||||||
# gracefully). Errors collected from these actors are
|
|
||||||
# immediately raised for handling by a supervisor strategy.
|
|
||||||
# As such if the strategy propagates any error(s) upwards
|
|
||||||
# the above "daemon actor" nursery will be notified.
|
|
||||||
async with (
|
|
||||||
collapse_eg(),
|
|
||||||
trio.open_nursery() as ria_nursery,
|
|
||||||
):
|
):
|
||||||
an = ActorNursery(
|
an = ActorNursery(
|
||||||
actor,
|
actor,
|
||||||
ria_nursery,
|
|
||||||
da_nursery,
|
da_nursery,
|
||||||
errors
|
errors
|
||||||
)
|
)
|
||||||
|
|
@ -633,11 +622,21 @@ async def _open_and_supervise_one_cancels_all_nursery(
|
||||||
'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._request_reap_all()
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -652,7 +651,7 @@ async def _open_and_supervise_one_cancels_all_nursery(
|
||||||
# if the caller's scope errored then we activate our
|
# if the caller's scope errored then we activate our
|
||||||
# one-cancels-all supervisor strategy (don't
|
# one-cancels-all supervisor strategy (don't
|
||||||
# worry more are coming).
|
# worry more are coming).
|
||||||
an._join_procs.set()
|
an._request_reap_all()
|
||||||
|
|
||||||
# XXX NOTE XXX: hypothetically an error could
|
# XXX NOTE XXX: hypothetically an error could
|
||||||
# be raised and then a cancel signal shows up
|
# be raised and then a cancel signal shows up
|
||||||
|
|
@ -660,12 +659,12 @@ async def _open_and_supervise_one_cancels_all_nursery(
|
||||||
# block here might not complete? For now,
|
# block here might not complete? For now,
|
||||||
# shield both.
|
# shield both.
|
||||||
with trio.CancelScope(shield=True):
|
with trio.CancelScope(shield=True):
|
||||||
etype: type = type(inner_err)
|
etype: type = type(_scope_err)
|
||||||
if etype in (
|
if etype in (
|
||||||
trio.Cancelled,
|
trio.Cancelled,
|
||||||
KeyboardInterrupt,
|
KeyboardInterrupt,
|
||||||
) or (
|
) or (
|
||||||
is_multi_cancelled(inner_err)
|
is_multi_cancelled(_scope_err)
|
||||||
):
|
):
|
||||||
log.cancel(
|
log.cancel(
|
||||||
f'Actor-nursery cancelled by {etype}\n\n'
|
f'Actor-nursery cancelled by {etype}\n\n'
|
||||||
|
|
@ -682,7 +681,7 @@ async def _open_and_supervise_one_cancels_all_nursery(
|
||||||
log.cancel(
|
log.cancel(
|
||||||
'Actor-nursery caught remote cancellation\n'
|
'Actor-nursery caught remote cancellation\n'
|
||||||
'\n'
|
'\n'
|
||||||
f'{inner_err.tb_str}'
|
f'{_scope_err.tb_str}'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
log.exception(
|
log.exception(
|
||||||
|
|
@ -701,50 +700,11 @@ async def _open_and_supervise_one_cancels_all_nursery(
|
||||||
# cancel all subactors
|
# cancel all subactors
|
||||||
await an.cancel()
|
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(
|
|
||||||
child_in_debug=an._at_least_one_child_in_debug
|
|
||||||
)
|
|
||||||
|
|
||||||
# If actor-local error was raised while waiting on
|
|
||||||
# ".run_in_actor()" actors then we also want to cancel all
|
|
||||||
# remaining sub-actors (due to our lone strategy:
|
|
||||||
# one-cancels-all).
|
|
||||||
if an._children:
|
|
||||||
log.cancel(
|
|
||||||
'Actor-nursery cancelling due error type:\n'
|
|
||||||
f'{outer_err}\n'
|
|
||||||
)
|
|
||||||
with trio.CancelScope(shield=True):
|
|
||||||
await an.cancel()
|
|
||||||
raise
|
|
||||||
|
|
||||||
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):
|
||||||
|
|
@ -788,15 +748,13 @@ async def open_nursery(
|
||||||
Create and yield a new ``ActorNursery`` to be used for spawning
|
Create and yield a new ``ActorNursery`` to be used for spawning
|
||||||
structured concurrent subactors.
|
structured concurrent subactors.
|
||||||
|
|
||||||
When an actor is spawned a new trio task is started which
|
When an actor is spawned a new trio task invokes one of the
|
||||||
invokes one of the process spawning backends to create and start
|
process spawning backends to create and start a new subprocess.
|
||||||
a new subprocess. These tasks are started by one of two nurseries
|
These tasks are started in the supervisor's process nursery.
|
||||||
detailed below. The reason for spawning processes from within
|
Spawning from a task is required because ``trio_run_in_process``
|
||||||
a new task is because ``trio_run_in_process`` itself creates a new
|
creates an internal nursery which the opening task **must** close;
|
||||||
internal nursery and the same task that opens a nursery **must**
|
this also makes each task's cancellation scope correspond to its
|
||||||
close it. It turns out this approach is probably more correct
|
spawned subactor.
|
||||||
anyway since it is more clear from the following nested nurseries
|
|
||||||
which cancellation scopes correspond to each spawned subactor set.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
__tracebackhide__: bool = hide_tb
|
__tracebackhide__: bool = hide_tb
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
@ -170,13 +169,16 @@ async def mp_proc(
|
||||||
# any process we may have started.
|
# any process we may have started.
|
||||||
|
|
||||||
portal = Portal(chan)
|
portal = Portal(chan)
|
||||||
|
reap_request, _ = actor_nursery._register_child_reap(
|
||||||
|
subactor.aid.uid,
|
||||||
|
)
|
||||||
actor_nursery._children[subactor.aid.uid] = (subactor, proc, portal)
|
actor_nursery._children[subactor.aid.uid] = (subactor, proc, portal)
|
||||||
|
|
||||||
# unblock parent task
|
# unblock parent task
|
||||||
task_status.started(portal)
|
task_status.started(portal)
|
||||||
|
|
||||||
# wait for ``ActorNursery`` block to signal that
|
# wait for this child or its `ActorNursery` to signal that
|
||||||
# subprocesses can be waited upon.
|
# the subprocess can be joined.
|
||||||
# This is required to ensure synchronization
|
# This is required to ensure synchronization
|
||||||
# with user code that may want to manually await results
|
# with user code that may want to manually await results
|
||||||
# from nursery spawned sub-actors. We don't want the
|
# from nursery spawned sub-actors. We don't want the
|
||||||
|
|
@ -185,16 +187,7 @@ async def mp_proc(
|
||||||
# nursery block closes do we allow subactor results to be
|
# nursery block closes do we allow subactor results to be
|
||||||
# awaited and reported upwards to the supervisor.
|
# awaited and reported upwards to the supervisor.
|
||||||
with trio.CancelScope(shield=True):
|
with trio.CancelScope(shield=True):
|
||||||
await actor_nursery._join_procs.wait()
|
await reap_request.wait()
|
||||||
|
|
||||||
async with trio.open_nursery() as nursery:
|
|
||||||
if portal in actor_nursery._cancel_after_result_on_exit:
|
|
||||||
nursery.start_soon(
|
|
||||||
cancel_on_completion,
|
|
||||||
portal,
|
|
||||||
subactor,
|
|
||||||
errors
|
|
||||||
)
|
|
||||||
|
|
||||||
# This is a "soft" (cancellable) join/reap which
|
# This is a "soft" (cancellable) join/reap which
|
||||||
# will remote cancel the actor on a ``trio.Cancelled``
|
# will remote cancel the actor on a ``trio.Cancelled``
|
||||||
|
|
@ -205,13 +198,6 @@ async def mp_proc(
|
||||||
portal
|
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
|
||||||
if proc.is_alive():
|
if proc.is_alive():
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
||||||
|
|
@ -446,6 +354,7 @@ async def new_proc(
|
||||||
# mark the new actor with the global spawn method
|
# mark the new actor with the global spawn method
|
||||||
subactor._spawn_method = _spawn_method
|
subactor._spawn_method = _spawn_method
|
||||||
|
|
||||||
|
try:
|
||||||
await target(
|
await target(
|
||||||
name,
|
name,
|
||||||
actor_nursery,
|
actor_nursery,
|
||||||
|
|
@ -458,11 +367,13 @@ async def new_proc(
|
||||||
task_status=task_status,
|
task_status=task_status,
|
||||||
proc_kwargs=proc_kwargs
|
proc_kwargs=proc_kwargs
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
actor_nursery._mark_child_reaped(subactor.aid.uid)
|
||||||
|
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
@ -161,6 +160,9 @@ async def trio_proc(
|
||||||
assert proc
|
assert proc
|
||||||
|
|
||||||
portal = Portal(chan)
|
portal = Portal(chan)
|
||||||
|
reap_request, _ = actor_nursery._register_child_reap(
|
||||||
|
subactor.aid.uid,
|
||||||
|
)
|
||||||
actor_nursery._children[subactor.aid.uid] = (
|
actor_nursery._children[subactor.aid.uid] = (
|
||||||
subactor,
|
subactor,
|
||||||
proc,
|
proc,
|
||||||
|
|
@ -191,18 +193,10 @@ async def trio_proc(
|
||||||
# resume caller at next checkpoint now that child is up
|
# resume caller at next checkpoint now that child is up
|
||||||
task_status.started(portal)
|
task_status.started(portal)
|
||||||
|
|
||||||
# wait for ActorNursery.wait() to be called
|
# wait for this child or its `ActorNursery` to request
|
||||||
|
# process joining.
|
||||||
with trio.CancelScope(shield=True):
|
with trio.CancelScope(shield=True):
|
||||||
await actor_nursery._join_procs.wait()
|
await reap_request.wait()
|
||||||
|
|
||||||
async with trio.open_nursery() as nursery:
|
|
||||||
if portal in actor_nursery._cancel_after_result_on_exit:
|
|
||||||
nursery.start_soon(
|
|
||||||
cancel_on_completion,
|
|
||||||
portal,
|
|
||||||
subactor,
|
|
||||||
errors
|
|
||||||
)
|
|
||||||
|
|
||||||
# This is a "soft" (cancellable) join/reap which
|
# This is a "soft" (cancellable) join/reap which
|
||||||
# will remote cancel the actor on a ``trio.Cancelled``
|
# will remote cancel the actor on a ``trio.Cancelled``
|
||||||
|
|
@ -213,14 +207,6 @@ async def trio_proc(
|
||||||
portal
|
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
|
||||||
# allowed! Do this **after** cancellation/teardown to avoid
|
# allowed! Do this **after** cancellation/teardown to avoid
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
# 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.
|
||||||
|
Target arguments follow Trio's positional convention; use
|
||||||
|
`functools.partial()` to bind target keyword arguments.
|
||||||
|
|
||||||
|
The "spiritual successor" to (and replacement of) the removed
|
||||||
|
legacy `ActorNursery.run_in_actor()` API; see
|
||||||
|
https://github.com/goodboy/tractor/issues/477
|
||||||
|
|
||||||
|
'''
|
||||||
|
from . import _api as _api
|
||||||
|
from ._api import (
|
||||||
|
run as run,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MODULE: str = _api.__name__
|
||||||
|
|
@ -0,0 +1,365 @@
|
||||||
|
# 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.open_context()` for scheduling the lone remote
|
||||||
|
task with linked cancellation 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 functools
|
||||||
|
import inspect
|
||||||
|
from typing import (
|
||||||
|
Any,
|
||||||
|
Awaitable,
|
||||||
|
Callable,
|
||||||
|
TYPE_CHECKING,
|
||||||
|
TypeVar,
|
||||||
|
TypeVarTuple,
|
||||||
|
Unpack,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .._context import (
|
||||||
|
Context,
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
from ..msg.ptr import NamespacePath
|
||||||
|
from ..runtime._state import current_actor
|
||||||
|
from ..runtime._supervise import (
|
||||||
|
ActorNursery,
|
||||||
|
open_nursery,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..discovery._addr import UnwrappedAddress
|
||||||
|
from ..runtime._portal import Portal
|
||||||
|
|
||||||
|
|
||||||
|
ArgsT = TypeVarTuple('ArgsT')
|
||||||
|
RetT = TypeVar('RetT')
|
||||||
|
|
||||||
|
|
||||||
|
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.open_context()` 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!'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_call(
|
||||||
|
fn: Callable,
|
||||||
|
args: tuple[Any, ...],
|
||||||
|
) -> tuple[
|
||||||
|
Callable,
|
||||||
|
tuple[Any, ...],
|
||||||
|
dict[str, Any],
|
||||||
|
]:
|
||||||
|
'''
|
||||||
|
Normalize Trio-style positional and partial-bound arguments.
|
||||||
|
|
||||||
|
Actor calls must send a namespace-addressable base function and
|
||||||
|
serializable inputs to another process, so decompose partials and
|
||||||
|
validate their complete call signature before runtime startup.
|
||||||
|
|
||||||
|
'''
|
||||||
|
kwargs: dict[str, Any] = {}
|
||||||
|
while isinstance(fn, functools.partial):
|
||||||
|
partial_args: tuple[Any, ...] = fn.args
|
||||||
|
|
||||||
|
# `functools.Placeholder` was added in Python 3.14. Drop
|
||||||
|
# this `getattr()` guard once 3.14 is the minimum version.
|
||||||
|
placeholder = getattr(
|
||||||
|
functools,
|
||||||
|
'Placeholder',
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
placeholder is not None
|
||||||
|
and
|
||||||
|
any(
|
||||||
|
arg is placeholder
|
||||||
|
for arg in partial_args
|
||||||
|
)
|
||||||
|
):
|
||||||
|
call_args = iter(args)
|
||||||
|
merged_args: list[Any] = []
|
||||||
|
for arg in partial_args:
|
||||||
|
if arg is placeholder:
|
||||||
|
try:
|
||||||
|
arg = next(call_args)
|
||||||
|
except StopIteration:
|
||||||
|
raise TypeError(
|
||||||
|
'Not enough positional arguments to '
|
||||||
|
'fill `functools.Placeholder`s'
|
||||||
|
) from None
|
||||||
|
|
||||||
|
merged_args.append(arg)
|
||||||
|
|
||||||
|
merged_args.extend(call_args)
|
||||||
|
args = tuple(merged_args)
|
||||||
|
else:
|
||||||
|
args = partial_args + args
|
||||||
|
|
||||||
|
partial_kwargs = dict(fn.keywords or {})
|
||||||
|
partial_kwargs.update(kwargs)
|
||||||
|
kwargs = partial_kwargs
|
||||||
|
fn = fn.func
|
||||||
|
|
||||||
|
_validate_one_shot_fn(fn)
|
||||||
|
inspect.signature(fn).bind(*args, **kwargs)
|
||||||
|
return fn, args, kwargs
|
||||||
|
|
||||||
|
|
||||||
|
@context
|
||||||
|
async def _invoke_one_shot(
|
||||||
|
ctx: Context,
|
||||||
|
namespace: str,
|
||||||
|
funcname: str,
|
||||||
|
args: list[Any],
|
||||||
|
kwargs: dict[str, Any],
|
||||||
|
) -> Any:
|
||||||
|
'''
|
||||||
|
Invoke an ordinary async function inside a linked IPC context.
|
||||||
|
|
||||||
|
'''
|
||||||
|
# Do not use `NamespacePath.load_ref()` here: target resolution
|
||||||
|
# must remain behind the actor's RPC module allowlist.
|
||||||
|
fn: Callable = current_actor()._get_rpc_func(
|
||||||
|
namespace,
|
||||||
|
funcname,
|
||||||
|
)
|
||||||
|
_validate_one_shot_fn(fn)
|
||||||
|
await ctx.started()
|
||||||
|
return await fn(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def _invoke_from_portal(
|
||||||
|
portal: Portal,
|
||||||
|
fn: Callable,
|
||||||
|
args: tuple[Any, ...],
|
||||||
|
kwargs: dict[str, Any],
|
||||||
|
) -> Any:
|
||||||
|
'''
|
||||||
|
Run `fn` through the context-linked one-shot endpoint.
|
||||||
|
|
||||||
|
'''
|
||||||
|
namespace, funcname = NamespacePath.from_ref(fn).to_tuple()
|
||||||
|
async with portal.open_context(
|
||||||
|
_invoke_one_shot,
|
||||||
|
namespace=namespace,
|
||||||
|
funcname=funcname,
|
||||||
|
args=list(args),
|
||||||
|
kwargs=kwargs,
|
||||||
|
) as (ctx, _):
|
||||||
|
return await ctx.wait_for_result()
|
||||||
|
|
||||||
|
|
||||||
|
async def _invoke_in_subactor(
|
||||||
|
an: ActorNursery,
|
||||||
|
fn: Callable,
|
||||||
|
args: tuple[Any, ...],
|
||||||
|
kwargs: dict[str, Any],
|
||||||
|
name: str,
|
||||||
|
spawn_kwargs: dict[str, Any],
|
||||||
|
) -> Any:
|
||||||
|
'''
|
||||||
|
Spawn a (daemon) subactor via `an.start_actor()`,
|
||||||
|
schedule `fn` as its context-linked lone remote task 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 _invoke_from_portal(
|
||||||
|
portal,
|
||||||
|
fn,
|
||||||
|
args,
|
||||||
|
kwargs,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Cancel and join this child before returning. The nursery
|
||||||
|
# helper shields teardown, escalates a missed cancel ack and
|
||||||
|
# waits for the child monitor to remove its process record.
|
||||||
|
await an._cancel_and_reap_child(portal)
|
||||||
|
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
fn: Callable[[Unpack[ArgsT]], Awaitable[RetT]],
|
||||||
|
*args: Unpack[ArgsT],
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
|
||||||
|
) -> RetT:
|
||||||
|
'''
|
||||||
|
Run the async `fn(*args)` 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()`.
|
||||||
|
|
||||||
|
As with Trio's API, target arguments are positional. Use
|
||||||
|
`functools.partial()` to bind target keyword arguments; all
|
||||||
|
keyword arguments accepted here configure actor placement or
|
||||||
|
spawning. A caller-supplied `portal` must address an actor started
|
||||||
|
with both `tractor.to_actor.MODULE` and the target function's
|
||||||
|
module in its `enable_modules` list. Calls that spawn their own
|
||||||
|
actor add the trampoline module automatically.
|
||||||
|
|
||||||
|
Unlike the removed legacy `ActorNursery.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
|
||||||
|
fn, args, kwargs = _normalize_call(fn, args)
|
||||||
|
|
||||||
|
if (
|
||||||
|
runtime_kwargs is not None
|
||||||
|
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 _invoke_from_portal(
|
||||||
|
portal,
|
||||||
|
fn,
|
||||||
|
args,
|
||||||
|
kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
name: str = name or fn.__name__
|
||||||
|
spawn_kwargs: dict[str, Any] = dict(
|
||||||
|
enable_modules=(
|
||||||
|
[
|
||||||
|
# The public `to_actor.MODULE` alias is only for
|
||||||
|
# callers configuring an existing actor.
|
||||||
|
__name__,
|
||||||
|
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,
|
||||||
|
args,
|
||||||
|
kwargs,
|
||||||
|
name,
|
||||||
|
spawn_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
an: ActorNursery
|
||||||
|
async with open_nursery(
|
||||||
|
**(runtime_kwargs or {}),
|
||||||
|
) as an:
|
||||||
|
return await _invoke_in_subactor(
|
||||||
|
an,
|
||||||
|
fn,
|
||||||
|
args,
|
||||||
|
kwargs,
|
||||||
|
name,
|
||||||
|
spawn_kwargs,
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue