Companion experiments for the Async-SIG post comparing nested block-scoped
shielding between aiotools' TaskScope
and asyncbis' CancelScope/TaskGroup.
Scenario ported from aiotools'
test_taskscope_shielded_nested_4
(timings scaled 1/10, wall-clock instead of VirtualClock).
Each script carries a PEP 723 inline script metadata block, so uv resolves the pinned dependencies (and CPython 3.12 itself) into a throwaway environment -- no venv setup needed:
$ uv run 01_nested_shield_equivalence.py
$ uv run 02_task_cancel_divergence.py
$ uv run 03_repeated_cancel.pyAdd --no-project if you run these from inside another project's directory.
Or set it up manually:
$ pip install "aiotools==2.2.4" typing-extensions \
"git+https://github.com/agronholm/asyncbis@592cc0b"
$ python 01_nested_shield_equivalence.pyTested with CPython 3.12 on Linux. Each script asserts its claims and prints
OK: ... on success. Note that asyncbis is a single-commit research sketch;
01 and 03 reach into task._cancel_scope (captured before the task's
first step) because there is no public API for a task's root scope yet.
typing-extensions is listed explicitly because aiotools 2.2.4 imports it
without declaring it as a runtime dependency.
| # | Claim | Key observation |
|---|---|---|
| 01 | Block-level shield-nesting semantics are observably equivalent across the two models -- if the cancel is addressed to the task's root scope on asyncbis | Identical trace / completed set / cancelled(). Divergence: the unshielded sibling L1 dies at t=1.42s (aiotools: deferred delivery replayed at the outermost shield's exit) vs. t=0.03s (asyncbis: immediate tree-wide level propagation) |
| 02 | Task.cancel() -- the entry point existing asyncio code uses -- inverts the outcome on asyncbis |
It targets the innermost active scope; the shield is pierced (shields only block parent-to-child propagation), the CancelledError is absorbed at that block's boundary, and the task completes successfully (cancelled() == False). Under aiotools the identical call is deferred and the task ends cancelled |
| 03 | Repeated cancels: counted & preserved vs. idempotent / scope-peeling; neither is a force-cancel | aiotools: two latched requests replay on exit, cancelling() == 2 (attribution works). asyncbis: same-tick repeat is a no-op; spaced repeats peel one scope per call, each absorbed. Bonus (part D): after a genuine root-scope cancellation, task.cancelling() == 0 -- the 3.11 counting protocol is inert under the level model |
===== 01_nested_shield_equivalence.py =====
--- aiotools (task.cancel via cancel_and_wait) ---
trace = ['level0-begin', 'level1-begin', 'level2-begin', 'level3-begin', 'level4-begin', 'level5-begin', 'level5-end', 'level4-end', 'level3-end', 'level2-end']
completed = ['L2', 'L3', 'L4', 'L5']
cancel_times = {'L1': 1.422}
cancelled() = True
--- asyncbis (root cancel scope) ---
trace = ['level0-begin', 'level1-begin', 'level2-begin', 'level3-begin', 'level4-begin', 'level5-begin', 'level5-end', 'level4-end', 'level3-end', 'level2-end']
completed = ['L2', 'L3', 'L4', 'L5']
cancel_times = {'L1': 0.026}
cancelled() = True
OK: identical trace/results/cancelled; L1 cancelled at 1.422s (aiotools) vs 0.026s (asyncbis)
===== 02_task_cancel_divergence.py =====
trace = ['level0-begin', 'level1-begin', 'level2-begin', 'level3-begin', 'level4-begin', 'level5-begin', 'level4-end', 'level3-end', 'level2-end', 'level1-end', 'level0-end']
completed = ['L1', 'L2', 'L3', 'L4']
cancel_times = {'L5': 0.026}
cancelled() = False
OK: task.cancel() pierced the innermost shield, was absorbed at its boundary, and the task completed successfully (cancelled() == False)
===== 03_repeated_cancel.py =====
[A: aiotools, double cancel while shielded]
trace=['outer-begin', 'inner-begin', 'inner-end'] cancelled=True cancelling()=2
[B: asyncbis, same-tick double cancel]
trace=['outer-begin', 'inner-begin', 'outer-end', 'body-end'] cancelled=False
[C: asyncbis, spaced double cancel]
trace=['outer-begin', 'inner-begin', 'body-end'] cancelled=False
[D: asyncbis, root-scope cancel]
cancelled=True cancelling()=0
OK: aiotools counts & defers repeats (never force); asyncbis is idempotent per scope / peels one scope per call, and cancelling() stays 0 even for a genuinely cancelled task
Timing-based assertions use generous margins (> 1.0s / < 0.2s) and should be
robust on any non-pathological machine, but they are wall-clock based.