How this was produced. The investigation and this write-up were done with
Claude Code — but this is not a synthetic or
AI-invented report. The bug surfaced in a real production test suite (220+ files; a real
admin-login flow that does a time-based TOTP 2FA check): ~150 tests that are green on stable
1.3.14 fail on canary 1.4. From there it was traced, reduced, and re-verified against
stable down to the small, dependency-light repository linked below, which fails on canary and
passes on stable (and with --isolate). Every number below was measured on real runs, not
guessed. Happy to provide more diagnostics or run specific builds.
What version of Bun is running?
1.4.0-canary.1+05895486d (reproduces ❌) — the new test runner.
1.3.14+0d9b296af does not reproduce ✅.
What platform is your computer?
macOS — Darwin 25.5.0, arm64 (Apple Silicon).
What steps can reproduce the bug?
Minimal repro repository: https://github.com/RodriguesCosta/bun-report-time
git clone https://github.com/RodriguesCosta/bun-report-time
cd bun-report-time
bun install
bun test # canary 1.4 -> ~18 failures
bun test --isolate # canary 1.4 -> 0 failures
# stable 1.3 -> 0 failures
The setup. When one test file calls setSystemTime(<date>) and never restores the
real clock (no setSystemTime() in an afterEach/afterAll), the mocked clock leaks into
the other test files of the same bun test run. That part is expected/known. The bug is
what happens next on canary.
The repo contains:
tests/00-leak-system-time.spec.ts — freezes the clock with setSystemTime(2024-01-09) and
intentionally never resets it. It does nothing else.
tests/login-*.spec.ts — 40 independent files, each running a tiny TOTP/2FA check via
otpauth: generate() a code → small async gap
(setImmediate + setTimeout(3)) → validate({ window: 1 }). None of them touch the clock.
A ±30s window means this can never legitimately fail.
What is the expected behavior?
0 failures, as on stable Bun 1.3.
What do you see instead?
On canary 1.4 (default runner), ~18 of 320 validations fail:
# canary 1.4.0-canary.1
312 pass
18 fail
Ran 330 tests across 41 files.
# stable 1.3.14
330 pass
0 fail
Every failure is the same assertion:
error: expect(received).not.toBeNull()
at tests/login-NN.spec.ts -> verifier.validate({ token: code, window: 1 })
The failures are deterministic in count here (18 every run) and cluster in the
latest-running files.
Three things make it disappear (all point at the same place)
- Run on stable Bun 1.3 → 0 failures.
- Add
afterEach(() => setSystemTime()) to the leaking file → 0 failures on canary too.
- Run canary with
bun test --isolate → 0 failures.
# canary, default -> 18 fail
# canary, --isolate -> 0 fail
# stable 1.3, default -> 0 fail
Since --isolate ("run each test file in a fresh global object") makes it vanish, the bug
appears to live in the default, non-isolate code path of the 1.4 runner — the leaked
setSystemTime mock state is mishandled when files share the global object.
Extra signal while narrowing it down
These may help locate it:
- Not raw crypto. HMAC-SHA256 / ECDSA-P256 / AES-256-GCM round-trips are bit-identical on
canary vs stable in a tight loop (thousands of iterations, 0 divergence).
- Not file-level parallelism. The default runner runs files strictly sequentially (verified
up to 50 files, no interleaving).
- Basic
setSystemTime semantics look fine on both: freeze holds, setSystemTime() resets,
no drift over 100s.
- Scale is required. A poison file + a handful of victims passes. Failures only appear past
~30 downstream files.
otpauth triggers it; a hand-rolled TOTP does not. Re-implementing the same TOTP with
node:crypto (createHmac + floor(Date.now()/30000)) does not reproduce. The relevant
difference seems to be that otpauth reads Date.now() internally and separately inside
generate() and validate() (counter = Math.floor(Date.now() / 1000 / period)).
- The puzzling part. In failing cases I instrumented, the generate-time and validate-time
Date.now() are identical (dt = 0, same 30s step), and calling verifier.generate() at
validate-time returns the same code as the token being checked — yet
verifier.validate({ window: 1 }) still returns null. So a valid token is rejected while,
by every external measurement, the clock is consistent. This looks like the mocked-clock state
being read inconsistently by library code under load, rather than a simple clock jump.
Possibly the same subsystem as the 1.4 --isolate / per-file global-state work (#31771, #32176,
#31316), given that --isolate is the workaround.
What version of Bun is running?
1.4.0-canary.1+05895486d(reproduces ❌) — the new test runner.1.3.14+0d9b296afdoes not reproduce ✅.What platform is your computer?
macOS — Darwin 25.5.0, arm64 (Apple Silicon).
What steps can reproduce the bug?
Minimal repro repository: https://github.com/RodriguesCosta/bun-report-time
The setup. When one test file calls
setSystemTime(<date>)and never restores thereal clock (no
setSystemTime()in anafterEach/afterAll), the mocked clock leaks intothe other test files of the same
bun testrun. That part is expected/known. The bug iswhat happens next on canary.
The repo contains:
tests/00-leak-system-time.spec.ts— freezes the clock withsetSystemTime(2024-01-09)andintentionally never resets it. It does nothing else.
tests/login-*.spec.ts— 40 independent files, each running a tiny TOTP/2FA check viaotpauth:generate()a code → small async gap(
setImmediate+setTimeout(3)) →validate({ window: 1 }). None of them touch the clock.A ±30s window means this can never legitimately fail.
What is the expected behavior?
0 failures, as on stable Bun 1.3.
What do you see instead?
On canary 1.4 (default runner), ~18 of 320 validations fail:
Every failure is the same assertion:
The failures are deterministic in count here (18 every run) and cluster in the
latest-running files.
Three things make it disappear (all point at the same place)
afterEach(() => setSystemTime())to the leaking file → 0 failures on canary too.bun test --isolate→ 0 failures.Since
--isolate("run each test file in a fresh global object") makes it vanish, the bugappears to live in the default, non-isolate code path of the 1.4 runner — the leaked
setSystemTimemock state is mishandled when files share the global object.Extra signal while narrowing it down
These may help locate it:
canary vs stable in a tight loop (thousands of iterations, 0 divergence).
up to 50 files, no interleaving).
setSystemTimesemantics look fine on both: freeze holds,setSystemTime()resets,no drift over 100s.
~30 downstream files.
otpauthtriggers it; a hand-rolled TOTP does not. Re-implementing the same TOTP withnode:crypto(createHmac+floor(Date.now()/30000)) does not reproduce. The relevantdifference seems to be that
otpauthreadsDate.now()internally and separately insidegenerate()andvalidate()(counter = Math.floor(Date.now() / 1000 / period)).Date.now()are identical (dt = 0, same 30s step), and callingverifier.generate()atvalidate-time returns the same code as the token being checked — yet
verifier.validate({ window: 1 })still returnsnull. So a valid token is rejected while,by every external measurement, the clock is consistent. This looks like the mocked-clock state
being read inconsistently by library code under load, rather than a simple clock jump.
Possibly the same subsystem as the 1.4
--isolate/ per-file global-state work (#31771, #32176,#31316), given that
--isolateis the workaround.