Compare commits

..

12 Commits

Author SHA1 Message Date
Gud Boi bf46f5cc5e Clarify pointer and IPC cancellation contracts
Source prose left the stalled transport peer ambiguous, omitted why a
local namespace pointer retains its object and described cancellation
as interrupting a frame write which is now shielded.

Identify remote-peer and bounded-cancel behavior, document process-local
pointer caching, and explain the shield completion checkpoint which
makes startup cancellation protocol-safe on a connected channel.

Review: PR #481 (goodboy)
https://github.com/goodboy/tractor/pull/481#pullrequestreview-5012942328

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-25 01:38:17 -04:00
Gud Boi 773370a423 Clarify cancellation race test contracts
Cancellation tests covered distinct hard-reap, deadline and scheduler
contracts, but their names and prose blurred public boolean outcomes,
transport closure and expected timeout behavior.

Document each deterministic unit seam and race ordering, distinguish
the five-second hang ceiling from normal teardown, and rename deadline
tests around their shared request-and-ack budget.

Review: PR #481 (goodboy)
https://github.com/goodboy/tractor/pull/481#pullrequestreview-5012942328

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-25 01:17:04 -04:00
Gud Boi a99a4c9353 Assert `to_actor.run()` runtime lifecycle
The implicit-runtime test verified the caller started outside Tractor
but did not prove that the target ran inside an actor or that the
private runtime was gone when the call returned.

Assert an active actor inside the shared remote target and assert the
caller has no current actor again after the one-shot call completes.

Review: PR #481 (goodboy)
https://github.com/goodboy/tractor/pull/481#pullrequestreview-5012942328

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-25 00:09:10 -04:00
Gud Boi b58889f625 Move `NamespacePath` ref test into `tests.msg`
The retained-reference regression exercised generic message pointer
behavior but lived in the one-shot actor API suite and combined an
unrelated public trampoline alias assertion.

Move the pointer regression into a focused message-layer test module
and retain the alias contract as its own `to_actor` API test.

Review: PR #481 (goodboy)
https://github.com/goodboy/tractor/pull/481#pullrequestreview-5012942328

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-24 23:49:09 -04:00
Gud Boi 0a580df63d Share actor-context test helpers
The context and one-shot suites duplicated cancellation file markers
and filtering of registrar-owned runtime contexts.

Move those mechanics into `tests._helpers` while retaining each
endpoint's distinct startup handshake. Also update the startup-cancel
`Channel.send()` mock to accept and forward the new `send_deadline` arg.

Caught-during: review remediation
Found-via: `/run-tests` test_cancel_during_context_startup[trio]

Review: PR #481 (goodboy)
https://github.com/goodboy/tractor/pull/481#pullrequestreview-5012942328

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-24 23:13:20 -04:00
Gud Boi 617ca1de43 Clarify `run()` actor lifetime management
`run()` described its actor-selection kwargs as placement controls,
but they determine who owns the actor lifetime and whether an existing
actor is reused or a new one is spawned.

Use lifetime-management terminology in the parameter comments and
docstring, and identify the existing-actor handle as `portal: Portal`.

Review: PR #481 (goodboy)
https://github.com/goodboy/tractor/pull/481#pullrequestreview-5012942328

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-24 22:37:49 -04:00
Gud Boi 9373e9434d Inline `functools.Placeholder` lookup
Partial normalization assigned the optional Python 3.14 placeholder
sentinel separately from its only conditional consumer.

Bind the sentinel with a walrus expression directly in the guard while
retaining the `getattr()` fallback for older Python versions.

Review: PR #481 (goodboy)
https://github.com/goodboy/tractor/pull/481#pullrequestreview-5012942328

Prompt-IO: ai/prompt-io/opencode/20260825T021319Z_ce430fca_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-24 22:19:21 -04:00
Gud Boi ce430fca64 Clarify provisional child registration
Child monitors register before their IPC handshake so cancellation
owns every started process, but the bare `None` portal arg obscured
that `Portal(chan)` replaces the provisional entry after connection.

Document that transition and name all `_register_child()` args in both
spawn backends. Replace the MP test's positional-only lambda with a
signature-accurate fake which asserts the provisional portal state.

Caught-during: review remediation
Found-via: `/run-tests` test_mp_late_registration_never_starts_process

Review: PR #481 (goodboy)
https://github.com/goodboy/tractor/pull/481#pullrequestreview-5012942328

Prompt-IO: ai/prompt-io/opencode/20260825T015742Z_e42ecb55_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-24 22:11:15 -04:00
Gud Boi e42ecb559d Use `Aid` keys for child reap state
The fresh reap-coordination maps still used legacy `.uid` tuples even
though process monitors and channels carry complete `Aid` identities.
This extended the legacy key format into new private state.

Key both reap maps by `Aid` and derive `.uid` only when accessing the
existing `_children` map. UUID-based `Aid` hashing lets the subactor
and decoded channel identities resolve the same synchronization state.

Update registration tests to exercise the full identity keys.

Review: PR #481 (goodboy)
https://github.com/goodboy/tractor/pull/481#pullrequestreview-5012942328

Prompt-IO: ai/prompt-io/opencode/20260824T233957Z_5327b25e_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-24 21:56:00 -04:00
Gud Boi 5327b25e1b Factor `child_in_debug()` state sampling
`_try_cancel_then_kill()` repeated the same child/tree debugger
predicate before and after its cancel-RPC checkpoint. Inline
duplication obscured that lock state must be sampled at both points.

Factor the predicate into a local `child_in_debug()` sampler. Use it
for initial hard-kill protection and re-run it after the await before
debugger waiting, preserving dynamic lock-state behavior. Keep it
local since one input is supervisor-owned nursery configuration.

Review: PR #481 (goodboy)
https://github.com/goodboy/tractor/pull/481#pullrequestreview-5012942328

Prompt-IO: ai/prompt-io/opencode/20260824T225356Z_2f86dd1a_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-24 19:38:22 -04:00
Gud Boi 2f86dd1a33 Assert paired `ActorNursery` reap state
`._mark_child_reaped()` previously discarded the reap-request event
without checking that its completion-event peer existed. A one-sided
entry would silently lose process-reap synchronization.

Capture both pops and assert paired presence while allowing the valid
both-absent startup-failure path. Keep an unset request valid because
backend cancellation can reap immediately after registration.

Extend graceful and failed-cancel-ack runtime tests to require all
child and reap mappings empty before `to_actor.run()` returns.

Review: PR #481 (goodboy)
https://github.com/goodboy/tractor/pull/481#pullrequestreview-5012942328

Prompt-IO: ai/prompt-io/opencode/20260824T223614Z_88d538e3_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-24 18:50:59 -04:00
Gud Boi 88d538e3a6 Share `Context.cancel()` deadline with frame sends
A parent-side ctx cancel timeout previously bounded only the
remote `_cancel_task` ack. Complete-frame `send_all()` shielding
could hold request publication forever when a peer stopped reading.

Compute one absolute deadline and pass it through `._run_from_ns()`
so transport publication and the ack wait consume the same timeout
budget. Add a mock-clock regression which stalls the private RPC
under a nested shield and proves the transaction returns on time.

Review: PR #481 (goodboy)
https://github.com/goodboy/tractor/pull/481#pullrequestreview-5012942328

Prompt-IO: ai/prompt-io/opencode/20260824T222033Z_ce38cb6f_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
2026-08-24 18:34:19 -04:00
25 changed files with 725 additions and 147 deletions

View File

@ -0,0 +1,37 @@
---
model: openai/gpt-5.6-sol
service: opencode
session: d9d7df2c-7044-463f-8768-ec024718eac9
timestamp: 2026-08-24T22:20:33Z
git_ref: ce38cb6f
scope: code
substantive: true
raw_file: 20260824T222033Z_ce38cb6f_prompt_io.raw.md
---
## Prompt
Continue the PR #481 review after triage. The human explicitly accepted
the proposed merge-blocking `Context.cancel()` deadline update with
"keep" and required the work to remain limited to that review item.
## Response summary
Update `Context.cancel()` so one absolute deadline bounds both shielded
cancel-request publication and acknowledgement waiting. Add a focused
mocked-clock regression for the blocked-publication failure mode and run
the narrow cancellation tests.
## Files changed
- `tractor/_context.py` - forward the cancel transaction's absolute
deadline to frame publication.
- `tests/test_to_actor.py` - prove blocked context-cancel publication is
bounded by the shared deadline.
## Human edits
The human retained ownership of review scope and explicitly selected
"keep" for this item after receiving keep/defer/drop options. The human
required no unrelated cancellation changes and did not directly edit
source lines.

View File

@ -0,0 +1,26 @@
---
model: openai/gpt-5.6-sol
service: opencode
timestamp: 2026-08-24T22:20:33Z
git_ref: ce38cb6f
diff_cmd: git diff HEAD~1..HEAD
---
Implement the approved PR #481 review update for `Context.cancel()`.
Use one absolute deadline for both cancellation-request frame
publication and acknowledgement waiting, without broadening the change
to unrelated cancellation behavior.
> `git diff HEAD~1..HEAD -- tractor/_context.py`
`Context.cancel()` computes one absolute cancellation deadline, uses it
for the outer bounded wait, and forwards it through
`Portal._run_from_ns()` to shielded frame publication.
> `git diff HEAD~1..HEAD -- tests/test_to_actor.py`
A deterministic mocked-clock regression arranges a shielded blocked
publication and proves that `Context.cancel()` forwards the same deadline
which bounds the complete cancel transaction.
Run the focused cancellation deadline regressions after the edit.

View File

@ -0,0 +1,35 @@
---
model: openai/gpt-5.6-sol
service: opencode
session: d9d7df2c-7044-463f-8768-ec024718eac9
timestamp: 2026-08-24T22:36:14Z
git_ref: 88d538e3
scope: code
substantive: true
raw_file: 20260824T223614Z_88d538e3_prompt_io.raw.md
---
## Prompt
Continue PR #481 review remediation after committing the shared
`Context.cancel()` deadline fix. The human accepted the proposed
child-reap bookkeeping invariant, asking only that the first fix receive
its own commit plan and commit before this update began.
## Response summary
Check that `ActorNursery` removes its paired reap-coordination entries
together while preserving valid pre-registration and immediate-cancel
paths. Extend the existing real-runtime reap tests to prove all three
child bookkeeping mappings are empty before `to_actor.run()` returns.
## Files changed
- `tractor/runtime/_supervise.py` - assert paired reap-map cleanup.
- `tests/test_to_actor.py` - verify graceful and hard-reap bookkeeping.
## Human edits
The human explicitly accepted this invariant update but directed the
preceding cancellation fix to be planned and committed as a separate
boundary first. No direct source-line edits were made by the human.

View File

@ -0,0 +1,26 @@
---
model: openai/gpt-5.6-sol
service: opencode
timestamp: 2026-08-24T22:36:14Z
git_ref: 88d538e3
diff_cmd: git diff HEAD~1..HEAD
---
Implement the approved PR #481 child-reap bookkeeping update after the
preceding `Context.cancel()` fix was committed separately.
> `git diff HEAD~1..HEAD -- tractor/runtime/_supervise.py`
`ActorNursery._mark_child_reaped()` captures both reap-coordination
entries and asserts that they are either both present or both absent.
It intentionally does not require the reap-request event to be set,
because backend cancellation can reap immediately after registration.
> `git diff HEAD~1..HEAD -- tests/test_to_actor.py`
Existing real-runtime graceful and hard-reap tests verify that
`ActorNursery._children`, `ActorNursery._child_reap_requests`, and
`ActorNursery._child_reaped` are all empty before the one-shot call
returns.
Run focused bookkeeping and real-runtime reap tests after the edit.

View File

@ -0,0 +1,39 @@
---
model: openai/gpt-5.6-sol
service: opencode
session: d9d7df2c-7044-463f-8768-ec024718eac9
timestamp: 2026-08-24T22:53:56Z
git_ref: 2f86dd1a
scope: code
substantive: true
raw_file: 20260824T225356Z_2f86dd1a_prompt_io.raw.md
---
## Prompt
Continue PR #481 review remediation after committing the paired
`ActorNursery` reap-state invariant. The human selected "keep" for the
reviewer's request to factor a duplicated debugger predicate in
`_try_cancel_then_kill()`.
## Response summary
Factor the child/tree debugger predicate into a local sampler used both
before and after the cancel-RPC checkpoint. Preserve dynamic debugger
lock re-evaluation and its distinction from root-wide debug mode.
## Files changed
- `tractor/runtime/_supervise.py` - factor the duplicated debugger
predicate without changing cancellation behavior.
## Human edits
The human explicitly selected "keep" after receiving keep/defer/drop
options for this isolated review item. During commit-plan review, the
agent found that a single pre-checkpoint snapshot could become stale;
the human selected a local helper which re-evaluates the lock after the
cancel RPC. The human then considered moving the predicate into
`.devx.debug` and accepted keeping it local after confirming that no
existing helper shares its supervisor-owned semantics. No direct
source-line edits were made by the human.

View File

@ -0,0 +1,18 @@
---
model: openai/gpt-5.6-sol
service: opencode
timestamp: 2026-08-24T22:53:56Z
git_ref: 2f86dd1a
diff_cmd: git diff HEAD~1..HEAD
---
Implement the approved PR #481 review refactor in
`_try_cancel_then_kill()` without changing debugger behavior.
> `git diff HEAD~1..HEAD -- tractor/runtime/_supervise.py`
Compute the child/tree debugger predicate once, reuse it in the broader
hard-kill protection predicate, and pass it directly to
`debug.maybe_wait_for_debugger()`.
Run focused debugger/cancellation coverage and lint after the edit.

View File

@ -0,0 +1,36 @@
---
model: openai/gpt-5.6-sol
service: opencode
session: d9d7df2c-7044-463f-8768-ec024718eac9
timestamp: 2026-08-24T23:39:57Z
git_ref: 5327b25e
scope: code
substantive: true
raw_file: 20260824T233957Z_5327b25e_prompt_io.raw.md
---
## Prompt
Continue PR #481 review remediation after committing the debugger-state
sampler. The human selected "keep" for the paired review request to use
`Aid` objects as keys in the newly added reap-coordination maps.
## Response summary
Migrate only `ActorNursery._child_reap_requests` and
`ActorNursery._child_reaped` to `Aid` keys. Preserve the legacy
`ActorNursery._children` `.uid` key and pass full actor identities
through the narrow process-monitor bookkeeping path.
## Files changed
- `tractor/runtime/_supervise.py` - key fresh reap maps by `Aid`.
- `tractor/spawn/_spawn.py` - pass `Aid` into completed-reap cleanup.
- `tests/test_to_actor.py` - exercise `Aid` registration keys.
## Human edits
The human explicitly selected "keep" after reviewing the scope,
performance, and mutability tradeoffs. The human retained the legacy
tuple key for `_children` and accepted `Aid` for only the two fresh
private mappings. No direct source-line edits were made by the human.

View File

@ -0,0 +1,27 @@
---
model: openai/gpt-5.6-sol
service: opencode
timestamp: 2026-08-24T23:39:57Z
git_ref: 5327b25e
diff_cmd: git diff HEAD~1..HEAD
---
Implement the approved PR #481 review update which uses `Aid` keys for
the two fresh `ActorNursery` reap-coordination maps while preserving the
legacy `.uid` key for `ActorNursery._children`.
> `git diff HEAD~1..HEAD -- tractor/runtime/_supervise.py`
Type and access `_child_reap_requests` and `_child_reaped` by `Aid`.
Pass full actor identities through registration, cancellation, and
completed-reap bookkeeping, deriving `.uid` only for `_children`.
> `git diff HEAD~1..HEAD -- tractor/spawn/_spawn.py`
Forward `subactor.aid` when publishing completed process teardown.
> `git diff HEAD~1..HEAD -- tests/test_to_actor.py`
Update deterministic registration tests to exercise `Aid` map keys.
Run focused registration/reaping tests and the full `to_actor` suite.

View File

@ -0,0 +1,38 @@
---
model: openai/gpt-5.6-sol
service: opencode
session: d9d7df2c-7044-463f-8768-ec024718eac9
timestamp: 2026-08-25T01:57:42Z
git_ref: e42ecb55
scope: code
substantive: true
raw_file: 20260825T015742Z_e42ecb55_prompt_io.raw.md
---
## Prompt
Continue PR #481 review remediation after committing the `Aid` reap-map
migration. The human selected "keep" for comments explaining why both
spawn backends provisionally register children with `portal=None`.
## Response summary
Document that a child has no `Portal` until its IPC handshake yields a
`Channel`, and make `portal=None` explicit at both registration calls.
Identify the later replacement of each provisional entry with
`Portal(chan)`. Update the MP registration test double to accept and
assert the explicit provisional portal state. Name every registration
argument consistently in both backends.
## Files changed
- `tractor/spawn/_mp.py` - clarify provisional MP registration.
- `tractor/spawn/_trio.py` - clarify provisional Trio registration.
- `tests/test_to_actor.py` - model explicit provisional registration.
## Human edits
The human explicitly selected "keep" after receiving keep/defer/drop
options for this paired clarification. During local review, the human
then requested that `subactor` and `proc` also be passed by name in both
backend calls. No direct source-line edits were made by the human.

View File

@ -0,0 +1,21 @@
---
model: openai/gpt-5.6-sol
service: opencode
timestamp: 2026-08-25T01:57:42Z
git_ref: e42ecb55
diff_cmd: git diff HEAD~1..HEAD
---
Implement the approved PR #481 clarification for provisional child
registration in both process-spawn backends.
> `git diff HEAD~1..HEAD -- tractor/spawn/_mp.py`
> `git diff HEAD~1..HEAD -- tractor/spawn/_trio.py`
Explain that `portal=None` is provisional because no `Portal` can exist
until the child completes its IPC handshake and returns a `Channel`.
Use an explicit keyword argument and identify the later replacement with
`Portal(chan)`.
Run lint and the full `to_actor` runtime suite.

View File

@ -0,0 +1,32 @@
---
model: openai/gpt-5.6-sol
service: opencode
session: d9d7df2c-7044-463f-8768-ec024718eac9
timestamp: 2026-08-25T02:13:19Z
git_ref: ce430fca
scope: code
substantive: true
raw_file: 20260825T021319Z_ce430fca_prompt_io.raw.md
---
## Prompt
Continue PR #481 review remediation after committing provisional child
registration clarifications. The human selected "keep" for inlining the
guarded `functools.Placeholder` lookup with a walrus assignment.
## Response summary
Remove the standalone placeholder assignment and bind the optional
Python 3.14 sentinel directly in the existing conditional while
preserving compatibility behavior.
## Files changed
- `tractor/to_actor/_api.py` - inline placeholder feature detection.
## Human edits
The human explicitly selected "keep" after receiving keep/defer/drop
options for this isolated cleanup. No direct source-line edits were made
by the human.

View File

@ -0,0 +1,19 @@
---
model: openai/gpt-5.6-sol
service: opencode
timestamp: 2026-08-25T02:13:19Z
git_ref: ce430fca
diff_cmd: git diff HEAD~1..HEAD
---
Implement the approved PR #481 review cleanup for Python 3.14 partial
placeholder detection.
> `git diff HEAD~1..HEAD -- tractor/to_actor/_api.py`
Inline the guarded `functools.Placeholder` lookup into the existing
condition with a walrus assignment, preserving fallback behavior when
the attribute is unavailable.
Run partial/placeholder normalization tests and the full `to_actor`
suite.

50
tests/_helpers.py 100644
View File

@ -0,0 +1,50 @@
'''
Shared helpers for actor-runtime test suites.
'''
from pathlib import Path
from types import TracebackType
import tractor
class CancellationMarkers:
'''
Mark a test endpoint's lifetime without cleanup checkpoints.
'''
def __init__(
self,
started_path: str,
cancelled_path: str,
) -> None:
self.started_path = started_path
self.cancelled_path = cancelled_path
def __enter__(self) -> None:
Path(self.started_path).touch()
def __exit__(
self,
exc_type: type[BaseException]|None,
exc_value: BaseException|None,
traceback: TracebackType|None,
) -> None:
Path(self.cancelled_path).touch()
def non_registration_contexts(
actor: tractor.Actor,
) -> dict[tuple, str]:
'''
Snapshot application contexts without registrar-service traffic.
'''
return {
key: str(ctx._nsf)
for key, ctx in actor._contexts.items()
if str(ctx._nsf) != (
'tractor.discovery._registry:'
'Registrar.register_actor'
)
}

View File

@ -0,0 +1,43 @@
'''
`NamespacePath` Python-object reference tests.
'''
import pytest
from tractor.msg import ptr as msgptr
from tractor.msg.ptr import NamespacePath
def example_target() -> None:
'''
Provide a module-addressable reference for pointer tests.
'''
def test_retains_target_ref(
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
Reuse a retained target ref when splitting its namespace path.
`NamespacePath.from_ref()` previously discarded `example_target`,
so `to_tuple()` imported and resolved the just-created string again.
Replacing `resolve_name()` with a failure proves the retained ref
supplies the tuple without a redundant lookup.
'''
target = NamespacePath.from_ref(example_target)
def fail_resolve(name: str) -> object:
raise AssertionError(f'unexpected lookup for {name!r}')
monkeypatch.setattr(
msgptr,
'resolve_name',
fail_resolve,
)
assert target.to_tuple() == (
example_target.__module__,
example_target.__name__,
)

View File

@ -40,6 +40,11 @@ from tractor._testing import (
expect_ctxc,
)
from ._helpers import (
CancellationMarkers,
non_registration_contexts,
)
# ``Context`` semantics are as follows,
# ------------------------------------
@ -164,31 +169,18 @@ def test_overrun_error_send_tolerates_transport_close(
_state: bool = False
def _non_registration_contexts(
actor: Actor,
) -> dict[tuple, str]:
return {
key: str(ctx._nsf)
for key, ctx in actor._contexts.items()
if str(ctx._nsf) != (
'tractor.discovery._registry:'
'Registrar.register_actor'
)
}
@tractor.context
async def startup_cancel_target(
ctx: Context,
started_path: str,
cancelled_path: str,
) -> None:
Path(started_path).touch()
try:
with CancellationMarkers(
started_path,
cancelled_path,
):
await ctx.started()
await trio.sleep_forever()
finally:
Path(cancelled_path).touch()
async def return_one() -> int:
@ -317,11 +309,13 @@ async def test_cancel_during_context_startup(
chan: tractor.Channel,
payload: object,
hide_tb: bool = False,
send_deadline: float = float('inf'),
) -> None:
await original_send(
chan,
payload,
hide_tb=hide_tb,
send_deadline=send_deadline,
)
if isinstance(payload, tractor.msg.Start):
if payload.func == 'startup_cancel_target':
@ -344,7 +338,7 @@ async def test_cancel_during_context_startup(
'startup_cancel_worker',
enable_modules=[__name__],
)
contexts_before = _non_registration_contexts(actor)
contexts_before = non_registration_contexts(actor)
monkeypatch.setattr(
tractor.Channel,
'send',
@ -365,12 +359,12 @@ async def test_cancel_during_context_startup(
original_send,
)
assert cancelled_path.exists()
assert _non_registration_contexts(actor) == contexts_before
assert non_registration_contexts(actor) == contexts_before
assert await portal.run_from_ns(
__name__,
'return_one',
) == 1
assert _non_registration_contexts(actor) == contexts_before
assert non_registration_contexts(actor) == contexts_before
await portal.cancel_actor()
@ -396,7 +390,7 @@ async def test_start_serialization_error_cleans_context(
'serialization_error_worker',
enable_modules=[__name__],
)
contexts_before = _non_registration_contexts(actor)
contexts_before = non_registration_contexts(actor)
with pytest.raises(tractor.MsgTypeError):
async with portal.open_context(
simple_setup_teardown,
@ -404,7 +398,7 @@ async def test_start_serialization_error_cleans_context(
):
raise AssertionError('invalid `Start` was accepted')
assert _non_registration_contexts(actor) == contexts_before
assert non_registration_contexts(actor) == contexts_before
async with portal.open_context(
simple_setup_teardown,
data=1,
@ -412,7 +406,7 @@ async def test_start_serialization_error_cleans_context(
assert started == 2
assert await ctx.wait_for_result() == 'yo'
assert _non_registration_contexts(actor) == contexts_before
assert non_registration_contexts(actor) == contexts_before
await portal.cancel_actor()
@ -437,7 +431,7 @@ async def test_start_module_error_cleans_context(
portal: tractor.Portal = await an.start_actor(
'module_error_worker',
)
contexts_before = _non_registration_contexts(actor)
contexts_before = non_registration_contexts(actor)
with pytest.raises(tractor.RemoteActorError) as excinfo:
async with portal.open_context(
simple_setup_teardown,
@ -446,7 +440,7 @@ async def test_start_module_error_cleans_context(
raise AssertionError('unexposed context was started')
assert excinfo.value.boxed_type is tractor.ModuleNotExposed
assert _non_registration_contexts(actor) == contexts_before
assert non_registration_contexts(actor) == contexts_before
await portal.cancel_actor()

View File

@ -20,15 +20,26 @@ from tractor import (
)
from tractor._testing import tractor_test
from tractor._exceptions import ActorTooSlowError
from tractor.msg import ptr as msgptr
from tractor.msg.ptr import NamespacePath
from tractor.spawn import _mp as mp_spawn
from tractor.to_actor import _api as to_actor_api
from ._helpers import (
CancellationMarkers,
non_registration_contexts,
)
async def add_one(
n: int,
) -> int:
'''
Increment within an active actor runtime.
'''
assert tractor.current_actor(
err_on_no_runtime=False,
) is not None
return n + 1
@ -58,11 +69,11 @@ async def mark_task_cancellation(
started_path: str,
cancelled_path: str,
) -> None:
Path(started_path).touch()
try:
with CancellationMarkers(
started_path,
cancelled_path,
):
await trio.sleep_forever()
finally:
Path(cancelled_path).touch()
async def echo_startup_control(
@ -84,46 +95,15 @@ async def collect_call(
return args, kwargs
def _non_registration_contexts(
actor: tractor.Actor,
) -> dict[tuple, str]:
return {
key: str(ctx._nsf)
for key, ctx in actor._contexts.items()
if str(ctx._nsf) != (
'tractor.discovery._registry:'
'Registrar.register_actor'
)
}
def test_namespace_path_retains_target_ref(
monkeypatch: pytest.MonkeyPatch,
):
def test_public_module_alias() -> None:
'''
Reuse the client-side target ref when splitting its namespace path.
Keep the public trampoline alias separate from its private module.
`NamespacePath.from_ref()` previously discarded `add_one`, so
`to_tuple()` imported and resolved the just-created string again.
Replacing `resolve_name()` with a failure proves the retained ref
supplies the tuple without a redundant lookup. The public module
alias assertion also keeps internal `_api.__name__` authoritative.
Callers use `to_actor.MODULE` to configure an existing actor's RPC
allowlist, while `_api.__name__` remains the authoritative module
path and does not re-export the alias internally.
'''
target = NamespacePath.from_ref(add_one)
def fail_resolve(name: str) -> object:
raise AssertionError(f'unexpected lookup for {name!r}')
monkeypatch.setattr(
msgptr,
'resolve_name',
fail_resolve,
)
assert target.to_tuple() == (
add_one.__module__,
add_one.__name__,
)
assert to_actor.MODULE == to_actor_api.__name__
assert not hasattr(to_actor_api, 'MODULE')
@ -152,7 +132,9 @@ def test_one_shot_boots_implicit_runtime(
'''
Outside any actor-runtime `to_actor.run()` boots one
implicitly (just like bare `open_nursery()` usage)
configured via pass-through `runtime_kwargs`.
configured via pass-through `runtime_kwargs`. The remote target
asserts its runtime exists; the caller then verifies the private
runtime is fully torn down before `to_actor.run()` returns.
'''
async def main() -> None:
@ -169,6 +151,9 @@ def test_one_shot_boots_implicit_runtime(
),
)
assert result == 42
assert tractor.current_actor(
err_on_no_runtime=False,
) is None
trio.run(main)
@ -201,10 +186,10 @@ async def test_spawn_from_caller_nursery(
Previously `to_actor.run()` treated an actor-runtime cancel ack
as process reaping, so the call returned while the child monitor
and its `ActorNursery._children` record remained alive until the
entire nursery exited. The assertion inside the still-open
nursery proves child-process joining and record removal now
complete before the one-shot call returns.
and its `ActorNursery` child/reap bookkeeping remained alive until
the entire nursery exited. The assertions inside the still-open
nursery prove child-process joining and removal from all three
mappings complete before the one-shot call returns.
'''
async with tractor.open_nursery() as an:
@ -214,6 +199,8 @@ async def test_spawn_from_caller_nursery(
an=an,
) == 11
assert not an._children
assert not an._child_reap_requests
assert not an._child_reaped
@tractor_test
@ -225,13 +212,18 @@ async def test_cancel_ack_failure_hard_reaps_child(
'''
Escalate a failed cancel acknowledgement and reap the child.
`Portal.cancel_actor()` can return `False` when its transport is
already closed without confirming runtime cancellation. The old
one-shot path ignored that result, released the nursery-wide join
gate and then waited forever for a still-running process. This
test forces that exact result without cancelling the actor, caps
the call to detect the former hang and verifies the child monitor
removes its `ActorNursery._children` record before returning.
`Portal.cancel_actor()` catches `TransportClosed` and returns
`False` when it can not confirm runtime cancellation. The mock
represents that public post-transport-failure result, so no
underlying exception remains to bubble through `to_actor.run()`.
The old one-shot path ignored `False`, released the nursery-wide
join gate and then waited forever for a still-running process.
`_cancel_and_reap_child()` must instead hard-kill and join the child.
The five-second scope is only a generous CI hang ceiling: normal
teardown returns much sooner, while expiry fails the test. Final
assertions prove the child monitor removes every `ActorNursery`
child/reap entry before returning.
'''
async def cancel_without_ack(
@ -242,6 +234,8 @@ async def test_cancel_ack_failure_hard_reaps_child(
assert raise_on_timeout
return False
# Model `Portal.cancel_actor()` after it catches `TransportClosed`;
# there is no transport exception left for `run()` to re-raise.
monkeypatch.setattr(
tractor.Portal,
'cancel_actor',
@ -249,6 +243,8 @@ async def test_cancel_ack_failure_hard_reaps_child(
)
async with tractor.open_nursery() as an:
# Expiry means hard reaping hung; five seconds is not the
# expected duration of the successful path.
with trio.fail_after(5):
assert await to_actor.run(
add_one,
@ -256,11 +252,13 @@ async def test_cancel_ack_failure_hard_reaps_child(
an=an,
) == 21
assert not an._children
assert not an._child_reap_requests
assert not an._child_reaped
def test_cancel_actor_timeout_closes_blocked_send():
def test_cancel_actor_shares_request_and_ack_deadline():
'''
Thread one absolute cancel deadline into shielded frame publication.
Share one cancel deadline across request publication and ack waiting.
The cancel RPC's outer timeout cannot penetrate a complete-frame
shield. The fake private RPC applies the forwarded send deadline to
@ -268,6 +266,11 @@ def test_cancel_actor_timeout_closes_blocked_send():
bounded `ActorTooSlowError` and the recorded absolute deadline prove
publication and acknowledgement share one timeout budget.
A real subactor can not deterministically stall the caller's
outbound frame at this exact boundary. Actual partial-frame stream
closure is covered by
`test_transport_send_deadline_closes_partial_frame()`.
'''
class ConnectedChannel:
def __init__(self) -> None:
@ -282,6 +285,8 @@ def test_cancel_actor_timeout_closes_blocked_send():
async def main() -> None:
channel = ConnectedChannel()
# `Portal.__init__()` requires live actor-runtime state; this
# unit seam needs only its channel and private RPC method.
portal = object.__new__(tractor.Portal)
portal._chan = channel
deadlines: list[float] = []
@ -319,6 +324,75 @@ def test_cancel_actor_timeout_closes_blocked_send():
)
def test_context_cancel_shares_request_and_ack_deadline():
'''
Bound context-cancel publication and acknowledgement together.
`Context.cancel()` shields its transaction from outer cancellation,
while `MsgpackTransport.send()` separately shields complete frame
publication. Previously the context's timeout was not forwarded to
that inner shield, so a peer which stopped reading could leave the
cancel task blocked forever instead of respecting `timeout`.
The fake private RPC records the forwarded absolute deadline and
blocks under a send-like shield until that deadline. The mock clock
advances directly to it; completion and the exact recorded value
prove that publication shares the context's one-second budget.
The transport suite separately proves that deadline expiry closes
a stream after partial frame publication.
'''
async def main() -> None:
deadlines: list[float] = []
async def blocked_cancel(
namespace: str,
function: str,
kwargs: dict[str, object],
cancel_on_startup: bool,
send_deadline: float,
) -> None:
assert (namespace, function) == ('self', '_cancel_task')
assert kwargs == {'cid': 'blocked-context'}
assert not cancel_on_startup
deadlines.append(send_deadline)
with trio.CancelScope(
deadline=send_deadline,
shield=True,
):
await trio.sleep_forever()
await trio.lowlevel.checkpoint_if_cancelled()
peer_aid = tractor.msg.Aid(
name='blocked_peer',
uuid='test',
)
def connected() -> bool:
return True
# `Context.__init__()` requires live actor/channel registration;
# this unit seam supplies only state consumed by `.cancel()`.
ctx = object.__new__(tractor.Context)
ctx.chan = SimpleNamespace(
aid=peer_aid,
connected=connected,
transport=SimpleNamespace(maddr='test://blocked'),
)
ctx.cid = 'blocked-context'
ctx._portal = SimpleNamespace(_run_from_ns=blocked_cancel)
ctx._nsf = NamespacePath.from_ref(add_one)
await ctx.cancel(timeout=1)
assert deadlines == [1.]
trio.run(
main,
clock=MockClock(autojump_threshold=0),
)
def _mock_actor_nursery() -> tractor.ActorNursery:
an = object.__new__(tractor.ActorNursery)
an._children = {}
@ -342,6 +416,8 @@ def test_late_child_registration_observes_cancel():
'''
an = _mock_actor_nursery()
# `ActorNursery.cancel()` has set its sticky flag after taking the
# old `_children` snapshot but before backend registration resumes.
an._cancel_called = True
aid = tractor.msg.Aid(
name='late_child',
@ -366,8 +442,8 @@ def test_late_child_registration_observes_cancel():
proc,
None,
)
assert an._child_reap_requests[aid.uid] is reap_request
assert an._child_reaped[aid.uid] is reaped
assert an._child_reap_requests[aid] is reap_request
assert an._child_reaped[aid] is reaped
def test_mp_late_registration_never_starts_process(
@ -391,6 +467,24 @@ def test_mp_late_registration_never_starts_process(
process = FakeProcess()
def register_child(
subactor: object,
proc: object,
portal: object|None,
) -> tuple[trio.Event, trio.Event, bool]:
'''
Simulate provisional MP registration before child startup.
'''
assert subactor
assert proc is process
assert portal is None
return (
trio.Event(),
trio.Event(),
True,
)
class FakeContext:
def get_start_method(self) -> str:
return 'spawn'
@ -400,11 +494,7 @@ def test_mp_late_registration_never_starts_process(
return process
nursery = SimpleNamespace(
_register_child=lambda *args: (
trio.Event(),
trio.Event(),
True,
),
_register_child=register_child,
)
subactor = SimpleNamespace(
aid=tractor.msg.Aid(
@ -455,10 +545,14 @@ def test_late_child_reap_registration_is_released():
an._child_reap_requests = {}
an._child_reaped = {}
# Nursery teardown publishes its reap request while the child
# monitor is checkpointed before per-child event registration.
an._join_procs.set()
reap_request, _ = an._register_child_reap(
('late_child', 'uid'),
aid = tractor.msg.Aid(
name='late_child',
uuid='uid',
)
reap_request, _ = an._register_child_reap(aid)
assert reap_request.is_set()
@ -507,7 +601,7 @@ async def test_reuse_existing_actor_via_portal(
to_actor.MODULE,
],
)
contexts_before = _non_registration_contexts(actor)
contexts_before = non_registration_contexts(actor)
for i in range(3):
assert await to_actor.run(
add_one,
@ -520,7 +614,7 @@ async def test_reuse_existing_actor_via_portal(
'echo_startup_control',
_cancel_on_startup='target_value',
) == 'target_value'
assert _non_registration_contexts(actor) == contexts_before
assert non_registration_contexts(actor) == contexts_before
# still alive: caller owns the actor's lifetime.
await portal.cancel_actor()
@ -806,7 +900,7 @@ async def test_portal_task_cancelled_with_local_caller(
to_actor.MODULE,
],
)
contexts_before = _non_registration_contexts(actor)
contexts_before = non_registration_contexts(actor)
async with trio.open_nursery() as tn:
tn.start_soon(
@ -824,13 +918,13 @@ async def test_portal_task_cancelled_with_local_caller(
tn.cancel_scope.cancel()
assert cancelled_path.exists()
assert _non_registration_contexts(actor) == contexts_before
assert non_registration_contexts(actor) == contexts_before
assert await to_actor.run(
add_one,
1,
portal=portal,
) == 2
assert _non_registration_contexts(actor) == contexts_before
assert non_registration_contexts(actor) == contexts_before
await portal.cancel_actor()
@ -856,7 +950,7 @@ async def test_context_trampoline_preserves_module_allowlist(
'restricted_context_worker',
enable_modules=[to_actor.MODULE],
)
contexts_before = _non_registration_contexts(actor)
contexts_before = non_registration_contexts(actor)
with pytest.raises(RemoteActorError) as excinfo:
await to_actor.run(
add_one,
@ -865,7 +959,7 @@ async def test_context_trampoline_preserves_module_allowlist(
)
assert excinfo.value.boxed_type is tractor.ModuleNotExposed
assert _non_registration_contexts(actor) == contexts_before
assert non_registration_contexts(actor) == contexts_before
await portal.cancel_actor()
@ -889,7 +983,7 @@ async def test_portal_requires_context_trampoline(
'no_context_trampoline_worker',
enable_modules=[__name__],
)
contexts_before = _non_registration_contexts(actor)
contexts_before = non_registration_contexts(actor)
with pytest.raises(RemoteActorError) as excinfo:
await to_actor.run(
add_one,
@ -900,5 +994,5 @@ async def test_portal_requires_context_trampoline(
err = excinfo.value
assert err.boxed_type is tractor.ModuleNotExposed
assert to_actor.MODULE in str(err)
assert _non_registration_contexts(actor) == contexts_before
assert non_registration_contexts(actor) == contexts_before
await portal.cancel_actor()

View File

@ -1097,7 +1097,12 @@ class Context:
)
cid: str = self.cid
with trio.move_on_after(timeout) as cs:
cancel_deadline: float = (
trio.current_time()
+
timeout
)
with trio.move_on_at(cancel_deadline) as cs:
cs.shield = True
log.cancel(
header
@ -1113,6 +1118,7 @@ class Context:
'_cancel_task',
kwargs={'cid': cid},
cancel_on_startup=False,
send_deadline=cancel_deadline,
)
if cs.cancelled_caught:

View File

@ -518,9 +518,10 @@ class MsgpackTransport(MsgTransport):
# the frame is complete, the explicit checkpoint
# immediately delivers any pending cancellation.
#
# Ordinary sends may delay cancellation while a peer is
# not reading. Actor-wide cancel requests pass their own
# deadline so this operation can close a stalled stream.
# Ordinary sends may delay cancellation while the remote
# peer actor is not reading. Bounded actor/context cancel
# requests pass an absolute deadline so this operation
# can close a stalled stream.
with trio.CancelScope(
deadline=send_deadline,
shield=True,

View File

@ -123,6 +123,15 @@ class NamespacePath(str):
ref: type|object,
) -> NamespacePath:
'''
Build a path while retaining its process-local object reference.
The originating process already holds `ref`; caching it prevents
`to_tuple()` from immediately importing and resolving the same
object again. Serialized paths carry only the `str` value and
therefore resolve lazily through `load_ref()` after decoding.
'''
fqnp: tuple[str, str] = cls._mk_fqnp(ref)
nsp = cls(':'.join(fqnp))

View File

@ -882,12 +882,15 @@ class Actor:
except BaseException as startup_err:
with trio.CancelScope(shield=True):
# `MsgpackTransport.send()` closes its stream when
# cancellation interrupts the length-prefixed write
# because an unknown prefix may already be sent. A
# connected channel means cancellation happened before
# that write or after it completed, so `_cancel_task`
# is protocol-safe (and a no-op if `Start` was unsent).
# `MsgpackTransport.send()` shields length-prefixed frame
# publication until complete, then checkpoints pending
# cancellation before returning. Thus `start_published`
# can remain false after a complete `Start` reached the
# wire. If the send's own deadline catches a partial
# frame, it closes the stream. A connected channel means
# cancellation happened before the write or after frame
# completion, so `_cancel_task` is protocol-safe (and
# a no-op when `Start` was unsent).
if (
cancel_on_startup
and

View File

@ -46,6 +46,7 @@ from ..log import (
get_logger,
get_loglevel,
)
from ..msg import Aid
from ._runtime import Actor
from ._portal import Portal
from ..trionics import (
@ -130,12 +131,21 @@ async def _try_cancel_then_kill(
# mutated by per-child `debug_mode=True`). ORing covers
# every flavor without false-positively skipping
# legitimate hard-kill paths in non-debug trees.
debug_protected: bool = (
def child_in_debug() -> bool:
'''
Sample child/tree debugger protection state.
'''
return (
debug_mode_active
or
debug.Lock.ctx_in_debug is not None
)
debug_protected: bool = (
child_in_debug()
or
_state._runtime_vars.get('_debug_mode', False)
or
debug_mode_active
)
try:
@ -145,11 +155,8 @@ async def _try_cancel_then_kill(
if not cancelled:
if debug_protected:
await debug.maybe_wait_for_debugger(
child_in_debug=(
debug_mode_active
or
debug.Lock.ctx_in_debug is not None
),
# Re-sample after the cancel-RPC checkpoint.
child_in_debug=child_in_debug(),
header_msg=(
'Delaying subproc hard-reap while '
'debugger locked..\n'
@ -240,11 +247,11 @@ class ActorNursery:
self._join_procs = trio.Event()
self._child_reap_requests: dict[
tuple[str, str],
Aid,
trio.Event,
] = {}
self._child_reaped: dict[
tuple[str, str],
Aid,
trio.Event,
] = {}
self._at_least_one_child_in_debug: bool = False
@ -313,7 +320,7 @@ class ActorNursery:
def _register_child_reap(
self,
uid: tuple[str, str],
aid: Aid,
) -> tuple[trio.Event, trio.Event]:
'''
Register a child monitor's process-reap events.
@ -321,8 +328,8 @@ class ActorNursery:
'''
reap_request = trio.Event()
reaped = trio.Event()
self._child_reap_requests[uid] = reap_request
self._child_reaped[uid] = reaped
self._child_reap_requests[aid] = reap_request
self._child_reaped[aid] = reaped
if self._join_procs.is_set():
reap_request.set()
return reap_request, reaped
@ -337,13 +344,14 @@ class ActorNursery:
Atomically publish one child and its reap coordination.
'''
uid: tuple[str, str] = subactor.aid.uid
aid: Aid = subactor.aid
uid: tuple[str, str] = aid.uid
self._children[uid] = (
subactor,
proc,
portal,
)
reap_request, reaped = self._register_child_reap(uid)
reap_request, reaped = self._register_child_reap(aid)
return (
reap_request,
reaped,
@ -363,18 +371,26 @@ class ActorNursery:
def _mark_child_reaped(
self,
uid: tuple[str, str],
aid: Aid,
) -> None:
'''
Publish completed child-process teardown to its waiter.
'''
uid: tuple[str, str] = aid.uid
self._children.pop(uid, None)
self._child_reap_requests.pop(uid, None)
reap_request: trio.Event|None = (
self._child_reap_requests.pop(aid, None)
)
reaped: trio.Event|None = self._child_reaped.pop(
uid,
aid,
None,
)
assert (
(reap_request is None)
==
(reaped is None)
)
if reaped is not None:
reaped.set()
@ -386,14 +402,15 @@ class ActorNursery:
Cancel, join and unregister one nursery-owned child.
'''
uid: tuple[str, str] = portal.channel.aid.uid
aid: Aid = portal.channel.aid
uid: tuple[str, str] = aid.uid
child_entry = self._children.get(uid)
if child_entry is None:
return
subactor, proc, _ = child_entry
reap_request: trio.Event = self._child_reap_requests[uid]
reaped: trio.Event = self._child_reaped[uid]
reap_request: trio.Event = self._child_reap_requests[aid]
reaped: trio.Event = self._child_reaped[aid]
with trio.CancelScope(shield=True):
try:

View File

@ -141,14 +141,16 @@ async def mp_proc(
# `multiprocessing` only (since no async interface): publish the
# process and its reap coordination before start so cancellation
# can own every subsequently started child.
# No `Portal` exists until the IPC handshake returns `chan`.
# Replace this provisional entry with `Portal(chan)` below.
(
reap_request,
_,
cancel_during_registration,
) = actor_nursery._register_child(
subactor,
proc,
None,
subactor=subactor,
proc=proc,
portal=None,
)
if cancel_during_registration:
raise RuntimeError(

View File

@ -460,7 +460,7 @@ async def new_proc(
proc_kwargs=proc_kwargs
)
finally:
actor_nursery._mark_child_reaped(subactor.aid.uid)
actor_nursery._mark_child_reaped(subactor.aid)
# NOTE: bottom-of-module to avoid a circular import since the

View File

@ -130,14 +130,17 @@ async def trio_proc(
f' |_{proc}\n'
)
# No `Portal` exists until the IPC handshake returns
# `chan`. Replace this provisional entry with
# `Portal(chan)` below.
(
reap_request,
_,
cancel_during_registration,
) = actor_nursery._register_child(
subactor,
proc,
None,
subactor=subactor,
proc=proc,
portal=None,
)
if cancel_during_registration:
cancelled_during_spawn = True

View File

@ -116,13 +116,14 @@ def _normalize_call(
# `functools.Placeholder` was added in Python 3.14. Drop
# this `getattr()` guard once 3.14 is the minimum version.
placeholder = getattr(
if (
(
placeholder := getattr(
functools,
'Placeholder',
None,
)
if (
placeholder is not None
) is not None
and
any(
arg is placeholder
@ -239,8 +240,8 @@ async def run(
fn: Callable[[Unpack[ArgsT]], Awaitable[RetT]],
*args: Unpack[ArgsT],
# actor "placement": reuse an already-running peer
# via its `portal`, spawn a fresh subactor from
# actor lifetime management: reuse an already-running peer
# via its `portal: Portal`, spawn a fresh subactor from
# a caller-managed `an: ActorNursery`, or, when
# neither is provided, open a private actor-nursery
# (implicitly booting the actor-runtime as needed)
@ -274,9 +275,10 @@ async def run(
As with Trio's API, target arguments are positional. Use
`functools.partial()` to bind target keyword arguments; all
keyword arguments accepted here configure actor placement or
spawning. A caller-supplied `portal` must address an actor started
with both `tractor.to_actor.MODULE` and the target function's
keyword arguments accepted here configure actor lifetime
management, including actor reuse and spawning. A caller-supplied
`portal` must address an actor started with both
`tractor.to_actor.MODULE` and the target function's
module in its `enable_modules` list. Calls that spawn their own
actor add the trampoline module automatically.