Skip to content

test(llc): gate the poll-loop cancellation tests on the API they need (#13727) - #13737

Merged
mrveiss merged 2 commits into
Dev_new_guifrom
issue-13727
Aug 8, 2026
Merged

mrveiss merged 2 commits into
Dev_new_guifrom
issue-13727

Conversation

@mrveiss

@mrveiss mrveiss commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Thinking Path

All 5 failures named in #13727 — and a 6th the issue did not list — are one root cause, not five contract mismatches.

honour_pending_cancellation() calls asyncio.Task.cancelling(), added in Python 3.11. Below that it raises AttributeError. It runs once per _loop iteration and sits outside the inner try, so the error escapes _loop and the loop dies after exactly one tick. The visible symptoms are then tick-count and task.cancelled() assertions — indistinguishable from a real regression in the cancellation contract, which is exactly the "red module stops carrying information" problem the issue describes.

Measured across four interpreters with a stdlib-only replica of the five tests:

Interpreter Task.cancelling Result
3.10.12 absent 5 failed / 0 passed
3.11.15 present 0 failed / 5 passed
3.12.13 present 0 failed / 5 passed
3.14.6 present 0 failed / 5 passed

The platform floor is 3.12 (startup_validator._validate_system_requirements) and CI runs 3.14, so the scheduler code is correct on every supported interpreter and needs no change. The local box runs 3.10, below the floor.

A compatibility shim was considered and rejected: there is no fallback for Task.cancelling() — nothing else in asyncio records a cancellation an except arm already masked — so a getattr(..., None) default would turn the #13085/#13203 guard permanently off and silently buy every poll loop a re-armed interval after shutdown. That is the exact bug those issues fixed. The rejection is written into the comment so it is not "helpfully" added later.

What Changed

llc/scheduler/base.py — exports SUPPORTS_PENDING_CANCELLATION (a capability check, hasattr(asyncio.Task, "cancelling"), not a version number) and PENDING_CANCELLATION_REQUIREMENT, so the dependency is stated once rather than duplicated per test module. No behaviour change.

llc/tests/test_poll_loop_scheduler.py — the 5 affected tests gated on that capability, with the interpreter dependency documented in the module docstring. The other 14 tests are untouched and keep running everywhere.

llc/scheduler/community_cluster_scheduler_test.py — test_swallowed_cancel_does_not_rearm_a_full_interval fails for the identical reason and is not listed in the issue; found while sweeping the scheduler package. Same gate, fixed here rather than left behind.

A test that could not fail

Verifying by deliberate failure (AC 3) turned up a real defect in the module. test_restart_after_aclose_clears_the_stop_event claimed to cover start()'s _stop_event.clear(), but ran at poll_interval=0.0, where the inter-poll wait returns immediately whether or not the event is still set. Deleting start()'s _stop_event.clear() left it green.

It is renamed to test_restart_after_aclose_keeps_ticking — what it actually proves — with a scope note, and test_start_rearms_the_stop_event added for the real contract.

The corrected contract (stated in the new test's docstring): a stale set event does not leave the loop "born stopped" — start() sets _running=True, so it runs. It makes every inter-poll wait return without suspending, because awaiting an already-set asyncio.Event completes synchronously and on Python 3.12+ wait_for adds no yield of its own. Measured: 100 waits on a set event give other tasks 0 steps on 3.12/3.14, versus 300 on 3.10. _loop would spin without ever handing control back — starving the event loop, a hung backend rather than a pacing nit. Reproduced directly: at a realistic interval with clear() removed, the probe never returned.

The new test asserts on the event itself, before the freshly created task takes its first step, so a regression fails instead of wedging the suite in the very starvation it describes — confirmed: the mutated run fails in 1.45 s.

Verification

Target modules, Python 3.10 — the 6 failures are gone:

$ python3 -m pytest autobot-backend/llc/tests/test_poll_loop_scheduler.py \
                    autobot-backend/llc/scheduler/ -q -p no:randomly
17 passed, 6 skipped in 1.71s

Skips are explicit, never silent:

SKIPPED [1] .../test_poll_loop_scheduler.py:242: requires asyncio.Task.cancelling()
            (Python 3.11+); the platform floor is 3.12 and CI runs 3.14

No collateral damage — same wide sweep, this branch vs. untouched base at 319c00127:

failed passed skipped
base 149 1438 1
this branch 143 1439 7

−6 failures (exactly the six fixed), +1 pass (the new test), +6 skips. The remaining 143 are pre-existing and unrelated (#13162).

Verified by deliberate failure, not a green run (AC 3)

Every gated test was confirmed to still catch a real regression on a supported interpreter (3.12 + 3.14), so the gate hides nothing:

Injected regression Caught by
honour_pending_cancellation() call removed from _loop test_swallowed_cancellation_does_not_start_another_interval
inter-poll wait swallows CancelledError instead of re-raising test_cancellation_breaks_loop_cleanly, test_cancellation_marks_the_task_cancelled
_loop's finally no longer clears _running test_cancellation_breaks_loop_cleanly
failing tick kills the loop (no catch-and-continue) test_tick_exception_does_not_kill_loop
aclose() no longer clears _running via stop() test_restart_after_aclose_keeps_ticking
start()'s _stop_event.clear() removed test_start_rearms_the_stop_event (new — the old test stayed green)

black --check, flake8 --max-line-length=120, isort --check-only: clean.

Acceptance criteria

  • test_poll_loop_scheduler.py passes in full on Dev_new_gui — 0 failures; on CI's 3.14 nothing is even skipped
  • The corrected contract is stated in the test docstring — test_start_rearms_the_stop_event, plus the scope note on the renamed test
  • Verified by deliberate failure — 6 injected regressions, each caught by a named test

Discovered, filed separately

The Python floor these tests depend on is declared in startup_validator._validate_system_requirements, which has zero callers repo-wide — the guard never runs, so nothing stops a sub-floor deployment where every LLC poll loop dies after one tick with an unretrieved AttributeError. Out of scope here; filed as its own wire-in issue.

Model Used

Claude Opus 5 (1M context)

Closes #13727

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No hardcoded values detected that have SSOT config equivalents!

mrveiss added 2 commits August 8, 2026 19:24
…#13727)

All 5 failures in test_poll_loop_scheduler.py, plus a 6th in
community_cluster_scheduler_test.py that the issue did not list, are one root
cause: honour_pending_cancellation() calls asyncio.Task.cancelling(), which is
Python 3.11+. On a sub-floor interpreter it raises AttributeError, the error
escapes _loop, and the loop dies after exactly one tick — so the failures read
as tick-count and cancelled-task assertions, i.e. as a regression in the
cancellation contract rather than as an unsupported interpreter.

Measured: 5 failed / 0 passed on 3.10; 0 failed / 5 passed on 3.11, 3.12 and
3.14. The platform floor is 3.12 (startup_validator) and CI runs 3.14, so the
code is correct everywhere it is supported and needs no change.

Gate the 6 affected tests on hasattr(asyncio.Task, "cancelling") — the
capability itself, not a version number — via constants exported from
llc.scheduler.base so the requirement is stated once. Nothing is skipped on a
supported interpreter. A compatibility shim is explicitly rejected in the
comment: a silent fallback would turn the #13085/#13203 guard permanently off.

Also corrects a test that could not fail. test_restart_after_aclose_clears_the_
stop_event claimed to cover start()'s _stop_event.clear() but ran at
poll_interval=0.0, where the inter-poll wait returns immediately either way;
deleting that line left it green. Renamed to what it actually proves, and added
test_start_rearms_the_stop_event for the real contract: a stale set event makes
every wait return without suspending (measured on 3.12/3.14: 100 such waits give
other tasks zero steps, vs 300 on 3.10), so the loop would starve the event loop
rather than merely tick fast.

Verification: every gated test was confirmed to fail under a targeted
regression on 3.12/3.14, so the gate hides nothing.
@mrveiss

mrveiss commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

Review — pre-merge pass

Reviewed the full diff against origin/Dev_new_gui at e18b31c6. Self-review (GitHub blocks self-approval, so this is recorded as a comment rather than an approval).

Blast radius

base.py is additive only — the diff contains no deleted or modified lines, just two new module-level constants:

$ git diff origin/Dev_new_gui...HEAD -- autobot-backend/llc/scheduler/base.py | grep '^-' | grep -v '^---'
(no output)

No shipped code path changes behaviour. The two constants are evaluated once at import (hasattr), so there is no per-call cost on the poll loop.

Things I checked that could have gone wrong

Risk Check Result
The gate silently skips on a supported interpreter — the exact failure mode this PR exists to prevent Ran the module on the 3.14 venv 23 passed, 0 skipped
Renaming test_restart_after_aclose_clears_the_stop_event breaks a reference Grepped the repo for the old name Only .test_durations and .pytest_cache. The durations entry goes stale, not broken — pytest-split assigns unknown tests the average, and Unit & Integration Tests (which runs --splits 12 --durations-path .test_durations) passed with the rename in place
New exported names collide Grepped SUPPORTS_PENDING_CANCELLATION / PENDING_CANCELLATION_REQUIREMENT Only the definition and its two importers
The new test leaks a pending task into the suite loop Ran with -W error::RuntimeWarning No warnings
The new test hangs rather than fails under regression — it describes event-loop starvation Removed start()'s _stop_event.clear() and ran Fails in 1.45 s, does not hang

The judgement call worth flagging to a second reader

Test-support constants are exported from a production module. The alternative was duplicating the predicate and its reason string across two test files in different directories (llc/tests/ and llc/scheduler/, so no shared conftest reaches both). I took the single canonical definition over the duplication, and kept pytest out of base.py entirely — the module exports a plain bool and a plain str, nothing test-framework-specific. Reasonable to disagree with; nothing else depends on the choice.

On the skip itself

A skip is normally the wrong answer to a red test, and this repo has explicitly said so (#13162, #13699, #13723). The distinction here: the guard is unimplementable below 3.11 — nothing else in asyncio records a cancellation an except arm has already masked — and the skip is gated on the capability rather than a version number, so it evaporates the moment the interpreter can support it. On every supported interpreter the count is 0 skipped. A compatibility shim was the tempting alternative and is explicitly rejected in the code comment, because a silent fallback would turn the #13085/#13203 guard permanently off.

Verdict

No blocking findings. Merge once the rebased checks finish.

@mrveiss
mrveiss merged commit 52277d7 into Dev_new_gui Aug 8, 2026
64 checks passed
@mrveiss
mrveiss deleted the issue-13727 branch August 8, 2026 16:39
mrveiss added a commit that referenced this pull request Aug 8, 2026
…_validator

Caught in review of this PR. #13737 landed three comments and a skip-reason
string that read "the platform floor is 3.12 (startup_validator.
_validate_system_requirements)" — citing the exact check this PR raises to 3.14.
Merging without this leaves the codebase asserting two different floors and
citing the same source for both, which is the drift the audit set out to remove.

The skip reason is user-visible: it prints on every skipped test on a sub-floor
interpreter, so a stale number there is read as fact.

No behaviour change; comments, a docstring, and one message string.
Re-verified on 3.14: 23 passed, black and flake8 clean.
mrveiss added a commit that referenced this pull request Aug 8, 2026
…stency audit

Audited every Python-version declaration in the repo - CI workflows, .python-version,
tool configs, Dockerfiles, Ansible roles and playbooks, runtime version checks -
and re-measured wheel availability against PyPI rather than trusting the reason
recorded in the tracking issue. Written up in docs/audit/python_314_consistency.md.

Result: the repo is already on 3.14 nearly everywhere. This PR carries the audit
and the items with no runtime risk; everything with blast radius is filed under
epic #13743 in dependency order.

startup_validator: the only runtime-enforced statement of the floor said 3.12
while CI, .python-version, mypy, both Dockerfiles and every backend Ansible role
said 3.14. Raised to 3.14. It has zero callers (#13738), so this has no runtime
effect until that check is wired.

pyproject: the comment justifying black's py312 target claimed the NPU worker
pins 3.12+. It pins 3.11. Corrected, and annotated with the measurement that
#10877's premise has expired - openvino 2026.3.0, torch 2.13.0 and numpy 2.5.1
all ship cp314 wheels; only onnxruntime-openvino (Windows worker) does not.

format.sh: told the user the project targets py3.12 and to install black on 3.12,
one line after telling them to install it on 3.14. Now names 3.14 as what CI runs
and separates running-under from target-version.

docs: seven stale version claims corrected, including GETTING_STARTED claiming
install.sh installs 3.12 (it installs 3.14) and BACKEND_DEBUGGING writing its
aioredis shim into a hardcoded python3.13 path, where a 3.14 backend would never
import it. Dated audit baselines and archived plans left as historical records.

Also retargets three comments and a user-visible skip-reason string left by
#13737 that cited this same check as a 3.12 floor.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant