Skip to content

Don't trust CPUID leaf 0x15 for the TSC frequency inside a hypervisor guest - #31295

Closed
robobun wants to merge 1 commit into
mainfrom
farm/c0e995d2/hw-timer-hypervisor-tsc
Closed

Don't trust CPUID leaf 0x15 for the TSC frequency inside a hypervisor guest#31295
robobun wants to merge 1 commit into
mainfrom
farm/c0e995d2/hw-timer-hypervisor-tsc

Conversation

@robobun

@robobun robobun commented May 23, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes the TSC mis-calibration in hw_timer that skews timer deadlines inside hypervisor guests.

Report (against the shipped Zig build, which src/perf/hw_timer.zig / hw_timer.rs implement): on some GCP Cloud Run (KVM) hosts, setTimeout(500) fires at ~1400 ms — exactly 2.80× the requested delay, locked in at process start — while Date.now / performance.now / hrtime stay correct on the same instance.

Cause: read_frequency() trusts CPUID leaf 0x15 whenever invariant TSC is advertised. Inside a VM that leaf describes the host part's crystal ratio leaked through the VMM; with TSC scaling the guest's rdtsc ticks at a different rate, so now_ns() is calibrated with the wrong frequency and every deadline derived from it is off by a constant factor (2.8× on the affected hosts). Every other clock uses clock_gettime, which the guest kernel calibrates correctly — hence the observed divergence.

Fix (src/perf/hw_timer.rs, the Rust port of the module; the non-compiled .zig reference is left untouched per porting conventions):

  • Under a hypervisor (CPUID.1:ECX[31]), never use leaf 0x15. Trust only the hypervisor timing leaf 0x4000_0010 (VMware interface, also implemented by KVM), which publishes the guest TSC rate in kHz and accounts for TSC scaling — with a plausibility range check. Otherwise return 0 so now_ns() stays on the OS clock (vDSO/QPC), i.e. the pre-hw_timer behavior.
  • Bare metal keeps the existing leaf 0x15 path unchanged.
  • The decision is factored into a pure resolve_x64_tsc_frequency(X64TscCpuidInfo) so it can be exercised with synthetic CPUID values, and calibration_snapshot() exposes the chosen frequency plus a counter/OS-clock sample pair. Both are reachable from JS via a new hwTimerInternals entry in bun:internal-for-testing (wired through dispatch_js2native.rs; bun_runtime now depends on bun_perf).

Note: in the Rust port, hw_timer is not yet wired into Timespec::now / the timer tick path (timers currently read clock_gettime directly), so this fixes the module before that wiring lands rather than a live regression in this tree.

Verification

test/js/bun/perf/hw-timer-tsc-frequency.test.ts:

  • the exact failure shape from the report: hypervisor bit set + populated leaf 0x15 + no timing leaf → must resolve to 0 (OS fallback), not the leaf 0x15 value;

  • hypervisor timing leaf wins over leaf 0x15 when both are present; implausible timing-leaf values (0, 1 kHz, ~4.3 THz) are rejected;

  • bare-metal leaf 0x15 math unchanged, partially-populated leaf 0x15 still falls back;

  • end-to-end on the machine running the test: if a non-zero frequency is chosen, it must match the counter rate measured against the OS monotonic clock over 150 ms (on the affected GCP hosts the old code calibrates to ~2.8× the measured rate).

  • bun bd test test/js/bun/perf/hw-timer-tsc-frequency.test.ts with the fix: 6 pass.

  • Same command with src/ reverted to the merge base: all 6 tests fail (the hwTimerInternals binding and the hypervisor-aware decision it exposes don't exist there).

  • cargo check / cargo clippy --no-deps clean for bun_perf and bun_runtime.

  • This container is itself a KVM guest (Xeon 8375C) where leaf 0x15 is zeroed and leaf 0x4000_0010 reports 2899987 kHz: the end-to-end test exercises the new hypervisor-leaf path for real and the measured rate agrees within measurement noise.

Rebase notes (after #31783)

Rebased onto current main after #31783 ("Resolve the audited TODO(port)/TODO(b2)/PORT NOTE backlog"), which removed PORT NOTE comments, ported from trailers, and the .zig section headers this PR's hunks sat next to. Resolution: kept the same code, reworded the three PORT NOTE comments as plain why-comments (the leaf-0x15 divergence rationale and the rbx/cpuid intrinsic note), dropped the historical porting narration in os_monotonic_ns() and the file trailer, and dropped the section-header line in dispatch_js2native.rs to match the file's new style. No functional changes; all six tests pass on the rebased tree.

Rebase notes (after #31746)

Rebased again after #31746 ("Remove stale TODO(port) comments and delete dead code"), which reduced hw_timer.rs to just read_counter() — deleting the #[allow(dead_code)] cpuid/sysctlbyname helpers and their core::ffi import. This PR re-adds those helpers (now with live callers through read_frequency()), restores the cfg-gated import, and merges cleanly around the new linearFifo test bindings that landed nearby in dispatch_js2native.rs / internal-for-testing.ts. History squashed to a single commit; all six tests pass on the rebased tree.

Rebase notes (after #31254)

main merged #31254, which removed the unwired now_ns()/now_ms()/calibrate() machinery from hw_timer.rs (keeping only read_counter() and the cpuid/sysctl helpers) and switched locally-defined dispatch functions to pub(crate). This PR was rebased onto that shape rather than mechanically restoring the old file:

@robobun

robobun commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai

coderabbitai Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Implements CPUID-driven x64 TSC frequency detection (hypervisor-first, CPUID 0x15 fallback, OS-clock fallback), exposes a public resolver and calibration snapshot in Rust, adds test-only JS bindings, and includes tests that validate resolver plausibility and end-to-end calibration.

Changes

TSC Frequency Detection with Hypervisor Support

Layer / File(s) Summary
TSC frequency detection refactoring and testing APIs
src/perf/hw_timer.rs
Core refactor: require invariant TSC, detect hypervisors (CPUID 0x1 ECX[31]), read hypervisor timing leaf 0x4000_0010 when present, conditionally read CPUID 0x15, and delegate to resolve_x64_tsc_frequency. Adds X64TscCpuidInfo, resolve_x64_tsc_frequency, CalibrationSnapshot, and calibration_snapshot() for testing.
Runtime dependency and JavaScript-to-Rust bindings
src/runtime/Cargo.toml, src/runtime/dispatch_js2native.rs
Adds bun_perf workspace dependency. Implements perf_hw_timer_resolve_tsc_frequency (coerces args to u32, builds X64TscCpuidInfo, calls resolver, returns Hz) and perf_hw_timer_calibration_state (returns CalibrationSnapshot fields as JS object).
JavaScript testing export
src/js/internal-for-testing.ts
Exports hwTimerInternals with resolveTscFrequency(...) and calibrationState() that wrap the Rust test bindings and expose the calibration state shape.
Comprehensive TSC frequency validation tests
test/js/bun/perf/hw-timer-tsc-frequency.test.ts
Adds tests for hypervisor guest scenarios (HV leaf absent/invalid => OS fallback), HV leaf trusted when plausible (kHz -> Hz), implausible HV values => fallback, bare-metal CPUID 0x15 usage, and an end-to-end calibration consistency test comparing calibrated frequency to observed counter progression against OS monotonic clock within a relative tolerance.

Suggested reviewers

  • RiskyMH
  • dylan-conway
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: preventing reliance on CPUID leaf 0x15 for TSC frequency when inside a hypervisor guest, which is the core issue addressed by the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request description comprehensively covers both required sections of the template: 'What does this PR do?' and 'How did you verify your code works?'

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@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.

I didn't find any bugs, but this changes low-level TSC calibration logic with hypervisor-specific CPUID handling (leaf 0x4000_0010 trust, plausibility bounds) — worth a human look given it will eventually drive all timer deadlines.

Extended reasoning...

Overview

This PR refactors src/perf/hw_timer.rs to stop trusting CPUID leaf 0x15 for the TSC frequency when running inside a hypervisor guest, instead reading the hypervisor timing leaf 0x4000_0010 (with a [100 MHz, 10 GHz] plausibility check) or falling back to the OS clock. The decision is factored into a pure resolve_x64_tsc_frequency(X64TscCpuidInfo) function, and a new calibration_snapshot() plus hwTimerInternals JS bindings (wired via dispatch_js2native.rs, with bun_runtime gaining a bun_perf dep) expose it to a new test file. Six files changed.

Security risks

None. No untrusted input handling, auth, crypto, or network surface — just CPUID reads and arithmetic on values the CPU/VMM hands back.

Level of scrutiny

Medium-high. Although the PR notes hw_timer isn't yet wired into the live timer path in the Rust port, this code is intended to drive every now_ns()-derived deadline once it lands. The correctness of the hypervisor heuristic (only trusting 0x4000_0010, ignoring leaf 0x15 entirely under the HV bit, the chosen plausibility bounds) is a design decision with platform-wide implications across KVM/VMware/Hyper-V/Xen guests, and benefits from review by someone with domain context on how those VMMs populate these leaves.

Other factors

  • Bare-metal behavior is preserved (leaf 0x15 path unchanged), and the fallback is conservative (OS clock), so the risk profile is "may be slower in some VMs" rather than "may mis-calibrate worse."
  • Test coverage is good: synthetic CPUID cases plus an end-to-end measured-vs-calibrated check with a 20% tolerance over 150 ms.
  • CI (Build #57383) is currently reporting failures on the latest commit.
  • The new bun_perf dependency on bun_runtime and the codegen-mangled binding names in dispatch_js2native.rs follow existing patterns in that file.

@robobun
robobun force-pushed the farm/c0e995d2/hw-timer-hypervisor-tsc branch from 0d075d1 to 39e01cf Compare May 24, 2026 20:25
Comment thread src/perf/hw_timer.rs Outdated

@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.

Thanks for dropping the unsafe wrapper — the Windows path looks correct now. I didn't find any further issues, but the hypervisor-vs-leaf-0x15 decision logic and the multi-platform #[cfg] branches in hw_timer.rs are substantive enough that a human should sign off on the design.

Extended reasoning...

Overview

This PR reworks TSC frequency detection in src/perf/hw_timer.rs (~190 net new lines): it gates on invariant TSC, detects the hypervisor bit (CPUID.1:ECX[31]), reads the VMware/KVM timing leaf 0x4000_0010 when present, and refuses to trust leaf 0x15 inside a guest. The decision is factored into a pure resolve_x64_tsc_frequency(X64TscCpuidInfo) plus a calibration_snapshot() helper, both exposed to JS via two new pub(crate) bindings in src/runtime/dispatch_js2native.rs and hwTimerInternals in internal-for-testing.ts. bun_runtime gains a new workspace dep on bun_perf, and a 6-case test file exercises synthetic CPUID combinations plus an end-to-end calibration check on the host machine.

Prior feedback

My only earlier finding (the unused_unsafe lint on the Windows QPC/QPF block, which would have failed the x86_64-pc-windows-msvc build under -D warnings) was fixed in 37e5b1f — the current os_monotonic_ns() calls bun_sys::windows::QueryPerformanceCounter/Frequency directly with an explanatory comment. The bug-hunting pass on this revision found nothing new.

Security risks

None identified. The new code reads CPUID leaves, rdtsc/CNTVCT_EL0, clock_gettime, and QPC — all read-only, side-effect-free, and not influenced by attacker-controlled input. The JS bindings are gated behind bun:internal-for-testing and only marshal numbers/booleans into a struct.

Level of scrutiny

This is low-level, performance-adjacent timing infrastructure with per-arch and per-OS #[cfg] branches (aarch64, x86_64 × macOS/FreeBSD/Linux/Windows), inline asm, and FFI. While hw_timer is not yet wired into the live timer tick path in this tree (per the PR description), it is intended to be, and the core change is a policy decision — never trust leaf 0x15 under a hypervisor, prefer leaf 0x4000_0010 with a [100 MHz, 10 GHz] plausibility window, otherwise fall back to the OS clock. That's a reasonable design and well-argued in the description, but it's the kind of judgment call (along with the new bun_runtime → bun_perf dep edge and the PORT NOTE: diverges from the Zig spec annotation) that a maintainer should ratify rather than a bot.

Other factors

Test coverage is good: the synthetic-CPUID tests pin down each branch of resolve_x64_tsc_frequency, and the end-to-end test compares the chosen frequency against a measured rate over 150 ms with a 20% tolerance (vs. the 2.8× failure mode). The dispatch bindings follow the existing pub(crate) convention from #31254 and the bun_get_use_system_ca pattern in the same file.

Comment thread src/perf/hw_timer.rs
Comment thread src/perf/hw_timer.rs

@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.

Thanks for addressing the cross-target lint issues — all three prior comments are resolved and I found nothing new. Deferring to a human for the substantive bits: the hypervisor clock-source trust model and the platform-gated FFI/asm paths are the kind of timing-infrastructure decision worth a second pair of eyes.

Extended reasoning...

Overview

This PR reworks TSC frequency detection in src/perf/hw_timer.rs so that CPUID leaf 0x15 is no longer trusted inside a hypervisor guest (where it can describe the host crystal rather than the guest's scaled rdtsc rate, causing a ~2.8× timer skew on some GCP/KVM hosts). It adds a pure resolve_x64_tsc_frequency() decision function plus a calibration_snapshot() helper, wires both through new bun:internal-for-testing bindings in dispatch_js2native.rs (adding a bun_runtimebun_perf dependency), and ships a 6-case test file. Roughly 250 lines of new Rust across many #[cfg(...)] branches (aarch64/x86_64 × Linux/Windows/macOS/FreeBSD) including inline asm, cpuid, sysctlbyname, clock_gettime, and QPC.

Security risks

None apparent. The new surface reads CPUID/clock registers and exposes results only via bun:internal-for-testing, which is CI/debug-gated. No user-controlled input reaches the FFI paths.

Level of scrutiny

Moderate-to-high. The module is not yet wired into the live timer tick path in this tree (per the PR description), so immediate blast radius is limited to the test bindings. But this is timing infrastructure that will drive setTimeout deadlines once wired, and the core change is a policy decision — which CPUID sources to trust under a hypervisor, what plausibility bounds to apply, and when to fall back to the OS clock. That, plus the density of platform-gated unsafe FFI (which already needed two follow-up commits for cross-target lints that local checks missed), makes this worth a human reviewer rather than a bot approval.

Other factors

  • All three of my prior inline comments (Windows unused_unsafe, macOS/FreeBSD borrow_as_ptr, stale now_ns() doc reference) were addressed in 37e5b1f and c991b41; the current diff reflects those fixes.
  • The bug-hunting system found no new issues on the latest revision.
  • Good test coverage: the decision function is exercised with synthetic CPUID values for the exact failure shape plus edge cases, and an end-to-end check compares the chosen frequency against the measured counter rate on the CI host.

@robobun

robobun commented May 24, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for the latest commit (c991b41, Buildkite build #57730): every build lane and every test lane that ran is green — including the Windows build-rust lanes and all Linux/Alpine/FreeBSD/macOS-x64 test suites. The only two red entries, darwin-14-aarch64-test-bun and darwin-26-aarch64-test-bun, show "Expired"/errored: the jobs never got a macOS aarch64 agent (build stats report jobs_failed_count: 0), and both of those lanes passed this PR's test suite on earlier runs of the same diff (e.g. build #57385).

So the remaining red is runner availability, not a code failure. From my side the PR is complete: fix, tests, and review feedback (Windows unused_unsafe, macOS/FreeBSD borrow_as_ptr, doc wording) are all addressed, with zero unresolved review threads. A retry of the two expired macOS jobs (or a maintainer review/merge) is all that's left.

@robobun
robobun force-pushed the farm/c0e995d2/hw-timer-hypervisor-tsc branch 2 times, most recently from 4e3cf8e to 05d1d99 Compare June 4, 2026 03:35
@robobun

robobun commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

Update on the latest CI run (build #60251, commit 05d1d99): the red test lanes all trace to one file — test/cli/install/bunx.test.ts, failing with the same 4 network-dependent tests on every platform ("should work for github repository", "…with committish", "should set npm_config_user_agent to bun", "should handle package that requires node 24").

Verified locally that this is not from this PR: the identical 4 tests fail the same way on plain main at this PR's merge base (a3464c6) — same pass/fail/skip counts (29/4/1) on both trees. This PR touches src/perf/hw_timer.rs and test-only internal bindings; nothing in the install/bunx path. The PR's own test file passes on every lane.

So the bunx failures are main/environment-borne (those tests hit GitHub/the registry). Once that's fixed on main this PR can rebase or merge as-is at maintainer discretion — everything specific to this diff is green.

@robobun
robobun force-pushed the farm/c0e995d2/hw-timer-hypervisor-tsc branch from 05d1d99 to 4766495 Compare June 6, 2026 15:43
Comment thread src/runtime/dispatch_js2native.rs Outdated
… guest

Inside a VM the TSC the guest reads may be scaled or emulated, so leaf 0x15
(which describes the host part's crystal) need not match the rate rdtsc
actually ticks at; on some GCP/KVM hosts it is off by ~2.8x, which skews every
deadline derived from a clock calibrated with it. Under a hypervisor, only
trust the timing leaf 0x4000_0010 the hypervisor itself publishes (guest TSC
kHz, TSC-scaling aware); otherwise fall back to the OS clock. Bare metal keeps
the leaf 0x15 path.

hw_timer gets the frequency-resolution layer back in its corrected form
(read_frequency + a pure resolve_x64_tsc_frequency + calibration_snapshot);
the unwired now_ns/now_ms/calibrate machinery stays out. The decision logic
and a counter/OS-clock snapshot are exposed through bun:internal-for-testing
(hwTimerInternals) so tests can cover the hypervisor/bare-metal combinations
and verify the chosen frequency tracks the OS monotonic clock on the machine
running the test.
@robobun
robobun force-pushed the farm/c0e995d2/hw-timer-hypervisor-tsc branch from 4766495 to cc55cd9 Compare June 6, 2026 16:05
@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: main has removed the code this PR was fixing.

Rebasing would mean re-creating a deleted module purely to host test bindings for logic with no production caller, so there is nothing left to land here. If a calibrated-TSC fast path is ever reintroduced, the hypervisor handling from this PR (never trust leaf 0x15 under CPUID.1:ECX[31]; use the hypervisor timing leaf 0x4000_0010 or fall back to the OS clock) is the piece it needs, and this branch remains as a reference.

@robobun robobun closed this Aug 16, 2026
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.

1 participant