Clarify `to_actor.run()` ownership

The guides described every placement as spawn-run-reap and omitted
the trampoline allowlist required when reusing an existing actor.

- distinguish call-owned children from caller-owned portal actors
- document stable module-global target addresses and allowlists
- add the #477 feature news fragment

(this patch was generated in some part by `opencode` using
`gpt-5.6-sol` (`openai`))
wkt/to_actor_subpkg
Gud Boi 2026-08-20 17:21:03 -04:00
parent 086962ca36
commit a849161fa5
7 changed files with 110 additions and 68 deletions

View File

@ -54,15 +54,16 @@ One-shot task actors
.. 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.
Without ``portal=``, :func:`tractor.to_actor.run` (parlance of
``trio.to_thread.run_sync()`` and friends) is the convenience
one-shot: spawn, run one task, block on its result and reap. It
combines :meth:`ActorNursery.start_actor`, a linked
:meth:`Portal.open_context` call and per-child reaping. With
``portal=`` it owns only the linked task and leaves the existing
actor's lifetime to the portal owner; that actor must expose both
the target module and ``tractor.to_actor.MODULE``. It supersedes
the legacy, non-blocking ``ActorNursery.run_in_actor()`` retained
only for compatibility until its removal in PR #484.
.. deprecated:: 0.1.0a6

View File

@ -62,7 +62,10 @@ one kwarg away,
.. code:: python
async with tractor.open_actor_cluster(
modules=['mylib.workers'],
modules=[
'mylib.workers',
tractor.to_actor.MODULE,
],
count=4,
names=['scout', 'miner', 'smelter', 'smith'],
debug_mode=True, # whole-fleet crash-to-REPL
@ -71,9 +74,11 @@ one kwarg away,
From here the composition patterns are the usual ``tractor`` fare:
``portal.run()`` for bare one-shot RPCs (as in the demo),
``tractor.to_actor.run(..., portal=portal)`` for linked one-shot calls,
or — for a persistent bidirectional dialog per worker — concurrently
enter N ``portal.open_context()`` blocks with
``tractor.to_actor.run(..., portal=portal)`` for cancellation-linked
one-shot tasks in an existing worker (include
``tractor.to_actor.MODULE`` in ``modules``; the cluster still owns
the worker's lifetime), or — for a persistent bidirectional dialog
per worker — concurrently enter N ``portal.open_context()`` blocks with
``tractor.trionics.gather_contexts()``; see :doc:`/guide/context`
for that whole layer.

View File

@ -15,12 +15,12 @@ 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
``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
wrappers B)
one primitive: ``tractor.to_actor.run()`` uses it for a linked
one-shot task, spawning and reaping an actor only when no ``portal=``
is supplied; 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 wrappers B)
The endpoint contract
---------------------

View File

@ -82,10 +82,9 @@ 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:
When the call should own a fresh subactor whose entire job is one
function call, :func:`tractor.to_actor.run` spawns it, runs the task,
returns its result and reaps the process — all in one blocking call:
.. code:: python
@ -101,16 +100,37 @@ 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 linked
:meth:`~tractor.Portal.open_context` call; see the
:doc:`context guide </guide/context>`), and passing neither
opens a private call-scoped nursery (booting the runtime if needed).
- placement also determines process ownership: ``an=`` spawns and
reaps a fresh child in an existing actor nursery, while passing
neither does the same in a private call-scoped nursery (booting
the runtime if needed). ``portal=`` instead runs one linked task
in an existing actor; it neither spawns nor reaps that actor, so
the portal's owner remains responsible for its lifetime.
- concurrency composes the plain ``trio`` way: schedule
multiple ``run()`` calls into a local task nursery (see
``examples/parallelism/to_actor_one_shots.py``).
A reused actor must expose both the target module and the
``to_actor`` context trampoline:
.. code:: python
async with tractor.open_nursery() as an:
portal = await an.start_actor(
'worker',
enable_modules=[
__name__,
tractor.to_actor.MODULE,
],
)
try:
final = await tractor.to_actor.run(
partial(fib, n=10),
portal=portal,
)
finally:
await portal.cancel_actor()
Pure RPC daemons: ``run_daemon()``
----------------------------------
When a process's *only* job is to sit at the root of its own

View File

@ -105,9 +105,9 @@ What's going on here?
``to_actor.run()``: 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
Without ``portal=``, :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()``.
.. code:: python
@ -126,6 +126,10 @@ A few details worth knowing:
``name='something_cuter'``.
- the function's module is auto-added to the child's
``enable_modules`` allowlist.
- the target must be a module-global async function, or a
``functools.partial`` thereof. Nested functions, methods and callable
objects have no stable ``module:name`` RPC address and are rejected
before actor startup.
- target arguments are positional; use ``functools.partial()``
to bind target keyword arguments. Keywords passed directly to
``run()`` configure actor placement and spawning.
@ -133,18 +137,23 @@ A few details worth knowing:
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).
- "placement" composes: ``an=`` spawns a call-owned child from an
existing actor nursery, while passing neither opens a private
call-scoped nursery. ``portal=`` instead reuses an existing actor:
the call scopes only its linked remote task, neither spawns nor
reaps the actor, and leaves its lifetime with the portal's owner.
That actor must expose both the target module and
``tractor.to_actor.MODULE``.
.. note::
:func:`tractor.to_actor.run` is a convenience, **not** the core
model — it's built *entirely* on
:meth:`~tractor.ActorNursery.start_actor` plus a linked
:meth:`~tractor.Portal.open_context` call and per-child
cancellation/reaping. Teach your fingers to use it for quick
model. For actor-owning placements it combines
:meth:`~tractor.ActorNursery.start_actor`, a linked
:meth:`~tractor.Portal.open_context` call, and per-child
cancellation/reaping. With ``portal=`` it uses only the linked
context call and leaves the existing actor's lifetime untouched.
Teach your fingers to use it for quick
fire-and-collect parallelism — think a per-function trio-parallel_
style one-shot — and reach for
:meth:`~tractor.ActorNursery.start_actor` plus
@ -153,25 +162,25 @@ A few details worth knowing:
Actor lifetimes and teardown order
----------------------------------
So we have two lifetime flavors:
There are two actor-lifetime flavors:
- **one-shot** (``to_actor.run()``): lives exactly as long as
its single task; reaped the moment its result (or error)
arrives back in the (blocking) call.
- **daemon** (:meth:`~tractor.ActorNursery.start_actor`): lives
until *someone* cancels it — an explicit
- **call-owned one-shot** (``to_actor.run()`` without ``portal=``):
spawned for one task, then cancelled and joined before ``run()``
returns its result or raises its error.
- **caller-owned daemon** (:meth:`~tractor.ActorNursery.start_actor`),
including an actor later reused through
``to_actor.run(..., portal=portal)``: lives until *someone*
cancels it via an explicit
:meth:`~tractor.Portal.cancel_actor`, a bulk
: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:
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. call-owned actors do not survive their own ``to_actor.run()``
calls; each is reaped before its call returns.
2. the nursery waits on caller-owned daemon actors
**indefinitely**. If you spawned one, 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

View File

@ -43,15 +43,16 @@ Run it::
What's going on here?
- ``trio.run(main)`` starts the **root actor**; the ``tractor``
runtime boots *implicitly* inside ``tractor.to_actor.run()``
whenever it isn't already up. No special entrypoint, no
framework takeover - it's just a ``trio`` app,
runtime boots *implicitly* inside this ``tractor.to_actor.run()``
call because neither ``an=`` nor ``portal=`` was supplied. 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
function: ``cellar_door()``,
- 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
a **new process** and executes ``cellar_door()`` as its linked
one-shot 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
@ -67,10 +68,10 @@ What's going on here?
.. note::
``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
a single function call. The core primitives are
Without ``portal=``, ``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 a single function call. The core primitives are
:meth:`~tractor.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
@ -79,10 +80,10 @@ What's going on here?
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 subactor spawned by ``to_actor.run()`` terminates after 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()``:
.. literalinclude:: ../../examples/actor_spawning_and_causality_with_daemon.py
:caption: examples/actor_spawning_and_causality_with_daemon.py
@ -90,14 +91,17 @@ 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 subactor spawned and owned by ``to_actor.run()`` is cancelled
and reaped before the call returns its result or raises its error,
- 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
above), or its parent nursery is cancelled wholesale.
Passing ``portal=`` is different: the call owns only the linked
remote task. It neither spawns nor reaps the existing actor; the
portal's owner must end that actor's lifetime.
.. tip::
Want your *entire program* to just be a long-lived RPC

View File

@ -0,0 +1,3 @@
Add ``tractor.to_actor.run()`` for Trio-style one-shot async calls in
new or existing actors, with caller-scoped result/error propagation,
linked cancellation, and deterministic reaping of call-owned children.