Add tractor.to_actor one-shot task API subpkg - #481
Conversation
There was a problem hiding this comment.
Pull request overview
Introduces a new tractor.to_actor subpackage providing a high-level, “one-shot” remote task invocation API built on existing ActorNursery.start_actor() + Portal.run() + Portal.cancel_actor() primitives, intended as the successor to ActorNursery.run_in_actor() (issue #477).
Changes:
- Adds
tractor.to_actor.run()implementing one-shot subactor spawn/reuse, remote execution, and teardown behavior. - Exposes the new subpackage at the top level (
import tractor.to_actor) and documents the intended migration path fromrun_in_actor(). - Adds a dedicated test suite and a runnable example demonstrating concurrent one-shot usage patterns.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tractor/to_actor/_api.py | Implements the new to_actor.run() API and its helper routines. |
| tractor/to_actor/init.py | Defines the new tractor.to_actor public surface (re-export of run). |
| tractor/runtime/_supervise.py | Updates run_in_actor() notes/TODOs to point to the successor API. |
| tractor/init.py | Re-exports to_actor at the package top level. |
| tests/test_to_actor.py | Adds coverage for placement modes, error propagation, concurrency pattern, and validation. |
| examples/parallelism/to_actor_one_shots.py | Adds an example demonstrating implicit runtime boot and concurrent one-shots. |
| ai/prompt-io/prompts/issue_477.md | Adds the driver prompt used for the work. |
| ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.raw.md | Adds raw AI output log for the work session. |
| ai/prompt-io/claude/20260702T154255Z_65bf9df5_prompt_io.md | Adds summarized AI output log for the work session. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
tractor.to_actor one-shot task API subpkg
5cd190c to
a34aaf9
Compare
a34aaf9 to
4151b95
Compare
goodboy
left a comment
There was a problem hiding this comment.
[P1] Do not treat a cancel acknowledgement as process reaping
tractor/to_actor/_api.py:96-112 | confidence: high | category: concurrency
Portal.cancel_actor() only requests runtime cancellation. It can return False after its bounded timeout, and even True does not mean the subprocess was joined or removed from ActorNursery._children.
Consequences:
- Private placement can hang during nursery exit when cancellation is not acknowledged.
- Caller-managed
an=placement returns while child monitor tasks and process records remain alive. - Repeated one-shots through a long-lived nursery accumulate resources instead of being reaped per call.
The existing Copilot thread correctly identifies part of this problem.
Recommendation: add an owned-child operation that cancels, escalates if necessary, waits for process termination, and removes that specific child before to_actor.run() returns. Add timeout and immediate-an._children cleanup tests.
[P2] Separate remote function arguments from placement controls
tractor/to_actor/_api.py:115-145 | confidence: high | category: API correctness
The API consumes names such as name, portal, an, loglevel, and runtime_kwargs before building fn_kwargs. A valid remote function cannot receive an argument with any of those names.
For example, to_actor.run(greet, name='Alice') names the actor but invokes greet() without its required name.
Recommendation: accept remote arguments through an explicit mapping or otherwise create a collision-free calling convention. Add coverage for functions using reserved parameter names.
[P2] Cancel portal-reused remote tasks when the caller is cancelled
tractor/to_actor/_api.py:183-192 | confidence: high | category: cancellation
The portal= path directly awaits Portal.run(). If the local caller is cancelled, no Context.cancel() request is sent, so the remote task can continue after its calling task has exited.
Recommendation: retain the submitted Context and shield a task-level cancellation request during local cancellation. Do not cancel the caller-owned actor itself.
[P3] Reject runtime options that cannot take effect
tractor/to_actor/_api.py:168-181,217-218 | confidence: high | category: API correctness
When already inside an actor runtime without an= or portal=, runtime_kwargs reaches open_nursery(), whose existing-runtime path ignores those root-runtime options. Configuration can therefore appear accepted without taking effect. The truthiness check also permits runtime_kwargs={} alongside placement options.
Recommendation: reject runtime_kwargs whenever no new root runtime will be created and validate with runtime_kwargs is not None.
Review State
#481is not landing-ready due to the P1 lifecycle issue.- Correctness thread
r3514759131remains unresolved and is valid. - Prompt-provenance thread
r3514759173should be declined: the time-specific instruction is historical input and rewriting it would falsify the provenance record. - Fresh CI is still running.
- The source TODO marker changed from
[ ]to[x]; confirm that was an intentional human-owned state transition.
Checks run: static inspection of the exact PR diff, callers, process monitors, cancellation paths, tests, and current GitHub review state.
Checks not run: local tests and analyzers were not authorized; fresh GitHub CI is currently exercising the rebased head.
Scope: PR #481, base/merge-base 4e27dcde, head 4151b956, 9 changed files. Prompt provenance was excluded from semantic code review except for triaging its review comment.
(this review was generated in some part by opencode using gpt-5.6-sol (openai))
Give each `ActorNursery` child its own reap request and completion event. Owned one-shots now wait for process joining and bookkeeping removal before returning. Escalate unacknowledged cancellation with `proc.kill()` after an active debugger releases. Latch nursery-wide teardown for monitors that finish startup late, and snapshot children before cancellation checkpoints permit concurrent removal. Cover immediate managed-nursery cleanup, failed cancel acknowledgements and late monitor registration across Trio TCP/UDS and `mp_spawn`. Caught-during: review remediation Found-via: `/run-tests` test_late_child_reap_registration_is_released Review: PR #481 (copilot-pull-request-reviewer) #481 (comment) Prompt-IO: ai/prompt-io/opencode/20260818T031532Z_4151b956_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
`SendStream.send_all()` can raise `trio.Cancelled` after writing an arbitrary prefix of the four-byte length header and payload. The peer can no longer distinguish a following msg boundary. Close the stream under a shield before propagating cancellation so callers can not append another msg to an indeterminate byte stream. Caught-during: review remediation Found-via: prospective P2 cancellation review Review: PR #481 (opencode) #481 (review) Prompt-IO: ai/prompt-io/opencode/20260818T193001Z_bf06b4f8_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
Derive the `Actor._contexts` key from each `Context` in one idempotent `Actor._drop_context()` helper instead of reconstructing the peer UID and CID at every teardown site. Use the helper for caller-side context exit and preserve a strict identity assertion when the callee-side RPC task deregisters itself. Keep channel closure and cancellation shielding with their existing lifecycle owners. Caught-during: review remediation Found-via: staged P2 lifecycle review Review: PR #481 (opencode) #481 (review) Prompt-IO: ai/prompt-io/opencode/20260818T193002Z_bf06b4f8_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
Cancellation after `Start` publication but before `StartAck` can strand the caller context and leave its remote task running. Make one shielded, bounded task-cancel request before dropping local startup state. Keep the private `cancel_on_startup` policy outside public target kwargs and disable it for the `_cancel_task` RPC itself so cleanup can not recursively cancel its own startup. Release each private helper context on exit and prove the caller-owned actor remains reusable after controlled startup cancellation. Caught-during: review remediation Found-via: `/run-tests` test_cancel_during_context_startup Review: PR #481 (opencode) #481 (review) Prompt-IO: ai/prompt-io/opencode/20260818T193003Z_bf06b4f8_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
`Actor.start_remote_task()` registers its caller context before sending `Start`, but only cancellation cleaned that state. Encoding, ack timeout, malformed ack and remote authorization errors leaked it. Protect the complete send, acknowledgement and validation phase. Track successful publication, make a remote cancellation attempt only when protocol-safe and always release the local context while preserving the original startup error. Cover both pre-publication serialization failure and a remote `ModuleNotExposed` rejection without damaging a reused portal. Caught-during: review remediation Found-via: `/run-tests` startup-failure regressions Review: PR #481 (opencode) #481 (review) Prompt-IO: ai/prompt-io/opencode/20260818T193004Z_bf06b4f8_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
Pass target inputs positionally and normalize every retained `functools.partial()` layer, including Python 3.14 Placeholder binding. Validate the complete target signature before startup. Route each ordinary async fn through a static `@context` endpoint so remote results, errors and caller cancellation remain linked. Send namespace and function components separately, then resolve through `Actor._get_rpc_func()` so the RPC module allowlist remains authoritative. Retain client-created `NamespacePath` refs so `to_tuple()` does not re-import their callable. Owned actors enable the endpoint's `__name__` directly. Keep `to_actor.MODULE` as the importer-facing alias used by caller-owned portals while retaining the target module's authorization boundary. Cover all placement modes, nested partials, argument collisions, linked cancellation, remote errors and authorization failures. Caught-during: review remediation Found-via: `/run-tests` portal cancellation regression Review: PR #481 (opencode) #481 (review) Prompt-IO: ai/prompt-io/opencode/20260818T193005Z_bf06b4f8_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
Treat `runtime_kwargs` as provided whenever it is not `None`. Previously an empty dict bypassed placement validation and was silently ignored when `an` or `portal` selected an existing runtime. Reject both placement modes before actor startup for empty and configured runtime kwargs while preserving empty-dict use when `to_actor.run()` owns its private runtime. Caught-during: review remediation Found-via: `/code-review` P3 option-validation finding Review: PR #481 (opencode) #481 (review) Prompt-IO: ai/prompt-io/opencode/20260819T020757Z_b38efed7_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
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`))
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 73 out of 73 changed files in this pull request and generated no new comments.
Suppressed comments (3)
tractor/runtime/_supervise.py:679
ActorNursery.cancel()never setsself._cancelled_caughttoTrueon the successful (non-timeout) path; theelse:branch is currently a no-op. This makesActorNursery.cancelled_caughtpermanentlyFalseand contradicts the property/docstring intent.
tractor/runtime/_supervise.py:677- In the cancel-timeout fallback (
cs.cancelled_caught), the log says "Hard killing" but the code still usesproc.terminate(). If this path is meant to guarantee teardown under misbehaving children, preferproc.kill()when available (SIGKILL / non-ignorable), and only fall back toterminate()when.kill()is unavailable.
tractor/runtime/_supervise.py:93 - The
_try_cancel_then_kill()docstring still claims it always callsPortal.cancel_actor(raise_on_timeout=True), but the implementation now passesraise_on_timeout=not debug_protected. Updating the docstring avoids misleading readers about when timeouts are expected to raise/escalate.
The Darwin-only debugger skip in `49fc92b0` passed the raw `CI=true` env string to `skipif`, so `pytest` evaluated `true` as Python source and failed at setup instead of skipping the issue #320 node. Cast `_ci_env` through `bool()` so the marker always receives a boolean while retaining Linux coverage. Prompt-IO: ai/prompt-io/opencode/20260820T135125Z_9f99043b_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
The frame-publication shield added in `88a23449` checks for pending cancellation only after a successful write. If actor teardown closes the stream first, `ClosedResourceError` escaped as `TransportClosed` and could defeat the caller's cancel scope. Check for pending cancellation on the transport-error path before normalizing the close. Genuine stream errors retain their existing translation when no cancellation is active. The cancellation-first path can swap which nested debugger intermediary renders as the immediate source vs. relay. Keep assertions over both actor levels while accepting either valid role. Prompt-IO: ai/prompt-io/opencode/20260820T143845Z_559fd0f1_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
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`))
A cancel RPC could stall forever in complete-frame transport shielding before the peer received it, bypassing the outer ack timeout and blocking graceful supervision. - thread one absolute deadline from `Portal.cancel_actor()` through the private `Start` publication path - force-close a partial-frame stream before releasing its send lock - keep ordinary sends unbounded and preserve pending cancellation - document the current `Start -> StartAck -> CancelAck` exchange and link the dedicated `Cancel` msg follow-up in #506 - cover partial publication and the shared send/ack timeout budget Prompt-IO: ai/prompt-io/opencode/20260821T023537Z_ae6f2ac3_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
A child could pass the early `.start_actor()` guard, miss the `.cancel()` child snapshot and register afterward. Its monitor inherited a reap request without runtime cancellation and could wait forever. - publish child/reap events before sampling `_cancel_called` - make MP abort before `proc.start()` when cancellation won - kill a Trio child opened after cancellation won registration - reject starts begun after nursery cancellation is already visible - add deterministic registration and MP no-start regressions - drop the touched Trio backend's stale `get_runtime_vars` import Prompt-IO: ai/prompt-io/opencode/20260821T040803Z_3c1bbe73_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
Target validation moved to a follow-up branch, so the guide should not claim unstable callable forms are rejected before actor startup. Describe module-global functions and `functools.partial()` wrappers as portable stable-address forms without promising absent enforcement. (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
70f497b to
ce38cb6
Compare
There was a problem hiding this comment.
i think this was added by accident maybe?
| - the child is *auto-cancelled* once its "main" result lands; | ||
| at nursery exit these run-once children are always reaped | ||
| first (causality_ is paramount!). | ||
| - targets cross IPC as ``module:name`` references, so portable calls |
There was a problem hiding this comment.
maybe we should clarify this limitation, that it's due to our enable_modules: list requirement in (sub)actors such that arbitrarily defined fns can't be invoked remotely without prior permission handed to the runtime.
| ) | ||
| ) | ||
|
|
||
| await trio.sleep(0.5) |
There was a problem hiding this comment.
this sleep is from the orig which has an async call and i presume the sleep was for naive syncing? maybe it can be dropped since we moved to blocking semantics on the name_error caller?
There was a problem hiding this comment.
maybe we need a comment like below explaining this yields to the sched so that breakpoint_forever caller task gets invoked; presuming that's why it's still necessary.
| tn.start_soon(portal.run, name_error) | ||
| tn.start_soon(portal1.run, spawn_error) | ||
|
|
||
| # yield to the bg tasks so both RPC requests are |
There was a problem hiding this comment.
ahh ok, so the sleep i quiffer earlier likely needs this as well?
| async with ( | ||
| tractor.open_nursery( | ||
| debug_mode=True, | ||
| enable_transports=['uds'], # TODO, apss this via osenv? |
There was a problem hiding this comment.
typo apply we can prolly fix
| async with ( | ||
| tractor.open_nursery( | ||
| debug_mode=True, | ||
| # loglevel='debug' # ?XXX required? |
There was a problem hiding this comment.
i presume not required since it's been sitting here so long with green CI 😛
| await trio.sleep(1/50000/50) | ||
|
|
||
| return os.getpid() | ||
| return pid |
There was a problem hiding this comment.
maybe a comment indicating that the return val is what's returned to the to_actor.run() caller side?
There was a problem hiding this comment.
hmm maybe we should relable this file as concurrent_toactor_primes.py?
if it's basically the same as concurrent_actors_primes.py but using the to_actor.run() api. also follow-up note, concurrent_actors_primes.py definitely needs an update after #484 (and if that pr doesn't already do that) to conventional naming (an, tn) and per /py-codestyle.
| for name in ('donny', 'gretchen') | ||
| } | ||
|
|
||
| async def run_and_print(name: str, other_actor: str): |
There was a problem hiding this comment.
switch this to multiline arg, per line style.
|
|
||
| async def run_and_print(name: str, other_actor: str): | ||
| print( | ||
| await portals[name].run( |
There was a problem hiding this comment.
maybe a comment indicating this is Portal.run() usage?
| @@ -0,0 +1,3 @@ | |||
| Add ``tractor.to_actor.run()`` for Trio-style one-shot async calls in | |||
There was a problem hiding this comment.
ahh nice i've totes forgot about doing changelog entries 🫣, we should keep this up 👍🏼
| ) | ||
| child.sendline('c') | ||
| child.expect(EOF) | ||
| assert_before( |
There was a problem hiding this comment.
wait these were removed but are never checked any more orrr?
| ] | ||
| if not no_capfd: | ||
| expect_on_teardown += [ | ||
| # 'Shutting down actor runtime', |
There was a problem hiding this comment.
aww i always like this little cutie, but i guess it makes sense to be all "professional" in our msgs now since we're becoming a real project Bp
| ) | ||
| packed: dict[str, object] = {} | ||
|
|
||
| def pack_overrun( |
There was a problem hiding this comment.
maybe a comment that this mocked fn is used to avoid actually raising the error, presuming that's why it's actually being defined over our real helper fn?
| payload, | ||
| hide_tb=hide_tb, | ||
| ) | ||
| if isinstance(payload, tractor.msg.Start): |
There was a problem hiding this comment.
why do we need an isinstance check if we only ever call this once with a Start msg?
| try: | ||
| await ctx.started() | ||
| await trio.sleep_forever() | ||
| finally: |
There was a problem hiding this comment.
should we maybe be ensuring a Cancelled (or ContextCancelled) is actually raised explicitly instead of presuming?
|
|
||
| def _non_registration_contexts( | ||
| actor: Actor, | ||
| ) -> dict[tuple, str]: |
There was a problem hiding this comment.
doc string indicating this simply returns all registered actor contexts except the registry calls?
|
|
||
| ''' | ||
| async with tractor.open_nursery() as an: | ||
| actor = tractor.current_actor() |
There was a problem hiding this comment.
actually for any examples/tests we're updating in this PR we might as well add terse typing at least from tractor.* if possible.
| portal: tractor.Portal = await an.start_actor( | ||
| 'module_error_worker', | ||
| ) | ||
| contexts_before = _non_registration_contexts(actor) |
There was a problem hiding this comment.
yeah particularly for helper fns being added to test suites it'd be good to see return types for easier human passes.
Add
tractor.to_actorone-shot task API subpkgMotivation
First cut at resolving #477: the
.run_in_actor()"one shot"single-remote-task style API is coupled to
ActorNurseryinternals(the secondary
._ria_nursery, portal result-marking sets andbackend-side reaper tasks) which complicates the spawn and
cancellation machinery and defers remote-error propagation to nursery
teardown. Adopting the parlance of sibling APIs (
trio.to_thread,anyio.to_process) we instead deliver atractor.to_actorsubpkgcomposed purely from the lower level daemon-spawn + portal
primitives, moving error collection/propagation up into whatever
local
trioscope encloses the call — and paving the way to fullydrop the
._ria_nurserymachinery.Summary of changes
tractor.to_actorsubpkg exportingrun(fn, **fn_kwargs) -> Any: spawn a subactor viaActorNursery.start_actor(), schedulefnas its lone remote task withPortal.run(), block waiting onand return its result, ALWAYS reaping the subactor via a
finally-scopedPortal.cancel_actor(). Remote errors raisedirectly in the caller's task as boxed
RemoteActorErrors."Placement" opts:
portal=re-uses a running actor (nospawn/reap),
an=spawns from a caller-managed actor-nursery,neither opens a private call-scoped
open_nursery()(implicitlybooting the runtime, tunable via pass-through
runtime_kwargs).Fail-fast pre-spawn validation: non-streaming async fn only,
portal=/an=mutually exclusive, noruntime_kwargsalongside aplacement opt. Legacy
.run_in_actor()behavior untouched; itsTODO/docstring now x-ref the successor API.
tests/test_to_actor.py: 11-test suite covering all placementvariants, remote-error relay (bare + inside a caller-managed
an),portal re-use w/o implicit reap, the concurrent "worker-pool-ish"
pattern (local
triotask nursery scheduling one-shots against ashared
an) and the 4 validation rejections.examples/parallelism/to_actor_one_shots.py: runnable demo(auto-collected by
test_docs_examples.py) of the fully-implicitone-shot + the concurrent prime-check pattern, a mini version of
the neighboring
concurrent_actors_primes.py.Future follow up
Tracked in #477 (see the landed-shape comment there),
.run_in_actor()usage (tests, examples, docs)to
to_actor.run()DeprecationWarningfrom.run_in_actor()(blocked on^)
._ria_nursery+._cancel_after_result_on_exitmachinery — in progress on the stacked
drop_ria_nurserybranch(result-waiting being re-scoped into the
to_actorlayer), seeai/conc-anal/ria_nursery_removal_plan.md.run_in_actor()autodoc entry.run_in_actor()? #172)(this pr content was generated in some part by
claude-code)