Skip to content

test(cron): run cron-local-time assertions in-process instead of per-test subprocess - #35735

Open
robobun wants to merge 2 commits into
mainfrom
farm/4c2e5bd1/speed-up-cron-local-time-test
Open

robobun wants to merge 2 commits into
mainfrom
farm/4c2e5bd1/speed-up-cron-local-time-test

Conversation

@robobun

@robobun robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

What

test/js/bun/cron/cron-local-time.test.ts spawned 28 bun -e subprocesses, one per (tz, expr, from) assertion, solely to pin the process time zone. On debug+ASAN each spawn is roughly a second of startup, so the file took ~25s wall in CI (build #80083, debian-13 x64-asan) and tests that did two sequential spawns (e.g. the Lord Howe fall-back case) regularly hit the 5s default timeout.

Change

Assigning process.env.TZ at runtime updates WTF's time-zone override immediately; cron.test.ts's Bun.cron.parse suite already relies on this. So the helpers now set process.env.TZ in-process behind a small withTZ() wrapper and call Bun.cron.parse directly:

function parseInTZ(tz, expr, from, opts?) {
  return withTZ(tz, () => Bun.cron.parse(expr, new Date(from), opts)!.toISOString());
}

One subprocess is kept (TZ set in the spawned process's environment is honored at startup) to keep explicit coverage of the startup-env path that withTZ() does not exercise. The fake-timers case now uses in-process jest.useFakeTimers() the same way in-process-cron.test.ts does.

The describe blocks drop .concurrent since they now mutate global process.env.TZ; each in-process test is a few ms so sequential is still fast.

Assertion improvements

  • tz option matches the same zone set as process TZ now asserts the concrete expected ISO string per zone rather than only viaOpt === viaEnv, so a drift in both paths would be caught.
  • spring-forward: only the first match in the gap fires shifted uses a single chainInTZ + toEqual instead of two separate awaited spawns.
  • Lord Howe: 30-minute fall-back groups both sub-cases into one toEqual for a clearer diff on failure.
  • The new startup-env spawn asserts { stdout, stderr, exitCode } with toEqual rather than separate toBe calls.

No tests were deleted or skipped; the 30 original cases are all present plus the one new startup-env case (31 total).

Timing (local, debug+ASAN, ./build/debug/bun-debug test)

before after
wall time ~35s ~4-8s
result 4 pass / 26 fail (5s timeouts) 31 pass / 0 fail
subprocess spawns 28 1

The before/after variance is FS-dependent; CI's 25s maps to the same 28-spawn cost.


[stamp-90s] gate passed · iteration 2 · 1 files touched

passes on PR (with fix)
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/js/bun/cron/cron-local-time.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/bun/cron/cron-local-time.test.ts
bun test v1.4.0 (3beb38990)

test/js/bun/cron/cron-local-time.test.ts:
(pass) Bun.cron.parse — local time zone > 0 9 * * * in America/Los_Angeles is 9am Pacific (PDT = UTC-7) [279.43ms]
(pass) Bun.cron.parse — local time zone > 0 9 * * * in UTC is 9am UTC [4.68ms]
(pass) Bun.cron.parse — local time zone > 0 9 * * * in Asia/Tokyo is 9am JST (UTC+9, no DST) [3.94ms]
(pass) Bun.cron.parse — local time zone > weekday matching uses local day-of-week (0 12 * * MON across the dateline) [2.91ms]
(pass) Bun.cron.parse — local time zone > TZ set in the spawned process's environment is honored at startup [2292.89ms]
(pass) Bun.cron.parse — DST transitions > spring-forward: schedule in the missing hour fires shifted forward (same day) [5.78ms]
(pass) Bun.cron.parse — DST transitions > fall-back: schedule in the duplicated hour fires at the first occurrence [3.39ms]
(pass) Bun.cron.parse — DST transitions > fall-back: starting from the second occurrence does not return a time before from [3.88ms]
(pass) Bun.cron.parse — DST transitions > fall-back: wildcard hour fires through both occurrences (cronie semantics) [4.02ms]
(pass) Bun.cron.parse — DST transitions > fall-back: every-minute fires through both occurrences [3.92ms]
(pass) Bun.cron.parse — DST transitions > fall-back: every-minute chained from the transition walks the repeated hour [11.19ms]
(pass) Bun.cron.parse — DST transitions > fall-back: */15 chained from the transition fires at each quarter-hour [5.51ms]
(pass) Bun.cron.parse — DST transitions > spring-forward: only the first match in the gap fires shifted (croner semantics) [4.73ms]
(pass) Bun.cron.parse — DST transitions > Lord Howe: 30-minute spring-forward gap shifts by 30 min [3.89ms]
(pass) Bun.cron.parse — DST transitions > Lord Howe: 30-minute fall-back — wildcard fires through repeated half-hou
... (truncated)
Exit: 0
diff hotspot
test/js/bun/cron/cron-local-time.test.ts | 311 +++++++++++++++----------------
 1 file changed, 154 insertions(+), 157 deletions(-)

gate history · 2 passed · 1 rejected · iteration 2

evidence per changed file
file                                      reads  edits  tests
test/js/bun/cron/cron-local-time.test.ts      2      3      0

…test subprocess

The file spawned 28 bun subprocesses (one per TZ assertion) just to pin the
process time zone. On ASAN debug each spawn is ~1s of startup, so the file
took ~25s in CI and tests with two sequential spawns regularly hit the 5s
default timeout.

Assigning process.env.TZ at runtime updates WTF's time-zone override
immediately (cron.test.ts already relies on this), so the assertions can run
in-process behind a withTZ() helper. One spawn is kept to cover the
startup-env path; everything else is pure computation.

Also: the opt-vs-env equivalence test now asserts concrete expected ISO
strings per zone instead of only 'viaOpt === viaEnv', and the fake-timers
case uses in-process jest.useFakeTimers() like in-process-cron.test.ts does.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Cron time-zone tests now run mostly in-process by controlling process.env.TZ, add broader DST coverage, validate { tz } and tz: undefined behavior, and use fake timers to verify scheduled cron timestamps.

Cron local-time behavior

Layer / File(s) Summary
In-process time-zone harness
test/js/bun/cron/cron-local-time.test.ts
Adds temporary process.env.TZ handling with restoration and updates parsing helpers to accept time-zone options.
DST transition coverage
test/js/bun/cron/cron-local-time.test.ts
Expands gap, fall-back, Lord Howe, and Santiago transition assertions.
Time-zone overrides and scheduled jobs
test/js/bun/cron/cron-local-time.test.ts
Tests { tz } overrides, undefined fallback, and Bun.cron execution with fake timers.

Possibly related PRs

  • oven-sh/bun#35122: Covers related { tz?: string } and DST-aware local-time cron behavior.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes the main change: running cron-local-time tests in-process instead of spawning subprocesses.
Description check ✅ Passed The description covers the change, rationale, and verification details, even though its headings differ from the template.

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

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:08 PM PT - Jul 25th, 2026

@robobun, your commit 3beb389 is building: #81208

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

LGTM — test-only refactor that swaps 28 subprocess spawns for in-process process.env.TZ assignment, matching the pattern already used in cron.test.ts.

What was reviewed:

  • All 30 original assertions preserved with identical expected values; the one strengthened test's new hardcoded ISO strings (Auckland 21:00Z, Lord Howe 22:30Z) check out against the zones' June offsets.
  • .concurrent correctly dropped since withTZ() mutates a process global; the ?? "" restore matches cron.test.ts:1397's documented convention (empty string clears the WTF override, delete would not).
  • The remaining spawn drains pipes concurrently and asserts a combined object; the fake-timers case mirrors in-process-cron.test.ts's try/finally shape.
Extended reasoning...

Overview

This PR touches a single test file, test/js/bun/cron/cron-local-time.test.ts, converting it from spawning 28 bun -e subprocesses (one per assertion, each with a fixed TZ env var) to setting process.env.TZ in-process via a withTZ() helper and calling Bun.cron.parse directly. One subprocess is retained to keep coverage of the startup-env path. No production code is modified.

Security risks

None. Test-only change; no new inputs, no network, no filesystem writes beyond what the harness already does.

Level of scrutiny

Low-to-moderate. The concern for a test refactor like this is whether coverage was silently weakened. I traced each converted case:

  • Every original (tz, expr, from, expected) tuple is present with the same expected ISO string.
  • The spring-forward: only the first match in the gap case switched from two sequential parseInTZ calls to one chainInTZ(..., 2); the chain feeds the first result (07:00Z) as from to the second call, which is exactly what the old code did explicitly.
  • The tz option matches the same zone set as process TZ case was strengthened: it now asserts concrete expected ISO strings per zone rather than only viaOpt === viaEnv. I verified the four new constants against each zone's June UTC offset (PDT −7, JST +9, NZST +12, LHST +10:30) and they are correct.
  • describe.concurrent → describe is required now that tests mutate the process-global TZ; a race here would have been a real flake source.

Other factors

  • The process.env.TZ = old ?? "" restore idiom exactly matches cron.test.ts:1395-1397, where a comment explains that delete process.env.TZ would not clear WTF::setTimeZoneOverride and that empty-string assignment reverts to the system zone. The finder-raised concern about restoring to "" when originally unset was correctly ruled out — this is the documented, intentional convention in the sibling file.
  • The fake-timers test uses the same try { ... } finally { jest.useRealTimers() } shape as in-process-cron.test.ts:124-145, and using _job disposes the cron before real timers are restored.
  • The one remaining subprocess test follows REVIEW.md's guidance: Promise.all over stdout/stderr/exited and a single combined-object toEqual.
  • PR description reports 31 pass / 0 fail on debug+ASAN locally, down from ~35s wall to ~4-8s. No prior human or bot reviews to address.

@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 `@test/js/bun/cron/cron-local-time.test.ts`:
- Line 215: Remove the duplicate const actual declaration in the test scope,
keeping a single Record<string, { viaOpt: string; viaEnv: string }> binding for
the existing test logic.
- Around line 14-26: Update the TZ restoration logic in withTZ and the
beforeAll/afterAll hooks to preserve an unset process.env.TZ by deleting the
variable when its saved value is undefined, while assigning the saved string
value when defined. Ensure both helper calls and the test suite leave the
original environment state unchanged.
🪄 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: 2ee9d55f-c7ae-46c4-a4a9-aafa2d21cb21

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and bc4cb41.

📒 Files selected for processing (1)
  • test/js/bun/cron/cron-local-time.test.ts

Comment thread test/js/bun/cron/cron-local-time.test.ts
Comment thread test/js/bun/cron/cron-local-time.test.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.

LGTM — test-only refactor that swaps per-assertion subprocess spawns for in-process process.env.TZ assignment, matching the established pattern in cron.test.ts.

What was reviewed

  • All 30 original assertions are preserved with identical expected values; one new startup-env spawn case added, no tests deleted or weakened.
  • describe.concurrent correctly dropped now that tests mutate process-global TZ; withTZ and the fake-timers case both restore in finally.
  • The chainInTZ rewrite of the spring-forward gap test is semantically equivalent (step 2's input equals the original second call's explicit from); the new Auckland/Lord Howe expected ISO strings check out against their June UTC offsets.
Extended reasoning...

Overview

This PR touches a single test file, test/js/bun/cron/cron-local-time.test.ts, refactoring it from spawning 28 bun -e subprocesses (one per assertion, solely to pin TZ) to setting process.env.TZ in-process via a withTZ() wrapper and calling Bun.cron.parse directly. One subprocess is retained to cover the startup-env path. No production code is touched.

Security risks

None. Test-only change; no new inputs, no network, no filesystem writes beyond what the harness already does.

Level of scrutiny

Low-medium. The main risk with a test refactor like this is silently weakening or losing coverage. I compared each rewritten test against the original: every expression/from/expected triple is preserved verbatim. The one substantive assertion change — tz option matches the same zone set as process TZ — is a strict strengthening (it now asserts concrete ISO strings per zone rather than only viaOpt === viaEnv, so a shared drift would now be caught). The spring-forward: only the first match in the gap test moved from two parseInTZ calls to one chainInTZ(…, 2); since chainInTZ feeds step 1's output (2025-03-09T07:00:00.000Z) into step 2, and the original second call used exactly that instant as from, the semantics are identical.

Other factors

  • The process.env.TZ = old ?? "" restore pattern (empty string reverts to system zone; delete would strip the accessor without clearing WTF's override) is copied exactly from cron.test.ts:1392-1397, so this is the established convention, not a new invention.
  • .concurrent was correctly removed since the suites now mutate process-global state; the PR comments this explicitly.
  • jest.useFakeTimers() / useRealTimers() are wrapped in try/finally so a failure won't leak fake timers into later tests in the file.
  • I sanity-checked the two newly-hardcoded expected values (Pacific/Auckland at UTC+12 → 2026-06-15T21:00:00.000Z; Australia/Lord_Howe at UTC+10:30 → 2026-06-15T22:30:00.000Z) and both are correct for June (no DST).
  • The PR description shows a 31/31 pass on debug+ASAN. This is a straightforward, well-motivated test-speedup that follows repo conventions.

@robobun
robobun force-pushed the farm/4c2e5bd1/speed-up-cron-local-time-test branch from bc4cb41 to 3beb389 Compare July 25, 2026 18:26

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

LGTM — test-only refactor that swaps per-assertion subprocess spawns for in-process process.env.TZ mutation, following the same pattern already in cron.test.ts.

What was reviewed

  • All 30 original assertions are preserved with identical expected ISO strings; .concurrent correctly dropped since tests now mutate global process.env.TZ.
  • withTZ restores via ?? "" (not delete) — matches the documented WTF override-clear idiom at cron.test.ts:1392-1397.
  • One subprocess kept for startup-env coverage; drains stdout/stderr/exited concurrently and asserts the combined object.
  • Fake-timers case wraps useRealTimers() in finally; using _job disposes the cron handle.
Extended reasoning...

Overview

This PR touches exactly one file, test/js/bun/cron/cron-local-time.test.ts, and no production code. It replaces 28 bun -e subprocess spawns (each ~1s under debug+ASAN, causing 5s-timeout failures in CI) with direct in-process calls to Bun.cron.parse, pinning the time zone per assertion via a small withTZ() helper that assigns process.env.TZ and restores it in finally. One subprocess is retained to cover the startup-env path that in-process assignment does not exercise. The fake-timers test moves from a spawned bun -e script to in-process jest.useFakeTimers().

Security risks

None. This is a test-only change with no production code, no new dependencies, and no network/file-system side effects beyond the single retained Bun.spawn of the debug binary itself.

Level of scrutiny

Low-to-moderate. The concern with a subprocess→in-process test refactor is silently weakening coverage. I checked each converted case against the diff: every (tz, expr, from, expected) tuple is preserved verbatim; two cases (spring-forward */15 and Lord Howe fall-back) are regrouped into single toEqual calls without changing inputs or expected outputs; tz option matches the same zone set as process TZ is strengthened to assert concrete ISO strings per zone rather than only viaOpt === viaEnv. No test is deleted, skipped, or has its expected value changed.

The process.env.TZ mutation approach is the established pattern in this directory — cron.test.ts:1390-1397 uses the identical beforeAll/afterAll + ?? "" restore, with a comment explaining why delete would not clear WTF::setTimeZoneOverride. The describe.concurrent → describe change is required because the tests now share mutable global state; the PR comments this explicitly and each in-process test runs in a few ms so serialization costs nothing.

Other factors

Both CodeRabbit findings on this PR were false positives and were withdrawn (the ?? "" restore is intentional per the cron.test.ts precedent; there is only one const actual). The bug-hunting system found nothing. The PR description shows 31/31 passing on debug+ASAN. The retained subprocess test follows harness conventions: {...bunEnv, TZ: ...}, concurrent pipe drain via Promise.all, and a single toEqual({stdout, stderr, exitCode}) assertion. The fake-timers test correctly restores real timers in finally and disposes the cron job via using.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Diff is green on every lane that ran tests: cron-local-time.test.ts passes 31/31 across all platforms that completed (117 jobs passed on #81208).

CI red is unrelated build infrastructure that also fails on main (#80215 has the same pattern):

  • build-bun timed out on darwin-x64, linux aarch64-musl, freebsd x64/aarch64 across three separate runs (#80954, #81102, #81208)
  • 5 tests marked [flaky] by the CI helper, all unrelated to cron (filter-workspace, cpu-prof, tls-getpeercert-leak, http-server-connections-checking-leak, multi-run)

This PR is a single-file .test.ts change; it cannot affect compilation. Ready for review.

This branch has not been deployed

No deployments
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