Compare commits
No commits in common. "2a59cefbe80ae770b4c0bec074eeb51d07cfb0be" and "ba4af0ad1aada332a7de83fe415d4b0bfeb71075" have entirely different histories.
2a59cefbe8
...
ba4af0ad1a
|
|
@ -276,97 +276,6 @@ Gate (box ran ~2.7x slow this session, load-induced
|
|||
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.
|
||||
|
||||
## Verification gate
|
||||
|
||||
- `tests/test_cancellation.py test_spawning.py test_local.py
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ Spawning actors
|
|||
|
||||
.. autoclass:: ActorNursery
|
||||
:members: start_actor,
|
||||
run_in_actor,
|
||||
cancel,
|
||||
cancel_called,
|
||||
cancelled_caught
|
||||
|
|
@ -46,22 +47,10 @@ Spawning actors
|
|||
:meth:`ActorNursery.start_actor` (daemon actor + portal) is the
|
||||
blessed spawning primitive; pair it with
|
||||
``Portal.open_context()`` for SC-linked remote tasks.
|
||||
|
||||
One-shot task actors
|
||||
--------------------
|
||||
|
||||
.. autofunction:: tractor.to_actor.run
|
||||
|
||||
.. note::
|
||||
|
||||
:func:`tractor.to_actor.run` (parlance of
|
||||
``trio.to_thread.run_sync()`` and friends) is the
|
||||
*convenience* one-shot — spawn, run a single task, block on
|
||||
its result, reap — built entirely on
|
||||
:meth:`ActorNursery.start_actor` + :meth:`Portal.run` +
|
||||
:meth:`Portal.cancel_actor`, so don't design around it as the
|
||||
core model. It supersedes the removed (legacy, non-blocking)
|
||||
``ActorNursery.run_in_actor()``.
|
||||
:meth:`ActorNursery.run_in_actor` is a *convenience* one-shot —
|
||||
spawn, run a single task, auto-cancel after the result — slated
|
||||
to be rebuilt as a high-level wrapper, so don't design around
|
||||
it as the core model.
|
||||
|
||||
.. deprecated:: 0.1.0a6
|
||||
|
||||
|
|
@ -82,12 +71,14 @@ flowing back `exactly like trio`_.
|
|||
:members: run,
|
||||
run_from_ns,
|
||||
open_stream_from,
|
||||
wait_for_result,
|
||||
cancel_actor,
|
||||
chan
|
||||
|
||||
.. 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
|
||||
``enable_modules``. ``Portal.channel`` is the legacy spelling
|
||||
of :attr:`Portal.chan`.
|
||||
|
|
|
|||
|
|
@ -76,8 +76,8 @@ Just flip the flag on :meth:`tractor.ActorNursery.start_actor`:
|
|||
infect_asyncio=True,
|
||||
)
|
||||
|
||||
The one-shot convenience ``tractor.to_actor.run()`` accepts the
|
||||
same flag. The ``to_asyncio`` APIs may **only** be called from
|
||||
The one-shot convenience ``ActorNursery.run_in_actor()`` accepts
|
||||
the same flag. The ``to_asyncio`` APIs may **only** be called from
|
||||
tasks inside an infected actor; calling them anywhere else raises
|
||||
a loud ``RuntimeError``. You can introspect at runtime with
|
||||
``tractor.current_actor().is_infected_aio()``.
|
||||
|
|
@ -229,7 +229,7 @@ dialog, skip the channel ceremony and use
|
|||
|
||||
It schedules the fn as an ``asyncio.Task``, waits for completion
|
||||
and hands the return value back to ``trio``; think of it as the
|
||||
cross-loop sibling of ``tractor.to_actor.run()``. Errors and
|
||||
cross-loop sibling of ``ActorNursery.run_in_actor()``. Errors and
|
||||
cancellation are translated exactly as for channels.
|
||||
|
||||
Cross-loop errors and cancellation
|
||||
|
|
|
|||
|
|
@ -64,13 +64,11 @@ What's going on here?
|
|||
- three healthy actors are spawned as daemons via
|
||||
:meth:`tractor.ActorNursery.start_actor`; left alone they'd
|
||||
happily idle forever,
|
||||
- a fourth actor runs ``assert_err()`` via a blocking
|
||||
``tractor.to_actor.run()`` one-shot and promptly trips its
|
||||
``assert 0``,
|
||||
- a fourth actor runs ``assert_err()`` via ``.run_in_actor()`` and
|
||||
promptly trips its ``assert 0``,
|
||||
- the resulting ``AssertionError`` ships back over IPC as a
|
||||
serialized error msg and re-raises *boxed* right at the call
|
||||
inside the nursery block as a
|
||||
:class:`tractor.RemoteActorError`,
|
||||
serialized error msg and re-raises *boxed* inside the nursery
|
||||
block as a :class:`tractor.RemoteActorError`,
|
||||
- the nursery reacts like any ``trio`` nursery would: it cancels
|
||||
the three healthy siblings (graceful runtime-cancel requests,
|
||||
acks awaited), reaps all four processes, then re-raises,
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ a single `structured concurrency`_ (SC) scope over IPC.
|
|||
:alt: sequence diagram of the context handshake msg flow
|
||||
|
||||
Pretty much everything else is (or is slated to be) built on this
|
||||
one primitive: ``tractor.to_actor.run()`` is a convenience for
|
||||
"spawn, run the lone task, await the result, tear down"; plain
|
||||
one primitive: ``ActorNursery.run_in_actor()`` is a convenience
|
||||
for "spawn, open a context, await the result, tear down"; plain
|
||||
``Portal.run()`` RPC is planned to be re-implemented on top of it;
|
||||
the multi-process debugger's tree-wide REPL lock rides one. Grok
|
||||
this page and the rest of the library reads as convenience
|
||||
|
|
|
|||
|
|
@ -119,16 +119,15 @@ Run a func in a process
|
|||
|
||||
Even a pool can be overkill; "run this one async func in a
|
||||
subprocess and give me the result" is a one-liner via
|
||||
:func:`tractor.to_actor.run`,
|
||||
:meth:`tractor.ActorNursery.run_in_actor`,
|
||||
|
||||
.. literalinclude:: ../../examples/parallelism/single_func.py
|
||||
:caption: examples/parallelism/single_func.py
|
||||
:language: python
|
||||
|
||||
``to_actor.run()`` is a *convenience wrapper* — spawn an actor,
|
||||
run exactly one task in it, block on and return its result, reap
|
||||
— not the core spawning model (that's
|
||||
:meth:`tractor.ActorNursery.start_actor` plus
|
||||
``run_in_actor()`` is a *convenience wrapper* — spawn an actor, run
|
||||
exactly one task in it, reap on result — not the core spawning
|
||||
model (that's :meth:`tractor.ActorNursery.start_actor` plus
|
||||
:meth:`tractor.Portal.open_context`; see :doc:`/guide/context`).
|
||||
But for this fire-and-collect shape it's exactly the right amount
|
||||
of typing.
|
||||
|
|
|
|||
|
|
@ -80,30 +80,28 @@ One special namespace exists: ``'self'`` resolves to the remote
|
|||
how internal machinery (cancel requests, registry ops) travels;
|
||||
don't build your app on it.
|
||||
|
||||
One-shot subactors: ``to_actor.run()``
|
||||
--------------------------------------
|
||||
When a subactor's *entire job* is a single function call, skip
|
||||
the portal plumbing with :func:`tractor.to_actor.run`: spawn,
|
||||
run the lone task, return its result and reap the process — all
|
||||
in one blocking call:
|
||||
One-shot results: ``wait_for_result()``
|
||||
---------------------------------------
|
||||
A portal returned from
|
||||
:meth:`~tractor.ActorNursery.run_in_actor` has exactly one
|
||||
"main" task running remotely; that task's ``return`` value is
|
||||
delivered as the portal's *final result*:
|
||||
|
||||
.. 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:
|
||||
|
||||
- it blocks until the remote task returns, re-raising any
|
||||
remote error in the usual boxed form right in the calling
|
||||
task.
|
||||
- "placement" is composable: ``an=`` spawns from an existing
|
||||
actor-nursery, ``portal=`` reuses an already-running actor
|
||||
(no spawn/reap, just a ``Portal.run()``), and passing
|
||||
neither opens a private call-scoped nursery (booting the
|
||||
runtime if needed).
|
||||
- concurrency composes the plain ``trio`` way: schedule
|
||||
multiple ``run()`` calls into a local task nursery (see
|
||||
``examples/parallelism/to_actor_one_shots.py``).
|
||||
remote error in the usual boxed form.
|
||||
- once resolved it's idempotent: later calls return the same
|
||||
cached value.
|
||||
- a *daemon* portal (from ``start_actor()``) has no main task,
|
||||
so there's no final result to wait for: you'll get a warning
|
||||
plus a ``NoResult`` sentinel. Results of individual daemon
|
||||
calls come straight back from each ``await portal.run()``.
|
||||
|
||||
Pure RPC daemons: ``run_daemon()``
|
||||
----------------------------------
|
||||
|
|
|
|||
|
|
@ -103,22 +103,19 @@ What's going on here?
|
|||
on him **forever**. Daemon lifetimes are *yours* to end; that
|
||||
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
|
||||
an actor, run exactly one async function in it, block on the
|
||||
result, then reap the process — the distributed sibling of
|
||||
``trio.to_thread.run_sync()``.
|
||||
:meth:`~tractor.ActorNursery.run_in_actor` is the convenience
|
||||
wrapper: spawn an actor, run exactly one async function in it,
|
||||
then reap the process as soon as the result arrives.
|
||||
|
||||
.. code:: python
|
||||
|
||||
async with (
|
||||
tractor.open_nursery() as an,
|
||||
trio.open_nursery() as tn,
|
||||
):
|
||||
async with tractor.open_nursery() as an:
|
||||
portal = await an.run_in_actor(burn_cpu)
|
||||
# burn rubber in the parent too...
|
||||
tn.start_soon(burn_cpu)
|
||||
total = await tractor.to_actor.run(burn_cpu, an=an)
|
||||
await burn_cpu()
|
||||
total = await portal.wait_for_result()
|
||||
|
||||
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
|
||||
``enable_modules`` allowlist.
|
||||
- extra ``**kwargs`` are forwarded to the function itself.
|
||||
- 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).
|
||||
- the child is *auto-cancelled* once its "main" result lands;
|
||||
at nursery exit these run-once children are always reaped
|
||||
first (causality_ is paramount!).
|
||||
|
||||
.. note::
|
||||
|
||||
``to_actor.run()`` is a convenience, **not** the core model —
|
||||
it's built *entirely* on ``start_actor()`` + ``Portal.run()``
|
||||
+ ``Portal.cancel_actor()``. Teach your fingers to use it for
|
||||
quick fire-and-collect parallelism — think a per-function
|
||||
``run_in_actor()`` is a convenience, **not** the core model.
|
||||
The source literally marks it for an eventual rebuild as
|
||||
a thin "hilevel" wrapper on top of
|
||||
:meth:`~tractor.Portal.open_context` (the modern inter-actor
|
||||
task API). Teach your fingers to use it for quick
|
||||
fire-and-collect parallelism — think a per-function
|
||||
trio-parallel_ style one-shot — and reach for
|
||||
``start_actor()`` + ``open_context()`` for anything
|
||||
long-lived, stateful or streaming
|
||||
|
|
@ -151,9 +145,9 @@ Actor lifetimes and teardown order
|
|||
----------------------------------
|
||||
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)
|
||||
arrives back in the (blocking) call.
|
||||
arrives.
|
||||
- **daemon** (``start_actor()``): lives until *someone* cancels
|
||||
it — an explicit ``await portal.cancel_actor()``, a bulk
|
||||
``await an.cancel()``, or the one-cancels-all strategy kicking
|
||||
|
|
@ -161,12 +155,11 @@ So we have two lifetime flavors:
|
|||
|
||||
On a clean exit of the nursery block the teardown order is:
|
||||
|
||||
1. one-shot actors never make it to nursery exit: each is
|
||||
reaped inside its own ``to_actor.run()`` call, any error
|
||||
raising immediately in the calling task so your code
|
||||
(acting as supervisor) gets first crack at handling it.
|
||||
2. the nursery then waits on daemon actors — **indefinitely**.
|
||||
If you spawned a daemon, you own its lifetime.
|
||||
1. the nursery waits on every run-once actor's final result;
|
||||
any errors from these are raised immediately so your code
|
||||
(acting as supervisor) gets first crack at handling them.
|
||||
2. then it waits on daemon actors — **indefinitely**. If you
|
||||
spawned a daemon, you own its lifetime.
|
||||
|
||||
When a child *is* cancelled, teardown is graceful-first per SC
|
||||
discipline: the runtime sends an IPC cancel request and gives
|
||||
|
|
|
|||
|
|
@ -43,20 +43,24 @@ Run it::
|
|||
What's going on here?
|
||||
|
||||
- ``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
|
||||
framework takeover - it's just a ``trio`` app,
|
||||
- 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()``,
|
||||
- you get back a ``Portal``: your handle for invoking tasks in
|
||||
the new process's (separate!) memory domain. We lean on it
|
||||
much harder in the next section,
|
||||
- the subactor, *some_linguist*, boots a fresh ``trio.run()`` in
|
||||
a **new process** and executes ``cellar_door()`` as its *main
|
||||
task* (note the child proving it is *not* the root with
|
||||
``tractor.is_root_process()``), then ships the return value
|
||||
back over IPC,
|
||||
- the call *blocks* until that final result arrives, then
|
||||
returns it - causality is preserved: your task only proceeds
|
||||
once the child is *done*, dead, and reaped.
|
||||
- the parent grabs that *final result* with
|
||||
``await portal.wait_for_result()``, much like you'd expect
|
||||
from a "future" - except causality is preserved: the nursery
|
||||
block only exits once the child is *done*, dead, and reaped.
|
||||
|
||||
.. margin:: Just need a worker pool?
|
||||
|
||||
|
|
@ -67,22 +71,19 @@ What's going on here?
|
|||
|
||||
.. note::
|
||||
|
||||
``to_actor.run()`` (parlance of ``trio.to_thread`` and
|
||||
friends) is the *convenience* wrapper: one-shot
|
||||
``run_in_actor()`` is the *convenience* wrapper: one-shot
|
||||
spawn-run-reap semantics for when a subactor's entire job is
|
||||
a single function call. The core primitives are
|
||||
``ActorNursery.start_actor()`` (next up) — which hands you
|
||||
a ``Portal``, your handle for invoking tasks in the new
|
||||
process's (separate!) memory domain — paired with
|
||||
``ActorNursery.start_actor()`` (next up) paired with
|
||||
``Portal.open_context()`` for full, SC-linked cross-actor
|
||||
dialogs - see :doc:`/guide/context`.
|
||||
|
||||
Daemon actors and RPC
|
||||
---------------------
|
||||
A ``to_actor.run()`` one-shot subactor terminates when its lone
|
||||
task returns. But often you want long-lived *daemon* actors
|
||||
instead: spawned once, then serving (allowlisted) RPC requests
|
||||
until told otherwise. That's ``start_actor()``:
|
||||
A ``run_in_actor()``-spawned actor terminates when its main task
|
||||
returns. But often you want long-lived *daemon* actors instead:
|
||||
spawned once, then serving (allowlisted) RPC requests until told
|
||||
otherwise. That's ``start_actor()``:
|
||||
|
||||
.. literalinclude:: ../../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:
|
||||
|
||||
- a ``to_actor.run()`` one-shot actor lives exactly as long as
|
||||
its lone task; the call blocks until that function (and thus
|
||||
the process) completes,
|
||||
- a ``run_in_actor()`` actor lives exactly as long as its main
|
||||
task; the nursery waits for that function (and thus the
|
||||
process) to complete before unblocking,
|
||||
- a ``start_actor()`` actor *lives forever* - an RPC daemon the
|
||||
nursery will happily wait on **indefinitely** - until some
|
||||
task explicitly cancels it via ``Portal.cancel_actor()`` (as
|
||||
|
|
|
|||
|
|
@ -17,37 +17,26 @@ async def say_hello(other_actor):
|
|||
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():
|
||||
"""Main tractor entry point, the "master" process (for now
|
||||
acts as the "director").
|
||||
"""
|
||||
async with (
|
||||
tractor.open_nursery() as an,
|
||||
trio.open_nursery() as tn,
|
||||
):
|
||||
async with tractor.open_nursery() as n:
|
||||
print("Alright... Action!")
|
||||
|
||||
# both actors wait on the *other* to register so their
|
||||
# one-shots must run concurrently.
|
||||
tn.start_soon(run_and_print, an, 'donny', 'gretchen')
|
||||
tn.start_soon(run_and_print, an, 'gretchen', 'donny')
|
||||
|
||||
donny = await n.run_in_actor(
|
||||
say_hello,
|
||||
name='donny',
|
||||
# arguments are always named
|
||||
other_actor='gretchen',
|
||||
)
|
||||
gretchen = await n.run_in_actor(
|
||||
say_hello,
|
||||
name='gretchen',
|
||||
other_actor='donny',
|
||||
)
|
||||
print(await gretchen.wait_for_result())
|
||||
print(await donny.wait_for_result())
|
||||
print("CUTTTT CUUTT CUT!!! Donny!! You're supposed to say...")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,14 +10,17 @@ async def cellar_door():
|
|||
async def main():
|
||||
"""The main ``tractor`` routine.
|
||||
"""
|
||||
# spawn a subactor, run ``cellar_door()`` as its lone task,
|
||||
# block until its result arrives and the subactor is reaped.
|
||||
print(
|
||||
await tractor.to_actor.run(
|
||||
async with tractor.open_nursery() as n:
|
||||
|
||||
portal = await n.run_in_actor(
|
||||
cellar_door,
|
||||
name='some_linguist',
|
||||
)
|
||||
)
|
||||
|
||||
# The ``async with`` will unblock here since the 'some_linguist'
|
||||
# actor has completed its main task ``cellar_door``.
|
||||
|
||||
print(await portal.wait_for_result())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
from functools import partial
|
||||
|
||||
import trio
|
||||
import tractor
|
||||
|
||||
|
|
@ -23,36 +21,25 @@ async def breakpoint_forever():
|
|||
async def spawn_until(depth=0):
|
||||
""""A nested nursery that triggers another ``NameError``.
|
||||
"""
|
||||
async with (
|
||||
tractor.open_nursery() as an,
|
||||
trio.open_nursery() as tn,
|
||||
):
|
||||
async with tractor.open_nursery() as n:
|
||||
if depth < 1:
|
||||
|
||||
tn.start_soon(
|
||||
partial(
|
||||
tractor.to_actor.run,
|
||||
breakpoint_forever,
|
||||
an=an,
|
||||
)
|
||||
)
|
||||
await n.run_in_actor(breakpoint_forever)
|
||||
|
||||
p = await n.run_in_actor(
|
||||
name_error,
|
||||
name='name_error'
|
||||
)
|
||||
await trio.sleep(0.5)
|
||||
# rx and propagate error from child
|
||||
await tractor.to_actor.run(
|
||||
name_error,
|
||||
an=an,
|
||||
name='name_error',
|
||||
)
|
||||
await p.result()
|
||||
|
||||
else:
|
||||
# recusrive call to spawn another process branching layer of
|
||||
# the tree; blocks (up) each level until the leaf's
|
||||
# `name_error` relays through.
|
||||
# the tree
|
||||
depth -= 1
|
||||
await tractor.to_actor.run(
|
||||
await n.run_in_actor(
|
||||
spawn_until,
|
||||
an=an,
|
||||
depth=depth,
|
||||
name=f'spawn_until_{depth}',
|
||||
)
|
||||
|
|
@ -78,33 +65,34 @@ async def main():
|
|||
└─ python -m tractor._child --uid ('spawn_until_0', 'de918e6d ...)
|
||||
|
||||
"""
|
||||
async with (
|
||||
tractor.open_nursery(
|
||||
async with tractor.open_nursery(
|
||||
debug_mode=True,
|
||||
loglevel='pdb',
|
||||
) as an,
|
||||
trio.open_nursery() as tn,
|
||||
):
|
||||
# spawn both spawner trees as concurrent one-shots; the
|
||||
# first tree's (relayed) error cancels the other.
|
||||
tn.start_soon(
|
||||
partial(
|
||||
tractor.to_actor.run,
|
||||
) as n:
|
||||
|
||||
# spawn both actors
|
||||
portal = await n.run_in_actor(
|
||||
spawn_until,
|
||||
an=an,
|
||||
depth=3,
|
||||
name='spawner0',
|
||||
)
|
||||
)
|
||||
tn.start_soon(
|
||||
partial(
|
||||
tractor.to_actor.run,
|
||||
portal1 = await n.run_in_actor(
|
||||
spawn_until,
|
||||
an=an,
|
||||
depth=4,
|
||||
name='spawner1',
|
||||
)
|
||||
)
|
||||
|
||||
# TODO: test this case as well where the parent don't see
|
||||
# the sub-actor errors by default and instead expect a user
|
||||
# ctrl-c to kill the root.
|
||||
with trio.move_on_after(3):
|
||||
await trio.sleep_forever()
|
||||
|
||||
# gah still an issue here.
|
||||
await portal.result()
|
||||
|
||||
# should never get here
|
||||
await portal1.result()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@ async def spawn_error():
|
|||
""""A nested nursery that triggers another ``NameError``.
|
||||
"""
|
||||
async with tractor.open_nursery() as n:
|
||||
return await tractor.to_actor.run(
|
||||
portal = await n.run_in_actor(
|
||||
name_error,
|
||||
an=n,
|
||||
name='name_error_1',
|
||||
)
|
||||
return await portal.result()
|
||||
|
||||
|
||||
async def main():
|
||||
|
|
@ -38,36 +38,29 @@ async def main():
|
|||
- root actor should then fail on assert
|
||||
- program termination
|
||||
"""
|
||||
async with (
|
||||
tractor.open_nursery(
|
||||
async with tractor.open_nursery(
|
||||
debug_mode=True,
|
||||
loglevel='devx',
|
||||
) as an,
|
||||
trio.open_nursery() as tn,
|
||||
):
|
||||
# spawn both actors..
|
||||
portal = await an.start_actor(
|
||||
'name_error',
|
||||
enable_modules=[__name__],
|
||||
)
|
||||
portal1 = await an.start_actor(
|
||||
'spawn_error',
|
||||
enable_modules=[__name__],
|
||||
)
|
||||
) as n:
|
||||
|
||||
# ..and bg-schedule their erroring tasks.
|
||||
tn.start_soon(portal.run, name_error)
|
||||
tn.start_soon(portal1.run, spawn_error)
|
||||
|
||||
# yield to the bg tasks so both RPC requests are
|
||||
# submitted (and start crashing) before the root's own
|
||||
# error below (the legacy `run_in_actor()` submitted
|
||||
# in-line with each spawn).
|
||||
await trio.sleep(0.5)
|
||||
# spawn both actors
|
||||
portal = await n.run_in_actor(
|
||||
name_error,
|
||||
name='name_error',
|
||||
)
|
||||
portal1 = await n.run_in_actor(
|
||||
spawn_error,
|
||||
name='spawn_error',
|
||||
)
|
||||
|
||||
# trigger a root actor error
|
||||
assert 0
|
||||
|
||||
# attempt to collect results (which raises error in parent)
|
||||
# still has some issues where the parent seems to get stuck
|
||||
await portal.result()
|
||||
await portal1.result()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
trio.run(main)
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ async def spawn_error():
|
|||
""""A nested nursery that triggers another ``NameError``.
|
||||
"""
|
||||
async with tractor.open_nursery() as n:
|
||||
return await tractor.to_actor.run(
|
||||
portal = await n.run_in_actor(
|
||||
name_error,
|
||||
an=n,
|
||||
name='name_error_1',
|
||||
)
|
||||
return await portal.result()
|
||||
|
||||
|
||||
async def main():
|
||||
|
|
@ -36,39 +36,17 @@ async def main():
|
|||
`-python -m tractor._child --uid ('spawn_error', '52ee14a5 ...)
|
||||
`-python -m tractor._child --uid ('name_error', '3391222c ...)
|
||||
"""
|
||||
errors: list[BaseException] = []
|
||||
|
||||
async with tractor.open_nursery(
|
||||
debug_mode=True,
|
||||
# loglevel='runtime',
|
||||
) as an:
|
||||
) as n:
|
||||
|
||||
async def run_and_collect(fn):
|
||||
'''
|
||||
One-shot whose (boxed) error is stashed instead of
|
||||
raised so a sibling's crash never cancels the others
|
||||
before they've had their own debugger sessions (the
|
||||
"collect all errors" the legacy `run_in_actor()` API
|
||||
did implicitly at nursery teardown).
|
||||
|
||||
'''
|
||||
try:
|
||||
await tractor.to_actor.run(fn, an=an)
|
||||
except tractor.RemoteActorError as rae:
|
||||
errors.append(rae)
|
||||
|
||||
# Spawn all one-shot task actors, collecting (vs.
|
||||
# raising) their errors.
|
||||
async with trio.open_nursery() as tn:
|
||||
tn.start_soon(run_and_collect, breakpoint_forever)
|
||||
tn.start_soon(run_and_collect, name_error)
|
||||
tn.start_soon(run_and_collect, spawn_error)
|
||||
|
||||
if errors:
|
||||
raise BaseExceptionGroup(
|
||||
'multi_subactors errored!',
|
||||
errors,
|
||||
)
|
||||
# Spawn both actors, don't bother with collecting results
|
||||
# (would result in a different debugger outcome due to parent's
|
||||
# cancellation).
|
||||
await n.run_in_actor(breakpoint_forever)
|
||||
await n.run_in_actor(name_error)
|
||||
await n.run_in_actor(spawn_error)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
from functools import partial
|
||||
|
||||
import trio
|
||||
import tractor
|
||||
|
||||
|
|
@ -12,14 +10,14 @@ async def name_error():
|
|||
async def spawn_until(depth=0):
|
||||
""""A nested nursery that triggers another ``NameError``.
|
||||
"""
|
||||
async with tractor.open_nursery() as an:
|
||||
async with tractor.open_nursery() as n:
|
||||
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:
|
||||
depth -= 1
|
||||
await tractor.to_actor.run(
|
||||
await n.run_in_actor(
|
||||
spawn_until,
|
||||
an=an,
|
||||
depth=depth,
|
||||
name=f'spawn_until_{depth}',
|
||||
)
|
||||
|
|
@ -39,33 +37,28 @@ async def main():
|
|||
└─ python -m tractor._child --uid ('name_error', '6c2733b8 ...)
|
||||
|
||||
'''
|
||||
async with (
|
||||
tractor.open_nursery(
|
||||
async with tractor.open_nursery(
|
||||
debug_mode=True,
|
||||
enable_transports=['uds'], # TODO, apss this via osenv?
|
||||
loglevel='devx', # XXX, required for test!
|
||||
) as an,
|
||||
trio.open_nursery() as tn,
|
||||
):
|
||||
# spawn the deeper tree in the bg..
|
||||
tn.start_soon(
|
||||
partial(
|
||||
tractor.to_actor.run,
|
||||
spawn_until,
|
||||
an=an,
|
||||
depth=1,
|
||||
name='spawner1',
|
||||
)
|
||||
)
|
||||
) as n:
|
||||
|
||||
# ..while blocking on the shallow (faster to fail) tree
|
||||
# whose propagated error triggers nursery cancellation.
|
||||
await tractor.to_actor.run(
|
||||
# spawn both actors
|
||||
portal = await n.run_in_actor(
|
||||
spawn_until,
|
||||
an=an,
|
||||
depth=0,
|
||||
name='spawner0',
|
||||
)
|
||||
portal1 = await n.run_in_actor(
|
||||
spawn_until,
|
||||
depth=1,
|
||||
name='spawner1',
|
||||
)
|
||||
|
||||
# nursery cancellation should be triggered due to propagated
|
||||
# error from child.
|
||||
await portal.result()
|
||||
await portal1.result()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
|
|
@ -13,24 +13,17 @@ async def main():
|
|||
simultaneously.
|
||||
|
||||
'''
|
||||
async with (
|
||||
tractor.open_nursery(
|
||||
async with tractor.open_nursery(
|
||||
debug_mode=True,
|
||||
# loglevel='debug' # ?XXX required?
|
||||
) as n,
|
||||
trio.open_nursery() as tn,
|
||||
):
|
||||
# spawn the actor..
|
||||
portal = await n.start_actor(
|
||||
'key_error',
|
||||
enable_modules=[__name__],
|
||||
)
|
||||
) as n:
|
||||
|
||||
# spawn both actors
|
||||
portal = await n.run_in_actor(key_error)
|
||||
print(
|
||||
f'Child is up @ {portal.chan.aid.reprol()}'
|
||||
)
|
||||
# ..then schedule its erroring task in the bg while the
|
||||
# root blocks below.
|
||||
tn.start_soon(portal.run, key_error)
|
||||
|
||||
|
||||
# XXX: originally a bug caused by this is where root would enter
|
||||
# the debugger and clobber the tty used by the repl even though
|
||||
|
|
|
|||
|
|
@ -75,10 +75,10 @@ async def main():
|
|||
async with tractor.open_nursery(
|
||||
debug_mode=True,
|
||||
) as n:
|
||||
await tractor.to_actor.run(
|
||||
portal: tractor.Portal = await n.run_in_actor(
|
||||
cancelled_before_pause,
|
||||
an=n,
|
||||
)
|
||||
await portal.wait_for_result()
|
||||
|
||||
# ensure the same works in the root actor!
|
||||
await pm_on_cancelled()
|
||||
|
|
|
|||
|
|
@ -19,12 +19,10 @@ async def main():
|
|||
loglevel='cancel',
|
||||
) as n:
|
||||
|
||||
# parks awaiting a result which only arrives once the
|
||||
# user quits (`BdbQuit`s) the child's REPL loop.
|
||||
await tractor.to_actor.run(
|
||||
portal = await n.run_in_actor(
|
||||
breakpoint_forever,
|
||||
an=n,
|
||||
)
|
||||
await portal.wait_for_result()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
|
|
@ -12,12 +12,16 @@ async def main():
|
|||
) as an:
|
||||
|
||||
# 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()
|
||||
p: tractor.Portal = await an.run_in_actor(name_error)
|
||||
|
||||
# the one-shot blocks on the subactor's result so the
|
||||
# boxed `NameError` raises right here.
|
||||
await tractor.to_actor.run(name_error, an=an)
|
||||
# with this style, should raise on this line
|
||||
await p.wait_for_result()
|
||||
|
||||
# with this alt style should raise at `open_nusery()`
|
||||
# return await p.wait_for_result()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ async def main() -> None:
|
|||
|
||||
# TODO: 3 sub-actor usage cases:
|
||||
# -[x] via a `.open_context()`
|
||||
# -[ ] via a `to_actor.run()` call
|
||||
# -[ ] via a `.run_in_actor()` call
|
||||
# -[ ] via a `.run()`
|
||||
# -[ ] via a `.to_thread.run_sync()` in subactor
|
||||
async with p.open_context(
|
||||
|
|
|
|||
|
|
@ -25,15 +25,17 @@ async def burn_cpu():
|
|||
|
||||
async def main():
|
||||
|
||||
async with trio.open_nursery() as tn:
|
||||
async with tractor.open_nursery() as n:
|
||||
|
||||
portal = await n.run_in_actor(burn_cpu)
|
||||
|
||||
# burn rubber in the parent too
|
||||
tn.start_soon(burn_cpu)
|
||||
await burn_cpu()
|
||||
|
||||
# run the same func as the lone task in a subactor,
|
||||
# block on (and collect) its result
|
||||
pid = await tractor.to_actor.run(burn_cpu)
|
||||
# wait on result from target function
|
||||
pid = await portal.wait_for_result()
|
||||
|
||||
# end of nursery block
|
||||
print(f"Collected subproc {pid}")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,12 +15,11 @@ async def main():
|
|||
enable_modules=[__name__],
|
||||
))
|
||||
|
||||
# run one one-shot task actor that will fail immediately;
|
||||
# its error raises right here in the caller's task..
|
||||
await tractor.to_actor.run(assert_err, an=n)
|
||||
# start one actor that will fail immediately
|
||||
await n.run_in_actor(assert_err)
|
||||
|
||||
# ..as a ``RemoteActorError`` containing an ``AssertionError``
|
||||
# and all the other actors have been cancelled
|
||||
# should error here with a ``RemoteActorError`` containing
|
||||
# an ``AssertionError`` and all the other actors have been cancelled
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
|
|
@ -849,36 +849,43 @@ def test_multi_nested_subactors_error_through_nurseries(
|
|||
break
|
||||
|
||||
# boxed source errors
|
||||
#
|
||||
# NB post-#477 (`to_actor.run()` one-shots in local
|
||||
# task-nurseries) the final relay is the LAST-released
|
||||
# (leaf) REPL's error chain: it wins each level's
|
||||
# relay-vs-cancel race so every level's single-member
|
||||
# group gets unwrapped by the runtime's `collapse_eg()`
|
||||
# (annotated at each actor boundary) while the sibling
|
||||
# tree ('spawner1') is cancelled + absorbed. The legacy
|
||||
# `run_in_actor()` teardown-reap instead grouped BOTH the
|
||||
# `name_error` and bp-quit chains into the final dump
|
||||
# (the previously-unexplained "extra" patterns).
|
||||
expect_patts: list[str] = [
|
||||
"NameError: name 'doggypants' is not defined",
|
||||
"tractor._exceptions.RemoteActorError:",
|
||||
"('name_error'",
|
||||
|
||||
# each level's unwrapped-single-member-group
|
||||
# annotation + the first-level subtree's boundary
|
||||
# footer.
|
||||
"( ^^^ this exc was collapsed from a group ^^^ )",
|
||||
"------ ('spawner0'",
|
||||
# first level subtrees
|
||||
# "tractor._exceptions.RemoteActorError: ('spawner0'",
|
||||
"src_uid=('spawner0'",
|
||||
|
||||
# "tractor._exceptions.RemoteActorError: ('spawner1'",
|
||||
|
||||
# propagation of errors up through nested subtrees
|
||||
# "tractor._exceptions.RemoteActorError: ('spawn_until_0'",
|
||||
# "tractor._exceptions.RemoteActorError: ('spawn_until_1'",
|
||||
# "tractor._exceptions.RemoteActorError: ('spawn_until_2'",
|
||||
# ^-NOTE-^ old RAE repr, new one is below with a field
|
||||
# showing the src actor's uid.
|
||||
"src_uid=('spawn_until_2'",
|
||||
]
|
||||
# XXX, I HAVE NO IDEA why these patts only show on the
|
||||
# `trio`-spawner but it seems to have something to do with
|
||||
# what gets dumped in prior-prompt latches somehow??
|
||||
# TODO for claude, explain and or work through how this is
|
||||
# happening but ONLY WHEN RUN FROM THE TEST, bc when i try to
|
||||
# run the test script manually the correct output ALWAYS seems
|
||||
# to be in the last `str(child.before.decode())` output !?!?
|
||||
if (
|
||||
not is_forking_spawner
|
||||
and
|
||||
last_send_char == 'q'
|
||||
):
|
||||
expect_patts += [
|
||||
# expect the pdb-quit exc relayed from the leaf's
|
||||
# bp-loop child.
|
||||
# expect the pdb-quit exc.
|
||||
"bdb.BdbQuit",
|
||||
"src_uid=('breakpoint_forever'",
|
||||
# BUT WHY these dude!?
|
||||
"src_uid=('spawn_until_0'",
|
||||
"relay_uid=('spawn_until_1'",
|
||||
]
|
||||
|
||||
assert_before(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ Advanced streaming patterns using bidirectional streams and contexts.
|
|||
|
||||
'''
|
||||
from collections import Counter
|
||||
from functools import partial
|
||||
import itertools
|
||||
import platform
|
||||
from typing import Type
|
||||
|
|
@ -174,8 +173,8 @@ def test_dynamic_pub_sub(
|
|||
# test. Picked backend-aware: under `trio` backend spawn is
|
||||
# cheap (~1s for `cpus` actors) but fork-based backends pay
|
||||
# a per-spawn cost (forkserver round-trip + IPC peer-handshake)
|
||||
# that can stack up over the `cpus - 1` one-shot
|
||||
# (`to_actor.run()`) spawns — especially on UDS under cross-pytest contention
|
||||
# that can stack up over `cpus - 1` sequential `n.run_in_actor()`
|
||||
# calls — especially on UDS under cross-pytest contention
|
||||
# (#451 / #452). 4s was flaking right at the edge under fork
|
||||
# backends — bumped to 8s with diag-snapshot-on-timeout via
|
||||
# `fail_after_w_trace` so a borderline run still fails loud
|
||||
|
|
@ -215,56 +214,34 @@ def test_dynamic_pub_sub(
|
|||
f'enter `fail_after_w_trace({fail_after_s})` scope'
|
||||
)
|
||||
try:
|
||||
async with (
|
||||
tractor.open_nursery(
|
||||
async with tractor.open_nursery(
|
||||
registry_addrs=[reg_addr],
|
||||
debug_mode=debug_mode,
|
||||
) 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,
|
||||
):
|
||||
) as n:
|
||||
test_log.cancel(
|
||||
'test_dynamic_pub_sub: '
|
||||
'actor nursery opened'
|
||||
)
|
||||
|
||||
# name of this actor will be same as target func
|
||||
tn.start_soon(
|
||||
partial(
|
||||
tractor.to_actor.run,
|
||||
publisher,
|
||||
an=n,
|
||||
)
|
||||
)
|
||||
await n.run_in_actor(publisher)
|
||||
|
||||
for i, sub in zip(
|
||||
range(cpus - 2),
|
||||
itertools.cycle(_registry.keys())
|
||||
):
|
||||
tn.start_soon(
|
||||
partial(
|
||||
tractor.to_actor.run,
|
||||
await n.run_in_actor(
|
||||
consumer,
|
||||
an=n,
|
||||
name=f'consumer_{sub}',
|
||||
subs=[sub],
|
||||
)
|
||||
)
|
||||
|
||||
# make one dynamic subscriber
|
||||
tn.start_soon(
|
||||
partial(
|
||||
tractor.to_actor.run,
|
||||
await n.run_in_actor(
|
||||
consumer,
|
||||
an=n,
|
||||
name='consumer_dynamic',
|
||||
subs=list(_registry.keys()),
|
||||
)
|
||||
)
|
||||
|
||||
# block until "cancelled by user"
|
||||
await trio.sleep(3)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
Cancellation and error propagation
|
||||
|
||||
"""
|
||||
from functools import partial
|
||||
import os
|
||||
import signal
|
||||
import platform
|
||||
|
|
@ -17,10 +16,7 @@ from tractor._testing import (
|
|||
tractor_test,
|
||||
)
|
||||
from tractor._testing.trace import FailAfterWTraceFactory
|
||||
from tractor.trionics import (
|
||||
collapse_eg,
|
||||
gather_contexts,
|
||||
)
|
||||
from tractor.trionics import gather_contexts
|
||||
from .conftest import no_windows
|
||||
|
||||
|
||||
|
|
@ -309,30 +305,30 @@ async def test_cancel_infinite_streamer(
|
|||
@pytest.mark.parametrize(
|
||||
'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),
|
||||
(2, 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, {}),
|
||||
(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
|
||||
(3, tractor.RemoteActorError, AssertionError,
|
||||
(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
|
||||
(3, BaseExceptionGroup, AssertionError,
|
||||
(assert_err, {'delay': 1}), (do_nuthin, {}, False)),
|
||||
],
|
||||
ids=[
|
||||
'1_one_shot_fails',
|
||||
'2_one_shots_fail',
|
||||
'3_one_shots_fail',
|
||||
'1_run_in_actor_fails',
|
||||
'2_run_in_actors_fail',
|
||||
'3_run_in_actors_fail',
|
||||
'1_daemon_actors_fail',
|
||||
'1_daemon_actors_fail_all_one_shots_dun_quick',
|
||||
'no_daemon_actors_fail_all_one_shots_sleep_then_fail',
|
||||
'1_daemon_actors_fail_all_run_in_actors_dun_quick',
|
||||
'no_daemon_actors_fail_all_run_in_actors_sleep_then_fail',
|
||||
],
|
||||
)
|
||||
@tractor_test(
|
||||
|
|
@ -351,22 +347,12 @@ async def test_some_cancels_all(
|
|||
|
||||
This is the first and only supervisory strategy at the moment.
|
||||
|
||||
One-shot subactors run as concurrent `to_actor.run()` tasks
|
||||
in a local task-nursery so their errors raise WHILE the
|
||||
actor-nursery block is still open (vs the legacy
|
||||
`run_in_actor()` teardown-reap); the first error cancels the
|
||||
sibling one-shots (whose `trio.Cancelled`s the task-nursery
|
||||
absorbs) so the group shape is 1..num_actors
|
||||
`RemoteActorError`s depending on relay-vs-cancel timing —
|
||||
with `collapse_eg()` unwrapping the deterministic
|
||||
single-error cases to a bare `RemoteActorError`.
|
||||
|
||||
'''
|
||||
(
|
||||
num_actors,
|
||||
first_err,
|
||||
err_type,
|
||||
one_shot_func,
|
||||
ria_func,
|
||||
da_func,
|
||||
) = num_actors_and_errs
|
||||
try:
|
||||
|
|
@ -380,30 +366,23 @@ async def test_some_cancels_all(
|
|||
enable_modules=[__name__],
|
||||
))
|
||||
|
||||
func, kwargs = one_shot_func
|
||||
async with (
|
||||
collapse_eg(),
|
||||
trio.open_nursery() as tn,
|
||||
):
|
||||
func, kwargs = ria_func
|
||||
riactor_portals = []
|
||||
for i in range(num_actors):
|
||||
# schedule one-shot task actor(s); errors
|
||||
# raise into this task-nursery scope.
|
||||
tn.start_soon(
|
||||
partial(
|
||||
tractor.to_actor.run,
|
||||
# start actor(s) that will fail immediately
|
||||
riactor_portals.append(
|
||||
await an.run_in_actor(
|
||||
func,
|
||||
an=an,
|
||||
name=f'actor_{i}',
|
||||
**kwargs,
|
||||
**kwargs
|
||||
)
|
||||
)
|
||||
|
||||
if da_func:
|
||||
func, kwargs, expect_error = da_func
|
||||
for portal in dactor_portals:
|
||||
# if this function fails then we should error
|
||||
# here and the nursery should teardown all
|
||||
# other actors
|
||||
# if this function fails then we should error here
|
||||
# and the nursery should teardown all other actors
|
||||
try:
|
||||
await portal.run(func, **kwargs)
|
||||
|
||||
|
|
@ -420,24 +399,18 @@ async def test_some_cancels_all(
|
|||
pytest.fail(
|
||||
"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 (
|
||||
BaseExceptionGroup,
|
||||
tractor.RemoteActorError,
|
||||
) as _err:
|
||||
except first_err as _err:
|
||||
err = _err
|
||||
if isinstance(err, BaseExceptionGroup):
|
||||
# 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
|
||||
assert len(err.exceptions) == num_actors
|
||||
for exc in err.exceptions:
|
||||
assert isinstance(exc, tractor.RemoteActorError)
|
||||
if isinstance(exc, tractor.RemoteActorError):
|
||||
assert exc.boxed_type == err_type
|
||||
else:
|
||||
assert isinstance(exc, trio.Cancelled)
|
||||
elif isinstance(err, tractor.RemoteActorError):
|
||||
assert err.boxed_type == err_type
|
||||
|
||||
assert an.cancel_called is True
|
||||
|
|
@ -450,20 +423,8 @@ async def spawn_and_error(
|
|||
breadth: int,
|
||||
depth: int,
|
||||
) -> None:
|
||||
'''
|
||||
Recursively spawn a breadth-wide level of erroring one-shot
|
||||
subactors as concurrent `to_actor.run()` tasks; the leaf level
|
||||
errors ~simultaneously and each level's task-nursery groups
|
||||
whatever `RemoteActorError`s relay before the first one's
|
||||
cancel wins, boxing the (`ExceptionGroup`-shaped) group into
|
||||
this actor's own relayed error.
|
||||
|
||||
'''
|
||||
name = tractor.current_actor().name
|
||||
async with (
|
||||
tractor.open_nursery() as an,
|
||||
trio.open_nursery() as tn,
|
||||
):
|
||||
async with tractor.open_nursery() as nursery:
|
||||
for i in range(breadth):
|
||||
|
||||
if depth > 0:
|
||||
|
|
@ -483,14 +444,7 @@ async def spawn_and_error(
|
|||
kwargs = {
|
||||
'name': f'{name}_errorer_{i}',
|
||||
}
|
||||
tn.start_soon(
|
||||
partial(
|
||||
tractor.to_actor.run,
|
||||
*args,
|
||||
an=an,
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
await nursery.run_in_actor(*args, **kwargs)
|
||||
|
||||
|
||||
# NOTE: `main_thread_forkserver` capture-fd hang class is no
|
||||
|
|
@ -532,11 +486,7 @@ async def test_nested_multierrors(
|
|||
depth: int,
|
||||
):
|
||||
'''
|
||||
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.
|
||||
Test that failed actor sets are wrapped in `BaseExceptionGroup`s.
|
||||
|
||||
Parametrized over recursion `depth ∈ {1, 3}`:
|
||||
|
||||
|
|
@ -586,13 +536,6 @@ async def test_nested_multierrors(
|
|||
# fork-spawn jitter + UDS-contention widens both `t1` and
|
||||
# `t2` further.
|
||||
#
|
||||
# NB post-#477 (`to_actor.run()` fan-out in a local
|
||||
# task-nursery) a race-tripped sibling's `Cancelled` is
|
||||
# ABSORBED by the task-nursery instead of landing in the
|
||||
# group — the raced case now shows as a *smaller* BEG, so
|
||||
# this marker should consistently `xpass`; drop it once CI
|
||||
# confirms.
|
||||
#
|
||||
# With `strict=False` the clean-cascade cases (most
|
||||
# depth=1 runs, rare depth=3 runs) report as `xpassed`
|
||||
# while the race-tripped cases report as `xfailed` —
|
||||
|
|
@ -677,14 +620,6 @@ async def test_nested_multierrors(
|
|||
timeout = 16
|
||||
case ('main_thread_forkserver', 3):
|
||||
timeout = 30
|
||||
# any other fork-based backend (`mp_spawn` et al) pays
|
||||
# the same per-spawn round-trip costs as MTF so rides
|
||||
# its budgets; without a default arm `timeout` is left
|
||||
# unbound -> `UnboundLocalError` at the scaling below.
|
||||
case (_, 1):
|
||||
timeout = 16
|
||||
case (_, 3):
|
||||
timeout = 30
|
||||
|
||||
# inflate the budget by the throttle headroom probed above so
|
||||
# a slow box doesn't masquerade as a deadline regression.
|
||||
|
|
@ -697,81 +632,66 @@ async def test_nested_multierrors(
|
|||
|
||||
async with fail_after_w_trace(timeout):
|
||||
try:
|
||||
async with (
|
||||
tractor.open_nursery() as nursery,
|
||||
trio.open_nursery() as tn,
|
||||
):
|
||||
async with tractor.open_nursery() as nursery:
|
||||
for i in range(subactor_breadth):
|
||||
tn.start_soon(
|
||||
partial(
|
||||
tractor.to_actor.run,
|
||||
await nursery.run_in_actor(
|
||||
spawn_and_error,
|
||||
an=nursery,
|
||||
name=f'spawner_{i}',
|
||||
breadth=subactor_breadth,
|
||||
depth=depth,
|
||||
)
|
||||
)
|
||||
except (
|
||||
BaseExceptionGroup,
|
||||
tractor.RemoteActorError,
|
||||
) as err:
|
||||
# group membership is bounded by the relay-vs-cancel
|
||||
# race: the first spawner-tree's error cancels its
|
||||
# siblings, whose own errors only group when relayed
|
||||
# first; a fully-raced tree even collapses (via the
|
||||
# runtime's own `collapse_eg()` unwrapping each level's
|
||||
# single-member group) to a bare `RemoteActorError`
|
||||
# re-boxing the leaf `AssertionError` at every actor
|
||||
# boundary. The deterministic exact-breadth nested-BEG
|
||||
# was the legacy `run_in_actor()` reap-all-at-teardown.
|
||||
subexcs: list[BaseException] = (
|
||||
err.exceptions
|
||||
if isinstance(err, BaseExceptionGroup)
|
||||
else [err]
|
||||
)
|
||||
assert 1 <= len(subexcs) <= subactor_breadth
|
||||
for subexc in subexcs:
|
||||
if (
|
||||
_friggin_windows
|
||||
and
|
||||
isinstance(subexc, trio.Cancelled)
|
||||
):
|
||||
except BaseExceptionGroup as err:
|
||||
assert len(err.exceptions) == subactor_breadth
|
||||
for subexc in err.exceptions:
|
||||
|
||||
# verify first level actor errors are wrapped as remote
|
||||
if _friggin_windows:
|
||||
|
||||
# windows is often too slow and cancellation seems
|
||||
# to happen before an actor is spawned
|
||||
if isinstance(subexc, trio.Cancelled):
|
||||
continue
|
||||
|
||||
assert isinstance(subexc, tractor.RemoteActorError)
|
||||
|
||||
accepted: tuple[Type[BaseException], ...] = (
|
||||
# ≥2 sub-tree errors relayed before the
|
||||
# cancel-cascade won → grouped per-level.
|
||||
ExceptionGroup,
|
||||
# every level collapsed down to its lone
|
||||
# relayed (leaf) error.
|
||||
AssertionError,
|
||||
# a mid-level spawner relays an
|
||||
# already-boxed (collapsed) leaf chain,
|
||||
# re-boxing the `RemoteActorError` itself.
|
||||
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,
|
||||
# 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`.
|
||||
trio.Cancelled,
|
||||
BaseExceptionGroup,
|
||||
)
|
||||
if _friggin_windows:
|
||||
# on windows it seems we can't exactly be
|
||||
# sure wtf will happen..
|
||||
accepted += (
|
||||
|
||||
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,
|
||||
)
|
||||
assert subexc.boxed_type in accepted
|
||||
else:
|
||||
pytest.fail(
|
||||
'Should have raised a (grouped) `RemoteActorError`?'
|
||||
assert isinstance(subexc, tractor.RemoteActorError)
|
||||
|
||||
if depth > 0 and subactor_breadth > 1:
|
||||
# XXX not sure what's up with this..
|
||||
# on windows sometimes spawning is just too slow and
|
||||
# we get back the (sent) cancel signal instead
|
||||
if _friggin_windows:
|
||||
if isinstance(subexc, tractor.RemoteActorError):
|
||||
assert subexc.boxed_type in (
|
||||
BaseExceptionGroup,
|
||||
tractor.RemoteActorError
|
||||
)
|
||||
else:
|
||||
assert isinstance(subexc, BaseExceptionGroup)
|
||||
else:
|
||||
assert subexc.boxed_type is ExceptionGroup
|
||||
else:
|
||||
assert subexc.boxed_type in (
|
||||
tractor.RemoteActorError,
|
||||
trio.Cancelled
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -837,13 +757,11 @@ def test_cancel_via_SIGINT_other_task(
|
|||
):
|
||||
async with tractor.open_nursery(
|
||||
registry_addrs=[reg_addr],
|
||||
) 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).
|
||||
) as tn:
|
||||
for i in range(3):
|
||||
await an.start_actor(
|
||||
f'namesucka_{i}',
|
||||
await tn.run_in_actor(
|
||||
sleep_forever,
|
||||
name='namesucka',
|
||||
)
|
||||
task_status.started()
|
||||
await trio.sleep_forever()
|
||||
|
|
@ -884,11 +802,8 @@ async def spin_for(period=3):
|
|||
async def spawn_sub_with_sync_blocking_task():
|
||||
async with tractor.open_nursery() as an:
|
||||
print('starting sync blocking subactor..\n')
|
||||
# one-shot: parks HERE awaiting the sync-sleeping
|
||||
# grandchild's result until cancelled from above.
|
||||
await tractor.to_actor.run(
|
||||
await an.run_in_actor(
|
||||
spin_for,
|
||||
an=an,
|
||||
name='sleeper',
|
||||
)
|
||||
print('exiting first subactor layer..\n')
|
||||
|
|
@ -994,19 +909,11 @@ def test_cancel_while_childs_child_in_sync_sleep(
|
|||
debug_mode=debug_mode,
|
||||
registry_addrs=[reg_addr],
|
||||
) as an,
|
||||
trio.open_nursery() as tn,
|
||||
):
|
||||
# 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,
|
||||
await an.run_in_actor(
|
||||
spawn_sub_with_sync_blocking_task,
|
||||
an=an,
|
||||
name='sync_blocking_sub',
|
||||
)
|
||||
)
|
||||
await trio.sleep(1)
|
||||
|
||||
if man_cancel_outer:
|
||||
|
|
|
|||
|
|
@ -131,12 +131,9 @@ def test_ringbuf(
|
|||
child_read_shm,
|
||||
**common_kwargs,
|
||||
total_bytes=total_bytes,
|
||||
) as (rctx, _sent),
|
||||
) as (sctx, _sent),
|
||||
):
|
||||
# ctx-acm exits await each child task's
|
||||
# `Return` (the prior `recv_p.result()` here
|
||||
# was a daemon-portal no-op).
|
||||
pass
|
||||
await recv_p.result()
|
||||
|
||||
await send_p.cancel_actor()
|
||||
await recv_p.cancel_actor()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
'''
|
||||
`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
|
||||
Verifies the "spiritual successor" to (and eventual
|
||||
replacement of) `ActorNursery.run_in_actor()`; see
|
||||
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
|
||||
caller's task as a boxed `RemoteActorError` instead
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -780,7 +780,7 @@ class Context:
|
|||
# `Portal.open_context()` has been opened since it's
|
||||
# assumed that other portal APIs like,
|
||||
# - `Portal.run()`,
|
||||
# - `to_actor.run()`
|
||||
# - `ActorNursery.run_in_actor()`
|
||||
# do their own error checking at their own call points and
|
||||
# result processing.
|
||||
|
||||
|
|
|
|||
|
|
@ -1161,6 +1161,10 @@ class TransportClosed(Exception):
|
|||
)
|
||||
|
||||
|
||||
class NoResult(RuntimeError):
|
||||
"No final result is expected for this actor"
|
||||
|
||||
|
||||
class ModuleNotExposed(ModuleNotFoundError):
|
||||
"The requested module is not exposed for RPC"
|
||||
|
||||
|
|
|
|||
|
|
@ -221,18 +221,15 @@ def pub(
|
|||
import tractor
|
||||
|
||||
async with tractor.open_nursery() as n:
|
||||
portal = await n.start_actor(
|
||||
portal = n.run_in_actor(
|
||||
'publisher', # actor name
|
||||
enable_modules=[__name__],
|
||||
)
|
||||
async with portal.open_stream_from(
|
||||
partial( # func to execute in it
|
||||
pub_service,
|
||||
topics=('clicks', 'users'),
|
||||
task_name='source1',
|
||||
)
|
||||
) as stream:
|
||||
async for value in stream:
|
||||
)
|
||||
async for value in await portal.result():
|
||||
print(f"Subscriber received {value}")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -304,11 +304,12 @@ class Start(
|
|||
|
||||
It is called by all the following public APIs:
|
||||
|
||||
- `to_actor.run()`
|
||||
- `ActorNursery.run_in_actor()`
|
||||
|
||||
- `Portal.run()`
|
||||
`|_.run_from_ns()`
|
||||
`|_.open_stream_from()`
|
||||
`|_._submit_for_result()`
|
||||
|
||||
- `Context.open_context()`
|
||||
|
||||
|
|
|
|||
|
|
@ -50,11 +50,13 @@ from ..ipc import Channel
|
|||
from ..log import get_logger
|
||||
from ..msg import (
|
||||
# Error,
|
||||
PayloadMsg,
|
||||
NamespacePath,
|
||||
Return,
|
||||
)
|
||||
from .._exceptions import (
|
||||
ActorTooSlowError,
|
||||
NoResult,
|
||||
TransportClosed,
|
||||
)
|
||||
from .._context import (
|
||||
|
|
@ -100,6 +102,14 @@ class Portal:
|
|||
) -> None:
|
||||
|
||||
self._chan: Channel = channel
|
||||
# during the portal's lifetime
|
||||
self._final_result_pld: Any|None = None
|
||||
self._final_result_msg: PayloadMsg|None = None
|
||||
|
||||
# When set to a ``Context`` (when _submit_for_result is called)
|
||||
# it is expected that ``result()`` will be awaited at some
|
||||
# point.
|
||||
self._expect_result_ctx: Context|None = None
|
||||
self._streams: set[MsgStream] = set()
|
||||
|
||||
# TODO, this should be PRIVATE (and never used publicly)! since it's just
|
||||
|
|
@ -127,6 +137,102 @@ class Portal:
|
|||
)
|
||||
return self.chan
|
||||
|
||||
# TODO: factor this out into a `.highlevel` API-wrapper that uses
|
||||
# a single `.open_context()` call underneath.
|
||||
async def _submit_for_result(
|
||||
self,
|
||||
ns: str,
|
||||
func: str,
|
||||
**kwargs
|
||||
) -> None:
|
||||
|
||||
if self._expect_result_ctx is not None:
|
||||
raise RuntimeError(
|
||||
'A pending main result has already been submitted'
|
||||
)
|
||||
|
||||
self._expect_result_ctx: Context = await self.actor.start_remote_task(
|
||||
self.channel,
|
||||
nsf=NamespacePath(f'{ns}:{func}'),
|
||||
kwargs=kwargs,
|
||||
portal=self,
|
||||
)
|
||||
|
||||
# TODO: we should deprecate this API right? since if we remove
|
||||
# `.run_in_actor()` (and instead move it to a `.highlevel`
|
||||
# wrapper api (around a single `.open_context()` call) we don't
|
||||
# really have any notion of a "main" remote task any more?
|
||||
#
|
||||
# @api_frame
|
||||
async def wait_for_result(
|
||||
self,
|
||||
hide_tb: bool = True,
|
||||
) -> Any:
|
||||
'''
|
||||
Return the final result delivered by a `Return`-msg from the
|
||||
remote peer actor's "main" task's `return` statement.
|
||||
|
||||
'''
|
||||
__tracebackhide__: bool = hide_tb
|
||||
# Check for non-rpc errors slapped on the
|
||||
# channel for which we always raise
|
||||
exc = self.channel._exc
|
||||
if exc:
|
||||
raise exc
|
||||
|
||||
# not expecting a "main" result
|
||||
if self._expect_result_ctx is None:
|
||||
peer_id: str = f'{self.channel.aid.reprol()!r}'
|
||||
log.warning(
|
||||
f'Portal to peer {peer_id} will not deliver a final result?\n'
|
||||
f'\n'
|
||||
f'Context.result() can only be called by the parent of '
|
||||
f'a sub-actor when it was spawned with '
|
||||
f'`ActorNursery.run_in_actor()`'
|
||||
f'\n'
|
||||
f'Further this `ActorNursery`-method-API will deprecated in the'
|
||||
f'near fututre!\n'
|
||||
)
|
||||
return NoResult
|
||||
|
||||
# expecting a "main" result
|
||||
assert self._expect_result_ctx
|
||||
|
||||
if self._final_result_msg is None:
|
||||
try:
|
||||
(
|
||||
self._final_result_msg,
|
||||
self._final_result_pld,
|
||||
) = await self._expect_result_ctx._pld_rx.recv_msg(
|
||||
ipc=self._expect_result_ctx,
|
||||
expect_msg=Return,
|
||||
)
|
||||
except BaseException as err:
|
||||
# TODO: wrap this into `@api_frame` optionally with
|
||||
# some kinda filtering mechanism like log levels?
|
||||
__tracebackhide__: bool = False
|
||||
raise err
|
||||
|
||||
return self._final_result_pld
|
||||
|
||||
# TODO: factor this out into a `.highlevel` API-wrapper that uses
|
||||
# a single `.open_context()` call underneath.
|
||||
async def result(
|
||||
self,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> Any|Exception:
|
||||
typname: str = type(self).__name__
|
||||
log.warning(
|
||||
f'`{typname}.result()` is DEPRECATED!\n'
|
||||
f'\n'
|
||||
f'Use `{typname}.wait_for_result()` instead!\n'
|
||||
)
|
||||
return await self.wait_for_result(
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def _cancel_streams(self):
|
||||
# terminate all locally running async generator
|
||||
# IPC calls
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"""
|
||||
from contextlib import asynccontextmanager as acm
|
||||
from functools import partial
|
||||
import inspect
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
|
@ -231,6 +232,14 @@ class ActorNursery:
|
|||
# and syncing purposes to any actor opened nurseries.
|
||||
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
|
||||
self._cancelled_caught: 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
|
||||
async def cancel(
|
||||
self,
|
||||
|
|
@ -481,6 +568,51 @@ class ActorNursery:
|
|||
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
|
||||
async def _open_and_supervise_one_cancels_all_nursery(
|
||||
actor: Actor,
|
||||
|
|
@ -495,10 +627,11 @@ async def _open_and_supervise_one_cancels_all_nursery(
|
|||
errors: dict[tuple[str, str], BaseException] = {}
|
||||
|
||||
# The single "daemon actor" nursery into which ALL subactors
|
||||
# are spawned; one-shot (`to_actor.run()`) subactors are
|
||||
# result-waited and reaped in their caller's own task-scope
|
||||
# (see the #477 `.run_in_actor()`/`._ria_nursery` removal);
|
||||
# errors from this nursery bubble up to the caller.
|
||||
# are spawned — both `.start_actor()` daemons AND
|
||||
# `.run_in_actor()` one-shots. The latter's result-reaping now
|
||||
# runs via `_reap_ria_portals()` at block-exit rather than a
|
||||
# 2ndary `._ria_nursery` (see the #477 removal); errors from
|
||||
# this nursery bubble up to the caller.
|
||||
async with (
|
||||
collapse_eg(),
|
||||
trio.open_nursery() as da_nursery,
|
||||
|
|
@ -522,6 +655,11 @@ async def _open_and_supervise_one_cancels_all_nursery(
|
|||
)
|
||||
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)
|
||||
# daemon nursery. Pre-#477 a 2ndary `._ria_nursery`
|
||||
# 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
|
||||
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:
|
||||
# an error was stashed by the handler above (or by
|
||||
# a spawn task via the shared `errors` dict) so
|
||||
# cancel any remaining subactors, summarize and
|
||||
# re-raise.
|
||||
# No errors were raised while awaiting ".run_in_actor()"
|
||||
# actors but those actors may have returned remote errors as
|
||||
# results (meaning they errored remotely and have relayed
|
||||
# those errors back to this parent actor). The errors are
|
||||
# collected in ``errors`` so cancel all actors, summarize
|
||||
# all errors and re-raise.
|
||||
if errors:
|
||||
if an._children:
|
||||
with trio.CancelScope(shield=True):
|
||||
|
|
|
|||
|
|
@ -188,7 +188,9 @@ async def mp_proc(
|
|||
|
||||
# This is a "soft" (cancellable) join/reap which
|
||||
# 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(
|
||||
proc,
|
||||
proc_waiter,
|
||||
|
|
|
|||
|
|
@ -126,6 +126,98 @@ def try_set_start_method(
|
|||
return _ctx
|
||||
|
||||
|
||||
async def exhaust_portal(
|
||||
|
||||
portal: Portal,
|
||||
actor: Actor
|
||||
|
||||
) -> Any:
|
||||
'''
|
||||
Pull final result from portal (assuming it has one).
|
||||
|
||||
If the main task is an async generator do our best to consume
|
||||
what's left of it.
|
||||
'''
|
||||
__tracebackhide__ = True
|
||||
try:
|
||||
log.debug(
|
||||
f'Waiting on final result from {actor.aid.uid}'
|
||||
)
|
||||
|
||||
# XXX: streams should never be reaped here since they should
|
||||
# always be established and shutdown using a context manager api
|
||||
final: Any = await portal.wait_for_result()
|
||||
|
||||
except (
|
||||
Exception,
|
||||
BaseExceptionGroup,
|
||||
) as err:
|
||||
# we reraise in the parent task via a ``BaseExceptionGroup``
|
||||
return err
|
||||
|
||||
except trio.Cancelled as err:
|
||||
# lol, of course we need this too ;P
|
||||
# TODO: merge with above?
|
||||
log.warning(
|
||||
'Cancelled portal result waiter task:\n'
|
||||
f'uid: {portal.channel.aid}\n'
|
||||
f'error: {err}\n'
|
||||
)
|
||||
return err
|
||||
|
||||
else:
|
||||
log.debug(
|
||||
f'Returning final result from portal:\n'
|
||||
f'uid: {portal.channel.aid}\n'
|
||||
f'result: {final}\n'
|
||||
)
|
||||
return final
|
||||
|
||||
|
||||
async def cancel_on_completion(
|
||||
|
||||
portal: Portal,
|
||||
actor: Actor,
|
||||
errors: dict[tuple[str, str], Exception],
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Cancel actor gracefully once its "main" portal's
|
||||
result arrives.
|
||||
|
||||
Should only be called for actors spawned via the
|
||||
`Portal.run_in_actor()` API.
|
||||
|
||||
=> and really this API will be deprecated and should be
|
||||
re-implemented as a `.hilevel.one_shot_task_nursery()`..)
|
||||
|
||||
'''
|
||||
# if this call errors we store the exception for later
|
||||
# in ``errors`` which will be reraised inside
|
||||
# an exception group and we still send out a cancel request
|
||||
result: Any|Exception = await exhaust_portal(
|
||||
portal,
|
||||
actor,
|
||||
)
|
||||
if isinstance(result, Exception):
|
||||
errors[actor.aid.uid]: Exception = result
|
||||
log.cancel(
|
||||
'Cancelling subactor runtime due to error:\n\n'
|
||||
f'Portal.cancel_actor() => {portal.channel.aid}\n\n'
|
||||
f'error: {result}\n'
|
||||
)
|
||||
|
||||
else:
|
||||
log.runtime(
|
||||
'Cancelling subactor gracefully:\n\n'
|
||||
f'Portal.cancel_actor() => {portal.channel.aid}\n\n'
|
||||
f'result: {result}\n'
|
||||
)
|
||||
|
||||
# cancel the process now that we have a final result
|
||||
await portal.cancel_actor()
|
||||
|
||||
|
||||
async def hard_kill(
|
||||
proc: trio.Process,
|
||||
|
||||
|
|
@ -369,8 +461,8 @@ async def new_proc(
|
|||
|
||||
|
||||
# NOTE: bottom-of-module to avoid a circular import since the
|
||||
# backend submodules pull `soft_kill`/`hard_kill`/`proc_waiter`
|
||||
# from this module.
|
||||
# backend submodules pull `cancel_on_completion`/`soft_kill`/
|
||||
# `hard_kill`/`proc_waiter` from this module.
|
||||
from ._trio import trio_proc
|
||||
from ._mp import mp_proc
|
||||
|
||||
|
|
|
|||
|
|
@ -196,7 +196,9 @@ async def trio_proc(
|
|||
|
||||
# This is a "soft" (cancellable) join/reap which
|
||||
# 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(
|
||||
proc,
|
||||
trio.Process.wait, # XXX, uses `pidfd_open()` below.
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ Adopts the "run it over there" parlance from analogous
|
|||
reuse) a subactor, schedule a single remote task, wait on
|
||||
its result and (when the call owns the subactor) reap it.
|
||||
|
||||
The "spiritual successor" to (and replacement of) the removed
|
||||
legacy `ActorNursery.run_in_actor()` API; see
|
||||
The "spiritual successor" to (and eventual replacement of)
|
||||
the `ActorNursery.run_in_actor()` API; see
|
||||
https://github.com/goodboy/tractor/issues/477
|
||||
|
||||
'''
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ the lower level daemon-actor spawn + portal APIs,
|
|||
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
|
||||
spawn-machinery nurseries as with the (to be deprecated)
|
||||
`ActorNursery.run_in_actor()` API.
|
||||
|
||||
'''
|
||||
|
|
@ -151,9 +151,9 @@ async def run(
|
|||
the distributed-parallelism equivalent of
|
||||
`trio.to_thread.run_sync()`.
|
||||
|
||||
Unlike the removed legacy `.run_in_actor()` (which
|
||||
returned a `Portal` whose result was only collected
|
||||
at actor-nursery teardown) this is a plain "call and
|
||||
Unlike `ActorNursery.run_in_actor()` (which returns
|
||||
a `Portal` whose result is 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue