Skip to content

flaky: test_poll_skips_while_another_holds_the_lock fails on Windows CI with WorkerCancelled #581

Description

@pbean

tests/test_tui_app.py::test_poll_skips_while_another_holds_the_lock fails
intermittently on Windows CI with textual.worker.WorkerCancelled: Worker was cancelled, and did not complete.

Where it was seen

  • Run 31743771689,
    job test (windows, py3.11) — the only failure in the job (1 failed, 5258 passed, 173 skipped, 5 xfailed), after 10m22s.
  • test (windows, py3.14) in the same run, same commit passed this test.
  • Re-running that one job on the same commit passed (12m22s). Recorded as evidence
    of nondeterminism, not as a remedy — the zero-retry policy in
    docs/testing.md is the reason this is an issue and not a rerun loop.
  • Observed on PR fix(deferredwork): atomic ledger writes + surrogate-safe sanitizer (#328, #329) #580, whose diff touches deferredwork.py, engine.py, sweep.py
    and platform_util.py and no TUI code at allgit diff over
    src/bmad_loop/tui/ and tests/test_tui_app.py is empty for that branch.

Why it races

DashboardScreen._poll is declared @work(thread=True, exclusive=True, group="poll")
(src/bmad_loop/tui/screens/dashboard.py:746), and the screen arms a periodic tick with
self.set_interval(1.0, self._tick) (dashboard.py:292). exclusive=True means a
newer worker in group poll cancels the older one.

The test starts its own poll worker by hand and then awaits it:

worker = screen._poll(ctx, screen._generation, False, None)
await worker.wait()

If the 1s interval tick fires between those two lines and kicks its own poll, Textual
cancels the test's worker, and Worker.wait() raises WorkerCancelled for a
CANCELLED worker. The test's preamble is three await until(...) spins plus a
non-blocking lock-acquire spin, so where it lands relative to the 1s boundary is luck —
and CI runners are slow enough (this job ran 10m22s) to straddle it.

Note what this does not mean: the property under test still held. The guarded body
never ran either way, which is exactly what
assert ctx.entries == before checks. The failure is the wait() call conflating
"cancelled by the interval tick" with "did not complete" — a test-harness defect, not a
product one.

Suggested direction (not prescriptive)

Either stop the interval before the hand-started worker (screen's timer paused for the
duration), or treat WorkerCancelled as an acceptable outcome and assert the invariant
(ctx.entries == before) directly rather than through wait(). The second keeps the
regression this test was written for — two threads feeding ctx.log's pyte stream —
while dropping the dependence on winning a race against the tick.

Whichever is chosen, the ablation rule applies: the test asserts an absence (the
guarded body never ran), so delete the _poll_lock guard and confirm it reddens before
trusting the repaired version.

Housekeeping

docs/testing.md (§ "CI, flakes, and deliberate absences") enumerates the open flake
instances inline — "#360 and #529 are the open instances". That line needs this issue
added when it is next touched. It is already stale on the other half (#529 is closed),
which #553 tracks.

The test itself arrived with e325764 "fix(tui): serialize poll worker and anchor log
jumps"
— it is the regression guard for #178. The guard is worth keeping; only the way
it awaits is at fault.

Out of scope for #580

Filed rather than fixed there — that PR is atomic_write_text + surrogate
neutralization on the deferred-work ledger writers and must not widen into the TUI.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Robustness, enhancement, tests, or docs worth schedulingarea:tuiTextual TUIbugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions