Compare commits

...

3 Commits

Author SHA1 Message Date
ant-kurt
6bcf623412 fix(spawn): prevent nested spawnSync from corrupting event loop handle
SpawnSyncEventLoop is a singleton that saves vm.event_loop_handle in
prepare() and restores it in cleanup(). If spawnSync is called
recursively, the inner prepare() overwrites the singleton's
original_event_loop_handle with the already-overridden value, and both
cleanups restore to the isolated loop instead of the main loop. Stdin,
timers, and async subprocess sockets registered on the main loop are
then orphaned, and the process exits once pending work drains.

Observed on Rocky Linux 8 where startup timing causes an async spawn's
completion callback to run during spawnSync's isolated-loop tick, and
that callback calls spawnSync again. Trace shows two epoll_create1
calls, cross-loop fd confusion (epoll_ctl DEL → ENOENT), and stack
pointer evidence of nesting (~14KB deeper for the inner call).

Two complementary fixes:

1. Stack-local save/restore at the call site
   (js_bun_spawn_bindings.zig): each spawnSync invocation saves
   vm.event_loop_handle on its own stack frame. LIFO defer order
   guarantees the outermost restore runs last with the correct value,
   regardless of what the singleton's field contains.

2. Nesting counter on the singleton (SpawnSyncEventLoop.zig):
   prepare()/cleanup() only act on the outermost call; nested calls are
   no-ops. Makes the singleton independently correct.

Either fix alone resolves the bug; both together give defense in depth.
2026-03-10 20:10:13 +00:00
kashyap murali
2eb2b01823 docs: add Oxford comma to platform support list in README (#27953)
Adds a serial (Oxford) comma before "and Windows" in the platform
support line for grammatical consistency.
2026-03-10 00:21:31 -07:00
robobun
05026087b3 fix(watch): fix off-by-one in file:// URL prefix stripping (#27970)
## Summary

- Fix off-by-one error when stripping `file://` prefix in
`node_fs_watcher.zig` and `node_fs_stat_watcher.zig`
- `"file://"` is 7 characters, but `slice[6..]` was used instead of
`slice[7..]`, retaining the second `/`
- Use `slice["file://".len..]` for clarity, matching the existing
pattern in `VirtualMachine.zig:1750`

The bug was masked by downstream path normalization in
`joinAbsStringBufZ` which collapses the duplicate leading slash (e.g.
`//tmp/foo` → `/tmp/foo`).

## Test plan

- [x] Existing `fs.watch` URL tests pass
(`test/js/node/watch/fs.watch.test.ts`)
- [x] Existing `fs.watchFile` URL tests pass
(`test/js/node/watch/fs.watchFile.test.ts`)
- [x] Debug build compiles successfully

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-09 19:52:09 -07:00
5 changed files with 35 additions and 4 deletions

View File

@@ -43,7 +43,7 @@ bunx cowsay 'Hello, world!' # execute a package
## Install
Bun supports Linux (x64 & arm64), macOS (x64 & Apple Silicon) and Windows (x64 & arm64).
Bun supports Linux (x64 & arm64), macOS (x64 & Apple Silicon), and Windows (x64 & arm64).
> **Linux users** — Kernel version 5.6 or higher is strongly recommended, but the minimum is 5.1.

View File

@@ -546,13 +546,27 @@ pub fn spawnMaybeSync(
else
jsc_vm.eventLoop();
// Save the original event loop handle on THIS stack frame. The singleton's
// `original_event_loop_handle` field is not nesting-safe: if a queued JS
// callback runs during sync_loop.tickWithTimeout() and calls spawnSync again,
// the nested prepare() overwrites the singleton field with the already-
// overridden handle, and both cleanups then restore the wrong value. Saving
// on each caller's stack frame means LIFO defer order restores correctly.
const saved_event_loop_handle = if (comptime is_sync) jsc_vm.event_loop_handle else {};
const saved_event_loop_ptr = if (comptime is_sync) jsc_vm.event_loop else {};
if (comptime is_sync) {
jsc_vm.rareData().spawnSyncEventLoop(jsc_vm).prepare(jsc_vm);
}
defer {
if (comptime is_sync) {
jsc_vm.rareData().spawnSyncEventLoop(jsc_vm).cleanup(jsc_vm, jsc_vm.eventLoop());
// Call cleanup first for its other bookkeeping (Windows timer stop).
// Its handle restore may be wrong if nesting occurred — we overwrite it below.
jsc_vm.rareData().spawnSyncEventLoop(jsc_vm).cleanup(jsc_vm, saved_event_loop_ptr);
// Authoritative restore from OUR stack frame, not the (possibly corrupted) singleton.
jsc_vm.event_loop_handle = saved_event_loop_handle;
jsc_vm.event_loop = saved_event_loop_ptr;
}
}

View File

@@ -31,6 +31,13 @@ original_event_loop_handle: @FieldType(jsc.VirtualMachine, "event_loop_handle")
uv_timer: if (bun.Environment.isWindows) ?*bun.windows.libuv.Timer else void = if (bun.Environment.isWindows) null else {},
did_timeout: bool = false,
/// Reentrancy guard. spawnSync can be called recursively if a queued JS
/// callback (e.g. an async subprocess's completion handler) runs during
/// tickWithTimeout and itself calls spawnSync. Without this counter, the
/// nested prepare() would overwrite original_event_loop_handle with the
/// already-overridden value, and both cleanups would restore the wrong loop.
nesting_depth: u32 = 0,
/// Minimal handler for the isolated loop
const Handler = struct {
pub fn wakeup(loop: *uws.Loop) callconv(.c) void {
@@ -95,12 +102,22 @@ pub fn prepare(this: *SpawnSyncEventLoop, vm: *jsc.VirtualMachine) void {
this.did_timeout = false;
this.event_loop.virtual_machine = vm;
// Only save/override on the outermost call. Nested calls are no-ops
// because the isolated loop is already active.
defer this.nesting_depth += 1;
if (this.nesting_depth > 0) return;
this.original_event_loop_handle = vm.event_loop_handle;
vm.event_loop_handle = if (bun.Environment.isPosix) this.uws_loop else this.uws_loop.uv_loop;
}
/// Restore the original event loop handle after spawnSync completes
pub fn cleanup(this: *SpawnSyncEventLoop, vm: *jsc.VirtualMachine, prev_event_loop: *jsc.EventLoop) void {
// Only restore on the outermost call. Inner cleanups skip restoration
// so the outer spawnSync keeps running on the isolated loop.
this.nesting_depth -= 1;
if (this.nesting_depth > 0) return;
vm.event_loop_handle = this.original_event_loop_handle;
vm.event_loop = prev_event_loop;

View File

@@ -509,7 +509,7 @@ pub const StatWatcher = struct {
defer bun.path_buffer_pool.put(buf);
var slice = args.path.slice();
if (bun.strings.startsWith(slice, "file://")) {
slice = slice[6..];
slice = slice["file://".len..];
}
var parts = [_]string{slice};

View File

@@ -632,7 +632,7 @@ pub const FSWatcher = struct {
const file_path: [:0]const u8 = brk: {
var slice = args.path.slice();
if (bun.strings.startsWith(slice, "file://")) {
slice = slice[6..];
slice = slice["file://".len..];
}
const cwd = bun.fs.FileSystem.instance.top_level_dir;