Skip to content

fix(engine): ask Windows for a fine timer tick while a session runs - #52

Merged
donislawdev merged 2 commits into
masterfrom
fix/injected-latency-timer-resolution
Jul 27, 2026
Merged

fix(engine): ask Windows for a fine timer tick while a session runs#52
donislawdev merged 2 commits into
masterfrom
fix/injected-latency-timer-resolution

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

What and why

Engineering audit finding F3. The injector holds a delayed packet with
Condition.wait(timeout=...), and Windows rounds that timeout UP to the system timer tick -
15.6 ms unless the process asks for better. That rounding was the whole of the added-latency
error, and it was a fixed surcharge rather than a percentage, so it swamped the small settings
this tool is most used for (LAN, game server, VoIP hop).

Measured on the REAL capture path before the fix (ping through live WinDivert, --dst-ip 8.8.8.8, two independent runs): a constant +12.6 ms of round-trip overshoot - +12.2 ms at
--latency 10 and +12.8 ms at --latency 50, where a proportional error would have been ~62 ms
at the higher setting. The control run at --latency 0 measured +0.4 and -0.3 ms, so it was not
the capture path, the driver or the link.

After (same ping test, 40 packets per setting)

step nominal min p50 p90 max p50 overshoot before
--latency 10 45 45 46 50 61 +1 ms +12.6 ms
--latency 50 125 125 126 130 135 +1 ms +12.6 ms
baseline 25 24 25 28 112 - -

Same +1 ms at both settings, so the surcharge is gone rather than scaled. p90 is +5 ms at both.

How

  • winenv.request_fine_timers() / release_fine_timers() - Windows only, no-op elsewhere. Taken
    for the life of a SESSION in BeanEngine.start() and given back in _stop_locked after the
    injector thread is joined. Session-scoped rather than process-scoped: the pair costs ~1.3 us
    (measured), so nothing holds a finer tick while the tool sits idle.
  • winenv._allow_fine_timers_in_background() - timeBeginPeriod alone is a fix that works and
    then stops.
    Windows 11 throttles a background process's timer resolution request, and this
    tool lives in the background (start a session, switch to the app under test). Measured: the
    request kept returning success with a perfectly balanced request/release log while the effect
    vanished after roughly ten seconds - in one process, Condition.wait(10 ms) went 10.1, 10.3,
    then 15.6 ms for every later session. SetProcessInformation with ProcessPowerThrottling +
    PROCESS_POWER_THROTTLING_IGNORE_TIMER_RESOLUTION (state 0) opts out, once per process. With
    it the same wait held 10.1-10.7 ms across 40 s of sampling and eight back-to-back sessions.
  • BeanEngine._fine_timers tracks whether the request was GRANTED. The OS refcounts these per
    process, so releasing one we never took cancels somebody else's.

For the reviewer

  • Querying the system-wide timer resolution proves nothing here, and the docstring says so:
    NtQueryTimerResolution reported a current tick of 1.0 ms (something else on the machine was
    holding it) while our own waits were still rounded to 15.6 ms, because since Windows 10 2004
    the tick is per-process. A future session that checks the global number and concludes "the
    timer is already fine" would be reading a number that does not apply to it.
  • The two balance tests were verified by mutation: dropping the release turns the first red
    with ['request']; releasing unconditionally turns the second red with
    ['request', 'release']. An unbalanced pair is otherwise invisible from inside the program - it
    just means the process keeps a finer system timer for life.
  • The tail is the link, not this change. A first acceptance run (10 packets per setting) read
    as a half fix, at +6.3 / +9.5 ms. That mean is outlier-dominated: about 1% of packets sit in a
    tail and a ping carries two impaired packets, so one outlier moves a ten-sample mean by 4-8 ms.
    Its own minima were already exactly nominal. In the 40-packet run the untouched baseline
    produced the worst outlier of the whole session (112 ms, against 61 and 135 with the tool in
    the path).
  • No public contract touched: no CLI flag, exit code, NDJSON field or on-disk format changes. Off
    Windows every call in this change is a no-op.

Checklist

  • python -m pytest tests passes locally (674 tests, 0 failures, elevated shell). python smoke_gui.py passes.
  • New behaviour has tests (four in tests/test_failsafe.py; two verified by mutation).
  • UI text goes through i18n keys, with both lang/en.json and lang/pl.json updated - not applicable, no UI strings in this change.
  • User-facing changes noted in CHANGELOG.md; technical ones and new tests in CHANGELOG-INTERNAL.md, under [Unreleased].
  • Commits follow Conventional Commits (type(scope): summary).
  • No version bump - the owner closes a version via VERSION.txt.

🤖 Generated with Claude Code

donislawdev and others added 2 commits July 27, 2026 19:17
The injector holds a delayed packet with Condition.wait(timeout=...), and
Windows rounds that timeout up to the system timer tick - 15.6 ms unless
the process asks for better. That rounding was the whole added-latency
error. Measured on the real capture path (ping through live WinDivert,
two runs): a CONSTANT +12.6 ms of round-trip overshoot regardless of the
setting - +12.2 ms at --latency 10, +12.8 ms at --latency 50, where a
proportional error would have been ~62 ms at the higher one. The control
run at --latency 0 measured +0.4 / -0.3 ms, so it was not the capture
path, the driver or the link.

- winenv: request_fine_timers / release_fine_timers (Windows only, no-op
  elsewhere), taken for the life of a SESSION and given back after the
  injector is joined. The pair costs ~1.3 us, so nothing holds a finer
  tick while the tool is idle.
- winenv: _allow_fine_timers_in_background(). timeBeginPeriod ALONE is a
  fix that works and then stops - Windows 11 throttles a background
  process's request, and this tool lives in the background. Measured: the
  request kept succeeding with a balanced log while the effect vanished
  after ~10 s (wait(10 ms): 10.1, 10.3, then 15.6 for every later session
  in the process). SetProcessInformation/ProcessPowerThrottling with
  IGNORE_TIMER_RESOLUTION and state 0 opts out, once per process.
- engine: _fine_timers tracks whether the request was GRANTED, because
  the OS refcounts them per process and releasing one we never took
  cancels somebody else's.
- tests: four in test_failsafe.py; the two balance tests verified by
  mutation (no release -> ['request']; unconditional release ->
  ['request', 'release']).
- both changelogs (convention 39).

Result through the engine, eight sessions in one process: overshoot
+0.22 to +0.55 ms (was +8.3), worst case ~25 ms -> ~11 ms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…timer fix

The user-facing entry quoted figures from the synthetic harness. Replaced
with the ping measurement, which the reader can reproduce: 40 packets per
setting through live WinDivert, +1 ms of overshoot at both --latency 10
and --latency 50 (was +12.6 ms at both).

Also records two measurement lessons in the internal log, because the
first acceptance run read as a half fix:

- a mean over ten pings is outlier-dominated here (~1% of packets sit in
  a tail, two impaired packets per ping), which moved it by 4-8 ms; the
  median was right, and even that run's minima were already exactly
  nominal.
- the tail belongs to the link: in the 40-packet run the untouched
  baseline produced the worst outlier of the whole session (112 ms,
  against 61 and 135 with the tool in the path).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit 2e3ad5d into master Jul 27, 2026
8 checks passed
@donislawdev
donislawdev deleted the fix/injected-latency-timer-resolution branch July 27, 2026 18:32
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