Skip to content

Bun 1.4 regression: a leaked setSystemTime() non-deterministically breaks Date.now()-based code in other test files (default runner; --isolate fixes it) #32793

Description

@RodriguesCosta

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)

  1. Run on stable Bun 1.3 → 0 failures.
  2. Add afterEach(() => setSystemTime()) to the leaking file → 0 failures on canary too.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions