Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/runtime/cron.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -312,15 +312,15 @@ launchctl list | grep bun.cron
**Logs:** stdout and stderr are written to:

```
/tmp/bun.cron.<title>.stdout.log
/tmp/bun.cron.<title>.stderr.log
~/Library/Logs/bun/cron/bun.cron.<title>.stdout.log
~/Library/Logs/bun/cron/bun.cron.<title>.stderr.log
Comment thread
robobun marked this conversation as resolved.
```

For example, a job titled `weekly-report`:

```sh
cat /tmp/bun.cron.weekly-report.stdout.log
tail -f /tmp/bun.cron.weekly-report.stderr.log
cat ~/Library/Logs/bun/cron/bun.cron.weekly-report.stdout.log
tail -f ~/Library/Logs/bun/cron/bun.cron.weekly-report.stderr.log
```

**Manually uninstalling without code:**
Expand Down
2 changes: 1 addition & 1 deletion packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7691,7 +7691,7 @@ declare module "bun" {
* - **Windows headless/CI:** registration fails if the current user's SID can't be
* resolved (typical under service accounts). Run as a regular user or create the
* task manually with `schtasks /create /ru SYSTEM`.
* - **macOS:** stdout/stderr are written to `/tmp/bun.cron.<title>.{stdout,stderr}.log`.
* - **macOS:** stdout/stderr are written to `~/Library/Logs/bun/cron/bun.cron.<title>.{stdout,stderr}.log`.
*
* ### Idempotency & removal
*
Expand Down
10 changes: 10 additions & 0 deletions src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,16 @@ export const decodeURIComponentSIMD = $newCppFunction(
);

export const getDevServerDeinitCount = $bindgenFn("DevServer.bind.ts", "getDeinitCountForTesting");

// Builds the macOS launchd plist body from its inputs (host-independent) so the
// cron log-path / XML-escaping logic can be asserted on any platform.
export const cronPlistForTesting = $bindgenFn("BunObject.bind.ts", "cronPlistForTesting") as (
home: string,
title: string,
bunExe: string,
absPath: string,
schedule: string,
) => string;
export const getCounters = $newZigFunction("Counters.zig", "createCountersObject", 0);
export const linearFifoOrderedRemoveProbe = $newZigFunction(
"collections/linear_fifo.zig",
Expand Down
15 changes: 15 additions & 0 deletions src/runtime/api/BunObject.bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,18 @@ export const gc = fn({
},
ret: t.usize,
});

// Builds the macOS launchd plist body (used by `Bun.cron()` on macOS) from its
// inputs, so the log-path / XML-escaping logic can be unit-tested on any host.
// Exposed via `bun:internal-for-testing`. Throws on an invalid cron expression.
export const cronPlistForTesting = fn({
args: {
global: t.globalObject,
home: t.UTF8String,
title: t.UTF8String,
bunExe: t.UTF8String,
absPath: t.UTF8String,
schedule: t.UTF8String,
},
ret: t.DOMString,
});
180 changes: 117 additions & 63 deletions src/runtime/api/cron.rs
Original file line number Diff line number Diff line change
Expand Up @@ -494,14 +494,13 @@ impl CronRegisterJob {
let s = unsafe { &mut *this };
s.state = RegisterState::WritingPlist;

let calendar_xml = match cron_to_calendar_interval(s.schedule.as_bytes()) {
Ok(x) => x,
Err(_) => {
s.set_err(format_args!("Invalid cron expression"));
// SAFETY: local reborrow `s` has ended; `this` is the live heap job.
return unsafe { Self::finish(this) };
}
};
// Validate the schedule up front so an invalid expression errors before we
// create any directories. The plist body (which re-runs this) is built below.
if cron_to_calendar_interval(s.schedule.as_bytes()).is_err() {
s.set_err(format_args!("Invalid cron expression"));
// SAFETY: local reborrow `s` has ended; `this` is the live heap job.
return unsafe { Self::finish(this) };
}

let Some(home) = env_var::HOME.get() else {
s.set_err(format_args!("HOME environment variable not set"));
Expand All @@ -523,6 +522,23 @@ impl CronRegisterJob {
return unsafe { Self::finish(this) };
}

// Per-user log directory. World-writable /tmp lets another local user
// pre-create a symlink at the predictable path and have launchd write
// through it as this user (CWE-59/377). Must exist before launchd runs.
let mut log_dir = Vec::new();
let _ = write!(
&mut log_dir,
"{}/Library/Logs/bun/cron",
bstr::BStr::new(home)
);
if Fd::cwd().make_path(&log_dir).is_err() {
s.set_err(format_args!(
"Failed to create ~/Library/Logs/bun/cron directory"
));
// SAFETY: local reborrow `s` has ended; `this` is the live heap job.
return unsafe { Self::finish(this) };
}

let plist_path = match alloc_print_z(format_args!(
"{}/Library/LaunchAgents/bun.cron.{}.plist",
bstr::BStr::new(home),
Expand All @@ -537,61 +553,27 @@ impl CronRegisterJob {
};
s.tmp_path = Some(plist_path);

// XML-escape all dynamic values
macro_rules! try_escape {
($e:expr) => {
match xml_escape($e) {
Ok(v) => v,
Err(_) => {
s.set_err(format_args!("Out of memory"));
// SAFETY: local reborrow `s` has ended; `this` is the live heap job.
return unsafe { Self::finish(this) };
}
}
};
}
let xml_title = try_escape!(s.title.as_bytes());
let xml_bun = try_escape!(s.bun_exe.as_bytes());
let xml_path = try_escape!(s.abs_path.as_bytes());
let xml_sched = try_escape!(s.schedule.as_bytes());

let mut plist = Vec::new();
if write!(
&mut plist,
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n\
<key>Label</key>\n\
<string>bun.cron.{0}</string>\n\
<key>ProgramArguments</key>\n\
<array>\n\
<string>{1}</string>\n\
<string>run</string>\n\
<string>--cron-title={0}</string>\n\
<string>--cron-period={3}</string>\n\
<string>{2}</string>\n\
</array>\n\
<key>StartCalendarInterval</key>\n\
{4}\n\
<key>StandardOutPath</key>\n\
<string>/tmp/bun.cron.{0}.stdout.log</string>\n\
<key>StandardErrorPath</key>\n\
<string>/tmp/bun.cron.{0}.stderr.log</string>\n\
</dict>\n\
</plist>\n",
bstr::BStr::new(&xml_title),
bstr::BStr::new(&xml_bun),
bstr::BStr::new(&xml_path),
bstr::BStr::new(&xml_sched),
bstr::BStr::new(&calendar_xml),
)
.is_err()
{
s.set_err(format_args!("Out of memory"));
// SAFETY: local reborrow `s` has ended; `this` is the live heap job.
return unsafe { Self::finish(this) };
}
// Build the plist body (log paths, XML-escaping, calendar interval) via the
// shared helper so the macOS path and the `internal-for-testing` hook agree.
let plist = match build_launchd_plist(
home,
s.title.as_bytes(),
s.bun_exe.as_bytes(),
s.abs_path.as_bytes(),
s.schedule.as_bytes(),
) {
Ok(p) => p,
Err(PlistError::InvalidSchedule) => {
s.set_err(format_args!("Invalid cron expression"));
// SAFETY: local reborrow `s` has ended; `this` is the live heap job.
return unsafe { Self::finish(this) };
}
Err(PlistError::OutOfMemory) => {
s.set_err(format_args!("Out of memory"));
// SAFETY: local reborrow `s` has ended; `this` is the live heap job.
return unsafe { Self::finish(this) };
}
};

let file = match File::openat(
Fd::cwd(),
Expand Down Expand Up @@ -2649,6 +2631,78 @@ pub fn cron_to_calendar_interval(schedule: &[u8]) -> Result<Vec<u8>, CalendarErr
Ok(result)
}

/// Error building a launchd plist: either an invalid cron expression or an
/// allocation failure while formatting.
pub enum PlistError {
InvalidSchedule,
OutOfMemory,
}

/// Build the launchd plist body for a macOS cron job. Kept platform-independent
/// (not `#[cfg(macos)]`) so the path and escaping logic can be unit-tested on any
/// host via `bun:internal-for-testing`. `home` is the value of `$HOME`; the log
/// paths are placed under `{home}/Library/Logs/bun/cron` — a per-user directory —
/// rather than world-writable `/tmp` (CWE-59/377).
pub fn build_launchd_plist(
home: &[u8],
title: &[u8],
bun_exe: &[u8],
abs_path: &[u8],
schedule: &[u8],
) -> Result<Vec<u8>, PlistError> {
let calendar_xml =
cron_to_calendar_interval(schedule).map_err(|_| PlistError::InvalidSchedule)?;

let mut log_dir = Vec::new();
write!(
&mut log_dir,
"{}/Library/Logs/bun/cron",
bstr::BStr::new(home)
)
.map_err(|_| PlistError::OutOfMemory)?;

let xml_title = xml_escape(title).map_err(|_| PlistError::OutOfMemory)?;
let xml_bun = xml_escape(bun_exe).map_err(|_| PlistError::OutOfMemory)?;
let xml_path = xml_escape(abs_path).map_err(|_| PlistError::OutOfMemory)?;
let xml_sched = xml_escape(schedule).map_err(|_| PlistError::OutOfMemory)?;
let xml_log_dir = xml_escape(&log_dir).map_err(|_| PlistError::OutOfMemory)?;

let mut plist = Vec::new();
write!(
&mut plist,
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n\
<key>Label</key>\n\
<string>bun.cron.{0}</string>\n\
<key>ProgramArguments</key>\n\
<array>\n\
<string>{1}</string>\n\
<string>run</string>\n\
<string>--cron-title={0}</string>\n\
<string>--cron-period={3}</string>\n\
<string>{2}</string>\n\
</array>\n\
<key>StartCalendarInterval</key>\n\
{4}\n\
<key>StandardOutPath</key>\n\
<string>{5}/bun.cron.{0}.stdout.log</string>\n\
<key>StandardErrorPath</key>\n\
<string>{5}/bun.cron.{0}.stderr.log</string>\n\
</dict>\n\
</plist>\n",
bstr::BStr::new(&xml_title),
bstr::BStr::new(&xml_bun),
bstr::BStr::new(&xml_path),
bstr::BStr::new(&xml_sched),
bstr::BStr::new(&calendar_xml),
bstr::BStr::new(&xml_log_dir),
)
.map_err(|_| PlistError::OutOfMemory)?;
Ok(plist)
}

fn append_calendar_key(result: &mut Vec<u8>, key: &[u8], val: i32) -> Result<(), CalendarError> {
let _ = write!(
result,
Expand Down
68 changes: 68 additions & 0 deletions src/runtime/hw_exports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,60 @@ pub fn bindgen_fmt_jsc_dispatch_fmt_string(
}
}

/// `BunObject.cronPlistForTesting(home, title, bunExe, absPath, schedule) -> bun.String`
/// (cron.test.ts internal — builds the macOS launchd plist body on any host so the
/// log-path / XML-escaping logic can be asserted without a darwin-only code path).
///
/// # Safety
/// All `arg_*` pointers and `out` must be valid C++ stack locals.
// HOST_EXPORT(bindgen_BunObject_dispatchCronPlistForTesting1, c)
// Called only from the generated `extern "C"` thunk; C++ guarantees non-null stack locals.
#[allow(clippy::not_unsafe_ptr_arg_deref)]
#[allow(clippy::too_many_arguments)]
pub fn bindgen_bunobject_dispatch_cron_plist_for_testing(
global: &JSGlobalObject,
arg_home: *const bun_core::String,
arg_title: *const bun_core::String,
arg_bun_exe: *const bun_core::String,
arg_abs_path: *const bun_core::String,
arg_schedule: *const bun_core::String,
out: *mut bun_core::String,
) -> bool {
// SAFETY: every `arg_*` points to a `bun_core::String` on the C++ caller's
// stack (see GeneratedBindings.cpp call site); `to_utf8` only borrows.
let (home, title, bun_exe, abs_path, schedule) = unsafe {
(
(*arg_home).to_utf8(),
(*arg_title).to_utf8(),
(*arg_bun_exe).to_utf8(),
(*arg_abs_path).to_utf8(),
(*arg_schedule).to_utf8(),
)
};
use crate::api::cron::{PlistError, build_launchd_plist};
match build_launchd_plist(
home.slice(),
title.slice(),
bun_exe.slice(),
abs_path.slice(),
schedule.slice(),
) {
Ok(plist) => {
// SAFETY: `out` is a valid C++ stack out-param.
unsafe { *out = bun_core::String::clone_utf8(&plist) };
true
}
Err(PlistError::InvalidSchedule) => {
let _ = global.throw_pretty(format_args!("Invalid cron expression"));
false
}
Err(PlistError::OutOfMemory) => {
let _ = global.throw_out_of_memory();
false
}
}
}

/// `DevServer.getDeinitCountForTesting() -> usize`.
///
/// # Safety
Expand Down Expand Up @@ -715,6 +769,7 @@ bun_jsc::jsc_abi_extern! {
// C++-side host fns (Generated*Bindings.cpp).
fn bindgen_Fmt_jsc_jsFmtString(g: *mut JSGlobalObject, c: *mut CallFrame) -> JSValue;
fn bindgen_DevServer_jsGetDeinitCountForTesting(g: *mut JSGlobalObject, c: *mut CallFrame) -> JSValue;
fn bindgen_BunObject_jsCronPlistForTesting(g: *mut JSGlobalObject, c: *mut CallFrame) -> JSValue;
}

// HOST_EXPORT(js2native_bindgen_fmt_jsc_fmtString, jsc)
Expand All @@ -730,6 +785,19 @@ pub fn js2native_bindgen_fmt_jsc_fmt_string(global: &JSGlobalObject) -> JSValue
)
}

// HOST_EXPORT(js2native_bindgen_BunObject_cronPlistForTesting, jsc)
pub fn js2native_bindgen_bunobject_cron_plist_for_testing(global: &JSGlobalObject) -> JSValue {
let name = bun_core::ZigString::init_utf8(b"cronPlistForTesting");
bun_jsc::host_fn::new_runtime_function(
global,
Some(&name),
5,
bindgen_BunObject_jsCronPlistForTesting,
false,
None,
)
}

// HOST_EXPORT(js2native_bindgen_DevServer_getDeinitCountForTesting, jsc)
pub fn js2native_bindgen_dev_server_get_deinit_count(global: &JSGlobalObject) -> JSValue {
let name = bun_core::ZigString::init_utf8(b"getDeinitCountForTesting");
Expand Down
Loading
Loading