Skip to content

fix(nodes): hold a 1 ms timer tick for the duration of a Delay - #134

Merged
gbradham merged 1 commit into
mainfrom
fix/delay-precision-flake
Aug 21, 2026
Merged

fix(nodes): hold a 1 ms timer tick for the duration of a Delay#134
gbradham merged 1 commit into
mainfrom
fix/delay-precision-flake

Conversation

@gbradham

Copy link
Copy Markdown
Member

It wasn't flaky

I called test_delay_node_is_accurate_under_realistic_loop_pressure flaky earlier. It isn't — it was failing about two runs in three on Windows, and it was right to.

Measured over 20 runs, a 0.5 s delay overshot by:

min 0.0   median 31.0   p90 47.0   max 63.0   (ms)

Those are 0, 15.6, 31.2, 46.8, 62.4 — exact multiples of Windows' default 15.6 ms system timer tick.

threading.Event.wait(timeout) is backed by a condition variable and cannot resolve finer than one tick. So the original field-bug fix — moving the sleep off the congested qasync loop onto a worker thread — solved the loop contention half and left the platform half untouched. The node's own docstring said so without drawing the conclusion: "precision ~1 ms on macOS/Linux". Windows was never covered.

The fix

Ask Windows for a 1 ms tick around the wait. Same measurement, after:

min 0.0   median 16.0   p90 16.0   max 16.0   (ms)     0/20 over the 25 ms assertion

Roughly 2× better typical, 4× better tail, and — more useful for an instrument — deterministic rather than scattered across four tick boundaries. The test then passed 10 consecutive runs.

Also correcting the docstring, which promised a precision it only delivered on two of three supported platforms.

Why it is scoped, not global

timeBeginPeriod is process-global and costs power: a faster tick means the CPU sleeps less deeply, which matters on a laptop on battery. So it is held only while something is actually waiting on it, not for the life of the process.

  • Concurrent delays share one request through a refcount, so the first to finish cannot drop the tick out from under the others.
  • Every failure path — no winmm, a refused period, an exception from the call — degrades to the coarse tick rather than propagating. A delay that is 15 ms long is a nuisance; a delay that raises ends an experiment.
  • A failed load is cached, so the warning is logged once rather than once per delay.
  • No-op on macOS and Linux, which already resolve these waits to about a millisecond.

Scope, stated plainly

The tick bounds every timed wait in the app — the data recorder's 100 ms sampling loop, the Timer node, the runner's UI timers. Only Delay takes it today. Widening that is a deliberate decision about how much of the time GLIDER should hold a fast tick, so I have not made it here.

Tests — 9 new

tests/unit/core/test_timer_resolution.py: raise and release; overlapping holders sharing one request; release on exception; no holder-count leak across repeated use; no-op off Windows; a refused period is never released later (releasing one that was never granted would unbalance Windows' own refcount and could lower another process's tick); missing winmm survivable; an exception from the call survivable; a failed load not retried.

Verification

PYTHONPATH=src QT_QPA_PLATFORM=offscreen pytest tests/3650 passed, 4 skipped, ruff and black clean. The precision test passed 10/10 consecutive runs with -m slow.

Note this test is excluded from CI (-m "not slow"), so this changes nothing about CI signal — it makes the local pytest -m slow run trustworthy, which is the only job it has.

test_delay_node_is_accurate_under_realistic_loop_pressure failed about two
runs in three on Windows. It is not flaky. Measured over 20 runs, a 0.5 s
delay overshot by a median of 31 ms with a worst case of 63 ms, and the
values were 0, 16, 31, 47, 63 -- exact multiples of 15.6 ms, which is
Windows' default system timer tick.

threading.Event.wait(timeout) is backed by a condition variable and cannot
resolve finer than that tick, so moving the sleep off the event loop fixed
the loop-contention half of the original field bug and left the platform half
in place. The node's own docstring said as much without drawing the
conclusion: "precision ~1 ms on macOS/Linux".

Asking Windows for a 1 ms tick around the wait takes the same measurement to
a median and worst case of 16 ms -- and, more usefully, makes it deterministic
instead of scattered across four tick boundaries. The test then passed ten
consecutive runs.

The request is process-global and costs power, because a faster tick means
the CPU sleeps less deeply, so it is held only while something is waiting on
it rather than for the life of the process. Concurrent delays share one
request through a refcount, so the first to finish cannot drop the tick out
from under the others. Every failure path -- no winmm, a refused period, an
exception from the call -- degrades to the coarse tick rather than
propagating: a delay that is 15 ms long is a nuisance, a delay that raises
ends an experiment.

Worth noting the tick still bounds every other timed wait in the app. Only
Delay takes it today.
@gbradham
gbradham merged commit 4555efe into main Aug 21, 2026
4 checks passed
@gbradham
gbradham deleted the fix/delay-precision-flake branch August 21, 2026 02:33
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