Add `.trionics.start_or_cancel()` test suite
Resolve #474 with a new `tests/trionics/test_taskc.py` (9 tests) covering the `trio.Nursery.start()` wrapper landed in PR #464, incl. the `modden.runtime.progman.open_wks()` use case dug out as a minimal repro. Deats, - the lossy `RuntimeError('child exited without calling task_status.started()')` only fires when the child absorbs its ambient cancel pre-`.started()` (graceful-teardown pattern); a well-behaved child surfaces `Cancelled` direct from `.start()` on `trio` 0.29 - verified empirically 1st. - `test_sibling_err_not_masked_by_startup_rte`: the `modden` case; ONLY the root-cause sibling `ValueError` escapes the nursery with the wrapper vs. bare-`.start()`'s lossy riding-along startup-RTE noise. - `test_pure_oob_cancel_not_morphed_to_rte`: plain ancestor `cs.cancel()` exits clean vs. bare's eg-wrapped RTE. - `test_genuine_startup_rte_still_raised`: sans cancellation the protocol-bug RTE re-raises same as bare. - `test_childs_own_rte_never_demoted_to_cancel`: exact-msg + `isinstance`-guard regression cover; a child's own `RuntimeError('never got started!')`/`RuntimeError(1234)` never demotes to a `Cancelled`. - `test_started_value_and_args_passthru`: positional args, `name=` and `.started()`-value forwarding. Also, - `use_start_or_cancel=False` params pin upstream `trio`'s current lossy behaviour as wart-documentation: a break on a `trio` upgrade likely means new upstream porcelain and the wrapper deserves a re-audit. - verified 0 flakes over 50 hammer runs + 2 impl mutations each caught by exactly the targeted tests. Prompt-IO: ai/prompt-io/claude/20260702T161624Z_65bf9df5_prompt_io.md (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-codewkt/start_or_cancel_tests_474
parent
65bf9df5ba
commit
ee875c930b
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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)
|
||||
Loading…
Reference in New Issue