The Context: a cross-actor task pair#
If you’ve written any trio you already know the contract: every
task lives in a nursery, errors always propagate, cancellation is
scoped, and nothing leaks. tractor extends that exact contract
across processes — the same guarantees from the seminal
blog post, just with the nursery split across two memory
domains. The primitive that does it is tractor.Context: a
linked pair of tasks, one in each of two actors, supervised as
a single structured concurrency (SC) scope over IPC.
The SC-transitive supervision protocol, msg by msg.#
Pretty much everything else is (or is slated to be) built on this
one primitive: ActorNursery.run_in_actor() is a convenience
for “spawn, open a context, await the result, tear down”; plain
Portal.run() RPC is planned to be re-implemented on top of it;
the multi-process debugger’s tree-wide REPL lock rides one. Grok
this page and the rest of the library reads as convenience
wrappers B)
The endpoint contract#
A context endpoint is an async function decorated with
tractor.context() which declares a param annotated
tractor.Context — any param name you like, the annotation is
what’s required:
@tractor.context
async def trainer(
ctx: tractor.Context,
model: str,
) -> str:
await ctx.started('ready')
return f'trained {model}'
The parent (aka “opener”) side invokes it through a
tractor.Portal using Portal.open_context(), passing
any extra kwargs which are shipped over the wire as the remote
task’s arguments. Since the target fn is referenced by module
path, that module must be listed in the peer actor’s
enable_modules allowlist — RPC capability is always opt-in.
The decorator also accepts a pld_spec: a type (union) which
every payload in the dialog is validated against, upgrading your
msgs to a typed contract enforced via tractor.MsgTypeError.
Validation strictness follows the “cheap or nasty”
(un)protocol pattern: the one-shot Started payload gets the
nasty treatment (stringently round-trip checked before it’s even
sent) while high-rate stream payloads stay cheap (checked only
receiver side).
The handshake, on the wire#
Every context runs one instance of tractor’s “SC-transitive
supervision protocol”: a tiny fixed grammar of msgspec-typed msgs
encapsulating all RPC dialogs between actors. Transitive
because each IPC link obeys the same rules a local nursery does —
starts are acked, completion is awaited, errors and cancels always
relay — so chaining links across a process tree composes into one
tree-wide SC scope.
The figure up top shows a full dialog; in order:
Startsent by
Portal.open_context(): “schedule a task running this function with these kwargs”.StartAckthe peer runtime confirms the task is scheduled and that the endpoint really is a context-style fn.
Startedemitted when the child task calls
tractor.Context.started(); carries the first payload and unblocks the parent’s entry ofopen_context().Yieldone per
tractor.MsgStream.send(), flowing in either direction while a stream is open.Stopgraceful end-of-stream: the far side’s
async forterminates cleanly.Returnthe child fn returned; its value becomes the context’s final result. If the child raised instead, an
Errormsg takes this slot carrying the boxed traceback.
ctx.started(): just like task_status.started()#
The startup phase is a deliberate clone of
trio.Nursery.start() semantics: the child decides when it’s
“up”, optionally handing back a first value, and the parent stays
blocked until that moment:
# trio, in-process
first = await nursery.start(child_fn)
# tractor, cross-process
async with portal.open_context(child_fn) as (ctx, first):
...
The as (ctx, first) tuple is exactly that pair: the
tractor.Context handle plus whatever value the child
passed to await ctx.started(value). And readiness is not
optional — for instance opening a stream before .started()
has been called raises a RuntimeError; handshake first, then
dialog.
Bidirectional streaming over a context#
The canonical ping-pong (design history: #53, #223) — a full-duplex msg stream between a parent and its spawned peer:
import trio
import tractor
@tractor.context
async def simple_rpc(
ctx: tractor.Context,
data: int,
) -> None:
'''Test a small ping-pong 2-way streaming server.
'''
# signal to parent that we're up much like
# ``trio.TaskStatus.started()``
await ctx.started(data + 1)
async with ctx.open_stream() as stream:
count = 0
async for msg in stream:
assert msg == 'ping'
await stream.send('pong')
count += 1
else:
assert count == 10
async def main() -> None:
async with tractor.open_nursery() as n:
portal = await n.start_actor(
'rpc_server',
enable_modules=[__name__],
)
# XXX: syntax requires py3.9
async with (
portal.open_context(
simple_rpc, # taken from pytest parameterization
data=10,
) as (ctx, sent),
ctx.open_stream() as stream,
):
assert sent == 11
count = 0
# receive msgs using async for style
await stream.send('ping')
async for msg in stream:
assert msg == 'pong'
await stream.send('ping')
count += 1
if count >= 9:
break
# explicitly teardown the daemon-actor
await portal.cancel_actor()
if __name__ == '__main__':
trio.run(main)
What’s going on?
start_actor()spawns the daemon-style subactor'rpc_server'with this very module in its allowlist.portal.open_context(simple_rpc, data=10)fires theStartmsg then blocks until the child task callsawait ctx.started(data + 1)— hencesent == 11.both tasks enter
ctx.open_stream(): a stream dialog is only fully open once each side has entered its block.the parent seeds the first
'ping'; each side then echoes the other, oneYieldmsg perstream.send().after the 9th pong the parent
breaks (10 pings sent in total) and exits its stream block, which sendsStop; the child’sasync forcompletes gracefully and itselseclause asserts all 10 pings arrived.the 10th in-transit pong? Discarded by the implicit drain at
open_context()exit, which runs the dialog down to the child’sReturn(hereNone).daemon actors live until told otherwise:
portal.cancel_actor()reaps the subactor explicitly.
Results: the Return leg#
Every context resolves to a final outcome. Wait on it explicitly from the parent side:
async with portal.open_context(ep) as (ctx, first):
...
result = await ctx.wait_for_result()
or just exit the block — __aexit__ implicitly drains the msg
flow until the Return (or Error) arrives, discarding any
in-transit Yields on the way. Either way the rule of
causality holds exactly as in a local nursery: the opener
never unblocks before the remote task is done.
For post-hoc inspection (think supervision/restart logic) the ctx
also exposes Context.outcome, .maybe_error and
.has_outcome — where a “result” might well be the error the
dialog ended with.
Cancellation semantics#
The part you actually came for; read it twice B)
A context’s two tasks are cancel-scope-linked across the IPC boundary: whatever ends one side — error, cancellation, plain old return — is relayed such that the other side ends equivalently. No silent half-open dialogs, no orphaned remote tasks, ever.
ctx.cancel() cancels the remote task#
tractor.Context.cancel() requests cancellation of the
remote task only:
async with portal.open_context(ep) as (ctx, first):
await accomplish_things(ctx)
await ctx.cancel() # remote task, NOT me
A tractor.Context is not a trio.CancelScope:
the call doesn’t (and can’t) cancel your local task. It sends the
cancel request and waits a bounded timeout for the peer
runtime’s CancelAck, then your code proceeds to the block exit
as normal.
Compare scopes here: Portal.cancel_actor() is the big hammer
which cancels the peer’s entire runtime (and thus process);
ctx.cancel() is the per-dialog scalpel.
ContextCancelled and the absorption rule#
When a context task gets cancelled by request the requestee’s
runtime reports back with a tractor.ContextCancelled
(“ctxc”) whose .canceller field holds the uid of the actor
which asked. That one field decides what you observe:
- you requested it
i.e.
ctxc.canceller == tractor.current_actor().uid: the ctxc is absorbed atopen_context()exit — nothing raises in your block. You asked for a graceful stop and got it; if you care,await ctx.wait_for_result()hands the ctxc back as a plain value for inspection.- anyone else requested it
the peer cancelling itself, or some third actor cancelling it from the side: the ctxc is raised in your block. From your scope’s perspective a task you depend on was killed out from under you and SC demands you hear about it — exactly like a sibling crash in a nursery.
In code:
try:
async with portal.open_context(ep) as (ctx, first):
...
except tractor.ContextCancelled as ctxc:
# can only be a peer- or third-party cancel;
# self-requested cancels are absorbed at exit.
assert ctxc.canceller != tractor.current_actor().uid
This self- vs cross-cancel split is what makes explicit teardown composable: a supervisor cancels its dialogs without try/except noise, while unexpected cancellation anywhere in the tree still propagates loudly like any other failure.
Warning
Once ctx.cancel() has been called the dialog is done: a
subsequent ctx.open_stream() raises RuntimeError.
For introspection the ctx exposes trio-flavored status props:
.cancel_called (this side requested), .cancel_acked (peer
confirmed), .cancelled_caught and .canceller —
deliberately mirroring trio.CancelScope naming.
Errors propagate, both ways#
A crash on either end tears down the pair, SC style:
child raises: the exception ships back as an
Errormsg and re-raises in the parent block boxed as atractor.RemoteActorError; the original class rides along as.boxed_typewith.src_uidnaming the crashed actor.parent raises (or is cancelled) inside the block: an equivalent error/cancel is relayed to the child task so it can never outlive the dialog.
try:
async with portal.open_context(ep) as (ctx, first):
...
except tractor.RemoteActorError as rae:
if rae.boxed_type is ValueError:
... # remote ValueError, type preserved
Errors that hop through intermediary actors on their way up the
tree (“inceptions” XD) keep the full relay trail in
.relay_uid / .relay_path. Payloads violating your declared
pld_spec surface as the IPC analog of a TypeError:
tractor.MsgTypeError.
Overruns and backpressure#
Stream msgs land in a bounded per-context buffer on the receiver
side. A sender that outpaces a non-consuming receiver overruns
it and the runtime raises StreamOverrun (from
tractor._exceptions; also a trio.TooSlowError) instead
of buffering without bound — SC discipline applies to memory too.
Your knobs:
msg_buffer_sizeonctx.open_stream()sizes the buffer.allow_overruns=True(onPortal.open_context()and/orctx.open_stream()) opts in to absorbing overflow instead of erroring — reasonable for bursty telemetry-ish feeds, just know you’re trading the error for extra buffering.
One context, one stream#
A MsgStream is strictly one-shot use: once it closes —
gracefully or not, from either side — it can never be re-opened
on the same ctx. Want another round with the same peer? Open a
fresh context; they’re cheap. The full close-vs-cancel teardown
story lives in Cross-process streaming.
Where to next?
Cross-process streaming covers the rest of the msg-moving story:
the legacy one-way API, multi-actor pipelines and in-actor
broadcast fan-out. For exhaustive API detail see
tractor.Context, tractor.MsgStream and
tractor.ContextCancelled.