Repository navigation
test(llc): gate the poll-loop cancellation tests on the API they need (#13727) - #13737
Conversation
✅ SSOT Configuration Compliance: Passing🎉 No hardcoded values detected that have SSOT config equivalents! |
…#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.
Review — pre-merge passReviewed the full diff against Blast radius
No shipped code path changes behaviour. The two constants are evaluated once at import ( Things I checked that could have gone wrong
The judgement call worth flagging to a second readerTest-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 ( On the skip itselfA 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 VerdictNo blocking findings. Merge once the rebased checks finish. |
…_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.
…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.
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()callsasyncio.Task.cancelling(), added in Python 3.11. Below that it raisesAttributeError. It runs once per_loopiteration and sits outside the innertry, so the error escapes_loopand the loop dies after exactly one tick. The visible symptoms are then tick-count andtask.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:
Task.cancellingThe 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 anexceptarm already masked — so agetattr(..., 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— exportsSUPPORTS_PENDING_CANCELLATION(a capability check,hasattr(asyncio.Task, "cancelling"), not a version number) andPENDING_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_intervalfails 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_eventclaimed to coverstart()'s_stop_event.clear(), but ran atpoll_interval=0.0, where the inter-poll wait returns immediately whether or not the event is still set. Deletingstart()'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, andtest_start_rearms_the_stop_eventadded 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-setasyncio.Eventcompletes synchronously and on Python 3.12+wait_foradds 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._loopwould spin without ever handing control back — starving the event loop, a hung backend rather than a pacing nit. Reproduced directly: at a realistic interval withclear()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:
Skips are explicit, never silent:
No collateral damage — same wide sweep, this branch vs. untouched base at
319c00127:−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:
honour_pending_cancellation()call removed from_looptest_swallowed_cancellation_does_not_start_another_intervalCancelledErrorinstead of re-raisingtest_cancellation_breaks_loop_cleanly,test_cancellation_marks_the_task_cancelled_loop'sfinallyno longer clears_runningtest_cancellation_breaks_loop_cleanlytest_tick_exception_does_not_kill_loopaclose()no longer clears_runningviastop()test_restart_after_aclose_keeps_tickingstart()'s_stop_event.clear()removedtest_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.pypasses in full onDev_new_gui— 0 failures; on CI's 3.14 nothing is even skippedtest_start_rearms_the_stop_event, plus the scope note on the renamed testDiscovered, 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 unretrievedAttributeError. Out of scope here; filed as its own wire-in issue.Model Used
Claude Opus 5 (1M context)
Closes #13727