diff --git a/CLAUDE.md b/CLAUDE.md index 986bff8ae9..526996c187 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,16 +38,36 @@ If no valid issue number is provided, find the best existing file to modify inst ### Writing Tests -Tests use Bun's Jest-compatible test runner with proper test fixtures: +Tests use Bun's Jest-compatible test runner with proper test fixtures. + +- For **single-file tests**, prefer `-e` over `tempDir`. +- For **multi-file tests**, prefer `tempDir` and `Bun.spawn`. ```typescript import { test, expect } from "bun:test"; import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; -test("my feature", async () => { +test("(single-file test) my feature", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "console.log('Hello, world!')"], + env: bunEnv, + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + + expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`"Hello, world!"`); + expect(exitCode).toBe(0); +}); + +test("(multi-file test) my feature", async () => { // Create temp directory with test files using dir = tempDir("test-prefix", { - "index.js": `console.log("hello");`, + "index.js": `import { foo } from "./foo.ts"; foo();`, + "foo.ts": `export function foo() { console.log("foo"); }`, }); // Spawn Bun process diff --git a/src/bun.js/api/bun/js_bun_spawn_bindings.zig b/src/bun.js/api/bun/js_bun_spawn_bindings.zig index d3e99986e1..38dba791f5 100644 --- a/src/bun.js/api/bun/js_bun_spawn_bindings.zig +++ b/src/bun.js/api/bun/js_bun_spawn_bindings.zig @@ -466,6 +466,25 @@ pub fn spawnMaybeSync( !jsc_vm.isInspectorEnabled() and !bun.feature_flag.BUN_FEATURE_FLAG_DISABLE_SPAWNSYNC_FAST_PATH.get(); + // For spawnSync, use an isolated event loop to prevent JavaScript timers from firing + // and to avoid interfering with the main event loop + const event_loop: *jsc.EventLoop = if (comptime is_sync) + &jsc_vm.rareData().spawnSyncEventLoop(jsc_vm).event_loop + else + jsc_vm.eventLoop(); + + 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()); + } + } + + const loop_handle = jsc.EventLoopHandle.init(event_loop); + const spawn_options = bun.spawn.SpawnOptions{ .cwd = cwd, .detached = detached, @@ -488,7 +507,7 @@ pub fn spawnMaybeSync( .windows = if (Environment.isWindows) .{ .hide_window = windows_hide, .verbatim_arguments = windows_verbatim_arguments, - .loop = jsc.EventLoopHandle.init(jsc_vm), + .loop = loop_handle, }, }; @@ -534,9 +553,8 @@ pub fn spawnMaybeSync( .result => |result| result, }; - const loop = jsc_vm.eventLoop(); - - const process = spawned.toProcess(loop, is_sync); + // Use the isolated loop for spawnSync operations + const process = spawned.toProcess(loop_handle, is_sync); var subprocess = bun.new(Subprocess, .{ .ref_count = .init(), @@ -571,7 +589,7 @@ pub fn spawnMaybeSync( .pid_rusage = null, .stdin = Writable.init( &stdio[0], - loop, + event_loop, subprocess, spawned.stdin, &promise_for_stream, @@ -581,7 +599,7 @@ pub fn spawnMaybeSync( }, .stdout = Readable.init( stdio[1], - loop, + event_loop, subprocess, spawned.stdout, jsc_vm.allocator, @@ -590,7 +608,7 @@ pub fn spawnMaybeSync( ), .stderr = Readable.init( stdio[2], - loop, + event_loop, subprocess, spawned.stderr, jsc_vm.allocator, @@ -688,14 +706,15 @@ pub fn spawnMaybeSync( var send_exit_notification = false; - // This must go before other things happen so that the exit handler is registered before onProcessExit can potentially be called. - if (timeout) |timeout_val| { - subprocess.event_loop_timer.next = bun.timespec.msFromNow(timeout_val); - globalThis.bunVM().timer.insert(&subprocess.event_loop_timer); - subprocess.setEventLoopTimerRefd(true); - } - if (comptime !is_sync) { + // This must go before other things happen so that the exit handler is + // registered before onProcessExit can potentially be called. + if (timeout) |timeout_val| { + subprocess.event_loop_timer.next = bun.timespec.msFromNow(timeout_val); + globalThis.bunVM().timer.insert(&subprocess.event_loop_timer); + subprocess.setEventLoopTimerRefd(true); + } + bun.debugAssert(out != .zero); if (on_exit_callback.isCell()) { @@ -743,7 +762,7 @@ pub fn spawnMaybeSync( } if (subprocess.stdout == .pipe) { - if (subprocess.stdout.pipe.start(subprocess, loop).asErr()) |err| { + if (subprocess.stdout.pipe.start(subprocess, event_loop).asErr()) |err| { _ = subprocess.tryKill(subprocess.killSignal); _ = globalThis.throwValue(err.toJS(globalThis)) catch {}; return error.JSError; @@ -754,7 +773,7 @@ pub fn spawnMaybeSync( } if (subprocess.stderr == .pipe) { - if (subprocess.stderr.pipe.start(subprocess, loop).asErr()) |err| { + if (subprocess.stderr.pipe.start(subprocess, event_loop).asErr()) |err| { _ = subprocess.tryKill(subprocess.killSignal); _ = globalThis.throwValue(err.toJS(globalThis)) catch {}; return error.JSError; @@ -767,15 +786,16 @@ pub fn spawnMaybeSync( should_close_memfd = false; + // Once everything is set up, we can add the abort listener + // Adding the abort listener may call the onAbortSignal callback immediately if it was already aborted + // Therefore, we must do this at the very end. + if (abort_signal) |signal| { + signal.pendingActivityRef(); + subprocess.abort_signal = signal.addListener(subprocess, Subprocess.onAbortSignal); + abort_signal = null; + } + if (comptime !is_sync) { - // Once everything is set up, we can add the abort listener - // Adding the abort listener may call the onAbortSignal callback immediately if it was already aborted - // Therefore, we must do this at the very end. - if (abort_signal) |signal| { - signal.pendingActivityRef(); - subprocess.abort_signal = signal.addListener(subprocess, Subprocess.onAbortSignal); - abort_signal = null; - } if (!subprocess.process.hasExited()) { jsc_vm.onSubprocessSpawn(subprocess.process); } @@ -813,14 +833,50 @@ pub fn spawnMaybeSync( jsc_vm.onSubprocessSpawn(subprocess.process); } - // We cannot release heap access while JS is running + var did_timeout = false; + + // Use the isolated event loop to tick instead of the main event loop + // This ensures JavaScript timers don't fire and stdin/stdout from the main process aren't affected { - const old_vm = jsc_vm.uwsLoop().internal_loop_data.jsc_vm; - jsc_vm.uwsLoop().internal_loop_data.jsc_vm = null; - defer { - jsc_vm.uwsLoop().internal_loop_data.jsc_vm = old_vm; + var absolute_timespec = bun.timespec.epoch; + var now = bun.timespec.now(); + var user_timespec: bun.timespec = if (timeout) |timeout_ms| now.addMs(timeout_ms) else absolute_timespec; + + // Support `AbortSignal.timeout`, but it's best-effort. + // Specifying both `timeout: number` and `AbortSignal.timeout` chooses the soonest one. + // This does mean if an AbortSignal times out it will throw + if (subprocess.abort_signal) |signal| { + if (signal.getTimeout()) |abort_signal_timeout| { + if (abort_signal_timeout.event_loop_timer.state == .ACTIVE) { + if (user_timespec.eql(&.epoch) or abort_signal_timeout.event_loop_timer.next.order(&user_timespec) == .lt) { + user_timespec = abort_signal_timeout.event_loop_timer.next; + } + } + } } + + const has_user_timespec = !user_timespec.eql(&.epoch); + + const sync_loop = jsc_vm.rareData().spawnSyncEventLoop(jsc_vm); + while (subprocess.computeHasPendingActivity()) { + // Re-evaluate this at each iteration of the loop since it may change between iterations. + const bun_test_timeout: bun.timespec = if (bun.jsc.Jest.Jest.runner) |runner| runner.getActiveTimeout() else .epoch; + const has_bun_test_timeout = !bun_test_timeout.eql(&.epoch); + + if (has_bun_test_timeout) { + switch (bun_test_timeout.orderIgnoreEpoch(user_timespec)) { + .lt => absolute_timespec = bun_test_timeout, + .eq => {}, + .gt => absolute_timespec = user_timespec, + } + } else if (has_user_timespec) { + absolute_timespec = user_timespec; + } else { + absolute_timespec = .epoch; + } + const has_timespec = !absolute_timespec.eql(&.epoch); + if (subprocess.stdin == .buffer) { subprocess.stdin.buffer.watch(); } @@ -833,10 +889,52 @@ pub fn spawnMaybeSync( subprocess.stdout.pipe.watch(); } - jsc_vm.tick(); - jsc_vm.eventLoop().autoTick(); + // Tick the isolated event loop without passing timeout to avoid blocking + // The timeout check is done at the top of the loop + switch (sync_loop.tickWithTimeout(if (has_timespec and !did_timeout) &absolute_timespec else null)) { + .completed => { + now = bun.timespec.now(); + }, + .timeout => { + now = bun.timespec.now(); + const did_user_timeout = has_user_timespec and (absolute_timespec.eql(&user_timespec) or user_timespec.order(&now) == .lt); + + if (did_user_timeout) { + did_timeout = true; + _ = subprocess.tryKill(subprocess.killSignal); + } + + // Support bun:test timeouts AND spawnSync() timeout. + // There is a scenario where inside of spawnSync() a totally + // different test fails, and that SHOULD be okay. + if (has_bun_test_timeout) { + if (bun_test_timeout.order(&now) == .lt) { + var active_file_strong = bun.jsc.Jest.Jest.runner.?.bun_test_root.active_file + // TODO: add a .cloneNonOptional()? + .clone(); + + defer active_file_strong.deinit(); + var taken_active_file = active_file_strong.take().?; + defer taken_active_file.deinit(); + + bun.jsc.Jest.Jest.runner.?.removeActiveTimeout(jsc_vm); + + // This might internally call `std.c.kill` on this + // spawnSync process. Even if we do that, we still + // need to reap the process. So we may go through + // the event loop again, but it should wake up + // ~instantly so we can drain the events. + jsc.Jest.bun_test.BunTest.bunTestTimeoutCallback(taken_active_file, &absolute_timespec, jsc_vm); + } + } + }, + } } } + if (globalThis.hasException()) { + // e.g. a termination exception. + return .zero; + } subprocess.updateHasPendingActivity(); @@ -845,16 +943,11 @@ pub fn spawnMaybeSync( const stdout = try subprocess.stdout.toBufferedValue(globalThis); const stderr = try subprocess.stderr.toBufferedValue(globalThis); const resource_usage: JSValue = if (!globalThis.hasException()) try subprocess.createResourceUsageObject(globalThis) else .zero; - const exitedDueToTimeout = subprocess.event_loop_timer.state == .FIRED; + const exitedDueToTimeout = did_timeout; const exitedDueToMaxBuffer = subprocess.exited_due_to_maxbuf; const resultPid = jsc.JSValue.jsNumberFromInt32(subprocess.pid()); subprocess.finalize(); - if (globalThis.hasException()) { - // e.g. a termination exception. - return .zero; - } - const sync_value = jsc.JSValue.createEmptyObject(globalThis, 5 + @as(usize, @intFromBool(!signalCode.isEmptyOrUndefinedOrNull()))); sync_value.put(globalThis, jsc.ZigString.static("exitCode"), exitCode); if (!signalCode.isEmptyOrUndefinedOrNull()) { diff --git a/src/bun.js/api/bun/subprocess/StaticPipeWriter.zig b/src/bun.js/api/bun/subprocess/StaticPipeWriter.zig index 4679de7699..ddc8fc0c60 100644 --- a/src/bun.js/api/bun/subprocess/StaticPipeWriter.zig +++ b/src/bun.js/api/bun/subprocess/StaticPipeWriter.zig @@ -110,8 +110,12 @@ pub fn NewStaticPipeWriter(comptime ProcessType: type) type { return @sizeOf(@This()) + this.source.memoryCost() + this.writer.memoryCost(); } - pub fn loop(this: *This) *uws.Loop { - return this.event_loop.loop(); + pub fn loop(this: *This) *bun.Async.Loop { + if (comptime bun.Environment.isWindows) { + return this.event_loop.loop().uv_loop; + } else { + return this.event_loop.loop(); + } } pub fn watch(this: *This) void { @@ -132,7 +136,6 @@ const bun = @import("bun"); const Environment = bun.Environment; const Output = bun.Output; const jsc = bun.jsc; -const uws = bun.uws; const Subprocess = jsc.API.Subprocess; const Source = Subprocess.Source; diff --git a/src/bun.js/api/bun/subprocess/SubprocessPipeReader.zig b/src/bun.js/api/bun/subprocess/SubprocessPipeReader.zig index 2dde5e875b..aa4ec42aac 100644 --- a/src/bun.js/api/bun/subprocess/SubprocessPipeReader.zig +++ b/src/bun.js/api/bun/subprocess/SubprocessPipeReader.zig @@ -189,8 +189,12 @@ pub fn eventLoop(this: *PipeReader) *jsc.EventLoop { return this.event_loop; } -pub fn loop(this: *PipeReader) *uws.Loop { - return this.event_loop.virtual_machine.uwsLoop(); +pub fn loop(this: *PipeReader) *bun.Async.Loop { + if (comptime bun.Environment.isWindows) { + return this.event_loop.virtual_machine.uwsLoop().uv_loop; + } else { + return this.event_loop.virtual_machine.uwsLoop(); + } } fn deinit(this: *PipeReader) void { @@ -213,7 +217,6 @@ fn deinit(this: *PipeReader) void { const bun = @import("bun"); const Environment = bun.Environment; const default_allocator = bun.default_allocator; -const uws = bun.uws; const jsc = bun.jsc; const JSGlobalObject = jsc.JSGlobalObject; diff --git a/src/bun.js/api/server/FileRoute.zig b/src/bun.js/api/server/FileRoute.zig index ca581ba6c0..48f86bd559 100644 --- a/src/bun.js/api/server/FileRoute.zig +++ b/src/bun.js/api/server/FileRoute.zig @@ -472,7 +472,11 @@ const StreamTransfer = struct { } pub fn loop(this: *StreamTransfer) *Async.Loop { - return this.eventLoop().loop(); + if (comptime bun.Environment.isWindows) { + return this.eventLoop().loop().uv_loop; + } else { + return this.eventLoop().loop(); + } } fn onWritable(this: *StreamTransfer, _: u64, _: AnyResponse) bool { diff --git a/src/bun.js/bindings/AbortSignal.zig b/src/bun.js/bindings/AbortSignal.zig index f1e0082ef5..4d3444c07a 100644 --- a/src/bun.js/bindings/AbortSignal.zig +++ b/src/bun.js/bindings/AbortSignal.zig @@ -8,7 +8,7 @@ pub const AbortSignal = opaque { extern fn WebCore__AbortSignal__ref(arg0: *AbortSignal) *AbortSignal; extern fn WebCore__AbortSignal__toJS(arg0: *AbortSignal, arg1: *JSGlobalObject) JSValue; extern fn WebCore__AbortSignal__unref(arg0: *AbortSignal) void; - + extern fn WebCore__AbortSignal__getTimeout(arg0: *AbortSignal) ?*Timeout; pub fn listen( this: *AbortSignal, comptime Context: type, @@ -138,6 +138,19 @@ pub const AbortSignal = opaque { return WebCore__AbortSignal__new(global); } + /// Returns a borrowed handle to the internal Timeout, or null. + /// + /// Lifetime: owned by AbortSignal; may become invalid if the timer fires/cancels. + /// + /// Thread-safety: not thread-safe; call only on the owning thread/loop. + /// + /// Usage: if you need to operate on the Timeout (run/cancel/deinit), hold a ref + /// to `this` for the duration (e.g., `this.ref(); defer this.unref();`) and avoid + /// caching the pointer across turns. + pub fn getTimeout(this: *AbortSignal) ?*Timeout { + return WebCore__AbortSignal__getTimeout(this); + } + pub const Timeout = struct { event_loop_timer: jsc.API.Timer.EventLoopTimer, diff --git a/src/bun.js/bindings/bindings.cpp b/src/bun.js/bindings/bindings.cpp index a0a514865d..b0c60b98c6 100644 --- a/src/bun.js/bindings/bindings.cpp +++ b/src/bun.js/bindings/bindings.cpp @@ -5474,6 +5474,15 @@ extern "C" JSC::EncodedJSValue WebCore__AbortSignal__abortReason(WebCore::AbortS return JSC::JSValue::encode(abortSignal->reason().getValue(jsNull())); } +extern "C" WebCore::AbortSignalTimeout WebCore__AbortSignal__getTimeout(WebCore::AbortSignal* arg0) +{ + WebCore::AbortSignal* abortSignal = reinterpret_cast(arg0); + if (!abortSignal->hasActiveTimeoutTimer()) { + return nullptr; + } + return abortSignal->getTimeout(); +} + extern "C" WebCore::AbortSignal* WebCore__AbortSignal__ref(WebCore::AbortSignal* abortSignal) { abortSignal->ref(); diff --git a/src/bun.js/bindings/webcore/AbortSignal.h b/src/bun.js/bindings/webcore/AbortSignal.h index 436a8e5b2f..68ded5d832 100644 --- a/src/bun.js/bindings/webcore/AbortSignal.h +++ b/src/bun.js/bindings/webcore/AbortSignal.h @@ -124,6 +124,8 @@ public: size_t memoryCost() const; + AbortSignalTimeout getTimeout() const { return m_timeout; } + private: enum class Aborted : bool { No, diff --git a/src/bun.js/event_loop.zig b/src/bun.js/event_loop.zig index bd963d0282..d8de605e34 100644 --- a/src/bun.js/event_loop.zig +++ b/src/bun.js/event_loop.zig @@ -512,6 +512,15 @@ pub fn tick(this: *EventLoop) void { this.global.handleRejectedPromises(); } +pub fn tickWithoutJS(this: *EventLoop) void { + const ctx = this.virtual_machine; + this.tickConcurrent(); + + while (this.tickWithCount(ctx) > 0) { + this.tickConcurrent(); + } +} + pub fn waitForPromise(this: *EventLoop, promise: jsc.AnyPromise) void { const jsc_vm = this.virtual_machine.jsc_vm; switch (promise.status(jsc_vm)) { @@ -652,6 +661,12 @@ pub fn getActiveTasks(globalObject: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun. return result; } +pub fn deinit(this: *EventLoop) void { + this.tasks.deinit(); + this.immediate_tasks.clearAndFree(bun.default_allocator); + this.next_immediate_tasks.clearAndFree(bun.default_allocator); +} + pub const AnyEventLoop = @import("./event_loop/AnyEventLoop.zig").AnyEventLoop; pub const ConcurrentPromiseTask = @import("./event_loop/ConcurrentPromiseTask.zig").ConcurrentPromiseTask; pub const WorkTask = @import("./event_loop/WorkTask.zig").WorkTask; diff --git a/src/bun.js/event_loop/SpawnSyncEventLoop.zig b/src/bun.js/event_loop/SpawnSyncEventLoop.zig new file mode 100644 index 0000000000..deeaea42eb --- /dev/null +++ b/src/bun.js/event_loop/SpawnSyncEventLoop.zig @@ -0,0 +1,188 @@ +//! Isolated event loop for spawnSync operations. +//! +//! This provides a completely separate event loop instance to ensure that: +//! - JavaScript timers don't fire during spawnSync +//! - stdin/stdout from the main process aren't affected +//! - The subprocess runs in complete isolation +//! - We don't recursively run the main event loop +//! +//! Implementation approach: +//! - Creates a separate uws.Loop instance with its own kqueue/epoll fd (POSIX) or libuv loop (Windows) +//! - Wraps it in a full jsc.EventLoop instance +//! - On POSIX: temporarily overrides vm.event_loop_handle to point to isolated loop +//! - On Windows: stores isolated loop pointer in EventLoop.uws_loop +//! - Minimal handler callbacks (wakeup/pre/post are no-ops) +//! +//! Similar to Node.js's approach in vendor/node/src/spawn_sync.cc but adapted for Bun's architecture. + +const SpawnSyncEventLoop = @This(); + +/// Separate JSC EventLoop instance for this spawnSync +/// This is a FULL event loop, not just a handle +event_loop: jsc.EventLoop, + +/// Completely separate uws.Loop instance - critical for avoiding recursive event loop execution +uws_loop: *uws.Loop, + +/// On POSIX, we need to temporarily override the VM's event_loop_handle +/// Store the original so we can restore it +original_event_loop_handle: @FieldType(jsc.VirtualMachine, "event_loop_handle") = undefined, + +uv_timer: if (bun.Environment.isWindows) ?*bun.windows.libuv.Timer else void = if (bun.Environment.isWindows) null else {}, +did_timeout: bool = false, + +/// Minimal handler for the isolated loop +const Handler = struct { + pub fn wakeup(loop: *uws.Loop) callconv(.C) void { + _ = loop; + // No-op: we don't need to wake up from another thread for spawnSync + } + + pub fn pre(loop: *uws.Loop) callconv(.C) void { + _ = loop; + // No-op: no pre-tick work needed for spawnSync + } + + pub fn post(loop: *uws.Loop) callconv(.C) void { + _ = loop; + // No-op: no post-tick work needed for spawnSync + } +}; + +pub fn init(self: *SpawnSyncEventLoop, vm: *jsc.VirtualMachine) void { + const loop = uws.Loop.create(Handler); + + self.* = .{ + .event_loop = undefined, + .uws_loop = loop, + }; + + // Initialize the JSC EventLoop with empty state + // CRITICAL: On Windows, store our isolated loop pointer + self.event_loop = .{ + .tasks = jsc.EventLoop.Queue.init(bun.default_allocator), + .global = vm.global, + .virtual_machine = vm, + .uws_loop = if (bun.Environment.isWindows) self.uws_loop else {}, + }; + + // Set up the loop's internal data to point to this isolated event loop + self.uws_loop.internal_loop_data.setParentEventLoop(jsc.EventLoopHandle.init(&self.event_loop)); + self.uws_loop.internal_loop_data.jsc_vm = null; +} + +fn onCloseUVTimer(timer: *bun.windows.libuv.Timer) callconv(.C) void { + bun.default_allocator.destroy(timer); +} + +pub fn deinit(this: *SpawnSyncEventLoop) void { + if (comptime bun.Environment.isWindows) { + if (this.uv_timer) |timer| { + timer.stop(); + timer.unref(); + this.uv_timer = null; + libuv.uv_close(@alignCast(@ptrCast(timer)), @ptrCast(&onCloseUVTimer)); + } + } + + this.event_loop.deinit(); + this.uws_loop.deinit(); +} + +/// Configure the event loop for a specific VM context +pub fn prepare(this: *SpawnSyncEventLoop, vm: *jsc.VirtualMachine) void { + this.event_loop.global = vm.global; + this.did_timeout = false; + this.event_loop.virtual_machine = vm; + + 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 { + vm.event_loop_handle = this.original_event_loop_handle; + vm.event_loop = prev_event_loop; + + if (bun.Environment.isWindows) { + if (this.uv_timer) |timer| { + timer.stop(); + timer.unref(); + } + } +} + +/// Get an EventLoopHandle for this isolated loop +pub fn handle(this: *SpawnSyncEventLoop) jsc.EventLoopHandle { + return jsc.EventLoopHandle.init(&this.event_loop); +} + +fn onUVTimer(timer_: *bun.windows.libuv.Timer) callconv(.C) void { + const this: *SpawnSyncEventLoop = @ptrCast(@alignCast(timer_.data)); + this.did_timeout = true; + this.uws_loop.uv_loop.stop(); +} + +const TickState = enum { timeout, completed }; + +fn prepareTimerOnWindows(this: *SpawnSyncEventLoop, ts: *const bun.timespec) void { + const timer: *bun.windows.libuv.Timer = this.uv_timer orelse brk: { + const uv_timer: *bun.windows.libuv.Timer = bun.default_allocator.create(bun.windows.libuv.Timer) catch |e| bun.handleOom(e); + uv_timer.* = std.mem.zeroes(bun.windows.libuv.Timer); + uv_timer.init(this.uws_loop.uv_loop); + break :brk uv_timer; + }; + + timer.start(ts.msUnsigned(), 0, &onUVTimer); + timer.ref(); + this.uv_timer = timer; + timer.data = this; +} + +/// Tick the isolated event loop with an optional timeout +/// This is similar to the main event loop's tick but completely isolated +pub fn tickWithTimeout(this: *SpawnSyncEventLoop, timeout: ?*const bun.timespec) TickState { + const duration: ?*const bun.timespec = if (timeout) |ts| &ts.duration(&.now()) else null; + if (bun.Environment.isWindows) { + if (duration) |ts| { + prepareTimerOnWindows(this, ts); + } + } + + // Tick the isolated uws loop with the specified timeout + // This will only process I/O related to this subprocess + // and will NOT interfere with the main event loop + this.uws_loop.tickWithTimeout(duration); + + if (timeout) |ts| { + if (bun.Environment.isWindows) { + this.uv_timer.?.unref(); + this.uv_timer.?.stop(); + } else { + this.did_timeout = bun.timespec.now().order(ts) != .lt; + } + } + + this.event_loop.tickWithoutJS(); + + const did_timeout = this.did_timeout; + this.did_timeout = false; + + if (did_timeout) { + return .timeout; + } + + return .completed; +} + +/// Check if the loop has any active handles +pub fn isActive(this: *const SpawnSyncEventLoop) bool { + return this.uws_loop.isActive(); +} + +const std = @import("std"); + +const bun = @import("bun"); +const jsc = bun.jsc; +const uws = bun.uws; +const libuv = bun.windows.libuv; diff --git a/src/bun.js/rare_data.zig b/src/bun.js/rare_data.zig index 7040aa7f4f..bc10ca0809 100644 --- a/src/bun.js/rare_data.zig +++ b/src/bun.js/rare_data.zig @@ -42,6 +42,8 @@ valkey_context: ValkeyContext = .{}, tls_default_ciphers: ?[:0]const u8 = null, +#spawn_sync_event_loop: bun.ptr.Owned(?*SpawnSyncEventLoop) = .initNull(), + const PipeReadBuffer = [256 * 1024]u8; const DIGESTED_HMAC_256_LEN = 32; pub const AWSSignatureCache = struct { @@ -537,6 +539,7 @@ pub fn deinit(this: *RareData) void { bun.default_allocator.destroy(pipe); } + this.#spawn_sync_event_loop.deinit(); this.aws_signature_cache.deinit(); this.s3_default_client.deinit(); @@ -569,6 +572,17 @@ pub fn websocketDeflate(this: *RareData) *WebSocketDeflate.RareData { }; } +pub const SpawnSyncEventLoop = @import("./event_loop/SpawnSyncEventLoop.zig"); + +pub fn spawnSyncEventLoop(this: *RareData, vm: *jsc.VirtualMachine) *SpawnSyncEventLoop { + return this.#spawn_sync_event_loop.get() orelse brk: { + this.#spawn_sync_event_loop = .new(undefined); + const ptr: *SpawnSyncEventLoop = this.#spawn_sync_event_loop.get().?; + ptr.init(vm); + break :brk ptr; + }; +} + const IPC = @import("./ipc.zig"); const UUID = @import("./uuid.zig"); const WebSocketDeflate = @import("../http/websocket_client/WebSocketDeflate.zig"); diff --git a/src/bun.js/test/jest.zig b/src/bun.js/test/jest.zig index 6ceb285a08..cc9bec138c 100644 --- a/src/bun.js/test/jest.zig +++ b/src/bun.js/test/jest.zig @@ -97,6 +97,22 @@ pub const TestRunner = struct { bun_test_root: bun_test.BunTestRoot, + pub fn getActiveTimeout(this: *const TestRunner) bun.timespec { + const active_file = this.bun_test_root.active_file.get() orelse return .epoch; + if (active_file.timer.state != .ACTIVE or active_file.timer.next.eql(&.epoch)) { + return .epoch; + } + return active_file.timer.next; + } + + pub fn removeActiveTimeout(this: *TestRunner, vm: *jsc.VirtualMachine) void { + const active_file = this.bun_test_root.active_file.get() orelse return; + if (active_file.timer.state != .ACTIVE or active_file.timer.next.eql(&.epoch)) { + return; + } + vm.timer.remove(&active_file.timer); + } + pub const Summary = struct { pass: u32 = 0, expectations: u32 = 0, diff --git a/src/bun.js/webcore/FileReader.zig b/src/bun.js/webcore/FileReader.zig index b4398604c0..95a5cdfcef 100644 --- a/src/bun.js/webcore/FileReader.zig +++ b/src/bun.js/webcore/FileReader.zig @@ -159,7 +159,11 @@ pub fn eventLoop(this: *const FileReader) jsc.EventLoopHandle { } pub fn loop(this: *const FileReader) *bun.Async.Loop { - return this.eventLoop().loop(); + if (comptime bun.Environment.isWindows) { + return this.eventLoop().loop().uv_loop; + } else { + return this.eventLoop().loop(); + } } pub fn setup( diff --git a/src/bun.js/webcore/FileSink.zig b/src/bun.js/webcore/FileSink.zig index 0d51f1c319..2f3e1aa45f 100644 --- a/src/bun.js/webcore/FileSink.zig +++ b/src/bun.js/webcore/FileSink.zig @@ -357,7 +357,11 @@ pub fn setup(this: *FileSink, options: *const FileSink.Options) bun.sys.Maybe(vo } pub fn loop(this: *FileSink) *bun.Async.Loop { - return this.event_loop_handle.loop(); + if (comptime bun.Environment.isWindows) { + return this.event_loop_handle.loop().uv_loop; + } else { + return this.event_loop_handle.loop(); + } } pub fn eventLoop(this: *FileSink) jsc.EventLoopHandle { diff --git a/src/cli/filter_run.zig b/src/cli/filter_run.zig index 57d56a3d47..9ba9fbec17 100644 --- a/src/cli/filter_run.zig +++ b/src/cli/filter_run.zig @@ -127,8 +127,12 @@ pub const ProcessHandle = struct { return this.state.event_loop; } - pub fn loop(this: *This) *bun.uws.Loop { - return this.state.event_loop.loop; + pub fn loop(this: *This) *bun.Async.Loop { + if (comptime bun.Environment.isWindows) { + return this.state.event_loop.loop.uv_loop; + } else { + return this.state.event_loop.loop; + } } }; diff --git a/src/deps/libuv.zig b/src/deps/libuv.zig index 21d65d79dd..08dd7e01e4 100644 --- a/src/deps/libuv.zig +++ b/src/deps/libuv.zig @@ -634,6 +634,11 @@ pub const Loop = extern struct { this.active_handles -= 1; } + pub fn stop(this: *Loop) void { + log("stop", .{}); + uv_stop(this); + } + pub fn isActive(this: *Loop) bool { const loop_alive = uv_loop_alive(this) != 0; // This log may be helpful if you are curious what exact handles are active diff --git a/src/deps/uws/Loop.zig b/src/deps/uws/Loop.zig index 1a93f2c1f7..999b1e95ac 100644 --- a/src/deps/uws/Loop.zig +++ b/src/deps/uws/Loop.zig @@ -165,6 +165,10 @@ pub const PosixLoop = extern struct { pub fn shouldEnableDateHeaderTimer(this: *const PosixLoop) bool { return this.internal_loop_data.shouldEnableDateHeaderTimer(); } + + pub fn deinit(this: *PosixLoop) void { + c.us_loop_free(this); + } }; pub const WindowsLoop = extern struct { @@ -261,6 +265,10 @@ pub const WindowsLoop = extern struct { c.uws_loop_date_header_timer_update(this); } + pub fn deinit(this: *WindowsLoop) void { + c.us_loop_free(this); + } + fn NewHandler(comptime UserType: type, comptime callback_fn: fn (UserType) void) type { return struct { loop: *Loop, diff --git a/src/deps/uws/WindowsNamedPipe.zig b/src/deps/uws/WindowsNamedPipe.zig index 754dd0add1..e7ca08c964 100644 --- a/src/deps/uws/WindowsNamedPipe.zig +++ b/src/deps/uws/WindowsNamedPipe.zig @@ -457,6 +457,10 @@ pub fn isTLS(this: *WindowsNamedPipe) bool { return this.flags.is_ssl; } +pub fn loop(this: *WindowsNamedPipe) *bun.Async.Loop { + return this.vm.uvLoop(); +} + pub fn encodeAndWrite(this: *WindowsNamedPipe, data: []const u8) i32 { log("encodeAndWrite (len: {})", .{data.len}); if (this.wrapper) |*wrapper| { diff --git a/src/install/PackageManager/security_scanner.zig b/src/install/PackageManager/security_scanner.zig index ad1f9e0f7a..79a6b1ba42 100644 --- a/src/install/PackageManager/security_scanner.zig +++ b/src/install/PackageManager/security_scanner.zig @@ -779,8 +779,12 @@ pub const SecurityScanSubprocess = struct { return &this.manager.event_loop; } - pub fn loop(this: *const SecurityScanSubprocess) *bun.uws.Loop { - return this.manager.event_loop.loop(); + pub fn loop(this: *const SecurityScanSubprocess) *bun.Async.Loop { + if (comptime bun.Environment.isWindows) { + return this.manager.event_loop.loop().uv_loop; + } else { + return this.manager.event_loop.loop(); + } } pub fn onReaderDone(this: *SecurityScanSubprocess) void { diff --git a/src/install/lifecycle_script_runner.zig b/src/install/lifecycle_script_runner.zig index 40c316ecbf..9066d3586f 100644 --- a/src/install/lifecycle_script_runner.zig +++ b/src/install/lifecycle_script_runner.zig @@ -47,8 +47,12 @@ pub const LifecycleScriptSubprocess = struct { pub const OutputReader = bun.io.BufferedReader; - pub fn loop(this: *const LifecycleScriptSubprocess) *bun.uws.Loop { - return this.manager.event_loop.loop(); + pub fn loop(this: *const LifecycleScriptSubprocess) *bun.Async.Loop { + if (comptime bun.Environment.isWindows) { + return this.manager.event_loop.loop().uv_loop; + } else { + return this.manager.event_loop.loop(); + } } pub fn eventLoop(this: *const LifecycleScriptSubprocess) *jsc.AnyEventLoop { diff --git a/src/io/PipeReader.zig b/src/io/PipeReader.zig index 7486ab225e..e2b81e54ec 100644 --- a/src/io/PipeReader.zig +++ b/src/io/PipeReader.zig @@ -709,6 +709,7 @@ const WindowsBufferedReaderVTable = struct { chunk: []const u8, hasMore: ReadState, ) bool = null, + loop: *const fn (*anyopaque) *Async.Loop, }; pub const WindowsBufferedReader = struct { @@ -757,12 +758,16 @@ pub const WindowsBufferedReader = struct { fn onReaderError(this: *anyopaque, err: bun.sys.Error) void { return Type.onReaderError(@as(*Type, @alignCast(@ptrCast(this))), err); } + fn loop(this: *anyopaque) *Async.Loop { + return Type.loop(@as(*Type, @alignCast(@ptrCast(this)))); + } }; return .{ .vtable = .{ .onReadChunk = if (@hasDecl(Type, "onReadChunk")) &fns.onReadChunk else null, .onReaderDone = &fns.onReaderDone, .onReaderError = &fns.onReaderError, + .loop = &fns.loop, }, }; } @@ -909,7 +914,10 @@ pub const WindowsBufferedReader = struct { pub fn start(this: *WindowsBufferedReader, fd: bun.FileDescriptor, _: bool) bun.sys.Maybe(void) { bun.assert(this.source == null); - const source = switch (Source.open(uv.Loop.get(), fd)) { + // Use the event loop from the parent, not the global one + // This is critical for spawnSync to use its isolated loop + const loop = this.vtable.loop(this.parent); + const source = switch (Source.open(loop, fd)) { .err => |err| return .{ .err = err }, .result => |source| source, }; @@ -1058,7 +1066,7 @@ pub const WindowsBufferedReader = struct { file_ptr.iov = uv.uv_buf_t.init(buf); this.flags.has_inflight_read = true; - if (uv.uv_fs_read(uv.Loop.get(), &file_ptr.fs, file_ptr.file, @ptrCast(&file_ptr.iov), 1, if (this.flags.use_pread) @intCast(this._offset) else -1, onFileRead).toError(.write)) |err| { + if (uv.uv_fs_read(this.vtable.loop(this.parent), &file_ptr.fs, file_ptr.file, @ptrCast(&file_ptr.iov), 1, if (this.flags.use_pread) @intCast(this._offset) else -1, onFileRead).toError(.write)) |err| { file_ptr.complete(false); this.flags.has_inflight_read = false; this.flags.is_paused = true; @@ -1108,7 +1116,7 @@ pub const WindowsBufferedReader = struct { file.iov = uv.uv_buf_t.init(buf); this.flags.has_inflight_read = true; - if (uv.uv_fs_read(uv.Loop.get(), &file.fs, file.file, @ptrCast(&file.iov), 1, if (this.flags.use_pread) @intCast(this._offset) else -1, onFileRead).toError(.write)) |err| { + if (uv.uv_fs_read(this.vtable.loop(this.parent), &file.fs, file.file, @ptrCast(&file.iov), 1, if (this.flags.use_pread) @intCast(this._offset) else -1, onFileRead).toError(.write)) |err| { file.complete(false); this.flags.has_inflight_read = false; return .{ .err = err }; diff --git a/src/io/PipeWriter.zig b/src/io/PipeWriter.zig index 9304960e28..f993361953 100644 --- a/src/io/PipeWriter.zig +++ b/src/io/PipeWriter.zig @@ -258,7 +258,9 @@ pub fn PosixBufferedWriter(Parent: type, function_table: anytype) type { pub fn registerPoll(this: *PosixWriter) void { var poll = this.getPoll() orelse return; - switch (poll.registerWithFd(bun.uws.Loop.get(), .writable, .dispatch, poll.fd)) { + // Use the event loop from the parent, not the global one + const loop = this.parent.eventLoop().loop(); + switch (poll.registerWithFd(loop, .writable, .dispatch, poll.fd)) { .err => |err| { onError(this.parent, err); }, @@ -897,7 +899,10 @@ fn BaseWindowsPipeWriter( else => @compileError("Expected `bun.FileDescriptor` or `*bun.MovableIfWindowsFd` but got: " ++ @typeName(rawfd)), }; bun.assert(this.source == null); - const source = switch (Source.open(uv.Loop.get(), fd)) { + // Use the event loop from the parent, not the global one + // This is critical for spawnSync to use its isolated loop + const loop = this.parent.loop(); + const source = switch (Source.open(loop, fd)) { .result => |source| source, .err => |err| return .{ .err = err }, }; @@ -1059,7 +1064,7 @@ pub fn WindowsBufferedWriter(Parent: type, function_table: anytype) type { file.prepare(); this.write_buffer = uv.uv_buf_t.init(buffer); - if (uv.uv_fs_write(uv.Loop.get(), &file.fs, file.file, @ptrCast(&this.write_buffer), 1, -1, onFsWriteComplete).toError(.write)) |err| { + if (uv.uv_fs_write(this.parent.loop(), &file.fs, file.file, @ptrCast(&this.write_buffer), 1, -1, onFsWriteComplete).toError(.write)) |err| { file.complete(false); this.close(); onError(this.parent, err); @@ -1404,7 +1409,7 @@ pub fn WindowsStreamingWriter(comptime Parent: type, function_table: anytype) ty file.prepare(); this.write_buffer = uv.uv_buf_t.init(bytes); - if (uv.uv_fs_write(uv.Loop.get(), &file.fs, file.file, @ptrCast(&this.write_buffer), 1, -1, onFsWriteComplete).toError(.write)) |err| { + if (uv.uv_fs_write(this.parent.loop(), &file.fs, file.file, @ptrCast(&this.write_buffer), 1, -1, onFsWriteComplete).toError(.write)) |err| { file.complete(false); this.last_write_result = .{ .err = err }; onError(this.parent, err); diff --git a/src/js/node/_http_client.ts b/src/js/node/_http_client.ts index 9cd64e9994..f01d4f4629 100644 --- a/src/js/node/_http_client.ts +++ b/src/js/node/_http_client.ts @@ -909,13 +909,9 @@ function ClientRequest(input, options, cb) { this[kEmitState] = 0; - this.setSocketKeepAlive = (_enable = true, _initialDelay = 0) => { - $debug(`${NODE_HTTP_WARNING}\n`, "WARN: ClientRequest.setSocketKeepAlive is a no-op"); - }; + this.setSocketKeepAlive = (_enable = true, _initialDelay = 0) => {}; - this.setNoDelay = (_noDelay = true) => { - $debug(`${NODE_HTTP_WARNING}\n`, "WARN: ClientRequest.setNoDelay is a no-op"); - }; + this.setNoDelay = (_noDelay = true) => {}; this[kClearTimeout] = () => { const timeoutTimer = this[kTimeoutTimer]; diff --git a/src/shell/IOReader.zig b/src/shell/IOReader.zig index 582c50deb6..78e7637693 100644 --- a/src/shell/IOReader.zig +++ b/src/shell/IOReader.zig @@ -46,8 +46,12 @@ pub fn eventLoop(this: *IOReader) jsc.EventLoopHandle { return this.evtloop; } -pub fn loop(this: *IOReader) *bun.uws.Loop { - return this.evtloop.loop(); +pub fn loop(this: *IOReader) *bun.Async.Loop { + if (comptime bun.Environment.isWindows) { + return this.evtloop.loop().uv_loop; + } else { + return this.evtloop.loop(); + } } pub fn init(fd: bun.FileDescriptor, evtloop: jsc.EventLoopHandle) *IOReader { diff --git a/src/shell/IOWriter.zig b/src/shell/IOWriter.zig index 451be65d59..1ffe692b1f 100644 --- a/src/shell/IOWriter.zig +++ b/src/shell/IOWriter.zig @@ -185,6 +185,14 @@ pub fn eventLoop(this: *IOWriter) jsc.EventLoopHandle { return this.evtloop; } +pub fn loop(this: *IOWriter) *bun.Async.Loop { + if (comptime bun.Environment.isWindows) { + return this.evtloop.loop().uv_loop; + } else { + return this.evtloop.loop(); + } +} + /// Idempotent write call fn write(this: *IOWriter) enum { suspended, diff --git a/src/shell/subproc.zig b/src/shell/subproc.zig index 39fd6868da..1b92a18efb 100644 --- a/src/shell/subproc.zig +++ b/src/shell/subproc.zig @@ -1035,8 +1035,12 @@ pub const PipeReader = struct { return p.reader.buffer().items[this.written..]; } - pub fn loop(this: *CapturedWriter) *uws.Loop { - return this.parent().event_loop.loop(); + pub fn loop(this: *CapturedWriter) *bun.Async.Loop { + if (comptime bun.Environment.isWindows) { + return this.parent().event_loop.loop().uv_loop; + } else { + return this.parent().event_loop.loop(); + } } pub fn parent(this: *CapturedWriter) *PipeReader { @@ -1340,8 +1344,12 @@ pub const PipeReader = struct { return this.event_loop; } - pub fn loop(this: *PipeReader) *uws.Loop { - return this.event_loop.loop(); + pub fn loop(this: *PipeReader) *bun.Async.Loop { + if (comptime bun.Environment.isWindows) { + return this.event_loop.loop().uv_loop; + } else { + return this.event_loop.loop(); + } } fn deinit(this: *PipeReader) void { @@ -1402,7 +1410,6 @@ const Output = bun.Output; const assert = bun.assert; const default_allocator = bun.default_allocator; const strings = bun.strings; -const uws = bun.uws; const jsc = bun.jsc; const JSGlobalObject = jsc.JSGlobalObject; diff --git a/test/CLAUDE.md b/test/CLAUDE.md index ee22ea9f1b..d92db49229 100644 --- a/test/CLAUDE.md +++ b/test/CLAUDE.md @@ -27,14 +27,47 @@ Use `bun:test` with files that end in `*.test.{ts,js,jsx,tsx,mjs,cjs}`. If it's When spawning Bun processes, use `bunExe` and `bunEnv` from `harness`. This ensures the same build of Bun is used to run the test and ensures debug logging is silenced. +##### Use `-e` for single-file tests + ```ts import { bunEnv, bunExe, tempDir } from "harness"; import { test, expect } from "bun:test"; -test("spawns a Bun process", async () => { +test("single-file test spawns a Bun process", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "console.log('Hello, world!')"], + env: bunEnv, + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + + expect(stderr).toBe(""); + expect(stdout).toBe("Hello, world!\n"); + expect(exitCode).toBe(0); +}); +``` + +##### When multi-file tests are required: + +```ts +import { bunEnv, bunExe, tempDir } from "harness"; +import { test, expect } from "bun:test"; + +test("multi-file test spawns a Bun process", async () => { + // If a test MUST use multiple files: using dir = tempDir("my-test-prefix", { "my.fixture.ts": ` - console.log("Hello, world!"); + import { foo } from "./foo.ts"; + foo(); + `, + "foo.ts": ` + export function foo() { + console.log("Hello, world!"); + } `, }); diff --git a/test/cli/install/bun-install-pathname-trailing-slash.test.ts b/test/cli/install/bun-install-pathname-trailing-slash.test.ts index 5f6e4b43db..266dee5f14 100644 --- a/test/cli/install/bun-install-pathname-trailing-slash.test.ts +++ b/test/cli/install/bun-install-pathname-trailing-slash.test.ts @@ -16,7 +16,7 @@ test("custom registry doesn't have multiple trailing slashes in pathname", async port: 0, async fetch(req) { urls.push(req.url); - return new Response("ok"); + return Response.json({ broken: true, message: "This is a test response" }); }, }); const { port, hostname } = server; @@ -39,7 +39,7 @@ registry = "http://${hostname}:${port}/prefixed-route/" }), ); - Bun.spawnSync({ + await using proc = Bun.spawn({ cmd: [bunExe(), "install", "--force"], env: bunEnv, cwd: package_dir, @@ -48,6 +48,9 @@ registry = "http://${hostname}:${port}/prefixed-route/" stdin: "ignore", }); + // The install should fail, but we're just testing the request goes to the right route. + expect(await proc.exited).toBe(1); + expect(urls.length).toBe(1); expect(urls).toEqual([`http://${hostname}:${port}/prefixed-route/react`]); }); diff --git a/test/cli/install/redacted-config-logs.test.ts b/test/cli/install/redacted-config-logs.test.ts index a5dc7381ca..e24948cd8b 100644 --- a/test/cli/install/redacted-config-logs.test.ts +++ b/test/cli/install/redacted-config-logs.test.ts @@ -1,9 +1,9 @@ -import { spawnSync, write } from "bun"; +import { write } from "bun"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tmpdirSync } from "harness"; import { join } from "path"; -describe("redact", async () => { +describe.concurrent("redact", async () => { const tests = [ { title: "url password", @@ -71,7 +71,7 @@ describe("redact", async () => { ]); // once without color - let proc = spawnSync({ + await using proc1 = Bun.spawn({ cmd: [bunExe(), "install"], cwd: testDir, env: { ...bunEnv, NO_COLOR: "1" }, @@ -79,13 +79,13 @@ describe("redact", async () => { stderr: "pipe", }); - let out = proc.stdout.toString(); - let err = proc.stderr.toString(); - expect(proc.exitCode).toBe(+!!bunfig); - expect(err).toContain(expected || "*"); + const [out1, err1, exitCode1] = await Promise.all([proc1.stdout.text(), proc1.stderr.text(), proc1.exited]); + + expect(exitCode1).toBe(+!!bunfig); + expect(err1).toContain(expected || "*"); // once with color - proc = spawnSync({ + await using proc2 = Bun.spawn({ cmd: [bunExe(), "install"], cwd: testDir, env: { ...bunEnv, NO_COLOR: undefined, FORCE_COLOR: "1" }, @@ -93,10 +93,10 @@ describe("redact", async () => { stderr: "pipe", }); - out = proc.stdout.toString(); - err = proc.stderr.toString(); - expect(proc.exitCode).toBe(+!!bunfig); - expect(err).toContain(expected || "*"); + const [out2, err2, exitCode2] = await Promise.all([proc2.stdout.text(), proc2.stderr.text(), proc2.exited]); + + expect(exitCode2).toBe(+!!bunfig); + expect(err2).toContain(expected || "*"); }); } }); diff --git a/test/cli/test/process-kill-fixture-sync.ts b/test/cli/test/process-kill-fixture-sync.ts index ed5c89b7da..57c163936a 100644 --- a/test/cli/test/process-kill-fixture-sync.ts +++ b/test/cli/test/process-kill-fixture-sync.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { test } from "bun:test"; import { bunEnv, bunExe } from "harness"; test("test timeout kills dangling processes", async () => { diff --git a/test/cli/test/test-timeout-behavior.test.ts b/test/cli/test/test-timeout-behavior.test.ts index e908b052ca..07e02e3f03 100644 --- a/test/cli/test/test-timeout-behavior.test.ts +++ b/test/cli/test/test-timeout-behavior.test.ts @@ -5,7 +5,7 @@ import path from "path"; if (isFlaky && isLinux) { test.todo("processes get killed"); } else { - test.each([true, false])(`processes get killed (sync: %p)`, async sync => { + test.concurrent.each([true, false])(`processes get killed (sync: %p)`, async sync => { const { exited, stdout, stderr } = Bun.spawn({ cmd: [ bunExe(), diff --git a/test/internal/ban-limits.json b/test/internal/ban-limits.json index 905a6bf35a..80972cbe79 100644 --- a/test/internal/ban-limits.json +++ b/test/internal/ban-limits.json @@ -10,7 +10,7 @@ ".stdDir()": 42, ".stdFile()": 16, "// autofix": 164, - ": [^=]+= undefined,$": 255, + ": [^=]+= undefined,$": 256, "== alloc.ptr": 0, "== allocator.ptr": 0, "@import(\"bun\").": 0, diff --git a/test/js/bun/spawn/spawn-signal.test.ts b/test/js/bun/spawn/spawn-signal.test.ts index 38c0c39fc6..628c74c507 100644 --- a/test/js/bun/spawn/spawn-signal.test.ts +++ b/test/js/bun/spawn/spawn-signal.test.ts @@ -67,26 +67,3 @@ test("spawnSync AbortSignal works as timeout", async () => { const end = performance.now(); expect(end - start).toBeLessThan(100); }); - -// TODO: this test should fail. -// It passes because we are ticking the event loop incorrectly in spawnSync. -// it should be ticking a different event loop. -test("spawnSync AbortSignal...executes javascript?", async () => { - const start = performance.now(); - var signal = AbortSignal.timeout(10); - signal.addEventListener("abort", () => { - console.log("abort", performance.now()); - }); - const subprocess = Bun.spawnSync({ - cmd: [bunExe(), "--eval", "await Bun.sleep(100000)"], - env: bunEnv, - stdout: "inherit", - stderr: "inherit", - stdin: "inherit", - signal, - }); - console.log("after", performance.now()); - expect(subprocess.success).toBeFalse(); - const end = performance.now(); - expect(end - start).toBeLessThan(100); -}); diff --git a/test/js/bun/spawn/spawnsync-isolated-event-loop.test.ts b/test/js/bun/spawn/spawnsync-isolated-event-loop.test.ts new file mode 100644 index 0000000000..cc8a1b942f --- /dev/null +++ b/test/js/bun/spawn/spawnsync-isolated-event-loop.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +describe.concurrent("spawnSync isolated event loop", () => { + test("JavaScript timers should not fire during spawnSync", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + let timerFired = false; + + // Set a timer that should NOT fire during spawnSync + const interval = setInterval(() => { + timerFired = true; + console.log("TIMER_FIRED"); + process.exit(1); + }, 1); + + // Run a subprocess synchronously + const result = Bun.spawnSync({ + cmd: ["${bunExe()}", "-e", "Bun.sleepSync(16)"], + env: process.env, + }); + + clearInterval(interval); + + console.log("SUCCESS: Timer did not fire during spawnSync"); + process.exit(0); + `, + ], + env: bunEnv, + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + expect(stdout).toContain("SUCCESS"); + expect(stdout).not.toContain("TIMER_FIRED"); + expect(stdout).not.toContain("FAIL"); + expect(exitCode).toBe(0); + }); + + test("microtasks should not drain during spawnSync", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + queueMicrotask(() => { + console.log("MICROTASK_FIRED"); + process.exit(1); + }); + + // Run a subprocess synchronously + const result = Bun.spawnSync({ + cmd: ["${bunExe()}", "-e", "42"], + env: process.env, + }); + + console.log("SUCCESS: Timer did not fire during spawnSync"); + process.exit(0); + `, + ], + env: bunEnv, + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + expect(stdout).toContain("SUCCESS"); + expect(stdout).not.toContain("MICROTASK_FIRED"); + expect(stdout).not.toContain("FAIL"); + expect(exitCode).toBe(0); + }); + + test("stdin/stdout from main process should not be affected by spawnSync", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + // Write to stdout before spawnSync + console.log("BEFORE"); + + // Run a subprocess synchronously + const result = Bun.spawnSync({ + cmd: ["echo", "SUBPROCESS"], + env: process.env, + }); + + // Write to stdout after spawnSync + console.log("AFTER"); + + // Verify subprocess output + const subprocessOut = new TextDecoder().decode(result.stdout); + if (!subprocessOut.includes("SUBPROCESS")) { + console.log("FAIL: Subprocess output missing"); + process.exit(1); + } + + console.log("SUCCESS"); + process.exit(0); + `, + ], + env: bunEnv, + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + expect(stdout).toContain("BEFORE"); + expect(stdout).toContain("AFTER"); + expect(stdout).toContain("SUCCESS"); + expect(exitCode).toBe(0); + }); + + test("multiple spawnSync calls should each use isolated event loop", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + let timerCount = 0; + + // Set timers that should NOT fire during spawnSync + setTimeout(() => { timerCount++; }, 10); + setTimeout(() => { timerCount++; }, 20); + setTimeout(() => { timerCount++; }, 30); + + // Run multiple subprocesses synchronously + for (let i = 0; i < 3; i++) { + const result = Bun.spawnSync({ + cmd: ["${bunExe()}", "-e", "Bun.sleepSync(50)"], + }); + + if (timerCount > 0) { + console.log(\`FAIL: Timer fired during spawnSync iteration \${i}\`); + process.exit(1); + } + } + + console.log("SUCCESS: No timers fired during any spawnSync call"); + process.exit(); + `, + ], + env: bunEnv, + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + expect(stdout).toContain("SUCCESS"); + expect(stdout).not.toContain("FAIL"); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index fc8f4f087c..ccb54b234c 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -7,7 +7,6 @@ */ import { bunEnv, bunExe, exampleSite, randomPort } from "harness"; import { createTest } from "node-harness"; -import { spawnSync } from "node:child_process"; import { EventEmitter, once } from "node:events"; import nodefs, { unlinkSync } from "node:fs"; import http, { @@ -832,7 +831,12 @@ describe("node:http", () => { it("should correctly stream a multi-chunk response #5320", async done => { runTest(done, (server, serverPort, done) => { - const req = request({ host: "localhost", port: `${serverPort}`, path: "/multi-chunk-response", method: "GET" }); + const req = request({ + host: "localhost", + port: `${serverPort}`, + path: "/multi-chunk-response", + method: "GET", + }); req.on("error", err => done(err)); @@ -1046,9 +1050,10 @@ describe("node:http", () => { }); }); - test("test unix socket server", done => { + test("test unix socket server", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); const socketPath = `${tmpdir()}/bun-server-${Math.random().toString(32)}.sock`; - const server = createServer((req, res) => { + await using server = createServer((req, res) => { expect(req.method).toStrictEqual("GET"); expect(req.url).toStrictEqual("/bun?a=1"); res.writeHead(200, { @@ -1059,18 +1064,20 @@ describe("node:http", () => { res.end(); }); - server.listen(socketPath, () => { - // TODO: unix socket is not implemented in fetch. - const output = spawnSync("curl", ["--unix-socket", socketPath, "http://localhost/bun?a=1"]); + server.listen(socketPath, async () => { try { - expect(output.stdout.toString()).toStrictEqual("Bun\n"); - done(); + const response = await fetch(`http://localhost/bun?a=1`, { + unix: socketPath, + }); + const text = await response.text(); + expect(text).toBe("Bun\n"); + resolve(); } catch (err) { - done(err); - } finally { - server.close(); + reject(err); } }); + + await promise; }); test("should not decompress gzip, issue#4397", async () => { @@ -1284,26 +1291,26 @@ describe("server.address should be valid IP", () => { }); it("should propagate exception in sync data handler", async () => { - const { exitCode, stdout } = Bun.spawnSync({ + await using proc = Bun.spawn({ cmd: [bunExe(), "run", path.join(import.meta.dir, "node-http-error-in-data-handler-fixture.1.js")], stdout: "pipe", stderr: "inherit", env: bunEnv, }); - - expect(stdout.toString()).toContain("Test passed"); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toContain("Test passed"); expect(exitCode).toBe(0); }); it("should propagate exception in async data handler", async () => { - const { exitCode, stdout } = Bun.spawnSync({ + await using proc = Bun.spawn({ cmd: [bunExe(), "run", path.join(import.meta.dir, "node-http-error-in-data-handler-fixture.2.js")], stdout: "pipe", stderr: "inherit", env: bunEnv, }); - - expect(stdout.toString()).toContain("Test passed"); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toContain("Test passed"); expect(exitCode).toBe(0); }); diff --git a/test/js/node/test/parallel/test-child-process-spawnsync-timeout.js b/test/js/node/test/parallel/test-child-process-spawnsync-timeout.js index 426ac05a43..36f2555ac8 100644 --- a/test/js/node/test/parallel/test-child-process-spawnsync-timeout.js +++ b/test/js/node/test/parallel/test-child-process-spawnsync-timeout.js @@ -39,6 +39,7 @@ if (common.isWindows) { switch (process.argv[2]) { case 'child': + console.log('child started'); setTimeout(() => { debug('child fired'); process.exit(1); diff --git a/test/js/third_party/astro/astro-post.test.js b/test/js/third_party/astro/astro-post.test.js index 904fb55398..14b529d13f 100644 --- a/test/js/third_party/astro/astro-post.test.js +++ b/test/js/third_party/astro/astro-post.test.js @@ -4,21 +4,21 @@ import { bunEnv, nodeExe } from "harness"; import { join } from "path"; const fixtureDir = join(import.meta.dirname, "fixtures"); -function postNodeFormData(port) { - const result = Bun.spawnSync({ +async function postNodeFormData(port) { + const result = Bun.spawn({ cmd: [nodeExe(), join(fixtureDir, "node-form-data.fetch.fixture.js"), port?.toString()], env: bunEnv, stdio: ["inherit", "inherit", "inherit"], }); - expect(result.exitCode).toBe(0); + expect(await result.exited).toBe(0); } -function postNodeAction(port) { - const result = Bun.spawnSync({ +async function postNodeAction(port) { + const result = Bun.spawn({ cmd: [nodeExe(), join(fixtureDir, "node-action.fetch.fixture.js"), port?.toString()], env: bunEnv, stdio: ["inherit", "inherit", "inherit"], }); - expect(result.exitCode).toBe(0); + expect(await result.exited).toBe(0); } describe("astro", async () => { @@ -66,7 +66,7 @@ describe("astro", async () => { }); test("is able todo a POST request to an astro action using node", async () => { - postNodeAction(previewServer.port); + await postNodeAction(previewServer.port); }); test("is able to post form data to an astro using bun", async () => { @@ -89,6 +89,6 @@ describe("astro", async () => { }); }); test("is able to post form data to an astro using node", async () => { - postNodeFormData(previewServer.port); + await postNodeFormData(previewServer.port); }); }); diff --git a/test/js/web/fetch/fetch-leak-test-fixture-5.js b/test/js/web/fetch/fetch-leak-test-fixture-5.js index afcdb6e31b..924ef07b7c 100644 --- a/test/js/web/fetch/fetch-leak-test-fixture-5.js +++ b/test/js/web/fetch/fetch-leak-test-fixture-5.js @@ -87,15 +87,17 @@ function getBody() { return body; } +async function iterate() { + const promises = []; + for (let j = 0; j < batch; j++) { + promises.push(fetch(server, { method: "POST", body: getBody() })); + } + await Promise.all(promises); +} + try { for (let i = 0; i < iterations; i++) { - { - const promises = []; - for (let j = 0; j < batch; j++) { - promises.push(fetch(server, { method: "POST", body: getBody() })); - } - await Promise.all(promises); - } + await iterate(); { Bun.gc(true); diff --git a/test/js/web/fetch/fetch-leak.test.ts b/test/js/web/fetch/fetch-leak.test.ts index b2f246aabf..6014f2ef2f 100644 --- a/test/js/web/fetch/fetch-leak.test.ts +++ b/test/js/web/fetch/fetch-leak.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, bunRun, tls as COMMON_CERT, gc, isCI } from "harness"; +import { bunEnv, bunExe, tls as COMMON_CERT, gc, isCI } from "harness"; import { once } from "node:events"; import { createServer } from "node:http"; import { join } from "node:path"; @@ -17,7 +17,7 @@ describe("fetch doesn't leak", () => { }, }); - const proc = Bun.spawn({ + await using proc = Bun.spawn({ env: { ...bunEnv, SERVER: server.url.href, @@ -76,7 +76,7 @@ describe("fetch doesn't leak", () => { env.COUNT = "1000"; } - const proc = Bun.spawn({ + await using proc = Bun.spawn({ env, stderr: "inherit", stdout: "inherit", @@ -114,7 +114,7 @@ describe.each(["FormData", "Blob", "Buffer", "String", "URLSearchParams", "strea const rss = []; - const process = Bun.spawn({ + await using process = Bun.spawn({ cmd: [ bunExe(), "--smol", @@ -189,16 +189,19 @@ test("should not leak using readable stream", async () => { const buffer = Buffer.alloc(1024 * 128, "b"); using server = Bun.serve({ port: 0, - fetch: req => { - return new Response(buffer); - }, + routes: { "/*": new Response(buffer) }, }); - const { stdout, stderr } = bunRun(join(import.meta.dir, "fetch-leak-test-fixture-6.js"), { - ...bunEnv, - SERVER_URL: server.url.href, - MAX_MEMORY_INCREASE: "5", // in MB + await using proc = Bun.spawn([bunExe(), join(import.meta.dir, "fetch-leak-test-fixture-6.js")], { + env: { + ...bunEnv, + SERVER_URL: server.url.href, + MAX_MEMORY_INCREASE: "5", // in MB + }, + stdout: "pipe", + stderr: "pipe", }); - expect(stderr).toBe(""); - expect(stdout).toContain("done"); + const [exited, stdout, stderr] = await Promise.all([proc.exited, proc.stdout.text(), proc.stderr.text()]); + expect(stdout + stderr).toContain("done"); + expect(exited).toBe(0); });