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.

sequence diagram of the context handshake msg flow

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:

Start

sent by Portal.open_context(): “schedule a task running this function with these kwargs”.

StartAck

the peer runtime confirms the task is scheduled and that the endpoint really is a context-style fn.

Started

emitted when the child task calls tractor.Context.started(); carries the first payload and unblocks the parent’s entry of open_context().

Yield

one per tractor.MsgStream.send(), flowing in either direction while a stream is open.

Stop

graceful end-of-stream: the far side’s async for terminates cleanly.

Return

the child fn returned; its value becomes the context’s final result. If the child raised instead, an Error msg 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:

examples/rpc_bidir_streaming.py#
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 the Start msg then blocks until the child task calls await ctx.started(data + 1) — hence sent == 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, one Yield msg per stream.send().

  • after the 9th pong the parent breaks (10 pings sent in total) and exits its stream block, which sends Stop; the child’s async for completes gracefully and its else clause 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’s Return (here None).

  • 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 at open_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 Error msg and re-raises in the parent block boxed as a tractor.RemoteActorError; the original class rides along as .boxed_type with .src_uid naming 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_size on ctx.open_stream() sizes the buffer.

  • allow_overruns=True (on Portal.open_context() and/or ctx.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.