Compare commits
8 Commits
27c34aebb6
...
7ec597138c
| Author | SHA1 | Date |
|---|---|---|
|
|
7ec597138c | |
|
|
e537c4b5ef | |
|
|
349c89791b | |
|
|
b2a64b2bfd | |
|
|
91b8ad782c | |
|
|
eed2bb738e | |
|
|
d48e43d30d | |
|
|
4c3053f036 |
|
|
@ -1,80 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
---
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
@ -134,8 +134,7 @@ examples in gh #482) used a *suffix* form
|
||||||
semantically inverted: it puts the overlay addr where the bearer
|
semantically inverted: it puts the overlay addr where the bearer
|
||||||
belongs, `tcp` where wg's `udp` `ListenPort` goes, and declares
|
belongs, `tcp` where wg's `udp` `ListenPort` goes, and declares
|
||||||
no overlay endpoint at all. `parse_wg_maddr()` in
|
no overlay endpoint at all. `parse_wg_maddr()` in
|
||||||
`examples/multihost/wg_lan/` now rejects it with an actionable
|
`examples/wg_lan/` now rejects it with an actionable error.
|
||||||
error.
|
|
||||||
Observed protocol-name lists, for writing the `match`:
|
Observed protocol-name lists, for writing the `match`:
|
||||||
|
|
||||||
| maddr | `[p.name for p in m.protocols()]` |
|
| maddr | `[p.name for p in m.protocols()]` |
|
||||||
|
|
@ -198,14 +197,7 @@ side-effect-free; verification is the *caller's* explicit step
|
||||||
### 3.4 deliverables
|
### 3.4 deliverables
|
||||||
|
|
||||||
- `examples/` scripts distilled from #482 §§3-5 (this is the
|
- `examples/` scripts distilled from #482 §§3-5 (this is the
|
||||||
unchecked "commit examples from ^" bullet in #443). They live
|
unchecked "commit examples from ^" bullet in #443).
|
||||||
under `examples/multihost/` — `test_docs_examples.py` walks
|
|
||||||
`examples/` recursively and runs every collected file as a
|
|
||||||
subproc asserting `rc == 0` (it doesn't even filter by
|
|
||||||
extension, so a stray `README.md` would be `python`-run too),
|
|
||||||
and `'multihost' not in p[0]` is already in its exclusion
|
|
||||||
list. Anything needing a real second host or a live tunnel
|
|
||||||
belongs there.
|
|
||||||
- a `docs/` page: tunnel setup, the maddr form, the two-host
|
- a `docs/` page: tunnel setup, the maddr form, the two-host
|
||||||
run. Keep prose in the docs; keep the examples runnable and
|
run. Keep prose in the docs; keep the examples runnable and
|
||||||
minimal.
|
minimal.
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,6 @@ Supersedes the example set in gh
|
||||||
[#482](https://github.com/goodboy/tractor/issues/482) — see
|
[#482](https://github.com/goodboy/tractor/issues/482) — see
|
||||||
[what changed](#what-changed-vs-482).
|
[what changed](#what-changed-vs-482).
|
||||||
|
|
||||||
> **Why `examples/multihost/`?** `tests/test_docs_examples.py`
|
|
||||||
> walks `examples/` recursively and runs everything it collects
|
|
||||||
> as a subproc, asserting `rc == 0`. These need a real second
|
|
||||||
> host and a live `wg` tunnel, so they can't satisfy that;
|
|
||||||
> `'multihost' not in p[0]` is already in the test's exclusion
|
|
||||||
> list, which is what keeps them out of CI.
|
|
||||||
|
|
||||||
## the maddr form
|
## the maddr form
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -2,19 +2,16 @@
|
||||||
`tractor.log`-wrapping unit tests.
|
`tractor.log`-wrapping unit tests.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import shutil
|
import shutil
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import tractor
|
import tractor
|
||||||
import trio
|
|
||||||
from tractor import (
|
from tractor import (
|
||||||
_code_load,
|
_code_load,
|
||||||
log,
|
log,
|
||||||
)
|
)
|
||||||
from tractor.ipc import _chan
|
|
||||||
|
|
||||||
|
|
||||||
def test_root_pkg_not_duplicated_in_logger_name():
|
def test_root_pkg_not_duplicated_in_logger_name():
|
||||||
|
|
@ -225,88 +222,6 @@ def test_add_log_level_pluggable():
|
||||||
delattr(log.StackLevelAdapter, name.lower())
|
delattr(log.StackLevelAdapter, name.lower())
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
'suppression',
|
|
||||||
[
|
|
||||||
'level',
|
|
||||||
'logger',
|
|
||||||
'global',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_log_guard_skips_payload_formatting(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
suppression: str,
|
|
||||||
):
|
|
||||||
'''
|
|
||||||
Suppressed transport logs must not render payloads.
|
|
||||||
|
|
||||||
The original hot-path guard compared only the effective logger
|
|
||||||
level. A logger disabled through its `Logger.disabled` flag or
|
|
||||||
the global `logging.disable()` threshold could therefore still
|
|
||||||
call `pformat()` before `Logger.isEnabledFor()` discarded the
|
|
||||||
record.
|
|
||||||
|
|
||||||
Exercise effective-level, per-logger, and global suppression
|
|
||||||
independently. A poisoned `_chan.pformat()` proves rendering is
|
|
||||||
skipped, while the fake transport proves `Channel.send()` still
|
|
||||||
transmits the original payload and traceback-hiding flag.
|
|
||||||
|
|
||||||
'''
|
|
||||||
sent: list[tuple[object, bool]] = []
|
|
||||||
|
|
||||||
class FakeTransport:
|
|
||||||
async def send(
|
|
||||||
self,
|
|
||||||
payload: object,
|
|
||||||
hide_tb: bool = False,
|
|
||||||
) -> None:
|
|
||||||
sent.append((payload, hide_tb))
|
|
||||||
|
|
||||||
def fail_pformat(payload: object) -> str:
|
|
||||||
raise AssertionError(
|
|
||||||
f'suppressed log rendered payload: {payload!r}'
|
|
||||||
)
|
|
||||||
|
|
||||||
chan_log = log.get_logger(
|
|
||||||
name=f'guard_test.{suppression}',
|
|
||||||
)
|
|
||||||
std_log = chan_log.logger
|
|
||||||
orig_level: int = std_log.level
|
|
||||||
orig_disable: int = logging.root.manager.disable
|
|
||||||
transport_level: int = log.CUSTOM_LEVELS['TRANSPORT']
|
|
||||||
|
|
||||||
monkeypatch.setattr(_chan, 'log', chan_log)
|
|
||||||
monkeypatch.setattr(_chan, 'pformat', fail_pformat)
|
|
||||||
try:
|
|
||||||
logging.disable(logging.NOTSET)
|
|
||||||
std_log.setLevel(transport_level)
|
|
||||||
|
|
||||||
if suppression == 'level':
|
|
||||||
std_log.setLevel(logging.INFO)
|
|
||||||
elif suppression == 'logger':
|
|
||||||
monkeypatch.setattr(std_log, 'disabled', True)
|
|
||||||
else:
|
|
||||||
logging.disable(logging.CRITICAL)
|
|
||||||
|
|
||||||
assert not chan_log.isEnabledFor(transport_level)
|
|
||||||
|
|
||||||
transport = FakeTransport()
|
|
||||||
chan = _chan.Channel(transport=transport)
|
|
||||||
payload = object()
|
|
||||||
|
|
||||||
async def send_payload() -> None:
|
|
||||||
await chan.send(
|
|
||||||
payload,
|
|
||||||
hide_tb=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
trio.run(send_payload)
|
|
||||||
assert sent == [(payload, True)]
|
|
||||||
finally:
|
|
||||||
std_log.setLevel(orig_level)
|
|
||||||
logging.disable(orig_disable)
|
|
||||||
|
|
||||||
|
|
||||||
# TODO, moar tests against existing feats:
|
# TODO, moar tests against existing feats:
|
||||||
# ------ - ------
|
# ------ - ------
|
||||||
# - [ ] color settings?
|
# - [ ] color settings?
|
||||||
|
|
|
||||||
|
|
@ -1,333 +0,0 @@
|
||||||
'''
|
|
||||||
`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_forever()
|
|
||||||
except trio.Cancelled:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
`cancelled_at_start` records that the wrapper's own await
|
|
||||||
raises `Cancelled`, rather than merely relying on the
|
|
||||||
nursery's eventual exception-group shape.
|
|
||||||
|
|
||||||
'''
|
|
||||||
cancelled_at_start: list[bool] = []
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
async with trio.open_nursery() as tn:
|
|
||||||
tn.start_soon(raise_val_err)
|
|
||||||
if use_start_or_cancel:
|
|
||||||
try:
|
|
||||||
await start_or_cancel(
|
|
||||||
tn,
|
|
||||||
absorbs_cancel_pre_started,
|
|
||||||
)
|
|
||||||
except trio.Cancelled:
|
|
||||||
cancelled_at_start.append(True)
|
|
||||||
raise
|
|
||||||
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:
|
|
||||||
assert cancelled_at_start == [True]
|
|
||||||
# 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`.
|
|
||||||
|
|
||||||
`cancelled_at_start` proves cancellation interrupts the
|
|
||||||
wrapper call itself before the cancelled scope exits.
|
|
||||||
|
|
||||||
'''
|
|
||||||
cancelled_at_start: list[bool] = []
|
|
||||||
|
|
||||||
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:
|
|
||||||
try:
|
|
||||||
await start_or_cancel(
|
|
||||||
tn,
|
|
||||||
absorbs_cancel_pre_started,
|
|
||||||
)
|
|
||||||
except trio.Cancelled:
|
|
||||||
cancelled_at_start.append(True)
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
await tn.start(
|
|
||||||
absorbs_cancel_pre_started,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert cs.cancelled_caught
|
|
||||||
|
|
||||||
if use_start_or_cancel:
|
|
||||||
trio.run(main)
|
|
||||||
assert cancelled_at_start == [True]
|
|
||||||
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',
|
|
||||||
[
|
|
||||||
# Broad substring matches would wrongly demote either
|
|
||||||
# child-owned error to `Cancelled` under cancellation.
|
|
||||||
'never got started!',
|
|
||||||
'child exited without calling user hook',
|
|
||||||
# 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 as the sole exception-group
|
|
||||||
leaf, preserving object identity.
|
|
||||||
|
|
||||||
'''
|
|
||||||
child_rte = RuntimeError(rte_arg)
|
|
||||||
|
|
||||||
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 child_rte
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
assert excinfo.value.exceptions == (child_rte,)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
@ -198,6 +198,9 @@ class Channel:
|
||||||
# assert transport.raddr == addr
|
# assert transport.raddr == addr
|
||||||
chan = Channel(transport=transport)
|
chan = Channel(transport=transport)
|
||||||
|
|
||||||
|
# ?TODO, compact this into adapter level-methods?
|
||||||
|
# -[ ] would avoid extra repr-calcs if level not active?
|
||||||
|
# |_ how would the `calc_if_level` look though? func?
|
||||||
if log.at_least_level('runtime'):
|
if log.at_least_level('runtime'):
|
||||||
from tractor.devx import (
|
from tractor.devx import (
|
||||||
pformat as _pformat,
|
pformat as _pformat,
|
||||||
|
|
@ -322,8 +325,6 @@ class Channel:
|
||||||
'''
|
'''
|
||||||
__tracebackhide__: bool = hide_tb
|
__tracebackhide__: bool = hide_tb
|
||||||
try:
|
try:
|
||||||
if log.at_least_level('transport'):
|
|
||||||
# don't materialize the payload repr if not necessary
|
|
||||||
log.transport(
|
log.transport(
|
||||||
'=> send IPC msg:\n\n'
|
'=> send IPC msg:\n\n'
|
||||||
f'{pformat(payload)}\n'
|
f'{pformat(payload)}\n'
|
||||||
|
|
|
||||||
|
|
@ -309,10 +309,7 @@ class MsgpackTransport(MsgTransport):
|
||||||
log.transport(f'received header {size}') # type: ignore
|
log.transport(f'received header {size}') # type: ignore
|
||||||
msg_bytes: bytes = await self.recv_stream.receive_exactly(size)
|
msg_bytes: bytes = await self.recv_stream.receive_exactly(size)
|
||||||
|
|
||||||
if log.at_least_level('transport'):
|
log.transport(f"received {msg_bytes}") # type: ignore
|
||||||
log.transport( # type: ignore
|
|
||||||
f'received {msg_bytes}'
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
# NOTE: lookup the `trio.Task.context`'s var for
|
# NOTE: lookup the `trio.Task.context`'s var for
|
||||||
# the current `MsgCodec`.
|
# the current `MsgCodec`.
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,9 @@ def at_least_level(
|
||||||
if isinstance(level, str):
|
if isinstance(level, str):
|
||||||
level: int = CUSTOM_LEVELS[level.upper()]
|
level: int = CUSTOM_LEVELS[level.upper()]
|
||||||
|
|
||||||
return log.isEnabledFor(level)
|
if log.getEffectiveLevel() <= level:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# TODO, compare with using a "filter" instead?
|
# TODO, compare with using a "filter" instead?
|
||||||
|
|
|
||||||
|
|
@ -306,12 +306,12 @@ class PldRx(Struct):
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
pld: PayloadT = self._pld_dec.decode(pld)
|
pld: PayloadT = self._pld_dec.decode(pld)
|
||||||
if log.at_least_level('runtime'):
|
|
||||||
# don't materialize the payload repr if not necessary
|
|
||||||
log.runtime(
|
log.runtime(
|
||||||
f'Decoded payload for\n'
|
f'Decoded payload for\n'
|
||||||
f'\n'
|
# f'\n'
|
||||||
f'{msg}\n'
|
f'{msg}\n'
|
||||||
|
# ^TODO?, ideally just render with `,
|
||||||
|
# pld={decode}` in the `msg.pformat()`??
|
||||||
f'where, '
|
f'where, '
|
||||||
f'{type(msg).__name__}.pld={pld!r}\n'
|
f'{type(msg).__name__}.pld={pld!r}\n'
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1003,15 +1003,17 @@ async def process_messages(
|
||||||
task_status.started(loop_cs)
|
task_status.started(loop_cs)
|
||||||
|
|
||||||
async for msg in chan:
|
async for msg in chan:
|
||||||
if log.at_least_level('transport'):
|
|
||||||
log.transport( # type: ignore
|
log.transport( # type: ignore
|
||||||
f'IPC msg from peer\n'
|
f'IPC msg from peer\n'
|
||||||
f'<= {chan.aid.reprol()}\n\n'
|
f'<= {chan.aid.reprol()}\n\n'
|
||||||
|
|
||||||
# TODO: pretty-printing structs is FRAGILE;
|
# TODO: use of the pprinting of structs is
|
||||||
# -[ ] add a non-raising log formatter with
|
# FRAGILE and should prolly not be
|
||||||
# native-repr fallback before using
|
#
|
||||||
# `.msg.pretty_struct` here.
|
# avoid fmting depending on loglevel for perf?
|
||||||
|
# -[ ] specifically `pretty_struct.pformat()` sub-call..?
|
||||||
|
# - how to only log-level-aware actually call this?
|
||||||
|
# -[ ] use `.msg.pretty_struct` here now instead!
|
||||||
# f'{pretty_struct.pformat(msg)}\n'
|
# f'{pretty_struct.pformat(msg)}\n'
|
||||||
f'{msg}\n'
|
f'{msg}\n'
|
||||||
)
|
)
|
||||||
|
|
@ -1260,7 +1262,6 @@ async def process_messages(
|
||||||
log.exception(message)
|
log.exception(message)
|
||||||
raise RuntimeError(message)
|
raise RuntimeError(message)
|
||||||
|
|
||||||
if log.at_least_level('transport'):
|
|
||||||
log.transport(
|
log.transport(
|
||||||
'Waiting on next IPC msg from\n'
|
'Waiting on next IPC msg from\n'
|
||||||
f'peer: {chan.aid.reprol()}\n'
|
f'peer: {chan.aid.reprol()}\n'
|
||||||
|
|
|
||||||
|
|
@ -349,10 +349,7 @@ async def start_or_cancel(
|
||||||
# demote it to a `Cancelled`, losing the real error. The
|
# demote it to a `Cancelled`, losing the real error. The
|
||||||
# `isinstance` guard also avoids a `TypeError` when
|
# `isinstance` guard also avoids a `TypeError` when
|
||||||
# `rte.args[0]` isn't a `str`.
|
# `rte.args[0]` isn't a `str`.
|
||||||
rte.args[0] == (
|
'child exited without calling' in rte.args[0]
|
||||||
'child exited without calling '
|
|
||||||
'task_status.started()'
|
|
||||||
)
|
|
||||||
):
|
):
|
||||||
# re-raises the in-flight `trio.Cancelled` IFF we're
|
# re-raises the in-flight `trio.Cancelled` IFF we're
|
||||||
# under effective cancellation; else a cheap no-op and
|
# under effective cancellation; else a cheap no-op and
|
||||||
|
|
|
||||||
12
uv.lock
12
uv.lock
|
|
@ -308,11 +308,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.18"
|
version = "3.10"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -900,11 +900,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "setuptools"
|
name = "setuptools"
|
||||||
version = "83.0.0"
|
version = "82.0.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue