Skip to content

feat(cron): add Bun.cron API for OS-level cron jobs and expression parsing#26999

Open
Jarred-Sumner wants to merge 6 commits intomainfrom
claude/cron
Open

feat(cron): add Bun.cron API for OS-level cron jobs and expression parsing#26999
Jarred-Sumner wants to merge 6 commits intomainfrom
claude/cron

Conversation

@Jarred-Sumner
Copy link
Collaborator

@Jarred-Sumner Jarred-Sumner commented Feb 13, 2026

Summary

Adds a complete cron API to Bun: register OS-level cron jobs, remove them, and parse cron expressions to compute the next matching time.

New APIs

// Register a cron job (uses crontab on Linux, launchd on macOS, Task Scheduler on Windows)
await Bun.cron("./worker.ts", "30 2 * * MON", "weekly-report");

// Parse a cron expression → next matching UTC Date
const next = Bun.cron.parse("*/15 * * * *");
const weekday = Bun.cron.parse("0 9 * * MON-FRI");
const jan1 = Bun.cron.parse("@yearly");

// Remove a registered job
await Bun.cron.remove("weekly-report");

How a cron job runs

When registered, the OS scheduler invokes bun run --cron-title=<title> --cron-period='<schedule>' <script> at each scheduled time. Bun imports the module, finds default.scheduled(), and calls it:

// worker.ts — the script registered with Bun.cron()
export default {
  async scheduled(controller) {
    // controller.type === "scheduled"
    // controller.cron === "30 2 * * 1"       (the normalized schedule)
    // controller.scheduledTime === 1737340200000  (ms since epoch)

    await doWork();
  },
};

This follows the Cloudflare Workers Cron Triggers API. The handler can be async — Bun waits for the promise to settle before exiting. --cron-title and --cron-period must be provided together (validated at startup).

Cron expression features

  • Standard 5-field format: minute hour day-of-month month day-of-week
  • Named days: SUNSAT, SundaySaturday (case-insensitive)
  • Named months: JANDEC, JanuaryDecember (case-insensitive)
  • Sunday as 7: weekday field accepts 7 as alias for 0
  • Nicknames: @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly
  • POSIX OR logic: when both day-of-month and day-of-week are specified (neither is *), either matching fires the job — standard POSIX cron behavior
  • All operators: *, , (list), - (range), / (step)
  • Named values and nicknames are normalized to numeric form before registration

How Bun.cron() works per platform

Platform Backend Logs
Linux crontab journalctl -u cron
macOS launchd plist with cartesian-product StartCalendarInterval /tmp/bun.cron.<title>.{stdout,stderr}.log
Windows schtasks with /st start time and /d day Event Viewer → TaskScheduler

Re-registering with the same title overwrites the existing job in-place.

Implementation details

  • Parser (cron_parser.zig): Bitset representation — u64 for minutes, u32 for hours/days, u16 for months, u8 for weekdays. Uses bun.ComptimeStringMap with getASCIIICaseInsensitive() for name lookups and bun.strings throughout.
  • Next-occurrence algorithm: Advances by largest non-matching unit (month → day → hour → minute), normalizes via JSC's UTC GregorianDateTime functions each iteration. Searches up to ~4 years.
  • C++ bindings: Added Bun__gregorianDateTimeToMSUTC and Bun__msToGregorianDateTimeUTC for UTC time decomposition/recomposition.
  • Schedule normalization: Named values (Monday, @daily) are converted to numeric form (1, 0 0 * * *) via formatNumeric() before platform registration.
  • Path resolution: Uses vm.transpiler.resolver.resolve() relative to the caller's source directory for full module resolution (TypeScript, package.json exports, node_modules).
  • macOS cronToCalendarInterval: Now expands comma-separated values into cartesian-product <dict> entries, supporting complex expressions.
  • Windows cronToSchtasks: Now passes /st HH:MM start time and /d DAY for DAILY/WEEKLY schedules.
  • --cron-title/--cron-period validation: Must be provided together (fails fast at startup otherwise).
  • bun.sys.getcwd: Uses bun's syscall wrapper instead of std.posix.getcwd.
  • bun.env_var: Uses type-safe cached env var accessors (HOME, PATH) instead of bun.getenvZ.

Bug fixes

  • filterCrontab substring matching: Fixed pre-existing bug where removing job "test" could also remove "test-cleanup" due to indexOf substring matching. Changed to exact line match with bun.strings.eql.

Test plan

  • 61 tests, 161 assertions pass (bun bd test test/js/bun/cron/cron.test.ts)
  • Cross-platform compilation: 61/61 targets (bun run zig:check-all)
  • Tests fail with system bun (verifying they test new functionality)
  • Registration/removal tests gated with describe.skipIf(!isLinux) and save/restore original crontab
  • Sequential parse() calls verify patterns produce correct sequences (not just single results)
  • OR logic explicitly tested: 0 0 15 * FRI produces both 15th-of-month and Friday matches, with assertion that Jan 15 (Wednesday) appears in results
  • All 7 day names and 12 month names verified via getUTCDay()/getUTCMonth()
  • MON-FRI/2 verified to produce Mon, Wed, Fri sequence
  • Nicknames verified against equivalent 5-field expressions over multiple results
  • Edge cases: leap year Feb 29, day-31 month skipping, impossible dates return null, year boundary crossing, NaN/Infinity rejection

🤖 Generated with Claude Code

@Jarred-Sumner Jarred-Sumner requested a review from alii as a code owner February 13, 2026 11:09
@robobun
Copy link
Collaborator

robobun commented Feb 13, 2026

Updated 9:59 AM PT - Mar 6th, 2026

@claude, your commit 8b135fe has 3 failures in Build #38741 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 26999

That installs a local version of the PR into your bun-26999 executable, so you can run:

bun-26999 --bun

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a Bun.cron feature: docs and types, a new Bun.cron JS API (register/remove/parse), Zig implementation (parser and cross-platform installers), CLI flags and cron execution mode, UTC date-time bindings, runtime wiring, and tests.

Changes

Cohort / File(s) Summary
Documentation
docs/docs.json, docs/runtime/cron.mdx
Add runtime navigation entry and full user-facing documentation for Bun.cron.
Type Definitions
packages/bun-types/bun.d.ts
Introduce CronWithAutocomplete and Bun.cron typings: callable cron(path, schedule, title): Promise<void>, cron.remove(title): Promise<void>, and `cron.parse(expression, relativeDate?): Date
CLI Flags & Options
src/cli.zig, src/cli/Arguments.zig
Add cron_title/cron_period runtime fields and --cron-title/--cron-period CLI flags with mutual-presence validation.
Runtime Entrypoint
src/bun.js.zig
When cron options present, emit an eval script importing the module and calling default.scheduled(controller); add escapeForJSString helper and switch eval entry path.
Global Bun API Property
src/bun.js/api/BunObject.zig, src/bun.js/bindings/BunObject+exports.h, src/bun.js/bindings/BunObject.cpp
Register a lazy Bun.cron property and corresponding getter/export table entries.
Process Exit Handler Integration
src/bun.js/api/bun/process.zig
Add CronRegisterJob and CronRemoveJob to ProcessExitHandler.TaggedPointer and dispatch them in the exit handler.
Cron Core (Zig)
src/bun.js/api/cron.zig
Implement cron subsystem: CronRegisterJob/CronRemoveJob, cross-platform install/remove flows (Linux crontab, macOS launchd plist, Windows schtasks), state machines, process spawning, and JS-facing entry points (cronRegister, cronRemove, cronParse, getCronObject). Review for platform-specific flows and large surface area.
Cron Parser (Zig)
src/bun.js/api/cron_parser.zig
Add CronExpression parser/validator and next-occurrence computation for 5-field cron syntax, nicknames, named tokens, and utilities used by cron core.
UTC Date-Time Bindings
src/bun.js/bindings/JSGlobalObject.zig, src/bun.js/bindings/bindings.cpp
Add GregorianDateTime and UTC conversion helpers: gregorianDateTimeToMSUTC and msToGregorianDateTimeUTC (C++ exports) used by cron computations.
Runtime eval & cron wiring
src/bun.js/api/BunObject.zig, src/bun.js.zig
Wire cron property into runtime bootstrap; cron-mode modifies eval_source/entry_path and depends on new UTC/date bindings and JS-escaping helper.
Tests
test/js/bun/cron/cron.test.ts, test/integration/bun-types/fixture/cron.ts
Add comprehensive JS tests (API surface, parse semantics, Linux-gated registration/removal, execution-mode behavior) and TypeScript fixture validating types and overloads.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly and concisely summarizes the main change: adding a Bun.cron API for OS-level cron job management and expression parsing.
Description check ✅ Passed Description comprehensively covers what the PR does (new APIs, platform support, implementation details) and includes test plan demonstrating verification across platforms with 61 passing tests.

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


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

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🤖 Fix all issues with AI agents
In `@packages/bun-types/bun.d.ts`:
- Line 7274: The example for Bun.cron.parse uses Date.UTC(2025, 6, 1) which
incorrectly sets the month to July because Date.UTC months are 0-based; update
the example so the Date.UTC month matches the cron string—either change
Date.UTC(2025, 6, 1) to Date.UTC(2025, 0, 1) to represent January, or change the
cron expression "0 0 1 JAN *" to use "JUL" to match month index 6; adjust the
example so Bun.cron.parse and Date.UTC refer to the same month.
- Around line 7259-7262: The example defining the variable nextJan1 uses
Date.UTC with month index 6 (July) but should represent January; update the
Date.UTC call in the nextJan1 example from Date.UTC(2025, 6, 1) to
Date.UTC(2025, 0, 1) so the month is correctly January (JavaScript Date months
are 0-based).

In `@src/bun.js.zig`:
- Around line 224-229: Replace std.posix.getcwd call with bun.sys.getcwd and
handle the Maybe return using a switch to extract the .result or handle .err;
ensure you allocate a buffer large enough for cwd + trigger.len (use
bun.PathBuffer or a larger custom buffer instead of the current fixed-size
eval_path_buf if needed), check the resulting cwd length against your buffer
capacity before memcpy, and propagate or log the bun.sys.Error on the error
branch rather than ignoring it; update references to cwd_slice, eval_path_buf,
and eval_entry_path accordingly.

In `@src/bun.js/api/cron_parser.zig`:
- Around line 35-61: Replace all uses of std.mem string helpers with bun.strings
SIMD-accelerated variants: change std.mem.trim(u8, input, " \t") in the cron
parser to bun.strings.trim, change std.mem.eql(u8, fields[2], "*") and
std.mem.eql(u8, fields[4], "*") to bun.strings.eql, and update helper functions
splitRange, parseWeekdayName, and parseMonthName to use bun.strings.indexOf /
bun.strings.indexOfScalar and bun.strings.eql instead of
std.mem.indexOfScalar/std.mem.eql; keep the same argument types and semantics so
parseNickname, parseField, and the returned struct usage remain unchanged.

In `@src/bun.js/api/cron.zig`:
- Around line 933-955: cronToCalendarInterval currently treats each cron field
as either "*" or a single integer (using std.fmt.parseInt) which rejects
ranges/lists/steps; fix by expanding fields into explicit integer lists and
emitting one <dict> per StartCalendarInterval combination: replace the
single-value parse in cronToCalendarInterval with a parser that returns []i32
for each field (supporting "*", single value, ranges, comma-separated lists, and
step syntax), then compute the cartesian product of those five integer lists and
for each tuple generate the same "<key>...<integer>..." block (append a separate
"    <dict>\n...    </dict>\n" for each combination to result). Ensure you keep
the existing keys array and result Managed(u8) usage, free any allocated temp
strings, and return error.InvalidCron on malformed field syntax.
- Around line 213-226: The temp file creation uses predictable names via
allocPrintZ and bun.sys.File.openat with O_CREAT|O_TRUNC (assigned to
this.tmp_path and written via file.writeAll), which is vulnerable to
symlink/TOCTOU attacks; change to create a secure unique temp file
(mkstemp-style) or open with O_EXCL and a cryptographically random suffix and
retry on EEXIST, or create a private temp directory per-run and use a randomized
filename, then store that secure path in this.tmp_path and proceed to write via
file.writeAll; ensure proper cleanup on errors and keep using defer file.close()
after successful open.
- Around line 744-749: The property name strings on the cron object are created
with jsc.ZigString.static; update getCronObject so the two cron_fn.put calls use
bun.String.static for the property keys instead (replace
jsc.ZigString.static("remove") and jsc.ZigString.static("parse") with
bun.String.static("remove") and bun.String.static("parse") respectively) to
follow the JS interop guideline; keep the same targets (cron_fn, remove_fn,
parse_fn) and call signatures.
- Around line 483-491: The parsed cron time/day values are being discarded:
extend the SchtasksParams struct to include start_time (string for /st) and
day_spec (string for /d), update cronToSchtasks to populate these fields for
DAILY (set start_time from parsed minute/hour) and WEEKLY (set start_time and
day_spec from parsed minute/hour/dow), and then modify startWindows where argv
is built to append "/st", schtasks_params.start_time.ptr and, for WEEKLY
schedules, "/d", schtasks_params.day_spec.ptr (only when non-null) alongside the
existing "/sc" and "/mo" so the schtasks command uses the intended start time
and day.
- Around line 256-260: Replace uses of bun.getenvZ with the type-safe cached
accessors: where the code currently calls bun.getenvZ("HOME") (producing the
local variable home and following this.setErr/this.finish error handling),
change it to bun.env_var.HOME.get() and adjust the nil/optional check
accordingly; likewise replace bun.getenvZ("HOME") at the other occurrence and
replace bun.getenvZ("PATH") with bun.env_var.PATH.get(); keep the existing error
handling logic (this.setErr and this.finish) but use the new accessor's
return/optional semantics.
- Around line 894-905: The resolvePath function currently checks POSIX-only
absolute paths and concatenates with "/" and std APIs; change it to use bun.path
utilities: replace the leading-slash check with bun.path.isAbsolute(.auto,
path), get the caller directory via bun.path.dirname(caller_path.slice(),
.auto), allocate a bun.PathBuffer instead of a std.fs.max_path_bytes buffer, and
produce the joined absolute string using bun.path.joinAbsStringBufZ(&pathBuf,
caller_dir, path, bun.default_allocator) (or the exact bun.path.join API
available) then dup the resulting Z string with bun.default_allocator.dupeZ;
update any error handling to return error.PathTooLong as before.
- Around line 877-884: The code uses std.mem string ops (std.mem.splitScalar,
std.mem.eql, std.mem.trim, std.mem.startsWith) on the lines loop that compares
against marker and elsewhere; replace these with the bun.strings equivalents for
SIMD acceleration: use bun.strings.splitScalar (or bun.strings.split if
available) for splitting, bun.strings.trim for trimming, bun.strings.eql for
equality checks, and bun.strings.startsWith for prefix checks; update usages
around the variables lines, line, marker, and skip_next and in the later block
(previously lines ~981-999) to call bun.strings.* so the signatures/types match
(convert to the expected slice/array types if needed) and keep the same control
flow behavior.

In `@src/cli/Arguments.zig`:
- Around line 828-833: Ensure --cron-title and --cron-period are provided
together: after reading args.option for both (the current assignments to
ctx.runtime_options.cron_title and ctx.runtime_options.cron_period), add a
validation that if one of ctx.runtime_options.cron_title or
ctx.runtime_options.cron_period is set and the other is not, emit a clear error
and exit/fail-fast (use the same error reporting mechanism used elsewhere in
this module). Update the logic around args.option(...) handling so you check
both values after parsing and return a descriptive error when only one flag is
present.

In `@test/js/bun/cron/cron.test.ts`:
- Around line 13-43: The clearTestCrontabs helper currently requires fs
dynamically and only removes bun-marked entries, leaving manually injected
crontab lines behind; move the fs import (writeFileSync, unlinkSync) to module
scope, modify clearTestCrontabs to snapshot the original crontab via
readCrontab() at test start and restore it in a finally block, and extract the
temporary-file + crontab-write logic into a shared helper (e.g.,
writeCrontabTempAndInstall) used by clearTestCrontabs so the original crontab
(including non-bun entries like the test's "0 0 * * * /usr/bin/some-other-job")
is always restored; update references to readCrontab and
Bun.spawnSync("/usr/bin/crontab", tmpFile) accordingly.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Fix all issues with AI agents
In `@docs/runtime/cron.mdx`:
- Around line 116-121: The example's inline comment contradicts the POSIX OR
behavior: update the comment next to Bun.cron.parse("0 0 15 * FRI") to reflect
OR semantics (e.g., "Fires on the 15th of every month OR every Friday") and
ensure surrounding explanatory text explicitly states that when both
day-of-month and day-of-week are specified the expression matches if either
condition is true; keep the symbol Bun.cron.parse and the sample cron string
unchanged.

In `@packages/bun-types/bun.d.ts`:
- Around line 7259-7262: The JSDoc example for the parameter named "expression"
contains an unescaped "*/" which prematurely closes the comment; update the
example string in the JSDoc (the `"*/15 * * * *"` part) to escape the slash
(e.g. change `*/` to `*\/`) so the comment doesn't terminate early and
TypeScript can parse the declaration for the function documented by these params
(`expression`, `relativeDate`).

In `@src/bun.js.zig`:
- Around line 217-223: The generated JS string for cron_script currently
interpolates ctx.runtime_options.cron_period raw into std.fmt.allocPrint, which
can break the script if the period contains quotes, backslashes or line
terminators; modify the allocPrint call that builds cron_script to run
ctx.runtime_options.cron_period through escapeForJSString (the same helper used
for escaped_path) and pass that escaped_period into the format args so the
embedded "{s}" for the period is safe; update any variable name used (e.g.,
escaped_period) and ensure the call site inside the cron_script format uses that
escaped value.
- Around line 230-233: The Source stored in vm.module_loader.eval_source is
holding slices into the stack buffer (eval_entry_path from eval_path_buf) so
they become dangling after boot() returns; to fix, ensure a heap-owned path is
passed into logger.Source.initPathString (or fs.Path.init) instead of the stack
buffer: allocate a dupe/copy of eval_entry_path on the heap (using
bun.default_allocator.dupe or equivalent) before calling
logger.Source.initPathString (or add/use a variant that accepts/assumes
heap-owned buffers), assign that heap buffer to script_source and only then set
vm.module_loader.eval_source, and keep the existing run.entry_path dupe logic
consistent so both run.entry_path and script_source.path reference heap-owned
memory.

In `@src/bun.js/api/BunObject.zig`:
- Around line 71-73: The inline `@import` call used in the cron property
initializer should be moved to a file-level constant: add a bottom-of-file
import binding (e.g., const cronModule = `@import`("./cron.zig");) and change the
property initializer from
toJSLazyPropertyCallback(`@import`("./cron.zig").getCronObject) to
toJSLazyPropertyCallback(cronModule.getCronObject); do the same for the other
inline imports referenced around lines 141-144 so all `@import` usages are
declared as file-level constants and never used inline inside initializers or
functions.

In `@src/bun.js/api/cron.zig`:
- Around line 989-994: The current use of a thread-local static buffer
(static_min in cronToSchtasks) is fragile because it returns a pointer into
mutable static storage that startWindows and spawnCmd currently consume
immediately; change cronToSchtasks to return an owned allocated string (e.g.,
[]u8 or []const u8 from the allocator) instead of &static_min.buf so each call
gets its own buffer, copy n into that allocation using `@memcpy`, and update
callers (startWindows / spawnCmd) to accept and free (or let allocator-managed
scope drop) the returned allocation; alternatively, if you prefer minimal
change, add a clear comment on cronToSchtasks that documents the requirement
that the returned pointer must be consumed synchronously and may be corrupted by
subsequent calls.
- Around line 1028-1032: The allocPrintZ helper does an extra allocation by
calling std.fmt.allocPrint to create slice, copying it with allocator.dupeZ (to
add a NUL) and freeing the original; replace this with a single allocation
approach — either call std.fmt.allocPrintZ(allocator, fmt, args) if available,
or allocate a buffer sized slice.len + 1 up front and format directly into it so
you can return the NUL-terminated buffer without the intermediate alloc/free
(update the function allocPrintZ and remove the unnecessary allocator.free of
the temporary slice and allocator.dupeZ usage).

@claude
Copy link
Contributor

claude bot commented Feb 13, 2026

Code Review

Newest first

🔴 c0bec — 3 issue(s) found

Issue Severity File
macOS plist missing <array> wrapper for multiple StartCalendarInterval dicts 🔴 src/bun.js/api/cron.zig
Windows schtasks argv null entries drop /f flag 🔴 src/bun.js/api/cron.zig
cronToSchtasks fails on named weekdays 🔴 src/bun.js/api/cron.zig
Previous reviews

🔴 b3629 — 3 issue(s) found

Issue Severity File
Windows schtasks argv truncated by null entries 🔴 src/bun.js/api/cron.zig
Missing <array> wrapper for macOS launchd plist 🔴 src/bun.js/api/cron.zig
HOURLY schtasks ignores minute value 🔴 src/bun.js/api/cron.zig

🔴 317a4 — 1 issue(s) found

Issue Severity File
Windows schtasks argv: null elements drop /f flag for non-WEEKLY schedules 🔴 src/bun.js/api/cron.zig

🔴 4bbbd — 3 issue(s) found

Issue Severity File
Crontab/plist/schtasks entries pass bun_exe twice, making cron jobs always fail 🔴 src/bun.js/api/cron.zig
cronToSchtasks never matches */N patterns because schedule is pre-normalized (Windows) 🔴 src/bun.js/api/cron.zig
cronToCalendarInterval fails on normalized schedules with comma-separated values (macOS) 🔴 src/bun.js/api/cron.zig

Powered by Claude Code Review

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/bun.js.zig`:
- Around line 592-613: The function escapeForJSString creates a
std.array_list.Managed (result) and may leak its allocation if an append fails;
fix by adding errdefer result.deinit() immediately after the Managed init (e.g.,
after var result = try std.array_list.Managed(u8).initCapacity(...)) so the list
is cleaned up on error, and keep the existing return result.toOwnedSlice() on
success (errdefer ensures no deinit runs on success). Ensure the early
non-escaping path still uses allocator.dupe as before.

In `@test/js/bun/cron/cron.test.ts`:
- Around line 199-205: The test currently spawns a crontab process using "using
setup = Bun.spawn(...)" and writes to setup.stdin but doesn't verify the process
exit status; capture the process result by awaiting setup.exited into a variable
(e.g., result) and assert the exit code is zero (or the expected success code)
before proceeding with the rest of the test so a failing crontab call fails the
test; update the section around the Bun.spawn call and the subsequent
setup.exited usage to await and assert the exit code.

@claude
Copy link
Contributor

claude bot commented Feb 13, 2026

Code Review

Newest first

🔴 3f80d — 2 issue(s) found

Issue Severity File
Crontab entry passes bun_exe as script argument 🔴 src/bun.js/api/cron.zig
Windows schtasks argv null terminates before /f flag 🔴 src/bun.js/api/cron.zig

🔴 da282 — 3 issue(s) found

Issue Severity File
cronToCalendarInterval fails on comma-separated values 🔴 src/bun.js/api/cron.zig
Duplicate bun_exe in cron command 🔴 src/bun.js/api/cron.zig
cronToSchtasks pattern matching fails 🔴 src/bun.js/api/cron.zig

Powered by Claude Code Review

@claude
Copy link
Contributor

claude bot commented Feb 13, 2026

Code Review

Newest first

🔴 da282 — 2 issue(s) found

Issue Severity File
cronToCalendarInterval fails on comma-separated normalized cron fields 🔴 src/bun.js/api/cron.zig
SpawnOptions windows.loop uninitialized causing UB 🔴 src/bun.js/api/cron.zig

Powered by Claude Code Review

@claude
Copy link
Contributor

claude bot commented Feb 13, 2026

Code Review

Newest first

🔴 1f6db — 3 issue(s) found

Issue Severity File
cronToCalendarInterval fails on normalized multi-value schedules 🔴 src/bun.js/api/cron.zig
Cron commands pass bun_exe as duplicate argument (all platforms) 🔴 src/bun.js/api/cron.zig
Dynamic require("fs") inside function body instead of module-scope import 🟡 test/js/bun/cron/cron.test.ts

🔴 da282 — 2 issue(s) found

Issue Severity File
cronToCalendarInterval fails on comma-separated normalized cron fields 🔴 src/bun.js/api/cron.zig
SpawnOptions windows.loop uninitialized causing UB 🔴 src/bun.js/api/cron.zig

🔴 da282 — 3 issue(s) found

Issue Severity File
cronToCalendarInterval fails on comma-separated values 🔴 src/bun.js/api/cron.zig
Duplicate bun_exe in cron command 🔴 src/bun.js/api/cron.zig
cronToSchtasks pattern matching fails 🔴 src/bun.js/api/cron.zig

🔴 4bbbd — 3 issue(s) found

Issue Severity File
Crontab/plist/schtasks entries pass bun_exe twice, making cron jobs always fail 🔴 src/bun.js/api/cron.zig
cronToSchtasks never matches */N patterns because schedule is pre-normalized (Windows) 🔴 src/bun.js/api/cron.zig
cronToCalendarInterval fails on normalized schedules with comma-separated values (macOS) 🔴 src/bun.js/api/cron.zig

Powered by Claude Code Review

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@docs/runtime/cron.mdx`:
- Around line 172-175: The documentation incorrectly shows the crontab
invocation as "bun run --cron-title=... --cron-period='...' <bun-path>
<script-path>"; update the prose in docs/runtime/cron.mdx so the crontab entry
shows the schedule first and the bun executable quoted next: "<schedule>
'<bun-path>' run --cron-title=<title> --cron-period='<schedule>'
'<script-path>'". Locate the paragraph that mentions running the cron job (it
currently contains the "bun run --cron-title" snippet and the reference to the
scheduled() handler) and replace that command example with the corrected
pattern, preserving the explanation about importing the module and calling
scheduled().

In `@src/bun.js/api/cron.zig`:
- Around line 399-404: The normalization step is expanding step patterns (e.g.,
"*/15") into explicit lists via parsed.formatNumeric and normalized_schedule,
which breaks cronToSchtasks because it expects "*/N"; update the code to call
cronToSchtasks with the original raw expression (schedule_slice or the parsed
raw string) instead of normalized_schedule, or alternatively detect
evenly-spaced numeric lists produced by parsed.formatNumeric and convert them
back into the "*/N" form before passing to cronToSchtasks; adjust references in
the code paths that use CronExpression.parse, parsed.formatNumeric,
normalized_schedule, and cronToSchtasks so Windows conversion preserves step
patterns.
- Around line 1051-1054: Replace the use of std.mem.startsWith for the minute
prefix check with the SIMD-accelerated helper: change std.mem.startsWith(u8,
minute, "*/") to bun.strings.startsWith(minute, "*/") in the cron matching
conditional (keeping the other bun.strings.eql checks for hour, dom, month, dow
unchanged) so the code uses bun.strings.* utilities consistently.

@claude
Copy link
Contributor

claude bot commented Feb 13, 2026

Code Review

Newest first

🔴 3f80d — 4 issue(s) found

Issue Severity File
Duplicate bun_exe in cron commands 🔴 src/bun.js/api/cron.zig
Windows schtasks argv null truncation 🔴 src/bun.js/api/cron.zig
cronToSchtasks step pattern dead code 🔴 src/bun.js/api/cron.zig
macOS plist missing array wrapper 🔴 src/bun.js/api/cron.zig

Powered by Claude Code Review

@claude
Copy link
Contributor

claude bot commented Feb 13, 2026

Code Review

Newest first

🔴 164c1 — 3 issue(s) found

Issue Severity File
Windows schtasks argv truncated by null optionals 🔴 src/bun.js/api/cron.zig
macOS plist missing array wrapper for multiple StartCalendarInterval dicts 🔴 src/bun.js/api/cron.zig
Wildcard detection fails for */1, breaking day/weekday OR logic 🟡 src/bun.js/api/cron_parser.zig

🔴 a546e — 2 issue(s) found

Issue Severity File
Windows schtasks argv has null gaps that drop /f flag 🔴 src/bun.js/api/cron.zig
macOS plist missing array wrapper for multiple StartCalendarInterval dicts 🔴 src/bun.js/api/cron.zig

🔴 5530f — 2 issue(s) found

Issue Severity File
Windows schtasks argv has null gaps that drop /f flag 🔴 src/bun.js/api/cron.zig
macOS plist missing array wrapper for multi-dict StartCalendarInterval 🔴 src/bun.js/api/cron.zig

Powered by Claude Code Review

@Fanna1119
Copy link

when can we expect this feature to be bundled? :D

@TomasHubelbauer
Copy link

I can't wait for this either, hopefully it makes it to the next release! 👀

@alii
Copy link
Member

alii commented Mar 6, 2026

@robobun Three tasks:

  • Yourself, run a general smoke test to make sure this actually works.
  • Make sure the tests in CI is skipped in linux when crontab is not in PATH (using Bun.which).
  • Make sure that all crons are cleaned up in our tests. Use the using syntax for this, so even if a test throws, we still get the cleanup. This is because our macOS runners are persistent and so we definitely need them to be cleaned up.

@robobun
Copy link
Collaborator

robobun commented Mar 6, 2026

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 6, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (11)
src/bun.js.zig (2)

611-622: ⚠️ Potential issue | 🟡 Minor

Guard Managed allocation with errdefer in escapeForJSString.

If any append/appendSlice fails, result leaks. Add errdefer result.deinit(); right after initCapacity.

🔧 Proposed fix
     var result = try std.array_list.Managed(u8).initCapacity(allocator, input.len + 16);
+    errdefer result.deinit();
     for (input) |c| {
         switch (c) {
             '\\' => try result.appendSlice("\\\\"),
             '"' => try result.appendSlice("\\\""),
             '\n' => try result.appendSlice("\\n"),
             '\r' => try result.appendSlice("\\r"),
             '\t' => try result.appendSlice("\\t"),
             else => try result.append(c),
         }
     }

As per coding guidelines: "In Zig code, be careful with allocators and use defer for cleanup."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js.zig` around lines 611 - 622, The Managed array_list allocation in
escapeForJSString (var result = try
std.array_list.Managed(u8).initCapacity(allocator, input.len + 16)) is not
cleaned up on error, causing a leak if any append/appendSlice fails; add an
errdefer result.deinit(); immediately after the initCapacity call (before the
for loop) so that result is deinitialized on error, keeping the existing return
result.toOwnedSlice() behavior on success.

219-242: ⚠️ Potential issue | 🟡 Minor

Add error-path cleanup for cron allocations.

cron_script (Line 219) and heap_entry_path (Line 238) can leak if later fallible ops fail (e.g., Line 229 getcwd, Line 238 dupe, Line 239 create). Add errdefer immediately after each allocation.

🔧 Proposed fix
             const cron_script = try std.fmt.allocPrint(bun.default_allocator,
                 \\const mod = await import("{s}");
                 \\const scheduled = (mod.default || mod).scheduled;
                 \\if (typeof scheduled !== "function") throw new Error("Module does not export default.scheduled()");
                 \\const controller = {{ cron: "{s}", type: "scheduled", scheduledTime: Date.now() }};
                 \\await scheduled(controller);
             , .{ escaped_path, escaped_period });
+            errdefer bun.default_allocator.free(cron_script);
             // entry_path must end with /[eval] for the transpiler to use eval_source
             const trigger = bun.pathLiteral("/[eval]");
             var cwd_buf: bun.PathBuffer = undefined;
             const cwd_slice = switch (bun.sys.getcwd(&cwd_buf)) {
                 .result => |cwd| cwd,
                 .err => return error.SystemResources,
             };
             var eval_path_buf: [bun.MAX_PATH_BYTES + trigger.len]u8 = undefined;
             `@memcpy`(eval_path_buf[0..cwd_slice.len], cwd_slice);
             `@memcpy`(eval_path_buf[cwd_slice.len..][0..trigger.len], trigger);
             const eval_entry_path = eval_path_buf[0 .. cwd_slice.len + trigger.len];
             // Heap-allocate the path so it outlives this stack frame
             const heap_entry_path = try bun.default_allocator.dupe(u8, eval_entry_path);
+            errdefer bun.default_allocator.free(heap_entry_path);
             const script_source = try bun.default_allocator.create(logger.Source);

As per coding guidelines: "In Zig code, be careful with allocators and use defer for cleanup."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js.zig` around lines 219 - 242, cron_script and heap_entry_path are
allocated but not cleaned up on subsequent fallible operations (e.g.,
bun.sys.getcwd, bun.default_allocator.dupe, bun.default_allocator.create); add
errdefer blocks immediately after each allocation to free those allocations on
error paths: after creating cron_script (the result of std.fmt.allocPrint) add
an errdefer that frees cron_script via bun.default_allocator.free or the
appropriate free/allocator method, and after duplicating eval_entry_path into
heap_entry_path (bun.default_allocator.dupe) add an errdefer that frees
heap_entry_path and also ensure any earlier cron_script errdefer is still
effective; likewise, if create(logger.Source) fails, ensure heap_entry_path is
freed in its errdefer so no heap leak remains.
src/bun.js/api/BunObject.zig (1)

72-72: 🛠️ Refactor suggestion | 🟠 Major

Move inline @import to file-level constant.

The inline @import("./cron.zig") violates the Zig import placement guideline. Move it to a file-level constant at the bottom of the file.

-    pub const cron = toJSLazyPropertyCallback(`@import`("./cron.zig").getCronObject);
+    pub const cron = toJSLazyPropertyCallback(cron_module.getCronObject);

Add at the bottom of the file with other imports:

const cron_module = `@import`("./cron.zig");

As per coding guidelines: "Place @import statements at the bottom of the file... Never use @import() inline inside of functions. Always put imports at the bottom of the file or containing struct."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/api/BunObject.zig` at line 72, The inline `@import` in the cron
property should be moved to a file-level import: add a bottom-of-file constant
like const cron_module = `@import`("./cron.zig"); alongside the other imports, and
update the property declaration pub const cron =
toJSLazyPropertyCallback(`@import`("./cron.zig").getCronObject); to use
cron_module.getCronObject instead (i.e., pub const cron =
toJSLazyPropertyCallback(cron_module.getCronObject);) so `@import` is not used
inline.
packages/bun-types/bun.d.ts (1)

7303-7305: ⚠️ Potential issue | 🟡 Minor

Fix the parse-chaining example so it type-checks.

Line 7304 uses an undeclared from, and Line 7305 passes first even though Bun.cron.parse() can return null.

✏️ Proposed fix
-     * const first = Bun.cron.parse("@hourly", from);
-     * const second = Bun.cron.parse("@hourly", first);
+     * const from = new Date();
+     * const first = Bun.cron.parse("@hourly", from);
+     * const second = first ? Bun.cron.parse("@hourly", first) : null;
#!/bin/bash
# Verify the example currently references `from` without declaration
# and chains a nullable result directly.
sed -n '7299,7307p' packages/bun-types/bun.d.ts
rg -n 'const from =|const first = Bun\.cron\.parse\("@hourly", from\)|const second = Bun\.cron\.parse\("@hourly", first\)' packages/bun-types/bun.d.ts
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bun-types/bun.d.ts` around lines 7303 - 7305, The example chains
Bun.cron.parse incorrectly: declare a proper Date source (e.g., const from = new
Date()) to replace the undeclared from, and guard the nullable return of
Bun.cron.parse before passing it into the next call; specifically, update the
example so you assign const from = new Date(), call const first =
Bun.cron.parse("@hourly", from), then check first !== null (or use an if) before
calling Bun.cron.parse("@hourly", first) to produce second — referencing
Bun.cron.parse, from, first, and second to locate the lines to change.
test/js/bun/cron/cron.test.ts (1)

17-22: ⚠️ Potential issue | 🟠 Major

Fail fast on crontab command failures in setup/restore paths.

Line 21 and Line 201 currently allow crontab command failures to pass silently, which can leave stale scheduler state and produce false-positive tests.

🔧 Proposed fix
 function writeCrontab(content: string) {
   const tmpFile = `/tmp/bun-cron-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`;
   writeFileSync(tmpFile, content);
   try {
-    Bun.spawnSync({ cmd: [crontabPath!, tmpFile] });
+    const result = Bun.spawnSync({
+      cmd: [crontabPath!, tmpFile],
+      stdout: "pipe",
+      stderr: "pipe",
+    });
+    if (result.exitCode !== 0) {
+      throw new Error(`crontab install failed (exit ${result.exitCode}): ${result.stderr.toString()}`);
+    }
   } finally {
     try {
       unlinkSync(tmpFile);
     } catch {}
   }
 }
@@
     setup.stdin.write("0 0 * * * /usr/bin/some-other-job\n");
     setup.stdin.end();
-    await setup.exited;
+    const setupExitCode = await setup.exited;
+    expect(setupExitCode).toBe(0);

As per coding guidelines: "Always check exit codes and test error scenarios when spawning processes in tests."

Also applies to: 195-201

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/js/bun/cron/cron.test.ts` around lines 17 - 22, The crontab spawn calls
in writeCrontab (and the corresponding restore/reset helper used around lines
195-201) ignore the child process exit status, allowing failures to be silent;
update both places to capture the result of Bun.spawnSync, check result.status
(or result.exitCode), and throw or fail the test when it is non-zero, including
the command, exit code and result.stderr/stdout in the thrown error message so
failures fail fast and surface diagnostics; locate the crontab invocations in
writeCrontab and the restore helper and add the status check and informative
error throw there.
src/bun.js/api/cron.zig (6)

909-909: 🛠️ Refactor suggestion | 🟠 Major

Use bun.path.dirname instead of std.fs.path.dirname.

Per coding guidelines: "Use bun.path.join/dirname/basename instead of std.fs.path.join/dirname/basename for path manipulation."

🔧 Proposed fix
-    const source_dir = std.fs.path.dirname(caller_utf8.slice()) orelse ".";
+    const source_dir = bun.path.dirname(caller_utf8.slice(), .auto) orelse ".";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/api/cron.zig` at line 909, Replace the use of std.fs.path.dirname
with bun.path.dirname when computing source_dir: locate the assignment to
source_dir (the line using std.fs.path.dirname(caller_utf8.slice()) orelse ".")
and change it to call bun.path.dirname on the caller path (caller_utf8.slice())
so it follows the coding guideline to use bun.path.dirname instead of
std.fs.path.dirname while preserving the fallback to ".".

757-758: 🛠️ Refactor suggestion | 🟠 Major

Replace jsc.ZigString.static with bun.String.static for property names.

Per coding guidelines, prefer bun.String over ZigString in new code for JS interop.

🔧 Proposed fix
-    cron_fn.put(globalThis, jsc.ZigString.static("remove"), remove_fn);
-    cron_fn.put(globalThis, jsc.ZigString.static("parse"), parse_fn);
+    cron_fn.put(globalThis, bun.String.static("remove"), remove_fn);
+    cron_fn.put(globalThis, bun.String.static("parse"), parse_fn);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/api/cron.zig` around lines 757 - 758, The property-name creation
uses jsc.ZigString.static; change both calls to use bun.String.static instead
(update the two lines where cron_fn.put(globalThis,
jsc.ZigString.static("remove"), remove_fn) and cron_fn.put(globalThis,
jsc.ZigString.static("parse"), parse_fn) so they call
bun.String.static("remove") and bun.String.static("parse") respectively) to
follow the JS interop guideline preferring bun.String over jsc.ZigString.

1079-1085: ⚠️ Potential issue | 🟠 Major

Windows HOURLY schedule loses minute specification.

The HOURLY case parses the minute value but discards it. Without /st, the task runs at an undefined time each hour, making 15 * * * * (run at minute 15) indistinguishable from 0 * * * * (run at minute 0).

🔧 Proposed fix
     // Hourly: 0 * * * * or N * * * *
     if (bun.strings.eql(hour, "*") and bun.strings.eql(dom, "*") and
         bun.strings.eql(month, "*") and bun.strings.eql(dow, "*"))
     {
-        _ = std.fmt.parseInt(u32, minute, 10) catch return error.UnsupportedSchedule;
-        return .{ .sc = "HOURLY", .mo = "1" };
+        const m = std.fmt.parseInt(u32, minute, 10) catch return error.UnsupportedSchedule;
+        const static_st = struct {
+            var buf: [6:0]u8 = undefined;
+        };
+        _ = std.fmt.bufPrintZ(&static_st.buf, "00:{d:0>2}", .{m}) catch return error.UnsupportedSchedule;
+        return .{ .sc = "HOURLY", .mo = "1", .start_time = &static_st.buf };
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/api/cron.zig` around lines 1079 - 1085, The HOURLY branch in
cron.zig currently parses the minute but discards it and returns .mo = "1";
update the branch that checks hour/dom/month/dow == "*" to capture the parsed
minute value (from parseInt(u32, minute, 10) or use the original minute string)
and set the returned schedule object's mo field to that minute so e.g. "15 * * *
*" preserves minute 15; modify the return in that block (refer to variables
minute and the returned struct .sc and .mo) to include the actual minute instead
of the hardcoded "1".

264-269: ⚠️ Potential issue | 🟠 Major

macOS: Missing LaunchAgents directory creation and memory leak.

Two issues remain unaddressed:

  1. Directory may not exist: ~/Library/LaunchAgents might not exist on fresh macOS installs. The openat() with O.CREAT only creates the file, not parent directories, so registration will fail.

  2. Memory leak: At line 369, this.tmp_path = null prevents deletion of the installed plist, but the allocated plist_path string is never freed.

🔧 Proposed fix
     this.tmp_path = plist_path;
+
+    // Ensure ~/Library/LaunchAgents exists
+    const agents_dir = bun.path.dirname(plist_path, .posix) orelse {
+        this.setErr("Invalid plist path", .{});
+        this.finish();
+        return;
+    };
+    bun.makePath(bun.FD.cwd(), agents_dir) catch {
+        this.setErr("Failed to create LaunchAgents directory", .{});
+        this.finish();
+        return;
+    };

And in spawnBootstrap:

     var argv = [_:null]?[*:0]const u8{ "/bin/launchctl", "bootstrap", uid_str.ptr, plist_path.ptr, null };
+    bun.default_allocator.free(plist_path);
     this.tmp_path = null; // don't delete the installed plist

Also applies to: 328-331, 369-370

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/api/cron.zig` around lines 264 - 269, The plist allocation and
assignment leak memory and can fail if the LaunchAgents parent directory doesn't
exist: before calling allocPrintZ/creating the plist file ensure the
~/Library/LaunchAgents directory exists (create it with recursive/mode 0700
using the default allocator or POSIX mkdir/mkdirat) so openat with O_CREAT won't
fail due to missing parents; after you create and install the plist ensure you
free the allocPrintZ buffer (plist_path) when you assign it to this.tmp_path or
when you later set this.tmp_path = null so the allocated string is released
(apply the same fix around the other allocPrintZ usages at the referenced blocks
and inside spawnBootstrap).

809-812: ⚠️ Potential issue | 🔴 Critical

Critical: Windows subprocess runs with empty environment.

The code creates null_envp as an array containing only a null sentinel, then passes @ptrCast(&null_envp) to spawnProcess. This is a non-null pointer to an empty environment block, not a null pointer.

In libuv, env = null means "inherit parent environment", while a non-null pointer means "use this environment block." Passing a pointer to an empty array causes the subprocess to run with zero environment variables, breaking PATH, HOME, USERPROFILE, etc.

🔧 Proposed fix

Either pass the actual parent environment on Windows:

-    var null_envp = [_:null]?[*:0]const u8{null};
-    const envp: [*:null]?[*:0]const u8 = if (comptime bun.Environment.isPosix) `@ptrCast`(`@constCast`(std.c.environ)) else `@ptrCast`(&null_envp);
+    const envp: [*:null]?[*:0]const u8 = `@ptrCast`(`@constCast`(std.c.environ));

Or modify spawnProcess to accept an optional envp and pass true null for inheritance.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/api/cron.zig` around lines 809 - 812, The Windows branch is
creating a non-null pointer to an empty env block (null_envp) causing child
processes to run with zero environment; change the envp assignment so that when
bun.Environment.isPosix is false you pass a true null pointer to spawnProcess
instead of `@ptrCast`(&null_envp). Locate null_envp and the envp definition in the
file (symbols: null_envp, envp, bun.Environment.isPosix) and ensure the value
passed into bun.spawn.spawnProcess is a null pointer for inheritance on Windows
(or adjust spawnProcess to accept an optional env and map a null optional to a
true null pointer) so children inherit the parent environment.

806-806: 🧹 Nitpick | 🔵 Trivial

Consider capturing stderr for debugging spawn failures.

Setting .stderr = .ignore suppresses all error output from spawned processes, making it harder to diagnose failures. Consider using .inherit or piping to a log during development/debugging.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/api/cron.zig` at line 806, The spawn configuration currently sets
".stderr = .ignore" which discards all error output; locate the spawn call in
api/cron.zig where ".stderr = .ignore" is set and change it to either ".stderr =
.inherit" during development or set ".stderr = .piped" (or equivalent) and read
the pipe to forward stderr into your logging system so spawn failures are
captured; ensure you flush/close the pipe and handle errors when reading so logs
always include stderr content for debugging.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/cli/Arguments.zig`:
- Around line 834-837: The current XOR check only ensures both or neither are
non-empty but doesn't reject the case where both arguments were passed as empty
strings; update the validation around ctx.runtime_options.cron_title and
ctx.runtime_options.cron_period to explicitly reject empty values: after the
existing condition, add a check that if either
ctx.runtime_options.cron_title.len == 0 or ctx.runtime_options.cron_period.len
== 0 then call Output.errGeneric with a clear message (e.g., "--cron-title and
--cron-period cannot be empty") and Global.exit(1), so empty-provided values are
treated as invalid.

---

Duplicate comments:
In `@packages/bun-types/bun.d.ts`:
- Around line 7303-7305: The example chains Bun.cron.parse incorrectly: declare
a proper Date source (e.g., const from = new Date()) to replace the undeclared
from, and guard the nullable return of Bun.cron.parse before passing it into the
next call; specifically, update the example so you assign const from = new
Date(), call const first = Bun.cron.parse("@hourly", from), then check first !==
null (or use an if) before calling Bun.cron.parse("@hourly", first) to produce
second — referencing Bun.cron.parse, from, first, and second to locate the lines
to change.

In `@src/bun.js.zig`:
- Around line 611-622: The Managed array_list allocation in escapeForJSString
(var result = try std.array_list.Managed(u8).initCapacity(allocator, input.len +
16)) is not cleaned up on error, causing a leak if any append/appendSlice fails;
add an errdefer result.deinit(); immediately after the initCapacity call (before
the for loop) so that result is deinitialized on error, keeping the existing
return result.toOwnedSlice() behavior on success.
- Around line 219-242: cron_script and heap_entry_path are allocated but not
cleaned up on subsequent fallible operations (e.g., bun.sys.getcwd,
bun.default_allocator.dupe, bun.default_allocator.create); add errdefer blocks
immediately after each allocation to free those allocations on error paths:
after creating cron_script (the result of std.fmt.allocPrint) add an errdefer
that frees cron_script via bun.default_allocator.free or the appropriate
free/allocator method, and after duplicating eval_entry_path into
heap_entry_path (bun.default_allocator.dupe) add an errdefer that frees
heap_entry_path and also ensure any earlier cron_script errdefer is still
effective; likewise, if create(logger.Source) fails, ensure heap_entry_path is
freed in its errdefer so no heap leak remains.

In `@src/bun.js/api/BunObject.zig`:
- Line 72: The inline `@import` in the cron property should be moved to a
file-level import: add a bottom-of-file constant like const cron_module =
`@import`("./cron.zig"); alongside the other imports, and update the property
declaration pub const cron =
toJSLazyPropertyCallback(`@import`("./cron.zig").getCronObject); to use
cron_module.getCronObject instead (i.e., pub const cron =
toJSLazyPropertyCallback(cron_module.getCronObject);) so `@import` is not used
inline.

In `@src/bun.js/api/cron.zig`:
- Line 909: Replace the use of std.fs.path.dirname with bun.path.dirname when
computing source_dir: locate the assignment to source_dir (the line using
std.fs.path.dirname(caller_utf8.slice()) orelse ".") and change it to call
bun.path.dirname on the caller path (caller_utf8.slice()) so it follows the
coding guideline to use bun.path.dirname instead of std.fs.path.dirname while
preserving the fallback to ".".
- Around line 757-758: The property-name creation uses jsc.ZigString.static;
change both calls to use bun.String.static instead (update the two lines where
cron_fn.put(globalThis, jsc.ZigString.static("remove"), remove_fn) and
cron_fn.put(globalThis, jsc.ZigString.static("parse"), parse_fn) so they call
bun.String.static("remove") and bun.String.static("parse") respectively) to
follow the JS interop guideline preferring bun.String over jsc.ZigString.
- Around line 1079-1085: The HOURLY branch in cron.zig currently parses the
minute but discards it and returns .mo = "1"; update the branch that checks
hour/dom/month/dow == "*" to capture the parsed minute value (from parseInt(u32,
minute, 10) or use the original minute string) and set the returned schedule
object's mo field to that minute so e.g. "15 * * * *" preserves minute 15;
modify the return in that block (refer to variables minute and the returned
struct .sc and .mo) to include the actual minute instead of the hardcoded "1".
- Around line 264-269: The plist allocation and assignment leak memory and can
fail if the LaunchAgents parent directory doesn't exist: before calling
allocPrintZ/creating the plist file ensure the ~/Library/LaunchAgents directory
exists (create it with recursive/mode 0700 using the default allocator or POSIX
mkdir/mkdirat) so openat with O_CREAT won't fail due to missing parents; after
you create and install the plist ensure you free the allocPrintZ buffer
(plist_path) when you assign it to this.tmp_path or when you later set
this.tmp_path = null so the allocated string is released (apply the same fix
around the other allocPrintZ usages at the referenced blocks and inside
spawnBootstrap).
- Around line 809-812: The Windows branch is creating a non-null pointer to an
empty env block (null_envp) causing child processes to run with zero
environment; change the envp assignment so that when bun.Environment.isPosix is
false you pass a true null pointer to spawnProcess instead of
`@ptrCast`(&null_envp). Locate null_envp and the envp definition in the file
(symbols: null_envp, envp, bun.Environment.isPosix) and ensure the value passed
into bun.spawn.spawnProcess is a null pointer for inheritance on Windows (or
adjust spawnProcess to accept an optional env and map a null optional to a true
null pointer) so children inherit the parent environment.
- Line 806: The spawn configuration currently sets ".stderr = .ignore" which
discards all error output; locate the spawn call in api/cron.zig where ".stderr
= .ignore" is set and change it to either ".stderr = .inherit" during
development or set ".stderr = .piped" (or equivalent) and read the pipe to
forward stderr into your logging system so spawn failures are captured; ensure
you flush/close the pipe and handle errors when reading so logs always include
stderr content for debugging.

In `@test/js/bun/cron/cron.test.ts`:
- Around line 17-22: The crontab spawn calls in writeCrontab (and the
corresponding restore/reset helper used around lines 195-201) ignore the child
process exit status, allowing failures to be silent; update both places to
capture the result of Bun.spawnSync, check result.status (or result.exitCode),
and throw or fail the test when it is non-zero, including the command, exit code
and result.stderr/stdout in the thrown error message so failures fail fast and
surface diagnostics; locate the crontab invocations in writeCrontab and the
restore helper and add the status check and informative error throw there.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c556e653-8ce3-417f-8360-7ff160a60e61

📥 Commits

Reviewing files that changed from the base of the PR and between 5530ffd and 5ab0ab1.

📒 Files selected for processing (16)
  • docs/docs.json
  • docs/runtime/cron.mdx
  • packages/bun-types/bun.d.ts
  • src/bun.js.zig
  • src/bun.js/api/BunObject.zig
  • src/bun.js/api/bun/process.zig
  • src/bun.js/api/cron.zig
  • src/bun.js/api/cron_parser.zig
  • src/bun.js/bindings/BunObject+exports.h
  • src/bun.js/bindings/BunObject.cpp
  • src/bun.js/bindings/JSGlobalObject.zig
  • src/bun.js/bindings/bindings.cpp
  • src/cli.zig
  • src/cli/Arguments.zig
  • test/integration/bun-types/fixture/cron.ts
  • test/js/bun/cron/cron.test.ts

Copy link
Collaborator

@robobun robobun left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — test changes and CI status

@alii's three tasks ✅

All three tasks from the comment are addressed in commit 5ab0ab1:

  1. Smoke test: Verified the test file runs. On a machine without crontab, the crontab-dependent tests are correctly skipped (11 skip) while the expression parsing tests pass (7 pass). The Bun.cron.parse tests fail on system bun as expected (it's a new API).

  2. Skip on Linux when no crontab in PATH: describe.skipIf(!hasCrontab) using Bun.which("crontab") replaces the old !isLinux check. This correctly skips on any platform where crontab isn't available.

  3. Cleanup with using: All tests now use using _restore = saveCrontabState() (disposable pattern) instead of beforeEach/afterEach. Even if a test throws, cleanup runs.

CI: ✅ Green

Build #38698 passed — all checks completed successfully on HEAD 5ab0ab1.

Remaining unresolved review threads

There are ~15 unresolved threads from coderabbitai and claude[bot] about the core cron.zig implementation (not the test changes). Key concerns:

  • schtasks argv null entries (line 498): Null sentinel array has gaps when optional args are absent
  • cronToSchtasks only handles simple cron patterns (line 485): Called with raw_schedule but only parses single-value fields
  • cronToCalendarInterval product expansion (line 1021): When fields have multiple values, the product count logic may be incorrect
  • HOURLY discards minute spec (line 1083): Minute value is parsed then discarded in schtasks conversion
  • SpawnOptions missing windows.loop (line 806): Uninitialized field
  • bun.d.ts type/doc issues: Cron chaining example won't compile, schedule types could be improved

These are for the PR author to address — they're about the implementation, not the test scaffolding.

@Jarred-Sumner
Copy link
Collaborator Author

@robobun fix it. all 11.

@robobun
Copy link
Collaborator

robobun commented Mar 6, 2026

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 6, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (4)
src/bun.js/api/cron.zig (4)

782-783: 🧹 Nitpick | 🔵 Trivial

Use bun.String.static for property keys in new Zig JS interop code.

Swap jsc.ZigString.static(...) with bun.String.static(...) for remove/parse keys.

As per coding guidelines, "Prefer bun.String over ZigString in new Zig code."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/api/cron.zig` around lines 782 - 783, The property key creation
uses jsc.ZigString.static; update the two calls so cron_fn.put(globalThis,
jsc.ZigString.static("remove"), remove_fn) and cron_fn.put(globalThis,
jsc.ZigString.static("parse"), parse_fn) use bun.String.static instead of
jsc.ZigString.static for the "remove" and "parse" keys so the calls become
cron_fn.put(globalThis, bun.String.static("remove"), remove_fn) and
cron_fn.put(globalThis, bun.String.static("parse"), parse_fn).

831-840: ⚠️ Potential issue | 🔴 Critical

Windows child process env inheritance is likely broken.

The Windows branch passes a pointer to { null } instead of a true null env pointer, which can yield an empty environment rather than inheriting parent vars. This can break scheduler invocation reliability.

#!/bin/bash
# Verify spawnProcess env semantics and compare with existing patterns
rg -n "fn spawnProcess|pub fn spawnProcess" src --type zig -A30 -B5
rg -n "options\\.envp orelse|std\\.c\\.environ|\\.env\\s*=" src/bun.js/api/bun/process.zig -A8 -B8
rg -n "spawnProcess\\(&spawn_options, `@ptrCast`\\(argv\\), envp\\)" src/bun.js/api/cron.zig -A8 -B8
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/api/cron.zig` around lines 831 - 840, The child process env
pointer logic is incorrect for Windows: instead of passing a pointer to a
one-element array containing null (null_envp) you must pass a true null env
pointer to spawnProcess so the child inherits the parent environment; update the
envp assignment used in spawnProcess(&spawn_options, `@ptrCast`(argv), envp) to
yield a null pointer on Windows (when bun.Environment.isWindows) and
std.c.environ (or `@ptrCast`(`@constCast`(std.c.environ))) on POSIX; refer to the
symbols envp, null_envp, spawn_options, bun.spawn.spawnProcess and
bun.Environment.isPosix/isWindows to locate and change the branch so Windows
gets a real null rather than &null_envp.

1156-1156: 🧹 Nitpick | 🔵 Trivial

Use bun.strings.eql instead of std.mem.eql for string comparison.

This keeps string ops aligned with the project’s SIMD-optimized helpers.

As per coding guidelines, "Use bun.strings.eql/indexOf/startsWith instead of std.mem.eql/indexOf/startsWith for string operations."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/api/cron.zig` at line 1156, Replace the std.mem.eql call with the
project's SIMD-optimized helper: change the comparison in cron.zig (the line
using std.mem.eql(u8, &upper, prefix) inside the relevant loop/function) to use
bun.strings.eql with the same operands; ensure bun.strings is imported/available
in this module and keep the argument order/values identical so the behavior is
unchanged (use bun.strings.eql(&upper, prefix) or the appropriate slice forms
used elsewhere in this file).

264-270: ⚠️ Potential issue | 🟠 Major

Create LaunchAgents directory and fix retained plist path leak.

openat won’t create missing parent directories, so first-run setup can fail if ~/Library/LaunchAgents is absent. Also, setting this.tmp_path = null drops the owned plist_path allocation without freeing it.

As per coding guidelines, "Use bun.makePath() for creating directories recursively in Zig."

Also applies to: 328-332, 368-370

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/api/cron.zig` around lines 264 - 270, allocPrintZ is building a
plist path into ~/Library/LaunchAgents but openat won't create the parent
directory and assigning this.tmp_path = plist_path can leak the allocation later
when this.tmp_path is dropped; before calling allocPrintZ, call
bun.makePath(home, "Library/LaunchAgents") (or
bun.makePath("{s}/Library/LaunchAgents", .{home})) to create the directory
recursively, then set this.tmp_path = plist_path only after successfully
creating it; ensure any previous this.tmp_path is freed (or transferred
ownership) instead of overwriting with null to avoid leaks — apply the same fix
pattern around the other occurrences involving allocPrintZ / this.tmp_path noted
in the comment (the blocks around the other ranges).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/api/cron.zig`:
- Around line 484-486: cron registration currently passes this.raw_schedule into
cronToSchtasks which uses a 5-field tokenizer, causing valid nicknames (e.g.,
`@daily`) that were validated by CronExpression.parse in cronRegister to fail;
change startWindows (and the other occurrence around cronToSchtasks at lines
~1070-1079) to pass the normalized/expanded schedule (use the parsed/normalized
field, e.g., this.schedule or the output of CronExpression.parse) into
cronToSchtasks instead of this.raw_schedule so nicknames are resolved before
Windows conversion.
- Around line 1134-1141: The weekly schedule branch currently rejects dow=7;
update the handler around the parseInt/resolveWeekdayName result (the variable d
returned by std.fmt.parseInt or resolveWeekdayName) to treat 7 as Sunday by
mapping d==7 to 0 before the bounds check and indexing into day_names;
specifically, after computing d in the routine that produces the u32 `d`, add a
small normalization step (if d == 7 then d = 0) and then proceed with the
existing if (d > 6) validation and the return that uses day_names[d] and
start_time building.

---

Duplicate comments:
In `@src/bun.js/api/cron.zig`:
- Around line 782-783: The property key creation uses jsc.ZigString.static;
update the two calls so cron_fn.put(globalThis, jsc.ZigString.static("remove"),
remove_fn) and cron_fn.put(globalThis, jsc.ZigString.static("parse"), parse_fn)
use bun.String.static instead of jsc.ZigString.static for the "remove" and
"parse" keys so the calls become cron_fn.put(globalThis,
bun.String.static("remove"), remove_fn) and cron_fn.put(globalThis,
bun.String.static("parse"), parse_fn).
- Around line 831-840: The child process env pointer logic is incorrect for
Windows: instead of passing a pointer to a one-element array containing null
(null_envp) you must pass a true null env pointer to spawnProcess so the child
inherits the parent environment; update the envp assignment used in
spawnProcess(&spawn_options, `@ptrCast`(argv), envp) to yield a null pointer on
Windows (when bun.Environment.isWindows) and std.c.environ (or
`@ptrCast`(`@constCast`(std.c.environ))) on POSIX; refer to the symbols envp,
null_envp, spawn_options, bun.spawn.spawnProcess and
bun.Environment.isPosix/isWindows to locate and change the branch so Windows
gets a real null rather than &null_envp.
- Line 1156: Replace the std.mem.eql call with the project's SIMD-optimized
helper: change the comparison in cron.zig (the line using std.mem.eql(u8,
&upper, prefix) inside the relevant loop/function) to use bun.strings.eql with
the same operands; ensure bun.strings is imported/available in this module and
keep the argument order/values identical so the behavior is unchanged (use
bun.strings.eql(&upper, prefix) or the appropriate slice forms used elsewhere in
this file).
- Around line 264-270: allocPrintZ is building a plist path into
~/Library/LaunchAgents but openat won't create the parent directory and
assigning this.tmp_path = plist_path can leak the allocation later when
this.tmp_path is dropped; before calling allocPrintZ, call bun.makePath(home,
"Library/LaunchAgents") (or bun.makePath("{s}/Library/LaunchAgents", .{home}))
to create the directory recursively, then set this.tmp_path = plist_path only
after successfully creating it; ensure any previous this.tmp_path is freed (or
transferred ownership) instead of overwriting with null to avoid leaks — apply
the same fix pattern around the other occurrences involving allocPrintZ /
this.tmp_path noted in the comment (the blocks around the other ranges).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a1960ed3-e6ef-4b9d-b55a-af3635b3ba65

📥 Commits

Reviewing files that changed from the base of the PR and between 5ab0ab1 and b040d01.

📒 Files selected for processing (3)
  • packages/bun-types/bun.d.ts
  • src/bun.js/api/cron.zig
  • src/cli/Arguments.zig

@Jarred-Sumner
Copy link
Collaborator Author

@robobun fix every issue from the PR review comments.

@alii
Copy link
Member

alii commented Mar 6, 2026

@robobun fix every issue from the PR review comments. Maybe you forgot to push

@robobun
Copy link
Collaborator

robobun commented Mar 6, 2026

All 11 review issues addressed in 4584a29:

cron.zig fixes (1-9):

  1. cronToSchtasks now uses this.schedule (normalized) instead of this.raw_schedule so nicknames like @daily work
  2. dow=7 accepted as Sunday per POSIX cron
  3. bun.strings.eql replaces std.mem.eql in resolveWeekdayName
  4. bun.String.static replaces jsc.ZigString.static in getCronObject
  5. ~/Library/LaunchAgents created before plist write via bun.makePath
  6. Buffer overflow in MINUTE case fixed — std.fmt.bufPrintZ instead of raw @memcpy
  7. CronRemoveJob.maybeFinished exempts Windows .installing_crontab state so removing a non-existent task resolves cleanly
  8. std.c.environ used for env inheritance on all platforms (was passing non-null pointer to empty array on Windows)
  9. launchd OR semantics: when both day-of-month and day-of-week are specified, two separate dict sets are emitted

docs/runtime/cron.mdx fixes (10-11):
10. grep bun.cron (was sh.bun.cron)
11. Documented that complex patterns are supported via Cartesian product expansion

Cross-platform compilation verified: bun run zig:check-all passes all 61/61 targets (Linux, macOS, Windows × x86_64/aarch64 × Debug/Release).

Jarred-Sumner and others added 6 commits March 6, 2026 17:16
…rsing

Adds `Bun.cron()`, `Bun.cron.remove()`, and `Bun.cron.parse()` — a
complete API for registering OS-level cron jobs and parsing cron
expressions from JavaScript.

**Bun.cron(path, schedule, title)** registers a cron job that runs a
JS/TS module on a schedule, using the platform's native scheduler:
- Linux: crontab
- macOS: launchd (plist + StartCalendarInterval)
- Windows: Task Scheduler (schtasks)

The target module exports a `scheduled(controller)` handler following
the Cloudflare Workers Cron Triggers API.

**Bun.cron.parse(expression, relativeDate?)** parses a cron expression
and returns the next matching UTC Date. Supports:
- Standard 5-field expressions (minute hour day month weekday)
- Named days: SUN-SAT, Sunday-Saturday (case-insensitive)
- Named months: JAN-DEC, January-December (case-insensitive)
- Sunday as 7 (normalized to 0)
- Predefined nicknames: @Yearly, @annually, @monthly, @Weekly, @daily,
  @midnight, @hourly
- POSIX OR logic: when both day-of-month and day-of-week are specified
  (neither is *), either matching is sufficient

The parser uses a bitset representation (u64/u32/u16/u8 per field) for
efficient matching. The next-occurrence algorithm advances by the
largest non-matching unit (month > day > hour > minute) and normalizes
via JSC's UTC date functions on each iteration.

Expressions are normalized to numeric form before platform registration,
so named values like "Monday" or "@daily" produce valid crontab entries.

Also fixes a pre-existing bug in filterCrontab where substring matching
(indexOf) could cause removing job "test" to also remove "test-cleanup".
Changed to exact line matching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace beforeEach/afterEach with `using _restore = saveCrontabState()`
  so crontab is always restored even if a test crashes
- Use Bun.which("crontab") instead of hardcoded /usr/bin/crontab
- Skip cron tests based on crontab availability, not just isLinux
- Build schtasks argv dynamically to avoid null gaps truncating before /f
- Initialize windows.loop in SpawnOptions for Windows spawn support
- Wrap multiple calendar interval dicts in <array> for valid launchd plist
- Use minute value for /st in HOURLY schtasks schedule
- Resolve named weekdays (MON, TUE, etc.) in cronToSchtasks
- Use bun.path.dirname instead of std.fs.path.dirname in resolvePath
- Fix JSDoc chaining example to handle null return
- Add common cron patterns and template literal to CronWithAutocomplete type
- Reject empty --cron-title/--cron-period values in argument validation

https://claude.ai/code/session_01YZgjSQgUz2Xt7SZKNHv5jv
cron.zig:
1. Use this.schedule (not raw_schedule) in cronToSchtasks so nicknames
   like @daily get the normalized 5-field form
2. Accept dow=7 as Sunday (POSIX cron allows both 0 and 7)
3. Use bun.strings.eql instead of std.mem.eql in resolveWeekdayName
4. Use bun.String.static instead of jsc.ZigString.static in getCronObject
5. Create ~/Library/LaunchAgents dir before writing plist file
6. Fix buffer overflow in MINUTE case of cronToSchtasks — use
   std.fmt.bufPrintZ instead of raw @memcpy of user input
7. Exempt Windows schtasks /delete non-zero exit in CronRemoveJob so
   removing a non-existent task resolves without error
8. Use std.c.environ for env inheritance instead of null pointer that
   gave child processes an empty environment
9. Implement POSIX cron OR semantics for launchd: when both day-of-month
   and day-of-week are specified, emit separate dict sets so launchd
   fires when either matches

docs/runtime/cron.mdx:
10. Fix grep pattern: bun.cron not sh.bun.cron
11. Document that complex cron patterns are supported on macOS via
    Cartesian product expansion
Copy link
Collaborator

@robobun robobun left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI is red — two fixes needed

1. test/internal/ban-words.test.ts — failing on ALL 17 platforms

src/bun.js/api/cron.zig line 270 uses std.fs.cwd() which is a banned word in the Bun codebase. The ban-words test enforces: "Prefer bun.FD.cwd()".

Fix: Change line 270 from:

bun.makePath(std.fs.cwd(), launch_agents_dir) catch {

Look at how bun.makePath is called elsewhere — it takes a bun.FD or a dir fd. The correct fix is likely something like:

bun.makePath(bun.FD.cwd(), launch_agents_dir) catch {

Verify by checking the bun.makePath signature.

2. test/js/bun/cron/cron.test.ts — failing on macOS only (4 platforms)

This is a new test file. It fails on all 4 Darwin CI runners (13 aarch64, 14 aarch64, 14 x64, 13 x64). Investigate the specific test failure on macOS — it could be the cron registration tests interacting with crontab differently on macOS, or the execution mode tests failing.

Other notes

  • test/integration/next-pages/test/dev-server.test.ts failure on macOS 13 aarch64 looks like an infra flake (single platform).
  • The code itself looks well-structured. The cron parser, OS-level registration (crontab/launchd/schtasks), and POSIX OR semantics for day-of-month/day-of-week are all solid.
  • Zero unresolved review threads.

@alii
Copy link
Member

alii commented Mar 7, 2026

@robobun fix the red ci

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants