Showcase `to_actor.run()` across docs

The rendered guides and executable examples still taught the legacy
`ActorNursery.run_in_actor()` result-portal model even though #481
adds its blocking, linked-context replacement.

Deats,
- migrate one-shots to direct results through `to_actor.run()`
- preserve named target inputs with `functools.partial()`
- use daemon actors where reciprocal dialogs need longer lifetimes
- link the new API from core, asyncio and clustering references
- retain only explicit legacy/removal notes

Prompt-IO: ai/prompt-io/opencode/20260820T023005Z_88a23449_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
wkt/to_actor_subpkg
Gud Boi 2026-08-19 23:29:02 -04:00
parent 04123019b7
commit 40d2d9942e
27 changed files with 457 additions and 271 deletions

View File

@ -0,0 +1,40 @@
---
model: openai/gpt-5.6-sol
service: opencode
session: 76c5d31c-5a2f-4503-9b16-410ee7f4fab3
timestamp: 2026-08-20T02:30:05Z
git_ref: 88a23449
scope: docs
substantive: true
raw_file: 20260820T023005Z_88a23449_prompt_io.raw.md
---
## Prompt
Audit all documentation and executable examples once more, replacing
prescriptive `run_in_actor()` usage with `to_actor.run()` or explicit
actor/context lifetime APIs before PR #481 lands.
## Response summary
Rewrote one-shot documentation around direct blocking result delivery,
linked context execution and per-call reaping. Migrated all runnable
examples, using daemon actors where reciprocal dialogs require longer
lifetimes. Added API/guide cross-links and retained only three explicit
legacy references.
## Files changed
- `docs/` - update API, quickstart and subsystem guides to showcase
`tractor.to_actor.run()` and link its underlying core APIs.
- `examples/` - migrate one-shot calls and preserve explicit daemon
lifetimes for reciprocal or long-lived actor dialogs.
## Human edits
The human requested a final docs pass covering every place that should
showcase `to_actor` over `.run_in_actor()`. Earlier review also required
named target arguments to remain visible through `functools.partial()`
and core API references to link to local guides/reference pages. These
were human-directed agent edits; the human made no direct source-line
edits.

View File

@ -0,0 +1,27 @@
---
model: openai/gpt-5.6-sol
service: opencode
timestamp: 2026-08-20T02:30:05Z
git_ref: 88a23449
diff_cmd: git diff HEAD~1..HEAD
---
Perform a final rendered-documentation and executable-example pass so
PR #481 showcases `tractor.to_actor.run()` instead of the legacy
`ActorNursery.run_in_actor()` API.
> `git diff HEAD~1..HEAD -- docs examples`
Migrate one-shot guides and examples to direct result delivery through
`to_actor.run()`, preserving named target inputs with target partials.
Use daemon actors and concurrent portal calls where reciprocal actor
lifetimes require both peers to coexist. Add API and guide cross-links,
and retain only explicit legacy/removal notes.
Verification:
- executable docs examples: `23 passed`
- debugger/tooling TCP: `39 passed, 6 skipped`
- debugger/tooling UDS: `39 passed, 6 skipped`
- Ruff, compilation and `git diff --check`: clean
- local Sphinx build unavailable because Sphinx is not installed

View File

@ -37,7 +37,6 @@ Spawning actors
.. autoclass:: ActorNursery .. autoclass:: ActorNursery
:members: start_actor, :members: start_actor,
run_in_actor,
cancel, cancel,
cancel_called, cancel_called,
cancelled_caught cancelled_caught
@ -46,11 +45,24 @@ 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. :meth:`Portal.open_context` for SC-linked remote tasks.
:meth:`ActorNursery.run_in_actor` is a *convenience* one-shot —
spawn, run a single task, auto-cancel after the result — slated One-shot task actors
to be rebuilt as a high-level wrapper, so don't design around --------------------
it as the core model.
.. autofunction:: tractor.to_actor.run
.. note::
:func:`tractor.to_actor.run` (parlance of
``trio.to_thread.run_sync()`` and friends) is the
*convenience* one-shot — spawn, run a single task, block on
its result, reap — built entirely on
:meth:`ActorNursery.start_actor`, a linked
:meth:`Portal.open_context` call and per-child cancellation/reaping,
so don't design around it as the core model. It supersedes the
legacy, non-blocking ``ActorNursery.run_in_actor()`` retained only
for compatibility until its removal in PR #484.
.. deprecated:: 0.1.0a6 .. deprecated:: 0.1.0a6
@ -71,14 +83,12 @@ flowing back `exactly like trio`_.
:members: run, :members: run,
run_from_ns, run_from_ns,
open_stream_from, open_stream_from,
wait_for_result,
cancel_actor, cancel_actor,
chan chan
.. deprecated:: 0.1.0a6 .. deprecated:: 0.1.0a6
``Portal.result()`` warns; use :meth:`Portal.wait_for_result`. The str-form ``Portal.run('mod.path', 'fn_name')`` warns;
The str-form ``Portal.run('mod.path', 'fn_name')`` also warns;
pass a function *object* whose module is listed in the target's pass a function *object* whose module is listed in the target's
``enable_modules``. ``Portal.channel`` is the legacy spelling ``enable_modules``. ``Portal.channel`` is the legacy spelling
of :attr:`Portal.chan`. of :attr:`Portal.chan`.

View File

@ -5,8 +5,9 @@ This is the curated reference for ``tractor``'s public surface: the
names you can import and lean on without reading runtime internals. names you can import and lean on without reading runtime internals.
Everything below is re-exported at the top level (``import Everything below is re-exported at the top level (``import
tractor``) unless a page says otherwise; subsystems like tractor``) unless a page says otherwise; subsystems like
``tractor.msg``, ``tractor.trionics``, ``tractor.to_asyncio``, ``tractor.msg``, ``tractor.trionics``, ``tractor.to_actor``,
``tractor.devx`` and ``tractor.log`` are importable as submodules. ``tractor.to_asyncio``, ``tractor.devx`` and ``tractor.log`` are
importable as submodules.
``tractor`` is "just trio_" extended across processes: every API ``tractor`` is "just trio_" extended across processes: every API
here is designed to keep the structured concurrency (SC) rules you here is designed to keep the structured concurrency (SC) rules you
@ -23,6 +24,7 @@ Most-used names at a glance:
open_root_actor open_root_actor
open_nursery open_nursery
to_actor.run
run_daemon run_daemon
ActorNursery ActorNursery
Portal Portal

View File

@ -30,11 +30,12 @@ Starting asyncio tasks from trio
.. note:: .. note::
:func:`open_channel_from` mirrors the :func:`open_channel_from` mirrors the
``Portal.open_context()`` handshake: the asyncio side calls :meth:`tractor.Portal.open_context` handshake: the asyncio side calls
``chan.started_nowait(value)`` and that value pops out as ``chan.started_nowait(value)`` and that value pops out as
``first`` on the trio side. :func:`run_task` is the one-shot ``first`` on the trio side. :func:`run_task` is the one-shot
form — run a single asyncio-compatible coroutine fn and return form — run a single asyncio-compatible coroutine fn and return
its result to trio. its result to trio; :func:`tractor.to_actor.run` is its
cross-process sibling.
The inter-loop channel The inter-loop channel
---------------------- ----------------------

View File

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

View File

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

View File

@ -70,9 +70,10 @@ one kwarg away,
... ...
From here the composition patterns are the usual ``tractor`` fare: From here the composition patterns are the usual ``tractor`` fare:
``portal.run()`` for one-shot calls (as in the demo), or — for a ``portal.run()`` for bare one-shot RPCs (as in the demo),
persistent bidirectional dialog per worker — concurrently enter N ``tractor.to_actor.run(..., portal=portal)`` for linked one-shot calls,
``portal.open_context()`` blocks with or — for a persistent bidirectional dialog per worker — concurrently
enter N ``portal.open_context()`` blocks with
``tractor.trionics.gather_contexts()``; see :doc:`/guide/context` ``tractor.trionics.gather_contexts()``; see :doc:`/guide/context`
for that whole layer. for that whole layer.
@ -87,8 +88,8 @@ Clusters vs. nurseries
``open_actor_cluster()`` is sugar, not a new primitive: under the ``open_actor_cluster()`` is sugar, not a new primitive: under the
hood it's just :func:`tractor.open_nursery` plus N concurrent hood it's just :func:`tractor.open_nursery` plus N concurrent
``start_actor()`` calls plus a ``.cancel()`` on the way out. Reach :meth:`~tractor.ActorNursery.start_actor` calls plus a ``.cancel()``
for it when, on the way out. Reach for it when,
- you want a *flat*, homogeneous fleet (classic worker-pool or - you want a *flat*, homogeneous fleet (classic worker-pool or
map-style fan-out shapes), map-style fan-out shapes),

View File

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

View File

@ -9,8 +9,8 @@ docs; what you read is what CI runs).
Roughly in "first date to long term relationship" Roughly in "first date to long term relationship"
order, order,
- :doc:`spawning` — actor nurseries, daemons + - :doc:`spawning` — actor nurseries, daemons,
one-shot workers, process lifetimes. ``to_actor.run()`` one-shots and process lifetimes.
- :doc:`rpc` — portals: calling into another - :doc:`rpc` — portals: calling into another
process like it's a local ``await``. process like it's a local ``await``.
- :doc:`context` — the cross-actor task-pair - :doc:`context` — the cross-actor task-pair

View File

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

View File

@ -80,28 +80,36 @@ One special namespace exists: ``'self'`` resolves to the remote
how internal machinery (cancel requests, registry ops) travels; how internal machinery (cancel requests, registry ops) travels;
don't build your app on it. don't build your app on it.
One-shot results: ``wait_for_result()`` One-shot subactors: ``to_actor.run()``
--------------------------------------- --------------------------------------
A portal returned from When a subactor's *entire job* is a single function call, skip
:meth:`~tractor.ActorNursery.run_in_actor` has exactly one the portal plumbing with :func:`tractor.to_actor.run`: spawn,
"main" task running remotely; that task's ``return`` value is run the lone task, return its result and reap the process — all
delivered as the portal's *final result*: in one blocking call:
.. code:: python .. code:: python
portal = await an.run_in_actor(fib, n=10) from functools import partial
final = await portal.wait_for_result()
final = await tractor.to_actor.run(
partial(fib, n=10),
an=an,
)
Semantics worth knowing: Semantics worth knowing:
- it blocks until the remote task returns, re-raising any - it blocks until the remote task returns, re-raising any
remote error in the usual boxed form. remote error in the usual boxed form right in the calling
- once resolved it's idempotent: later calls return the same task.
cached value. - "placement" is composable: ``an=`` spawns from an existing
- a *daemon* portal (from ``start_actor()``) has no main task, actor-nursery, ``portal=`` reuses an already-running actor
so there's no final result to wait for: you'll get a warning (no spawn/reap, just a linked
plus a ``NoResult`` sentinel. Results of individual daemon :meth:`~tractor.Portal.open_context` call; see the
calls come straight back from each ``await portal.run()``. :doc:`context guide </guide/context>`), and passing neither
opens a private call-scoped nursery (booting the runtime if needed).
- concurrency composes the plain ``trio`` way: schedule
multiple ``run()`` calls into a local task nursery (see
``examples/parallelism/to_actor_one_shots.py``).
Pure RPC daemons: ``run_daemon()`` Pure RPC daemons: ``run_daemon()``
---------------------------------- ----------------------------------
@ -147,7 +155,8 @@ call tears down the entire sub-tree — SC, transitively.
When to graduate to ``Context`` When to graduate to ``Context``
------------------------------- -------------------------------
``portal.run()`` is great for one-shot, request-response calls. The :meth:`~tractor.Portal.run` method is great for one-shot,
request-response calls.
Reach for :meth:`~tractor.Portal.open_context` with an Reach for :meth:`~tractor.Portal.open_context` with an
``@tractor.context`` endpoint as soon as you want: ``@tractor.context`` endpoint as soon as you want:
@ -160,10 +169,15 @@ Reach for :meth:`~tractor.Portal.open_context` with an
:meth:`~tractor.Portal.cancel_actor` nukes the **entire** :meth:`~tractor.Portal.cancel_actor` nukes the **entire**
remote runtime and its process. remote runtime and its process.
In fact the source plans for ``Portal.run()`` itself to be :func:`tractor.to_actor.run` already enters the full
rebuilt on top of ``open_context()`` — contexts *are* the core :meth:`~tractor.Portal.open_context` lifecycle. The older
inter-actor protocol. Take the full tour in :meth:`~tractor.Portal.run` path instead uses the ``Context`` returned
:doc:`/guide/context`. by the lower-level ``Actor.start_remote_task()`` directly, avoiding a
``Started`` handshake but owning less lifecycle machinery. A follow-up
should factor their shared linked-task lifecycle without requiring
``Portal.run()`` to delegate through the public context API or add
another wire message. Take the full tour in
:doc:`the context guide </guide/context>`.
.. seealso:: .. seealso::

View File

@ -91,31 +91,34 @@ somebody-ing:
What's going on here? What's going on here?
- ``start_actor('frank', enable_modules=[__name__])`` forks off - :meth:`~tractor.ActorNursery.start_actor` forks off
a new process, boots a ``tractor`` runtime inside it, and a new process, boots a ``tractor`` runtime inside it, and
allows it to serve functions from the current module (see the allows it to serve functions from the current module (see the
allowlist section below). allowlist section below).
- each ``await portal.run(...)`` schedules a *new* task in - each :meth:`~tractor.Portal.run` call schedules a *new* task in
frank's task tree and waits on its result — the full RPC story frank's task tree and waits on its result — the full RPC story
lives in :doc:`/guide/rpc`. lives in :doc:`/guide/rpc`.
- frank has no main task to complete, so without the final - frank has no main task to complete, so without the final
``await portal.cancel_actor()`` the nursery block would wait :meth:`~tractor.Portal.cancel_actor` call the nursery block would
on him **forever**. Daemon lifetimes are *yours* to end; that wait on him **forever**. Daemon lifetimes are *yours* to end;
explicitness is the point. that explicitness is the point.
``run_in_actor()``: quick one-shot parallelism ``to_actor.run()``: quick one-shot parallelism
---------------------------------------------- ----------------------------------------------
:meth:`~tractor.ActorNursery.run_in_actor` is the convenience :func:`tractor.to_actor.run` is the convenience wrapper: spawn
wrapper: spawn an actor, run exactly one async function in it, an actor, run exactly one async function in it, block on the
then reap the process as soon as the result arrives. result, then reap the process — the distributed sibling of
``trio.to_thread.run_sync()``.
.. code:: python .. code:: python
async with tractor.open_nursery() as an: async with (
portal = await an.run_in_actor(burn_cpu) tractor.open_nursery() as an,
trio.open_nursery() as tn,
):
# burn rubber in the parent too... # burn rubber in the parent too...
await burn_cpu() tn.start_soon(burn_cpu)
total = await portal.wait_for_result() total = await tractor.to_actor.run(burn_cpu, an=an)
A few details worth knowing: A few details worth knowing:
@ -123,43 +126,52 @@ A few details worth knowing:
``name='something_cuter'``. ``name='something_cuter'``.
- the function's module is auto-added to the child's - the function's module is auto-added to the child's
``enable_modules`` allowlist. ``enable_modules`` allowlist.
- extra ``**kwargs`` are forwarded to the function itself. - target arguments are positional; use ``functools.partial()``
- the child is *auto-cancelled* once its "main" result lands; to bind target keyword arguments. Keywords passed directly to
at nursery exit these run-once children are always reaped ``run()`` configure actor placement and spawning.
first (causality_ is paramount!). - the call blocks until the result (or error) lands and the
child is *auto-cancelled* (reaped) right after — so remote
errors raise directly in your calling task (causality_ is
paramount!).
- "placement" composes: ``an=`` spawns from a caller-managed
actor-nursery, ``portal=`` reuses an already-running actor
(no spawn/reap), and passing neither opens a private
call-scoped nursery (booting the runtime if needed).
.. note:: .. note::
``run_in_actor()`` is a convenience, **not** the core model. :func:`tractor.to_actor.run` is a convenience, **not** the core
The source literally marks it for an eventual rebuild as model — it's built *entirely* on
a thin "hilevel" wrapper on top of :meth:`~tractor.ActorNursery.start_actor` plus a linked
:meth:`~tractor.Portal.open_context` (the modern inter-actor :meth:`~tractor.Portal.open_context` call and per-child
task API). Teach your fingers to use it for quick cancellation/reaping. Teach your fingers to use it for quick
fire-and-collect parallelism — think a per-function fire-and-collect parallelism — think a per-function trio-parallel_
trio-parallel_ style one-shot — and reach for style one-shot — and reach for
``start_actor()`` + ``open_context()`` for anything :meth:`~tractor.ActorNursery.start_actor` plus
long-lived, stateful or streaming :meth:`~tractor.Portal.open_context` for anything long-lived,
(:doc:`/guide/context`). stateful or streaming; see :doc:`/guide/context`.
Actor lifetimes and teardown order Actor lifetimes and teardown order
---------------------------------- ----------------------------------
So we have two lifetime flavors: So we have two lifetime flavors:
- **run-once** (``run_in_actor()``): lives exactly as long as - **one-shot** (``to_actor.run()``): lives exactly as long as
its single task; reaped the moment its result (or error) its single task; reaped the moment its result (or error)
arrives. arrives back in the (blocking) call.
- **daemon** (``start_actor()``): lives until *someone* cancels - **daemon** (:meth:`~tractor.ActorNursery.start_actor`): lives
it — an explicit ``await portal.cancel_actor()``, a bulk until *someone* cancels it — an explicit
``await an.cancel()``, or the one-cancels-all strategy kicking :meth:`~tractor.Portal.cancel_actor`, a bulk
in on error. :meth:`~tractor.ActorNursery.cancel`, or the one-cancels-all
strategy kicking in on error.
On a clean exit of the nursery block the teardown order is: On a clean exit of the nursery block the teardown order is:
1. the nursery waits on every run-once actor's final result; 1. one-shot actors never make it to nursery exit: each is
any errors from these are raised immediately so your code reaped inside its own ``to_actor.run()`` call, any error
(acting as supervisor) gets first crack at handling them. raising immediately in the calling task so your code
2. then it waits on daemon actors — **indefinitely**. If you (acting as supervisor) gets first crack at handling it.
spawned a daemon, you own its lifetime. 2. the nursery then waits on daemon actors — **indefinitely**.
If you spawned a daemon, you own its lifetime.
When a child *is* cancelled, teardown is graceful-first per SC When a child *is* cancelled, teardown is graceful-first per SC
discipline: the runtime sends an IPC cancel request and gives discipline: the runtime sends an IPC cancel request and gives

View File

@ -43,24 +43,20 @@ Run it::
What's going on here? What's going on here?
- ``trio.run(main)`` starts the **root actor**; the ``tractor`` - ``trio.run(main)`` starts the **root actor**; the ``tractor``
runtime boots *implicitly* inside ``tractor.open_nursery()`` runtime boots *implicitly* inside ``tractor.to_actor.run()``
whenever it isn't already up. No special entrypoint, no whenever it isn't already up. No special entrypoint, no
framework takeover - it's just a ``trio`` app, framework takeover - it's just a ``trio`` app,
- inside ``main()`` a *subactor* is spawned via - inside ``main()`` a *subactor* is spawned via
``ActorNursery.run_in_actor()`` and told to run exactly one ``tractor.to_actor.run()`` and told to run exactly one
function: ``cellar_door()``, function: ``cellar_door()``,
- you get back a ``Portal``: your handle for invoking tasks in
the new process's (separate!) memory domain. We lean on it
much harder in the next section,
- the subactor, *some_linguist*, boots a fresh ``trio.run()`` in - the subactor, *some_linguist*, boots a fresh ``trio.run()`` in
a **new process** and executes ``cellar_door()`` as its *main a **new process** and executes ``cellar_door()`` as its *main
task* (note the child proving it is *not* the root with task* (note the child proving it is *not* the root with
``tractor.is_root_process()``), then ships the return value ``tractor.is_root_process()``), then ships the return value
back over IPC, back over IPC,
- the parent grabs that *final result* with - the call *blocks* until that final result arrives, then
``await portal.wait_for_result()``, much like you'd expect returns it - causality is preserved: your task only proceeds
from a "future" - except causality is preserved: the nursery once the child is *done*, dead, and reaped.
block only exits once the child is *done*, dead, and reaped.
.. margin:: Just need a worker pool? .. margin:: Just need a worker pool?
@ -71,19 +67,22 @@ What's going on here?
.. note:: .. note::
``run_in_actor()`` is the *convenience* wrapper: one-shot ``to_actor.run()`` (parlance of ``trio.to_thread`` and
friends) is the *convenience* wrapper: one-shot
spawn-run-reap semantics for when a subactor's entire job is spawn-run-reap semantics for when a subactor's entire job is
a single function call. The core primitives are a single function call. The core primitives are
``ActorNursery.start_actor()`` (next up) paired with :meth:`~tractor.ActorNursery.start_actor` (next up) — which
``Portal.open_context()`` for full, SC-linked cross-actor hands you a ``Portal``, your handle for invoking tasks in the
dialogs - see :doc:`/guide/context`. new process's (separate!) memory domain — paired with
:meth:`~tractor.Portal.open_context` for full, SC-linked
cross-actor dialogs; see :doc:`/guide/context`.
Daemon actors and RPC Daemon actors and RPC
--------------------- ---------------------
A ``run_in_actor()``-spawned actor terminates when its main task A ``to_actor.run()`` one-shot subactor terminates when its lone
returns. But often you want long-lived *daemon* actors instead: task returns. But often you want long-lived *daemon* actors
spawned once, then serving (allowlisted) RPC requests until told instead: spawned once, then serving (allowlisted) RPC requests
otherwise. That's ``start_actor()``: until told otherwise. That's ``start_actor()``:
.. literalinclude:: ../../examples/actor_spawning_and_causality_with_daemon.py .. literalinclude:: ../../examples/actor_spawning_and_causality_with_daemon.py
:caption: examples/actor_spawning_and_causality_with_daemon.py :caption: examples/actor_spawning_and_causality_with_daemon.py
@ -91,9 +90,9 @@ otherwise. That's ``start_actor()``:
Two lifetime rules to internalize: Two lifetime rules to internalize:
- a ``run_in_actor()`` actor lives exactly as long as its main - a ``to_actor.run()`` one-shot actor lives exactly as long as
task; the nursery waits for that function (and thus the its lone task; the call blocks until that function (and thus
process) to complete before unblocking, the process) completes,
- a ``start_actor()`` actor *lives forever* - an RPC daemon the - a ``start_actor()`` actor *lives forever* - an RPC daemon the
nursery will happily wait on **indefinitely** - until some nursery will happily wait on **indefinitely** - until some
task explicitly cancels it via ``Portal.cancel_actor()`` (as task explicitly cancels it via ``Portal.cancel_actor()`` (as
@ -208,16 +207,20 @@ The script of the scene (runtime ``INFO`` log lines trimmed)::
The new tricks in play: The new tricks in play:
- two subactors, *donny* and *gretchen*, are each told to run - *donny* and *gretchen* start as daemon actors so each remains alive
``say_hello()`` targeting the *other* by name, while the other discovers it and completes its line,
- a local ``trio`` nursery runs both ``Portal.run(say_hello)`` calls
concurrently; starting both actors first avoids either reciprocal
dialog racing one-shot process reaping,
- ``tractor.wait_for_actor()`` blocks until the named peer has - ``tractor.wait_for_actor()`` blocks until the named peer has
registered with the tree's *registrar* (every actor announces registered with the tree's *registrar* (every actor announces
itself at boot), then yields a ``Portal`` connected itself at boot), then yields a ``Portal`` connected
**directly** to that peer, **directly** to that peer,
- each actor invokes its partner's ``hi()`` over that portal: - each actor invokes its partner's ``hi()`` over that portal:
actor-to-actor RPC with the root merely *directing* - and both actor-to-actor RPC with the root merely *directing* - and each
final lines flow back to ``main()`` via ``Portal.run()`` returns its final line directly to ``main()``,
``await portal.wait_for_result()``, - the actor nursery explicitly cancels both daemons only after both
dialogs complete,
- ``tractor.log.get_console_log("INFO")`` cranks up runtime - ``tractor.log.get_console_log("INFO")`` cranks up runtime
logging so you can watch the spawn/register/cancel machinery logging so you can watch the spawn/register/cancel machinery
narrate itself; remove it for a quiet set. narrate itself; remove it for a quiet set.

View File

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

View File

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

View File

@ -1,3 +1,5 @@
from functools import partial
import trio import trio
import tractor import tractor
@ -21,26 +23,39 @@ async def breakpoint_forever():
async def spawn_until(depth=0): async def spawn_until(depth=0):
""""A nested nursery that triggers another ``NameError``. """"A nested nursery that triggers another ``NameError``.
""" """
async with tractor.open_nursery() as n: async with (
tractor.open_nursery() as an,
trio.open_nursery() as tn,
):
if depth < 1: if depth < 1:
await n.run_in_actor(breakpoint_forever) tn.start_soon(
partial(
p = await n.run_in_actor( tractor.to_actor.run,
name_error, breakpoint_forever,
name='name_error' an=an,
)
) )
await trio.sleep(0.5) await trio.sleep(0.5)
# rx and propagate error from child # rx and propagate error from child
await p.result() await tractor.to_actor.run(
name_error,
an=an,
name='name_error',
)
else: else:
# recusrive call to spawn another process branching layer of # recusrive call to spawn another process branching layer of
# the tree # the tree; blocks (up) each level until the leaf's
# `name_error` relays through.
depth -= 1 depth -= 1
await n.run_in_actor( await tractor.to_actor.run(
spawn_until, partial(
depth=depth, spawn_until,
depth=depth,
),
an=an,
name=f'spawn_until_{depth}', name=f'spawn_until_{depth}',
) )
@ -65,35 +80,38 @@ async def main():
python -m tractor._child --uid ('spawn_until_0', 'de918e6d ...) python -m tractor._child --uid ('spawn_until_0', 'de918e6d ...)
""" """
async with tractor.open_nursery( async with (
debug_mode=True, tractor.open_nursery(
loglevel='pdb', debug_mode=True,
) as n: loglevel='pdb',
) as an,
# spawn both actors trio.open_nursery() as tn,
portal = await n.run_in_actor( ):
spawn_until, # spawn both spawner trees as concurrent one-shots; the
depth=3, # first tree's (relayed) error cancels the other.
name='spawner0', tn.start_soon(
partial(
tractor.to_actor.run,
partial(
spawn_until,
depth=3,
),
an=an,
name='spawner0',
)
) )
portal1 = await n.run_in_actor( tn.start_soon(
spawn_until, partial(
depth=4, tractor.to_actor.run,
name='spawner1', partial(
spawn_until,
depth=4,
),
an=an,
name='spawner1',
)
) )
# TODO: test this case as well where the parent don't see
# the sub-actor errors by default and instead expect a user
# ctrl-c to kill the root.
with trio.move_on_after(3):
await trio.sleep_forever()
# gah still an issue here.
await portal.result()
# should never get here
await portal1.result()
if __name__ == '__main__': if __name__ == '__main__':
trio.run(main) trio.run(main)

View File

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

View File

@ -17,12 +17,12 @@ async def name_error():
async def spawn_error(): async def spawn_error():
""""A nested nursery that triggers another ``NameError``. """"A nested nursery that triggers another ``NameError``.
""" """
async with tractor.open_nursery() as n: async with tractor.open_nursery() as an:
portal = await n.run_in_actor( return await tractor.to_actor.run(
name_error, name_error,
an=an,
name='name_error_1', name='name_error_1',
) )
return await portal.result()
async def main(): async def main():
@ -36,17 +36,39 @@ async def main():
`-python -m tractor._child --uid ('spawn_error', '52ee14a5 ...) `-python -m tractor._child --uid ('spawn_error', '52ee14a5 ...)
`-python -m tractor._child --uid ('name_error', '3391222c ...) `-python -m tractor._child --uid ('name_error', '3391222c ...)
""" """
errors: list[BaseException] = []
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=True, debug_mode=True,
# loglevel='runtime', # loglevel='runtime',
) as n: ) as an:
# Spawn both actors, don't bother with collecting results async def run_and_collect(fn):
# (would result in a different debugger outcome due to parent's '''
# cancellation). One-shot whose (boxed) error is stashed instead of
await n.run_in_actor(breakpoint_forever) raised so a sibling's crash never cancels the others
await n.run_in_actor(name_error) before they've had their own debugger sessions (the
await n.run_in_actor(spawn_error) "collect all errors" the legacy `run_in_actor()` API
did implicitly at nursery teardown).
'''
try:
await tractor.to_actor.run(fn, an=an)
except tractor.RemoteActorError as rae:
errors.append(rae)
# Spawn all one-shot task actors, collecting (vs.
# raising) their errors.
async with trio.open_nursery() as tn:
tn.start_soon(run_and_collect, breakpoint_forever)
tn.start_soon(run_and_collect, name_error)
tn.start_soon(run_and_collect, spawn_error)
if errors:
raise BaseExceptionGroup(
'multi_subactors errored!',
errors,
)
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -1,3 +1,5 @@
from functools import partial
import trio import trio
import tractor import tractor
@ -10,15 +12,17 @@ async def name_error():
async def spawn_until(depth=0): async def spawn_until(depth=0):
""""A nested nursery that triggers another ``NameError``. """"A nested nursery that triggers another ``NameError``.
""" """
async with tractor.open_nursery() as n: async with tractor.open_nursery() as an:
if depth < 1: if depth < 1:
# await n.run_in_actor('breakpoint_forever', breakpoint_forever) await tractor.to_actor.run(name_error, an=an)
await n.run_in_actor(name_error)
else: else:
depth -= 1 depth -= 1
await n.run_in_actor( await tractor.to_actor.run(
spawn_until, partial(
depth=depth, spawn_until,
depth=depth,
),
an=an,
name=f'spawn_until_{depth}', name=f'spawn_until_{depth}',
) )
@ -37,28 +41,37 @@ async def main():
python -m tractor._child --uid ('name_error', '6c2733b8 ...) python -m tractor._child --uid ('name_error', '6c2733b8 ...)
''' '''
async with tractor.open_nursery( async with (
debug_mode=True, tractor.open_nursery(
enable_transports=['uds'], # TODO, apss this via osenv? debug_mode=True,
loglevel='devx', # XXX, required for test! enable_transports=['uds'], # TODO, apss this via osenv?
) as n: loglevel='devx', # XXX, required for test!
) as an,
trio.open_nursery() as tn,
):
# spawn the deeper tree in the bg..
tn.start_soon(
partial(
tractor.to_actor.run,
partial(
spawn_until,
depth=1,
),
an=an,
name='spawner1',
)
)
# spawn both actors # ..while blocking on the shallow (faster to fail) tree
portal = await n.run_in_actor( # whose propagated error triggers nursery cancellation.
spawn_until, await tractor.to_actor.run(
depth=0, partial(
spawn_until,
depth=0,
),
an=an,
name='spawner0', name='spawner0',
) )
portal1 = await n.run_in_actor(
spawn_until,
depth=1,
name='spawner1',
)
# nursery cancellation should be triggered due to propagated
# error from child.
await portal.result()
await portal1.result()
if __name__ == '__main__': if __name__ == '__main__':

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,22 +20,20 @@ async def burn_cpu():
for _ in range(50000): for _ in range(50000):
await trio.sleep(1/50000/50) await trio.sleep(1/50000/50)
return os.getpid() return pid
async def main(): async def main():
async with tractor.open_nursery() as n: async with trio.open_nursery() as tn:
portal = await n.run_in_actor(burn_cpu) # burn rubber in the parent too
tn.start_soon(burn_cpu)
# burn rubber in the parent too # run the same func as the lone task in a subactor,
await burn_cpu() # block on (and collect) its result
pid = await tractor.to_actor.run(burn_cpu)
# wait on result from target function
pid = await portal.wait_for_result()
# end of nursery block
print(f"Collected subproc {pid}") print(f"Collected subproc {pid}")

View File

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