Add start_or_cancel() to trionics._taskc#464
Conversation
Wrapper around `trio.Nursery.start()` that DOESN'T mask
out-of-band cancellation as a lossy startup failure.
Picks the right re-raise: ambient `Cancelled` when
present, the genuine startup-protocol `RuntimeError`
otherwise.
The problem,
- `trio.Nursery.start()` raises a generic
`RuntimeError("child exited without calling
task_status.started()")` whenever the started task
exits BEFORE calling `task_status.started()` —
INCLUDING the common case where the child was
cancelled out-of-band by an *ancestor* cancel-scope
erroring/cancelling.
- In that case the original `trio.Cancelled` is
swallowed and the caller is left w/ an opaque,
root-cause-detached `RuntimeError`.
The fix,
- Catch the "...started" RTE.
- `await trio.lowlevel.checkpoint_if_cancelled()` —
re-raises the in-flight `Cancelled` IFF we're under
effective cancellation (ancestor-inclusive), carrying
trio's auto-generated reason which points at the true
root exc.
- If we're NOT cancelled the `checkpoint_if_cancelled()`
is a cheap no-op and we fall through to re-raise the
genuine startup-protocol RTE.
Re-export from `tractor.trionics` so callers don't have
to reach into `_taskc`.
(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
(cherry picked from commit 30e1592)
(cherry picked from commit 2b4589b)
`30e15925` ("Add `start_or_cancel()` to `trionics._taskc`")
inserted `async def start_or_cancel()` — whose body opens its
own col-4 `try:` — immediately before the trailing `else:
raise`. Because the edit was a pure insertion (0 deletions),
the *same* `else: raise` lines were silently REPARENTED: they
used to be the `for exc_match in matching: ... else: raise`
of `maybe_raise_from_masking_exc`, but now bind to
`start_or_cancel`'s `try/except` where they're unreachable
dead code.
Net effect: `maybe_raise_from_masking_exc` lost the `for/else`
re-raise of the un-masked exception, so a masked child
cancellation gets swallowed instead of surfaced.
- restore the `for/else: raise` to `maybe_raise_from_masking_exc`
- drop the now-dead `else: raise` from `start_or_cancel`
Surfaced as 2 deterministic failures in
`test_sigint_closes_lifetime_stack[wait_for_ctx-bg_aio_task-
send_SIGINT_to=child-*]` (the SIGINT-to-child "silent-abandon"
regime). Bisected with `trio` held at `0.29.0`: clean at
`9c36363b` (0/8), broken at `30e15925` (8/8), fixed (0/8).
NOT a `trio` (0.29↔0.33 identical) nor logging-plugin
regression.
(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
(cherry picked from commit 325574c)
(cherry picked from commit 0b8033f)
There was a problem hiding this comment.
Pull request overview
This PR extends tractor.trionics with a start_or_cancel() helper to preserve out-of-band trio.Cancelled when a started task exits before calling task_status.started(), and it also adjusts maybe_raise_from_masking_exc’s loop/else re-raise behavior.
Changes:
- Add
start_or_cancel()wrapper aroundtrio.Nursery.start()that re-surfaces effective cancellation viatrio.lowlevel.checkpoint_if_cancelled(). - Export
start_or_cancel()fromtractor.trionics. - Adjust
maybe_raise_from_masking_exccontrol-flow around the post-loop re-raise.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
tractor/trionics/_taskc.py |
Adds start_or_cancel() and modifies maybe_raise_from_masking_exc’s loop/else re-raise behavior. |
tractor/trionics/__init__.py |
Re-exports start_or_cancel from tractor.trionics. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| else: | ||
| raise |
There was a problem hiding this comment.
Verified empirically — this is a false positive. In a generator / @acm context manager the exception thrown in at yield stays the active exception for the frame, and there is no await between the except-block exit and this raise to clear exc_info, so the bare raise correctly re-raises the original group. Tested both sync and async @acm: each re-raises the thrown-in exception (no RuntimeError: No active exception to reraise). Keeping as-is.
| if ( | ||
| rte.args | ||
| and | ||
| 'started' in rte.args[0] | ||
| ): |
There was a problem hiding this comment.
Good catch — accepting. Guarding with isinstance(rte.args[0], str) and also tightening the match from the bare 'started' substring to trio's exact 'child exited without calling' wording (so a child task's own RuntimeError that happens to contain "started" can't get demoted to Cancelled under cancellation). Fix incoming on this branch.
There was a problem hiding this comment.
📎 Fixed in 3fbc77c8 (Tighten start_or_cancel's startup-RTE match) — once trionics_start_or_cancel is re-pushed: 3fbc77c8
The `'started' in rte.args[0]` check was too loose: it matched a child task's OWN `RuntimeError(...started...)` (not just trio's "child exited without calling task_status.started()"), and would `TypeError` on a non-`str` `args[0]`. Guard with `isinstance(..., str)` and match trio's exact wording so a real child error can't be demoted to `Cancelled` under cancellation. Review: PR #464 (copilot-pull-request-reviewer) #464 (review) (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code
Add
start_or_cancel()totrionics._taskcMotivation
trio.Nursery.start()collapses an out-of-band cancellation into alossy startup error. When the started task exits before calling
task_status.started()— including the very common case where it wascancelled out-of-band by an ancestor cancel-scope erroring or
cancelling — trio raises a generic
RuntimeError("child exited without calling task_status.started()")and the originaltrio.Cancelledis swallowed. The caller is left holding an opaque,root-cause-detached
RuntimeErrorinstead of the real cancellation(whose trio-generated reason points at the true root exc).
Summary of changes
start_or_cancel()— atrio.Nursery.start()wrapper that, onthe "didn't call
.started()"RuntimeError, re-surfaces anyambient (effective, hence ancestor-inclusive) cancellation via
trio.lowlevel.checkpoint_if_cancelled()so the realtrio.Cancelledpropagates. Only when not under cancellation isthat
RuntimeErrora genuine startup-protocol bug, so it'sre-raised as-is. Exported from
tractor.trionics.for/elsere-raise inmaybe_raise_from_masking_exc(the masking CM) — the trailing
else: raisebelongs to the loop'selseclause and had been mis-attached.TODOs before landing
main) — this is the bottom of the remainingstack and lands first;
trio_033_upgrade→custom_log_levels_api→
mtf_backendstack above it.Links
setproctitle(closed; rode along thestack, drop before landing)
(this pr content was generated in some part by
claude-code)