Compare commits

..

No commits in common. "2a59cefbe80ae770b4c0bec074eeb51d07cfb0be" and "ba4af0ad1aada332a7de83fe415d4b0bfeb71075" have entirely different histories.

38 changed files with 781 additions and 690 deletions

View File

@ -276,97 +276,6 @@ Gate (box ran ~2.7x slow this session, load-induced
completion). RECOMMEND a clean full-suite run on a completion). RECOMMEND a clean full-suite run on a
normal-speed box before this merges. 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.
## Verification gate ## Verification gate
- `tests/test_cancellation.py test_spawning.py test_local.py - `tests/test_cancellation.py test_spawning.py test_local.py

View File

@ -37,6 +37,7 @@ 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
@ -46,22 +47,10 @@ 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 —
One-shot task actors spawn, run a single task, auto-cancel after the result — slated
-------------------- to be rebuilt as a high-level wrapper, so don't design around
it as the core model.
.. autofunction:: tractor.to_actor.run
.. note::
:func:`tractor.to_actor.run` (parlance of
``trio.to_thread.run_sync()`` and friends) is the
*convenience* one-shot — spawn, run a single task, block on
its result, reap — built entirely on
:meth:`ActorNursery.start_actor` + :meth:`Portal.run` +
:meth:`Portal.cancel_actor`, so don't design around it as the
core model. It supersedes the removed (legacy, non-blocking)
``ActorNursery.run_in_actor()``.
.. deprecated:: 0.1.0a6 .. deprecated:: 0.1.0a6
@ -82,12 +71,14 @@ 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
The str-form ``Portal.run('mod.path', 'fn_name')`` warns; ``Portal.result()`` warns; use :meth:`Portal.wait_for_result`.
The str-form ``Portal.run('mod.path', 'fn_name')`` also warns;
pass a function *object* whose module is listed in the target's pass a function *object* whose module is listed in the target's
``enable_modules``. ``Portal.channel`` is the legacy spelling ``enable_modules``. ``Portal.channel`` is the legacy spelling
of :attr:`Portal.chan`. of :attr:`Portal.chan`.

View File

@ -76,8 +76,8 @@ Just flip the flag on :meth:`tractor.ActorNursery.start_actor`:
infect_asyncio=True, infect_asyncio=True,
) )
The one-shot convenience ``tractor.to_actor.run()`` accepts the The one-shot convenience ``ActorNursery.run_in_actor()`` accepts
same flag. The ``to_asyncio`` APIs may **only** be called from the 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 ``tractor.to_actor.run()``. Errors and cross-loop sibling of ``ActorNursery.run_in_actor()``. Errors and
cancellation are translated exactly as for channels. cancellation are translated exactly as for channels.
Cross-loop errors and cancellation Cross-loop errors and cancellation

View File

@ -64,13 +64,11 @@ 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 a blocking - a fourth actor runs ``assert_err()`` via ``.run_in_actor()`` and
``tractor.to_actor.run()`` one-shot and promptly trips its promptly trips its ``assert 0``,
``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* right at the call serialized error msg and re-raises *boxed* inside the nursery
inside the nursery block as a block as a :class:`tractor.RemoteActorError`,
:class:`tractor.RemoteActorError`,
- the nursery reacts like any ``trio`` nursery would: it cancels - the nursery reacts like any ``trio`` nursery would: it cancels
the three healthy siblings (graceful runtime-cancel requests, the three healthy siblings (graceful runtime-cancel requests,
acks awaited), reaps all four processes, then re-raises, acks awaited), reaps all four processes, then re-raises,

View File

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

View File

@ -119,16 +119,15 @@ 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
:func:`tractor.to_actor.run`, :meth:`tractor.ActorNursery.run_in_actor`,
.. 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
``to_actor.run()`` is a *convenience wrapper* — spawn an actor, ``run_in_actor()`` is a *convenience wrapper* — spawn an actor, run
run exactly one task in it, block on and return its result, reap exactly one task in it, reap on result — not the core spawning
— not the core spawning model (that's model (that's :meth:`tractor.ActorNursery.start_actor` plus
:meth:`tractor.ActorNursery.start_actor` plus
:meth:`tractor.Portal.open_context`; see :doc:`/guide/context`). :meth:`tractor.Portal.open_context`; see :doc:`/guide/context`).
But for this fire-and-collect shape it's exactly the right amount But for this fire-and-collect shape it's exactly the right amount
of typing. of typing.

View File

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

View File

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

View File

@ -43,20 +43,24 @@ 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.to_actor.run()`` runtime boots *implicitly* inside ``tractor.open_nursery()``
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
``tractor.to_actor.run()`` and told to run exactly one ``ActorNursery.run_in_actor()`` 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 call *blocks* until that final result arrives, then - the parent grabs that *final result* with
returns it - causality is preserved: your task only proceeds ``await portal.wait_for_result()``, much like you'd expect
once the child is *done*, dead, and reaped. from a "future" - except causality is preserved: the nursery
block only exits once the child is *done*, dead, and reaped.
.. margin:: Just need a worker pool? .. margin:: Just need a worker pool?
@ -67,22 +71,19 @@ What's going on here?
.. note:: .. note::
``to_actor.run()`` (parlance of ``trio.to_thread`` and ``run_in_actor()`` is the *convenience* wrapper: one-shot
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) — which hands you ``ActorNursery.start_actor()`` (next up) paired with
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 ``to_actor.run()`` one-shot subactor terminates when its lone A ``run_in_actor()``-spawned actor terminates when its main task
task returns. But often you want long-lived *daemon* actors returns. But often you want long-lived *daemon* actors instead:
instead: spawned once, then serving (allowlisted) RPC requests spawned once, then serving (allowlisted) RPC requests until told
until told otherwise. That's ``start_actor()``: 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
@ -90,9 +91,9 @@ until told otherwise. That's ``start_actor()``:
Two lifetime rules to internalize: Two lifetime rules to internalize:
- a ``to_actor.run()`` one-shot actor lives exactly as long as - a ``run_in_actor()`` actor lives exactly as long as its main
its lone task; the call blocks until that function (and thus task; the nursery waits for that function (and thus the
the process) completes, process) to complete before unblocking,
- a ``start_actor()`` actor *lives forever* - an RPC daemon the - a ``start_actor()`` actor *lives forever* - an RPC daemon the
nursery will happily wait on **indefinitely** - until some nursery will happily wait on **indefinitely** - until some
task explicitly cancels it via ``Portal.cancel_actor()`` (as task explicitly cancels it via ``Portal.cancel_actor()`` (as

View File

@ -17,38 +17,27 @@ async def say_hello(other_actor):
return await portal.run(hi) return await portal.run(hi)
async def run_and_print(
an: tractor.ActorNursery,
name: str,
other_actor: str,
):
print(
await tractor.to_actor.run(
say_hello,
an=an,
name=name,
# arguments are always named
other_actor=other_actor,
)
)
async def main(): 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 ( async with tractor.open_nursery() as n:
tractor.open_nursery() as an,
trio.open_nursery() as tn,
):
print("Alright... Action!") print("Alright... Action!")
# both actors wait on the *other* to register so their donny = await n.run_in_actor(
# one-shots must run concurrently. say_hello,
tn.start_soon(run_and_print, an, 'donny', 'gretchen') name='donny',
tn.start_soon(run_and_print, an, 'gretchen', 'donny') # arguments are always named
other_actor='gretchen',
print("CUTTTT CUUTT CUT!!! Donny!! You're supposed to say...") )
gretchen = await n.run_in_actor(
say_hello,
name='gretchen',
other_actor='donny',
)
print(await gretchen.wait_for_result())
print(await donny.wait_for_result())
print("CUTTTT CUUTT CUT!!! Donny!! You're supposed to say...")
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -10,14 +10,17 @@ async def cellar_door():
async def main(): async def main():
"""The main ``tractor`` routine. """The main ``tractor`` routine.
""" """
# spawn a subactor, run ``cellar_door()`` as its lone task, async with tractor.open_nursery() as n:
# block until its result arrives and the subactor is reaped.
print( portal = await n.run_in_actor(
await tractor.to_actor.run(
cellar_door, cellar_door,
name='some_linguist', name='some_linguist',
) )
)
# The ``async with`` will unblock here since the 'some_linguist'
# actor has completed its main task ``cellar_door``.
print(await portal.wait_for_result())
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -1,5 +1,3 @@
from functools import partial
import trio import trio
import tractor import tractor
@ -23,36 +21,25 @@ 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 ( async with tractor.open_nursery() as n:
tractor.open_nursery() as an,
trio.open_nursery() as tn,
):
if depth < 1: if depth < 1:
tn.start_soon( await n.run_in_actor(breakpoint_forever)
partial(
tractor.to_actor.run,
breakpoint_forever,
an=an,
)
)
p = await n.run_in_actor(
name_error,
name='name_error'
)
await trio.sleep(0.5) await trio.sleep(0.5)
# rx and propagate error from child # rx and propagate error from child
await tractor.to_actor.run( await p.result()
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; blocks (up) each level until the leaf's # the tree
# `name_error` relays through.
depth -= 1 depth -= 1
await tractor.to_actor.run( await n.run_in_actor(
spawn_until, spawn_until,
an=an,
depth=depth, depth=depth,
name=f'spawn_until_{depth}', name=f'spawn_until_{depth}',
) )
@ -78,34 +65,35 @@ async def main():
python -m tractor._child --uid ('spawn_until_0', 'de918e6d ...) python -m tractor._child --uid ('spawn_until_0', 'de918e6d ...)
""" """
async with ( async with tractor.open_nursery(
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 spawn_until,
# first tree's (relayed) error cancels the other. depth=3,
tn.start_soon( name='spawner0',
partial(
tractor.to_actor.run,
spawn_until,
an=an,
depth=3,
name='spawner0',
)
) )
tn.start_soon( portal1 = await n.run_in_actor(
partial( spawn_until,
tractor.to_actor.run, depth=4,
spawn_until, name='spawner1',
an=an,
depth=4,
name='spawner1',
)
) )
# TODO: test this case as well where the parent don't see
# the sub-actor errors by default and instead expect a user
# ctrl-c to kill the root.
with trio.move_on_after(3):
await trio.sleep_forever()
# gah still an issue here.
await portal.result()
# should never get here
await portal1.result()
if __name__ == '__main__': if __name__ == '__main__':
trio.run(main) trio.run(main)

View File

@ -16,11 +16,11 @@ 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 n:
return await tractor.to_actor.run( portal = await n.run_in_actor(
name_error, name_error,
an=n,
name='name_error_1', name='name_error_1',
) )
return await portal.result()
async def main(): async def main():
@ -38,36 +38,29 @@ async def main():
- root actor should then fail on assert - root actor should then fail on assert
- program termination - program termination
""" """
async with ( async with tractor.open_nursery(
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__],
)
# ..and bg-schedule their erroring tasks. # spawn both actors
tn.start_soon(portal.run, name_error) portal = await n.run_in_actor(
tn.start_soon(portal1.run, spawn_error) name_error,
name='name_error',
# yield to the bg tasks so both RPC requests are )
# submitted (and start crashing) before the root's own portal1 = await n.run_in_actor(
# error below (the legacy `run_in_actor()` submitted spawn_error,
# in-line with each spawn). name='spawn_error',
await trio.sleep(0.5) )
# trigger a root actor error # trigger a root actor error
assert 0 assert 0
# attempt to collect results (which raises error in parent)
# still has some issues where the parent seems to get stuck
await portal.result()
await portal1.result()
if __name__ == '__main__': if __name__ == '__main__':
trio.run(main) trio.run(main)

View File

@ -18,11 +18,11 @@ 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 n:
return await tractor.to_actor.run( portal = await n.run_in_actor(
name_error, name_error,
an=n,
name='name_error_1', name='name_error_1',
) )
return await portal.result()
async def main(): async def main():
@ -36,39 +36,17 @@ 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 an: ) as n:
async def run_and_collect(fn): # Spawn both actors, don't bother with collecting results
''' # (would result in a different debugger outcome due to parent's
One-shot whose (boxed) error is stashed instead of # cancellation).
raised so a sibling's crash never cancels the others await n.run_in_actor(breakpoint_forever)
before they've had their own debugger sessions (the await n.run_in_actor(name_error)
"collect all errors" the legacy `run_in_actor()` API await n.run_in_actor(spawn_error)
did implicitly at nursery teardown).
'''
try:
await tractor.to_actor.run(fn, an=an)
except tractor.RemoteActorError as rae:
errors.append(rae)
# Spawn all one-shot task actors, collecting (vs.
# raising) their errors.
async with trio.open_nursery() as tn:
tn.start_soon(run_and_collect, breakpoint_forever)
tn.start_soon(run_and_collect, name_error)
tn.start_soon(run_and_collect, spawn_error)
if errors:
raise BaseExceptionGroup(
'multi_subactors errored!',
errors,
)
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -1,5 +1,3 @@
from functools import partial
import trio import trio
import tractor import tractor
@ -12,14 +10,14 @@ async def name_error():
async def spawn_until(depth=0): async def spawn_until(depth=0):
""""A nested nursery that triggers another ``NameError``. """"A nested nursery that triggers another ``NameError``.
""" """
async with tractor.open_nursery() as an: async with tractor.open_nursery() as n:
if depth < 1: if depth < 1:
await tractor.to_actor.run(name_error, an=an) # await n.run_in_actor('breakpoint_forever', breakpoint_forever)
await n.run_in_actor(name_error)
else: else:
depth -= 1 depth -= 1
await tractor.to_actor.run( await n.run_in_actor(
spawn_until, spawn_until,
an=an,
depth=depth, depth=depth,
name=f'spawn_until_{depth}', name=f'spawn_until_{depth}',
) )
@ -39,33 +37,28 @@ async def main():
python -m tractor._child --uid ('name_error', '6c2733b8 ...) python -m tractor._child --uid ('name_error', '6c2733b8 ...)
''' '''
async with ( async with tractor.open_nursery(
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 the deeper tree in the bg..
tn.start_soon(
partial(
tractor.to_actor.run,
spawn_until,
an=an,
depth=1,
name='spawner1',
)
)
# ..while blocking on the shallow (faster to fail) tree # spawn both actors
# whose propagated error triggers nursery cancellation. portal = await n.run_in_actor(
await tractor.to_actor.run(
spawn_until, spawn_until,
an=an,
depth=0, depth=0,
name='spawner0', name='spawner0',
) )
portal1 = await n.run_in_actor(
spawn_until,
depth=1,
name='spawner1',
)
# nursery cancellation should be triggered due to propagated
# error from child.
await portal.result()
await portal1.result()
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -13,24 +13,17 @@ async def main():
simultaneously. simultaneously.
''' '''
async with ( async with tractor.open_nursery(
tractor.open_nursery( debug_mode=True,
debug_mode=True, # loglevel='debug' # ?XXX required?
# loglevel='debug' # ?XXX required? ) as n:
) as n,
trio.open_nursery() as tn, # spawn both actors
): portal = await n.run_in_actor(key_error)
# spawn the actor..
portal = await n.start_actor(
'key_error',
enable_modules=[__name__],
)
print( print(
f'Child is up @ {portal.chan.aid.reprol()}' f'Child is up @ {portal.chan.aid.reprol()}'
) )
# ..then schedule its erroring task in the bg while the
# root blocks below.
tn.start_soon(portal.run, key_error)
# XXX: originally a bug caused by this is where root would enter # XXX: originally a bug caused by this is where root would enter
# the debugger and clobber the tty used by the repl even though # the debugger and clobber the tty used by the repl even though

View File

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

View File

@ -19,12 +19,10 @@ async def main():
loglevel='cancel', loglevel='cancel',
) as n: ) as n:
# parks awaiting a result which only arrives once the portal = await n.run_in_actor(
# user quits (`BdbQuit`s) the child's REPL loop.
await tractor.to_actor.run(
breakpoint_forever, breakpoint_forever,
an=n,
) )
await portal.wait_for_result()
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -12,12 +12,16 @@ 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 `to_actor.run()` .. # ABOVE the @api_frame of `Portal.run_in_actor()` (which
# 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)
# the one-shot blocks on the subactor's result so the # with this style, should raise on this line
# boxed `NameError` raises right here. await p.wait_for_result()
await tractor.to_actor.run(name_error, an=an)
# with this alt style should raise at `open_nusery()`
# return await p.wait_for_result()
if __name__ == '__main__': if __name__ == '__main__':

View File

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

View File

@ -25,15 +25,17 @@ async def burn_cpu():
async def main(): async def main():
async with trio.open_nursery() as tn: async with tractor.open_nursery() as n:
# burn rubber in the parent too portal = await n.run_in_actor(burn_cpu)
tn.start_soon(burn_cpu)
# run the same func as the lone task in a subactor, # burn rubber in the parent too
# block on (and collect) its result await burn_cpu()
pid = await tractor.to_actor.run(burn_cpu)
# wait on result from target function
pid = await portal.wait_for_result()
# end of nursery block
print(f"Collected subproc {pid}") print(f"Collected subproc {pid}")

View File

@ -15,12 +15,11 @@ async def main():
enable_modules=[__name__], enable_modules=[__name__],
)) ))
# run one one-shot task actor that will fail immediately; # start one actor that will fail immediately
# its error raises right here in the caller's task.. await n.run_in_actor(assert_err)
await tractor.to_actor.run(assert_err, an=n)
# ..as a ``RemoteActorError`` containing an ``AssertionError`` # should error here with a ``RemoteActorError`` containing
# and all the other actors have been cancelled # an ``AssertionError`` and all the other actors have been cancelled
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -849,36 +849,43 @@ 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'",
# each level's unwrapped-single-member-group # first level subtrees
# annotation + the first-level subtree's boundary # "tractor._exceptions.RemoteActorError: ('spawner0'",
# footer. "src_uid=('spawner0'",
"( ^^^ this exc was collapsed from a group ^^^ )",
"------ ('spawner0'", # "tractor._exceptions.RemoteActorError: ('spawner1'",
# propagation of errors up through nested subtrees
# "tractor._exceptions.RemoteActorError: ('spawn_until_0'",
# "tractor._exceptions.RemoteActorError: ('spawn_until_1'",
# "tractor._exceptions.RemoteActorError: ('spawn_until_2'",
# ^-NOTE-^ old RAE repr, new one is below with a field
# showing the src actor's uid.
"src_uid=('spawn_until_2'",
] ]
# 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 relayed from the leaf's # expect the pdb-quit exc.
# bp-loop child.
"bdb.BdbQuit", "bdb.BdbQuit",
"src_uid=('breakpoint_forever'", # BUT WHY these dude!?
"src_uid=('spawn_until_0'",
"relay_uid=('spawn_until_1'",
] ]
assert_before( assert_before(

View File

@ -3,7 +3,6 @@ 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
@ -174,8 +173,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 the `cpus - 1` one-shot # that can stack up over `cpus - 1` sequential `n.run_in_actor()`
# (`to_actor.run()`) spawns — especially on UDS under cross-pytest contention # calls — 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
@ -215,55 +214,33 @@ 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 ( async with tractor.open_nursery(
tractor.open_nursery( registry_addrs=[reg_addr],
registry_addrs=[reg_addr], debug_mode=debug_mode,
debug_mode=debug_mode, ) as n:
) as n,
# 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
tn.start_soon( await n.run_in_actor(publisher)
partial(
tractor.to_actor.run,
publisher,
an=n,
)
)
for i, sub in zip( for i, sub in zip(
range(cpus - 2), range(cpus - 2),
itertools.cycle(_registry.keys()) itertools.cycle(_registry.keys())
): ):
tn.start_soon( await n.run_in_actor(
partial( consumer,
tractor.to_actor.run, name=f'consumer_{sub}',
consumer, subs=[sub],
an=n,
name=f'consumer_{sub}',
subs=[sub],
)
) )
# make one dynamic subscriber # make one dynamic subscriber
tn.start_soon( await n.run_in_actor(
partial( consumer,
tractor.to_actor.run, name='consumer_dynamic',
consumer, subs=list(_registry.keys()),
an=n,
name='consumer_dynamic',
subs=list(_registry.keys()),
)
) )
# block until "cancelled by user" # block until "cancelled by user"

View File

@ -2,7 +2,6 @@
Cancellation and error propagation Cancellation and error propagation
""" """
from functools import partial
import os import os
import signal import signal
import platform import platform
@ -17,10 +16,7 @@ from tractor._testing import (
tractor_test, tractor_test,
) )
from tractor._testing.trace import FailAfterWTraceFactory from tractor._testing.trace import FailAfterWTraceFactory
from tractor.trionics import ( from tractor.trionics import gather_contexts
collapse_eg,
gather_contexts,
)
from .conftest import no_windows from .conftest import no_windows
@ -309,30 +305,30 @@ async def test_cancel_infinite_streamer(
@pytest.mark.parametrize( @pytest.mark.parametrize(
'num_actors_and_errs', 'num_actors_and_errs',
[ [
# daemon actors sit idle while one-shot task actors error out # daemon actors sit idle while single 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 one-shot task actors sleep forever # 1 daemon actor errors out while single 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 one-shot task # daemon actors error out after brief delay while single 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 one-shot task # daemon complete quickly delay while single 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_one_shot_fails', '1_run_in_actor_fails',
'2_one_shots_fail', '2_run_in_actors_fail',
'3_one_shots_fail', '3_run_in_actors_fail',
'1_daemon_actors_fail', '1_daemon_actors_fail',
'1_daemon_actors_fail_all_one_shots_dun_quick', '1_daemon_actors_fail_all_run_in_actors_dun_quick',
'no_daemon_actors_fail_all_one_shots_sleep_then_fail', 'no_daemon_actors_fail_all_run_in_actors_sleep_then_fail',
], ],
) )
@tractor_test( @tractor_test(
@ -351,22 +347,12 @@ 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,
one_shot_func, ria_func,
da_func, da_func,
) = num_actors_and_errs ) = num_actors_and_errs
try: try:
@ -380,64 +366,51 @@ async def test_some_cancels_all(
enable_modules=[__name__], enable_modules=[__name__],
)) ))
func, kwargs = one_shot_func func, kwargs = ria_func
async with ( riactor_portals = []
collapse_eg(), for i in range(num_actors):
trio.open_nursery() as tn, # start actor(s) that will fail immediately
): riactor_portals.append(
for i in range(num_actors): await an.run_in_actor(
# schedule one-shot task actor(s); errors func,
# raise into this task-nursery scope. name=f'actor_{i}',
tn.start_soon( **kwargs
partial(
tractor.to_actor.run,
func,
an=an,
name=f'actor_{i}',
**kwargs,
)
) )
)
if da_func: if da_func:
func, kwargs, expect_error = da_func func, kwargs, expect_error = da_func
for portal in dactor_portals: for portal in dactor_portals:
# if this function fails then we should error # if this function fails then we should error here
# here and the nursery should teardown all # and the nursery should teardown all other actors
# other actors try:
try: await portal.run(func, **kwargs)
await portal.run(func, **kwargs)
except tractor.RemoteActorError as err: except tractor.RemoteActorError as err:
assert err.boxed_type == err_type assert err.boxed_type == err_type
# we only expect this first error to propogate # we only expect this first error to propogate
# (all other daemons are cancelled before they # (all other daemons are cancelled before they
# can be scheduled) # can be scheduled)
num_actors = 1 num_actors = 1
# reraise so nursery teardown is triggered # reraise so nursery teardown is triggered
raise raise
else: else:
if expect_error: if expect_error:
pytest.fail( pytest.fail(
"Deamon call should fail at checkpoint?") "Deamon call should fail at checkpoint?")
# should error here with a `RemoteActorError` or a beg of them # should error here with a ``RemoteActorError`` or ``MultiError``
except ( except first_err as _err:
BaseExceptionGroup,
tractor.RemoteActorError,
) as _err:
err = _err err = _err
if isinstance(err, BaseExceptionGroup): if isinstance(err, BaseExceptionGroup):
# only the concurrent multi-error cases can group; the assert len(err.exceptions) == num_actors
# 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:
assert isinstance(exc, tractor.RemoteActorError) if 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
@ -450,20 +423,8 @@ 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 ( async with tractor.open_nursery() as nursery:
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:
@ -483,14 +444,7 @@ async def spawn_and_error(
kwargs = { kwargs = {
'name': f'{name}_errorer_{i}', 'name': f'{name}_errorer_{i}',
} }
tn.start_soon( await nursery.run_in_actor(*args, **kwargs)
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
@ -532,11 +486,7 @@ async def test_nested_multierrors(
depth: int, depth: int,
): ):
''' '''
Test that a nested tree of concurrently failing one-shot Test that failed actor sets are wrapped in `BaseExceptionGroup`s.
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}`:
@ -586,13 +536,6 @@ 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` —
@ -677,14 +620,6 @@ 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.
@ -697,82 +632,67 @@ async def test_nested_multierrors(
async with fail_after_w_trace(timeout): async with fail_after_w_trace(timeout):
try: try:
async with ( async with tractor.open_nursery() as nursery:
tractor.open_nursery() as nursery,
trio.open_nursery() as tn,
):
for i in range(subactor_breadth): for i in range(subactor_breadth):
tn.start_soon( await nursery.run_in_actor(
partial( spawn_and_error,
tractor.to_actor.run, name=f'spawner_{i}',
spawn_and_error, breadth=subactor_breadth,
an=nursery, depth=depth,
name=f'spawner_{i}',
breadth=subactor_breadth,
depth=depth,
)
) )
except ( except BaseExceptionGroup as err:
BaseExceptionGroup, assert len(err.exceptions) == subactor_breadth
tractor.RemoteActorError, for subexc in err.exceptions:
) as err:
# group membership is bounded by the relay-vs-cancel # verify first level actor errors are wrapped as remote
# race: the first spawner-tree's error cancels its if _friggin_windows:
# 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
continue if isinstance(subexc, trio.Cancelled):
continue
assert isinstance(subexc, tractor.RemoteActorError) 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,
)
accepted: tuple[Type[BaseException], ...] = ( elif isinstance(subexc, BaseExceptionGroup):
# ≥2 sub-tree errors relayed before the for subsub in subexc.exceptions:
# cancel-cascade won → grouped per-level.
ExceptionGroup, if subsub in (tractor.RemoteActorError,):
# every level collapsed down to its lone subsub = subsub.boxed_type
# relayed (leaf) error.
AssertionError, assert type(subsub) in (
# a mid-level spawner relays an trio.Cancelled,
# already-boxed (collapsed) leaf chain, BaseExceptionGroup,
# re-boxing the `RemoteActorError` itself. )
tractor.RemoteActorError, else:
# under heavy load a runtime-internal reap assert isinstance(subexc, tractor.RemoteActorError)
# deadline can inject a `trio.Cancelled`
# into a child's group before relay (the if depth > 0 and subactor_breadth > 1:
# same class the depth=3 throttle-xfail # XXX not sure what's up with this..
# covers) upgrading it from an # on windows sometimes spawning is just too slow and
# `ExceptionGroup`. # we get back the (sent) cancel signal instead
BaseExceptionGroup, if _friggin_windows:
) if isinstance(subexc, tractor.RemoteActorError):
if _friggin_windows: assert subexc.boxed_type in (
# on windows it seems we can't exactly be BaseExceptionGroup,
# sure wtf will happen.. tractor.RemoteActorError
accepted += ( )
trio.Cancelled, else:
assert isinstance(subexc, BaseExceptionGroup)
else:
assert subexc.boxed_type is ExceptionGroup
else:
assert subexc.boxed_type in (
tractor.RemoteActorError,
trio.Cancelled
) )
assert subexc.boxed_type in accepted
else:
pytest.fail(
'Should have raised a (grouped) `RemoteActorError`?'
)
@no_windows @no_windows
@ -837,13 +757,11 @@ 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 an: ) as tn:
# 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 an.start_actor( await tn.run_in_actor(
f'namesucka_{i}', sleep_forever,
name='namesucka',
) )
task_status.started() task_status.started()
await trio.sleep_forever() await trio.sleep_forever()
@ -884,11 +802,8 @@ 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')
# one-shot: parks HERE awaiting the sync-sleeping await an.run_in_actor(
# 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')
@ -994,18 +909,10 @@ 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,
): ):
# bg one-shot: parks on the middle actor's result await an.run_in_actor(
# (itself parked on the sync-sleeping grandchild) spawn_sub_with_sync_blocking_task,
# until the `assert 0` below cancels this scope. name='sync_blocking_sub',
tn.start_soon(
partial(
tractor.to_actor.run,
spawn_sub_with_sync_blocking_task,
an=an,
name='sync_blocking_sub',
)
) )
await trio.sleep(1) await trio.sleep(1)

View File

@ -131,12 +131,9 @@ def test_ringbuf(
child_read_shm, child_read_shm,
**common_kwargs, **common_kwargs,
total_bytes=total_bytes, total_bytes=total_bytes,
) as (rctx, _sent), ) as (sctx, _sent),
): ):
# ctx-acm exits await each child task's await recv_p.result()
# `Return` (the prior `recv_p.result()` here
# was a daemon-portal no-op).
pass
await send_p.cancel_actor() await send_p.cancel_actor()
await recv_p.cancel_actor() await recv_p.cancel_actor()

View File

@ -1,8 +1,8 @@
''' '''
`tractor.to_actor`: one-shot single-remote-task API suite. `tractor.to_actor`: one-shot single-remote-task API suite.
Verifies the "spiritual successor" to (and replacement of) Verifies the "spiritual successor" to (and eventual
the removed legacy `ActorNursery.run_in_actor()`; see replacement of) `ActorNursery.run_in_actor()`; see
https://github.com/goodboy/tractor/issues/477 https://github.com/goodboy/tractor/issues/477
''' '''
@ -82,7 +82,7 @@ async def test_remote_error_relayed_to_caller_task(
A remote task error is raised directly in the A remote task error is raised directly in the
caller's task as a boxed `RemoteActorError` instead caller's task as a boxed `RemoteActorError` instead
of surfacing at actor-nursery teardown as with the of surfacing at actor-nursery teardown as with the
removed legacy `.run_in_actor()` API. legacy `.run_in_actor()` API.
''' '''
with pytest.raises(RemoteActorError) as excinfo: with pytest.raises(RemoteActorError) as excinfo:

View File

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

View File

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

View File

@ -221,19 +221,16 @@ def pub(
import tractor import tractor
async with tractor.open_nursery() as n: async with tractor.open_nursery() as n:
portal = await n.start_actor( portal = n.run_in_actor(
'publisher', # actor name 'publisher', # actor name
enable_modules=[__name__], partial( # func to execute in it
)
async with portal.open_stream_from(
partial( # func to execute in it
pub_service, pub_service,
topics=('clicks', 'users'), topics=('clicks', 'users'),
task_name='source1', task_name='source1',
) )
) as stream: )
async for value in stream: async for value in await portal.result():
print(f"Subscriber received {value}") print(f"Subscriber received {value}")
Here, you don't need to provide the ``ctx`` argument since the Here, you don't need to provide the ``ctx`` argument since the

View File

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

View File

@ -50,11 +50,13 @@ 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 (
@ -100,6 +102,14 @@ 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
@ -127,6 +137,102 @@ class Portal:
) )
return self.chan return self.chan
# TODO: factor this out into a `.highlevel` API-wrapper that uses
# a single `.open_context()` call underneath.
async def _submit_for_result(
self,
ns: str,
func: str,
**kwargs
) -> None:
if self._expect_result_ctx is not None:
raise RuntimeError(
'A pending main result has already been submitted'
)
self._expect_result_ctx: Context = await self.actor.start_remote_task(
self.channel,
nsf=NamespacePath(f'{ns}:{func}'),
kwargs=kwargs,
portal=self,
)
# TODO: we should deprecate this API right? since if we remove
# `.run_in_actor()` (and instead move it to a `.highlevel`
# wrapper api (around a single `.open_context()` call) we don't
# really have any notion of a "main" remote task any more?
#
# @api_frame
async def wait_for_result(
self,
hide_tb: bool = True,
) -> Any:
'''
Return the final result delivered by a `Return`-msg from the
remote peer actor's "main" task's `return` statement.
'''
__tracebackhide__: bool = hide_tb
# Check for non-rpc errors slapped on the
# channel for which we always raise
exc = self.channel._exc
if exc:
raise exc
# not expecting a "main" result
if self._expect_result_ctx is None:
peer_id: str = f'{self.channel.aid.reprol()!r}'
log.warning(
f'Portal to peer {peer_id} will not deliver a final result?\n'
f'\n'
f'Context.result() can only be called by the parent of '
f'a sub-actor when it was spawned with '
f'`ActorNursery.run_in_actor()`'
f'\n'
f'Further this `ActorNursery`-method-API will deprecated in the'
f'near fututre!\n'
)
return NoResult
# expecting a "main" result
assert self._expect_result_ctx
if self._final_result_msg is None:
try:
(
self._final_result_msg,
self._final_result_pld,
) = await self._expect_result_ctx._pld_rx.recv_msg(
ipc=self._expect_result_ctx,
expect_msg=Return,
)
except BaseException as err:
# TODO: wrap this into `@api_frame` optionally with
# some kinda filtering mechanism like log levels?
__tracebackhide__: bool = False
raise err
return self._final_result_pld
# TODO: factor this out into a `.highlevel` API-wrapper that uses
# a single `.open_context()` call underneath.
async def result(
self,
*args,
**kwargs,
) -> Any|Exception:
typname: str = type(self).__name__
log.warning(
f'`{typname}.result()` is DEPRECATED!\n'
f'\n'
f'Use `{typname}.wait_for_result()` instead!\n'
)
return await self.wait_for_result(
*args,
**kwargs,
)
async def _cancel_streams(self): async def _cancel_streams(self):
# terminate all locally running async generator # terminate all locally running async generator
# IPC calls # IPC calls

View File

@ -20,6 +20,7 @@
""" """
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,
) )
@ -231,6 +232,14 @@ 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, factor this into a .hilevel api!
#
# portals spawned with ``run_in_actor()`` are
# cancelled when their "main" result arrives. Reaped by
# `_reap_ria_portals()` at nursery-block exit now that
# the 2ndary `._ria_nursery` is gone (see issue #477).
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
@ -363,6 +372,84 @@ class ActorNursery:
) )
) )
# TODO: DEPRECATE THIS:
# -[x] impl instead as a hilevel wrapper on top of
# the lower level daemon-spawn + portal APIs
# |_ see `.to_actor.run()` (issue #477) which does
# `.start_actor()` + `Portal.run()` + a one-shot
# reap via `Portal.cancel_actor()`.
# -[ ] emit a `DeprecationWarning` here (requires
# migrating all in-repo usage first!)
# -[ ] 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.
NOTE: prefer the (eventual) replacement API
`tractor.to_actor.run()` which delivers the same
one-shot semantics decoupled from this nursery's
internal spawn machinery; see issue #477.
'''
__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,
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,
@ -481,6 +568,51 @@ class ActorNursery:
self._join_procs.set() self._join_procs.set()
async def _reap_ria_portals(
an: ActorNursery,
errors: dict[tuple[str, str], BaseException],
ria_children: list[tuple[Portal, Actor]]|None = None,
) -> None:
'''
Wait on and stash the final result/error from every
`.run_in_actor()`-spawned child then cancel its actor
runtime, one `_spawn.cancel_on_completion()` task per
child.
Replaces the per-child reaper task formerly spawned by
the spawn backends (keyed off
`._cancel_after_result_on_exit` membership) which
required routing such children into the (now removable)
`._ria_nursery`. Only call AFTER `._join_procs` is set
so user code inside the nursery block retains exclusive
result-await access; see the "manually await results"
note in `spawn._mp.mp_proc()`.
'''
if ria_children is None:
ria_children: list[tuple[Portal, Actor]] = [
(portal, subactor)
for subactor, _, portal in an._children.values()
if portal in an._cancel_after_result_on_exit
]
if not ria_children:
return
async with (
collapse_eg(),
trio.open_nursery() as tn,
):
portal: Portal
subactor: Actor
for portal, subactor in ria_children:
tn.start_soon(
_spawn.cancel_on_completion,
portal,
subactor,
errors,
)
@acm @acm
async def _open_and_supervise_one_cancels_all_nursery( async def _open_and_supervise_one_cancels_all_nursery(
actor: Actor, actor: Actor,
@ -495,10 +627,11 @@ async def _open_and_supervise_one_cancels_all_nursery(
errors: dict[tuple[str, str], BaseException] = {} errors: dict[tuple[str, str], BaseException] = {}
# The single "daemon actor" nursery into which ALL subactors # The single "daemon actor" nursery into which ALL subactors
# are spawned; one-shot (`to_actor.run()`) subactors are # are spawned — both `.start_actor()` daemons AND
# result-waited and reaped in their caller's own task-scope # `.run_in_actor()` one-shots. The latter's result-reaping now
# (see the #477 `.run_in_actor()`/`._ria_nursery` removal); # runs via `_reap_ria_portals()` at block-exit rather than a
# errors from this nursery bubble up to the caller. # 2ndary `._ria_nursery` (see the #477 removal); errors from
# this nursery bubble up to the caller.
async with ( async with (
collapse_eg(), collapse_eg(),
trio.open_nursery() as da_nursery, trio.open_nursery() as da_nursery,
@ -522,6 +655,11 @@ async def _open_and_supervise_one_cancels_all_nursery(
) )
an._join_procs.set() an._join_procs.set()
# collect results (and errors) from all
# `.run_in_actor()` children then cancel
# each, one reaper task per child.
await _reap_ria_portals(an, errors)
# Single one-cancels-all handler for the (now single) # Single one-cancels-all handler for the (now single)
# daemon nursery. Pre-#477 a 2ndary `._ria_nursery` # daemon nursery. Pre-#477 a 2ndary `._ria_nursery`
# required a separate *outer* handler to catch errors # required a separate *outer* handler to catch errors
@ -595,14 +733,46 @@ async def _open_and_supervise_one_cancels_all_nursery(
# '------ - ------' # '------ - ------'
) )
# snapshot `.run_in_actor()` children
# BEFORE cancelling: each backend
# spawn-task pops its `._children`
# entry as the proc gets reaped.
ria_children: list = [
(portal, subactor)
for subactor, _, portal
in an._children.values()
if portal in
an._cancel_after_result_on_exit
]
# cancel all subactors # cancel all subactors
await an.cancel() await an.cancel()
# then collect any already-relayed
# results/errors from ria children.
# Tightly bounded: anything
# collectable is already queued in
# the local ctx (relayed BEFORE the
# cancel above); a child hard-killed
# without relaying just parks its
# reaper which then self-cleans (a
# `trio.Cancelled` result is never
# stashed), mirroring the old
# backend-side reaper-vs-`soft_kill`
# cancel race.
with trio.move_on_after(0.5):
await _reap_ria_portals(
an,
errors,
ria_children=ria_children,
)
finally: finally:
# an error was stashed by the handler above (or by # No errors were raised while awaiting ".run_in_actor()"
# a spawn task via the shared `errors` dict) so # actors but those actors may have returned remote errors as
# cancel any remaining subactors, summarize and # results (meaning they errored remotely and have relayed
# re-raise. # those errors back to this parent actor). The errors are
# collected in ``errors`` so cancel all actors, summarize
# all errors and re-raise.
if errors: if errors:
if an._children: if an._children:
with trio.CancelScope(shield=True): with trio.CancelScope(shield=True):

View File

@ -188,7 +188,9 @@ async def mp_proc(
# 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``
# condition. # condition. Any `.run_in_actor()` result-reaping
# happens up in the `ActorNursery` machinery (see
# `_supervise._reap_ria_portals()`), NOT here.
await soft_kill( await soft_kill(
proc, proc,
proc_waiter, proc_waiter,

View File

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

View File

@ -196,7 +196,9 @@ async def trio_proc(
# 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``
# condition. # condition. Any `.run_in_actor()` result-reaping
# happens up in the `ActorNursery` machinery (see
# `_supervise._reap_ria_portals()`), NOT here.
await soft_kill( await soft_kill(
proc, proc,
trio.Process.wait, # XXX, uses `pidfd_open()` below. trio.Process.wait, # XXX, uses `pidfd_open()` below.

View File

@ -23,8 +23,8 @@ Adopts the "run it over there" parlance from analogous
reuse) a subactor, schedule a single remote task, wait on reuse) a subactor, schedule a single remote task, wait on
its result and (when the call owns the subactor) reap it. its result and (when the call owns the subactor) reap it.
The "spiritual successor" to (and replacement of) the removed The "spiritual successor" to (and eventual replacement of)
legacy `ActorNursery.run_in_actor()` API; see the `ActorNursery.run_in_actor()` API; see
https://github.com/goodboy/tractor/issues/477 https://github.com/goodboy/tractor/issues/477
''' '''

View File

@ -31,7 +31,7 @@ the lower level daemon-actor spawn + portal APIs,
such that error collection and propagation happens in the such that error collection and propagation happens in the
*caller's task* (and thus whatever `trio` nursery/scope *caller's task* (and thus whatever `trio` nursery/scope
encloses it) instead of inside the actor-nursery's encloses it) instead of inside the actor-nursery's
spawn-machinery nurseries as with the (now removed) legacy spawn-machinery nurseries as with the (to be deprecated)
`ActorNursery.run_in_actor()` API. `ActorNursery.run_in_actor()` API.
''' '''
@ -151,9 +151,9 @@ async def run(
the distributed-parallelism equivalent of the distributed-parallelism equivalent of
`trio.to_thread.run_sync()`. `trio.to_thread.run_sync()`.
Unlike the removed legacy `.run_in_actor()` (which Unlike `ActorNursery.run_in_actor()` (which returns
returned a `Portal` whose result was only collected a `Portal` whose result is only collected at
at actor-nursery teardown) this is a plain "call and actor-nursery teardown) this is a plain "call and
wait" primitive: any remote error is raised HERE, in wait" primitive: any remote error is raised HERE, in
the caller's task. Concurrency is composed the usual the caller's task. Concurrency is composed the usual
`trio` way by scheduling multiple `run()` calls in `trio` way by scheduling multiple `run()` calls in