Compare commits

...

3 Commits

Author SHA1 Message Date
Claude Bot
0ec0aa71e0 fix(spawnSync): prevent microtask drain and wakeup cross-contamination during spawnSync
tickQueueWithCount unconditionally calls drainMicrotasksWithGlobal after
every task. The SpawnSyncEventLoop's isolated EventLoop shares the same
JSC VM/GlobalObject, so draining microtasks on it drains the global
microtask queue — executing user JavaScript during spawnSync.

Additionally, EventLoop.wakeup() used vm.event_loop_handle which gets
swapped by SpawnSyncEventLoop.prepare(). This caused wakeups for the
main event loop to target the isolated uws loop instead.

Two fixes:

1. Add suppress_microtask_drain flag and tickTasksOnly() method that
   processes the task queue without draining microtasks. SpawnSyncEventLoop
   now uses tickTasksOnly() instead of tickWithoutJS() (which, despite
   its name, does drain microtasks via tickQueueWithCount).

2. Cache each EventLoop's own uws loop handle (cached_loop) so wakeup()
   always targets the correct loop, even when vm.event_loop_handle is
   temporarily swapped by spawnSync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-11 07:27:20 +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
7 changed files with 145 additions and 5 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

@@ -171,6 +171,10 @@ debug_thread_id: if (Environment.allow_assert) std.Thread.Id else void,
body_value_hive_allocator: webcore.Body.Value.HiveAllocator = undefined,
is_inside_deferred_task_queue: bool = false,
/// When true, drainMicrotasksWithGlobal is suppressed. Used by SpawnSyncEventLoop
/// to prevent the isolated event loop from draining the shared JSC microtask queue
/// (which would execute user JavaScript during spawnSync).
suppress_microtask_drain: bool = false,
// defaults off. .on("message") will set it to true unless overridden
// process.channel.unref() will set it to false and mark it overridden

View File

@@ -20,6 +20,11 @@ virtual_machine: *VirtualMachine = undefined,
waker: ?Waker = null,
forever_timer: ?*uws.Timer = null,
deferred_tasks: DeferredTaskQueue = .{},
/// On POSIX the canonical handle lives in `vm.event_loop_handle`, but that
/// pointer is temporarily swapped by SpawnSyncEventLoop. Each EventLoop
/// caches its *own* handle here so `wakeup()` always targets the correct
/// uws loop, even while spawnSync is active.
cached_loop: ?*uws.Loop = null,
uws_loop: if (Environment.isWindows) ?*uws.Loop else void = if (Environment.isWindows) null,
debug: Debug = .{},
@@ -125,6 +130,10 @@ const DrainMicrotasksResult = enum(u8) {
};
extern fn JSC__JSGlobalObject__drainMicrotasks(*jsc.JSGlobalObject) DrainMicrotasksResult;
pub fn drainMicrotasksWithGlobal(this: *EventLoop, globalObject: *jsc.JSGlobalObject, jsc_vm: *jsc.VM) bun.JSTerminated!void {
// During spawnSync, the isolated event loop shares the same VM/GlobalObject.
// Draining microtasks would execute user JavaScript, which must not happen.
if (this.virtual_machine.suppress_microtask_drain) return;
jsc.markBinding(@src());
jsc_vm.releaseWeakRefs();
@@ -525,6 +534,27 @@ pub fn tickWithoutJS(this: *EventLoop) void {
}
}
/// Tick the task queue without draining microtasks afterward.
/// Used by SpawnSyncEventLoop to process I/O completion tasks (pipe read/write,
/// process exit) without running user JavaScript via the global microtask queue.
///
/// `tickWithoutJS` is a misnomer — `tickQueueWithCount` unconditionally calls
/// `drainMicrotasksWithGlobal` after every task (Task.zig:528), which drains the
/// shared JSC microtask queue and can execute arbitrary user JS. This variant
/// sets a flag to suppress that drain.
pub fn tickTasksOnly(this: *EventLoop) void {
this.tickConcurrent();
const vm = this.virtual_machine;
const prev = vm.suppress_microtask_drain;
vm.suppress_microtask_drain = true;
defer vm.suppress_microtask_drain = prev;
while (this.tickWithCount(vm) > 0) {
this.tickConcurrent();
}
}
pub fn waitForPromise(this: *EventLoop, promise: jsc.AnyPromise) void {
const jsc_vm = this.virtual_machine.jsc_vm;
switch (promise.status()) {
@@ -584,6 +614,14 @@ pub fn ensureWaker(this: *EventLoop) void {
// _ = actual.addPostHandler(*jsc.EventLoop, this, jsc.EventLoop.afterUSocketsTick);
// _ = actual.addPreHandler(*jsc.VM, this.virtual_machine.jsc_vm, jsc.VM.drainMicrotasks);
}
// Cache this EventLoop's own loop handle so wakeup() works correctly
// even when vm.event_loop_handle is temporarily swapped (e.g. spawnSync).
if (this.cached_loop == null) {
this.cached_loop = if (comptime Environment.isWindows)
this.uws_loop
else
this.virtual_machine.event_loop_handle;
}
if (comptime Environment.isWindows) {
if (this.uws_loop == null) {
this.uws_loop = bun.uws.Loop.get();
@@ -598,6 +636,13 @@ pub fn performGC(this: *EventLoop) void {
}
pub fn wakeup(this: *EventLoop) void {
// Use cached_loop (per-EventLoop) rather than vm.event_loop_handle
// (per-VM, may be temporarily swapped by SpawnSyncEventLoop).
// This ensures wakeups always target the correct uws loop.
if (this.cached_loop) |loop| {
loop.wakeup();
return;
}
if (comptime Environment.isWindows) {
if (this.uws_loop) |loop| {
loop.wakeup();

View File

@@ -63,6 +63,7 @@ pub fn init(self: *SpawnSyncEventLoop, vm: *jsc.VirtualMachine) void {
.tasks = jsc.EventLoop.Queue.init(bun.default_allocator),
.global = vm.global,
.virtual_machine = vm,
.cached_loop = loop,
.uws_loop = if (bun.Environment.isWindows) self.uws_loop else {},
};
@@ -81,7 +82,7 @@ pub fn deinit(this: *SpawnSyncEventLoop) void {
timer.stop();
timer.unref();
this.uv_timer = null;
libuv.uv_close(@alignCast(@ptrCast(timer)), @ptrCast(&onCloseUVTimer));
libuv.uv_close(@ptrCast(@alignCast(timer)), @ptrCast(&onCloseUVTimer));
}
}
@@ -163,7 +164,7 @@ pub fn tickWithTimeout(this: *SpawnSyncEventLoop, timeout: ?*const bun.timespec)
}
}
this.event_loop.tickWithoutJS();
this.event_loop.tickTasksOnly();
const did_timeout = this.did_timeout;
this.did_timeout = false;

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;

View File

@@ -0,0 +1,90 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// Regression test: microtasks must not drain during Bun.spawnSync.
//
// Root cause: tickQueueWithCount (Task.zig) unconditionally calls
// drainMicrotasksWithGlobal after every task. The SpawnSyncEventLoop's
// isolated EventLoop shares the same JSC VM/GlobalObject, so draining
// microtasks on it would drain the global microtask queue, executing
// user JavaScript during spawnSync. The fix uses tickTasksOnly() which
// suppresses microtask draining via suppress_microtask_drain.
//
// The waiter thread (Linux-only, forced via BUN_FEATURE_FLAG_FORCE_WAITER_THREAD)
// uses a shared completion queue. When the async child exits during the busy-wait,
// its ResultTask gets enqueued. Then during spawnSync's isolated event loop tick,
// the microtask drain would run the queueMicrotask callback.
test("microtasks do not drain inside spawnSync with waiter thread", async () => {
using dir = tempDir("spawnsync-microtask", {
"repro.js": `
const cp = require('node:child_process')
let inSync = false
let hit = null
const p = cp.spawn('/bin/sh', ['-c', 'echo x'], {
stdio: ['ignore', 'pipe', 'ignore'],
})
queueMicrotask(() => {
if (inSync) hit = new Error('microtask drained inside spawnSync').stack
})
// Busy-block main loop so Waitpid thread reaps \`p\` before we enter spawnSync
const end = performance.now() + 150
while (performance.now() < end) {
/* busy */
}
inSync = true
Bun.spawnSync({ cmd: ['/bin/sh', '-c', 'sleep 0.01'], maxBuffer: 1048576 })
inSync = false
await new Promise(r => p.on('close', r))
if (hit) {
console.error(hit)
process.exit(1)
}
console.log('OK: microtask did not drain inside spawnSync')
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "repro.js"],
cwd: String(dir),
env: {
...bunEnv,
BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1",
},
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("OK: microtask did not drain inside spawnSync");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
test("spawnSync still works correctly with maxBuffer", () => {
const result = Bun.spawnSync({
cmd: ["/bin/sh", "-c", "echo hello"],
maxBuffer: 1048576,
});
expect(result.stdout.toString().trim()).toBe("hello");
expect(result.exitCode).toBe(0);
});
test("spawnSync with timeout still works", () => {
const result = Bun.spawnSync({
cmd: ["/bin/sh", "-c", "sleep 10"],
timeout: 100,
});
expect(result.exitCode).toBeNull();
expect(result.signalCode).toBe("SIGTERM");
});