diff --git a/ai/prompt-io/claude/20260702T161624Z_65bf9df5_prompt_io.md b/ai/prompt-io/claude/20260702T161624Z_65bf9df5_prompt_io.md new file mode 100644 index 00000000..414daa2b --- /dev/null +++ b/ai/prompt-io/claude/20260702T161624Z_65bf9df5_prompt_io.md @@ -0,0 +1,80 @@ +--- +model: claude-fable-5 +service: claude +session: f4da6654-63f9-4641-b567-f200da46d697 +timestamp: 2026-07-02T16:16:24Z +git_ref: 65bf9df5 +scope: tests +substantive: true +raw_file: 20260702T161624Z_65bf9df5_prompt_io.raw.md +--- + +## Prompt + +> attempt to resolve +> https://github.com/goodboy/tractor/issues/474 +> do it with /open-wkt. + +Issue #474: "We need test suite for +`.trionics.start_or_cancel()`" — dig out the use case +from `modden` as a unit test (and provide motivation +for upstream `trio` to possibly provide better +porcelain around this case). The wrapper landed via +PR #464 with no tests. + +## Response summary + +New `tests/trionics/test_taskc.py` (9 tests) covering +`start_or_cancel()`, authored in wkt +`start_or_cancel_tests_474`. + +Behaviour was first probed empirically (trio 0.29): +the lossy `RuntimeError('child exited without calling +task_status.started()')` only fires when the child +exits pre-`.started()` WITHOUT propagating the ambient +`Cancelled` — i.e. when the child (or lib code it +calls) absorbs the cancel in a graceful-teardown +pattern; a well-behaved child surfaces `Cancelled` +straight out of `.start()`. The `modden` +`progman.open_wks()` use case was reconstructed from +`modden/runtime/progman.py` accordingly. + +Tests (each `use_start_or_cancel` parametrization also +pins upstream trio's current lossy behaviour as +wart-documentation): + +- `test_sibling_err_not_masked_by_startup_rte` — the + `modden` case: sibling error OOB-cancels the shared + nursery scope; with the wrapper ONLY the root-cause + `ValueError` escapes; bare `.start()` adds the lossy + RTE alongside. +- `test_pure_oob_cancel_not_morphed_to_rte` — plain + ancestor `cs.cancel()`: wrapper → clean exit; bare + → eg-wrapped RTE. +- `test_genuine_startup_rte_still_raised` — no + cancellation → protocol-bug RTE re-raised same as + bare. +- `test_childs_own_rte_never_demoted_to_cancel` — a + child's own `RuntimeError('never got started!')` / + `RuntimeError(1234)` under ambient cancel is never + demoted to `Cancelled` (exact-msg-match + str-guard + regression cover). +- `test_started_value_and_args_passthru` — happy path: + positional args, `name=`, `.started()` value. + +Verified: 9/9 pass; 0 flakes across 50 hammer runs; +two impl mutations (checkpoint removed; guard relaxed +to substring match) each caught by exactly the +targeted tests; `tests/trionics/` + +`tests/test_trioisms.py` subset green (23 passed, +5 xfailed); ruff clean; 69-col style. + +## Files changed + +- `tests/trionics/test_taskc.py` — new + `start_or_cancel()` unit-test suite (gh #474). + +## Human edits + +Pending review — session paused pre-commit per user +deadline; nothing committed as of this entry. diff --git a/ai/prompt-io/claude/20260702T161624Z_65bf9df5_prompt_io.raw.md b/ai/prompt-io/claude/20260702T161624Z_65bf9df5_prompt_io.raw.md new file mode 100644 index 00000000..83ffb96b --- /dev/null +++ b/ai/prompt-io/claude/20260702T161624Z_65bf9df5_prompt_io.raw.md @@ -0,0 +1,107 @@ +--- +model: claude-fable-5 +service: claude +timestamp: 2026-07-02T16:16:24Z +git_ref: 65bf9df5 +diff_cmd: git diff main..wkt/start_or_cancel_tests_474 +--- + +# Raw output — gh #474 `start_or_cancel()` test suite + +## Generated test code + +> `git diff main..wkt/start_or_cancel_tests_474 -- tests/trionics/test_taskc.py` + +Prose summary of the generated module +(`tests/trionics/test_taskc.py`): + +- module docstring framing the `trio.Nursery.start()` + startup-cancellation wart, the wrapper's repair, and + the intent that `use_start_or_cancel=False` params + double as upstream-trio wart-documentation (break on + a trio upgrade → upstream may have shipped porcelain, + re-audit the wrapper); cites gh #474 / PR #464 and + `modden`'s `progman.open_wks()` as the source use + case. +- shared children: `absorbs_cancel_pre_started()` (the + graceful-teardown cancel-absorber which triggers the + lossy RTE path) + `raise_val_err()` (fast-erroring + sibling). +- `test_sibling_err_not_masked_by_startup_rte` + (parametrized `use_start_or_cancel`): asserts eg + contains exactly one `ValueError` and, wrapper-case, + NO residual RTE (`eg.split(ValueError)` remainder is + `None`); bare-case, the residual RTE carries trio's + exact "child exited without calling" wording. +- `test_pure_oob_cancel_not_morphed_to_rte` + (parametrized): wrapper-case runs clean and asserts + `cs.cancelled_caught`; bare-case asserts the + eg-wrapped RTE. +- `test_genuine_startup_rte_still_raised` + (parametrized): no-cancel protocol bug → RTE with + trio's wording from both call forms. +- `test_childs_own_rte_never_demoted_to_cancel` + (parametrized `rte_arg` in `'never got started!'`, + `1234`): child cancels the ambient scope then raises + its own RTE synchronously (no checkpoint between → + deterministically under-cancellation at catch time); + asserts the RTE survives with `args[0]` intact. +- `test_started_value_and_args_passthru`: `.started()` + value, positional args and the `name=` kwarg (via + `trio.lowlevel.current_task().name`) all forward. + +## Non-code output (verbatim highlights) + +Behaviour probe (trio 0.29, scratchpad scripts) — the +decision basis for the test shapes: + +``` +== B-sibling-err use_soc=False + start raised: RuntimeError('child exited without + calling task_status.started()') + top-level: ExceptionGroup([ValueError('sibling blew + up!'), RuntimeError('child exited without calling + task_status.started()')]) +== B-cs-cancel use_soc=False + top-level: ExceptionGroup([RuntimeError('child + exited without calling task_status.started()')]) +== B-sibling-err use_soc=True + start raised: Cancelled() + top-level: ExceptionGroup([ValueError('sibling blew + up!')]) +== B-cs-cancel use_soc=True + start raised: Cancelled() + top-level: clean return +== own-rte-under-cancel (both) -> RTE('never got + started!') propagates unchanged +``` + +Key finding: with a WELL-BEHAVED (non-absorbing) child +an OOB ancestor cancel surfaces `Cancelled` directly +from `.start()` on trio 0.29 — the lossy RTE requires +the child to absorb its cancel pre-`.started()`, which +is what `modden`'s `open_from_wks` teardown did. Trio's +nursery-exit wait defers cancel delivery to children, +so all tested shapes are deterministic (0 flakes / 50 +runs). + +Mutation verification: + +``` +mutation 1 (checkpoint_if_cancelled removed): + FAILED test_sibling_err_not_masked_by_startup_rte[True] + FAILED test_pure_oob_cancel_not_morphed_to_rte[True] +mutation 2 (guard relaxed to 'started' substring, + isinstance dropped): + FAILED test_childs_own_rte_never_demoted_to_cancel[never got started!] + FAILED test_childs_own_rte_never_demoted_to_cancel[1234] +``` + +Final runs: + +``` +tests/trionics/test_taskc.py: 9 passed in 0.03s +hammer: 0/50 runs failed +tests/trionics/ + tests/test_trioisms.py: + 23 passed, 5 xfailed in 3.02s +``` diff --git a/tests/trionics/test_taskc.py b/tests/trionics/test_taskc.py new file mode 100644 index 00000000..203e0e4d --- /dev/null +++ b/tests/trionics/test_taskc.py @@ -0,0 +1,312 @@ +''' +`tractor.trionics._taskc.start_or_cancel()` unit tests. + +`trio.Nursery.start()` collapses an out-of-band (ancestor) +cancellation into a lossy, + + `RuntimeError('child exited without calling + task_status.started()')` + +whenever the started child exits pre-`.started()` WITHOUT +propagating the ambient `trio.Cancelled`; a common outcome +when the child (or any lib code it calls) runs a graceful +teardown which absorbs the cancel and returns early. Our +`start_or_cancel()` wrapper re-surfaces the real in-flight +cancellation in that case so the true root error/cancel +propagates to the `.start()` caller instead. + +These tests verify both that repair AND document upstream +`trio`'s current lossy behaviour via the +`use_start_or_cancel=False` parametrizations; if a `trio` +upgrade breaks one of THOSE cases it likely means upstream +shipped better startup-cancellation porcelain and our +wrapper deserves a re-audit! + +The core use case was dug out of `modden`'s +`progman.open_wks()` program-spawn machinery as per gh +issue #474; the wrapper landed originally via gh PR #464. + +''' +import pytest +import trio +from trio import TaskStatus + +from tractor.trionics import start_or_cancel + + +async def absorbs_cancel_pre_started( + task_status: TaskStatus[None] = trio.TASK_STATUS_IGNORED, +): + ''' + Swallow the ambient (ancestor-scope) cancel and return + early, a naughty-but-realistic graceful-teardown pattern + and the exact shape which causes `trio.Nursery.start()` + to raise its lossy startup `RuntimeError` in place of + the real `trio.Cancelled`. + + ''' + try: + await trio.sleep(2) + except trio.Cancelled: + return + task_status.started() + + +async def raise_val_err(): + ''' + Sibling task which blows up (fast) thus OOB-cancelling + the shared parent-nursery's cancel-scope. + + ''' + await trio.lowlevel.checkpoint() + raise ValueError('sibling blew up!') + + +@pytest.mark.parametrize( + 'use_start_or_cancel', + [ + True, + False, + ], +) +def test_sibling_err_not_masked_by_startup_rte( + use_start_or_cancel: bool, +): + ''' + The `modden.runtime.progman` use case: a sibling task + errors while the `.start()`-ed child is still + pre-`.started()`, OOB-cancelling the shared nursery + scope; the child absorbs its cancel (graceful teardown) + and exits early. + + - with `start_or_cancel()` the in-flight cancellation + is re-surfaced as the real `trio.Cancelled` (then + absorbed by the cancelled nursery scope) so ONLY the + root-cause sibling error escapes the nursery. + + - with a bare `.start()`, upstream `trio` (currently) + also delivers its lossy startup `RuntimeError` + alongside, obscuring that the child was in fact + cancelled due to the sibling's error. + + ''' + async def main(): + async with trio.open_nursery() as tn: + tn.start_soon(raise_val_err) + if use_start_or_cancel: + await start_or_cancel( + tn, + absorbs_cancel_pre_started, + ) + else: + await tn.start(absorbs_cancel_pre_started) + + with pytest.raises(ExceptionGroup) as excinfo: + trio.run(main) + + eg: ExceptionGroup = excinfo.value + val_eg, rest_eg = eg.split(ValueError) + assert len(val_eg.exceptions) == 1 + + if use_start_or_cancel: + # the re-surfaced `Cancelled` is absorbed by the + # (sibling-error cancelled) nursery scope leaving + # NO startup-noise, just the root cause. + assert rest_eg is None + else: + # the `trio` wart: a lossy startup RTE rides along + # with (and distracts from) the root cause. + rte = rest_eg.exceptions[0] + assert isinstance(rte, RuntimeError) + assert 'child exited without calling' in rte.args[0] + + +@pytest.mark.parametrize( + 'use_start_or_cancel', + [ + True, + False, + ], +) +def test_pure_oob_cancel_not_morphed_to_rte( + use_start_or_cancel: bool, +): + ''' + A plain (error-free) ancestor `CancelScope.cancel()` + fired while the (cancel-absorbing) child is still + pre-`.started()`: + + - `start_or_cancel()` re-surfaces the `Cancelled` so + the cancelled scope exits CLEAN, no error at all. + + - a bare `.start()` (currently) morphs the plain + cancel into an (eg-wrapped) startup `RuntimeError`. + + ''' + async def main(): + with trio.CancelScope() as cs: + async with trio.open_nursery() as tn: + + async def canceller(): + await trio.lowlevel.checkpoint() + cs.cancel() + + tn.start_soon(canceller) + if use_start_or_cancel: + await start_or_cancel( + tn, + absorbs_cancel_pre_started, + ) + else: + await tn.start( + absorbs_cancel_pre_started, + ) + + assert cs.cancelled_caught + + if use_start_or_cancel: + trio.run(main) + else: + with pytest.raises(ExceptionGroup) as excinfo: + trio.run(main) + + rte = excinfo.value.exceptions[0] + assert isinstance(rte, RuntimeError) + assert 'child exited without calling' in rte.args[0] + + +@pytest.mark.parametrize( + 'use_start_or_cancel', + [ + True, + False, + ], +) +def test_genuine_startup_rte_still_raised( + use_start_or_cancel: bool, +): + ''' + Absent ANY in-flight cancellation, a child exiting + cleanly without calling `task_status.started()` is a + genuine startup-protocol bug; `start_or_cancel()` must + re-raise the resulting `RuntimeError` exactly like a + bare `.start()` does. + + ''' + async def exits_wo_started( + task_status: TaskStatus[None] = ( + trio.TASK_STATUS_IGNORED + ), + ): + await trio.lowlevel.checkpoint() + + async def main(): + async with trio.open_nursery() as tn: + with pytest.raises(RuntimeError) as excinfo: + if use_start_or_cancel: + await start_or_cancel( + tn, + exits_wo_started, + ) + else: + await tn.start(exits_wo_started) + + rte = excinfo.value + assert ( + 'child exited without calling' + in + rte.args[0] + ) + + trio.run(main) + + +@pytest.mark.parametrize( + 'rte_arg', + [ + # a bare `'started' in args[0]` substring match + # would (wrongly) demote this one to a `Cancelled` + # under ambient cancellation. + 'never got started!', + # non-`str` first-arg edge; must not `TypeError` + # inside the wrapper's msg-match guard. + 1234, + ], +) +def test_childs_own_rte_never_demoted_to_cancel( + rte_arg: str|int, +): + ''' + A child's OWN `RuntimeError`, one which merely smells + like `trio`'s startup wording (or carries a non-`str` + first arg), raised under ambient cancellation must NOT + be demoted to a `trio.Cancelled` by the exact-msg-match + guard inside `start_or_cancel()`; the real error must + always propagate to the caller unchanged. + + ''' + async def cancels_cs_then_raises( + task_status: TaskStatus[None] = ( + trio.TASK_STATUS_IGNORED + ), + ): + # cancel the ambient (ancestor) scope then raise + # sync-ly, no checkpoint between, so the child + # deterministically dies with ITS error while the + # caller is under effective cancellation. + cs.cancel() + raise RuntimeError(rte_arg) + + cs = trio.CancelScope() + + async def main(): + with cs: + async with trio.open_nursery() as tn: + await start_or_cancel( + tn, + cancels_cs_then_raises, + ) + + with pytest.raises(ExceptionGroup) as excinfo: + trio.run(main) + + rte = excinfo.value.exceptions[0] + assert isinstance(rte, RuntimeError) + assert rte.args[0] == rte_arg + + +def test_started_value_and_args_passthru(): + ''' + Happy path: positional args, the `name=` kwarg and the + `.started(value)`-delivered value all pass through + `start_or_cancel()` identically to a bare `.start()`. + + ''' + async def echo_started( + *args, + task_status: TaskStatus[tuple] = ( + trio.TASK_STATUS_IGNORED + ), + ): + task_name: str = trio.lowlevel.current_task().name + task_status.started(( + args, + task_name, + )) + + async def main(): + async with trio.open_nursery() as tn: + ( + args, + task_name, + ) = await start_or_cancel( + tn, + echo_started, + 'chillin', + 10, + name='doggy', + ) + assert args == ('chillin', 10) + assert task_name == 'doggy' + + trio.run(main)