Compare commits

..

No commits in common. "bf46f5cc5e2fc2463c1cd61ba12c9dadf6371b66" and "ce38cb6f0ea709f9c6644c220d7168f4bc6723d8" have entirely different histories.

25 changed files with 148 additions and 726 deletions

View File

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

View File

@ -20,26 +20,15 @@ from tractor import (
) )
from tractor._testing import tractor_test from tractor._testing import tractor_test
from tractor._exceptions import ActorTooSlowError from tractor._exceptions import ActorTooSlowError
from tractor.msg import ptr as msgptr
from tractor.msg.ptr import NamespacePath from tractor.msg.ptr import NamespacePath
from tractor.spawn import _mp as mp_spawn from tractor.spawn import _mp as mp_spawn
from tractor.to_actor import _api as to_actor_api from tractor.to_actor import _api as to_actor_api
from ._helpers import (
CancellationMarkers,
non_registration_contexts,
)
async def add_one( async def add_one(
n: int, n: int,
) -> int: ) -> int:
'''
Increment within an active actor runtime.
'''
assert tractor.current_actor(
err_on_no_runtime=False,
) is not None
return n + 1 return n + 1
@ -69,11 +58,11 @@ async def mark_task_cancellation(
started_path: str, started_path: str,
cancelled_path: str, cancelled_path: str,
) -> None: ) -> None:
with CancellationMarkers( Path(started_path).touch()
started_path, try:
cancelled_path,
):
await trio.sleep_forever() await trio.sleep_forever()
finally:
Path(cancelled_path).touch()
async def echo_startup_control( async def echo_startup_control(
@ -95,15 +84,46 @@ async def collect_call(
return args, kwargs return args, kwargs
def test_public_module_alias() -> None: def _non_registration_contexts(
''' actor: tractor.Actor,
Keep the public trampoline alias separate from its private module. ) -> 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'
)
}
Callers use `to_actor.MODULE` to configure an existing actor's RPC
allowlist, while `_api.__name__` remains the authoritative module def test_namespace_path_retains_target_ref(
path and does not re-export the alias internally. monkeypatch: pytest.MonkeyPatch,
):
'''
Reuse the client-side target ref when splitting its namespace path.
`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.
''' '''
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 to_actor.MODULE == to_actor_api.__name__
assert not hasattr(to_actor_api, 'MODULE') assert not hasattr(to_actor_api, 'MODULE')
@ -132,9 +152,7 @@ def test_one_shot_boots_implicit_runtime(
''' '''
Outside any actor-runtime `to_actor.run()` boots one Outside any actor-runtime `to_actor.run()` boots one
implicitly (just like bare `open_nursery()` usage) implicitly (just like bare `open_nursery()` usage)
configured via pass-through `runtime_kwargs`. The remote target configured via pass-through `runtime_kwargs`.
asserts its runtime exists; the caller then verifies the private
runtime is fully torn down before `to_actor.run()` returns.
''' '''
async def main() -> None: async def main() -> None:
@ -151,9 +169,6 @@ def test_one_shot_boots_implicit_runtime(
), ),
) )
assert result == 42 assert result == 42
assert tractor.current_actor(
err_on_no_runtime=False,
) is None
trio.run(main) trio.run(main)
@ -186,10 +201,10 @@ async def test_spawn_from_caller_nursery(
Previously `to_actor.run()` treated an actor-runtime cancel ack Previously `to_actor.run()` treated an actor-runtime cancel ack
as process reaping, so the call returned while the child monitor as process reaping, so the call returned while the child monitor
and its `ActorNursery` child/reap bookkeeping remained alive until and its `ActorNursery._children` record remained alive until the
the entire nursery exited. The assertions inside the still-open entire nursery exited. The assertion inside the still-open
nursery prove child-process joining and removal from all three nursery proves child-process joining and record removal now
mappings complete before the one-shot call returns. complete before the one-shot call returns.
''' '''
async with tractor.open_nursery() as an: async with tractor.open_nursery() as an:
@ -199,8 +214,6 @@ async def test_spawn_from_caller_nursery(
an=an, an=an,
) == 11 ) == 11
assert not an._children assert not an._children
assert not an._child_reap_requests
assert not an._child_reaped
@tractor_test @tractor_test
@ -212,18 +225,13 @@ async def test_cancel_ack_failure_hard_reaps_child(
''' '''
Escalate a failed cancel acknowledgement and reap the child. Escalate a failed cancel acknowledgement and reap the child.
`Portal.cancel_actor()` catches `TransportClosed` and returns `Portal.cancel_actor()` can return `False` when its transport is
`False` when it can not confirm runtime cancellation. The mock already closed without confirming runtime cancellation. The old
represents that public post-transport-failure result, so no one-shot path ignored that result, released the nursery-wide join
underlying exception remains to bubble through `to_actor.run()`. gate and then waited forever for a still-running process. This
test forces that exact result without cancelling the actor, caps
The old one-shot path ignored `False`, released the nursery-wide the call to detect the former hang and verifies the child monitor
join gate and then waited forever for a still-running process. removes its `ActorNursery._children` record before returning.
`_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( async def cancel_without_ack(
@ -234,8 +242,6 @@ async def test_cancel_ack_failure_hard_reaps_child(
assert raise_on_timeout assert raise_on_timeout
return False return False
# Model `Portal.cancel_actor()` after it catches `TransportClosed`;
# there is no transport exception left for `run()` to re-raise.
monkeypatch.setattr( monkeypatch.setattr(
tractor.Portal, tractor.Portal,
'cancel_actor', 'cancel_actor',
@ -243,8 +249,6 @@ async def test_cancel_ack_failure_hard_reaps_child(
) )
async with tractor.open_nursery() as an: 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): with trio.fail_after(5):
assert await to_actor.run( assert await to_actor.run(
add_one, add_one,
@ -252,13 +256,11 @@ async def test_cancel_ack_failure_hard_reaps_child(
an=an, an=an,
) == 21 ) == 21
assert not an._children assert not an._children
assert not an._child_reap_requests
assert not an._child_reaped
def test_cancel_actor_shares_request_and_ack_deadline(): def test_cancel_actor_timeout_closes_blocked_send():
''' '''
Share one cancel deadline across request publication and ack waiting. Thread one absolute cancel deadline into shielded frame publication.
The cancel RPC's outer timeout cannot penetrate a complete-frame The cancel RPC's outer timeout cannot penetrate a complete-frame
shield. The fake private RPC applies the forwarded send deadline to shield. The fake private RPC applies the forwarded send deadline to
@ -266,11 +268,6 @@ def test_cancel_actor_shares_request_and_ack_deadline():
bounded `ActorTooSlowError` and the recorded absolute deadline prove bounded `ActorTooSlowError` and the recorded absolute deadline prove
publication and acknowledgement share one timeout budget. 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: class ConnectedChannel:
def __init__(self) -> None: def __init__(self) -> None:
@ -285,8 +282,6 @@ def test_cancel_actor_shares_request_and_ack_deadline():
async def main() -> None: async def main() -> None:
channel = ConnectedChannel() 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 = object.__new__(tractor.Portal)
portal._chan = channel portal._chan = channel
deadlines: list[float] = [] deadlines: list[float] = []
@ -324,75 +319,6 @@ def test_cancel_actor_shares_request_and_ack_deadline():
) )
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: def _mock_actor_nursery() -> tractor.ActorNursery:
an = object.__new__(tractor.ActorNursery) an = object.__new__(tractor.ActorNursery)
an._children = {} an._children = {}
@ -416,8 +342,6 @@ def test_late_child_registration_observes_cancel():
''' '''
an = _mock_actor_nursery() 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 an._cancel_called = True
aid = tractor.msg.Aid( aid = tractor.msg.Aid(
name='late_child', name='late_child',
@ -442,8 +366,8 @@ def test_late_child_registration_observes_cancel():
proc, proc,
None, None,
) )
assert an._child_reap_requests[aid] is reap_request assert an._child_reap_requests[aid.uid] is reap_request
assert an._child_reaped[aid] is reaped assert an._child_reaped[aid.uid] is reaped
def test_mp_late_registration_never_starts_process( def test_mp_late_registration_never_starts_process(
@ -467,24 +391,6 @@ def test_mp_late_registration_never_starts_process(
process = FakeProcess() 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: class FakeContext:
def get_start_method(self) -> str: def get_start_method(self) -> str:
return 'spawn' return 'spawn'
@ -494,7 +400,11 @@ def test_mp_late_registration_never_starts_process(
return process return process
nursery = SimpleNamespace( nursery = SimpleNamespace(
_register_child=register_child, _register_child=lambda *args: (
trio.Event(),
trio.Event(),
True,
),
) )
subactor = SimpleNamespace( subactor = SimpleNamespace(
aid=tractor.msg.Aid( aid=tractor.msg.Aid(
@ -545,14 +455,10 @@ def test_late_child_reap_registration_is_released():
an._child_reap_requests = {} an._child_reap_requests = {}
an._child_reaped = {} an._child_reaped = {}
# Nursery teardown publishes its reap request while the child
# monitor is checkpointed before per-child event registration.
an._join_procs.set() an._join_procs.set()
aid = tractor.msg.Aid( reap_request, _ = an._register_child_reap(
name='late_child', ('late_child', 'uid'),
uuid='uid',
) )
reap_request, _ = an._register_child_reap(aid)
assert reap_request.is_set() assert reap_request.is_set()
@ -601,7 +507,7 @@ async def test_reuse_existing_actor_via_portal(
to_actor.MODULE, to_actor.MODULE,
], ],
) )
contexts_before = non_registration_contexts(actor) contexts_before = _non_registration_contexts(actor)
for i in range(3): for i in range(3):
assert await to_actor.run( assert await to_actor.run(
add_one, add_one,
@ -614,7 +520,7 @@ async def test_reuse_existing_actor_via_portal(
'echo_startup_control', 'echo_startup_control',
_cancel_on_startup='target_value', _cancel_on_startup='target_value',
) == '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. # still alive: caller owns the actor's lifetime.
await portal.cancel_actor() await portal.cancel_actor()
@ -900,7 +806,7 @@ async def test_portal_task_cancelled_with_local_caller(
to_actor.MODULE, to_actor.MODULE,
], ],
) )
contexts_before = non_registration_contexts(actor) contexts_before = _non_registration_contexts(actor)
async with trio.open_nursery() as tn: async with trio.open_nursery() as tn:
tn.start_soon( tn.start_soon(
@ -918,13 +824,13 @@ async def test_portal_task_cancelled_with_local_caller(
tn.cancel_scope.cancel() tn.cancel_scope.cancel()
assert cancelled_path.exists() assert cancelled_path.exists()
assert non_registration_contexts(actor) == contexts_before assert _non_registration_contexts(actor) == contexts_before
assert await to_actor.run( assert await to_actor.run(
add_one, add_one,
1, 1,
portal=portal, portal=portal,
) == 2 ) == 2
assert non_registration_contexts(actor) == contexts_before assert _non_registration_contexts(actor) == contexts_before
await portal.cancel_actor() await portal.cancel_actor()
@ -950,7 +856,7 @@ async def test_context_trampoline_preserves_module_allowlist(
'restricted_context_worker', 'restricted_context_worker',
enable_modules=[to_actor.MODULE], enable_modules=[to_actor.MODULE],
) )
contexts_before = non_registration_contexts(actor) contexts_before = _non_registration_contexts(actor)
with pytest.raises(RemoteActorError) as excinfo: with pytest.raises(RemoteActorError) as excinfo:
await to_actor.run( await to_actor.run(
add_one, add_one,
@ -959,7 +865,7 @@ async def test_context_trampoline_preserves_module_allowlist(
) )
assert excinfo.value.boxed_type is tractor.ModuleNotExposed 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() await portal.cancel_actor()
@ -983,7 +889,7 @@ async def test_portal_requires_context_trampoline(
'no_context_trampoline_worker', 'no_context_trampoline_worker',
enable_modules=[__name__], enable_modules=[__name__],
) )
contexts_before = non_registration_contexts(actor) contexts_before = _non_registration_contexts(actor)
with pytest.raises(RemoteActorError) as excinfo: with pytest.raises(RemoteActorError) as excinfo:
await to_actor.run( await to_actor.run(
add_one, add_one,
@ -994,5 +900,5 @@ async def test_portal_requires_context_trampoline(
err = excinfo.value err = excinfo.value
assert err.boxed_type is tractor.ModuleNotExposed assert err.boxed_type is tractor.ModuleNotExposed
assert to_actor.MODULE in str(err) 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() await portal.cancel_actor()

View File

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

View File

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

View File

@ -123,15 +123,6 @@ class NamespacePath(str):
ref: type|object, ref: type|object,
) -> NamespacePath: ) -> 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) fqnp: tuple[str, str] = cls._mk_fqnp(ref)
nsp = cls(':'.join(fqnp)) nsp = cls(':'.join(fqnp))

View File

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

View File

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

View File

@ -141,16 +141,14 @@ async def mp_proc(
# `multiprocessing` only (since no async interface): publish the # `multiprocessing` only (since no async interface): publish the
# process and its reap coordination before start so cancellation # process and its reap coordination before start so cancellation
# can own every subsequently started child. # 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, reap_request,
_, _,
cancel_during_registration, cancel_during_registration,
) = actor_nursery._register_child( ) = actor_nursery._register_child(
subactor=subactor, subactor,
proc=proc, proc,
portal=None, None,
) )
if cancel_during_registration: if cancel_during_registration:
raise RuntimeError( raise RuntimeError(

View File

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

View File

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

View File

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