From 2f86dd1a33d2878024fbe428f429d777c9e34b2e Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 24 Aug 2026 18:50:59 -0400 Subject: [PATCH] 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`)) --- .../20260824T223614Z_88d538e3_prompt_io.md | 35 +++++++++++++++++++ ...20260824T223614Z_88d538e3_prompt_io.raw.md | 26 ++++++++++++++ tests/test_to_actor.py | 14 +++++--- tractor/runtime/_supervise.py | 9 ++++- 4 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 ai/prompt-io/opencode/20260824T223614Z_88d538e3_prompt_io.md create mode 100644 ai/prompt-io/opencode/20260824T223614Z_88d538e3_prompt_io.raw.md diff --git a/ai/prompt-io/opencode/20260824T223614Z_88d538e3_prompt_io.md b/ai/prompt-io/opencode/20260824T223614Z_88d538e3_prompt_io.md new file mode 100644 index 00000000..5e85c5c4 --- /dev/null +++ b/ai/prompt-io/opencode/20260824T223614Z_88d538e3_prompt_io.md @@ -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. diff --git a/ai/prompt-io/opencode/20260824T223614Z_88d538e3_prompt_io.raw.md b/ai/prompt-io/opencode/20260824T223614Z_88d538e3_prompt_io.raw.md new file mode 100644 index 00000000..b584e6b5 --- /dev/null +++ b/ai/prompt-io/opencode/20260824T223614Z_88d538e3_prompt_io.raw.md @@ -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. diff --git a/tests/test_to_actor.py b/tests/test_to_actor.py index 1b98819a..9fddb224 100644 --- a/tests/test_to_actor.py +++ b/tests/test_to_actor.py @@ -201,10 +201,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 +214,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 @@ -231,7 +233,7 @@ async def test_cancel_ack_failure_hard_reaps_child( 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. + removes every `ActorNursery` child/reap entry before returning. ''' async def cancel_without_ack( @@ -256,6 +258,8 @@ 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(): diff --git a/tractor/runtime/_supervise.py b/tractor/runtime/_supervise.py index bc8df672..080559e2 100644 --- a/tractor/runtime/_supervise.py +++ b/tractor/runtime/_supervise.py @@ -370,11 +370,18 @@ class ActorNursery: ''' 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( uid, None, ) + assert ( + (reap_request is None) + == + (reaped is None) + ) if reaped is not None: reaped.set()