Compare commits

..

No commits in common. "9f99043b034246d27d56d59a9e2f369dc3412100" and "88a234499c22b63bc9fafae3c7d835e72d7e8b26" have entirely different histories.

30 changed files with 271 additions and 521 deletions

View File

@ -1,32 +0,0 @@
---
model: openai/gpt-5.6-sol
service: opencode
session: 76c5d31c-5a2f-4503-9b16-410ee7f4fab3
timestamp: 2026-08-20T02:30:04Z
git_ref: 88a23449
scope: tests
substantive: true
raw_file: 20260820T023004Z_88a23449_prompt_io.raw.md
---
## Prompt
Inspect the two failed macOS jobs in PR #481's new CI run and continue
toward a green landing candidate.
## Response summary
Confirmed both jobs fail only the known nested crash-REPL scenario from
issue #320, while Ubuntu TCP/UDS and Windows pass. Added a targeted
macOS-CI skip without reducing Linux coverage.
## Files changed
- `tests/devx/test_debugger.py` - skip the issue #320 nested
crash-REPL node on Darwin CI.
## Human edits
The human monitored the new CI run, reported both macOS jobs dead and
directed the agent to continue diagnosis. No direct source-line edits
were made by the human.

View File

@ -1,23 +0,0 @@
---
model: openai/gpt-5.6-sol
service: opencode
timestamp: 2026-08-20T02:30:04Z
git_ref: 88a23449
diff_cmd: git diff HEAD~1..HEAD
---
Diagnose the remaining macOS PR #481 CI failures after the Linux
debugger and transport fixes passed.
> `git diff HEAD~1..HEAD -- tests/devx/test_debugger.py`
Both macOS transports failed the same deeply nested crash-REPL test
already tracked by issue #320: TCP omitted one actor-specific traceback
record and UDS timed out waiting for a nested prompt. Apply an explicit
Darwin-CI skip to this one node while retaining Linux TCP/UDS coverage.
Verification:
- debugger/tooling TCP: `39 passed, 6 skipped`
- debugger/tooling UDS: `39 passed, 6 skipped`
- Ruff, compilation and `git diff --check`: clean

View File

@ -1,40 +0,0 @@
---
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

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

View File

@ -5,9 +5,8 @@ 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_actor``, ``tractor.msg``, ``tractor.trionics``, ``tractor.to_asyncio``,
``tractor.to_asyncio``, ``tractor.devx`` and ``tractor.log`` are ``tractor.devx`` and ``tractor.log`` are importable as submodules.
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
@ -24,7 +23,6 @@ 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,12 +30,11 @@ Starting asyncio tasks from trio
.. note:: .. note::
:func:`open_channel_from` mirrors the :func:`open_channel_from` mirrors the
:meth:`tractor.Portal.open_context` handshake: the asyncio side calls ``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; :func:`tractor.to_actor.run` is its its result to trio.
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 ``tractor.to_actor.run()`` accepts the The one-shot convenience ``ActorNursery.run_in_actor()`` accepts
same flag. The ``to_asyncio`` APIs may **only** be called from the same flag. The ``to_asyncio`` APIs may **only** be called from
tasks inside an infected actor; calling them anywhere else raises tasks inside an infected actor; calling them anywhere else raises
a loud ``RuntimeError``. You can introspect at runtime with a loud ``RuntimeError``. You can introspect at runtime with
``tractor.current_actor().is_infected_aio()``. ``tractor.current_actor().is_infected_aio()``.
@ -229,7 +229,7 @@ dialog, skip the channel ceremony and use
It schedules the fn as an ``asyncio.Task``, waits for completion It schedules the fn as an ``asyncio.Task``, waits for completion
and hands the return value back to ``trio``; think of it as the and hands the return value back to ``trio``; think of it as the
cross-loop sibling of ``tractor.to_actor.run()``. Errors and cross-loop sibling of ``ActorNursery.run_in_actor()``. Errors and
cancellation are translated exactly as for channels. cancellation are translated exactly as for channels.
Cross-loop errors and cancellation Cross-loop errors and cancellation

View File

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

View File

@ -70,10 +70,9 @@ 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 bare one-shot RPCs (as in the demo), ``portal.run()`` for one-shot calls (as in the demo), or — for a
``tractor.to_actor.run(..., portal=portal)`` for linked one-shot calls, persistent bidirectional dialog per worker — concurrently enter N
or — for a persistent bidirectional dialog per worker — concurrently ``portal.open_context()`` blocks with
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.
@ -88,8 +87,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
:meth:`~tractor.ActorNursery.start_actor` calls plus a ``.cancel()`` ``start_actor()`` calls plus a ``.cancel()`` on the way out. Reach
on the way out. Reach for it when, 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: ``tractor.to_actor.run()`` is a convenience for one primitive: ``ActorNursery.run_in_actor()`` is a convenience
"spawn, run the lone task, await the result, tear down"; plain for "spawn, open a context, await the result, tear down"; plain
``Portal.run()`` RPC is planned to be re-implemented on top of it; ``Portal.run()`` RPC is planned to be re-implemented on top of it;
the multi-process debugger's tree-wide REPL lock rides one. Grok the multi-process debugger's tree-wide REPL lock rides one. Grok
this page and the rest of the library reads as convenience this page and the rest of the library reads as convenience

View File

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

View File

@ -80,36 +80,28 @@ One special namespace exists: ``'self'`` resolves to the remote
how internal machinery (cancel requests, registry ops) travels; how internal machinery (cancel requests, registry ops) travels;
don't build your app on it. don't build your app on it.
One-shot subactors: ``to_actor.run()`` One-shot results: ``wait_for_result()``
-------------------------------------- ---------------------------------------
When a subactor's *entire job* is a single function call, skip A portal returned from
the portal plumbing with :func:`tractor.to_actor.run`: spawn, :meth:`~tractor.ActorNursery.run_in_actor` has exactly one
run the lone task, return its result and reap the process — all "main" task running remotely; that task's ``return`` value is
in one blocking call: delivered as the portal's *final result*:
.. code:: python .. code:: python
from functools import partial portal = await an.run_in_actor(fib, n=10)
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 right in the calling remote error in the usual boxed form.
task. - once resolved it's idempotent: later calls return the same
- "placement" is composable: ``an=`` spawns from an existing cached value.
actor-nursery, ``portal=`` reuses an already-running actor - a *daemon* portal (from ``start_actor()``) has no main task,
(no spawn/reap, just a linked so there's no final result to wait for: you'll get a warning
:meth:`~tractor.Portal.open_context` call; see the plus a ``NoResult`` sentinel. Results of individual daemon
:doc:`context guide </guide/context>`), and passing neither calls come straight back from each ``await portal.run()``.
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()``
---------------------------------- ----------------------------------
@ -155,8 +147,7 @@ call tears down the entire sub-tree — SC, transitively.
When to graduate to ``Context`` When to graduate to ``Context``
------------------------------- -------------------------------
The :meth:`~tractor.Portal.run` method is great for one-shot, ``portal.run()`` is great for one-shot, request-response calls.
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:
@ -169,15 +160,10 @@ 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.
:func:`tractor.to_actor.run` already enters the full In fact the source plans for ``Portal.run()`` itself to be
:meth:`~tractor.Portal.open_context` lifecycle. The older rebuilt on top of ``open_context()`` — contexts *are* the core
:meth:`~tractor.Portal.run` path instead uses the ``Context`` returned inter-actor protocol. Take the full tour in
by the lower-level ``Actor.start_remote_task()`` directly, avoiding a :doc:`/guide/context`.
``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,34 +91,31 @@ somebody-ing:
What's going on here? What's going on here?
- :meth:`~tractor.ActorNursery.start_actor` forks off - ``start_actor('frank', enable_modules=[__name__])`` 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 :meth:`~tractor.Portal.run` call schedules a *new* task in - each ``await portal.run(...)`` 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
:meth:`~tractor.Portal.cancel_actor` call the nursery block would ``await portal.cancel_actor()`` the nursery block would wait
wait on him **forever**. Daemon lifetimes are *yours* to end; on him **forever**. Daemon lifetimes are *yours* to end; that
that explicitness is the point. explicitness is the point.
``to_actor.run()``: quick one-shot parallelism ``run_in_actor()``: quick one-shot parallelism
---------------------------------------------- ----------------------------------------------
:func:`tractor.to_actor.run` is the convenience wrapper: spawn :meth:`~tractor.ActorNursery.run_in_actor` is the convenience
an actor, run exactly one async function in it, block on the wrapper: spawn an actor, run exactly one async function in it,
result, then reap the process — the distributed sibling of then reap the process as soon as the result arrives.
``trio.to_thread.run_sync()``.
.. code:: python .. code:: python
async with ( async with tractor.open_nursery() as an:
tractor.open_nursery() as an, portal = await an.run_in_actor(burn_cpu)
trio.open_nursery() as tn,
):
# burn rubber in the parent too... # burn rubber in the parent too...
tn.start_soon(burn_cpu) await burn_cpu()
total = await tractor.to_actor.run(burn_cpu, an=an) total = await portal.wait_for_result()
A few details worth knowing: A few details worth knowing:
@ -126,52 +123,43 @@ 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.
- target arguments are positional; use ``functools.partial()`` - extra ``**kwargs`` are forwarded to the function itself.
to bind target keyword arguments. Keywords passed directly to - the child is *auto-cancelled* once its "main" result lands;
``run()`` configure actor placement and spawning. at nursery exit these run-once children are always reaped
- the call blocks until the result (or error) lands and the first (causality_ is paramount!).
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::
:func:`tractor.to_actor.run` is a convenience, **not** the core ``run_in_actor()`` is a convenience, **not** the core model.
model — it's built *entirely* on The source literally marks it for an eventual rebuild as
:meth:`~tractor.ActorNursery.start_actor` plus a linked a thin "hilevel" wrapper on top of
:meth:`~tractor.Portal.open_context` call and per-child :meth:`~tractor.Portal.open_context` (the modern inter-actor
cancellation/reaping. Teach your fingers to use it for quick task API). Teach your fingers to use it for quick
fire-and-collect parallelism — think a per-function trio-parallel_ fire-and-collect parallelism — think a per-function
style one-shot — and reach for trio-parallel_ style one-shot — and reach for
:meth:`~tractor.ActorNursery.start_actor` plus ``start_actor()`` + ``open_context()`` for anything
:meth:`~tractor.Portal.open_context` for anything long-lived, long-lived, stateful or streaming
stateful or streaming; see :doc:`/guide/context`. (: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:
- **one-shot** (``to_actor.run()``): lives exactly as long as - **run-once** (``run_in_actor()``): lives exactly as long as
its single task; reaped the moment its result (or error) its single task; reaped the moment its result (or error)
arrives back in the (blocking) call. arrives.
- **daemon** (:meth:`~tractor.ActorNursery.start_actor`): lives - **daemon** (``start_actor()``): lives until *someone* cancels
until *someone* cancels it — an explicit it — an explicit ``await portal.cancel_actor()``, a bulk
:meth:`~tractor.Portal.cancel_actor`, a bulk ``await an.cancel()``, or the one-cancels-all strategy kicking
:meth:`~tractor.ActorNursery.cancel`, or the one-cancels-all in on error.
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. one-shot actors never make it to nursery exit: each is 1. the nursery waits on every run-once actor's final result;
reaped inside its own ``to_actor.run()`` call, any error any errors from these are raised immediately so your code
raising immediately in the calling task so your code (acting as supervisor) gets first crack at handling them.
(acting as supervisor) gets first crack at handling it. 2. then it waits on daemon actors — **indefinitely**. If you
2. the nursery then waits on daemon actors — **indefinitely**. spawned a daemon, you own its lifetime.
If you spawned a daemon, you own its lifetime.
When a child *is* cancelled, teardown is graceful-first per SC When a child *is* cancelled, teardown is graceful-first per SC
discipline: the runtime sends an IPC cancel request and gives discipline: the runtime sends an IPC cancel request and gives

View File

@ -43,20 +43,24 @@ Run it::
What's going on here? What's going on here?
- ``trio.run(main)`` starts the **root actor**; the ``tractor`` - ``trio.run(main)`` starts the **root actor**; the ``tractor``
runtime boots *implicitly* inside ``tractor.to_actor.run()`` runtime boots *implicitly* inside ``tractor.open_nursery()``
whenever it isn't already up. No special entrypoint, no whenever it isn't already up. No special entrypoint, no
framework takeover - it's just a ``trio`` app, framework takeover - it's just a ``trio`` app,
- inside ``main()`` a *subactor* is spawned via - inside ``main()`` a *subactor* is spawned via
``tractor.to_actor.run()`` and told to run exactly one ``ActorNursery.run_in_actor()`` and told to run exactly one
function: ``cellar_door()``, function: ``cellar_door()``,
- you get back a ``Portal``: your handle for invoking tasks in
the new process's (separate!) memory domain. We lean on it
much harder in the next section,
- the subactor, *some_linguist*, boots a fresh ``trio.run()`` in - the subactor, *some_linguist*, boots a fresh ``trio.run()`` in
a **new process** and executes ``cellar_door()`` as its *main a **new process** and executes ``cellar_door()`` as its *main
task* (note the child proving it is *not* the root with task* (note the child proving it is *not* the root with
``tractor.is_root_process()``), then ships the return value ``tractor.is_root_process()``), then ships the return value
back over IPC, back over IPC,
- the call *blocks* until that final result arrives, then - the parent grabs that *final result* with
returns it - causality is preserved: your task only proceeds ``await portal.wait_for_result()``, much like you'd expect
once the child is *done*, dead, and reaped. from a "future" - except causality is preserved: the nursery
block only exits once the child is *done*, dead, and reaped.
.. margin:: Just need a worker pool? .. margin:: Just need a worker pool?
@ -67,22 +71,19 @@ What's going on here?
.. note:: .. note::
``to_actor.run()`` (parlance of ``trio.to_thread`` and ``run_in_actor()`` is the *convenience* wrapper: one-shot
friends) is the *convenience* wrapper: one-shot
spawn-run-reap semantics for when a subactor's entire job is spawn-run-reap semantics for when a subactor's entire job is
a single function call. The core primitives are a single function call. The core primitives are
:meth:`~tractor.ActorNursery.start_actor` (next up) — which ``ActorNursery.start_actor()`` (next up) paired with
hands you a ``Portal``, your handle for invoking tasks in the ``Portal.open_context()`` for full, SC-linked cross-actor
new process's (separate!) memory domain — paired with dialogs - see :doc:`/guide/context`.
: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 ``to_actor.run()`` one-shot subactor terminates when its lone A ``run_in_actor()``-spawned actor terminates when its main task
task returns. But often you want long-lived *daemon* actors returns. But often you want long-lived *daemon* actors instead:
instead: spawned once, then serving (allowlisted) RPC requests spawned once, then serving (allowlisted) RPC requests until told
until told otherwise. That's ``start_actor()``: otherwise. That's ``start_actor()``:
.. literalinclude:: ../../examples/actor_spawning_and_causality_with_daemon.py .. literalinclude:: ../../examples/actor_spawning_and_causality_with_daemon.py
:caption: examples/actor_spawning_and_causality_with_daemon.py :caption: examples/actor_spawning_and_causality_with_daemon.py
@ -90,9 +91,9 @@ until told otherwise. That's ``start_actor()``:
Two lifetime rules to internalize: Two lifetime rules to internalize:
- a ``to_actor.run()`` one-shot actor lives exactly as long as - a ``run_in_actor()`` actor lives exactly as long as its main
its lone task; the call blocks until that function (and thus task; the nursery waits for that function (and thus the
the process) completes, process) to complete before unblocking,
- a ``start_actor()`` actor *lives forever* - an RPC daemon the - a ``start_actor()`` actor *lives forever* - an RPC daemon the
nursery will happily wait on **indefinitely** - until some nursery will happily wait on **indefinitely** - until some
task explicitly cancels it via ``Portal.cancel_actor()`` (as task explicitly cancels it via ``Portal.cancel_actor()`` (as
@ -207,20 +208,16 @@ The script of the scene (runtime ``INFO`` log lines trimmed)::
The new tricks in play: The new tricks in play:
- *donny* and *gretchen* start as daemon actors so each remains alive - two subactors, *donny* and *gretchen*, are each told to run
while the other discovers it and completes its line, ``say_hello()`` targeting the *other* by name,
- 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 each actor-to-actor RPC with the root merely *directing* - and both
``Portal.run()`` returns its final line directly to ``main()``, final lines flow back to ``main()`` via
- the actor nursery explicitly cancels both daemons only after both ``await portal.wait_for_result()``,
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,35 +21,23 @@ 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 an: async with tractor.open_nursery() as n:
print("Alright... Action!") print("Alright... Action!")
# both actors wait on (then dial!) the *other*, so each donny = await n.run_in_actor(
# must outlive both hellos: spawn as daemons, run the say_hello,
# hellos concurrently, reap only once both complete. name='donny',
portals: dict[str, tractor.Portal] = { # arguments are always named
name: await an.start_actor( other_actor='gretchen',
name, )
enable_modules=[__name__], gretchen = await n.run_in_actor(
) say_hello,
for name in ('donny', 'gretchen') name='gretchen',
} other_actor='donny',
)
async def run_and_print(name: str, other_actor: str): print(await gretchen.wait_for_result())
print( print(await donny.wait_for_result())
await portals[name].run( print("CUTTTT CUUTT CUT!!! Donny!! You're supposed to say...")
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,14 +10,17 @@ async def cellar_door():
async def main(): async def main():
"""The main ``tractor`` routine. """The main ``tractor`` routine.
""" """
# spawn a subactor, run ``cellar_door()`` as its lone task, async with tractor.open_nursery() as n:
# block until its result arrives and the subactor is reaped.
print( portal = await n.run_in_actor(
await tractor.to_actor.run(
cellar_door, cellar_door,
name='some_linguist', name='some_linguist',
) )
)
# The ``async with`` will unblock here since the 'some_linguist'
# actor has completed its main task ``cellar_door``.
print(await portal.wait_for_result())
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -1,5 +1,3 @@
from functools import partial
import trio import trio
import tractor import tractor
@ -23,39 +21,26 @@ async def breakpoint_forever():
async def spawn_until(depth=0): async def spawn_until(depth=0):
""""A nested nursery that triggers another ``NameError``. """"A nested nursery that triggers another ``NameError``.
""" """
async with ( async with tractor.open_nursery() as n:
tractor.open_nursery() as an,
trio.open_nursery() as tn,
):
if depth < 1: if depth < 1:
tn.start_soon( await n.run_in_actor(breakpoint_forever)
partial(
tractor.to_actor.run,
breakpoint_forever,
an=an,
)
)
p = await n.run_in_actor(
name_error,
name='name_error'
)
await trio.sleep(0.5) await trio.sleep(0.5)
# rx and propagate error from child # rx and propagate error from child
await tractor.to_actor.run( await p.result()
name_error,
an=an,
name='name_error',
)
else: else:
# recusrive call to spawn another process branching layer of # recusrive call to spawn another process branching layer of
# the tree; blocks (up) each level until the leaf's # the tree
# `name_error` relays through.
depth -= 1 depth -= 1
await tractor.to_actor.run( await n.run_in_actor(
partial( spawn_until,
spawn_until, depth=depth,
depth=depth,
),
an=an,
name=f'spawn_until_{depth}', name=f'spawn_until_{depth}',
) )
@ -80,38 +65,35 @@ async def main():
python -m tractor._child --uid ('spawn_until_0', 'de918e6d ...) python -m tractor._child --uid ('spawn_until_0', 'de918e6d ...)
""" """
async with ( async with tractor.open_nursery(
tractor.open_nursery( debug_mode=True,
debug_mode=True, loglevel='pdb',
loglevel='pdb', ) as n:
) as an,
trio.open_nursery() as tn, # spawn both actors
): portal = await n.run_in_actor(
# spawn both spawner trees as concurrent one-shots; the spawn_until,
# first tree's (relayed) error cancels the other. depth=3,
tn.start_soon( name='spawner0',
partial(
tractor.to_actor.run,
partial(
spawn_until,
depth=3,
),
an=an,
name='spawner0',
)
) )
tn.start_soon( portal1 = await n.run_in_actor(
partial( spawn_until,
tractor.to_actor.run, depth=4,
partial( name='spawner1',
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 an: async with tractor.open_nursery() as n:
return await tractor.to_actor.run( portal = await n.run_in_actor(
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,36 +38,29 @@ async def main():
- root actor should then fail on assert - root actor should then fail on assert
- program termination - program termination
""" """
async with ( async with tractor.open_nursery(
tractor.open_nursery( debug_mode=True,
debug_mode=True, loglevel='devx',
loglevel='devx', ) as n:
) as an,
trio.open_nursery() as tn,
):
# spawn both actors..
portal = await an.start_actor(
'name_error',
enable_modules=[__name__],
)
portal1 = await an.start_actor(
'spawn_error',
enable_modules=[__name__],
)
# ..and bg-schedule their erroring tasks. # spawn both actors
tn.start_soon(portal.run, name_error) portal = await n.run_in_actor(
tn.start_soon(portal1.run, spawn_error) name_error,
name='name_error',
# yield to the bg tasks so both RPC requests are )
# submitted (and start crashing) before the root's own portal1 = await n.run_in_actor(
# error below (the legacy `run_in_actor()` submitted spawn_error,
# in-line with each spawn). name='spawn_error',
await trio.sleep(0.5) )
# trigger a root actor error # trigger a root actor error
assert 0 assert 0
# attempt to collect results (which raises error in parent)
# still has some issues where the parent seems to get stuck
await portal.result()
await portal1.result()
if __name__ == '__main__': if __name__ == '__main__':
trio.run(main) trio.run(main)

View File

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

View File

@ -1,5 +1,3 @@
from functools import partial
import trio import trio
import tractor import tractor
@ -12,17 +10,15 @@ async def name_error():
async def spawn_until(depth=0): async def spawn_until(depth=0):
""""A nested nursery that triggers another ``NameError``. """"A nested nursery that triggers another ``NameError``.
""" """
async with tractor.open_nursery() as an: async with tractor.open_nursery() as n:
if depth < 1: if depth < 1:
await tractor.to_actor.run(name_error, an=an) # await n.run_in_actor('breakpoint_forever', breakpoint_forever)
await n.run_in_actor(name_error)
else: else:
depth -= 1 depth -= 1
await tractor.to_actor.run( await n.run_in_actor(
partial( spawn_until,
spawn_until, depth=depth,
depth=depth,
),
an=an,
name=f'spawn_until_{depth}', name=f'spawn_until_{depth}',
) )
@ -41,37 +37,28 @@ async def main():
python -m tractor._child --uid ('name_error', '6c2733b8 ...) python -m tractor._child --uid ('name_error', '6c2733b8 ...)
''' '''
async with ( async with tractor.open_nursery(
tractor.open_nursery( debug_mode=True,
debug_mode=True, enable_transports=['uds'], # TODO, apss this via osenv?
enable_transports=['uds'], # TODO, apss this via osenv? loglevel='devx', # XXX, required for test!
loglevel='devx', # XXX, required for test! ) as n:
) as an,
trio.open_nursery() as tn,
):
# spawn the deeper tree in the bg..
tn.start_soon(
partial(
tractor.to_actor.run,
partial(
spawn_until,
depth=1,
),
an=an,
name='spawner1',
)
)
# ..while blocking on the shallow (faster to fail) tree # spawn both actors
# whose propagated error triggers nursery cancellation. portal = await n.run_in_actor(
await tractor.to_actor.run( spawn_until,
partial( depth=0,
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,24 +13,17 @@ async def main():
simultaneously. simultaneously.
''' '''
async with ( async with tractor.open_nursery(
tractor.open_nursery( debug_mode=True,
debug_mode=True, # loglevel='debug' # ?XXX required?
# loglevel='debug' # ?XXX required? ) as n:
) as an,
trio.open_nursery() as tn, # spawn both actors
): portal = await n.run_in_actor(key_error)
# spawn the actor..
portal = await an.start_actor(
'key_error',
enable_modules=[__name__],
)
print( print(
f'Child is up @ {portal.chan.aid.reprol()}' f'Child is up @ {portal.chan.aid.reprol()}'
) )
# ..then schedule its erroring task in the bg while the
# root blocks below.
tn.start_soon(portal.run, key_error)
# XXX: originally a bug caused by this is where root would enter # XXX: originally a bug caused by this is where root would enter
# the debugger and clobber the tty used by the repl even though # the debugger and clobber the tty used by the repl even though

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

View File

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

View File

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

View File

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

View File

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

View File

@ -769,15 +769,6 @@ def test_multi_subactors_root_errors(
@has_nested_actors @has_nested_actors
@pytest.mark.skipif(
platform.system() == 'Darwin'
and
_ci_env,
reason=(
'Nested crash-REPL ordering is unreliable on macOS CI; '
'see https://github.com/goodboy/tractor/issues/320'
),
)
def test_multi_nested_subactors_error_through_nurseries( def test_multi_nested_subactors_error_through_nurseries(
ci_env: bool, ci_env: bool,
spawn: PexpectSpawner, spawn: PexpectSpawner,