fix(test): poll job store off the event loop in test_job_routes - #7740
Conversation
_wait_terminal/_wait_status were plain def helpers calling sdk.get + time.sleep from async tests, i.e. on the event loop, where read_bytes_with_retry deliberately re-raises PermissionError instead of sleeping the loop for its retry budget. When the job worker's concurrent os.replace was in flight, the Windows sharing violation surfaced as a flaky PermissionError out of sdk.get (#7703). Convert both helpers to async def polling via asyncio.to_thread — the same offload shape job_routes.py already uses for every production read — and await them at all 7 call sites. Add an injected-PermissionError invariant test and a coroutine-shape guard, and record the convention in testing-conventions.md. Fixes #7703
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Root-cause test-only fix matching the production offload pattern, with the invariant now falsifiable on POSIX; alternatives were weighed and correctly rejected. Suggestions
[DESIGN-REVIEWED] c2cd661 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Symptom
Backend Tests (Windows)shard 2 intermittently failstest/test_job_routes.py::test_get_existing_run_is_200withPermissionError: [Errno 13] Permission deniedon the run's record JSON under the pytest tmp(
app-data/jobs/<hex>.json). Non-deterministic: the same shard over the same backend code passed on#7686 and failed on #7696 minutes apart, with no Python in either diff. Linux shards never see it.
Root cause
The test file's polling helpers
_wait_terminal/_wait_statuswere plaindefhelpers callingsdk.get(run_id)+time.sleep(0.02). Every caller is an@pytest.mark.asynciocoroutine, so thepoll read the job record on the event loop. On that path
read_bytes_with_retry(
src/kiro_crew/atomic_write.py) deliberately re-raisesPermissionErrorinstead of sleeping theone loop for its retry budget (
on_event_loop()gate) — so the retry budget is effectively oneattempt. When the job worker's concurrent
os.replace(the last step ofatomic_write) happened tobe in flight, the Windows sharing violation surfaced as an uncaught
PermissionErrorout ofsdk.getand reddened the test.JobStore.readcatches only(FileNotFoundError, NotADirectoryError, ValueError), which is why the traceback names the final.json, not a.tmp.Production never sees this because every route offloads the read
(
await asyncio.to_thread(sdk.get, …)atjob_routes.py:256, 287, 295, 311) — andJobStore.read'sdocstring states that off-loop invariant explicitly. The test file was the only caller breaking it.
POSIX permits reading a file mid-replace, which is the entire "Windows only" observation — there is
no Windows-specific logic branch involved.
The earlier triage hypothesis on #7703 ("a background JobSDK worker still holds an open handle
while the test/teardown replaces or unlinks it") is superseded: the failure is on the READER side,
not the unlink, and the writer's replace is already retried (
replace_with_retryruns on the workerthread with its full budget). #7296 is not a duplicate (the issue-summary bot suggested the
link): that one is annotation-only, has no test identity, and an empty failed-log archive.
Fix (test-only, by design)
Following the in-tree precedent the routes already set (
job_routes.py'sasyncio.to_threadoffload):
_wait_terminal/_wait_statustoasync def, polling viaawait asyncio.to_thread(sdk.get, run_id)andawait asyncio.sleep(0.02)(theawaitalso stopsthe poll from monopolising the loop thread, which
time.sleepdid). Deadline semantics and theAssertionErrormessage shapes are unchanged.awaitthem at all 15 call sites — the 7 that existed when the branch was cut plus the 8 addedby fix(jobs): let a requested cancel survive a fresh read of the run #7680, which merged mid-flight and reused the helpers synchronously (caught as 3 failing tests
on rebase; a textual-clean, semantically-conflicting merge). The task spec counted "9 call sites"
from a grep that included the two
deflines; the re-verified count at branch time was 7.sdk.*read from an async test (get,list_active,list_recent,iter_runs): none — the only other match is amonkeypatch.setattr(sdk, "list_recent", …), not a read. The identically-named helpers intest_workflows_nudge_wiring.py/test_workflows_registry.pyare already async and unrelated(out of scope per spec).
docs/system-specs/common/testing-conventions.md(§ Async tests):never poll a synchronous store read from an async test; offload like the routes do. The invariant
previously lived only in
JobStore.read's docstring, which is what let this happen.Deliberately not done, per the spec's correctness analysis: adding
PermissionErrortoJobStore.read's except clause (would turn an existing record intoNone—_wait_terminalreadsthat as "not terminal yet" and
list_activeas a vanished record, the silent-skip hazarditer_runs' docstring warns about), and weakeningatomic_write.py'son_event_loop()gate (wouldput a sleep on the gateway loop). Both trade a loud test failure for a silent production one.
Verification
test_on_loop_read_propagates_permission_error_offloaded_read_retriesproves the invariantdirectly: with
platform_compat.IS_WINDOWSshimmed true (module attribute only — never a globalos.namepatch, which breaksPath.home()on POSIX) and the record's firstPath.read_bytesraising
PermissionError, a synchronoussdk.getfrom the loop propagates the error, while bothconverted helpers retry off-loop and succeed.
test_wait_helpers_are_coroutine_functionsasserts both helpers are coroutinefunctions, so a future revert to the synchronous shape fails loudly instead of reintroducing an
unreproducible Windows-only flake.
_wait_terminal's poll made synchronous (await asyncio.to_thread(sdk.get, run_id)→sdk.get(run_id); the helper-internal read IS the offload — call sites hold noto_thread):injected-PermissionError test FAILED with the propagated
PermissionError._wait_status: injected-PermissionError test FAILED on its_wait_statusleg.(
assert Falseoniscoroutinefunction).test/test_job_routes.py= 28 passed, 0 failed (22 pre-existing +4 from fix(jobs): let a requested cancel survive a fresh read of the run #7680 + 2 new) on the fix branch; 26 passed, 0 failed on an
origin/mainworktree.Failing-test id sets byte-identical both directions (both empty).
origin/maingit worktree,-n auto):fix 225 failed + 2 errors, main 224 failed + 2 errors; id-set diff = exactly one test
(
test_pod_e2e_harness_paths.py::test_health_refuses_a_foreign_port_holder_and_names_the_conflict),which passes standalone on the fix branch — a load flake of its 1-second
POD_E2E_HEALTH_TIMEOUTunder two concurrent full suites, not a regression. Excluding it, the226 remaining failing ids are byte-identical both directions, all pre-existing environmental
(AF_UNIX path length, missing host node for installer tests, platform-context composition).
black26.3.1 (repo gate script),isort,flake8,mypy— clean.No frontend surface changes — backend test file + docs only; a still frame cannot show a
concurrency fix. Evidence is the injected-PermissionError invariant test above.
Pattern harvest
Rule candidate: an invariant that callers must uphold ("only call this off the event loop") must
be enforced or conventionalised, not just documented at the callee —
JobStore.readdocumentedthe off-loop contract but nothing checked it, so the first synchronous test-side caller compiled,
passed on POSIX, and flaked only on Windows CI. This PR adds the convention to
testing-conventions.mdand a shape guard in the test file. Knowingly out of scope (already async,different subject): the
_wait_terminalhelpers intest/test_workflows_nudge_wiring.pyandtest/test_workflows_registry.py; no other sync store-read polls from async tests were found intest/test_job_routes.py, and other test modules were not audited per the spec'sno-opportunistic-widening constraint.
Fixes #7703