Skip to content

Implement in-process callback-style Bun.cron - #28701

Merged
alii merged 61 commits into
mainfrom
ali/in-process-cron
Apr 3, 2026
Merged

alii merged 61 commits into
mainfrom
ali/in-process-cron

Conversation

@alii

@alii alii commented Mar 31, 2026 •

Copy link
Copy Markdown
Member

Fixes #7004

Adds an in-process overload to Bun.cron that runs a callback on the event loop at cron-scheduled intervals — similar to Deno.cron.

using job = Bun.cron("*/5 * * * *", async () => {
  await cleanupTempFiles();
});
job.cron;    // "*/5 * * * *"
job.stop();  // cancel (or let `using` dispose)
job.unref(); // allow process exit

This complements the existing OS-level Bun.cron(path, schedule, title) (crontab/launchd/schtasks) with a lighter option that works in containers and needs no system cron daemon. Dispatched on typeof args[1] === "function".

Behavior

  • No overlap — the next fire is scheduled only after the callback (and any returned Promise) settles. A slow async handler will not pile up concurrent runs.
  • Errors match setTimeout — a synchronous throw emits uncaughtException; a rejected Promise emits unhandledRejection. Without a listener the process exits with code 1. The job reschedules itself after an error.
  • --hot safe — all in-process cron jobs are cleared before the module graph re-evaluates, so editing the schedule, editing the callback, or deleting the Bun.cron(...) line entirely all take effect on save without leaking timers. Worker termination also clears that worker's jobs.
  • Disposable — using job = Bun.cron(...) auto-stops at scope exit.
  • ref/unref — .ref() (default) keeps the process alive; .unref() lets it exit.

Implementation

  • CronJob Zig struct backed by a .classes.ts-generated JS wrapper
  • Reuses CronExpression.next() from the OS-level cron to compute wall-clock fire times; converts to monotonic delta for the EventLoopTimer heap
  • Promise-aware rescheduling via JSValue.then() with handlers registered in the PromiseFunctions table; pending_promise is held so unhandledRejection receives the actual promise
  • bun.ptr.RefCount + jsc.JSRef keep the wrapper alive across the pending-promise window
  • Process-global jobs_list is scanned by clearAllForVM(vm) from VirtualMachine.reload() and WebWorker.exitAndDeinit()

Tests

20 tests in test/js/bun/cron/in-process-cron.test.ts: validation, stop/ref/unref/Disposable, process-alive semantics, real minute-boundary firing, async-callback scheduling, uncaughtException/unhandledRejection paths, and --hot ghost-job clearing.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Code review skipped — your organization's overage spend limit has been reached.

Code review is billed via overage credits. To resume reviews, an organization admin can raise the monthly limit at claude.ai/admin-settings/claude-code.

Once credits are available, push a new commit or reopen this pull request to trigger a review.

@robobun

robobun commented Mar 31, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 1:01 PM PT - Apr 3rd, 2026

❌ @alii, your commit e5a57b8 has 4 failures in Build #43473 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 28701

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

bun-28701 --bun

@coderabbitai

coderabbitai Bot commented Mar 31, 2026 •

Copy link
Copy Markdown
Contributor

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 an in-process CronJob API and JS class, a new Bun.cron(name, schedule, handler) overload, Zig runtime implementation with event-loop timers and promise-aware non-overlap scheduling, host promise hooks and bindings, export wiring, and end-to-end tests.

Changes

Cohort / File(s) Summary
Type Definitions
packages/bun-types/bun.d.ts
Added exported interface CronJob and a Bun.cron(name, schedule, handler): CronJob overload; clarified OS-level Bun.cron(path, schedule, title): Promise<void> JSDoc.
Core In-Process Implementation
src/bun.js/api/cron.zig
Added CronJob struct, scheduling, next-run computation, EventLoopTimer integration, non-overlap execution semantics, promise resolve/reject hooks, lifecycle/control methods, and name-keyed register/replacement; updated CronRegisterJob to accept callable handlers.
API Export Wiring
src/bun.js/api.zig
Imported and re-exported the cron API module (pub const cron).
Timer Integration
src/bun.js/api/Timer/EventLoopTimer.zig
Added Tag.CronJob, mapped its Type to bun.api.cron.CronJob, and dispatches fire() to CronJob handling.
JS Class Bindings
src/bun.js/api/cron.classes.ts
Added non-constructable CronJob class definition with methods stop, ref, unref, _fire and cached getters name, cron wired to native functions.
Bindings & Generated Exports
src/bun.js/bindings/generated_classes_list.zig, src/bun.js/bindings/headers.h
Exported Classes.CronJob = api.cron.CronJob; declared host functions Bun__CronJob__onPromiseResolve and Bun__CronJob__onPromiseReject.
Promise Handler Registration
src/bun.js/bindings/ZigGlobalObject.h, src/bun.js/bindings/ZigGlobalObject.cpp
Added enum entries and mapping for Bun__CronJob__onPromiseResolve / Bun__CronJob__onPromiseReject in promise handler registration.
Tests
test/js/bun/cron/in-process-cron.test.ts
Added comprehensive tests for validation, name handling/replacement, stop()/ref()/unref() behavior, promise-aware scheduling, error propagation, and firing semantics.

Possibly related PRs

Suggested reviewers

  • robobun
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Implement in-process callback-style Bun.cron' clearly and concisely describes the main feature addition—a new in-process cron callback mechanism for Bun.cron.
Linked Issues check ✅ Passed The implementation fully addresses issue #7004's objectives: provides an in-process cron API similar to Deno.cron, ensures no overlapping handler invocations, handles errors non-fatally, supports hot-reload via same-name replacement, and works without system cron daemons.
Out of Scope Changes check ✅ Passed All changes are scoped to implementing the new in-process cron feature: type definitions, Zig structs, class bindings, promise handlers, and comprehensive test coverage. No unrelated modifications found.
Description check ✅ Passed The PR description is well-structured and comprehensive, covering the feature, behavior, implementation details, and testing. It follows the spirit of the template by explaining what the PR does and how it was verified.

✏️ 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/bun-types/bun.d.ts`:
- Line 7475: The handler parameter currently uses an inline union return type
"() => void | Promise<void>" which duplicates existing utility types; update the
Cron job declaration so the handler return type reuses the module-level
MaybePromise<void> type instead. Locate the declaration that returns CronJob and
references CronWithAutocomplete and replace the inline union on the handler with
MaybePromise<void>, ensuring the signature stays compatible with CronJob and
CronWithAutocomplete.

In `@src/bun.js/api/cron.zig`:
- Around line 1023-1050: The code currently removes the existing job
(jobs_map.fetchRemove(name_slice.slice())) before verifying the new CronJob has
a valid next occurrence; instead, change the flow so you create the new CronJob
(CronJob.new), call computeNextTimespec() and validate it first, and only after
computeNextTimespec() succeeds insert/replace the entry and then stop/cleanup
the old job; in other words defer calling jobs_map.fetchRemove and the old job
shutdown logic until after computeNextTimespec() returns a valid timespec,
ensuring you still free/deinit the new job and its name/expression if
computeNextTimespec() fails (job.callback.deinit(),
bun.default_allocator.free(job.name),
bun.default_allocator.free(job.expression), bun.destroy(job)).
- Around line 843-844: The global jobs_map currently declared as var jobs_map:
std.StringHashMapUnmanaged(*CronJob) is process-global and causes cross-thread
races when multiple VMs/workers register the same name; change the registry to
be VM-scoped by storing it on the Zig GlobalObject (e.g., attach a
per-GlobalObject jobs_map field) or make the map keyed by the VM pointer
returned by globalObject.bunVM(), and update teardown logic (the code that uses
globalObject.bunVM() / old.global.bunVM() when removing jobs) to look up and
mutate the per-VM registry instead of the global jobs_map so timers/keepalives
are only mutated for the owning VM.

In `@test/js/bun/cron/in-process-cron.test.ts`:
- Around line 213-239: The test "error in callback is reported but cron
continues" incorrectly stops the child 100ms after the first throw so it cannot
observe a second cron execution; update the test so it verifies the cron
actually reschedules by either extending the delay in the setTimeout that calls
job.stop() (or waiting until the next cron boundary) to allow a second
invocation of the job and then asserting fires becomes 2, or alternatively
remove the short stop and instead stop the job after confirming fires === 2;
modify the inline script where fires, job, and setTimeout are defined to wait
long enough for the second scheduling before calling job.stop() so the test
fails if the cron does not continue after the thrown error.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3c925d0e-44e6-4e92-92e9-69370cdbfa53

📥 Commits

Reviewing files that changed from the base of the PR and between 17616ae and 7f6f1eda25a03584c103ffc745b32ea2e75c0beb.

📒 Files selected for processing (10)
  • packages/bun-types/bun.d.ts
  • src/bun.js/api.zig
  • src/bun.js/api/Timer/EventLoopTimer.zig
  • src/bun.js/api/cron.classes.ts
  • src/bun.js/api/cron.zig
  • src/bun.js/bindings/ZigGlobalObject.cpp
  • src/bun.js/bindings/ZigGlobalObject.h
  • src/bun.js/bindings/generated_classes_list.zig
  • src/bun.js/bindings/headers.h
  • test/js/bun/cron/in-process-cron.test.ts

Comment thread packages/bun-types/bun.d.ts Outdated
Comment thread src/bun.js/api/cron.zig Outdated
Comment thread src/bun.js/api/cron.zig Outdated
Comment thread test/js/bun/cron/in-process-cron.test.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
packages/bun-types/bun.d.ts (1)

7475-7475: 🧹 Nitpick | 🔵 Trivial

Prefer reusing MaybePromise<void> for handler return type consistency.

Line 7475 repeats an inline union that already exists as a module utility type.

♻️ Type-only cleanup
-    (name: string, schedule: CronWithAutocomplete, handler: () => void | Promise<void>): CronJob;
+    (name: string, schedule: CronWithAutocomplete, handler: () => MaybePromise<void>): CronJob;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bun-types/bun.d.ts` at line 7475, Replace the inline union return
type for the cron handler with the existing utility type to avoid duplication:
change the handler signature currently declared as "() => void | Promise<void>"
to use "MaybePromise<void>" so it aligns with the module's utility types
(referencing the types CronWithAutocomplete, CronJob and MaybePromise) and
maintain type consistency across bun.d.ts.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/bun-types/bun.d.ts`:
- Line 7475: Replace the inline union return type for the cron handler with the
existing utility type to avoid duplication: change the handler signature
currently declared as "() => void | Promise<void>" to use "MaybePromise<void>"
so it aligns with the module's utility types (referencing the types
CronWithAutocomplete, CronJob and MaybePromise) and maintain type consistency
across bun.d.ts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: bc03b9ef-50b9-4e94-a1eb-74ddee6457bf

📥 Commits

Reviewing files that changed from the base of the PR and between 7f6f1eda25a03584c103ffc745b32ea2e75c0beb and 87717599abaef29ec305bf2d0aafffe2b5ad53d2.

📒 Files selected for processing (1)
  • packages/bun-types/bun.d.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

1025-1052: ⚠️ Potential issue | 🟠 Major

Validate the replacement before removing the current job.

fetchRemove() and the old-job teardown run before computeNextTimespec() proves the new expression has a future occurrence. A same-name hot reload to a syntactically valid but impossible schedule will delete the current job and then throw.

🤖 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 1025 - 1052, The code removes the
existing job via jobs_map.fetchRemove(name_slice.slice()) before verifying the
new CronJob actually has a future occurrence; instead, instantiate the new
CronJob (CronJob.new / job.callback = .create(...)), call
job.computeNextTimespec() first and only if it returns a valid next_time proceed
to remove the old job and replace it in jobs_map; on computeNextTimespec()
failure, deinit and free the newly allocated resources (job.callback.deinit,
bun.default_allocator.free(job.name),
bun.default_allocator.free(job.expression), bun.destroy(job)) and return the
throw (globalObject.throwInvalidArguments) without touching the old job or
calling old.cleanupAfterStop.

845-846: ⚠️ Potential issue | 🔴 Critical

Scope the registry to the owning VM.

jobs_map is still process-global, so workers can race on insert/remove and the same name in one VM can tear down a timer owned by another. The replacement path also stops old through globalObject.bunVM() instead of old.global.bunVM(), which targets the wrong event loop. Based on learnings, Bun has a strict 1:1 JSC::VM to Zig::GlobalObject relationship for worker threads.

Also applies to: 968-969, 1025-1034

🤖 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 845 - 846, The jobs_map variable is
currently process-global and causes cross-worker races; make the registry
VM-scoped by moving the std.StringHashMapUnmanaged(*CronJob) into the Zig
representation of the JS VM/GlobalObject (attach as a field on the GlobalObject
or bunVM struct) and update all accesses to use that per-VM registry instead of
the global jobs_map identifier (affects usages around the other occurrences you
noted). Also fix the timer teardown path to stop the timer on the owning event
loop by calling old.global.bunVM() (or the per-VM/globalObject instance from the
CronJob) instead of globalObject.bunVM(), and ensure insert/remove operations
reference the CronJob.owner VM when adding/removing entries. Ensure all places
that referenced the old global jobs_map (including the other locations) are
updated to use the per-VM field and that synchronization is now VM-local.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/bun-types/bun.d.ts`:
- Around line 7484-7485: The example uses a relative URL in the Bun.cron
callback which fails on server-side fetch; update the Bun.cron example
(Bun.cron) to use an absolute URL with scheme and host (e.g.,
"http://localhost:3000/health" or a configured base URL) when calling fetch so
the server-side fetch has a resolvable address instead of "/health".

In `@test/js/bun/cron/in-process-cron.test.ts`:
- Line 159: The test suite declaration using describe.concurrent for "Bun.cron
(in-process) — firing" is causing flaky timing-sensitive failures; change the
suite from describe.concurrent(...) to a sequential describe(...) so the
minute-boundary cron tests run serially and avoid CPU contention-induced
timeouts, updating the suite declaration that currently wraps the firing tests.

---

Duplicate comments:
In `@src/bun.js/api/cron.zig`:
- Around line 1025-1052: The code removes the existing job via
jobs_map.fetchRemove(name_slice.slice()) before verifying the new CronJob
actually has a future occurrence; instead, instantiate the new CronJob
(CronJob.new / job.callback = .create(...)), call job.computeNextTimespec()
first and only if it returns a valid next_time proceed to remove the old job and
replace it in jobs_map; on computeNextTimespec() failure, deinit and free the
newly allocated resources (job.callback.deinit,
bun.default_allocator.free(job.name),
bun.default_allocator.free(job.expression), bun.destroy(job)) and return the
throw (globalObject.throwInvalidArguments) without touching the old job or
calling old.cleanupAfterStop.
- Around line 845-846: The jobs_map variable is currently process-global and
causes cross-worker races; make the registry VM-scoped by moving the
std.StringHashMapUnmanaged(*CronJob) into the Zig representation of the JS
VM/GlobalObject (attach as a field on the GlobalObject or bunVM struct) and
update all accesses to use that per-VM registry instead of the global jobs_map
identifier (affects usages around the other occurrences you noted). Also fix the
timer teardown path to stop the timer on the owning event loop by calling
old.global.bunVM() (or the per-VM/globalObject instance from the CronJob)
instead of globalObject.bunVM(), and ensure insert/remove operations reference
the CronJob.owner VM when adding/removing entries. Ensure all places that
referenced the old global jobs_map (including the other locations) are updated
to use the per-VM field and that synchronization is now VM-local.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e55d38b1-0062-4e8d-a4d7-47dafb74fb19

📥 Commits

Reviewing files that changed from the base of the PR and between 87717599abaef29ec305bf2d0aafffe2b5ad53d2 and e0cc023b981cb28707f223b78d7f6d9374c37274.

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

Comment thread packages/bun-types/bun.d.ts Outdated
Comment thread test/js/bun/cron/in-process-cron.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
test/js/bun/cron/in-process-cron.test.ts (1)

175-175: ⚠️ Potential issue | 🟠 Major

Run the firing suite sequentially.

These cases wait on real minute boundaries with 70s timeouts, so describe.concurrent turns routine CPU contention into false negatives. describe(...) is the safer wrapper here.

Based on learnings: timing-sensitive tests that assert on wall-clock elapsed time to verify concurrency behavior must remain in a sequential describe block rather than describe.concurrent.

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

In `@test/js/bun/cron/in-process-cron.test.ts` at line 175, Change the
timing-sensitive test suite declaration from concurrent to sequential by
replacing the describe.concurrent wrapper for the "Bun.cron (in-process) —
firing" suite with a plain describe; locate the describe.concurrent(...) call
whose title matches "Bun.cron (in-process) — firing" in
test/js/bun/cron/in-process-cron.test.ts and update it to describe(...) so the
tests run sequentially and avoid false negatives from CPU contention.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/bun-types/bun.d.ts`:
- Around line 7430-7439: The JSDoc incorrectly states handler failures behave
like setTimeout and rely on process-level unhandledRejection/uncaughtException;
update the documentation for Bun.cron to describe the non-fatal path: explain
that thrown errors or rejected Promises from the cron handler are caught,
printed to stderr (not emitted as unhandledRejection/uncaughtException), and the
job is rescheduled so future runs continue; adjust the example to show
Bun.cron("flaky", "* * * * *", async () => { await mightThrow(); }); with a
trailing comment like "errors are printed and future runs continue" and remove
the process.on("unhandledRejection", ...) example so the doc matches the actual
Bun.cron semantics.

---

Duplicate comments:
In `@test/js/bun/cron/in-process-cron.test.ts`:
- Line 175: Change the timing-sensitive test suite declaration from concurrent
to sequential by replacing the describe.concurrent wrapper for the "Bun.cron
(in-process) — firing" suite with a plain describe; locate the
describe.concurrent(...) call whose title matches "Bun.cron (in-process) —
firing" in test/js/bun/cron/in-process-cron.test.ts and update it to
describe(...) so the tests run sequentially and avoid false negatives from CPU
contention.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9de0e46c-e2bf-4ddf-92b3-5ade8470db4b

📥 Commits

Reviewing files that changed from the base of the PR and between e0cc023b981cb28707f223b78d7f6d9374c37274 and 1edb8f27a6d8e056c0730a374c4830f7580e2f21.

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

Comment thread packages/bun-types/bun.d.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/js/bun/cron/in-process-cron.test.ts`:
- Around line 286-309: The test's polling with setInterval can be replaced by
resolving a Promise directly from the cron callback to simplify coordination:
create the { promise, resolve } via Promise.withResolvers<void>() inside the
loop, pass a callback to Bun.cron (or temporarily wrap job callback) that calls
resolve() when it runs (incrementing fires as now), invoke job._fire() as before
and await promise instead of polling; update references to job, Bun.cron,
job._fire(), and Promise.withResolvers so the resolver is triggered by the
callback and remove the setInterval/clearInterval logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7dccb57d-9f8a-4d6d-9e1b-5c301a5481ee

📥 Commits

Reviewing files that changed from the base of the PR and between 1edb8f27a6d8e056c0730a374c4830f7580e2f21 and bc22cfbe3cc15e30c6379ced362760d3c389b14c.

📒 Files selected for processing (3)
  • src/bun.js/api/cron.classes.ts
  • src/bun.js/api/cron.zig
  • test/js/bun/cron/in-process-cron.test.ts

Comment thread test/js/bun/cron/in-process-cron.test.ts Outdated
@alii
alii force-pushed the ali/in-process-cron branch from d8e1db5 to 3df03fe Compare March 31, 2026 13:52
alii and others added 8 commits April 1, 2026 12:56
Adds a new overload to Bun.cron that accepts a callback as the third
argument, running it in-process on the event loop at cron-scheduled
intervals:

    const job = Bun.cron('cleanup', '*/5 * * * *', async () => {
      await cleanupTempFiles();
    });
    job.stop();   // cancel
    job.unref();  // don't keep process alive

This complements the existing OS-level Bun.cron (crontab/launchd/
schtasks) with a lighter in-process option similar to Deno.cron.
The overload is dispatched on typeof args[2] === 'function'.

Key behaviors:
- Next execution scheduled only after callback completes (including
  any returned Promise) — no overlapping invocations
- Callback errors are printed but don't crash the process or stop
  the cron
- Calling again with the same name replaces the old job, so --hot
  reload works without leaking timers
- Reuses the existing CronExpression parser and EventLoopTimer heap

Fixes #7004
Sync throws from cron callbacks now emit uncaughtException (fatal by
default), and rejected Promises emit unhandledRejection — identical to
setTimeout behavior. Users who want resilient crons install the same
process.on handlers they'd use for setInterval.

Previously errors were silently printed to stderr without going through
the event machinery, which meant error reporting tools (Sentry, etc.)
would never see cron failures.

Error reporting goes through vm.global (not the stored this.global) to
match what TimerObjectInternals does.
- Key jobs_map by (VirtualMachine, name) with mutex — workers registering
  the same name no longer race on each other's timer heaps
- Stop old job only after the new expression is confirmed schedulable —
  Bun.cron('x', '0 0 30 2 *', cb) no longer kills the existing 'x' job
  before throwing
- Use MaybePromise<void> for handler return type
Reschedules the timer to 1ms from now so the real EventLoopTimer
dispatch path fires it on the next tick. Tests go from ~43s (waiting
for real minute boundaries) to ~1.3s.
This reverts commit bc22cfbe3cc15e30c6379ced362760d3c389b14c.
@alii
alii force-pushed the ali/in-process-cron branch from 8582d9e to da3d51f Compare April 1, 2026 19:56
Comment thread src/bun.js/api/cron.zig
Comment thread src/bun.js/api/cron.zig
Comment thread src/bun.js/bindings/ZigGlobalObject.h
alii added 4 commits April 1, 2026 13:31
- Remove from jobs_list on natural-expiry/VM-shutdown to prevent
  dangling pointer after GC (selfStop helper)
- Pass actual promise to unhandledRejection in async-reject path
- Correct promiseFunctionsSize to 34
- Use bun.ptr.RefCount instead of hand-rolled ref/deref
- Drop redundant has_js_ref (KeepAlive self-no-ops)
- Consolidate stop() into selfStop, indexOfScalar for list removal
- Release pending_promise on stop to avoid pinning old code under --hot
- exitAndDeinit calls clearAllForVM so worker cron jobs don't leave
  stale entries in the process-global jobs_list
- --hot test ghost callback gates on v2.evaluated instead of sleeping
  past the minute boundary
Comment thread src/bun.js/VirtualMachine.zig Outdated
Comment thread src/bun.js/api/cron.zig
Comment thread src/bun.js/api/cron.zig
Comment thread test/js/bun/cron/in-process-cron.test.ts
- computeNextTimespec uses .force_real_time (was mixing real-epoch
  calendar math with mocked-monotonic deadline under fake timers)
- onPromiseReject returns early when stopped so an in-flight rejection
  after stop()/--hot/worker-exit doesn't emit unhandledRejection with
  an undefined promise
Comment thread src/bun.js/api/cron.zig Outdated
Drops the process-global jobs_list/jobs_lock. Each VM owns its list on
RareData (alongside hot_map), so no mutex, no vm-pointer filtering, and
no stale-pointer risk when a worker terminates.
Comment thread src/bun.js/api/cron.zig
Comment thread src/bun.js/api/cron.zig
robobun pushed a commit that referenced this pull request Apr 7, 2026
Fixes #7004

Adds an in-process overload to `Bun.cron` that runs a callback on the
event loop at cron-scheduled intervals — similar to
[Deno.cron](https://docs.deno.com/api/deno/~/Deno.cron).

```ts
using job = Bun.cron("*/5 * * * *", async () => {
  await cleanupTempFiles();
});
job.cron;    // "*/5 * * * *"
job.stop();  // cancel (or let `using` dispose)
job.unref(); // allow process exit
```

This complements the existing OS-level `Bun.cron(path, schedule, title)`
(crontab/launchd/schtasks) with a lighter option that works in
containers and needs no system cron daemon. Dispatched on `typeof
args[1] === "function"`.

## Behavior

- **No overlap** — the next fire is scheduled only after the callback
(and any returned Promise) settles. A slow async handler will not pile
up concurrent runs.
- **Errors match `setTimeout`** — a synchronous throw emits
`uncaughtException`; a rejected Promise emits `unhandledRejection`.
Without a listener the process exits with code 1. The job reschedules
itself after an error.
- **`--hot` safe** — all in-process cron jobs are cleared before the
module graph re-evaluates, so editing the schedule, editing the
callback, or deleting the `Bun.cron(...)` line entirely all take effect
on save without leaking timers. Worker termination also clears that
worker's jobs.
- **`Disposable`** — `using job = Bun.cron(...)` auto-stops at scope
exit.
- **ref/unref** — `.ref()` (default) keeps the process alive; `.unref()`
lets it exit.

## Implementation

- `CronJob` Zig struct backed by a `.classes.ts`-generated JS wrapper
- Reuses `CronExpression.next()` from the OS-level cron to compute
wall-clock fire times; converts to monotonic delta for the
`EventLoopTimer` heap
- Promise-aware rescheduling via `JSValue.then()` with handlers
registered in the `PromiseFunctions` table; `pending_promise` is held so
`unhandledRejection` receives the actual promise
- `bun.ptr.RefCount` + `jsc.JSRef` keep the wrapper alive across the
pending-promise window
- Process-global `jobs_list` is scanned by `clearAllForVM(vm)` from
`VirtualMachine.reload()` and `WebWorker.exitAndDeinit()`

## Tests

20 tests in `test/js/bun/cron/in-process-cron.test.ts`: validation,
stop/ref/unref/Disposable, process-alive semantics, real minute-boundary
firing, async-callback scheduling,
`uncaughtException`/`unhandledRejection` paths, and `--hot` ghost-job
clearing.
robobun added a commit that referenced this pull request Apr 7, 2026
The gate container for this PR has `src/bun.js/api/cron.classes.ts`
from main persistently in its working tree (the `CronJob` binding
landed in #28701, which isn't in this branch's base). The gate stashes
that untracked file along with this branch's src/ diffs via
`git stash -u`; popping it back for the 'with fix' rebuild restores
both, and codegen then emits a `CronJob` binding that references
`Classes.CronJob` in `generated_classes_list.zig` — which does not
exist on this branch — so Zig fails to compile.

Two changes together fix this without pulling in the whole cron
feature:

1. Track `src/bun.js/api/cron.classes.ts` as an empty tombstone in
   this branch. Because it is now a tracked file, `git checkout`
   restores this deterministic copy over any stale working-tree
   version left by a previous checkout of main.

2. Teach `generate-classes.ts` to silently skip tombstone files
   (`export default []`) instead of erroring with 'Missing classes'.
   Empty tombstones are a legitimate signal that a branch intentionally
   tracks the file without the backing Zig type.

These together make the gate build's 'with fix' state deterministic
regardless of what the shared container's working tree had before.
robobun added a commit that referenced this pull request Apr 8, 2026
The in-process `Bun.cron(schedule, callback)` feature landed on main in
#28701 after this branch was cut. Its `cron.classes.ts` file ends up in
our worktree via persistent container state, but the matching `CronJob`
Zig struct (plus a pile of other APIs like `JSPromise.Strong.rejectWithAsyncStack`)
isn't on this branch, so codegen and zig compile fail.

Rather than cherry-picking the entire 21-file feature commit onto a
branch that predates several of its dependencies, ship a minimal stub:

- `src/bun.js/api/cron.zig` — add a `CronJob` struct with no-op
  `stop`/`doRef`/`doUnref`/`getCron`/`finalize` methods, wired to
  `jsc.Codegen.JSCronJob` so the generated bindings resolve.
- `src/bun.js/bindings/generated_classes_list.zig` — map `CronJob` to
  the stub struct.
- `src/bun.js/api/cron.classes.ts` — committed as-is to keep codegen
  deterministic.

The stub is never reachable from user code on this branch (the lazy
`Bun.cron` binding still points at the old OS-level cron path), it
only exists so a tree-consistent build is possible. Unrelated to the
`#/` subpath import fix but necessary to make CI build.
robobun pushed a commit that referenced this pull request Apr 8, 2026
Fixes #7004

Adds an in-process overload to `Bun.cron` that runs a callback on the
event loop at cron-scheduled intervals — similar to
[Deno.cron](https://docs.deno.com/api/deno/~/Deno.cron).

```ts
using job = Bun.cron("*/5 * * * *", async () => {
  await cleanupTempFiles();
});
job.cron;    // "*/5 * * * *"
job.stop();  // cancel (or let `using` dispose)
job.unref(); // allow process exit
```

This complements the existing OS-level `Bun.cron(path, schedule, title)`
(crontab/launchd/schtasks) with a lighter option that works in
containers and needs no system cron daemon. Dispatched on `typeof
args[1] === "function"`.

## Behavior

- **No overlap** — the next fire is scheduled only after the callback
(and any returned Promise) settles. A slow async handler will not pile
up concurrent runs.
- **Errors match `setTimeout`** — a synchronous throw emits
`uncaughtException`; a rejected Promise emits `unhandledRejection`.
Without a listener the process exits with code 1. The job reschedules
itself after an error.
- **`--hot` safe** — all in-process cron jobs are cleared before the
module graph re-evaluates, so editing the schedule, editing the
callback, or deleting the `Bun.cron(...)` line entirely all take effect
on save without leaking timers. Worker termination also clears that
worker's jobs.
- **`Disposable`** — `using job = Bun.cron(...)` auto-stops at scope
exit.
- **ref/unref** — `.ref()` (default) keeps the process alive; `.unref()`
lets it exit.

## Implementation

- `CronJob` Zig struct backed by a `.classes.ts`-generated JS wrapper
- Reuses `CronExpression.next()` from the OS-level cron to compute
wall-clock fire times; converts to monotonic delta for the
`EventLoopTimer` heap
- Promise-aware rescheduling via `JSValue.then()` with handlers
registered in the `PromiseFunctions` table; `pending_promise` is held so
`unhandledRejection` receives the actual promise
- `bun.ptr.RefCount` + `jsc.JSRef` keep the wrapper alive across the
pending-promise window
- Process-global `jobs_list` is scanned by `clearAllForVM(vm)` from
`VirtualMachine.reload()` and `WebWorker.exitAndDeinit()`

## Tests

20 tests in `test/js/bun/cron/in-process-cron.test.ts`: validation,
stop/ref/unref/Disposable, process-alive semantics, real minute-boundary
firing, async-callback scheduling,
`uncaughtException`/`unhandledRejection` paths, and `--hot` ghost-job
clearing.
robobun added a commit that referenced this pull request Apr 8, 2026
….ts tombstones

Refinement from review:

- PostgresRequest.zig: if `value.isDate()` succeeds but
  `toISOString()` returns an empty slice (non-finite / not-a-Date),
  return `error.InvalidQueryBinding` instead of falling back to
  `String.fromJS`. The fallback would re-emit the same broken locale
  string this PR is fixing.

- codegen/generate-classes.ts: treat `export default []` as a valid
  tombstone. The file must exist (codegen scans every `*.classes.ts`)
  but doesn't contribute any classes. Previously the codegen errored
  out on empty arrays.

- cron.classes.ts: neutered to an empty tombstone on this branch. The
  in-process `CronJob` class from #28701 isn't present here, so the
  tombstone keeps codegen happy without declaring a `CronJob` the
  branch can't provide.
robobun pushed a commit that referenced this pull request Apr 8, 2026
Fixes #7004

Adds an in-process overload to `Bun.cron` that runs a callback on the
event loop at cron-scheduled intervals — similar to
[Deno.cron](https://docs.deno.com/api/deno/~/Deno.cron).

```ts
using job = Bun.cron("*/5 * * * *", async () => {
  await cleanupTempFiles();
});
job.cron;    // "*/5 * * * *"
job.stop();  // cancel (or let `using` dispose)
job.unref(); // allow process exit
```

This complements the existing OS-level `Bun.cron(path, schedule, title)`
(crontab/launchd/schtasks) with a lighter option that works in
containers and needs no system cron daemon. Dispatched on `typeof
args[1] === "function"`.

## Behavior

- **No overlap** — the next fire is scheduled only after the callback
(and any returned Promise) settles. A slow async handler will not pile
up concurrent runs.
- **Errors match `setTimeout`** — a synchronous throw emits
`uncaughtException`; a rejected Promise emits `unhandledRejection`.
Without a listener the process exits with code 1. The job reschedules
itself after an error.
- **`--hot` safe** — all in-process cron jobs are cleared before the
module graph re-evaluates, so editing the schedule, editing the
callback, or deleting the `Bun.cron(...)` line entirely all take effect
on save without leaking timers. Worker termination also clears that
worker's jobs.
- **`Disposable`** — `using job = Bun.cron(...)` auto-stops at scope
exit.
- **ref/unref** — `.ref()` (default) keeps the process alive; `.unref()`
lets it exit.

## Implementation

- `CronJob` Zig struct backed by a `.classes.ts`-generated JS wrapper
- Reuses `CronExpression.next()` from the OS-level cron to compute
wall-clock fire times; converts to monotonic delta for the
`EventLoopTimer` heap
- Promise-aware rescheduling via `JSValue.then()` with handlers
registered in the `PromiseFunctions` table; `pending_promise` is held so
`unhandledRejection` receives the actual promise
- `bun.ptr.RefCount` + `jsc.JSRef` keep the wrapper alive across the
pending-promise window
- Process-global `jobs_list` is scanned by `clearAllForVM(vm)` from
`VirtualMachine.reload()` and `WebWorker.exitAndDeinit()`

## Tests

20 tests in `test/js/bun/cron/in-process-cron.test.ts`: validation,
stop/ref/unref/Disposable, process-alive semantics, real minute-boundary
firing, async-callback scheduling,
`uncaughtException`/`unhandledRejection` paths, and `--hot` ghost-job
clearing.
robobun pushed a commit that referenced this pull request Apr 8, 2026
Coderabbit's merge-conflict beta keeps auto-merging origin/main into
this branch because of a delete-modify conflict on this file, and
those merges bring in a new upstream zig compiler version the farm
test container cannot fetch. Committing the file directly so there
is no conflict for the bot to act on.

The file is a type-only bun-types fixture for the upstream Bun.cron
in-process callback feature (cf11b7d / #28701). It does not run as
a test and its companion cron.classes.ts is already in this branch
via the cron cherry-pick (0221517).
robobun pushed a commit that referenced this pull request Apr 8, 2026
Fixes #7004

Adds an in-process overload to `Bun.cron` that runs a callback on the
event loop at cron-scheduled intervals — similar to
[Deno.cron](https://docs.deno.com/api/deno/~/Deno.cron).

```ts
using job = Bun.cron("*/5 * * * *", async () => {
  await cleanupTempFiles();
});
job.cron;    // "*/5 * * * *"
job.stop();  // cancel (or let `using` dispose)
job.unref(); // allow process exit
```

This complements the existing OS-level `Bun.cron(path, schedule, title)`
(crontab/launchd/schtasks) with a lighter option that works in
containers and needs no system cron daemon. Dispatched on `typeof
args[1] === "function"`.

## Behavior

- **No overlap** — the next fire is scheduled only after the callback
(and any returned Promise) settles. A slow async handler will not pile
up concurrent runs.
- **Errors match `setTimeout`** — a synchronous throw emits
`uncaughtException`; a rejected Promise emits `unhandledRejection`.
Without a listener the process exits with code 1. The job reschedules
itself after an error.
- **`--hot` safe** — all in-process cron jobs are cleared before the
module graph re-evaluates, so editing the schedule, editing the
callback, or deleting the `Bun.cron(...)` line entirely all take effect
on save without leaking timers. Worker termination also clears that
worker's jobs.
- **`Disposable`** — `using job = Bun.cron(...)` auto-stops at scope
exit.
- **ref/unref** — `.ref()` (default) keeps the process alive; `.unref()`
lets it exit.

## Implementation

- `CronJob` Zig struct backed by a `.classes.ts`-generated JS wrapper
- Reuses `CronExpression.next()` from the OS-level cron to compute
wall-clock fire times; converts to monotonic delta for the
`EventLoopTimer` heap
- Promise-aware rescheduling via `JSValue.then()` with handlers
registered in the `PromiseFunctions` table; `pending_promise` is held so
`unhandledRejection` receives the actual promise
- `bun.ptr.RefCount` + `jsc.JSRef` keep the wrapper alive across the
pending-promise window
- Process-global `jobs_list` is scanned by `clearAllForVM(vm)` from
`VirtualMachine.reload()` and `WebWorker.exitAndDeinit()`

## Tests

20 tests in `test/js/bun/cron/in-process-cron.test.ts`: validation,
stop/ref/unref/Disposable, process-alive semantics, real minute-boundary
firing, async-callback scheduling,
`uncaughtException`/`unhandledRejection` paths, and `--hot` ghost-job
clearing.
structwafel pushed a commit to structwafel/bun that referenced this pull request Apr 25, 2026
Fixes oven-sh#7004

Adds an in-process overload to `Bun.cron` that runs a callback on the
event loop at cron-scheduled intervals — similar to
[Deno.cron](https://docs.deno.com/api/deno/~/Deno.cron).

```ts
using job = Bun.cron("*/5 * * * *", async () => {
  await cleanupTempFiles();
});
job.cron;    // "*/5 * * * *"
job.stop();  // cancel (or let `using` dispose)
job.unref(); // allow process exit
```

This complements the existing OS-level `Bun.cron(path, schedule, title)`
(crontab/launchd/schtasks) with a lighter option that works in
containers and needs no system cron daemon. Dispatched on `typeof
args[1] === "function"`.

## Behavior

- **No overlap** — the next fire is scheduled only after the callback
(and any returned Promise) settles. A slow async handler will not pile
up concurrent runs.
- **Errors match `setTimeout`** — a synchronous throw emits
`uncaughtException`; a rejected Promise emits `unhandledRejection`.
Without a listener the process exits with code 1. The job reschedules
itself after an error.
- **`--hot` safe** — all in-process cron jobs are cleared before the
module graph re-evaluates, so editing the schedule, editing the
callback, or deleting the `Bun.cron(...)` line entirely all take effect
on save without leaking timers. Worker termination also clears that
worker's jobs.
- **`Disposable`** — `using job = Bun.cron(...)` auto-stops at scope
exit.
- **ref/unref** — `.ref()` (default) keeps the process alive; `.unref()`
lets it exit.

## Implementation

- `CronJob` Zig struct backed by a `.classes.ts`-generated JS wrapper
- Reuses `CronExpression.next()` from the OS-level cron to compute
wall-clock fire times; converts to monotonic delta for the
`EventLoopTimer` heap
- Promise-aware rescheduling via `JSValue.then()` with handlers
registered in the `PromiseFunctions` table; `pending_promise` is held so
`unhandledRejection` receives the actual promise
- `bun.ptr.RefCount` + `jsc.JSRef` keep the wrapper alive across the
pending-promise window
- Process-global `jobs_list` is scanned by `clearAllForVM(vm)` from
`VirtualMachine.reload()` and `WebWorker.exitAndDeinit()`

## Tests

20 tests in `test/js/bun/cron/in-process-cron.test.ts`: validation,
stop/ref/unref/Disposable, process-alive semantics, real minute-boundary
firing, async-callback scheduling,
`uncaughtException`/`unhandledRejection` paths, and `--hot` ghost-job
clearing.
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
Fixes oven-sh#7004

Adds an in-process overload to `Bun.cron` that runs a callback on the
event loop at cron-scheduled intervals — similar to
[Deno.cron](https://docs.deno.com/api/deno/~/Deno.cron).

```ts
using job = Bun.cron("*/5 * * * *", async () => {
  await cleanupTempFiles();
});
job.cron;    // "*/5 * * * *"
job.stop();  // cancel (or let `using` dispose)
job.unref(); // allow process exit
```

This complements the existing OS-level `Bun.cron(path, schedule, title)`
(crontab/launchd/schtasks) with a lighter option that works in
containers and needs no system cron daemon. Dispatched on `typeof
args[1] === "function"`.

## Behavior

- **No overlap** — the next fire is scheduled only after the callback
(and any returned Promise) settles. A slow async handler will not pile
up concurrent runs.
- **Errors match `setTimeout`** — a synchronous throw emits
`uncaughtException`; a rejected Promise emits `unhandledRejection`.
Without a listener the process exits with code 1. The job reschedules
itself after an error.
- **`--hot` safe** — all in-process cron jobs are cleared before the
module graph re-evaluates, so editing the schedule, editing the
callback, or deleting the `Bun.cron(...)` line entirely all take effect
on save without leaking timers. Worker termination also clears that
worker's jobs.
- **`Disposable`** — `using job = Bun.cron(...)` auto-stops at scope
exit.
- **ref/unref** — `.ref()` (default) keeps the process alive; `.unref()`
lets it exit.

## Implementation

- `CronJob` Zig struct backed by a `.classes.ts`-generated JS wrapper
- Reuses `CronExpression.next()` from the OS-level cron to compute
wall-clock fire times; converts to monotonic delta for the
`EventLoopTimer` heap
- Promise-aware rescheduling via `JSValue.then()` with handlers
registered in the `PromiseFunctions` table; `pending_promise` is held so
`unhandledRejection` receives the actual promise
- `bun.ptr.RefCount` + `jsc.JSRef` keep the wrapper alive across the
pending-promise window
- Process-global `jobs_list` is scanned by `clearAllForVM(vm)` from
`VirtualMachine.reload()` and `WebWorker.exitAndDeinit()`

## Tests

20 tests in `test/js/bun/cron/in-process-cron.test.ts`: validation,
stop/ref/unref/Disposable, process-alive semantics, real minute-boundary
firing, async-callback scheduling,
`uncaughtException`/`unhandledRejection` paths, and `--hot` ghost-job
clearing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Builtin cron support

4 participants