Skip to content

win: high-resolution event-loop timer via waitable timer + IOCP - #34834

Merged
dylan-conway merged 15 commits into
mainfrom
farm/3199e702/win-hrtimer
Jul 20, 2026
Merged

win: high-resolution event-loop timer via waitable timer + IOCP#34834
dylan-conway merged 15 commits into
mainfrom
farm/3199e702/win-hrtimer

Conversation

@robobun

@robobun robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

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, which landed oven-sh/libuv#9 (same approach Go's runtime uses, 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, #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


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

robobun added 2 commits July 20, 2026 13:18
On an idle Windows box the GetQueuedCompletionStatusEx timeout rounds to
the ~15.6ms system tick, so setTimeout(cb, 1) and Bun.sleep(1) fire
~15ms late unless another process has raised the tick rate.

Patch libuv to arm a CREATE_WAITABLE_TIMER_HIGH_RESOLUTION waitable
timer for the poll deadline and associate it with the loop's IOCP via
NtAssociateWaitCompletionPacket (Win10 1803+). The kernel posts a
NULL-overlapped completion when it fires, which uv__poll already treats
as a pure wakeup, and GQCS blocks with INFINITE so its coarse ms timeout
never applies. On older Windows, fall back to timeBeginPeriod(1).

Before: setTimeout(1) median ~15.5ms on Windows
After:  setTimeout(1) median ~1.5ms on Windows
@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:35 PM PT - Jul 20th, 2026

@robobun, your commit 5e5f31a is building: #76472

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. setTimeout(fn, 0) slower to schedule on Windows #16714 - Reports setTimeout(fn, 0) taking ~15ms per call on Windows vs ~1ms on Linux, exactly the system tick granularity this PR fixes
  2. setInterval() timing way off on Windows #26965 - Reports setInterval(fn, 16) averaging ~28ms instead of ~16ms on Windows, consistent with IOCP timeout rounding to the 15.6ms tick boundary

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #16714
Fixes #26965

🤖 Generated with Claude Code

If a later uv_loop_init step failed (timer_heap alloc, wq_mutex init,
async init, loops_add), the hrtimer / wait-completion-packet handles
leaked through the fail_* unwind path. Move the call to just before the
successful return so no new cleanup label is needed.
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

Windows libuv polling adds high-resolution wakeups for short deadlines on supported systems, preserves the existing fallback path, manages per-loop timer resources, wires the patch into builds, and adds a 1 ms timer regression test.

Windows high-resolution timer support

Layer / File(s) Summary
Windows timer capability detection
patches/libuv/win-hrtimer.patch
Adds WinAPI declarations, dynamic loading, and startup probing for high-resolution waitable timer support.
Per-loop timer resources
patches/libuv/win-hrtimer.patch
Stores timer and completion-packet handles per loop, initializes them during loop setup, and cancels and closes them during shutdown.
IOCP deadline wakeups and validation
patches/libuv/win-hrtimer.patch, scripts/build/deps/libuv.ts, test/js/web/timers/setTimeout.test.js
Arms high-resolution timers for short poll deadlines, associates wakeups with IOCP, applies the patch during builds, and measures 1 ms timer delays.

Possibly related PRs

  • oven-sh/bun#34478: Both changes adjust Windows event-loop polling and IOCP/timer wakeup handling.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The patch improves short Windows timers, addressing the setTimeout latency and setInterval drift described in [#16714, #26965].
Out of Scope Changes check ✅ Passed The added build, patch, and test changes all support the Windows high-resolution timer work.
Title check ✅ Passed The title clearly summarizes the main change: high-resolution Windows event-loop timers via waitable timer + IOCP.
Description check ✅ Passed It covers both what changed and how it was verified, though the section headings differ from the template.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@patches/libuv/win-hrtimer.patch`:
- Around line 147-152: Replace TIMER_ALL_ACCESS with SYNCHRONIZE |
TIMER_MODIFY_STATE in both CreateWaitableTimerExW calls: the capability probe at
patches/libuv/win-hrtimer.patch lines 147-152 and the per-loop call in
uv__hrtimer_init at lines 170-175. No other access rights or timer behavior
should change.
- Around line 137-142: Shorten the uv__init probe comment at
patches/libuv/win-hrtimer.patch lines 137-142 to three lines or fewer, retaining
only a concise summary and referring to the patch header for details. Also
shorten the uv__poll arm-timer comment at lines 215-220 to three lines or fewer;
both sites require direct comment-only changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 33555de0-b50e-41f2-90d8-938051920943

📥 Commits

Reviewing files that changed from the base of the PR and between 9f4682d and 3d9b065.

📒 Files selected for processing (3)
  • patches/libuv/win-hrtimer.patch
  • scripts/build/deps/libuv.ts
  • test/js/web/timers/setTimeout.test.js

Comment thread patches/libuv/win-hrtimer.patch Outdated
Comment thread patches/libuv/win-hrtimer.patch Outdated
Comment thread patches/libuv/win-hrtimer.patch Outdated
Comment thread test/js/web/timers/setTimeout.test.js Outdated
…r in test

- CreateWaitableTimerExW: TIMER_ALL_ACCESS -> SYNCHRONIZE|TIMER_QUERY_STATE|
  TIMER_MODIFY_STATE (matches Go's createHighResTimer).
- Trim the uv__init probe and uv__poll arm comments to three lines; the
  patch header carries the full rationale.
- Test: filter 'WARNING: ASAN interferes' from stderr before asserting
  empty, matching the neighboring subprocess tests.
Comment thread patches/libuv/win-hrtimer.patch Outdated
… unused param

- Only arm the high-res waitable timer for deadlines <= 100ms. Bun.serve's
  1s date-header / 4s sweep timers (and any far-away user timer) fall
  through to GQCS's own ms timeout, so a pure HTTP workload pays zero
  extra syscalls per poll. Measured Bun.serve hello-world throughput on
  Windows debug was already within noise of main (11.4k vs 11.4k req/s)
  before this guard; with it, the arm path is unreachable there.
- NtCancelWaitCompletionPacket with RemoveSignaledPacket=TRUE to match
  Go's netpollQueueTimer and flush a stale queued packet instead of
  eating an extra empty iteration.
- Drop the unused uv_loop_t* parameter from uv__hrtimer_init.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No further findings — the earlier nits (init ordering, stderr filter, unused param, access mask) are all addressed. Deferring to a human because this rewires libuv's uv__poll hot path on Windows around undocumented Nt*WaitCompletionPacket syscalls and adds a process-wide timeBeginPeriod(1) fallback; a maintainer should sign off on those design choices and the stale-packet / manual-reset-timer edge cases.

What was reviewed:

  • timeout - 1 < 100 on unsigned DWORD — wraps at 0 and INFINITE, so the arm range is exactly [1, 100] as intended.
  • Init/close pairing: uv__hrtimer_init now runs after uv__loops_add, and uv__loop_close cancels + closes both handles; lfields is calloc'd so the NULL guards are sound.
  • Stale completion from a prior arm when the next poll skips the hrtimer branch — dequeues as a NULL-overlapped spurious wakeup, which uv__poll already ignores, so at worst one extra loop tick.
  • Test: median-of-50 with an 8 ms bound cleanly separates ~15.6 ms (before) from ~1-2 ms (after); min >= 1 guards against firing early.
Extended reasoning...

Overview

The PR adds a new libuv patch (patches/libuv/win-hrtimer.patch) that changes how uv__poll in src/win/core.c blocks for a deadline on Windows: instead of relying on GetQueuedCompletionStatusEx's millisecond timeout (which quantizes to the ~15.6 ms system tick), it arms a CREATE_WAITABLE_TIMER_HIGH_RESOLUTION waitable timer and associates it with the loop's IOCP via the undocumented NtAssociateWaitCompletionPacket, then blocks with INFINITE. On pre-1803 Windows it falls back to a one-shot timeBeginPeriod(1). Two struct fields are added to uv__loop_internal_fields_s, three ntdll function pointers are dynamically loaded, and winmm.lib is pulled in via #pragma comment. The build-script change registers the patch; the test change adds a subprocess median-latency assertion.

Security risks

Low. The new NT calls take no user-controlled input — all arguments are process-owned handles or constants. Access masks were already narrowed to SYNCHRONIZE | TIMER_QUERY_STATE | TIMER_MODIFY_STATE per earlier feedback. No parsing, no external data.

Level of scrutiny

High. uv__poll is the single blocking call at the heart of every Windows Bun process; a subtle mistake here is a hang or a busy-loop for every user. The mechanism relies on three undocumented ntdll exports whose exact semantics (re-association after cancel, RemoveSignaledPacket=TRUE behavior when the packet is already queued, interaction with a manual-reset timer that was left signaled from a prior iteration) are inferred from Go's runtime rather than a spec. The timeBeginPeriod(1) fallback is a process-global (and on older Windows, system-global) timer-resolution change with power implications — Node deliberately does not do this, so it's a design call a maintainer should ratify. The 100 ms arm threshold is a heuristic backed by one hello-world benchmark. None of that is a bug I can point to; it's exactly the kind of judgment a human owner of the Windows event loop should make.

Other factors

All five prior review threads (mine and CodeRabbit's) are resolved in commits 4a5a6b6 / 2345ba5 / f83da13. I traced the one non-obvious correctness edge I could find — a completion packet armed on iteration N whose next iteration has timeout outside [1, 100] so the NtCancelWaitCompletionPacket(TRUE) is skipped — and it degrades to a single NULL-overlapped spurious wakeup that the existing dequeue loop discards, so no hang. The SetWaitableTimer call reactivates the manual-reset timer each arm, and NtCancel(..., TRUE) is issued before re-associate, matching Go's sequence. The failure-closed shape (any arm/associate failure leaves timeout at its original ms value) is correct. Given the criticality of the code path and the design decisions involved, this is a defer rather than an approve.

@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

CI build #76320: 285/286 jobs passed. The one red job is bun-server.test.ts ("should not use 100% CPU when websocket is idle") on darwin 14 aarch64, where the fixture's CPU samples came in at 1.95/6.25/4.86% on what looks like a loaded CI box. This PR's change is gated to Windows (libuv is only built when cfg.windows), so that lane compiled and ran without any of this diff in it. The new setTimeout(1) quantization test passed on all Windows lanes. Ready for review.

robobun added 2 commits July 20, 2026 20:06
NtCancelWaitCompletionPacket can return STATUS_PENDING when the packet is
mid-delivery (timer fired, kernel is inserting it into the IOCP queue).
Re-associate in that window fails with STATUS_INVALID_PARAMETER_1 anyway,
so skip SetWaitableTimer + associate and fall through to the GQCS ms
timeout for this iteration. Matches Go's netpollQueueTimer. Previously
this was already failure-closed via the NT_SUCCESS check on associate;
the explicit guard just saves two syscalls and documents the edge case.
…en set

If the timer fires between SetWaitableTimer and the associate, the kernel
posts the packet immediately and sets AlreadySignaled. The packet is in
the queue so GQCS(INFINITE) would return immediately anyway, but
timeout=0 makes that explicit. Matches Go's netpollQueueTimer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/js/web/timers/setTimeout.test.js (1)

534-539: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Shorten this test comment to three lines. Preserve the subprocess/median rationale, but move the extended explanation out of source.

As per coding guidelines: “Keep code comments to three lines or fewer.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/web/timers/setTimeout.test.js` around lines 534 - 539, Shorten the
comment in the setTimeout(1) test to three lines or fewer while preserving that
it runs in a subprocess and uses the median to avoid resolution changes and
isolated scheduler hiccups affecting the assertion; remove the extended Windows
timer explanation from the source comment.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/web/timers/setTimeout.test.js`:
- Around line 561-565: Remove the exact-empty stderr assertion following the
filteredStderr construction in the subprocess test. Continue draining and
filtering stderr, including the existing ASAN warning handling, but do not
require filteredStderr to equal an empty string.

---

Outside diff comments:
In `@test/js/web/timers/setTimeout.test.js`:
- Around line 534-539: Shorten the comment in the setTimeout(1) test to three
lines or fewer while preserving that it runs in a subprocess and uses the median
to avoid resolution changes and isolated scheduler hiccups affecting the
assertion; remove the extended Windows timer explanation from the source
comment.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ce3d7c11-93a3-4494-937c-0c46cfb8c159

📥 Commits

Reviewing files that changed from the base of the PR and between 3d9b065 and 700ba53.

📒 Files selected for processing (2)
  • patches/libuv/win-hrtimer.patch
  • test/js/web/timers/setTimeout.test.js

Comment thread test/js/web/timers/setTimeout.test.js
robobun added 2 commits July 20, 2026 20:11
On pre-Win10-1803 the high-res probe fails and uv__poll keeps the plain
GQCS ms timeout, i.e. behavior is unchanged from before this patch.
Removes the winmm link and the process-global tick-rate side effect.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
patches/libuv/win-hrtimer.patch (1)

206-206: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the timeout range explicit.

Line 206 relies on subtraction/underflow to exclude zero and the infinite sentinel. Use explicit bounds so zero, finite deadlines, and unset values remain deliberate; the Windows poll timeout is a DWORD. (raw.githubusercontent.com)

Proposed fix
-    if (timeout - 1 < 100 && lfields->hrtimer != NULL) {
+    if (lfields->hrtimer != NULL && timeout >= 1 && timeout <= 100) {

As per coding guidelines: “Enumerate input spaces deliberately, distinguishing empty, zero, and unset.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@patches/libuv/win-hrtimer.patch` at line 206, Update the timeout condition
near the hrtimer check to use explicit DWORD bounds instead of timeout - 1
arithmetic: distinguish zero, finite timeout values below 100, and the
unset/infinite sentinel deliberately. Preserve the lfields->hrtimer != NULL
requirement while ensuring underflow cannot influence the decision.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@patches/libuv/win-hrtimer.patch`:
- Line 206: Update the timeout condition near the hrtimer check to use explicit
DWORD bounds instead of timeout - 1 arithmetic: distinguish zero, finite timeout
values below 100, and the unset/infinite sentinel deliberately. Preserve the
lfields->hrtimer != NULL requirement while ensuring underflow cannot influence
the decision.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 359edd84-93df-468e-92df-e469f37f8c12

📥 Commits

Reviewing files that changed from the base of the PR and between 1f98201 and 950ad9f.

📒 Files selected for processing (2)
  • patches/libuv/win-hrtimer.patch
  • scripts/build/deps/libuv.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found in this pass — all three earlier findings (init-failure HANDLE leak, stderr assertion, dead loop param) are addressed and CI is green on Windows. Deferring to a human because this rewires uv__poll's blocking semantics (GQCS now waits with INFINITE when the hrtimer arms) using undocumented Nt*WaitCompletionPacket APIs — the design looks sound and matches Go's runtime, but it's the Windows event-loop hot path and worth a maintainer's eyes on the 100ms threshold and the STATUS_PENDING / AlreadySignaled handling.

What was reviewed

  • uv__hrtimer_init ordering after uv__loops_add — no fallible work follows, so no leak on init failure.
  • uv__poll arm path: cancel → SetWaitableTimer → associate short-circuits to the original ms wait on any failure, so a bad status can't hang GQCS at INFINITE.
  • uv__loop_close releases both handles; lfields is calloc'd so the NULL checks are safe on the fallback path.
  • Test: subprocess isolation, median-of-50, filtered-stderr pattern matches neighboring tests; 8ms bound cleanly separates 15.6ms from ~1.5ms.
Extended reasoning...

Overview

This PR adds a vendored libuv patch (patches/libuv/win-hrtimer.patch) that changes how uv__poll blocks on Windows when the next timer deadline is ≤100ms. Instead of passing that timeout to GetQueuedCompletionStatusEx (which rounds to the ~15.6ms system tick), it arms a CREATE_WAITABLE_TIMER_HIGH_RESOLUTION waitable timer, associates it with the loop's IOCP via NtAssociateWaitCompletionPacket, and blocks GQCS with INFINITE. The kernel posts a NULL-overlapped completion when the timer fires, which the existing dequeue loop already treats as a pure wakeup. It also wires the patch into scripts/build/deps/libuv.ts and adds a subprocess regression test in setTimeout.test.js.

Security risks

None identified. The new code runs entirely inside the process against handles it created itself; the Nt*WaitCompletionPacket functions are dynamically probed and the whole path degrades to the pre-existing GQCS ms wait if any probe fails. Access rights on the waitable timer were narrowed to SYNCHRONIZE | TIMER_QUERY_STATE | TIMER_MODIFY_STATE per earlier review. No user-controlled input reaches this path.

Level of scrutiny

High. uv__poll is the single blocking call in Bun's Windows event loop — every timer, socket, file I/O, and subprocess completion flows through it. The change replaces a bounded ms wait with INFINITE on the assumption that the waitable-timer completion will always arrive. The patch handles the known edge cases (STATUS_PENDING on cancel → skip arm this round; AlreadySignaled → timeout = 0; any step failing → keep original ms timeout), and Go's runtime uses the same recipe, but this is exactly the kind of change where a subtle kernel-behavior misunderstanding manifests as a rare production hang. The 100ms cutoff is also a design choice (precision vs 3 syscalls/poll) that a maintainer should sign off on.

Other factors

All prior review feedback (mine and CodeRabbit's) has been addressed and the threads are resolved. CI build #76320 passed 285/286 with the one red job on darwin (this patch is Windows-only, gated by enabled: cfg => cfg.windows). The test is well-constructed — subprocess isolation avoids in-process tick-rate contamination, median-of-50 tolerates CI jitter, and the 8ms bound sits well between the before (15.6ms) and after (~1.5ms) values. The approach has strong prior art (golang/go#44343). I'm deferring not because I found a defect but because the blast radius of uv__poll warrants human sign-off.

robobun added 3 commits July 20, 2026 23:07
…imer_init

uv__init() is now unchanged from upstream. uv__hrtimer_init() gates on the
dynamically-loaded pNt*WaitCompletionPacket function pointers (matching
libuv's convention for optional ntdll APIs) and on CreateWaitableTimerExW
succeeding with the HIGH_RESOLUTION flag. Removes the uv__have_hrtimer
static and the duplicated CreateWaitableTimerExW call.
uv__loop_close now calls uv__hrtimer_close(lfields) instead of inlining
the handle cleanup, matching the init/close pairing convention.
Arm the high-res waitable timer for any finite nonzero deadline. The
100ms threshold was a heuristic and meant a 110ms timer could fire up to
~15ms late; the measured per-poll syscall overhead without the gate was
already noise vs main (11,484 vs 11,434 req/s on Bun.serve hello-world).
Comment thread patches/libuv/win-hrtimer.patch Outdated
robobun added 2 commits July 20, 2026 23:29
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
dylan-conway merged commit cb1e825 into main Jul 20, 2026
5 of 38 checks passed
@dylan-conway
dylan-conway deleted the farm/3199e702/win-hrtimer branch July 20, 2026 23:37
robobun added a commit that referenced this pull request Jul 30, 2026
…tries by build

The previous wording attributed aarch64 solo variance to #34834, which is a
Windows-only libuv change. Solo aarch64 >20s was observed in CI retry
attempts (builds 85866, 85400) but via a different mechanism than Windows.
Jarred-Sumner pushed a commit that referenced this pull request Jul 31, 2026
…ng (#36478)

Fixes `test/js/node/test/parallel/test-fs-read-stream-pos.js` going red
on main (seen on builds 84352, 84676, 85400, 85630, 85664, 85692,
85720-85866 across win2019, alpine/ubuntu/debian aarch64, and debian
x64-asan).

### Cause

The test's exit path is a pure timing race: a `setInterval(append, 1)`
writer must land a write between two consecutive `ReadStream` preads
within a single stream instance (partial chunk → another `'data'` before
`'end'`). Upstream ships it with a 90-second safety timer for exactly
that reason. In addition, each stream's `'data'` handler is wrapped in
`common.mustCallAtLeast(1)`, so any stream that observes zero bytes
fails the process on exit.

Two recent PRs together pushed both failure modes over the runner's 20 s
ceiling:

* #34834 raised Windows event-loop timer resolution from ~15 ms to ~1
ms. With the old resolution the writer and reader both fired on the same
~15 ms tick and each stream covered ~1 write (so the race hit in ≤25
cycles); with 1 ms resolution each stream covers ~10 writes, the partial
chunk is always last, and the per-stream hit rate drops to ~1/500. 30
solo runs on a Windows box: 1 s–40 s, max 40 s, 5 runs >20 s.
* #36175 removed the `cpuCount: 2` clamp on the linux/windows test
agents, taking the parallel-safe phase from width 1 to width 3. Running
alongside `test-fs-read-stream-fd-leak.js` (50× `createReadStream` at 2
ms) starves the 1 ms appender long enough for a later stream to be
created with `start == EOF`, producing zero `'data'` events and failing
`mustCallAtLeast` with "Mismatched function calls. Expected at least 1,
actual 0." (e.g. build 85400 debian aarch64, attempts 1–2).

Neither changes what the test is actually checking (ReadStream position
tracking while a file is being appended, nodejs/node#33940), which has
never failed here; all observed reds are the race not being reached
inside the runner's 20 s window, or the per-stream `mustCallAtLeast`
tripping under I/O contention.

### Change

In `scripts/runner.node.mjs`:

* `isParallelSafeTest` now returns `false` for this file, so it runs in
the serial phase. With no I/O neighbours the 1 ms writer keeps its
cadence and every stream sees at least one write, so the
`mustCallAtLeast` failure cannot occur.
* `getNodeParallelTestTimeout` gives it 120 s, so on the occasions the
race does not hit within 90 s the test's own safety timer fires and the
process exits 0 (Node's python runner also allows 120 s per
`test/parallel` file).

Same assertions and code path; only the runner's scheduling of this file
changes.

### Verification

* Windows x64 solo, 30 consecutive runs with the release canary: 30/30
pass, times 1 s–40 s (median ~6 s), none reached the 90 s safety timer,
none exited non-zero.
* `node scripts/runner.node.mjs --include
js/node/test/parallel/test-fs-read-stream-pos.js` on linux: file is
scheduled in the serial phase (not under "Running N parallel-safe
tests"), passes.
* `bun test test/internal/parallel-allowlist.test.ts`: 2/2 pass.

The event-loop ordering difference (Bun drains queued thread-pool
completions before yielding to due timers, Node yields to the timer
phase between poll iterations) is a pre-existing behaviour not
introduced by either of the two PRs above; changing it is out of scope
for unbreaking this test.

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

---

**no test proof** · iteration 0 · build/CI scripts only; test-proof not
applicable

<!-- robobun:evidence:end -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

setInterval() timing way off on Windows setTimeout(fn, 0) slower to schedule on Windows

2 participants