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`))
wkt/to_actor_subpkg
Gud Boi 2026-08-24 18:50:59 -04:00
parent 88d538e3a6
commit 2f86dd1a33
4 changed files with 78 additions and 6 deletions

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

@ -201,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._children` record remained alive until the and its `ActorNursery` child/reap bookkeeping remained alive until
entire nursery exited. The assertion inside the still-open the entire nursery exited. The assertions inside the still-open
nursery proves child-process joining and record removal now nursery prove child-process joining and removal from all three
complete before the one-shot call returns. mappings complete before the one-shot call returns.
''' '''
async with tractor.open_nursery() as an: async with tractor.open_nursery() as an:
@ -214,6 +214,8 @@ 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
@ -231,7 +233,7 @@ async def test_cancel_ack_failure_hard_reaps_child(
gate and then waited forever for a still-running process. This gate and then waited forever for a still-running process. This
test forces that exact result without cancelling the actor, caps test forces that exact result without cancelling the actor, caps
the call to detect the former hang and verifies the child monitor the call to detect the former hang and verifies the child monitor
removes its `ActorNursery._children` record before returning. removes every `ActorNursery` child/reap entry before returning.
''' '''
async def cancel_without_ack( async def cancel_without_ack(
@ -256,6 +258,8 @@ 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_timeout_closes_blocked_send(): def test_cancel_actor_timeout_closes_blocked_send():

View File

@ -370,11 +370,18 @@ class ActorNursery:
''' '''
self._children.pop(uid, None) self._children.pop(uid, None)
self._child_reap_requests.pop(uid, None) reap_request: trio.Event|None = (
self._child_reap_requests.pop(uid, None)
)
reaped: trio.Event|None = self._child_reaped.pop( reaped: trio.Event|None = self._child_reaped.pop(
uid, 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()