Compare commits

...

6 Commits

Author SHA1 Message Date
Jarred Sumner
bdef634674 Implement isolated event loop for spawnSync
Introduces a dedicated event loop for spawnSync operations to prevent JavaScript timers and microtasks from firing and to avoid interference with the main process's stdin/stdout. Refactors event loop and pipe handling to support isolation, updates related APIs for Windows compatibility, and adds tests to verify correct isolation behavior.
2025-11-06 00:16:35 -08:00
autofix-ci[bot]
887e72dd44 [autofix.ci] apply automated fixes 2025-11-05 19:08:25 -08:00
Jarred Sumner
f1a0d26ed7 Update subprocess.zig 2025-11-05 19:08:24 -08:00
Jarred Sumner
58d68da590 Update js_bun_spawn_bindings.zig 2025-11-05 19:08:24 -08:00
Jarred Sumner
f409b6e656 Move Bun.spawn & Bun.spawnSync bindings to separate file 2025-11-05 19:08:24 -08:00
Jarred Sumner
0e65edf377 Move bun.SignalCode into src/SignalCode.zig 2025-11-05 19:08:24 -08:00
25 changed files with 1685 additions and 1101 deletions

View File

@@ -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

169
src/SignalCode.zig Normal file
View File

@@ -0,0 +1,169 @@
pub const SignalCode = enum(u8) {
SIGHUP = 1,
SIGINT = 2,
SIGQUIT = 3,
SIGILL = 4,
SIGTRAP = 5,
SIGABRT = 6,
SIGBUS = 7,
SIGFPE = 8,
SIGKILL = 9,
SIGUSR1 = 10,
SIGSEGV = 11,
SIGUSR2 = 12,
SIGPIPE = 13,
SIGALRM = 14,
SIGTERM = 15,
SIG16 = 16,
SIGCHLD = 17,
SIGCONT = 18,
SIGSTOP = 19,
SIGTSTP = 20,
SIGTTIN = 21,
SIGTTOU = 22,
SIGURG = 23,
SIGXCPU = 24,
SIGXFSZ = 25,
SIGVTALRM = 26,
SIGPROF = 27,
SIGWINCH = 28,
SIGIO = 29,
SIGPWR = 30,
SIGSYS = 31,
_,
// The `subprocess.kill()` method sends a signal to the child process. If no
// argument is given, the process will be sent the 'SIGTERM' signal.
pub const default = SignalCode.SIGTERM;
pub const Map = ComptimeEnumMap(SignalCode);
pub fn name(value: SignalCode) ?[]const u8 {
if (@intFromEnum(value) <= @intFromEnum(SignalCode.SIGSYS)) {
return asByteSlice(@tagName(value));
}
return null;
}
pub fn valid(value: SignalCode) bool {
return @intFromEnum(value) <= @intFromEnum(SignalCode.SIGSYS) and @intFromEnum(value) >= @intFromEnum(SignalCode.SIGHUP);
}
/// Shell scripts use exit codes 128 + signal number
/// https://tldp.org/LDP/abs/html/exitcodes.html
pub fn toExitCode(value: SignalCode) ?u8 {
return switch (@intFromEnum(value)) {
1...31 => 128 +% @intFromEnum(value),
else => null,
};
}
pub fn description(signal: SignalCode) ?[]const u8 {
// Description names copied from fish
// https://github.com/fish-shell/fish-shell/blob/00ffc397b493f67e28f18640d3de808af29b1434/fish-rust/src/signal.rs#L420
return switch (signal) {
.SIGHUP => "Terminal hung up",
.SIGINT => "Quit request",
.SIGQUIT => "Quit request",
.SIGILL => "Illegal instruction",
.SIGTRAP => "Trace or breakpoint trap",
.SIGABRT => "Abort",
.SIGBUS => "Misaligned address error",
.SIGFPE => "Floating point exception",
.SIGKILL => "Forced quit",
.SIGUSR1 => "User defined signal 1",
.SIGUSR2 => "User defined signal 2",
.SIGSEGV => "Address boundary error",
.SIGPIPE => "Broken pipe",
.SIGALRM => "Timer expired",
.SIGTERM => "Polite quit request",
.SIGCHLD => "Child process status changed",
.SIGCONT => "Continue previously stopped process",
.SIGSTOP => "Forced stop",
.SIGTSTP => "Stop request from job control (^Z)",
.SIGTTIN => "Stop from terminal input",
.SIGTTOU => "Stop from terminal output",
.SIGURG => "Urgent socket condition",
.SIGXCPU => "CPU time limit exceeded",
.SIGXFSZ => "File size limit exceeded",
.SIGVTALRM => "Virtual timefr expired",
.SIGPROF => "Profiling timer expired",
.SIGWINCH => "Window size change",
.SIGIO => "I/O on asynchronous file descriptor is possible",
.SIGSYS => "Bad system call",
.SIGPWR => "Power failure",
else => null,
};
}
pub fn from(value: anytype) SignalCode {
return @enumFromInt(std.mem.asBytes(&value)[0]);
}
// This wrapper struct is lame, what if bun's color formatter was more versatile
const Fmt = struct {
signal: SignalCode,
enable_ansi_colors: bool,
pub fn format(this: Fmt, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void {
const signal = this.signal;
switch (this.enable_ansi_colors) {
inline else => |enable_ansi_colors| {
if (signal.name()) |str| if (signal.description()) |desc| {
try writer.print(Output.prettyFmt("{s} <d>({s})<r>", enable_ansi_colors), .{ str, desc });
return;
};
try writer.print("code {d}", .{@intFromEnum(signal)});
},
}
}
};
pub fn fmt(signal: SignalCode, enable_ansi_colors: bool) Fmt {
return .{ .signal = signal, .enable_ansi_colors = enable_ansi_colors };
}
pub fn fromJS(arg: jsc.JSValue, globalThis: *jsc.JSGlobalObject) !SignalCode {
if (arg.getNumber()) |sig64| {
// Node does this:
if (std.math.isNan(sig64)) {
return SignalCode.default;
}
// This matches node behavior, minus some details with the error messages: https://gist.github.com/Jarred-Sumner/23ba38682bf9d84dff2f67eb35c42ab6
if (std.math.isInf(sig64) or @trunc(sig64) != sig64) {
return globalThis.throwInvalidArguments("Unknown signal", .{});
}
if (sig64 < 0) {
return globalThis.throwInvalidArguments("Invalid signal: must be >= 0", .{});
}
if (sig64 > 31) {
return globalThis.throwInvalidArguments("Invalid signal: must be < 32", .{});
}
const code: SignalCode = @enumFromInt(@as(u8, @intFromFloat(sig64)));
return code;
} else if (arg.isString()) {
if (arg.asString().length() == 0) {
return SignalCode.default;
}
const signal_code = try arg.toEnum(globalThis, "signal", SignalCode);
return signal_code;
} else if (!arg.isEmptyOrUndefinedOrNull()) {
return globalThis.throwInvalidArguments("Invalid signal: must be a string or an integer", .{});
}
return SignalCode.default;
}
};
const std = @import("std");
const bun = @import("bun");
const ComptimeEnumMap = bun.ComptimeEnumMap;
const Output = bun.Output;
const asByteSlice = bun.asByteSlice;
const jsc = bun.jsc;
const JSGlobalObject = jsc.JSGlobalObject;
const JSValue = jsc.JSValue;

View File

@@ -0,0 +1,957 @@
// This is split into a separate function to conserve stack space.
// On Windows, a single path buffer can take 64 KB.
fn getArgv0(globalThis: *jsc.JSGlobalObject, PATH: []const u8, cwd: []const u8, pretend_argv0: ?[*:0]const u8, first_cmd: JSValue, allocator: std.mem.Allocator) bun.JSError!struct {
argv0: [:0]const u8,
arg0: [:0]u8,
} {
var arg0 = try first_cmd.toSliceOrNullWithAllocator(globalThis, allocator);
defer arg0.deinit();
// Heap allocate it to ensure we don't run out of stack space.
const path_buf: *bun.PathBuffer = try bun.default_allocator.create(bun.PathBuffer);
defer bun.default_allocator.destroy(path_buf);
var actual_argv0: [:0]const u8 = "";
const argv0_to_use: []const u8 = arg0.slice();
// This mimicks libuv's behavior, which mimicks execvpe
// Only resolve from $PATH when the command is not an absolute path
const PATH_to_use: []const u8 = if (strings.containsChar(argv0_to_use, '/'))
""
// If no $PATH is provided, we fallback to the one from environ
// This is already the behavior of the PATH passed in here.
else if (PATH.len > 0)
PATH
else if (comptime Environment.isPosix)
// If the user explicitly passed an empty $PATH, we fallback to the OS-specific default (which libuv also does)
bun.sliceTo(BUN_DEFAULT_PATH_FOR_SPAWN, 0)
else
"";
if (PATH_to_use.len == 0) {
actual_argv0 = try allocator.dupeZ(u8, argv0_to_use);
} else {
const resolved = which(path_buf, PATH_to_use, cwd, argv0_to_use) orelse {
return throwCommandNotFound(globalThis, argv0_to_use);
};
actual_argv0 = try allocator.dupeZ(u8, resolved);
}
return .{
.argv0 = actual_argv0,
.arg0 = if (pretend_argv0) |p| try allocator.dupeZ(u8, bun.sliceTo(p, 0)) else try allocator.dupeZ(u8, arg0.slice()),
};
}
/// `argv` for `Bun.spawn` & `Bun.spawnSync`
fn getArgv(globalThis: *jsc.JSGlobalObject, args: JSValue, PATH: []const u8, cwd: []const u8, argv0: *?[*:0]const u8, allocator: std.mem.Allocator, argv: *std.ArrayList(?[*:0]const u8)) bun.JSError!void {
var cmds_array = try args.arrayIterator(globalThis);
// + 1 for argv0
// + 1 for null terminator
argv.* = try @TypeOf(argv.*).initCapacity(allocator, cmds_array.len + 2);
if (args.isEmptyOrUndefinedOrNull()) {
return globalThis.throwInvalidArguments("cmd must be an array of strings", .{});
}
if (cmds_array.len == 0) {
return globalThis.throwInvalidArguments("cmd must not be empty", .{});
}
const argv0_result = try getArgv0(globalThis, PATH, cwd, argv0.*, (try cmds_array.next()).?, allocator);
argv0.* = argv0_result.argv0.ptr;
argv.appendAssumeCapacity(argv0_result.arg0.ptr);
while (try cmds_array.next()) |value| {
const arg = try value.toBunString(globalThis);
defer arg.deref();
argv.appendAssumeCapacity(try arg.toOwnedSliceZ(allocator));
}
if (argv.items.len == 0) {
return globalThis.throwInvalidArguments("cmd must be an array of strings", .{});
}
}
/// Bun.spawn() calls this.
pub fn spawn(globalThis: *jsc.JSGlobalObject, args: JSValue, secondaryArgsValue: ?JSValue) bun.JSError!JSValue {
return spawnMaybeSync(globalThis, args, secondaryArgsValue, false);
}
/// Bun.spawnSync() calls this.
pub fn spawnSync(globalThis: *jsc.JSGlobalObject, args: JSValue, secondaryArgsValue: ?JSValue) bun.JSError!JSValue {
return spawnMaybeSync(globalThis, args, secondaryArgsValue, true);
}
pub fn spawnMaybeSync(
globalThis: *jsc.JSGlobalObject,
args_: JSValue,
secondaryArgsValue: ?JSValue,
comptime is_sync: bool,
) bun.JSError!JSValue {
if (comptime is_sync) {
// We skip this on Windows due to test failures.
if (comptime !Environment.isWindows) {
// Since the event loop is recursively called, we need to check if it's safe to recurse.
if (!bun.StackCheck.init().isSafeToRecurse()) {
return globalThis.throwStackOverflow();
}
}
}
var arena = bun.ArenaAllocator.init(bun.default_allocator);
defer arena.deinit();
const allocator = arena.allocator();
var override_env = false;
var env_array = std.ArrayListUnmanaged(?[*:0]const u8){};
var jsc_vm = globalThis.bunVM();
var cwd = jsc_vm.transpiler.fs.top_level_dir;
var stdio = [3]Stdio{
.{ .ignore = {} },
.{ .pipe = {} },
.{ .inherit = {} },
};
if (comptime is_sync) {
stdio[1] = .{ .pipe = {} };
stdio[2] = .{ .pipe = {} };
}
var lazy = false;
var on_exit_callback = JSValue.zero;
var on_disconnect_callback = JSValue.zero;
var PATH = jsc_vm.transpiler.env.get("PATH") orelse "";
var argv = std.ArrayList(?[*:0]const u8).init(allocator);
var cmd_value = JSValue.zero;
var detached = false;
var args = args_;
var maybe_ipc_mode: if (is_sync) void else ?IPC.Mode = if (is_sync) {} else null;
var ipc_callback: JSValue = .zero;
var extra_fds = std.ArrayList(bun.spawn.SpawnOptions.Stdio).init(bun.default_allocator);
var argv0: ?[*:0]const u8 = null;
var ipc_channel: i32 = -1;
var timeout: ?i32 = null;
var killSignal: SignalCode = SignalCode.default;
var maxBuffer: ?i64 = null;
var windows_hide: bool = false;
var windows_verbatim_arguments: bool = false;
var abort_signal: ?*jsc.WebCore.AbortSignal = null;
defer {
// Ensure we clean it up on error.
if (abort_signal) |signal| {
signal.unref();
}
}
{
if (args.isEmptyOrUndefinedOrNull()) {
return globalThis.throwInvalidArguments("cmd must be an array", .{});
}
const args_type = args.jsType();
if (args_type.isArray()) {
cmd_value = args;
args = secondaryArgsValue orelse JSValue.zero;
} else if (!args.isObject()) {
return globalThis.throwInvalidArguments("cmd must be an array", .{});
} else if (try args.getTruthy(globalThis, "cmd")) |cmd_value_| {
cmd_value = cmd_value_;
} else {
return globalThis.throwInvalidArguments("cmd must be an array", .{});
}
if (args.isObject()) {
if (try args.getTruthy(globalThis, "argv0")) |argv0_| {
const argv0_str = try argv0_.getZigString(globalThis);
if (argv0_str.len > 0) {
argv0 = try argv0_str.toOwnedSliceZ(allocator);
}
}
// need to update `cwd` before searching for executable with `Which.which`
if (try args.getTruthy(globalThis, "cwd")) |cwd_| {
const cwd_str = try cwd_.getZigString(globalThis);
if (cwd_str.len > 0) {
cwd = try cwd_str.toOwnedSliceZ(allocator);
}
}
}
if (args != .zero and args.isObject()) {
// This must run before the stdio parsing happens
if (!is_sync) {
if (try args.getTruthy(globalThis, "ipc")) |val| {
if (val.isCell() and val.isCallable()) {
maybe_ipc_mode = ipc_mode: {
if (try args.getTruthy(globalThis, "serialization")) |mode_val| {
if (mode_val.isString()) {
break :ipc_mode try IPC.Mode.fromJS(globalThis, mode_val) orelse {
return globalThis.throwInvalidArguments("serialization must be \"json\" or \"advanced\"", .{});
};
} else {
if (!globalThis.hasException()) {
return globalThis.throwInvalidArgumentType("spawn", "serialization", "string");
}
return .zero;
}
}
break :ipc_mode .advanced;
};
ipc_callback = val.withAsyncContextIfNeeded(globalThis);
}
}
}
if (try args.getTruthy(globalThis, "signal")) |signal_val| {
if (signal_val.as(jsc.WebCore.AbortSignal)) |signal| {
abort_signal = signal.ref();
} else {
return globalThis.throwInvalidArgumentTypeValue("signal", "AbortSignal", signal_val);
}
}
if (try args.getTruthy(globalThis, "onDisconnect")) |onDisconnect_| {
if (!onDisconnect_.isCell() or !onDisconnect_.isCallable()) {
return globalThis.throwInvalidArguments("onDisconnect must be a function or undefined", .{});
}
on_disconnect_callback = if (comptime is_sync)
onDisconnect_
else
onDisconnect_.withAsyncContextIfNeeded(globalThis);
}
if (try args.getTruthy(globalThis, "onExit")) |onExit_| {
if (!onExit_.isCell() or !onExit_.isCallable()) {
return globalThis.throwInvalidArguments("onExit must be a function or undefined", .{});
}
on_exit_callback = if (comptime is_sync)
onExit_
else
onExit_.withAsyncContextIfNeeded(globalThis);
}
if (try args.getTruthy(globalThis, "env")) |env_arg| {
env_arg.ensureStillAlive();
const object = env_arg.getObject() orelse {
return globalThis.throwInvalidArguments("env must be an object", .{});
};
override_env = true;
// If the env object does not include a $PATH, it must disable path lookup for argv[0]
var NEW_PATH: []const u8 = "";
var envp_managed = env_array.toManaged(allocator);
try appendEnvpFromJS(globalThis, object, &envp_managed, &NEW_PATH);
env_array = envp_managed.moveToUnmanaged();
PATH = NEW_PATH;
}
try getArgv(globalThis, cmd_value, PATH, cwd, &argv0, allocator, &argv);
if (try args.get(globalThis, "stdio")) |stdio_val| {
if (!stdio_val.isEmptyOrUndefinedOrNull()) {
if (stdio_val.jsType().isArray()) {
var stdio_iter = try stdio_val.arrayIterator(globalThis);
var i: u31 = 0;
while (try stdio_iter.next()) |value| : (i += 1) {
try stdio[i].extract(globalThis, i, value, is_sync);
if (i == 2)
break;
}
i += 1;
while (try stdio_iter.next()) |value| : (i += 1) {
var new_item: Stdio = undefined;
try new_item.extract(globalThis, i, value, is_sync);
const opt = switch (new_item.asSpawnOption(i)) {
.result => |opt| opt,
.err => |e| {
return e.throwJS(globalThis);
},
};
if (opt == .ipc) {
ipc_channel = @intCast(extra_fds.items.len);
}
try extra_fds.append(opt);
}
} else {
return globalThis.throwInvalidArguments("stdio must be an array", .{});
}
}
} else {
if (try args.get(globalThis, "stdin")) |value| {
try stdio[0].extract(globalThis, 0, value, is_sync);
}
if (try args.get(globalThis, "stderr")) |value| {
try stdio[2].extract(globalThis, 2, value, is_sync);
}
if (try args.get(globalThis, "stdout")) |value| {
try stdio[1].extract(globalThis, 1, value, is_sync);
}
}
if (comptime !is_sync) {
if (try args.get(globalThis, "lazy")) |lazy_val| {
if (lazy_val.isBoolean()) {
lazy = lazy_val.toBoolean();
}
}
}
if (try args.get(globalThis, "detached")) |detached_val| {
if (detached_val.isBoolean()) {
detached = detached_val.toBoolean();
}
}
if (Environment.isWindows) {
if (try args.get(globalThis, "windowsHide")) |val| {
if (val.isBoolean()) {
windows_hide = val.asBoolean();
}
}
if (try args.get(globalThis, "windowsVerbatimArguments")) |val| {
if (val.isBoolean()) {
windows_verbatim_arguments = val.asBoolean();
}
}
}
if (try args.get(globalThis, "timeout")) |timeout_value| brk: {
if (timeout_value != .null) {
if (timeout_value.isNumber() and std.math.isPositiveInf(timeout_value.asNumber())) {
break :brk;
}
const timeout_int = try globalThis.validateIntegerRange(timeout_value, u64, 0, .{ .min = 0, .field_name = "timeout" });
if (timeout_int > 0)
timeout = @intCast(@as(u31, @truncate(timeout_int)));
}
}
if (try args.get(globalThis, "killSignal")) |val| {
killSignal = try bun.SignalCode.fromJS(val, globalThis);
}
if (try args.get(globalThis, "maxBuffer")) |val| {
if (val.isNumber() and val.isFinite()) { // 'Infinity' does not set maxBuffer
const value = try val.coerce(i64, globalThis);
if (value > 0) {
maxBuffer = value;
}
}
}
} else {
try getArgv(globalThis, cmd_value, PATH, cwd, &argv0, allocator, &argv);
}
}
log("spawn maxBuffer: {?d}", .{maxBuffer});
if (!override_env and env_array.items.len == 0) {
env_array.items = jsc_vm.transpiler.env.map.createNullDelimitedEnvMap(allocator) catch |err| return globalThis.throwError(err, "in Bun.spawn") catch return .zero;
env_array.capacity = env_array.items.len;
}
inline for (0..stdio.len) |fd_index| {
if (stdio[fd_index].canUseMemfd(is_sync, fd_index > 0 and maxBuffer != null)) {
if (stdio[fd_index].useMemfd(fd_index)) {
jsc_vm.counters.mark(.spawn_memfd);
}
}
}
var should_close_memfd = Environment.isLinux;
defer {
if (should_close_memfd) {
inline for (0..stdio.len) |fd_index| {
if (stdio[fd_index] == .memfd) {
stdio[fd_index].memfd.close();
stdio[fd_index] = .ignore;
}
}
}
}
//"NODE_CHANNEL_FD=" is 16 bytes long, 15 bytes for the number, and 1 byte for the null terminator should be enough/safe
var ipc_env_buf: [32]u8 = undefined;
if (!is_sync) if (maybe_ipc_mode) |ipc_mode| {
// IPC is currently implemented in a very limited way.
//
// Node lets you pass as many fds as you want, they all become be sockets; then, IPC is just a special
// runtime-owned version of "pipe" (in which pipe is a misleading name since they're bidirectional sockets).
//
// Bun currently only supports three fds: stdin, stdout, and stderr, which are all unidirectional
//
// And then one fd is assigned specifically and only for IPC. If the user dont specify it, we add one (default: 3).
//
// When Bun.spawn() is given an `.ipc` callback, it enables IPC as follows:
env_array.ensureUnusedCapacity(allocator, 3) catch |err| return globalThis.throwError(err, "in Bun.spawn") catch return .zero;
const ipc_fd: i32 = brk: {
if (ipc_channel == -1) {
// If the user didn't specify an IPC channel, we need to add one
ipc_channel = @intCast(extra_fds.items.len);
var ipc_extra_fd_default = Stdio{ .ipc = {} };
const fd: i32 = ipc_channel + 3;
switch (ipc_extra_fd_default.asSpawnOption(fd)) {
.result => |opt| {
try extra_fds.append(opt);
},
.err => |e| {
return e.throwJS(globalThis);
},
}
break :brk fd;
} else {
break :brk @intCast(ipc_channel + 3);
}
};
const pipe_env = std.fmt.bufPrintZ(
&ipc_env_buf,
"NODE_CHANNEL_FD={d}",
.{ipc_fd},
) catch {
return globalThis.throwOutOfMemory();
};
env_array.appendAssumeCapacity(pipe_env);
env_array.appendAssumeCapacity(switch (ipc_mode) {
inline else => |t| "NODE_CHANNEL_SERIALIZATION_MODE=" ++ @tagName(t),
});
};
try env_array.append(allocator, null);
try argv.append(null);
if (comptime is_sync) {
for (&stdio, 0..) |*io, i| {
io.toSync(@truncate(i));
}
}
// If the whole thread is supposed to do absolutely nothing while waiting,
// we can block the thread which reduces CPU usage.
//
// That means:
// - No maximum buffer
// - No timeout
// - No abort signal
// - No stdin, stdout, stderr pipes
// - No extra fds
// - No auto killer (for tests)
// - No execution time limit (for tests)
// - No IPC
// - No inspector (since they might want to press pause or step)
const can_block_entire_thread_to_reduce_cpu_usage_in_fast_path = (comptime Environment.isPosix and is_sync) and
abort_signal == null and
timeout == null and
maxBuffer == null and
!stdio[0].isPiped() and
!stdio[1].isPiped() and
!stdio[2].isPiped() and
extra_fds.items.len == 0 and
!jsc_vm.auto_killer.enabled and
!jsc_vm.jsc_vm.hasExecutionTimeLimit() and
!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 sync_event_loop: ?*jsc.EventLoop = if (comptime is_sync) brk: {
const sync_loop = jsc_vm.rareData().spawnSyncEventLoop() catch {
return globalThis.throwOutOfMemory();
};
sync_loop.prepare(jsc_vm);
break :brk &sync_loop.event_loop;
} else null;
const loop_handle = if (sync_event_loop) |loop_ptr|
jsc.EventLoopHandle.init(loop_ptr)
else
jsc.EventLoopHandle.init(jsc_vm);
const spawn_options = bun.spawn.SpawnOptions{
.cwd = cwd,
.detached = detached,
.stdin = switch (stdio[0].asSpawnOption(0)) {
.result => |opt| opt,
.err => |e| return e.throwJS(globalThis),
},
.stdout = switch (stdio[1].asSpawnOption(1)) {
.result => |opt| opt,
.err => |e| return e.throwJS(globalThis),
},
.stderr = switch (stdio[2].asSpawnOption(2)) {
.result => |opt| opt,
.err => |e| return e.throwJS(globalThis),
},
.extra_fds = extra_fds.items,
.argv0 = argv0,
.can_block_entire_thread_to_reduce_cpu_usage_in_fast_path = can_block_entire_thread_to_reduce_cpu_usage_in_fast_path,
.windows = if (Environment.isWindows) .{
.hide_window = windows_hide,
.verbatim_arguments = windows_verbatim_arguments,
.loop = loop_handle,
},
};
var spawned = switch (bun.spawn.spawnProcess(
&spawn_options,
@ptrCast(argv.items.ptr),
@ptrCast(env_array.items.ptr),
) catch |err| switch (err) {
error.EMFILE, error.ENFILE => {
spawn_options.deinit();
const display_path: [:0]const u8 = if (argv.items.len > 0 and argv.items[0] != null)
std.mem.sliceTo(argv.items[0].?, 0)
else
"";
var systemerror = bun.sys.Error.fromCode(if (err == error.EMFILE) .MFILE else .NFILE, .posix_spawn).withPath(display_path).toSystemError();
systemerror.errno = if (err == error.EMFILE) -bun.sys.UV_E.MFILE else -bun.sys.UV_E.NFILE;
return globalThis.throwValue(systemerror.toErrorInstance(globalThis));
},
else => {
spawn_options.deinit();
return globalThis.throwError(err, ": failed to spawn process") catch return .zero;
},
}) {
.err => |err| {
spawn_options.deinit();
switch (err.getErrno()) {
.ACCES, .NOENT, .PERM, .ISDIR, .NOTDIR => |errno| {
const display_path: [:0]const u8 = if (argv.items.len > 0 and argv.items[0] != null)
std.mem.sliceTo(argv.items[0].?, 0)
else
"";
if (display_path.len > 0) {
var systemerror = err.withPath(display_path).toSystemError();
if (errno == .NOENT) systemerror.errno = -bun.sys.UV_E.NOENT;
return globalThis.throwValue(systemerror.toErrorInstance(globalThis));
}
},
else => {},
}
return globalThis.throwValue(err.toJS(globalThis));
},
.result => |result| result,
};
// Use the isolated loop for spawnSync operations
const process = spawned.toProcess(loop_handle, is_sync);
var subprocess = bun.new(Subprocess, .{
.ref_count = .init(),
.globalThis = globalThis,
.process = process,
.pid_rusage = null,
.stdin = .{ .ignore = {} },
.stdout = .{ .ignore = {} },
.stderr = .{ .ignore = {} },
.stdio_pipes = .{},
.ipc_data = null,
.flags = .{
.is_sync = is_sync,
},
.killSignal = undefined,
});
const posix_ipc_fd = if (Environment.isPosix and !is_sync and maybe_ipc_mode != null)
spawned.extra_pipes.items[@intCast(ipc_channel)]
else
bun.invalid_fd;
MaxBuf.createForSubprocess(subprocess, &subprocess.stderr_maxbuf, maxBuffer);
MaxBuf.createForSubprocess(subprocess, &subprocess.stdout_maxbuf, maxBuffer);
var promise_for_stream: jsc.JSValue = .zero;
// When run synchronously, subprocess isn't garbage collected
subprocess.* = Subprocess{
.globalThis = globalThis,
.process = process,
.pid_rusage = null,
.stdin = Writable.init(
&stdio[0],
sync_event_loop orelse jsc_vm.eventLoop(),
subprocess,
spawned.stdin,
&promise_for_stream,
) catch {
subprocess.deref();
return globalThis.throwOutOfMemory();
},
.stdout = Readable.init(
stdio[1],
sync_event_loop orelse jsc_vm.eventLoop(),
subprocess,
spawned.stdout,
jsc_vm.allocator,
subprocess.stdout_maxbuf,
is_sync,
),
.stderr = Readable.init(
stdio[2],
sync_event_loop orelse jsc_vm.eventLoop(),
subprocess,
spawned.stderr,
jsc_vm.allocator,
subprocess.stderr_maxbuf,
is_sync,
),
// 1. JavaScript.
// 2. Process.
.ref_count = .initExactRefs(2),
.stdio_pipes = spawned.extra_pipes.moveToUnmanaged(),
.ipc_data = if (!is_sync and comptime Environment.isWindows)
if (maybe_ipc_mode) |ipc_mode| ( //
.init(ipc_mode, .{ .subprocess = subprocess }, .uninitialized) //
) else null
else
null,
.flags = .{
.is_sync = is_sync,
},
.killSignal = killSignal,
.stderr_maxbuf = subprocess.stderr_maxbuf,
.stdout_maxbuf = subprocess.stdout_maxbuf,
};
subprocess.process.setExitHandler(subprocess);
promise_for_stream.ensureStillAlive();
subprocess.flags.is_stdin_a_readable_stream = promise_for_stream != .zero;
if (promise_for_stream != .zero and !globalThis.hasException()) {
if (promise_for_stream.toError()) |err| {
_ = globalThis.throwValue(err) catch {};
}
}
if (globalThis.hasException()) {
const err = globalThis.takeException(error.JSError);
// Ensure we kill the process so we don't leave things in an unexpected state.
_ = subprocess.tryKill(subprocess.killSignal);
if (globalThis.hasException()) {
return error.JSError;
}
return globalThis.throwValue(err);
}
var posix_ipc_info: if (Environment.isPosix) IPC.Socket else void = undefined;
if (Environment.isPosix and !is_sync) {
if (maybe_ipc_mode) |mode| {
if (uws.us_socket_t.fromFd(
jsc_vm.rareData().spawnIPCContext(jsc_vm),
@sizeOf(*IPC.SendQueue),
posix_ipc_fd.cast(),
1,
)) |socket| {
subprocess.ipc_data = .init(mode, .{ .subprocess = subprocess }, .uninitialized);
posix_ipc_info = IPC.Socket.from(socket);
}
}
}
if (subprocess.ipc_data) |*ipc_data| {
if (Environment.isPosix) {
if (posix_ipc_info.ext(*IPC.SendQueue)) |ctx| {
ctx.* = &subprocess.ipc_data.?;
subprocess.ipc_data.?.socket = .{ .open = posix_ipc_info };
}
} else {
if (ipc_data.windowsConfigureServer(
subprocess.stdio_pipes.items[@intCast(ipc_channel)].buffer,
).asErr()) |err| {
subprocess.deref();
return globalThis.throwValue(err.toJS(globalThis));
}
subprocess.stdio_pipes.items[@intCast(ipc_channel)] = .unavailable;
}
ipc_data.writeVersionPacket(globalThis);
}
if (subprocess.stdin == .pipe and promise_for_stream == .zero) {
subprocess.stdin.pipe.signal = jsc.WebCore.streams.Signal.init(&subprocess.stdin);
}
const out = if (comptime !is_sync)
subprocess.toJS(globalThis)
else
JSValue.zero;
if (out != .zero) {
subprocess.this_value.setWeak(out);
// Immediately upgrade to strong if there's pending activity to prevent premature GC
subprocess.updateHasPendingActivity();
}
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) {
bun.debugAssert(out != .zero);
if (on_exit_callback.isCell()) {
jsc.Codegen.JSSubprocess.onExitCallbackSetCached(out, globalThis, on_exit_callback);
}
if (on_disconnect_callback.isCell()) {
jsc.Codegen.JSSubprocess.onDisconnectCallbackSetCached(out, globalThis, on_disconnect_callback);
}
if (ipc_callback.isCell()) {
jsc.Codegen.JSSubprocess.ipcCallbackSetCached(out, globalThis, ipc_callback);
}
if (stdio[0] == .readable_stream) {
jsc.Codegen.JSSubprocess.stdinSetCached(out, globalThis, stdio[0].readable_stream.value);
}
switch (subprocess.process.watch()) {
.result => {},
.err => {
send_exit_notification = true;
lazy = false;
},
}
}
defer {
if (send_exit_notification) {
if (subprocess.process.hasExited()) {
// process has already exited, we called wait4(), but we did not call onProcessExit()
subprocess.process.onExit(subprocess.process.status, &std.mem.zeroes(Rusage));
} else {
// process has already exited, but we haven't called wait4() yet
// https://cs.github.com/libuv/libuv/blob/b00d1bd225b602570baee82a6152eaa823a84fa6/src/unix/process.c#L1007
subprocess.process.wait(is_sync);
}
}
}
if (subprocess.stdin == .buffer) {
if (subprocess.stdin.buffer.start().asErr()) |err| {
_ = subprocess.tryKill(subprocess.killSignal);
_ = globalThis.throwValue(err.toJS(globalThis)) catch {};
return error.JSError;
}
}
if (subprocess.stdout == .pipe) {
if (subprocess.stdout.pipe.start(subprocess, sync_event_loop orelse jsc_vm.eventLoop()).asErr()) |err| {
_ = subprocess.tryKill(subprocess.killSignal);
_ = globalThis.throwValue(err.toJS(globalThis)) catch {};
return error.JSError;
}
if ((is_sync or !lazy) and subprocess.stdout == .pipe) {
subprocess.stdout.pipe.readAll();
}
}
if (subprocess.stderr == .pipe) {
if (subprocess.stderr.pipe.start(subprocess, sync_event_loop orelse jsc_vm.eventLoop()).asErr()) |err| {
_ = subprocess.tryKill(subprocess.killSignal);
_ = globalThis.throwValue(err.toJS(globalThis)) catch {};
return error.JSError;
}
if ((is_sync or !lazy) and subprocess.stderr == .pipe) {
subprocess.stderr.pipe.readAll();
}
}
should_close_memfd = false;
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);
}
return out;
}
comptime bun.assert(is_sync);
if (can_block_entire_thread_to_reduce_cpu_usage_in_fast_path) {
jsc_vm.counters.mark(.spawnSync_blocking);
const debug_timer = Output.DebugTimer.start();
subprocess.process.wait(true);
log("spawnSync fast path took {}", .{debug_timer});
// watchOrReap will handle the already exited case for us.
}
switch (subprocess.process.watchOrReap()) {
.result => {
// 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;
}
},
.err => {
subprocess.process.wait(true);
},
}
if (!subprocess.process.hasExited()) {
jsc_vm.onSubprocessSpawn(subprocess.process);
}
// 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 sync_loop = jsc_vm.rareData().spawn_sync_event_loop.?;
const prev_event_loop = jsc_vm.event_loop;
defer sync_loop.cleanup(jsc_vm, prev_event_loop);
const timespec: bun.timespec = if (timeout) |timeout_ms| bun.timespec.msFromNow(timeout_ms) else undefined;
while (subprocess.computeHasPendingActivity()) {
if (subprocess.stdin == .buffer) {
subprocess.stdin.buffer.watch();
}
if (subprocess.stderr == .pipe) {
subprocess.stderr.pipe.watch();
}
if (subprocess.stdout == .pipe) {
subprocess.stdout.pipe.watch();
}
// Calculate remaining timeout
// Tick the isolated event loop with the calculated timeout
switch (sync_loop.tickWithTimeout(if (timeout != null) &timespec else null)) {
.timeout => {
_ = subprocess.tryKill(subprocess.killSignal);
},
.completed => {},
}
}
}
subprocess.updateHasPendingActivity();
const signalCode = subprocess.getSignalCode(globalThis);
const exitCode = subprocess.getExitCode(globalThis);
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 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()) {
sync_value.put(globalThis, jsc.ZigString.static("signalCode"), signalCode);
}
sync_value.put(globalThis, jsc.ZigString.static("stdout"), stdout);
sync_value.put(globalThis, jsc.ZigString.static("stderr"), stderr);
sync_value.put(globalThis, jsc.ZigString.static("success"), JSValue.jsBoolean(exitCode.isInt32() and exitCode.asInt32() == 0));
sync_value.put(globalThis, jsc.ZigString.static("resourceUsage"), resource_usage);
if (timeout != null) sync_value.put(globalThis, jsc.ZigString.static("exitedDueToTimeout"), if (exitedDueToTimeout) .true else .false);
if (maxBuffer != null) sync_value.put(globalThis, jsc.ZigString.static("exitedDueToMaxBuffer"), if (exitedDueToMaxBuffer != null) .true else .false);
sync_value.put(globalThis, jsc.ZigString.static("pid"), resultPid);
return sync_value;
}
fn throwCommandNotFound(globalThis: *jsc.JSGlobalObject, command: []const u8) bun.JSError {
const err = jsc.SystemError{
.message = bun.handleOom(bun.String.createFormat("Executable not found in $PATH: \"{s}\"", .{command})),
.code = bun.String.static("ENOENT"),
.errno = -bun.sys.UV_E.NOENT,
.path = bun.String.cloneUTF8(command),
};
return globalThis.throwValue(err.toErrorInstance(globalThis));
}
pub fn appendEnvpFromJS(globalThis: *jsc.JSGlobalObject, object: *jsc.JSObject, envp: *std.ArrayList(?[*:0]const u8), PATH: *[]const u8) bun.JSError!void {
var object_iter = try jsc.JSPropertyIterator(.{ .skip_empty_name = false, .include_value = true }).init(globalThis, object);
defer object_iter.deinit();
try envp.ensureTotalCapacityPrecise(object_iter.len +
// +1 incase there's IPC
// +1 for null terminator
2);
while (try object_iter.next()) |key| {
var value = object_iter.value;
if (value.isUndefined()) continue;
const line = try std.fmt.allocPrintZ(envp.allocator, "{}={}", .{ key, try value.getZigString(globalThis) });
if (key.eqlComptime("PATH")) {
PATH.* = bun.asByteSlice(line["PATH=".len..]);
}
try envp.append(line);
}
}
const log = Output.scoped(.Subprocess, .hidden);
extern "C" const BUN_DEFAULT_PATH_FOR_SPAWN: [*:0]const u8;
const IPC = @import("../../ipc.zig");
const std = @import("std");
const Allocator = std.mem.Allocator;
const bun = @import("bun");
const Environment = bun.Environment;
const Output = bun.Output;
const SignalCode = bun.SignalCode;
const default_allocator = bun.default_allocator;
const strings = bun.strings;
const uws = bun.uws;
const which = bun.which;
const windows = bun.windows;
const MaxBuf = bun.io.MaxBuf;
const jsc = bun.jsc;
const JSGlobalObject = jsc.JSGlobalObject;
const JSValue = jsc.JSValue;
const Subprocess = jsc.Subprocess;
const Readable = Subprocess.Readable;
const Writable = Subprocess.Writable;
const Process = bun.spawn.Process;
const Rusage = bun.spawn.Rusage;
const Stdio = bun.spawn.Stdio;

File diff suppressed because it is too large Load Diff

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)) {

View File

@@ -0,0 +1,206 @@
//! 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: if (bun.Environment.isWindows) void else ?*uws.Loop = if (bun.Environment.isWindows) {} else null,
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() !*SpawnSyncEventLoop {
const self = try bun.default_allocator.create(SpawnSyncEventLoop);
// Create a COMPLETELY SEPARATE uws.Loop for spawnSync
// This is critical - we cannot use the main loop as that would cause recursive execution
// This creates a new kqueue/epoll fd (POSIX) or new libuv loop (Windows)
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 = undefined, // Will be set when used
.virtual_machine = undefined, // Will be set when used
.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));
// Critically: Set jsc_vm to null to prevent JavaScript from running
self.uws_loop.internal_loop_data.jsc_vm = null;
return self;
}
fn onCloseUVTimer(timer: *bun.windows.libuv.Timer) callconv(.C) void {
bun.default_allocator.destroy(timer);
}
pub fn deinit(this: *SpawnSyncEventLoop) void {
// Clean up tasks queue
this.event_loop.tasks.deinit();
if (comptime bun.Environment.isWindows) {
if (this.uv_timer) |timer| {
timer.stop();
timer.unref();
libuv.uv_close(@alignCast(@ptrCast(&timer)), &onCloseUVTimer);
}
}
}
/// 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;
// CRITICAL: On POSIX, temporarily override the VM's event_loop_handle to point to our isolated loop
// This ensures that when code calls usocketsLoop(), it gets our isolated loop instead of the main one
// We'll restore this after spawnSync completes
if (comptime !bun.Environment.isWindows) {
// Store the original handle so we can restore it later
this.original_event_loop_handle = vm.event_loop_handle;
vm.event_loop_handle = this.uws_loop;
}
}
/// Restore the original event loop handle after spawnSync completes
pub fn cleanup(this: *SpawnSyncEventLoop, vm: *jsc.VirtualMachine, prev_event_loop: *jsc.EventLoop) void {
if (comptime !bun.Environment.isWindows) {
if (this.original_event_loop_handle) |orig| {
vm.event_loop_handle = orig;
}
}
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 timer: ?*bun.windows.libuv.Timer = @alignCast(@ptrCast(timer_));
const this: *SpawnSyncEventLoop = @fieldParentPtr("uv_timer", timer);
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;
}
/// 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 {
if (bun.Environment.isWindows) {
if (timeout) |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(timeout);
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 TimerHeap = @import("../api/Timer.zig").TimerHeap;
const libuv = bun.windows.libuv;

View File

@@ -42,6 +42,8 @@ valkey_context: ValkeyContext = .{},
tls_default_ciphers: ?[:0]const u8 = null,
spawn_sync_event_loop: ?*SpawnSyncEventLoop = null,
const PipeReadBuffer = [256 * 1024]u8;
const DIGESTED_HMAC_256_LEN = 32;
pub const AWSSignatureCache = struct {
@@ -537,6 +539,12 @@ pub fn deinit(this: *RareData) void {
bun.default_allocator.destroy(pipe);
}
if (this.spawn_sync_event_loop) |loop| {
this.spawn_sync_event_loop = null;
loop.deinit();
bun.default_allocator.destroy(loop);
}
this.aws_signature_cache.deinit();
this.s3_default_client.deinit();
@@ -569,6 +577,16 @@ pub fn websocketDeflate(this: *RareData) *WebSocketDeflate.RareData {
};
}
pub const SpawnSyncEventLoop = @import("./event_loop/SpawnSyncEventLoop.zig");
pub fn spawnSyncEventLoop(this: *RareData) !*SpawnSyncEventLoop {
return this.spawn_sync_event_loop orelse brk: {
const loop = try SpawnSyncEventLoop.init();
this.spawn_sync_event_loop = loop;
break :brk loop;
};
}
const IPC = @import("./ipc.zig");
const UUID = @import("./uuid.zig");
const WebSocketDeflate = @import("../http/websocket_client/WebSocketDeflate.zig");

View File

@@ -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(

View File

@@ -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 {

View File

@@ -1069,129 +1069,7 @@ pub fn parseDouble(input: []const u8) !f64 {
return jsc.wtf.parseDouble(input);
}
pub const SignalCode = enum(u8) {
SIGHUP = 1,
SIGINT = 2,
SIGQUIT = 3,
SIGILL = 4,
SIGTRAP = 5,
SIGABRT = 6,
SIGBUS = 7,
SIGFPE = 8,
SIGKILL = 9,
SIGUSR1 = 10,
SIGSEGV = 11,
SIGUSR2 = 12,
SIGPIPE = 13,
SIGALRM = 14,
SIGTERM = 15,
SIG16 = 16,
SIGCHLD = 17,
SIGCONT = 18,
SIGSTOP = 19,
SIGTSTP = 20,
SIGTTIN = 21,
SIGTTOU = 22,
SIGURG = 23,
SIGXCPU = 24,
SIGXFSZ = 25,
SIGVTALRM = 26,
SIGPROF = 27,
SIGWINCH = 28,
SIGIO = 29,
SIGPWR = 30,
SIGSYS = 31,
_,
// The `subprocess.kill()` method sends a signal to the child process. If no
// argument is given, the process will be sent the 'SIGTERM' signal.
pub const default = SignalCode.SIGTERM;
pub const Map = ComptimeEnumMap(SignalCode);
pub fn name(value: SignalCode) ?[]const u8 {
if (@intFromEnum(value) <= @intFromEnum(SignalCode.SIGSYS)) {
return asByteSlice(@tagName(value));
}
return null;
}
pub fn valid(value: SignalCode) bool {
return @intFromEnum(value) <= @intFromEnum(SignalCode.SIGSYS) and @intFromEnum(value) >= @intFromEnum(SignalCode.SIGHUP);
}
/// Shell scripts use exit codes 128 + signal number
/// https://tldp.org/LDP/abs/html/exitcodes.html
pub fn toExitCode(value: SignalCode) ?u8 {
return switch (@intFromEnum(value)) {
1...31 => 128 +% @intFromEnum(value),
else => null,
};
}
pub fn description(signal: SignalCode) ?[]const u8 {
// Description names copied from fish
// https://github.com/fish-shell/fish-shell/blob/00ffc397b493f67e28f18640d3de808af29b1434/fish-rust/src/signal.rs#L420
return switch (signal) {
.SIGHUP => "Terminal hung up",
.SIGINT => "Quit request",
.SIGQUIT => "Quit request",
.SIGILL => "Illegal instruction",
.SIGTRAP => "Trace or breakpoint trap",
.SIGABRT => "Abort",
.SIGBUS => "Misaligned address error",
.SIGFPE => "Floating point exception",
.SIGKILL => "Forced quit",
.SIGUSR1 => "User defined signal 1",
.SIGUSR2 => "User defined signal 2",
.SIGSEGV => "Address boundary error",
.SIGPIPE => "Broken pipe",
.SIGALRM => "Timer expired",
.SIGTERM => "Polite quit request",
.SIGCHLD => "Child process status changed",
.SIGCONT => "Continue previously stopped process",
.SIGSTOP => "Forced stop",
.SIGTSTP => "Stop request from job control (^Z)",
.SIGTTIN => "Stop from terminal input",
.SIGTTOU => "Stop from terminal output",
.SIGURG => "Urgent socket condition",
.SIGXCPU => "CPU time limit exceeded",
.SIGXFSZ => "File size limit exceeded",
.SIGVTALRM => "Virtual timefr expired",
.SIGPROF => "Profiling timer expired",
.SIGWINCH => "Window size change",
.SIGIO => "I/O on asynchronous file descriptor is possible",
.SIGSYS => "Bad system call",
.SIGPWR => "Power failure",
else => null,
};
}
pub fn from(value: anytype) SignalCode {
return @enumFromInt(std.mem.asBytes(&value)[0]);
}
// This wrapper struct is lame, what if bun's color formatter was more versatile
const Fmt = struct {
signal: SignalCode,
enable_ansi_colors: bool,
pub fn format(this: Fmt, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void {
const signal = this.signal;
switch (this.enable_ansi_colors) {
inline else => |enable_ansi_colors| {
if (signal.name()) |str| if (signal.description()) |desc| {
try writer.print(Output.prettyFmt("{s} <d>({s})<r>", enable_ansi_colors), .{ str, desc });
return;
};
try writer.print("code {d}", .{@intFromEnum(signal)});
},
}
}
};
pub fn fmt(signal: SignalCode, enable_ansi_colors: bool) Fmt {
return .{ .signal = signal, .enable_ansi_colors = enable_ansi_colors };
}
};
pub const SignalCode = @import("./SignalCode.zig").SignalCode;
pub fn isMissingIOUring() bool {
if (comptime !Environment.isLinux)

View File

@@ -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;
}
}
};

View File

@@ -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

View File

@@ -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| {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 };

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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!");
}
`,
});

View File

@@ -0,0 +1,162 @@
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: ["sleep", "0.05"],
env: process.env,
});
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);
});
});