Skip to content

win: high-resolution poll timeouts via waitable timer + IOCP - #9

Merged
dylan-conway merged 1 commit into
oven-sh:bunfrom
robobun:farm/3199e702/win-hrtimer
Jul 20, 2026
Merged

win: high-resolution poll timeouts via waitable timer + IOCP#9
dylan-conway merged 1 commit into
oven-sh:bunfrom
robobun:farm/3199e702/win-hrtimer

Conversation

@robobun

@robobun robobun commented Jul 20, 2026

Copy link
Copy Markdown

GetQueuedCompletionStatusEx's ms timeout rounds to the system clock tick (~15.6 ms by default), so a 1 ms uv_timer fires ~15 ms late on an idle Windows box unless another process has raised the tick rate.

On Win10 1803+ / Server 2019+, arm a CREATE_WAITABLE_TIMER_HIGH_RESOLUTION waitable timer for the poll deadline and associate it with the loop's IOCP via NtAssociateWaitCompletionPacket. When it fires, the kernel posts a completion with lpOverlapped == NULL, which the dequeue loop already treats as a pure wakeup, and GQCS itself blocks with INFINITE so its coarse ms timeout never applies. This is the same approach Go's runtime uses (golang/go#44343).

On older Windows, fall back to timeBeginPeriod(1) once per process.

Measured on Windows Server 2019 (idle) via Bun's setTimeout:

before after
setTimeout(cb,1) 15.52 ms 1.41 ms
setTimeout(cb,5) 15.62 ms 5.24 ms

Companion Bun PR with the test + build-side patch: oven-sh/bun#34834

@robobun
robobun force-pushed the farm/3199e702/win-hrtimer branch 2 times, most recently from e2596e5 to b5c4eca Compare July 20, 2026 14:39
@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Author

Ran an adversarial review against this diff (8 probe dimensions, 2-vote refutation). Four concerns survived; all addressed in b5c4eca:

concern resolution
1 uv__hrtimer_init ran before 4 fallible uv_loop_init steps; handle leak on fail-path moved to just before return 0 (after uv__loops_add)
2 3 syscalls (cancel/set/associate) on every poll with a finite timeout, including an HTTP server's 1s/4s housekeeping timers arm only when timeout <= 100; far-away deadlines fall through to GQCS's ms wait. Bun.serve hello-world throughput on Windows debug (oha 10s/50 conn) was already within noise of main before this (11,484 vs 11,434 req/s); with the guard the arm path is unreachable there
3 fork PR and Bun's patch file had diverged on the uv__hrtimer_init signature synced; both single-arg now
4 Go (the cited reference) passes RemoveSignaledPacket=TRUE to NtCancelWaitCompletionPacket; this used FALSE and ate a spurious iteration instead switched to TRUE

Also manually verified on Windows Server 2019: I/O-beats-timer stress (100/100 fetch wins, 0 early fires), timer-beats-I/O (30/30 timer wins at 1ms vs 10ms server), 8 parallel Worker loops (medians 1.19-1.56ms), setInterval(16) avg 16.37ms, setTimeout(100) at 103.49ms.

@robobun
robobun force-pushed the farm/3199e702/win-hrtimer branch 6 times, most recently from 4bb29ad to 1824b45 Compare July 20, 2026 23:13
GetQueuedCompletionStatusEx's ms timeout rounds to the system clock tick
(~15.6 ms by default), so a 1 ms uv_timer fires ~15 ms late on an idle
Windows box unless another process has raised the tick rate.

On Win10 1803+ / Server 2019+, arm a CREATE_WAITABLE_TIMER_HIGH_RESOLUTION
waitable timer for the poll deadline and associate it with the loop's IOCP
via NtAssociateWaitCompletionPacket. When it fires the kernel posts a
completion with lpOverlapped == NULL, which the dequeue loop already
treats as a pure wakeup, and GQCS blocks with INFINITE so its coarse ms
timeout never applies. This is the same approach Go's runtime uses
(golang/go#44343).

On older Windows the HIGH_RESOLUTION flag or the Nt* function pointers are
absent; hrtimer stays NULL and uv__poll keeps the plain GQCS ms wait, so
behavior there is unchanged.

Measured on Windows Server 2019 (idle):
  setTimeout(cb, 1): 15.52 ms -> 1.41 ms
  setTimeout(cb, 5): 15.62 ms -> 5.24 ms
  setInterval(16):   28 ms    -> 16.4 ms
@robobun
robobun force-pushed the farm/3199e702/win-hrtimer branch from 1824b45 to bfcfd97 Compare July 20, 2026 23:18
@dylan-conway
dylan-conway merged commit 9687330 into oven-sh:bun Jul 20, 2026
robobun added a commit to oven-sh/bun that referenced this pull request Jul 20, 2026
oven-sh/libuv#9 landed the high-res poll timeouts on the bun branch, so
drop the local patch file and bump LIBUV_COMMIT. This also pulls in the
intervening Windows fs correctness fixes on that branch (oven-sh/libuv#7
and #8).
dylan-conway pushed a commit to oven-sh/bun that referenced this pull request Jul 20, 2026
## What

On an idle Windows box the `GetQueuedCompletionStatusEx` timeout rounds
to the ~15.6 ms system clock tick, so `setTimeout(cb, 1)` and
`Bun.sleep(1)` fire ~15 ms late unless another process happens to have
raised the tick rate (the "works when Spotify is open" heisenbug).
Node.js has the same behavior.

## How

Bumps libuv to
[oven-sh/libuv@9687330](oven-sh/libuv@9687330),
which landed [oven-sh/libuv#9](oven-sh/libuv#9)
(same approach Go's runtime uses,
[golang/go#44343](golang/go#44343)):

* At `uv__winapi_init`, dynamically load `NtCreateWaitCompletionPacket`
/ `NtAssociateWaitCompletionPacket` / `NtCancelWaitCompletionPacket`
from ntdll.
* Per loop, create a `CREATE_WAITABLE_TIMER_HIGH_RESOLUTION` waitable
timer + wait-completion-packet and stash them on
`uv__loop_internal_fields_s` (Win10 1803+ / Server 2019+; on older
Windows the handles stay NULL and `uv__poll` keeps its original GQCS ms
wait, so behavior is unchanged).
* In `uv__poll`, when `timeout > 0`: arm the waitable timer for the
deadline, associate it with the loop's IOCP, and wait in GQCS with
`INFINITE`. When the timer fires, the kernel posts a completion with
`lpOverlapped == NULL`, which the existing dequeue loop already treats
as a pure wakeup.

The bump also pulls in the intervening Windows fs correctness fixes on
the `bun` branch
([oven-sh/libuv#7](oven-sh/libuv#7),
[#8](oven-sh/libuv#8)).

## Numbers (Windows Server 2019, idle)

|                    | before    | after    |
|--------------------|-----------|----------|
| `setTimeout(cb,1)` | 15.52 ms  | 1.41 ms  |
| `Bun.sleep(1)`     | 15.62 ms  | 1.08 ms  |
| `setTimeout(cb,5)` | 15.62 ms  | 5.24 ms  |
| `setInterval(16)`  | ~28 ms    | 16.4 ms  |

`Bun.serve` hello-world throughput on Windows debug (oha, 10s, 50
concurrent): 11,484 req/s on this branch vs 11,434 req/s on main
(noise).

The added test in `setTimeout.test.js` measures the median of 50
`setTimeout(1)` samples in a subprocess and asserts it's under 8 ms
(before: 15.6 ms; after: ~1.5 ms).

Fixes #16714
Fixes #26965

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 1 · Platform-specific test-only change;
deferring to CI.

<!-- robobun:evidence:end -->
liooil pushed a commit to liooil/poly that referenced this pull request Aug 7, 2026
## What

On an idle Windows box the `GetQueuedCompletionStatusEx` timeout rounds
to the ~15.6 ms system clock tick, so `setTimeout(cb, 1)` and
`Bun.sleep(1)` fire ~15 ms late unless another process happens to have
raised the tick rate (the "works when Spotify is open" heisenbug).
Node.js has the same behavior.

## How

Bumps libuv to
[oven-sh/libuv@9687330](oven-sh/libuv@9687330),
which landed [oven-sh/libuv#9](oven-sh/libuv#9)
(same approach Go's runtime uses,
[golang/go#44343](golang/go#44343)):

* At `uv__winapi_init`, dynamically load `NtCreateWaitCompletionPacket`
/ `NtAssociateWaitCompletionPacket` / `NtCancelWaitCompletionPacket`
from ntdll.
* Per loop, create a `CREATE_WAITABLE_TIMER_HIGH_RESOLUTION` waitable
timer + wait-completion-packet and stash them on
`uv__loop_internal_fields_s` (Win10 1803+ / Server 2019+; on older
Windows the handles stay NULL and `uv__poll` keeps its original GQCS ms
wait, so behavior is unchanged).
* In `uv__poll`, when `timeout > 0`: arm the waitable timer for the
deadline, associate it with the loop's IOCP, and wait in GQCS with
`INFINITE`. When the timer fires, the kernel posts a completion with
`lpOverlapped == NULL`, which the existing dequeue loop already treats
as a pure wakeup.

The bump also pulls in the intervening Windows fs correctness fixes on
the `bun` branch
([oven-sh/libuv#7](oven-sh/libuv#7),
[#8](oven-sh/libuv#8)).

## Numbers (Windows Server 2019, idle)

|                    | before    | after    |
|--------------------|-----------|----------|
| `setTimeout(cb,1)` | 15.52 ms  | 1.41 ms  |
| `Bun.sleep(1)`     | 15.62 ms  | 1.08 ms  |
| `setTimeout(cb,5)` | 15.62 ms  | 5.24 ms  |
| `setInterval(16)`  | ~28 ms    | 16.4 ms  |

`Bun.serve` hello-world throughput on Windows debug (oha, 10s, 50
concurrent): 11,484 req/s on this branch vs 11,434 req/s on main
(noise).

The added test in `setTimeout.test.js` measures the median of 50
`setTimeout(1)` samples in a subprocess and asserts it's under 8 ms
(before: 15.6 ms; after: ~1.5 ms).

Fixes #16714
Fixes #26965

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 1 · Platform-specific test-only change;
deferring to CI.

<!-- robobun:evidence:end -->
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.

2 participants