From 9318a02b106b0f988bcaad602499b8955cad983f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:40:49 +0000 Subject: [PATCH 01/11] cron: interpret Bun.cron.parse() and in-process schedules in local time Bun.cron.parse() and the in-process Bun.cron(schedule, handler) now interpret cron expressions in the system's local time zone instead of UTC, matching the OS-level Bun.cron(path, schedule, title) overload (crontab/launchd/schtasks are all local-time). Reinstates the DST handling that was dropped when the parser switched to UTC: spring-forward times shift forward by the gap; in the fall-back duplicated hour, fixed-time schedules fire once (first occurrence) while schedules with a wildcard minute or hour fire through both occurrences (cronie semantics). Part of #28792. --- docs/runtime/cron.mdx | 15 +- packages/bun-types/bun.d.ts | 15 +- src/jsc/JSGlobalObject.rs | 46 ++++++ src/runtime/api/cron_parser.rs | 110 ++++++++++---- test/js/bun/cron/cron-local-time.test.ts | 178 +++++++++++++++++++++++ test/js/bun/cron/cron-parse.test.ts | 122 +++++++++++----- 6 files changed, 410 insertions(+), 76 deletions(-) create mode 100644 test/js/bun/cron/cron-local-time.test.ts diff --git a/docs/runtime/cron.mdx b/docs/runtime/cron.mdx index 4f16bab4c486..9ef8f086c65b 100644 --- a/docs/runtime/cron.mdx +++ b/docs/runtime/cron.mdx @@ -18,7 +18,7 @@ Bun.cron("0 * * * *", async () => { **Parse a cron expression to find the next matching time:** ```ts -// Next weekday at 9:30 AM UTC +// Next weekday at 9:30 AM local time const next = Bun.cron.parse("30 9 * * MON-FRI"); ``` @@ -32,7 +32,7 @@ await Bun.cron("./worker.ts", "30 2 * * MON", "weekly-report"); ## `Bun.cron.parse()` -Parse a cron expression and return the next matching `Date` in UTC. +Parse a cron expression and return the next matching `Date` in the system's local time zone. ```ts const next = Bun.cron.parse("*/15 * * * *"); @@ -113,14 +113,19 @@ Both `0` and `7` mean Sunday in the weekday field. ```ts const next = Bun.cron.parse("@daily"); -console.log(next); // => next UTC midnight +console.log(next); // => next local midnight ``` ### Time zone -`Bun.cron.parse()` and the in-process `Bun.cron(schedule, handler)` interpret schedules in **UTC**. There is no DST to handle — `0 9 * * *` always means 9:00 UTC. +Schedules are interpreted in the system's **local time zone** — the same way crontab, launchd, and Windows Task Scheduler read them. The OS-level form and the in-process callback form fire at the same wall-clock time. -The OS-level `Bun.cron(path, schedule, title)` uses the **system's local time zone**, because that's how crontab, launchd, and Windows Task Scheduler work. To make the two forms agree, run the process with `TZ=UTC`. +To use UTC, set `TZ=UTC` in the environment. + +DST transitions: + +- **Spring-forward** — a schedule that lands in the missing hour fires that day, shifted forward by the gap (e.g. `30 2 * * *` runs at 3:30 on the spring-forward day). For multi-minute patterns inside the gap (`*/15 2 * * *`), only the first match fires. +- **Fall-back** — a fixed-time schedule in the duplicated hour (`30 1 * * *`) fires once, at the first occurrence. A schedule whose minute or hour field is `*` (`0 * * * *`, `* * * * *`) fires through **both** occurrences — once per real-time minute, matching crontab on Linux. ### Day-of-month and day-of-week interaction diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index e7737063f9eb..3b53b0c96ac7 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -7647,8 +7647,7 @@ declare module "bun" { * * ### Cron expression syntax * - * Five fields: `minute hour day-of-month month day-of-week`. Schedules are - * interpreted in **UTC** — `0 9 * * *` fires at 9:00 UTC, regardless of `TZ`. + * Five fields: `minute hour day-of-month month day-of-week`. * * | Field | Values | Special chars | * |-------|--------|---------------| @@ -7802,7 +7801,9 @@ declare module "bun" { */ remove(title: string): Promise; /** - * Parse a cron expression and return the next matching `Date` in UTC. + * Parse a cron expression and return the next matching `Date` in the + * system's local time zone — the same way crontab, launchd, and Windows + * Task Scheduler interpret schedules. Set `TZ=UTC` to use UTC. * * Supports the same syntax as {@link Bun.cron} — 5-field expressions, named * days/months, and predefined nicknames like `@daily`. @@ -7811,14 +7812,18 @@ declare module "bun" { * matching uses OR logic per [POSIX cron](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/crontab.html): * a date matches if **either** field matches. * + * DST: spring-forward times shift forward by the gap; in the fall-back + * duplicated hour, fixed-time schedules fire once (first occurrence) while + * schedules with `*` minute or hour fire through both occurrences. + * * @param expression - A cron expression or nickname (e.g. `"0,15,30,45 * * * *"`, `"0 9 * * MON-FRI"`, `"@hourly"`) * @param relativeDate - Starting point for the search (defaults to `Date.now()`). Accepts a `Date` or milliseconds since epoch. - * @returns The next `Date` matching the expression in UTC, or `null` if no match exists within 8 years (e.g. `"0 0 30 2 *"` — Feb 30 never occurs) + * @returns The next `Date` matching the expression in local time, or `null` if no match exists within 8 years (e.g. `"0 0 30 2 *"` — Feb 30 never occurs) * @throws If the expression is invalid or `relativeDate` is `NaN`/`Infinity` * * @example * ```ts - * // Next weekday at 09:30 UTC + * // Next weekday at 09:30 local time * const next = Bun.cron.parse("30 9 * * MON-FRI"); * * // Chain calls to get a sequence diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index d80cdb520570..d17078a4dcdf 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -145,6 +145,30 @@ impl JSGlobalObject { ) } + pub fn gregorian_date_time_to_ms( + &self, + year: i32, + month: i32, + day: i32, + hour: i32, + minute: i32, + second: i32, + millisecond: i32, + ) -> JsResult { + crate::mark_binding(); + crate::cpp::Bun__gregorianDateTimeToMS( + self, + year, + month, + day, + hour, + minute, + second, + millisecond, + true, + ) + } + pub fn ms_to_gregorian_date_time_utc(&self, ms: f64) -> GregorianDateTime { crate::mark_binding(); let mut dt = GregorianDateTime::default(); @@ -167,6 +191,28 @@ impl JSGlobalObject { dt } + pub fn ms_to_gregorian_date_time(&self, ms: f64) -> GregorianDateTime { + crate::mark_binding(); + let mut dt = GregorianDateTime::default(); + // SAFETY: FFI — &self is a valid JSGlobalObject*; out-param pointers are to live + // stack locals (`dt` fields) and remain valid for the duration of the call. + unsafe { + crate::cpp::raw::Bun__msToGregorianDateTime( + self.as_ptr(), + ms, + true, + &raw mut dt.year, + &raw mut dt.month, + &raw mut dt.day, + &raw mut dt.hour, + &raw mut dt.minute, + &raw mut dt.second, + &raw mut dt.weekday, + ); + } + dt + } + pub fn throw_todo(&self, msg: &[u8]) -> JsError { let err = self.create_error_instance(format_args!("{}", bstr::BStr::new(msg))); if err.is_empty() { diff --git a/src/runtime/api/cron_parser.rs b/src/runtime/api/cron_parser.rs index d66e6a671df6..718d71ff3095 100644 --- a/src/runtime/api/cron_parser.rs +++ b/src/runtime/api/cron_parser.rs @@ -1,7 +1,7 @@ //! Cron expression parser and next-occurrence calculator. //! //! Parses standard 5-field cron expressions (minute hour day month weekday) -//! into a bitset representation, and computes the next matching UTC time. +//! into a bitset representation, and computes the next matching local time. //! //! Supports: //! - Wildcards: * @@ -14,7 +14,7 @@ //! - Nicknames: @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly use bun_core::strings; -use bun_jsc::{JSGlobalObject, JsResult}; +use bun_jsc::{GregorianDateTime, JSGlobalObject, JsResult}; #[derive(Clone, Copy)] pub struct CronExpression { @@ -112,26 +112,88 @@ impl CronExpression { &buf[..written] } - /// Compute the next UTC time (in ms since epoch) that matches this - /// expression, strictly after `from_ms`. Returns None if no match found - /// within 8 years. + /// POSIX cron: if both DOM and DOW are restricted (not `*`), match either; + /// otherwise match both (a `*` field matches all anyway). + fn matches_day(&self, day: i32, weekday: i32) -> bool { + let day_ok = bit_set(self.days, u32::try_from(day).expect("int cast")); + let weekday_ok = bit_set(self.weekdays, u32::try_from(weekday).expect("int cast")); + if !self.days_is_wildcard && !self.weekdays_is_wildcard { + day_ok || weekday_ok + } else { + day_ok && weekday_ok + } + } + + /// Check if a real instant matches all five fields in local time. + fn matches_instant(&self, global_object: &JSGlobalObject, ms: f64) -> bool { + let t = global_object.ms_to_gregorian_date_time(ms); + bit_set(self.minutes, u32::try_from(t.minute).expect("int cast")) + && bit_set(self.hours, u32::try_from(t.hour).expect("int cast")) + && bit_set(self.months, u32::try_from(t.month).expect("int cast")) + && self.matches_day(t.day, t.weekday) + } + + /// Convert a matching wall-clock `dt` to a real instant strictly after + /// `from_ms`, handling DST. Returns None if no such instant exists for + /// this wall-clock minute (fall-back FORMER already passed and the + /// schedule is fixed-time, so it fires once — cronie semantics). + fn resolve_local_match( + &self, + global_object: &JSGlobalObject, + dt: GregorianDateTime, + from_ms: f64, + ) -> JsResult> { + let result = + global_object.gregorian_date_time_to_ms(dt.year, dt.month, dt.day, dt.hour, dt.minute, 0, 0)?; + // During fall-back, `result` is the FORMER occurrence and the wall-clock + // walk steps over the second one. For schedules with `*` minute or `*` + // hour, scan real-time minutes (capped at the largest DST shift) for an + // earlier match in the repeated window. + if self.minutes == ALL_MINUTES || self.hours == ALL_HOURS { + let mut probe = ((from_ms / MINUTE_MS).floor() + 1.0) * MINUTE_MS; + let cap = result.min(from_ms + (MAX_DST_SHIFT_MIN + 1.0) * MINUTE_MS); + while probe < cap { + if self.matches_instant(global_object, probe) { + return Ok(Some(probe)); + } + probe += MINUTE_MS; + } + } + Ok(if result > from_ms { Some(result) } else { None }) + } + + /// Compute the next time (in ms since epoch) that matches this expression + /// in the system's local time zone, strictly after `from_ms`. Returns None + /// if no match found within 8 years. pub(crate) fn next( &self, global_object: &JSGlobalObject, from_ms: f64, ) -> JsResult> { - let mut dt = global_object.ms_to_gregorian_date_time_utc(from_ms); + let mut dt = global_object.ms_to_gregorian_date_time(from_ms); let start_year = dt.year; dt.minute += 1; - dt.second = 0; while dt.year - start_year <= 8 { - // Normalize overflow + recompute weekday via a UTC round-trip. - dt = global_object.ms_to_gregorian_date_time_utc( - global_object.gregorian_date_time_to_ms_utc( - dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, 0, - )?, + // Carry hour/minute manually so the candidate {hour,minute} is + // checked against the bitfields *before* DST shifts it; normalize + // the date+weekday via a noon round-trip (DST at midday stays on + // the same calendar day, so only date fields are copied back). + if dt.minute > 59 { + dt.minute -= 60; + dt.hour += 1; + } + if dt.hour > 23 { + dt.hour -= 24; + dt.day += 1; + } + let n = global_object.ms_to_gregorian_date_time( + global_object.gregorian_date_time_to_ms(dt.year, dt.month, dt.day, 12, 0, 0, 0)?, ); + dt.year = n.year; + dt.month = n.month; + dt.day = n.day; + dt.weekday = n.weekday; if !bit_set(self.months, u32::try_from(dt.month).expect("int cast")) { dt.month += 1; @@ -140,16 +202,7 @@ impl CronExpression { dt.minute = 0; continue; } - // POSIX: if both DOM and DOW are restricted (not `*`), either - // matching is enough; otherwise the `*` field matches all anyway. - let day_ok = bit_set(self.days, u32::try_from(dt.day).expect("int cast")); - let weekday_ok = bit_set(self.weekdays, u32::try_from(dt.weekday).expect("int cast")); - let day_match = if !self.days_is_wildcard && !self.weekdays_is_wildcard { - day_ok || weekday_ok - } else { - day_ok && weekday_ok - }; - if !day_match { + if !self.matches_day(dt.day, dt.weekday) { dt.day += 1; dt.hour = 0; dt.minute = 0; @@ -165,9 +218,10 @@ impl CronExpression { continue; } - return Ok(Some(global_object.gregorian_date_time_to_ms_utc( - dt.year, dt.month, dt.day, dt.hour, dt.minute, 0, 0, - )?)); + if let Some(r) = self.resolve_local_match(global_object, dt, from_ms)? { + return Ok(Some(r)); + } + dt.minute += 1; } Ok(None) } @@ -177,7 +231,11 @@ impl CronExpression { // Name lookup tables // ============================================================================ -const ALL_HOURS: u32 = (1 << 24) - 1; +const MINUTE_MS: f64 = 60_000.0; +const MAX_DST_SHIFT_MIN: f64 = 120.0; + +pub(crate) const ALL_MINUTES: u64 = (1 << 60) - 1; +pub(crate) const ALL_HOURS: u32 = (1 << 24) - 1; pub(crate) const ALL_DAYS: u32 = ((1u64 << 32) - 1) as u32 & !1u32; pub(crate) const ALL_MONTHS: u16 = ((1u32 << 13) - 1) as u16 & !1u16; pub(crate) const ALL_WEEKDAYS: u8 = (1 << 7) - 1; diff --git a/test/js/bun/cron/cron-local-time.test.ts b/test/js/bun/cron/cron-local-time.test.ts new file mode 100644 index 000000000000..3b5af3edf4a1 --- /dev/null +++ b/test/js/bun/cron/cron-local-time.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +// Bun.cron.parse() and the in-process Bun.cron(schedule, handler) interpret +// cron expressions in the system's local time zone — matching the OS-level +// overload (crontab/launchd/schtasks all use local time). +// +// Each test spawns a subprocess with a fixed TZ so the assertions are +// independent of the host's zone. + +async function parseInTZ(tz: string, expr: string, fromISO: string): Promise { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `process.stdout.write(Bun.cron.parse(${JSON.stringify(expr)}, new Date(${JSON.stringify(fromISO)})).toISOString())`, + ], + env: { ...bunEnv, TZ: tz }, + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(exitCode).toBe(0); + return stdout; +} + +async function chainInTZ(tz: string, expr: string, fromISO: string, steps: number): Promise { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `let t = new Date(${JSON.stringify(fromISO)}); + const seq = []; + for (let i = 0; i < ${steps}; i++) { t = Bun.cron.parse(${JSON.stringify(expr)}, t); seq.push(t.toISOString()); } + process.stdout.write(JSON.stringify(seq))`, + ], + env: { ...bunEnv, TZ: tz }, + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(exitCode).toBe(0); + return JSON.parse(stdout); +} + +describe.concurrent("Bun.cron.parse — local time zone", () => { + test("0 9 * * * in America/Los_Angeles is 9am Pacific (PDT = UTC-7)", async () => { + const next = await parseInTZ("America/Los_Angeles", "0 9 * * *", "2026-06-15T00:00:00Z"); + // 2026-06-15 00:00 UTC = 2026-06-14 17:00 PDT; next 9am PDT = 2026-06-15 09:00 PDT = 16:00 UTC + expect(next).toBe("2026-06-15T16:00:00.000Z"); + }); + + test("0 9 * * * in UTC is 9am UTC", async () => { + const next = await parseInTZ("UTC", "0 9 * * *", "2026-06-15T00:00:00Z"); + expect(next).toBe("2026-06-15T09:00:00.000Z"); + }); + + test("0 9 * * * in Asia/Tokyo is 9am JST (UTC+9, no DST)", async () => { + const next = await parseInTZ("Asia/Tokyo", "0 9 * * *", "2026-06-15T00:00:00Z"); + // 2026-06-15 00:00 UTC = 2026-06-15 09:00 JST → already at 09:00 but parse() returns the + // NEXT occurrence strictly after, so next is 2026-06-16 09:00 JST = 2026-06-16 00:00 UTC + expect(next).toBe("2026-06-16T00:00:00.000Z"); + }); + + test("weekday matching uses local day-of-week (0 12 * * MON across the dateline)", async () => { + // Pacific/Auckland is UTC+12 (NZST in June). 2026-06-15 is a Monday in NZST. + // 12:00 NZST = 00:00 UTC same date. + const next = await parseInTZ("Pacific/Auckland", "0 12 * * MON", "2026-06-14T23:00:00Z"); + // 2026-06-14 23:00 UTC = 2026-06-15 11:00 NZST (Mon); next Mon 12:00 NZST = 2026-06-15 00:00 UTC + expect(next).toBe("2026-06-15T00:00:00.000Z"); + }); +}); + +describe.concurrent("Bun.cron.parse — weekday 7 = Sunday in ranges", () => { + // 2026-01-01 is a Thursday. next() is strictly-after, so the first match for an + // every-day schedule is 2026-01-02. + test("1-7 means Mon-Sun (every day)", async () => { + expect(await parseInTZ("UTC", "0 0 * * 1-7", "2026-01-01T00:00:00Z")).toBe("2026-01-02T00:00:00.000Z"); + }); + test("5-7 means Fri-Sun", async () => { + expect(await parseInTZ("UTC", "0 0 * * 5-7", "2026-01-01T00:00:00Z")).toBe("2026-01-02T00:00:00.000Z"); + }); + test("6-7 means Sat-Sun", async () => { + expect(await parseInTZ("UTC", "0 0 * * 6-7", "2026-01-01T00:00:00Z")).toBe("2026-01-03T00:00:00.000Z"); + }); + test("0-7 means every day", async () => { + expect(await parseInTZ("UTC", "0 0 * * 0-7", "2026-01-01T00:00:00Z")).toBe("2026-01-02T00:00:00.000Z"); + }); + test("scalar 7 still means Sunday", async () => { + expect(await parseInTZ("UTC", "0 0 * * 7", "2026-01-01T00:00:00Z")).toBe("2026-01-04T00:00:00.000Z"); + }); +}); + +describe.concurrent("Bun.cron.parse — DST transitions", () => { + test("spring-forward: schedule in the missing hour fires shifted forward (same day)", async () => { + // US 2025 spring-forward: 2025-03-09 02:00 EST → 03:00 EDT (2:00-2:59 don't exist). + // "30 2 * * *" fires at 03:30 EDT — the gap-shifted instant. Matches croner and cron-parser. + const next = await parseInTZ("America/New_York", "30 2 * * *", "2025-03-09T05:00:00Z"); // = 00:00 EST + expect(next).toBe("2025-03-09T07:30:00.000Z"); // 03:30 EDT + }); + + test("fall-back: schedule in the duplicated hour fires at the first occurrence", async () => { + // US 2025 fall-back: 2025-11-02 02:00 EDT → 01:00 EST (1:00-1:59 occurs twice). + // Starting from 00:30 EDT (= 04:30 UTC), next "30 1 * * *" is the first 01:30 (EDT) = 05:30 UTC. + const next = await parseInTZ("America/New_York", "30 1 * * *", "2025-11-02T04:30:00Z"); + expect(next).toBe("2025-11-02T05:30:00.000Z"); + }); + + test("fall-back: starting from the second occurrence does not return a time before from", async () => { + // 06:30 UTC = 01:30 EST (the SECOND 01:30). next() must not return the first 01:30 (05:30 UTC). + const next = await parseInTZ("America/New_York", "30 1 * * *", "2025-11-02T06:30:00Z"); + // Next valid 01:30 is the following day (EST): 2025-11-03 01:30 EST = 06:30 UTC. + expect(next).toBe("2025-11-03T06:30:00.000Z"); + }); + + test("fall-back: wildcard hour fires through both occurrences (cronie semantics)", async () => { + // After the first 1:00 (05:00Z), next() returns the SECOND 1:00 (06:00Z). + // Matches cronie/Vixie and cron-parser. Fixed-time schedules (30 1 * * *) + // still fire once — only `*` minute or `*` hour schedules run through. + const next = await parseInTZ("America/New_York", "0 * * * *", "2025-11-02T05:00:01Z"); + expect(next).toBe("2025-11-02T06:00:00.000Z"); + }); + + test("fall-back: every-minute fires through both occurrences", async () => { + const next = await parseInTZ("America/New_York", "* * * * *", "2025-11-02T05:59:01Z"); + expect(next).toBe("2025-11-02T06:00:00.000Z"); + }); + + test("spring-forward: only the first match in the gap fires shifted (croner semantics)", async () => { + // "*/15 2 * * *" has 4 occurrences in the missing hour. Bun fires the first + // shifted to 3:00, then skips to next day. cron-parser shifts all four. + const first = await parseInTZ("America/New_York", "*/15 2 * * *", "2025-03-09T06:59:00Z"); + expect(first).toBe("2025-03-09T07:00:00.000Z"); // 03:00 EDT + const second = await parseInTZ("America/New_York", "*/15 2 * * *", "2025-03-09T07:00:00Z"); + expect(second).toBe("2025-03-10T06:00:00.000Z"); // next day 02:00 EDT + }); + + test("Lord Howe: 30-minute spring-forward gap shifts by 30 min", async () => { + // Australia/Lord_Howe 2025-10-05 02:00→02:30. "15 2 * * *" → 02:45 LHDT. + const next = await parseInTZ("Australia/Lord_Howe", "15 2 * * *", "2025-10-04T14:30:00Z"); + expect(next).toBe("2025-10-04T15:45:00.000Z"); + }); + + test("Lord Howe: 30-minute fall-back — wildcard fires through repeated half-hour", async () => { + // 2025-04-06 02:00 LHDT (+11) → 01:30 LHST (+10:30); 1:30-1:59 repeats. + // After first 1:59 (14:59Z), every-minute → second 1:30 (15:00Z). + const a = await parseInTZ("Australia/Lord_Howe", "* * * * *", "2025-04-05T14:59:01Z"); + expect(a).toBe("2025-04-05T15:00:00.000Z"); + // After first 1:45, "45 *" → second 1:45. + const b = await parseInTZ("Australia/Lord_Howe", "45 * * * *", "2025-04-05T14:45:01Z"); + expect(b).toBe("2025-04-05T15:15:00.000Z"); + }); + + test("Lord Howe: 30-minute fall-back — fixed-time fires once", async () => { + // After first 1:45, "45 1" (fixed) → next day, not the second 1:45. + const next = await parseInTZ("Australia/Lord_Howe", "45 1 * * *", "2025-04-05T14:45:01Z"); + expect(next).toBe("2025-04-06T15:15:00.000Z"); + }); + + test("fall-back: hourly chain walks 0→1→1→2 (both occurrences)", async () => { + expect(await chainInTZ("America/New_York", "0 * * * *", "2025-11-02T03:59:00Z", 4)).toEqual([ + "2025-11-02T04:00:00.000Z", // 0:00 EDT + "2025-11-02T05:00:00.000Z", // 1st 1:00 EDT + "2025-11-02T06:00:00.000Z", // 2nd 1:00 EST + "2025-11-02T07:00:00.000Z", // 2:00 EST + ]); + }); + + test("spring-forward: hourly chain walks 1→3→4 (no double-fire at 3)", async () => { + expect(await chainInTZ("America/New_York", "0 * * * *", "2025-03-09T05:59:00Z", 3)).toEqual([ + "2025-03-09T06:00:00.000Z", // 1:00 EST + "2025-03-09T07:00:00.000Z", // 3:00 EDT (2:00 doesn't exist) + "2025-03-09T08:00:00.000Z", // 4:00 EDT + ]); + }); + + test("Santiago: midnight spring-forward gap shifts to 01:00 same day", async () => { + // America/Santiago 2025-09-07 00:00→01:00. "0 0 * * *" → 01:00 CLST. + const next = await parseInTZ("America/Santiago", "0 0 * * *", "2025-09-06T23:00:00-04:00"); + expect(next).toBe("2025-09-07T04:00:00.000Z"); + }); +}); diff --git a/test/js/bun/cron/cron-parse.test.ts b/test/js/bun/cron/cron-parse.test.ts index 226f615b830d..c09636cb23f3 100644 --- a/test/js/bun/cron/cron-parse.test.ts +++ b/test/js/bun/cron/cron-parse.test.ts @@ -1,16 +1,34 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, isDebug } from "harness"; // Bun.cron.parse() and the in-process Bun.cron(schedule, handler) interpret -// schedules in UTC. The OS-level Bun.cron(path, schedule, title) overload -// uses the system's local time zone (crontab/launchd/schtasks all do). +// schedules in the system's local time zone — matching the OS-level +// Bun.cron(path, schedule, title) overload (crontab/launchd/schtasks). The +// algorithm tests below spawn under TZ=UTC so the expected values are +// independent of the host's zone. Zone-sensitive and DST cases live in +// cron-local-time.test.ts. -const parse = (expr: string, from: string) => Bun.cron.parse(expr, new Date(from))!.toISOString(); +async function parseUTC(expr: string, fromISO: string): Promise { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `process.stdout.write(String(Bun.cron.parse(${JSON.stringify(expr)}, new Date(${JSON.stringify(fromISO)}))?.toISOString() ?? "null"))`, + ], + env: { ...bunEnv, TZ: "UTC" }, + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(exitCode).toBe(0); + return stdout; +} -describe("Bun.cron.parse — UTC", () => { - test("0 9 * * * is 9am UTC regardless of process TZ", async () => { - // Parse is UTC; spawning under a non-UTC TZ should produce the same result. - for (const tz of ["America/Los_Angeles", "Asia/Tokyo", "UTC"]) { +describe.concurrent("Bun.cron.parse — local time (pinned TZ=UTC)", () => { + test("0 9 * * * in different TZs returns the local 9am", async () => { + for (const [tz, expected] of [ + ["America/Los_Angeles", "2026-06-15T16:00:00.000Z"], + ["Asia/Tokyo", "2026-06-16T00:00:00.000Z"], + ["UTC", "2026-06-15T09:00:00.000Z"], + ] as const) { await using proc = Bun.spawn({ cmd: [ bunExe(), @@ -20,53 +38,65 @@ describe("Bun.cron.parse — UTC", () => { env: { ...bunEnv, TZ: tz }, }); const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); - expect(stdout).toBe("2026-06-15T09:00:00.000Z"); + expect(stdout).toBe(expected); expect(exitCode).toBe(0); } }); - test("weekday matching uses UTC day-of-week", () => { + test("weekday matching uses local day-of-week", async () => { // 2026-06-15 is a Monday in UTC. - expect(parse("0 12 * * MON", "2026-06-14T23:00:00Z")).toBe("2026-06-15T12:00:00.000Z"); + expect(await parseUTC("0 12 * * MON", "2026-06-14T23:00:00Z")).toBe("2026-06-15T12:00:00.000Z"); }); - test("strictly-after: from = exact match returns the next occurrence", () => { - expect(parse("0 9 * * *", "2026-06-15T09:00:00Z")).toBe("2026-06-16T09:00:00.000Z"); + test("strictly-after: from = exact match returns the next occurrence", async () => { + expect(await parseUTC("0 9 * * *", "2026-06-15T09:00:00Z")).toBe("2026-06-16T09:00:00.000Z"); }); - test("Feb 29 finds next leap year", () => { - expect(parse("0 0 29 2 *", "2026-01-01T00:00:00Z")).toBe("2028-02-29T00:00:00.000Z"); + test("Feb 29 finds next leap year", async () => { + expect(await parseUTC("0 0 29 2 *", "2026-01-01T00:00:00Z")).toBe("2028-02-29T00:00:00.000Z"); }); - test("impossible day/month (Feb 30) returns null quickly", () => { - const t = performance.now(); - expect(Bun.cron.parse("0 0 30 2 *", new Date("2026-01-01T00:00:00Z"))).toBeNull(); - expect(performance.now() - t).toBeLessThan(50); + test("impossible day/month (Feb 30) returns null quickly", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const t = performance.now(); + const r = Bun.cron.parse("0 0 30 2 *", new Date("2026-01-01T00:00:00Z")); + process.stdout.write(JSON.stringify({ r, ms: performance.now() - t }))`, + ], + env: { ...bunEnv, TZ: "UTC" }, + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const { r, ms } = JSON.parse(stdout); + expect(r).toBeNull(); + expect(ms).toBeLessThan(isDebug ? 2000 : 50); + expect(exitCode).toBe(0); }); - test("DOM/DOW OR semantics when both restricted", () => { + test("DOM/DOW OR semantics when both restricted", async () => { // 0 0 13 * 5 → every 13th OR every Friday. From 2026-01-01 (Thu), first is Fri Jan 2. - expect(parse("0 0 13 * 5", "2026-01-01T00:00:00Z")).toBe("2026-01-02T00:00:00.000Z"); + expect(await parseUTC("0 0 13 * 5", "2026-01-01T00:00:00Z")).toBe("2026-01-02T00:00:00.000Z"); }); }); -describe("Bun.cron.parse — weekday 7 = Sunday in ranges", () => { +describe.concurrent("Bun.cron.parse — weekday 7 = Sunday in ranges", () => { // 2026-01-01 is a Thursday. next() is strictly-after, so the first match for // an every-day schedule is 2026-01-02. - test("1-7 means Mon-Sun (every day)", () => { - expect(parse("0 0 * * 1-7", "2026-01-01T00:00:00Z")).toBe("2026-01-02T00:00:00.000Z"); + test("1-7 means Mon-Sun (every day)", async () => { + expect(await parseUTC("0 0 * * 1-7", "2026-01-01T00:00:00Z")).toBe("2026-01-02T00:00:00.000Z"); }); - test("5-7 means Fri-Sun", () => { - expect(parse("0 0 * * 5-7", "2026-01-01T00:00:00Z")).toBe("2026-01-02T00:00:00.000Z"); + test("5-7 means Fri-Sun", async () => { + expect(await parseUTC("0 0 * * 5-7", "2026-01-01T00:00:00Z")).toBe("2026-01-02T00:00:00.000Z"); }); - test("6-7 means Sat-Sun", () => { - expect(parse("0 0 * * 6-7", "2026-01-01T00:00:00Z")).toBe("2026-01-03T00:00:00.000Z"); + test("6-7 means Sat-Sun", async () => { + expect(await parseUTC("0 0 * * 6-7", "2026-01-01T00:00:00Z")).toBe("2026-01-03T00:00:00.000Z"); }); - test("0-7 means every day", () => { - expect(parse("0 0 * * 0-7", "2026-01-01T00:00:00Z")).toBe("2026-01-02T00:00:00.000Z"); + test("0-7 means every day", async () => { + expect(await parseUTC("0 0 * * 0-7", "2026-01-01T00:00:00Z")).toBe("2026-01-02T00:00:00.000Z"); }); - test("scalar 7 still means Sunday", () => { - expect(parse("0 0 * * 7", "2026-01-01T00:00:00Z")).toBe("2026-01-04T00:00:00.000Z"); + test("scalar 7 still means Sunday", async () => { + expect(await parseUTC("0 0 * * 7", "2026-01-01T00:00:00Z")).toBe("2026-01-04T00:00:00.000Z"); }); }); @@ -90,14 +120,26 @@ describe("Bun.cron.parse — invalid `from` argument", () => { expect(() => Bun.cron.parse("* * * * *", new Date(from))).toThrow("Invalid date value"); }); - test("accepts the Date range boundary", () => { - // from = +8.64e15 is +275760-09-13T00:00:00Z; the next occurrence falls - // past the representable range → null, not an Invalid Date. - expect(Bun.cron.parse("* * * * *", 8.64e15)).toBeNull(); - // from = -8.64e15 is -271821-04-20T00:00:00Z; next minute is in range. - expect(Bun.cron.parse("* * * * *", -8.64e15)?.toISOString()).toBe("-271821-04-20T00:01:00.000Z"); - // Just inside the upper boundary: next minute lands exactly on 8.64e15. - expect(Bun.cron.parse("* * * * *", 8.64e15 - 60_000)?.getTime()).toBe(8.64e15); + test("accepts the Date range boundary", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const out = []; + // from = +8.64e15 is +275760-09-13T00:00:00Z; the next occurrence falls + // past the representable range → null, not an Invalid Date. + out.push(Bun.cron.parse("* * * * *", 8.64e15)); + // from = -8.64e15 is -271821-04-20T00:00:00Z; next minute is in range. + out.push(Bun.cron.parse("* * * * *", -8.64e15)?.toISOString()); + // Just inside the upper boundary: next minute lands exactly on 8.64e15. + out.push(Bun.cron.parse("* * * * *", 8.64e15 - 60_000)?.getTime()); + process.stdout.write(JSON.stringify(out));`, + ], + env: { ...bunEnv, TZ: "UTC" }, + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(JSON.parse(stdout)).toEqual([null, "-271821-04-20T00:01:00.000Z", 8.64e15]); + expect(exitCode).toBe(0); }); test("does not crash the process on 1e300", async () => { From 7c534d7d3b5676a1e47b3128505999bbaaead27e Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:43:10 +0000 Subject: [PATCH 02/11] [autofix.ci] apply automated fixes --- src/runtime/api/cron_parser.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runtime/api/cron_parser.rs b/src/runtime/api/cron_parser.rs index 718d71ff3095..a60fe79d6439 100644 --- a/src/runtime/api/cron_parser.rs +++ b/src/runtime/api/cron_parser.rs @@ -143,8 +143,8 @@ impl CronExpression { dt: GregorianDateTime, from_ms: f64, ) -> JsResult> { - let result = - global_object.gregorian_date_time_to_ms(dt.year, dt.month, dt.day, dt.hour, dt.minute, 0, 0)?; + let result = global_object + .gregorian_date_time_to_ms(dt.year, dt.month, dt.day, dt.hour, dt.minute, 0, 0)?; // During fall-back, `result` is the FORMER occurrence and the wall-clock // walk steps over the second one. For schedules with `*` minute or `*` // hour, scan real-time minutes (capped at the largest DST shift) for an From 189feca78261bfc96967d50d6c14be500f4dfb1d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:00:36 +0000 Subject: [PATCH 03/11] review: dedupe test suites, share JSGlobalObject date helpers, drop Feb-30 timing bound - JSGlobalObject: factor gregorian_date_time_to_ms{,_utc} and ms_to_gregorian_date_time{,_utc} into single impls taking a local-time flag. - cron-parse.test.ts: drop the TZ-iteration test (covered by cron-local-time.test.ts) and rename the describe block; drop the sub-50ms timing assertion on the Feb-30 null case since the local-time walk does more FFI round-trips than the UTC one and the test timeout already guards against unbounded search. - cron-local-time.test.ts: drop the weekday-7 suite (covered by cron-parse.test.ts). --- src/jsc/JSGlobalObject.rs | 60 ++++++++++-------------- test/js/bun/cron/cron-local-time.test.ts | 20 -------- test/js/bun/cron/cron-parse.test.ts | 42 ++--------------- 3 files changed, 29 insertions(+), 93 deletions(-) diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index d17078a4dcdf..1adbe98735f8 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -121,7 +121,8 @@ impl JSGlobalObject { JSValue::ZERO } - pub fn gregorian_date_time_to_ms_utc( + #[allow(clippy::too_many_arguments)] + fn gregorian_date_time_to_ms_impl( &self, year: i32, month: i32, @@ -130,6 +131,7 @@ impl JSGlobalObject { minute: i32, second: i32, millisecond: i32, + local: bool, ) -> JsResult { crate::mark_binding(); crate::cpp::Bun__gregorianDateTimeToMS( @@ -141,10 +143,23 @@ impl JSGlobalObject { minute, second, millisecond, - false, + local, ) } + pub fn gregorian_date_time_to_ms_utc( + &self, + year: i32, + month: i32, + day: i32, + hour: i32, + minute: i32, + second: i32, + millisecond: i32, + ) -> JsResult { + self.gregorian_date_time_to_ms_impl(year, month, day, hour, minute, second, millisecond, false) + } + pub fn gregorian_date_time_to_ms( &self, year: i32, @@ -155,21 +170,10 @@ impl JSGlobalObject { second: i32, millisecond: i32, ) -> JsResult { - crate::mark_binding(); - crate::cpp::Bun__gregorianDateTimeToMS( - self, - year, - month, - day, - hour, - minute, - second, - millisecond, - true, - ) + self.gregorian_date_time_to_ms_impl(year, month, day, hour, minute, second, millisecond, true) } - pub fn ms_to_gregorian_date_time_utc(&self, ms: f64) -> GregorianDateTime { + fn ms_to_gregorian_date_time_impl(&self, ms: f64, local: bool) -> GregorianDateTime { crate::mark_binding(); let mut dt = GregorianDateTime::default(); // SAFETY: FFI — &self is a valid JSGlobalObject*; out-param pointers are to live @@ -178,7 +182,7 @@ impl JSGlobalObject { crate::cpp::raw::Bun__msToGregorianDateTime( self.as_ptr(), ms, - false, + local, &raw mut dt.year, &raw mut dt.month, &raw mut dt.day, @@ -191,26 +195,12 @@ impl JSGlobalObject { dt } + pub fn ms_to_gregorian_date_time_utc(&self, ms: f64) -> GregorianDateTime { + self.ms_to_gregorian_date_time_impl(ms, false) + } + pub fn ms_to_gregorian_date_time(&self, ms: f64) -> GregorianDateTime { - crate::mark_binding(); - let mut dt = GregorianDateTime::default(); - // SAFETY: FFI — &self is a valid JSGlobalObject*; out-param pointers are to live - // stack locals (`dt` fields) and remain valid for the duration of the call. - unsafe { - crate::cpp::raw::Bun__msToGregorianDateTime( - self.as_ptr(), - ms, - true, - &raw mut dt.year, - &raw mut dt.month, - &raw mut dt.day, - &raw mut dt.hour, - &raw mut dt.minute, - &raw mut dt.second, - &raw mut dt.weekday, - ); - } - dt + self.ms_to_gregorian_date_time_impl(ms, true) } pub fn throw_todo(&self, msg: &[u8]) -> JsError { diff --git a/test/js/bun/cron/cron-local-time.test.ts b/test/js/bun/cron/cron-local-time.test.ts index 3b5af3edf4a1..4f5949f4e7ca 100644 --- a/test/js/bun/cron/cron-local-time.test.ts +++ b/test/js/bun/cron/cron-local-time.test.ts @@ -67,26 +67,6 @@ describe.concurrent("Bun.cron.parse — local time zone", () => { }); }); -describe.concurrent("Bun.cron.parse — weekday 7 = Sunday in ranges", () => { - // 2026-01-01 is a Thursday. next() is strictly-after, so the first match for an - // every-day schedule is 2026-01-02. - test("1-7 means Mon-Sun (every day)", async () => { - expect(await parseInTZ("UTC", "0 0 * * 1-7", "2026-01-01T00:00:00Z")).toBe("2026-01-02T00:00:00.000Z"); - }); - test("5-7 means Fri-Sun", async () => { - expect(await parseInTZ("UTC", "0 0 * * 5-7", "2026-01-01T00:00:00Z")).toBe("2026-01-02T00:00:00.000Z"); - }); - test("6-7 means Sat-Sun", async () => { - expect(await parseInTZ("UTC", "0 0 * * 6-7", "2026-01-01T00:00:00Z")).toBe("2026-01-03T00:00:00.000Z"); - }); - test("0-7 means every day", async () => { - expect(await parseInTZ("UTC", "0 0 * * 0-7", "2026-01-01T00:00:00Z")).toBe("2026-01-02T00:00:00.000Z"); - }); - test("scalar 7 still means Sunday", async () => { - expect(await parseInTZ("UTC", "0 0 * * 7", "2026-01-01T00:00:00Z")).toBe("2026-01-04T00:00:00.000Z"); - }); -}); - describe.concurrent("Bun.cron.parse — DST transitions", () => { test("spring-forward: schedule in the missing hour fires shifted forward (same day)", async () => { // US 2025 spring-forward: 2025-03-09 02:00 EST → 03:00 EDT (2:00-2:59 don't exist). diff --git a/test/js/bun/cron/cron-parse.test.ts b/test/js/bun/cron/cron-parse.test.ts index c09636cb23f3..1e8b8e0d27e0 100644 --- a/test/js/bun/cron/cron-parse.test.ts +++ b/test/js/bun/cron/cron-parse.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isDebug } from "harness"; +import { bunEnv, bunExe } from "harness"; // Bun.cron.parse() and the in-process Bun.cron(schedule, handler) interpret // schedules in the system's local time zone — matching the OS-level @@ -22,27 +22,7 @@ async function parseUTC(expr: string, fromISO: string): Promise { return stdout; } -describe.concurrent("Bun.cron.parse — local time (pinned TZ=UTC)", () => { - test("0 9 * * * in different TZs returns the local 9am", async () => { - for (const [tz, expected] of [ - ["America/Los_Angeles", "2026-06-15T16:00:00.000Z"], - ["Asia/Tokyo", "2026-06-16T00:00:00.000Z"], - ["UTC", "2026-06-15T09:00:00.000Z"], - ] as const) { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - `process.stdout.write(Bun.cron.parse("0 9 * * *", new Date("2026-06-15T00:00:00Z")).toISOString())`, - ], - env: { ...bunEnv, TZ: tz }, - }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); - expect(stdout).toBe(expected); - expect(exitCode).toBe(0); - } - }); - +describe.concurrent("Bun.cron.parse — algorithm (pinned TZ=UTC)", () => { test("weekday matching uses local day-of-week", async () => { // 2026-06-15 is a Monday in UTC. expect(await parseUTC("0 12 * * MON", "2026-06-14T23:00:00Z")).toBe("2026-06-15T12:00:00.000Z"); @@ -56,22 +36,8 @@ describe.concurrent("Bun.cron.parse — local time (pinned TZ=UTC)", () => { expect(await parseUTC("0 0 29 2 *", "2026-01-01T00:00:00Z")).toBe("2028-02-29T00:00:00.000Z"); }); - test("impossible day/month (Feb 30) returns null quickly", async () => { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - `const t = performance.now(); - const r = Bun.cron.parse("0 0 30 2 *", new Date("2026-01-01T00:00:00Z")); - process.stdout.write(JSON.stringify({ r, ms: performance.now() - t }))`, - ], - env: { ...bunEnv, TZ: "UTC" }, - }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); - const { r, ms } = JSON.parse(stdout); - expect(r).toBeNull(); - expect(ms).toBeLessThan(isDebug ? 2000 : 50); - expect(exitCode).toBe(0); + test("impossible day/month (Feb 30) returns null", async () => { + expect(await parseUTC("0 0 30 2 *", "2026-01-01T00:00:00Z")).toBe("null"); }); test("DOM/DOW OR semantics when both restricted", async () => { From a464488d4ff82bd50989bf9412f172f9767ed7f9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:02:44 +0000 Subject: [PATCH 04/11] [autofix.ci] apply automated fixes --- src/jsc/JSGlobalObject.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index 1adbe98735f8..8ba0d66984bb 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -157,7 +157,16 @@ impl JSGlobalObject { second: i32, millisecond: i32, ) -> JsResult { - self.gregorian_date_time_to_ms_impl(year, month, day, hour, minute, second, millisecond, false) + self.gregorian_date_time_to_ms_impl( + year, + month, + day, + hour, + minute, + second, + millisecond, + false, + ) } pub fn gregorian_date_time_to_ms( @@ -170,7 +179,16 @@ impl JSGlobalObject { second: i32, millisecond: i32, ) -> JsResult { - self.gregorian_date_time_to_ms_impl(year, month, day, hour, minute, second, millisecond, true) + self.gregorian_date_time_to_ms_impl( + year, + month, + day, + hour, + minute, + second, + millisecond, + true, + ) } fn ms_to_gregorian_date_time_impl(&self, ms: f64, local: bool) -> GregorianDateTime { From 18b1b18ad87c22e72478dbc4bb0ffd60897e62fd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:03:58 +0000 Subject: [PATCH 05/11] review: drop now-dead ms_to_gregorian_date_time_utc; pipe stderr in test helpers - JSGlobalObject: cron_parser.rs was the only caller of ms_to_gregorian_date_time_utc; the local-time rewire removed both call sites, so delete it (gregorian_date_time_to_ms_utc stays, used by the SQL date types). - cron-local-time.test.ts / cron-parse.test.ts: pipe stderr and assert it empty before the exit-code check so a child crash surfaces the error message instead of a bare 'expected 0, got 1'. --- src/jsc/JSGlobalObject.rs | 12 ++----- test/js/bun/cron/cron-local-time.test.ts | 41 ++++++++++++------------ test/js/bun/cron/cron-parse.test.ts | 4 ++- 3 files changed, 25 insertions(+), 32 deletions(-) diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index 8ba0d66984bb..b0839fe0adc7 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -191,7 +191,7 @@ impl JSGlobalObject { ) } - fn ms_to_gregorian_date_time_impl(&self, ms: f64, local: bool) -> GregorianDateTime { + pub fn ms_to_gregorian_date_time(&self, ms: f64) -> GregorianDateTime { crate::mark_binding(); let mut dt = GregorianDateTime::default(); // SAFETY: FFI — &self is a valid JSGlobalObject*; out-param pointers are to live @@ -200,7 +200,7 @@ impl JSGlobalObject { crate::cpp::raw::Bun__msToGregorianDateTime( self.as_ptr(), ms, - local, + true, &raw mut dt.year, &raw mut dt.month, &raw mut dt.day, @@ -213,14 +213,6 @@ impl JSGlobalObject { dt } - pub fn ms_to_gregorian_date_time_utc(&self, ms: f64) -> GregorianDateTime { - self.ms_to_gregorian_date_time_impl(ms, false) - } - - pub fn ms_to_gregorian_date_time(&self, ms: f64) -> GregorianDateTime { - self.ms_to_gregorian_date_time_impl(ms, true) - } - pub fn throw_todo(&self, msg: &[u8]) -> JsError { let err = self.create_error_instance(format_args!("{}", bstr::BStr::new(msg))); if err.is_empty() { diff --git a/test/js/bun/cron/cron-local-time.test.ts b/test/js/bun/cron/cron-local-time.test.ts index 4f5949f4e7ca..120ff89e580f 100644 --- a/test/js/bun/cron/cron-local-time.test.ts +++ b/test/js/bun/cron/cron-local-time.test.ts @@ -8,35 +8,34 @@ import { bunEnv, bunExe } from "harness"; // Each test spawns a subprocess with a fixed TZ so the assertions are // independent of the host's zone. -async function parseInTZ(tz: string, expr: string, fromISO: string): Promise { +async function evalInTZ(tz: string, src: string): Promise { await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - `process.stdout.write(Bun.cron.parse(${JSON.stringify(expr)}, new Date(${JSON.stringify(fromISO)})).toISOString())`, - ], + cmd: [bunExe(), "-e", src], env: { ...bunEnv, TZ: tz }, + stderr: "pipe", }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); expect(exitCode).toBe(0); return stdout; } +async function parseInTZ(tz: string, expr: string, fromISO: string): Promise { + return evalInTZ( + tz, + `process.stdout.write(Bun.cron.parse(${JSON.stringify(expr)}, new Date(${JSON.stringify(fromISO)})).toISOString())`, + ); +} + async function chainInTZ(tz: string, expr: string, fromISO: string, steps: number): Promise { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - `let t = new Date(${JSON.stringify(fromISO)}); - const seq = []; - for (let i = 0; i < ${steps}; i++) { t = Bun.cron.parse(${JSON.stringify(expr)}, t); seq.push(t.toISOString()); } - process.stdout.write(JSON.stringify(seq))`, - ], - env: { ...bunEnv, TZ: tz }, - }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); - expect(exitCode).toBe(0); - return JSON.parse(stdout); + const out = await evalInTZ( + tz, + `let t = new Date(${JSON.stringify(fromISO)}); + const seq = []; + for (let i = 0; i < ${steps}; i++) { t = Bun.cron.parse(${JSON.stringify(expr)}, t); seq.push(t.toISOString()); } + process.stdout.write(JSON.stringify(seq))`, + ); + return JSON.parse(out); } describe.concurrent("Bun.cron.parse — local time zone", () => { diff --git a/test/js/bun/cron/cron-parse.test.ts b/test/js/bun/cron/cron-parse.test.ts index 1e8b8e0d27e0..fbecff6541b5 100644 --- a/test/js/bun/cron/cron-parse.test.ts +++ b/test/js/bun/cron/cron-parse.test.ts @@ -16,8 +16,10 @@ async function parseUTC(expr: string, fromISO: string): Promise { `process.stdout.write(String(Bun.cron.parse(${JSON.stringify(expr)}, new Date(${JSON.stringify(fromISO)}))?.toISOString() ?? "null"))`, ], env: { ...bunEnv, TZ: "UTC" }, + stderr: "pipe", }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); expect(exitCode).toBe(0); return stdout; } From 1a19e7c9fb8caaf76af8a6448b5728f0b71e60fc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:46:43 +0000 Subject: [PATCH 06/11] cron: add { tz } option; pin cron.test.ts parse block to UTC Bun.cron.parse(expr, from?, { tz?: string }) and the in-process Bun.cron(schedule, handler, { tz?: string }) now accept an IANA time-zone name. Default is the system's local zone; { tz: 'UTC' } preserves the pre-1.4 behaviour. - bindings.cpp: Bun__resolveTimeZoneID / Bun__msToGregorianDateTimeInZone / Bun__gregorianDateTimeToMSInZone wrap JSC's Temporal ICU bridge (intlResolveTimeZoneID, getOffsetNanosecondsFor, exactTimeToLocalDateAndTime, getEpochNanosecondsFor with Compatible disambiguation), so the per-TimeZone UCalendar LRU cache and DST gap/fold handling are inherited rather than reimplemented. - cron_parser.rs: CronTz::{Local,Named(u32)} parameterises the zone-dependent calls (initial breakdown, resolve_local_match, matches_instant). The noon normalization round-trip in next() now uses UTC, since day overflow + weekday are pure calendar math and the named-zone path can't accept overflowed fields. - cron.rs: resolve_cron_tz() reads { tz } from the third argument of both Bun.cron.parse and the in-process Bun.cron callback overload; CronJob stores the resolved tz. Also: - cron.test.ts: pin the ~40-test describe('Bun.cron.parse') block to Etc/UTC via beforeAll/afterAll. It calls parse() in-process and asserts Date.UTC(...) values, which would fail on non-UTC hosts now that parse() is local by default (test/preload.ts deliberately skips TZ when syncing bunEnv into process.env). - cron-parse.test.ts: pipe stderr and assert combined { stdout, stderr, exitCode } in the two remaining inline spawns. - Types and docs updated for the new options argument. 9 new tests in cron-local-time.test.ts cover the tz override, DST under the override, error handling, and the in-process callback path with tz under fake timers. --- docs/runtime/cron.mdx | 22 ++++-- packages/bun-types/bun.d.ts | 26 +++++-- src/jsc/JSGlobalObject.rs | 73 +++++++++++++++++- src/jsc/bindings/bindings.cpp | 45 ++++++++++++ src/runtime/api/cron.rs | 48 ++++++++++-- src/runtime/api/cron_parser.rs | 64 ++++++++++++---- test/js/bun/cron/cron-local-time.test.ts | 94 ++++++++++++++++++++++++ test/js/bun/cron/cron-parse.test.ts | 12 ++- test/js/bun/cron/cron.test.ts | 8 +- 9 files changed, 354 insertions(+), 38 deletions(-) diff --git a/docs/runtime/cron.mdx b/docs/runtime/cron.mdx index 9ef8f086c65b..447902e842f2 100644 --- a/docs/runtime/cron.mdx +++ b/docs/runtime/cron.mdx @@ -41,10 +41,11 @@ console.log(next); // => next quarter-hour boundary ### Parameters -| Parameter | Type | Description | -| -------------- | ---------------- | -------------------------------------------------------- | -| `expression` | `string` | A 5-field cron expression or predefined nickname | -| `relativeDate` | `Date \| number` | Starting point for the search (defaults to `Date.now()`) | +| Parameter | Type | Description | +| -------------- | ----------------- | ------------------------------------------------------------------------------ | +| `expression` | `string` | A 5-field cron expression or predefined nickname | +| `relativeDate` | `Date \| number` | Starting point for the search (defaults to `Date.now()`) | +| `options` | `{ tz?: string }` | IANA time-zone name to interpret the schedule in (defaults to the system zone) | ### Returns @@ -120,7 +121,15 @@ console.log(next); // => next local midnight Schedules are interpreted in the system's **local time zone** — the same way crontab, launchd, and Windows Task Scheduler read them. The OS-level form and the in-process callback form fire at the same wall-clock time. -To use UTC, set `TZ=UTC` in the environment. +To override, pass an IANA time-zone name as `{ tz }` to `Bun.cron.parse()` or the in-process `Bun.cron(schedule, handler, options)`: + +```ts +// 09:00 UTC, regardless of the server's TZ +Bun.cron.parse("0 9 * * *", Date.now(), { tz: "UTC" }); + +// Fire at 09:00 New York time +Bun.cron("0 9 * * *", handler, { tz: "America/New_York" }); +``` DST transitions: @@ -166,8 +175,9 @@ In-process scheduling is the lightweight option for long-running servers and wor | ---------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `schedule` | `string` | A [cron expression](#cron-expression-syntax) or nickname like `"@hourly"`. | | `handler` | `(this: CronJob) => unknown` | Called on each fire. May return a Promise — the next fire is not scheduled until it settles. Inside a `function` callback, `this` is the `CronJob` (so `this.stop()` works). | +| `options` | `{ tz?: string }` | IANA time-zone name to interpret the schedule in (defaults to the system zone). | -Returns a [`CronJob`](#the-cronjob-handle) synchronously. Throws a `TypeError` if the expression is invalid or has no future occurrences, like `"0 0 30 2 *"` (February 30th). +Returns a [`CronJob`](#the-cronjob-handle) synchronously. Throws a `TypeError` if the expression is invalid, the time-zone name is unknown, or the expression has no future occurrences, like `"0 0 30 2 *"` (February 30th). ### No-overlap guarantee diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 3b53b0c96ac7..46096c814092 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -7606,6 +7606,17 @@ declare module "bun" { unref(): CronJob; } + /** + * Options for the in-process {@link Bun.cron} callback overload and {@link Bun.cron.parse}. + */ + interface CronOptions { + /** + * IANA time-zone name to interpret the schedule in (e.g. `"UTC"`, + * `"America/New_York"`). Defaults to the system's local time zone. + */ + tz?: string; + } + /** * Schedule cron jobs. * @@ -7701,7 +7712,7 @@ declare module "bun" { * @see {@link CronJob} for the returned handle. * @see {@link Bun.cron.parse} to preview the next fire time. */ - (schedule: CronWithAutocomplete, handler: (this: CronJob) => unknown): CronJob; + (schedule: CronWithAutocomplete, handler: (this: CronJob) => unknown, options?: CronOptions): CronJob; /** * Register an **OS-level** cron job that runs a JavaScript/TypeScript module on a schedule. * @@ -7803,7 +7814,8 @@ declare module "bun" { /** * Parse a cron expression and return the next matching `Date` in the * system's local time zone — the same way crontab, launchd, and Windows - * Task Scheduler interpret schedules. Set `TZ=UTC` to use UTC. + * Task Scheduler interpret schedules. Pass `{ tz: "UTC" }` (or any IANA + * time-zone name) to override. * * Supports the same syntax as {@link Bun.cron} — 5-field expressions, named * days/months, and predefined nicknames like `@daily`. @@ -7818,21 +7830,25 @@ declare module "bun" { * * @param expression - A cron expression or nickname (e.g. `"0,15,30,45 * * * *"`, `"0 9 * * MON-FRI"`, `"@hourly"`) * @param relativeDate - Starting point for the search (defaults to `Date.now()`). Accepts a `Date` or milliseconds since epoch. - * @returns The next `Date` matching the expression in local time, or `null` if no match exists within 8 years (e.g. `"0 0 30 2 *"` — Feb 30 never occurs) - * @throws If the expression is invalid or `relativeDate` is `NaN`/`Infinity` + * @param options - `{ tz?: string }` — IANA time-zone name to interpret the schedule in (defaults to the system's local zone). + * @returns The next `Date` matching the expression, or `null` if no match exists within 8 years (e.g. `"0 0 30 2 *"` — Feb 30 never occurs) + * @throws If the expression is invalid, `relativeDate` is `NaN`/`Infinity`, or `options.tz` is not a valid IANA name * * @example * ```ts * // Next weekday at 09:30 local time * const next = Bun.cron.parse("30 9 * * MON-FRI"); * + * // 09:00 in New York, regardless of the server's TZ + * const ny = Bun.cron.parse("0 9 * * *", Date.now(), { tz: "America/New_York" }); + * * // Chain calls to get a sequence * const from = new Date(); * const first = Bun.cron.parse("@hourly", from); * const second = first ? Bun.cron.parse("@hourly", first) : null; * ``` */ - parse(expression: CronWithAutocomplete, relativeDate?: Date | number): Date | null; + parse(expression: CronWithAutocomplete, relativeDate?: Date | number, options?: CronOptions): Date | null; }; /** Utility type for any process from {@link Bun.spawn()} with both stdout and stderr set to `"pipe"` */ diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index b0839fe0adc7..896ca52e75e3 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -191,7 +191,7 @@ impl JSGlobalObject { ) } - pub fn ms_to_gregorian_date_time(&self, ms: f64) -> GregorianDateTime { + fn ms_to_gregorian_date_time_impl(&self, ms: f64, local: bool) -> GregorianDateTime { crate::mark_binding(); let mut dt = GregorianDateTime::default(); // SAFETY: FFI — &self is a valid JSGlobalObject*; out-param pointers are to live @@ -200,7 +200,37 @@ impl JSGlobalObject { crate::cpp::raw::Bun__msToGregorianDateTime( self.as_ptr(), ms, - true, + local, + &raw mut dt.year, + &raw mut dt.month, + &raw mut dt.day, + &raw mut dt.hour, + &raw mut dt.minute, + &raw mut dt.second, + &raw mut dt.weekday, + ); + } + dt + } + + pub fn ms_to_gregorian_date_time_utc(&self, ms: f64) -> GregorianDateTime { + self.ms_to_gregorian_date_time_impl(ms, false) + } + + pub fn ms_to_gregorian_date_time(&self, ms: f64) -> GregorianDateTime { + self.ms_to_gregorian_date_time_impl(ms, true) + } + + pub fn ms_to_gregorian_date_time_in_zone(&self, ms: f64, tz_id: u32) -> GregorianDateTime { + crate::mark_binding(); + let mut dt = GregorianDateTime::default(); + // SAFETY: FFI — &self is a valid JSGlobalObject*; out-param pointers are to live + // stack locals (`dt` fields) and remain valid for the duration of the call. + unsafe { + crate::cpp::raw::Bun__msToGregorianDateTimeInZone( + self.as_ptr(), + ms, + tz_id, &raw mut dt.year, &raw mut dt.month, &raw mut dt.day, @@ -213,6 +243,45 @@ impl JSGlobalObject { dt } + #[allow(clippy::too_many_arguments)] + pub fn gregorian_date_time_to_ms_in_zone( + &self, + year: i32, + month: i32, + day: i32, + hour: i32, + minute: i32, + second: i32, + millisecond: i32, + tz_id: u32, + ) -> f64 { + crate::mark_binding(); + // SAFETY: FFI — &self is a valid JSGlobalObject*. + unsafe { + crate::cpp::raw::Bun__gregorianDateTimeToMSInZone( + self.as_ptr(), + year, + month, + day, + hour, + minute, + second, + millisecond, + tz_id, + ) + } + } + + /// Resolve an IANA time-zone name to a `TimeZoneID` suitable for + /// `*_in_zone`. Returns `None` if the name is not recognised. + pub fn resolve_time_zone_id(name: &[u8]) -> Option { + crate::mark_binding(); + // SAFETY: FFI — `name` is a valid byte slice; len matches. The C++ side reads + // only `len` bytes and does not retain the pointer. + let id = unsafe { crate::cpp::raw::Bun__resolveTimeZoneID(name.as_ptr(), name.len()) }; + if id == u32::MAX { None } else { Some(id) } + } + pub fn throw_todo(&self, msg: &[u8]) -> JsError { let err = self.create_error_instance(format_args!("{}", bstr::BStr::new(msg))); if err.is_empty() { diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index bb3db646c96a..506eb2fa35b4 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -93,6 +93,12 @@ #include "wtf/text/StringView.h" #include "wtf/text/WTFString.h" #include "wtf/GregorianDateTime.h" +#include "JavaScriptCore/IntlObject.h" +#include "JavaScriptCore/ISO8601.h" +#include "JavaScriptCore/JSCTimeZone.h" +#include "JavaScriptCore/TemporalCoreTypes.h" +#include "JavaScriptCore/TemporalEnums.h" +#include "JavaScriptCore/TimeZoneICUBridge.h" #include "JavaScriptCore/FunctionPrototype.h" #include "JSFetchHeaders.h" @@ -5728,6 +5734,45 @@ extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__msToGregorianDateTime(JSC::JSGlobal *weekday = dt.weekDay(); } +extern "C" [[ZIG_EXPORT(nothrow)]] uint32_t Bun__resolveTimeZoneID(const uint8_t* name, size_t len) +{ + auto id = JSC::intlResolveTimeZoneID(StringView { std::span(reinterpret_cast(name), len) }); + return id ? *id : std::numeric_limits::max(); +} + +extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__msToGregorianDateTimeInZone(JSC::JSGlobalObject* globalObject, double ms, uint32_t tzID, + int* year, int* month, int* day, int* hour, int* minute, int* second, int* weekday) +{ + UNUSED_PARAM(globalObject); + auto tz = JSC::TimeZone::fromID(tzID); + auto exact = JSC::ISO8601::ExactTime::fromEpochMilliseconds(static_cast(ms)); + auto off = JSC::TemporalCore::getOffsetNanosecondsFor(tz, exact); + int64_t offNs = off ? *off : 0; + auto dt = JSC::TemporalCore::exactTimeToLocalDateAndTime(exact, offNs); + *year = dt.date.year(); + *month = dt.date.month(); + *day = dt.date.day(); + *hour = static_cast(dt.time.hour()); + *minute = static_cast(dt.time.minute()); + *second = static_cast(dt.time.second()); + // ISO8601::dayOfWeek: 1=Mon..7=Sun; GregorianDateTime consumers expect 0=Sun..6=Sat. + *weekday = JSC::ISO8601::dayOfWeek(dt.date) % 7; +} + +extern "C" [[ZIG_EXPORT(nothrow)]] double Bun__gregorianDateTimeToMSInZone(JSC::JSGlobalObject* globalObject, + int year, int month, int day, int hour, int minute, int second, int millisecond, uint32_t tzID) +{ + UNUSED_PARAM(globalObject); + auto tz = JSC::TimeZone::fromID(tzID); + JSC::ISO8601::PlainDate date { year, static_cast(month), static_cast(day) }; + JSC::ISO8601::PlainTime time { static_cast(hour), static_cast(minute), + static_cast(second), static_cast(millisecond), 0, 0 }; + auto r = JSC::TemporalCore::getEpochNanosecondsFor(tz, date, time, JSC::TemporalDisambiguation::Compatible); + if (!r) + return std::numeric_limits::quiet_NaN(); + return static_cast(r->epochMilliseconds()); +} + extern "C" EncodedJSValue JSC__JSValue__dateInstanceFromNumber(JSC::JSGlobalObject* globalObject, double unixTimestamp) { auto& vm = JSC::getVM(globalObject); diff --git a/src/runtime/api/cron.rs b/src/runtime/api/cron.rs index af5941f3b04c..e317c8bc5b9c 100644 --- a/src/runtime/api/cron.rs +++ b/src/runtime/api/cron.rs @@ -10,7 +10,7 @@ use std::io::Write as _; -use super::cron_parser::{self, CronExpression}; +use super::cron_parser::{self, CronExpression, CronTz}; use core::ffi::c_char; use std::cell::Cell; @@ -678,15 +678,45 @@ impl CronRegisterJob { } } +/// Resolve the `{ tz?: string }` option to a `CronTz`. +fn resolve_cron_tz(global: &JSGlobalObject, opts: JSValue) -> JsResult { + if opts.is_empty() || opts.is_undefined_or_null() { + return Ok(CronTz::Local); + } + if !opts.is_object() { + return Err(global.throw_invalid_arguments(format_args!( + "Bun.cron: options must be an object" + ))); + } + let Some(tz_val) = opts.get_truthy(global, "tz")? else { + return Ok(CronTz::Local); + }; + if !tz_val.is_string() { + return Err(global.throw_invalid_arguments(format_args!( + "Bun.cron: options.tz must be a string" + ))); + } + let tz_str = bun_core::OwnedString::new(tz_val.to_bun_string(global)?); + let tz_slice = tz_str.to_utf8(); + match JSGlobalObject::resolve_time_zone_id(tz_slice.slice()) { + Some(id) => Ok(CronTz::Named(id)), + None => Err(global.throw_invalid_arguments(format_args!( + "Bun.cron: unknown time zone '{}'", + bstr::BStr::new(tz_slice.slice()) + ))), + } +} + // -- JS entry point -- (free fn: `#[host_fn]` Free shim calls bare `cron_register(..)`) #[bun_jsc::host_fn] pub fn cron_register(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { let args = frame.arguments_as_array::<3>(); - // In-process callback cron: Bun.cron(schedule, handler) + // In-process callback cron: Bun.cron(schedule, handler, opts?) if args[1].is_callable() { - return CronJob::register(global, args[0], args[1]); + let tz = resolve_cron_tz(global, args[2])?; + return CronJob::register(global, args[0], args[1], tz); } if args[0].is_string() && args[2].is_undefined() { return Err(global.throw_invalid_arguments(format_args!( @@ -1419,6 +1449,8 @@ pub struct CronJob { global: GlobalRef, // Read-only after construction. parsed: CronExpression, + // Read-only after construction. + tz: CronTz, poll_ref: JsCell, this_value: JsCell, stopped: Cell, @@ -1640,7 +1672,7 @@ impl CronJob { // (clock skew / NTP step); floor next() at the prior target so it can't // recompute the same minute and double-fire. let from_ms = now_ms.max(self.last_next_ms.get()); - let next_ms = match self.parsed.next(&self.global, from_ms) { + let next_ms = match self.parsed.next(&self.global, from_ms, self.tz) { Ok(Some(v)) => v, _ => return None, }; @@ -1835,6 +1867,7 @@ impl CronJob { global: &JSGlobalObject, schedule_arg: JSValue, callback_arg: JSValue, + tz: CronTz, ) -> JsResult { if !schedule_arg.is_string() { return Err(global.throw_invalid_arguments(format_args!( @@ -1863,6 +1896,7 @@ impl CronJob { event_loop_timer: JsCell::new(EventLoopTimer::init_paused(EventLoopTimerTag::CronJob)), global: GlobalRef::from(global), parsed, + tz, poll_ref: JsCell::new(KeepAlive::default()), this_value: JsCell::new(JsRef::empty()), stopped: Cell::new(false), @@ -2014,7 +2048,7 @@ pub fn get_cron_object(global_this: &JSGlobalObject, _obj: &JSObject) -> JSValue #[bun_jsc::host_fn] pub fn cron_parse(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { - let args = frame.arguments_as_array::<2>(); + let args = frame.arguments_as_array::<3>(); if !args[0].is_string() { return Err(global.throw_invalid_arguments(format_args!( @@ -2054,7 +2088,9 @@ pub fn cron_parse(global: &JSGlobalObject, frame: &CallFrame) -> JsResult GregorianDateTime { + match self { + CronTz::Local => g.ms_to_gregorian_date_time(ms), + CronTz::Named(id) => g.ms_to_gregorian_date_time_in_zone(ms, id), + } + } + + fn gregorian_to_ms( + self, + g: &JSGlobalObject, + year: i32, + month: i32, + day: i32, + hour: i32, + minute: i32, + ) -> JsResult { + match self { + CronTz::Local => g.gregorian_date_time_to_ms(year, month, day, hour, minute, 0, 0), + CronTz::Named(id) => { + Ok(g.gregorian_date_time_to_ms_in_zone(year, month, day, hour, minute, 0, 0, id)) + } + } + } +} + #[derive(Clone, Copy)] pub struct CronExpression { pub minutes: u64, // bits 0-59 @@ -124,9 +159,9 @@ impl CronExpression { } } - /// Check if a real instant matches all five fields in local time. - fn matches_instant(&self, global_object: &JSGlobalObject, ms: f64) -> bool { - let t = global_object.ms_to_gregorian_date_time(ms); + /// Check if a real instant matches all five fields in `tz`. + fn matches_instant(&self, global_object: &JSGlobalObject, tz: CronTz, ms: f64) -> bool { + let t = tz.ms_to_gregorian(global_object, ms); bit_set(self.minutes, u32::try_from(t.minute).expect("int cast")) && bit_set(self.hours, u32::try_from(t.hour).expect("int cast")) && bit_set(self.months, u32::try_from(t.month).expect("int cast")) @@ -140,11 +175,11 @@ impl CronExpression { fn resolve_local_match( &self, global_object: &JSGlobalObject, + tz: CronTz, dt: GregorianDateTime, from_ms: f64, ) -> JsResult> { - let result = global_object - .gregorian_date_time_to_ms(dt.year, dt.month, dt.day, dt.hour, dt.minute, 0, 0)?; + let result = tz.gregorian_to_ms(global_object, dt.year, dt.month, dt.day, dt.hour, dt.minute)?; // During fall-back, `result` is the FORMER occurrence and the wall-clock // walk steps over the second one. For schedules with `*` minute or `*` // hour, scan real-time minutes (capped at the largest DST shift) for an @@ -153,7 +188,7 @@ impl CronExpression { let mut probe = ((from_ms / MINUTE_MS).floor() + 1.0) * MINUTE_MS; let cap = result.min(from_ms + (MAX_DST_SHIFT_MIN + 1.0) * MINUTE_MS); while probe < cap { - if self.matches_instant(global_object, probe) { + if self.matches_instant(global_object, tz, probe) { return Ok(Some(probe)); } probe += MINUTE_MS; @@ -163,22 +198,23 @@ impl CronExpression { } /// Compute the next time (in ms since epoch) that matches this expression - /// in the system's local time zone, strictly after `from_ms`. Returns None - /// if no match found within 8 years. + /// in `tz`, strictly after `from_ms`. Returns None if no match found + /// within 8 years. pub(crate) fn next( &self, global_object: &JSGlobalObject, from_ms: f64, + tz: CronTz, ) -> JsResult> { - let mut dt = global_object.ms_to_gregorian_date_time(from_ms); + let mut dt = tz.ms_to_gregorian(global_object, from_ms); let start_year = dt.year; dt.minute += 1; while dt.year - start_year <= 8 { // Carry hour/minute manually so the candidate {hour,minute} is // checked against the bitfields *before* DST shifts it; normalize - // the date+weekday via a noon round-trip (DST at midday stays on - // the same calendar day, so only date fields are copied back). + // the date+weekday via a UTC round-trip (pure calendar math — + // day overflow and weekday are TZ-independent). if dt.minute > 59 { dt.minute -= 60; dt.hour += 1; @@ -187,8 +223,8 @@ impl CronExpression { dt.hour -= 24; dt.day += 1; } - let n = global_object.ms_to_gregorian_date_time( - global_object.gregorian_date_time_to_ms(dt.year, dt.month, dt.day, 12, 0, 0, 0)?, + let n = global_object.ms_to_gregorian_date_time_utc( + global_object.gregorian_date_time_to_ms_utc(dt.year, dt.month, dt.day, 12, 0, 0, 0)?, ); dt.year = n.year; dt.month = n.month; @@ -218,7 +254,7 @@ impl CronExpression { continue; } - if let Some(r) = self.resolve_local_match(global_object, dt, from_ms)? { + if let Some(r) = self.resolve_local_match(global_object, tz, dt, from_ms)? { return Ok(Some(r)); } dt.minute += 1; diff --git a/test/js/bun/cron/cron-local-time.test.ts b/test/js/bun/cron/cron-local-time.test.ts index 120ff89e580f..95f9fcaf5170 100644 --- a/test/js/bun/cron/cron-local-time.test.ts +++ b/test/js/bun/cron/cron-local-time.test.ts @@ -155,3 +155,97 @@ describe.concurrent("Bun.cron.parse — DST transitions", () => { expect(next).toBe("2025-09-07T04:00:00.000Z"); }); }); + +describe.concurrent("Bun.cron.parse — { tz } option", () => { + test("overrides process TZ (UTC opt under LA process)", async () => { + const next = await evalInTZ( + "America/Los_Angeles", + `process.stdout.write(Bun.cron.parse("0 9 * * *", new Date("2026-06-15T00:00:00Z"), {tz: "UTC"}).toISOString())`, + ); + expect(next).toBe("2026-06-15T09:00:00.000Z"); + }); + + test("named zone (America/New_York under UTC process)", async () => { + const next = await evalInTZ( + "UTC", + `process.stdout.write(Bun.cron.parse("0 9 * * *", new Date("2026-06-15T00:00:00Z"), {tz: "America/New_York"}).toISOString())`, + ); + // 9am EDT (UTC-4) = 13:00 UTC + expect(next).toBe("2026-06-15T13:00:00.000Z"); + }); + + test("tz option matches the same zone set as process TZ", async () => { + const zones = ["America/Los_Angeles", "Asia/Tokyo", "Pacific/Auckland", "Australia/Lord_Howe"]; + const out = await evalInTZ( + "UTC", + `const zones = ${JSON.stringify(zones)}; + const r = {}; + for (const z of zones) { + const viaOpt = Bun.cron.parse("0 9 * * *", new Date("2026-06-15T00:00:00Z"), {tz: z}).toISOString(); + process.env.TZ = z; + const viaEnv = Bun.cron.parse("0 9 * * *", new Date("2026-06-15T00:00:00Z")).toISOString(); + process.env.TZ = "UTC"; + r[z] = { viaOpt, viaEnv }; + } + process.stdout.write(JSON.stringify(r))`, + ); + const r = JSON.parse(out); + for (const z of zones) expect({ zone: z, ...r[z] }).toEqual({ zone: z, viaOpt: r[z].viaEnv, viaEnv: r[z].viaEnv }); + }); + + test("DST via tz option matches DST via process TZ (spring-forward)", async () => { + // US 2025 spring-forward: "30 2 * * *" → 03:30 EDT on 2025-03-09. + const next = await evalInTZ( + "UTC", + `process.stdout.write(Bun.cron.parse("30 2 * * *", new Date("2025-03-09T05:00:00Z"), {tz: "America/New_York"}).toISOString())`, + ); + expect(next).toBe("2025-03-09T07:30:00.000Z"); + }); + + test("DST via tz option matches DST via process TZ (fall-back, fixed fires once)", async () => { + // From the second 01:30 (EST), "30 1 * * *" → next day. + const next = await evalInTZ( + "UTC", + `process.stdout.write(Bun.cron.parse("30 1 * * *", new Date("2025-11-02T06:30:00Z"), {tz: "America/New_York"}).toISOString())`, + ); + expect(next).toBe("2025-11-03T06:30:00.000Z"); + }); + + test("unknown tz throws", () => { + expect(() => Bun.cron.parse("* * * * *", Date.now(), { tz: "Mars/Olympus" })).toThrow( + /unknown time zone 'Mars\/Olympus'/, + ); + expect(() => Bun.cron("* * * * *", () => {}, { tz: "Mars/Olympus" })).toThrow( + /unknown time zone 'Mars\/Olympus'/, + ); + }); + + test("non-string tz throws", () => { + // @ts-expect-error + expect(() => Bun.cron.parse("* * * * *", Date.now(), { tz: 42 })).toThrow(/options\.tz must be a string/); + }); + + test("tz: undefined falls back to local", async () => { + const next = await evalInTZ( + "Asia/Tokyo", + `process.stdout.write(Bun.cron.parse("0 9 * * *", new Date("2026-06-15T00:00:00Z"), {tz: undefined}).toISOString())`, + ); + expect(next).toBe("2026-06-16T00:00:00.000Z"); + }); + + test("in-process Bun.cron(schedule, handler, { tz }) uses the override", async () => { + const out = await evalInTZ( + "UTC", + `const { jest } = await import("bun:test"); + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-06-15T00:00:00Z")); + const fired = []; + using job = Bun.cron("0 9 * * *", () => fired.push(new Date().toISOString()), { tz: "America/New_York" }); + jest.advanceTimersByTime(14 * 60 * 60 * 1000); + jest.useRealTimers(); + process.stdout.write(JSON.stringify(fired));`, + ); + // 9am EDT = 13:00 UTC; advancing 14h from 00:00Z fires exactly once at 13:00Z. + expect(JSON.parse(out)).toEqual(["2026-06-15T13:00:00.000Z"]); + }); +}); diff --git a/test/js/bun/cron/cron-parse.test.ts b/test/js/bun/cron/cron-parse.test.ts index fbecff6541b5..2f334dd2d784 100644 --- a/test/js/bun/cron/cron-parse.test.ts +++ b/test/js/bun/cron/cron-parse.test.ts @@ -104,10 +104,14 @@ describe("Bun.cron.parse — invalid `from` argument", () => { process.stdout.write(JSON.stringify(out));`, ], env: { ...bunEnv, TZ: "UTC" }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + out: [null, "-271821-04-20T00:01:00.000Z", 8.64e15], + stderr: "", + exitCode: 0, }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); - expect(JSON.parse(stdout)).toEqual([null, "-271821-04-20T00:01:00.000Z", 8.64e15]); - expect(exitCode).toBe(0); }); test("does not crash the process on 1e300", async () => { @@ -121,6 +125,6 @@ describe("Bun.cron.parse — invalid `from` argument", () => { stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout, exitCode }).toEqual({ stdout: "Invalid date value", exitCode: 0 }); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "Invalid date value", stderr: "", exitCode: 0 }); }); }); diff --git a/test/js/bun/cron/cron.test.ts b/test/js/bun/cron/cron.test.ts index 6bcbe747773b..80a6a0b11dcb 100644 --- a/test/js/bun/cron/cron.test.ts +++ b/test/js/bun/cron/cron.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isLinux, isMacOS, isWindows, tempDir } from "harness"; import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; @@ -1388,6 +1388,12 @@ function nextN(expr: string, from: number, n: number): number[] { } describe("Bun.cron.parse", () => { + // parse() walks the system's local time zone; pin it so the Date.UTC(...) + // fixtures below are host-independent. + const oldTZ = process.env.TZ; + beforeAll(() => void (process.env.TZ = "Etc/UTC")); + afterAll(() => void (oldTZ === undefined ? delete process.env.TZ : (process.env.TZ = oldTZ))); + test("is a function that returns a Date", () => { expect(typeof Bun.cron.parse).toBe("function"); const result = Bun.cron.parse("* * * * *", Date.UTC(2025, 0, 15, 10, 30, 0)); From 35fef5a2c53aea8fba902629141dc8d92dedcd52 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:48:46 +0000 Subject: [PATCH 07/11] [autofix.ci] apply automated fixes --- src/runtime/api/cron.rs | 12 ++++++------ src/runtime/api/cron_parser.rs | 6 ++++-- test/js/bun/cron/cron-local-time.test.ts | 4 +--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/runtime/api/cron.rs b/src/runtime/api/cron.rs index e317c8bc5b9c..26ffa7c3a4c4 100644 --- a/src/runtime/api/cron.rs +++ b/src/runtime/api/cron.rs @@ -684,17 +684,17 @@ fn resolve_cron_tz(global: &JSGlobalObject, opts: JSValue) -> JsResult { return Ok(CronTz::Local); } if !opts.is_object() { - return Err(global.throw_invalid_arguments(format_args!( - "Bun.cron: options must be an object" - ))); + return Err( + global.throw_invalid_arguments(format_args!("Bun.cron: options must be an object")) + ); } let Some(tz_val) = opts.get_truthy(global, "tz")? else { return Ok(CronTz::Local); }; if !tz_val.is_string() { - return Err(global.throw_invalid_arguments(format_args!( - "Bun.cron: options.tz must be a string" - ))); + return Err( + global.throw_invalid_arguments(format_args!("Bun.cron: options.tz must be a string")) + ); } let tz_str = bun_core::OwnedString::new(tz_val.to_bun_string(global)?); let tz_slice = tz_str.to_utf8(); diff --git a/src/runtime/api/cron_parser.rs b/src/runtime/api/cron_parser.rs index 22a4449ed451..120f65533dc9 100644 --- a/src/runtime/api/cron_parser.rs +++ b/src/runtime/api/cron_parser.rs @@ -179,7 +179,8 @@ impl CronExpression { dt: GregorianDateTime, from_ms: f64, ) -> JsResult> { - let result = tz.gregorian_to_ms(global_object, dt.year, dt.month, dt.day, dt.hour, dt.minute)?; + let result = + tz.gregorian_to_ms(global_object, dt.year, dt.month, dt.day, dt.hour, dt.minute)?; // During fall-back, `result` is the FORMER occurrence and the wall-clock // walk steps over the second one. For schedules with `*` minute or `*` // hour, scan real-time minutes (capped at the largest DST shift) for an @@ -224,7 +225,8 @@ impl CronExpression { dt.day += 1; } let n = global_object.ms_to_gregorian_date_time_utc( - global_object.gregorian_date_time_to_ms_utc(dt.year, dt.month, dt.day, 12, 0, 0, 0)?, + global_object + .gregorian_date_time_to_ms_utc(dt.year, dt.month, dt.day, 12, 0, 0, 0)?, ); dt.year = n.year; dt.month = n.month; diff --git a/test/js/bun/cron/cron-local-time.test.ts b/test/js/bun/cron/cron-local-time.test.ts index 95f9fcaf5170..b15f3b59364e 100644 --- a/test/js/bun/cron/cron-local-time.test.ts +++ b/test/js/bun/cron/cron-local-time.test.ts @@ -215,9 +215,7 @@ describe.concurrent("Bun.cron.parse — { tz } option", () => { expect(() => Bun.cron.parse("* * * * *", Date.now(), { tz: "Mars/Olympus" })).toThrow( /unknown time zone 'Mars\/Olympus'/, ); - expect(() => Bun.cron("* * * * *", () => {}, { tz: "Mars/Olympus" })).toThrow( - /unknown time zone 'Mars\/Olympus'/, - ); + expect(() => Bun.cron("* * * * *", () => {}, { tz: "Mars/Olympus" })).toThrow(/unknown time zone 'Mars\/Olympus'/); }); test("non-string tz throws", () => { From bb7f663b24cb5ff90e35ed0ff68f6893e289fbbd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:09:18 +0000 Subject: [PATCH 08/11] cron: suppress Temporal UCalendar LRU under LSan; add CronOptions type fixture - test/leaksan.supp: JSC::TemporalCore::withTimeZone holds an 8-entry per-TimeZone UCalendar LRU for the process lifetime. The ICU OlsonTimeZone transition rules it lazily loads are never freed; the same suppression class as the existing IntlDateTimeFormat entries. - test/integration/bun-types/fixture/cron.ts: cover the new 3-arg Bun.cron.parse(expr, from, { tz }) / Bun.cron(schedule, handler, { tz }) forms and the exported Bun.CronOptions type. --- test/integration/bun-types/fixture/cron.ts | 12 ++++++++++++ test/leaksan.supp | 1 + 2 files changed, 13 insertions(+) diff --git a/test/integration/bun-types/fixture/cron.ts b/test/integration/bun-types/fixture/cron.ts index 9c57805e05ac..a5fa18b53b4b 100644 --- a/test/integration/bun-types/fixture/cron.ts +++ b/test/integration/bun-types/fixture/cron.ts @@ -70,3 +70,15 @@ declare const controller: Bun.CronController; expectType(controller.type).is<"scheduled">(); expectType(controller.cron).is(); expectType(controller.scheduledTime).is(); + +// -- { tz } option -- + +expectType(Bun.cron.parse("@hourly", Date.now(), { tz: "UTC" })).is(); +expectType(Bun.cron.parse("0 9 * * *", new Date(), { tz: "America/New_York" })).is(); +expectType(Bun.cron("* * * * *", () => {}, { tz: "UTC" })).is(); +expectType(Bun.cron("0 9 * * *", async () => {}, { tz: "America/New_York" })).is(); + +// -- CronOptions type is accessible -- + +declare const opts: Bun.CronOptions; +expectType(opts.tz).is(); diff --git a/test/leaksan.supp b/test/leaksan.supp index 69a3e1e5171d..533c81a85661 100644 --- a/test/leaksan.supp +++ b/test/leaksan.supp @@ -85,6 +85,7 @@ leak:JSC::stringProtoFuncReplaceUsingRegExp leak:WebCore::parseTypeAndSubtype leak:runtime.node.util.parse_args.parseArgs leak:JSC::IntlDateTimeFormat::initializeDateTimeFormat +leak:JSC::TemporalCore::withTimeZone leak:WebCore__DOMURL__fileSystemPath leak:runtime.node.node_fs_watcher.FSWatcher.Arguments.fromJS leak:jsc.web_worker.create From b2ac074e2ec45128a18b2f2e4c4529b498be22db Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:48:08 +0000 Subject: [PATCH 09/11] review: reject empty/non-ASCII tz; gate fall-back probe on actual overlap - resolve_cron_tz: use opts.get() + is_undefined_or_null() instead of get_truthy() so { tz: '' } reaches the validator and throws 'unknown time zone' instead of silently falling back to local. Also reject non-ASCII up front so the Latin-1 StringView cast in Bun__resolveTimeZoneID stays sound for its UTF-8 input (all valid IANA names are ASCII). - resolve_local_match: the real-time-minute probe can only find a match when a fall-back transition lies in (from_ms, result] (more real minutes elapsed than wall-clock minutes). Compare the UTC distance of the two local breakdowns against result - from_ms and skip the probe when they're equal, so @hourly chaining outside DST windows no longer does ~59 TZ lookups per call. - cron-local-time.test.ts: cover empty-string and non-ASCII tz, and the fall-back wildcard-hour chain via the tz option (exercises the gate). --- src/runtime/api/cron.rs | 22 ++++++++----- src/runtime/api/cron_parser.rs | 40 ++++++++++++++++++------ test/js/bun/cron/cron-local-time.test.ts | 27 ++++++++++++++++ 3 files changed, 72 insertions(+), 17 deletions(-) diff --git a/src/runtime/api/cron.rs b/src/runtime/api/cron.rs index 26ffa7c3a4c4..d4e3a9edbfe3 100644 --- a/src/runtime/api/cron.rs +++ b/src/runtime/api/cron.rs @@ -688,9 +688,12 @@ fn resolve_cron_tz(global: &JSGlobalObject, opts: JSValue) -> JsResult { global.throw_invalid_arguments(format_args!("Bun.cron: options must be an object")) ); } - let Some(tz_val) = opts.get_truthy(global, "tz")? else { + let Some(tz_val) = opts.get(global, "tz")? else { return Ok(CronTz::Local); }; + if tz_val.is_undefined_or_null() { + return Ok(CronTz::Local); + } if !tz_val.is_string() { return Err( global.throw_invalid_arguments(format_args!("Bun.cron: options.tz must be a string")) @@ -698,13 +701,18 @@ fn resolve_cron_tz(global: &JSGlobalObject, opts: JSValue) -> JsResult { } let tz_str = bun_core::OwnedString::new(tz_val.to_bun_string(global)?); let tz_slice = tz_str.to_utf8(); - match JSGlobalObject::resolve_time_zone_id(tz_slice.slice()) { - Some(id) => Ok(CronTz::Named(id)), - None => Err(global.throw_invalid_arguments(format_args!( - "Bun.cron: unknown time zone '{}'", - bstr::BStr::new(tz_slice.slice()) - ))), + let tz_bytes = tz_slice.slice(); + // IANA names are ASCII; rejecting here keeps the Latin-1 StringView cast in + // Bun__resolveTimeZoneID sound for non-ASCII UTF-8 input. + if tz_bytes.is_ascii() + && let Some(id) = JSGlobalObject::resolve_time_zone_id(tz_bytes) + { + return Ok(CronTz::Named(id)); } + Err(global.throw_invalid_arguments(format_args!( + "Bun.cron: unknown time zone '{}'", + bstr::BStr::new(tz_bytes) + ))) } // -- JS entry point -- (free fn: `#[host_fn]` Free shim calls bare `cron_register(..)`) diff --git a/src/runtime/api/cron_parser.rs b/src/runtime/api/cron_parser.rs index 120f65533dc9..754e20a3f6fc 100644 --- a/src/runtime/api/cron_parser.rs +++ b/src/runtime/api/cron_parser.rs @@ -178,21 +178,40 @@ impl CronExpression { tz: CronTz, dt: GregorianDateTime, from_ms: f64, + from_dt: GregorianDateTime, ) -> JsResult> { let result = tz.gregorian_to_ms(global_object, dt.year, dt.month, dt.day, dt.hour, dt.minute)?; // During fall-back, `result` is the FORMER occurrence and the wall-clock // walk steps over the second one. For schedules with `*` minute or `*` // hour, scan real-time minutes (capped at the largest DST shift) for an - // earlier match in the repeated window. + // earlier match in the repeated window. The scan can only find a match + // when more real minutes elapsed than wall-clock minutes (i.e. a + // fall-back transition lies in (from_ms, result]); the UTC-distance of + // the two local breakdowns gives the wall-clock delta without a TZ + // lookup. if self.minutes == ALL_MINUTES || self.hours == ALL_HOURS { - let mut probe = ((from_ms / MINUTE_MS).floor() + 1.0) * MINUTE_MS; - let cap = result.min(from_ms + (MAX_DST_SHIFT_MIN + 1.0) * MINUTE_MS); - while probe < cap { - if self.matches_instant(global_object, tz, probe) { - return Ok(Some(probe)); + let wall_from = global_object.gregorian_date_time_to_ms_utc( + from_dt.year, + from_dt.month, + from_dt.day, + from_dt.hour, + from_dt.minute, + 0, + 0, + )?; + let wall_dt = global_object.gregorian_date_time_to_ms_utc( + dt.year, dt.month, dt.day, dt.hour, dt.minute, 0, 0, + )?; + if result - from_ms > wall_dt - wall_from { + let mut probe = ((from_ms / MINUTE_MS).floor() + 1.0) * MINUTE_MS; + let cap = result.min(from_ms + (MAX_DST_SHIFT_MIN + 1.0) * MINUTE_MS); + while probe < cap { + if self.matches_instant(global_object, tz, probe) { + return Ok(Some(probe)); + } + probe += MINUTE_MS; } - probe += MINUTE_MS; } } Ok(if result > from_ms { Some(result) } else { None }) @@ -207,8 +226,9 @@ impl CronExpression { from_ms: f64, tz: CronTz, ) -> JsResult> { - let mut dt = tz.ms_to_gregorian(global_object, from_ms); - let start_year = dt.year; + let from_dt = tz.ms_to_gregorian(global_object, from_ms); + let start_year = from_dt.year; + let mut dt = from_dt; dt.minute += 1; while dt.year - start_year <= 8 { @@ -256,7 +276,7 @@ impl CronExpression { continue; } - if let Some(r) = self.resolve_local_match(global_object, tz, dt, from_ms)? { + if let Some(r) = self.resolve_local_match(global_object, tz, dt, from_ms, from_dt)? { return Ok(Some(r)); } dt.minute += 1; diff --git a/test/js/bun/cron/cron-local-time.test.ts b/test/js/bun/cron/cron-local-time.test.ts index b15f3b59364e..638acff1e733 100644 --- a/test/js/bun/cron/cron-local-time.test.ts +++ b/test/js/bun/cron/cron-local-time.test.ts @@ -211,6 +211,33 @@ describe.concurrent("Bun.cron.parse — { tz } option", () => { expect(next).toBe("2025-11-03T06:30:00.000Z"); }); + test("DST via tz option (fall-back, wildcard hour fires through both occurrences)", async () => { + const out = await evalInTZ( + "UTC", + `let t = new Date("2025-11-02T03:59:00Z"); + const seq = []; + for (let i = 0; i < 4; i++) { t = Bun.cron.parse("0 * * * *", t, {tz: "America/New_York"}); seq.push(t.toISOString()); } + process.stdout.write(JSON.stringify(seq))`, + ); + expect(JSON.parse(out)).toEqual([ + "2025-11-02T04:00:00.000Z", // 0:00 EDT + "2025-11-02T05:00:00.000Z", // 1st 1:00 EDT + "2025-11-02T06:00:00.000Z", // 2nd 1:00 EST + "2025-11-02T07:00:00.000Z", // 2:00 EST + ]); + }); + + test("empty-string tz throws (not silently falling back to local)", () => { + expect(() => Bun.cron.parse("* * * * *", Date.now(), { tz: "" })).toThrow(/unknown time zone ''/); + expect(() => Bun.cron("* * * * *", () => {}, { tz: "" })).toThrow(/unknown time zone ''/); + }); + + test("non-ASCII tz throws", () => { + expect(() => Bun.cron.parse("* * * * *", Date.now(), { tz: "Europe/Zürich" })).toThrow( + /unknown time zone 'Europe\/Zürich'/, + ); + }); + test("unknown tz throws", () => { expect(() => Bun.cron.parse("* * * * *", Date.now(), { tz: "Mars/Olympus" })).toThrow( /unknown time zone 'Mars\/Olympus'/, From 3ccd95e807f1e1adb2646c254a7136df244c7cdb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:03:49 +0000 Subject: [PATCH 10/11] test: restore Feb-30 perf assertion in-process via { tz: 'UTC' } The UTC normalization round-trip plus the tz option make the impossible-date search host-TZ-independent and fast enough to keep the sub-50ms guard (measured ~2ms in debug ASAN). --- test/js/bun/cron/cron-parse.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/js/bun/cron/cron-parse.test.ts b/test/js/bun/cron/cron-parse.test.ts index 2f334dd2d784..8df5c635d754 100644 --- a/test/js/bun/cron/cron-parse.test.ts +++ b/test/js/bun/cron/cron-parse.test.ts @@ -38,8 +38,10 @@ describe.concurrent("Bun.cron.parse — algorithm (pinned TZ=UTC)", () => { expect(await parseUTC("0 0 29 2 *", "2026-01-01T00:00:00Z")).toBe("2028-02-29T00:00:00.000Z"); }); - test("impossible day/month (Feb 30) returns null", async () => { - expect(await parseUTC("0 0 30 2 *", "2026-01-01T00:00:00Z")).toBe("null"); + test("impossible day/month (Feb 30) returns null quickly", () => { + const t = performance.now(); + expect(Bun.cron.parse("0 0 30 2 *", new Date("2026-01-01T00:00:00Z"), { tz: "UTC" })).toBeNull(); + expect(performance.now() - t).toBeLessThan(50); }); test("DOM/DOW OR semantics when both restricted", async () => { From 8520c37f1cd8dbf91cbe7768418c8019f835b2b0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:39:37 +0000 Subject: [PATCH 11/11] cron: probe forward when ambiguous dt maps before from_ms; fix TZ restore - resolve_local_match: when the wall-clock candidate is in a fall-back's repeated window and maps to its earlier instant (<= from_ms), the later instant may still match. Add result <= from_ms as a second probe trigger and don't cap at result in that branch, so chaining '* * * * *' or '*/N * * * *' from the transition instant walks the whole repeated hour instead of jumping to the next unambiguous minute. Regression-tested with a NY fall-back every-minute chain and a */15 chain. - cron.test.ts: restore process.env.TZ via assignment ('' when unset) instead of delete, since deleting the CustomAccessor removes it without clearing the WTF::setTimeZoneOverride and would leak Etc/UTC to later test files on hosts that don't export TZ. --- src/runtime/api/cron_parser.rs | 21 +++++++++++++-------- test/js/bun/cron/cron-local-time.test.ts | 21 +++++++++++++++++++++ test/js/bun/cron/cron.test.ts | 6 ++++-- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/runtime/api/cron_parser.rs b/src/runtime/api/cron_parser.rs index 754e20a3f6fc..ce4a3c19b9bd 100644 --- a/src/runtime/api/cron_parser.rs +++ b/src/runtime/api/cron_parser.rs @@ -184,12 +184,14 @@ impl CronExpression { tz.gregorian_to_ms(global_object, dt.year, dt.month, dt.day, dt.hour, dt.minute)?; // During fall-back, `result` is the FORMER occurrence and the wall-clock // walk steps over the second one. For schedules with `*` minute or `*` - // hour, scan real-time minutes (capped at the largest DST shift) for an - // earlier match in the repeated window. The scan can only find a match - // when more real minutes elapsed than wall-clock minutes (i.e. a - // fall-back transition lies in (from_ms, result]); the UTC-distance of - // the two local breakdowns gives the wall-clock delta without a TZ - // lookup. + // hour, scan real-time minutes (capped at the largest DST shift) to + // recover the repeated window. Two cases need the scan: + // (a) result > from_ms but a fall-back transition lies in + // (from_ms, result] — detected by more real than wall-clock + // minutes having elapsed (UTC-distance of the two local + // breakdowns gives the wall-clock delta without a TZ lookup). + // (b) result <= from_ms — `dt` is ambiguous and mapped to its + // earlier instant, already past; the later instant may match. if self.minutes == ALL_MINUTES || self.hours == ALL_HOURS { let wall_from = global_object.gregorian_date_time_to_ms_utc( from_dt.year, @@ -203,9 +205,12 @@ impl CronExpression { let wall_dt = global_object.gregorian_date_time_to_ms_utc( dt.year, dt.month, dt.day, dt.hour, dt.minute, 0, 0, )?; - if result - from_ms > wall_dt - wall_from { + if result <= from_ms || result - from_ms > wall_dt - wall_from { let mut probe = ((from_ms / MINUTE_MS).floor() + 1.0) * MINUTE_MS; - let cap = result.min(from_ms + (MAX_DST_SHIFT_MIN + 1.0) * MINUTE_MS); + let mut cap = from_ms + (MAX_DST_SHIFT_MIN + 1.0) * MINUTE_MS; + if result > from_ms { + cap = cap.min(result); + } while probe < cap { if self.matches_instant(global_object, tz, probe) { return Ok(Some(probe)); diff --git a/test/js/bun/cron/cron-local-time.test.ts b/test/js/bun/cron/cron-local-time.test.ts index 638acff1e733..8fc8ee87ee63 100644 --- a/test/js/bun/cron/cron-local-time.test.ts +++ b/test/js/bun/cron/cron-local-time.test.ts @@ -101,6 +101,27 @@ describe.concurrent("Bun.cron.parse — DST transitions", () => { expect(next).toBe("2025-11-02T06:00:00.000Z"); }); + test("fall-back: every-minute chained from the transition walks the repeated hour", async () => { + // From 05:59Z, chaining * * * * * must hit every real-time minute through + // the repeated 1:xx EST window (06:00Z..06:59Z), not jump to 07:00Z. + expect(await chainInTZ("America/New_York", "* * * * *", "2025-11-02T05:59:00Z", 4)).toEqual([ + "2025-11-02T06:00:00.000Z", + "2025-11-02T06:01:00.000Z", + "2025-11-02T06:02:00.000Z", + "2025-11-02T06:03:00.000Z", + ]); + }); + + test("fall-back: */15 chained from the transition fires at each quarter-hour", async () => { + expect(await chainInTZ("America/New_York", "*/15 * * * *", "2025-11-02T05:59:00Z", 5)).toEqual([ + "2025-11-02T06:00:00.000Z", + "2025-11-02T06:15:00.000Z", + "2025-11-02T06:30:00.000Z", + "2025-11-02T06:45:00.000Z", + "2025-11-02T07:00:00.000Z", + ]); + }); + test("spring-forward: only the first match in the gap fires shifted (croner semantics)", async () => { // "*/15 2 * * *" has 4 occurrences in the missing hour. Bun fires the first // shifted to 3:00, then skips to next day. cron-parser shifts all four. diff --git a/test/js/bun/cron/cron.test.ts b/test/js/bun/cron/cron.test.ts index 80a6a0b11dcb..4bca6d3f9bd4 100644 --- a/test/js/bun/cron/cron.test.ts +++ b/test/js/bun/cron/cron.test.ts @@ -1389,10 +1389,12 @@ function nextN(expr: string, from: number, n: number): number[] { describe("Bun.cron.parse", () => { // parse() walks the system's local time zone; pin it so the Date.UTC(...) - // fixtures below are host-independent. + // fixtures below are host-independent. `delete process.env.TZ` would remove + // the accessor without clearing the WTF::setTimeZoneOverride, so restore via + // assignment (empty string reverts to the system zone). const oldTZ = process.env.TZ; beforeAll(() => void (process.env.TZ = "Etc/UTC")); - afterAll(() => void (oldTZ === undefined ? delete process.env.TZ : (process.env.TZ = oldTZ))); + afterAll(() => void (process.env.TZ = oldTZ ?? "")); test("is a function that returns a Date", () => { expect(typeof Bun.cron.parse).toBe("function");