diff --git a/.vscode/launch.json b/.vscode/launch.json index 157d1859f7..0e0c371680 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1118,7 +1118,11 @@ "request": "attach", "name": "rr", "trace": "Off", - "setupCommands": ["handle SIGPWR nostop noprint pass"], + "setupCommands": [ + "handle SIGPWR nostop noprint pass", + "source ${workspaceFolder}/misctools/gdb/std_gdb_pretty_printers.py", + "source ${workspaceFolder}/misctools/gdb/zig_gdb_pretty_printers.py", + ], }, ], "inputs": [ diff --git a/docs/api/spawn.md b/docs/api/spawn.md index 77bd57233b..d570dc09f2 100644 --- a/docs/api/spawn.md +++ b/docs/api/spawn.md @@ -253,6 +253,19 @@ const proc = Bun.spawn({ The `killSignal` option also controls which signal is sent when an AbortSignal is aborted. +## Using maxBuffer + +For spawnSync, you can limit the maximum number of bytes of output before the process is killed: + +```ts +// KIll 'yes' after it emits over 100 bytes of output +const result = Bun.spawnSync({ + cmd: ["yes"], // or ["bun", "exec", "yes"] on windows + maxBuffer: 100, +}); +// process exits +``` + ## Inter-process communication (IPC) Bun supports direct inter-process communication channel between two `bun` processes. To receive messages from a spawned Bun subprocess, specify an `ipc` handler. @@ -423,6 +436,7 @@ namespace SpawnOptions { signal?: AbortSignal; timeout?: number; killSignal?: string | number; + maxBuffer?: number; } type Readable = diff --git a/misctools/gdb/std_gdb_pretty_printers.py b/misctools/gdb/std_gdb_pretty_printers.py new file mode 100644 index 0000000000..a564de7c18 --- /dev/null +++ b/misctools/gdb/std_gdb_pretty_printers.py @@ -0,0 +1,142 @@ +# pretty printing for the standard library. +# put "source /path/to/stage2_gdb_pretty_printers.py" in ~/.gdbinit to load it automatically. +import re +import gdb.printing + +# Handles both ArrayList and ArrayListUnmanaged. +class ArrayListPrinter: + def __init__(self, val): + self.val = val + + def to_string(self): + type = self.val.type.name[len('std.array_list.'):] + type = re.sub(r'^ArrayListAligned(Unmanaged)?\((.*),null\)$', r'ArrayList\1(\2)', type) + return '%s of length %s, capacity %s' % (type, self.val['items']['len'], self.val['capacity']) + + def children(self): + for i in range(self.val['items']['len']): + item = self.val['items']['ptr'] + i + yield ('[%d]' % i, item.dereference()) + + def display_hint(self): + return 'array' + +class MultiArrayListPrinter: + def __init__(self, val): + self.val = val + + def child_type(self): + (helper_fn, _) = gdb.lookup_symbol('%s.dbHelper' % self.val.type.name) + return helper_fn.type.fields()[1].type.target() + + def to_string(self): + type = self.val.type.name[len('std.multi_array_list.'):] + return '%s of length %s, capacity %s' % (type, self.val['len'], self.val['capacity']) + + def slice(self): + fields = self.child_type().fields() + base = self.val['bytes'] + cap = self.val['capacity'] + len = self.val['len'] + + if len == 0: + return + + fields = sorted(fields, key=lambda field: field.type.alignof, reverse=True) + + for field in fields: + ptr = base.cast(field.type.pointer()).dereference().cast(field.type.array(len - 1)) + base += field.type.sizeof * cap + yield (field.name, ptr) + + def children(self): + for i, (name, ptr) in enumerate(self.slice()): + yield ('[%d]' % i, name) + yield ('[%d]' % i, ptr) + + def display_hint(self): + return 'map' + +# Handles both HashMap and HashMapUnmanaged. +class HashMapPrinter: + def __init__(self, val): + self.type = val.type + is_managed = re.search(r'^std\.hash_map\.HashMap\(', self.type.name) + self.val = val['unmanaged'] if is_managed else val + + def header_ptr_type(self): + (helper_fn, _) = gdb.lookup_symbol('%s.dbHelper' % self.val.type.name) + return helper_fn.type.fields()[1].type + + def header(self): + if self.val['metadata'] == 0: + return None + return (self.val['metadata'].cast(self.header_ptr_type()) - 1).dereference() + + def to_string(self): + type = self.type.name[len('std.hash_map.'):] + type = re.sub(r'^HashMap(Unmanaged)?\((.*),std.hash_map.AutoContext\(.*$', r'AutoHashMap\1(\2)', type) + hdr = self.header() + if hdr is not None: + cap = hdr['capacity'] + else: + cap = 0 + return '%s of length %s, capacity %s' % (type, self.val['size'], cap) + + def children(self): + hdr = self.header() + if hdr is None: + return + is_map = self.display_hint() == 'map' + for i in range(hdr['capacity']): + metadata = self.val['metadata'] + i + if metadata.dereference()['used'] == 1: + yield ('[%d]' % i, (hdr['keys'] + i).dereference()) + if is_map: + yield ('[%d]' % i, (hdr['values'] + i).dereference()) + + def display_hint(self): + for field in self.header_ptr_type().target().fields(): + if field.name == 'values': + return 'map' + return 'array' + +# Handles both ArrayHashMap and ArrayHashMapUnmanaged. +class ArrayHashMapPrinter: + def __init__(self, val): + self.type = val.type + is_managed = re.search(r'^std\.array_hash_map\.ArrayHashMap\(', self.type.name) + self.val = val['unmanaged'] if is_managed else val + + def to_string(self): + type = self.type.name[len('std.array_hash_map.'):] + type = re.sub(r'^ArrayHashMap(Unmanaged)?\((.*),std.array_hash_map.AutoContext\(.*$', r'AutoArrayHashMap\1(\2)', type) + return '%s of length %s' % (type, self.val['entries']['len']) + + def children(self): + entries = MultiArrayListPrinter(self.val['entries']) + len = self.val['entries']['len'] + fields = {} + for name, ptr in entries.slice(): + fields[str(name)] = ptr + + for i in range(len): + if 'key' in fields: + yield ('[%d]' % i, fields['key'][i]) + else: + yield ('[%d]' % i, '{}') + if 'value' in fields: + yield ('[%d]' % i, fields['value'][i]) + + def display_hint(self): + for name, ptr in MultiArrayListPrinter(self.val['entries']).slice(): + if name == 'value': + return 'map' + return 'array' + +pp = gdb.printing.RegexpCollectionPrettyPrinter('Zig standard library') +pp.add_printer('ArrayList', r'^std\.array_list\.ArrayListAligned(Unmanaged)?\(.*\)$', ArrayListPrinter) +pp.add_printer('MultiArrayList', r'^std\.multi_array_list\.MultiArrayList\(.*\)$', MultiArrayListPrinter) +pp.add_printer('HashMap', r'^std\.hash_map\.HashMap(Unmanaged)?\(.*\)$', HashMapPrinter) +pp.add_printer('ArrayHashMap', r'^std\.array_hash_map\.ArrayHashMap(Unmanaged)?\(.*\)$', ArrayHashMapPrinter) +gdb.printing.register_pretty_printer(gdb.current_objfile(), pp) diff --git a/misctools/gdb/zig_gdb_pretty_printers.py b/misctools/gdb/zig_gdb_pretty_printers.py new file mode 100644 index 0000000000..1bc4b36e57 --- /dev/null +++ b/misctools/gdb/zig_gdb_pretty_printers.py @@ -0,0 +1,63 @@ +# pretty printing for the language. +# put "source /path/to/zig_gdb_pretty_printers.py" in ~/.gdbinit to load it automatically. +import gdb.printing + + +class ZigPrettyPrinter(gdb.printing.PrettyPrinter): + def __init__(self): + super().__init__('Zig') + + def __call__(self, val): + tag = val.type.tag + if tag is None: + return None + if tag == '[]u8': + return StringPrinter(val) + if tag.startswith('[]'): + return SlicePrinter(val) + if tag.startswith('?'): + return OptionalPrinter(val) + return None + + +class SlicePrinter: + def __init__(self, val): + self.val = val + + def to_string(self): + return f"{self.val['len']} items at {self.val['ptr']}" + + def children(self): + def it(val): + for i in range(int(val['len'])): + item = val['ptr'] + i + yield (f'[{i}]', item.dereference()) + return it(self.val) + + def display_hint(self): + return 'array' + + +class StringPrinter: + def __init__(self, val): + self.val = val + + def to_string(self): + return self.val['ptr'].string(length=int(self.val['len'])) + + def display_hint(self): + return 'string' + + +class OptionalPrinter: + def __init__(self, val): + self.val = val + + def to_string(self): + if self.val['some']: + return self.val['data'] + else: + return 'null' + + +gdb.printing.register_pretty_printer(gdb.current_objfile(), ZigPrettyPrinter()) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 4f5b8fb499..34df19fc4c 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -6587,7 +6587,8 @@ declare module "bun" { timeout?: number; /** - * The signal to use when killing the process after a timeout or when the AbortSignal is aborted. + * The signal to use when killing the process after a timeout, when the AbortSignal is aborted, + * or when the process goes over the `maxBuffer` limit. * * @default "SIGTERM" (signal 15) * @@ -6602,6 +6603,14 @@ declare module "bun" { * ``` */ killSignal?: string | number; + + /** + * The maximum number of bytes the process may output. If the process goes over this limit, + * it is killed with signal `killSignal` (defaults to SIGTERM). + * + * @default undefined (no limit) + */ + maxBuffer?: number; } type OptionsToSubprocess = @@ -6847,7 +6856,8 @@ declare module "bun" { resourceUsage: ResourceUsage; signalCode?: string; - exitedDueToTimeout?: true; + exitedDueToTimeout?: boolean; + exitedDueToMaxBuffer?: boolean; pid: number; } diff --git a/src/bun.js/MaxBuf.zig b/src/bun.js/MaxBuf.zig new file mode 100644 index 0000000000..df98addef8 --- /dev/null +++ b/src/bun.js/MaxBuf.zig @@ -0,0 +1,84 @@ +const Subprocess = @import("api/bun/subprocess.zig"); +const MaxBuf = @This(); +const bun = @import("root").bun; +const std = @import("std"); + +pub const Kind = enum { + stdout, + stderr, +}; +// null after subprocess finalize +owned_by_subprocess: ?*Subprocess, +// null after pipereader finalize +owned_by_reader: bool, +// if this goes negative, onMaxBuffer is called on the subprocess +remaining_bytes: i64, +// (once both are null, it is freed) + +pub fn createForSubprocess(owner: *Subprocess, ptr: *?*MaxBuf, initial: ?i64) void { + if (initial == null) { + ptr.* = null; + return; + } + const maxbuf = bun.default_allocator.create(MaxBuf) catch bun.outOfMemory(); + maxbuf.* = .{ + .owned_by_subprocess = owner, + .owned_by_reader = false, + .remaining_bytes = initial.?, + }; + ptr.* = maxbuf; +} +fn disowned(this: *MaxBuf) bool { + return this.owned_by_subprocess != null and this.owned_by_reader == false; +} +fn destroy(this: *MaxBuf) void { + bun.assert(this.disowned()); + bun.default_allocator.destroy(this); +} +pub fn removeFromSubprocess(ptr: *?*MaxBuf) void { + if (ptr.* == null) return; + const this = ptr.*.?; + bun.assert(this.owned_by_subprocess != null); + this.owned_by_subprocess = null; + ptr.* = null; + if (this.disowned()) { + this.destroy(); + } +} +pub fn addToPipereader(value: ?*MaxBuf, ptr: *?*MaxBuf) void { + if (value == null) return; + bun.assert(ptr.* == null); + ptr.* = value; + bun.assert(!value.?.owned_by_reader); + value.?.owned_by_reader = true; +} +pub fn removeFromPipereader(ptr: *?*MaxBuf) void { + if (ptr.* == null) return; + const this = ptr.*.?; + bun.assert(this.owned_by_reader); + this.owned_by_reader = false; + ptr.* = null; + if (this.disowned()) { + this.destroy(); + } +} +pub fn transferToPipereader(prev: *?*MaxBuf, next: *?*MaxBuf) void { + if (prev.* == null) return; + next.* = prev.*; + prev.* = null; +} +pub fn onReadBytes(this: *MaxBuf, bytes: u64) void { + this.remaining_bytes = std.math.sub(i64, this.remaining_bytes, std.math.cast(i64, bytes) orelse 0) catch -1; + if (this.remaining_bytes < 0 and this.owned_by_subprocess != null) { + const owned_by = this.owned_by_subprocess.?; + if (owned_by.stderr_maxbuf == this) { + MaxBuf.removeFromSubprocess(&owned_by.stderr_maxbuf); + owned_by.onMaxBuffer(.stderr); + } else if (owned_by.stdout_maxbuf == this) { + MaxBuf.removeFromSubprocess(&owned_by.stdout_maxbuf); + owned_by.onMaxBuffer(.stdout); + } else { + bun.assert(false); + } + } +} diff --git a/src/bun.js/api/bun/spawn/stdio.zig b/src/bun.js/api/bun/spawn/stdio.zig index 4d7a2c2bc4..61697c94f7 100644 --- a/src/bun.js/api/bun/spawn/stdio.zig +++ b/src/bun.js/api/bun/spawn/stdio.zig @@ -87,7 +87,7 @@ pub const Stdio = union(enum) { } } - pub fn canUseMemfd(this: *const @This(), is_sync: bool) bool { + pub fn canUseMemfd(this: *const @This(), is_sync: bool, has_max_buffer: bool) bool { if (comptime !Environment.isLinux) { return false; } @@ -95,7 +95,7 @@ pub const Stdio = union(enum) { return switch (this.*) { .blob => !this.blob.needsToReadFile(), .memfd, .array_buffer => true, - .pipe => is_sync, + .pipe => is_sync and !has_max_buffer, else => false, }; } diff --git a/src/bun.js/api/bun/subprocess.zig b/src/bun.js/api/bun/subprocess.zig index a084a1c5bd..fe289ed558 100644 --- a/src/bun.js/api/bun/subprocess.zig +++ b/src/bun.js/api/bun/subprocess.zig @@ -1,6 +1,7 @@ //! The Subprocess object is returned by `Bun.spawn`. This file also holds the //! code for `Bun.spawnSync` const Subprocess = @This(); +const MaxBuf = @import("../../MaxBuf.zig"); pub usingnamespace JSC.Codegen.JSSubprocess; pub usingnamespace bun.NewRefCounted(@This(), deinit, null); @@ -40,6 +41,10 @@ event_loop_timer: JSC.API.Bun.Timer.EventLoopTimer = .{ }, killSignal: SignalCode, +stdout_maxbuf: ?*MaxBuf = null, +stderr_maxbuf: ?*MaxBuf = null, +exited_due_to_maxbuf: ?MaxBuf.Kind = null, + pub const Flags = packed struct { is_sync: bool = false, killed: bool = false, @@ -177,7 +182,6 @@ pub fn appendEnvpFromJS(globalThis: *JSC.JSGlobalObject, object: *JSC.JSObject, } const log = Output.scoped(.Subprocess, false); -const default_max_buffer_size = 1024 * 1024 * 4; pub const StdioKind = enum { stdin, stdout, @@ -412,24 +416,11 @@ const Readable = union(enum) { } } - pub fn init(stdio: Stdio, event_loop: *JSC.EventLoop, process: *Subprocess, result: StdioResult, allocator: std.mem.Allocator, max_size: u32, is_sync: bool) Readable { + pub fn init(stdio: Stdio, event_loop: *JSC.EventLoop, process: *Subprocess, result: StdioResult, allocator: std.mem.Allocator, max_size: ?*MaxBuf, is_sync: bool) Readable { _ = allocator; // autofix - _ = max_size; // autofix _ = is_sync; // autofix assertStdioResult(result); - if (Environment.isWindows) { - return switch (stdio) { - .inherit => Readable{ .inherit = {} }, - .ignore, .ipc, .path, .memfd => Readable{ .ignore = {} }, - .fd => |fd| Readable{ .fd = fd }, - .dup2 => |dup2| Readable{ .fd = dup2.out.toFd() }, - .pipe => Readable{ .pipe = PipeReader.create(event_loop, process, result) }, - .array_buffer, .blob => Output.panic("TODO: implement ArrayBuffer & Blob support in Stdio readable", .{}), - .capture => Output.panic("TODO: implement capture support in Stdio readable", .{}), - }; - } - if (comptime Environment.isPosix) { if (stdio == .pipe) { _ = bun.sys.setNonblocking(result.?); @@ -439,12 +430,12 @@ const Readable = union(enum) { return switch (stdio) { .inherit => Readable{ .inherit = {} }, .ignore, .ipc, .path => Readable{ .ignore = {} }, - .fd => Readable{ .fd = result.? }, - .memfd => Readable{ .memfd = stdio.memfd }, - .pipe => Readable{ .pipe = PipeReader.create(event_loop, process, result) }, + .fd => |fd| if (Environment.isPosix) Readable{ .fd = result.? } else Readable{ .fd = fd }, + .memfd => if (Environment.isPosix) Readable{ .memfd = stdio.memfd } else Readable{ .ignore = {} }, + .dup2 => |dup2| if (Environment.isPosix) Output.panic("TODO: implement dup2 support in Stdio readable", .{}) else Readable{ .fd = dup2.out.toFd() }, + .pipe => Readable{ .pipe = PipeReader.create(event_loop, process, result, max_size) }, .array_buffer, .blob => Output.panic("TODO: implement ArrayBuffer & Blob support in Stdio readable", .{}), .capture => Output.panic("TODO: implement capture support in Stdio readable", .{}), - .dup2 => Output.panic("TODO: implement dup2 support in Stdio readable", .{}), }; } @@ -637,6 +628,11 @@ pub fn timeoutCallback(this: *Subprocess) JSC.API.Bun.Timer.EventLoopTimer.Arm { return .disarm; } +pub fn onMaxBuffer(this: *Subprocess, kind: MaxBuf.Kind) void { + this.exited_due_to_maxbuf = kind; + _ = this.tryKill(this.killSignal); +} + fn parseSignal(arg: JSC.JSValue, globalThis: *JSC.JSGlobalObject) !SignalCode { if (arg.getNumber()) |sig64| { // Node does this: @@ -1039,7 +1035,6 @@ pub const PipeReader = struct { err: bun.sys.Error, } = .{ .pending = {} }, stdio_result: StdioResult, - pub const IOReader = bun.io.BufferedReader; pub const Poll = IOReader; @@ -1061,13 +1056,14 @@ pub const PipeReader = struct { this.deref(); } - pub fn create(event_loop: *JSC.EventLoop, process: *Subprocess, result: StdioResult) *PipeReader { + pub fn create(event_loop: *JSC.EventLoop, process: *Subprocess, result: StdioResult, limit: ?*MaxBuf) *PipeReader { var this = PipeReader.new(.{ .process = process, .reader = IOReader.init(@This()), .event_loop = event_loop, .stdio_result = result, }); + MaxBuf.addToPipereader(limit, &this.reader.maxbuf); if (Environment.isWindows) { this.reader.source = .{ .pipe = this.stdio_result.buffer }; } @@ -1754,6 +1750,9 @@ pub fn finalize(this: *Subprocess) callconv(.C) void { } this.setEventLoopTimerRefd(false); + MaxBuf.removeFromSubprocess(&this.stdout_maxbuf); + MaxBuf.removeFromSubprocess(&this.stderr_maxbuf); + this.flags.finalized = true; this.deref(); } @@ -1947,6 +1946,7 @@ pub fn spawnMaybeSync( 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; @@ -2139,7 +2139,7 @@ pub fn spawnMaybeSync( } if (try args.get(globalThis, "timeout")) |val| { - if (val.isNumber()) { + if (val.isNumber() and val.isFinite()) { timeout = @max(val.coerce(i32, globalThis), 1); } } @@ -2147,18 +2147,26 @@ pub fn spawnMaybeSync( if (try args.get(globalThis, "killSignal")) |val| { killSignal = try parseSignal(val, globalThis); } + + if (try args.get(globalThis, "maxBuffer")) |val| { + if (val.isNumber() and val.isFinite()) { // 'Infinity' does not set maxBuffer + maxBuffer = val.coerce(i64, globalThis); + } + } } 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)) { + if (stdio[fd_index].canUseMemfd(is_sync, fd_index > 0 and maxBuffer != null)) { stdio[fd_index].useMemfd(fd_index); } } @@ -2310,6 +2318,9 @@ pub fn spawnMaybeSync( else bun.invalid_fd; + MaxBuf.createForSubprocess(subprocess, &subprocess.stderr_maxbuf, maxBuffer); + MaxBuf.createForSubprocess(subprocess, &subprocess.stdout_maxbuf, maxBuffer); + // When run synchronously, subprocess isn't garbage collected subprocess.* = Subprocess{ .globalThis = globalThis, @@ -2330,7 +2341,7 @@ pub fn spawnMaybeSync( subprocess, spawned.stdout, jsc_vm.allocator, - default_max_buffer_size, + subprocess.stdout_maxbuf, is_sync, ), .stderr = Readable.init( @@ -2339,7 +2350,7 @@ pub fn spawnMaybeSync( subprocess, spawned.stderr, jsc_vm.allocator, - default_max_buffer_size, + subprocess.stderr_maxbuf, is_sync, ), // 1. JavaScript. @@ -2357,6 +2368,8 @@ pub fn spawnMaybeSync( .is_sync = is_sync, }, .killSignal = killSignal, + .stderr_maxbuf = subprocess.stderr_maxbuf, + .stdout_maxbuf = subprocess.stdout_maxbuf, }; subprocess.process.setExitHandler(subprocess); @@ -2544,6 +2557,7 @@ pub fn spawnMaybeSync( const stderr = try subprocess.stderr.toBufferedValue(globalThis); const resource_usage: JSValue = if (!globalThis.hasException()) 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(); @@ -2561,7 +2575,8 @@ pub fn spawnMaybeSync( 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 (exitedDueToTimeout) sync_value.put(globalThis, JSC.ZigString.static("exitedDueToTimeout"), JSC.JSValue.true); + if (timeout != null) sync_value.put(globalThis, JSC.ZigString.static("exitedDueToTimeout"), if (exitedDueToTimeout) JSC.JSValue.true else JSC.JSValue.false); + if (maxBuffer != null) sync_value.put(globalThis, JSC.ZigString.static("exitedDueToMaxBuffer"), if (exitedDueToMaxBuffer != null) JSC.JSValue.true else JSC.JSValue.false); sync_value.put(globalThis, JSC.ZigString.static("pid"), resultPid); return sync_value; diff --git a/src/bun.js/bindings/ErrorCode.cpp b/src/bun.js/bindings/ErrorCode.cpp index b72aab1b27..15777ecbac 100644 --- a/src/bun.js/bindings/ErrorCode.cpp +++ b/src/bun.js/bindings/ErrorCode.cpp @@ -2001,6 +2001,14 @@ JSC_DEFINE_HOST_FUNCTION(Bun::jsFunctionMakeErrorWithCode, (JSC::JSGlobalObject return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_AMBIGUOUS_ARGUMENT, message)); } + case Bun::ErrorCode::ERR_CHILD_PROCESS_STDIO_MAXBUFFER: { + auto arg0 = callFrame->argument(1); + auto str0 = arg0.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + auto message = makeString(str0, " maxBuffer length exceeded"_s); + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_CHILD_PROCESS_STDIO_MAXBUFFER, message)); + } + case ErrorCode::ERR_IPC_DISCONNECTED: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_IPC_DISCONNECTED, "IPC channel is already disconnected"_s)); case ErrorCode::ERR_SERVER_NOT_RUNNING: diff --git a/src/bun.js/bindings/ErrorCode.ts b/src/bun.js/bindings/ErrorCode.ts index 1fd1f052ab..20443969cf 100644 --- a/src/bun.js/bindings/ErrorCode.ts +++ b/src/bun.js/bindings/ErrorCode.ts @@ -26,6 +26,7 @@ const errors: ErrorCodeMapping = [ ["ERR_BUFFER_OUT_OF_BOUNDS", RangeError], ["ERR_BUFFER_TOO_LARGE", RangeError], ["ERR_CHILD_PROCESS_IPC_REQUIRED", Error], + ["ERR_CHILD_PROCESS_STDIO_MAXBUFFER", RangeError], ["ERR_CLOSED_MESSAGE_PORT", Error], ["ERR_CONSOLE_WRITABLE_STREAM", TypeError, "TypeError"], ["ERR_CONSTRUCT_CALL_INVALID", TypeError], diff --git a/src/bun.js/node/node_util_binding.zig b/src/bun.js/node/node_util_binding.zig index 821eb28ed7..71b0ccf5fc 100644 --- a/src/bun.js/node/node_util_binding.zig +++ b/src/bun.js/node/node_util_binding.zig @@ -113,6 +113,10 @@ pub fn etimedoutErrorCode(_: *JSC.JSGlobalObject, _: *JSC.CallFrame) bun.JSError return JSC.JSValue.jsNumberFromInt32(-bun.C.UV_ETIMEDOUT); } +pub fn enobufsErrorCode(_: *JSC.JSGlobalObject, _: *JSC.CallFrame) bun.JSError!JSC.JSValue { + return JSC.JSValue.jsNumberFromInt32(-bun.C.UV_ENOBUFS); +} + /// `extractedSplitNewLines` for ASCII/Latin1 strings. Panics if passed a non-string. /// Returns `undefined` if param is utf8 or utf16 and not fully ascii. /// diff --git a/src/io/PipeReader.zig b/src/io/PipeReader.zig index b9121328a3..d65e489bd0 100644 --- a/src/io/PipeReader.zig +++ b/src/io/PipeReader.zig @@ -5,6 +5,7 @@ const Source = @import("./source.zig").Source; const ReadState = @import("./pipes.zig").ReadState; const FileType = @import("./pipes.zig").FileType; +const MaxBuf = @import("../bun.js/MaxBuf.zig"); const PollOrFd = @import("./pipes.zig").PollOrFd; @@ -92,6 +93,8 @@ const PosixBufferedReader = struct { _offset: usize = 0, vtable: BufferedReaderVTable, flags: Flags = .{}, + count: usize = 0, + maxbuf: ?*MaxBuf = null, const Flags = packed struct { is_done: bool = false, @@ -139,6 +142,7 @@ const PosixBufferedReader = struct { other.flags.is_done = true; other.handle = .{ .closed = {} }; other._offset = 0; + MaxBuf.transferToPipereader(&other.maxbuf, &to.maxbuf); to.handle.setOwner(to); // note: the caller is supposed to drain the buffer themselves @@ -184,14 +188,6 @@ const PosixBufferedReader = struct { } } - fn _onReadChunk(this: *PosixBufferedReader, chunk: []u8, hasMore: ReadState) bool { - if (hasMore == .eof) { - this.flags.received_eof = true; - } - - return this.vtable.onReadChunk(chunk, hasMore); - } - pub fn getFd(this: *PosixBufferedReader) bun.FileDescriptor { return this.handle.getFd(); } @@ -266,6 +262,7 @@ const PosixBufferedReader = struct { } pub fn deinit(this: *PosixBufferedReader) void { + MaxBuf.removeFromPipereader(&this.maxbuf); this.buffer().clearAndFree(); this.closeWithoutReporting(); } @@ -468,6 +465,7 @@ const PosixBufferedReader = struct { parent._offset, )) { .result => |bytes_read| { + if (parent.maxbuf) |l| l.onReadBytes(bytes_read); parent._offset += bytes_read; buf = stack_buffer_head[0..bytes_read]; stack_buffer_head = stack_buffer_head[bytes_read..]; @@ -560,6 +558,7 @@ const PosixBufferedReader = struct { switch (sys_fn(fd, buf, parent._offset)) { .result => |bytes_read| { + if (parent.maxbuf) |l| l.onReadBytes(bytes_read); parent._offset += bytes_read; buf = buf[0..bytes_read]; resizable_buffer.items.len += bytes_read; @@ -678,6 +677,7 @@ pub const WindowsBufferedReader = struct { _buffer: std.ArrayList(u8) = std.ArrayList(u8).init(bun.default_allocator), // for compatibility with Linux flags: Flags = .{}, + maxbuf: ?*MaxBuf = null, parent: *anyopaque = undefined, vtable: WindowsOutputReaderVTable = undefined, @@ -741,6 +741,7 @@ pub const WindowsBufferedReader = struct { other._offset = 0; other.buffer().* = std.ArrayList(u8).init(bun.default_allocator); other.source = null; + MaxBuf.transferToPipereader(&other.maxbuf, &to.maxbuf); to.setParent(parent); } @@ -802,6 +803,7 @@ pub const WindowsBufferedReader = struct { } fn _onReadChunk(this: *WindowsOutputReader, buf: []u8, hasMore: ReadState) bool { + if (this.maxbuf) |m| m.onReadBytes(buf.len); this.flags.has_inflight_read = false; if (hasMore == .eof) { this.flags.received_eof = true; @@ -867,6 +869,7 @@ pub const WindowsBufferedReader = struct { } pub fn deinit(this: *WindowsOutputReader) void { + MaxBuf.removeFromPipereader(&this.maxbuf); this.buffer().deinit(); const source = this.source orelse return; if (!source.isClosed()) { diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index d3a34cffec..b439bfe596 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -488,7 +488,7 @@ declare function $createCommonJSModule( ): JSCommonJSModule; declare function $evaluateCommonJSModule( moduleToEvaluate: JSCommonJSModule, - sourceModule: JSCommonJSModule + sourceModule: JSCommonJSModule, ): JSCommonJSModule[]; declare function $overridableRequire(this: JSCommonJSModule, id: string): any; @@ -644,6 +644,7 @@ declare function $ERR_BUFFER_OUT_OF_BOUNDS(name?: string): RangeError; declare function $ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE(value, expected): TypeError; declare function $ERR_CRYPTO_INCOMPATIBLE_KEY(name, value): Error; declare function $ERR_CHILD_PROCESS_IPC_REQUIRED(where): Error; +declare function $ERR_CHILD_PROCESS_STDIO_MAXBUFFER(message): Error; declare function $ERR_INVALID_ASYNC_ID(name, value): RangeError; declare function $ERR_ASYNC_TYPE(name): TypeError; declare function $ERR_ASYNC_CALLBACK(name): TypeError; diff --git a/src/js/internal/cluster/child.ts b/src/js/internal/cluster/child.ts index 7a73fed8c2..ca30515ba8 100644 --- a/src/js/internal/cluster/child.ts +++ b/src/js/internal/cluster/child.ts @@ -38,6 +38,7 @@ cluster._setupWorker = function () { // before calling, check if the channel is refd. if it isn't, then unref it after calling process.once(); $newZigFunction("node_cluster_binding.zig", "channelIgnoreOneDisconnectEventListener", 0)(); process.once("disconnect", () => { + process.channel = null; worker.emit("disconnect"); if (!worker.exitedAfterDisconnect) { diff --git a/src/js/internal/cluster/primary.ts b/src/js/internal/cluster/primary.ts index 315d13e0a6..49a35354c5 100644 --- a/src/js/internal/cluster/primary.ts +++ b/src/js/internal/cluster/primary.ts @@ -153,6 +153,7 @@ cluster.fork = function (env) { }); worker.process.once("disconnect", () => { + worker.process.channel = null; /* * Now is a good time to remove the handles * associated with this worker because it is diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 8bfba5bc1e..a35a5bfc42 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -241,6 +241,7 @@ function execFile(file, args, options, callback) { windowsVerbatimArguments: options.windowsVerbatimArguments, shell: options.shell, signal: options.signal, + maxBuffer: options.maxBuffer, }); let encoding; @@ -270,16 +271,15 @@ function execFile(file, args, options, callback) { if (!callback) return; - const readableEncoding = child?.stdout?.readableEncoding; // merge chunks let stdout; let stderr; - if (encoding || (child.stdout && readableEncoding)) { + if (child.stdout?.readableEncoding) { stdout = ArrayPrototypeJoin.$call(_stdout, ""); } else { stdout = BufferConcat(_stdout); } - if (encoding || (child.stderr && readableEncoding)) { + if (child.stderr?.readableEncoding) { stderr = ArrayPrototypeJoin.$call(_stderr, ""); } else { stderr = BufferConcat(_stderr); @@ -339,84 +339,50 @@ function execFile(file, args, options, callback) { }, options.timeout).unref(); } - const onData = (array, kind) => { - let total = 0; - let encodedLength; - return encoding - ? function onDataEncoded(chunk) { - total += chunk.length; + function addOnDataListener(child_buffer, _buffer, kind) { + if (encoding) child_buffer.setEncoding(encoding); - if (total > maxBuffer) { - const out = child[kind]; - const encoding = out.readableEncoding; - const actualLen = Buffer.byteLength(chunk, encoding); - if (encodedLength === undefined) { - encodedLength = 0; + let totalLen = 0; + if (maxBuffer === Infinity) { + child_buffer.on("data", function onDataNoMaxBuf(chunk) { + $arrayPush(_buffer, chunk); + }); + return; + } + child_buffer.on("data", function onData(chunk) { + const encoding = child_buffer.readableEncoding; + if (encoding) { + const length = Buffer.byteLength(chunk, encoding); + totalLen += length; - for (let i = 0, length = array.length; i < length; i++) { - encodedLength += Buffer.byteLength(array[i], encoding); - } - } + if (totalLen > maxBuffer) { + const truncatedLen = maxBuffer - (totalLen - length); + $arrayPush(_buffer, String.prototype.slice.$call(chunk, 0, truncatedLen)); - encodedLength += actualLen; - - if (encodedLength > maxBuffer) { - const joined = ArrayPrototypeJoin.$call(array, ""); - let combined = joined + chunk; - combined = StringPrototypeSlice.$call(combined, 0, maxBuffer); - array.length = 1; - array[0] = combined; - ex = ERR_CHILD_PROCESS_STDIO_MAXBUFFER(kind); - kill(); - } else { - const val = ArrayPrototypeJoin.$call(array, "") + chunk; - array.length = 1; - array[0] = val; - } - } else { - $arrayPush(array, chunk); - } + ex = $ERR_CHILD_PROCESS_STDIO_MAXBUFFER(kind); + kill(); + } else { + $arrayPush(_buffer, chunk); } - : function onDataRaw(chunk) { - total += chunk.length; + } else { + const length = chunk.length; + totalLen += length; - if (total > maxBuffer) { - const truncatedLen = maxBuffer - (total - chunk.length); - $arrayPush(array, chunk.slice(0, truncatedLen)); + if (totalLen > maxBuffer) { + const truncatedLen = maxBuffer - (totalLen - length); + $arrayPush(_buffer, chunk.slice(0, truncatedLen)); - ex = ERR_CHILD_PROCESS_STDIO_MAXBUFFER(kind); - kill(); - } else { - $arrayPush(array, chunk); - } - }; - }; - - if (child.stdout) { - if (encoding) child.stdout.setEncoding(encoding); - - child.stdout.on( - "data", - maxBuffer === Infinity - ? function onUnlimitedSizeBufferedData(chunk) { - $arrayPush(_stdout, chunk); - } - : onData(_stdout, "stdout"), - ); + ex = $ERR_CHILD_PROCESS_STDIO_MAXBUFFER(kind); + kill(); + } else { + $arrayPush(_buffer, chunk); + } + } + }); } - if (child.stderr) { - if (encoding) child.stderr.setEncoding(encoding); - - child.stderr.on( - "data", - maxBuffer === Infinity - ? function onUnlimitedSizeBufferedData(chunk) { - $arrayPush(_stderr, chunk); - } - : onData(_stderr, "stderr"), - ); - } + if (child.stdout) addOnDataListener(child.stdout, _stdout, "stdout"); + if (child.stderr) addOnDataListener(child.stderr, _stderr, "stderr"); child.addListener("close", exitHandler); child.addListener("error", errorHandler); @@ -563,6 +529,7 @@ function spawnSync(file, args, options) { exitCode, signalCode, exitedDueToTimeout, + exitedDueToMaxBuffer, pid, } = Bun.spawnSync({ // normalizeSpawnargs has already prepended argv0 to the spawnargs array @@ -577,6 +544,7 @@ function spawnSync(file, args, options) { argv0: options.args[0], timeout: options.timeout, killSignal: options.killSignal, + maxBuffer: options.maxBuffer, }); } catch (err) { error = err; @@ -616,6 +584,15 @@ function spawnSync(file, args, options) { "ETIMEDOUT", ); } + if (exitedDueToMaxBuffer && error == null) { + result.error = new SystemError( + "spawnSync " + options.file + " ENOBUFS (stdout or stderr buffer reached maxBuffer size limit)", + options.file, + "spawnSync " + options.file, + enobufsErrorCode(), + "ENOBUFS", + ); + } if (result.error) { result.error.syscall = "spawnSync " + options.file; @@ -625,6 +602,7 @@ function spawnSync(file, args, options) { return result; } const etimedoutErrorCode = $newZigFunction("node_util_binding.zig", "etimedoutErrorCode", 0); +const enobufsErrorCode = $newZigFunction("node_util_binding.zig", "enobufsErrorCode", 0); /** * Spawns a file as a shell synchronously. @@ -1336,6 +1314,7 @@ class ChildProcess extends EventEmitter { argv0: spawnargs[0], windowsHide: !!options.windowsHide, windowsVerbatimArguments: !!options.windowsVerbatimArguments, + maxBuffer: options.maxBuffer, }); this.pid = this.#handle.pid; @@ -1348,6 +1327,15 @@ class ChildProcess extends EventEmitter { if (has_ipc) { this.send = this.#send; this.disconnect = this.#disconnect; + this.channel = new Control(); + Object.defineProperty(this, "_channel", { + get() { + return this.channel; + }, + set(value) { + this.channel = value; + }, + }); if (options[kFromNode]) this.#closesNeeded += 1; } @@ -1416,8 +1404,8 @@ class ChildProcess extends EventEmitter { return; } $assert(!this.connected); - this.#maybeClose(); process.nextTick(() => this.emit("disconnect")); + process.nextTick(() => this.#maybeClose()); } #disconnect() { if (!this.connected) { @@ -1425,6 +1413,7 @@ class ChildProcess extends EventEmitter { return; } this.#handle.disconnect(); + this.channel = null; } kill(sig?) { @@ -1612,6 +1601,12 @@ function abortChildProcess(child, killSignal, reason) { } } +class Control extends EventEmitter { + constructor() { + super(); + } +} + class ShimmedStdin extends EventEmitter { constructor() { super(); @@ -1882,12 +1877,6 @@ function genericNodeError(message, errorProperties) { // TypeError // ); -function ERR_CHILD_PROCESS_STDIO_MAXBUFFER(stdio) { - const err = Error(`${stdio} maxBuffer length exceeded`); - err.code = "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"; - return err; -} - function ERR_UNKNOWN_SIGNAL(name) { const err = new TypeError(`Unknown signal: ${name}`); err.code = "ERR_UNKNOWN_SIGNAL"; diff --git a/test/js/bun/spawn/spawn-maxbuf.test.ts b/test/js/bun/spawn/spawn-maxbuf.test.ts new file mode 100644 index 0000000000..4d5f59b295 --- /dev/null +++ b/test/js/bun/spawn/spawn-maxbuf.test.ts @@ -0,0 +1,154 @@ +import { bunExe } from "harness"; + +const { isWindows } = require("../../node/test/common"); + +async function toUtf8(out: ReadableStream): Promise { + const stream = new TextDecoderStream(); + out.pipeTo(stream.writable); + let result = ""; + for await (const chunk of stream.readable) { + result += chunk; + } + return result; +} + +describe("yes is killed", () => { + // TODO + test("Bun.spawn", async () => { + const timeStart = Date.now(); + const proc = Bun.spawn([bunExe(), "exec", "yes"], { + maxBuffer: 256, + killSignal: isWindows ? "SIGKILL" : "SIGHUP", + stdio: ["pipe", "pipe", "pipe"], + }); + await proc.exited; + expect(proc.exitCode).toBe(null); + expect(proc.signalCode).toBe(isWindows ? "SIGKILL" : "SIGHUP"); + const timeEnd = Date.now(); + expect(timeEnd - timeStart).toBeLessThan(100); // make sure it's not waiting a full tick + const result = await toUtf8(proc.stdout); + expect(result).toStartWith("y\n".repeat(128)); + const stderr = await toUtf8(proc.stderr); + expect(stderr).toBe(""); + }); + + test("Bun.spawnSync", () => { + const timeStart = Date.now(); + const proc = Bun.spawnSync([bunExe(), "exec", "yes"], { + maxBuffer: 256, + killSignal: isWindows ? "SIGKILL" : "SIGHUP", + stdio: ["pipe", "pipe", "pipe"], + }); + expect(proc.exitedDueToMaxBuffer).toBe(true); + expect(proc.exitCode).toBe(null); + expect(proc.signalCode).toBe(isWindows ? "SIGKILL" : "SIGHUP"); + const timeEnd = Date.now(); + expect(timeEnd - timeStart).toBeLessThan(100); // make sure it's not waiting a full tick + const result = proc.stdout.toString("utf-8"); + expect(result).toStartWith("y\n".repeat(128)); + const stderr = proc.stderr.toString("utf-8"); + expect(stderr).toBe(""); + }); +}); + +describe("maxBuffer infinity does not limit the number of bytes", () => { + const sample = "this is a long example string\n"; + const sample_repeat_count = 10000; + test("Bun.spawn", async () => { + const proc = Bun.spawn([bunExe(), "-e", `console.log(${JSON.stringify(sample)}.repeat(${sample_repeat_count}))`], { + maxBuffer: Infinity, + }); + await proc.exited; + expect(proc.exitCode).toBe(0); + const result = await toUtf8(proc.stdout); + expect(result).toBe(sample.repeat(sample_repeat_count) + "\n"); + }); + + test("Bun.spawnSync", () => { + const proc = Bun.spawnSync( + [bunExe(), "-e", `console.log(${JSON.stringify(sample)}.repeat(${sample_repeat_count}))`], + { + maxBuffer: Infinity, + }, + ); + expect(proc.exitCode).toBe(0); + const result = proc.stdout.toString("utf-8"); + expect(result).toBe(sample.repeat(sample_repeat_count) + "\n"); + }); +}); + +describe("timeout kills the process", () => { + test("Bun.spawn", async () => { + const timeStart = Date.now(); + const proc = Bun.spawn([bunExe(), "exec", "sleep 5"], { + timeout: 100, + killSignal: isWindows ? "SIGKILL" : "SIGHUP", + stdio: ["pipe", "pipe", "pipe"], + }); + await proc.exited; + expect(proc.exitCode).toBe(null); + expect(proc.signalCode).toBe(isWindows ? "SIGKILL" : "SIGHUP"); + const timeEnd = Date.now(); + expect(timeEnd - timeStart).toBeLessThan(200); // make sure it's terminating early + const result = await toUtf8(proc.stdout); + expect(result).toBe(""); + const stderr = await toUtf8(proc.stderr); + expect(stderr).toBe(""); + }); + + test("Bun.spawnSync", () => { + const timeStart = Date.now(); + const proc = Bun.spawnSync([bunExe(), "exec", "sleep 5"], { + timeout: 100, + killSignal: isWindows ? "SIGKILL" : "SIGHUP", + stdio: ["pipe", "pipe", "pipe"], + }); + expect(proc.exitedDueToTimeout).toBe(true); + expect(proc.exitCode).toBe(null); + expect(proc.signalCode).toBe(isWindows ? "SIGKILL" : "SIGHUP"); + const timeEnd = Date.now(); + expect(timeEnd - timeStart).toBeGreaterThan(100); // make sure it actually waits + expect(timeEnd - timeStart).toBeLessThan(200); // make sure it's terminating early + const result = proc.stdout.toString("utf-8"); + expect(result).toBe(""); + const stderr = proc.stderr.toString("utf-8"); + expect(stderr).toBe(""); + }); +}); + +describe("timeout Infinity does not kill the process", () => { + test("Bun.spawn", async () => { + const timeStart = Date.now(); + const proc = Bun.spawn([bunExe(), "exec", "sleep 1"], { + timeout: Infinity, + killSignal: isWindows ? "SIGKILL" : "SIGHUP", + stdio: ["pipe", "pipe", "pipe"], + }); + await proc.exited; + expect(proc.exitCode).toBe(0); + const timeEnd = Date.now(); + expect(timeEnd - timeStart).toBeGreaterThan(1000); // make sure it actually waits + expect(timeEnd - timeStart).toBeLessThan(1500); // make sure it's terminating early + const result = await toUtf8(proc.stdout); + expect(result).toBe(""); + const stderr = await toUtf8(proc.stderr); + expect(stderr).toBe(""); + }); + + test("Bun.spawnSync", () => { + const timeStart = Date.now(); + const proc = Bun.spawnSync([bunExe(), "exec", "sleep 1"], { + timeout: Infinity, + killSignal: isWindows ? "SIGKILL" : "SIGHUP", + stdio: ["pipe", "pipe", "pipe"], + }); + expect(proc.exitCode).toBe(0); + const timeEnd = Date.now(); + expect(timeEnd - timeStart).toBeGreaterThan(1000); // make sure it actually waits + expect(timeEnd - timeStart).toBeLessThan(1500); + const result = proc.stdout.toString("utf-8"); + expect(result).toBe(""); + const stderr = proc.stderr.toString("utf-8"); + expect(stderr).toBe(""); + }); +}); diff --git a/test/js/node/test/parallel/test-child-process-exec-abortcontroller-promisified.js b/test/js/node/test/parallel/test-child-process-exec-abortcontroller-promisified.js new file mode 100644 index 0000000000..bfc9cd7a53 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-exec-abortcontroller-promisified.js @@ -0,0 +1,88 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const exec = require('child_process').exec; +const { promisify } = require('util'); + +const execPromisifed = promisify(exec); +const invalidArgTypeError = { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError' +}; + +const waitCommand = common.isWindows ? + // `"` is forbidden for Windows paths, no need for escaping. + `"${process.execPath}" -e "setInterval(()=>{}, 99)"` : + 'sleep 2m'; + +{ + const ac = new AbortController(); + const signal = ac.signal; + const promise = execPromisifed(waitCommand, { signal }); + ac.abort(); + assert.rejects(promise, { + name: 'AbortError', + cause: ac.signal.reason, + }).then(common.mustCall()); +} + +{ + const err = new Error('boom'); + const ac = new AbortController(); + const signal = ac.signal; + const promise = execPromisifed(waitCommand, { signal }); + assert.rejects(promise, { + name: 'AbortError', + cause: err + }).then(common.mustCall()); + ac.abort(err); +} + +{ + const ac = new AbortController(); + const signal = ac.signal; + const promise = execPromisifed(waitCommand, { signal }); + assert.rejects(promise, { + name: 'AbortError', + cause: 'boom' + }).then(common.mustCall()); + ac.abort('boom'); +} + +{ + assert.throws(() => { + execPromisifed(waitCommand, { signal: {} }); + }, invalidArgTypeError); +} + +{ + function signal() {} + assert.throws(() => { + execPromisifed(waitCommand, { signal }); + }, invalidArgTypeError); +} + +{ + const signal = AbortSignal.abort(); // Abort in advance + const promise = execPromisifed(waitCommand, { signal }); + + assert.rejects(promise, { name: 'AbortError' }) + .then(common.mustCall()); +} + +{ + const err = new Error('boom'); + const signal = AbortSignal.abort(err); // Abort in advance + const promise = execPromisifed(waitCommand, { signal }); + + assert.rejects(promise, { name: 'AbortError', cause: err }) + .then(common.mustCall()); +} + +{ + const signal = AbortSignal.abort('boom'); // Abort in advance + const promise = execPromisifed(waitCommand, { signal }); + + assert.rejects(promise, { name: 'AbortError', cause: 'boom' }) + .then(common.mustCall()); +} diff --git a/test/js/node/test/parallel/test-child-process-exec-maxbuf.js b/test/js/node/test/parallel/test-child-process-exec-maxbuf.js new file mode 100644 index 0000000000..d13454d25b --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-exec-maxbuf.js @@ -0,0 +1,146 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const cp = require('child_process'); + +function runChecks(err, stdio, streamName, expected) { + assert.strictEqual(err.message, `${streamName} maxBuffer length exceeded`); + assert(err instanceof RangeError); + assert.strictEqual(err.code, 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'); + assert.deepStrictEqual(stdio[streamName], expected); +} + +// The execPath might contain chars that should be escaped in a shell context. +// On non-Windows, we can pass the path via the env; `"` is not a valid char on +// Windows, so we can simply pass the path. +const execNode = (args, optionsOrCallback, callback) => { + const [cmd, opts] = common.escapePOSIXShell`"${process.execPath}" `; + let options = optionsOrCallback; + if (typeof optionsOrCallback === 'function') { + options = undefined; + callback = optionsOrCallback; + } + return cp.exec( + cmd + args, + { ...opts, ...options }, + callback, + ); +}; + +// default value +{ + execNode(`-e "console.log('a'.repeat(1024 * 1024))"`, common.mustCall((err) => { + assert(err instanceof RangeError); + assert.strictEqual(err.message, 'stdout maxBuffer length exceeded'); + assert.strictEqual(err.code, 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'); + })); +} + +// default value +{ + execNode(`-e "console.log('a'.repeat(1024 * 1024 - 1))"`, common.mustSucceed((stdout, stderr) => { + assert.strictEqual(stdout.trim(), 'a'.repeat(1024 * 1024 - 1)); + assert.strictEqual(stderr, ''); + })); +} + +{ + const options = { maxBuffer: Infinity }; + + execNode(`-e "console.log('hello world');"`, options, common.mustSucceed((stdout, stderr) => { + assert.strictEqual(stdout.trim(), 'hello world'); + assert.strictEqual(stderr, ''); + })); +} + +{ + const cmd = 'echo hello world'; + + cp.exec( + cmd, + { maxBuffer: 5 }, + common.mustCall((err, stdout, stderr) => { + runChecks(err, { stdout, stderr }, 'stdout', 'hello'); + }) + ); +} + +// default value +{ + execNode( + `-e "console.log('a'.repeat(1024 * 1024))"`, + common.mustCall((err, stdout, stderr) => { + runChecks( + err, + { stdout, stderr }, + 'stdout', + 'a'.repeat(1024 * 1024) + ); + }) + ); +} + +// default value +{ + execNode(`-e "console.log('a'.repeat(1024 * 1024 - 1))"`, common.mustSucceed((stdout, stderr) => { + assert.strictEqual(stdout.trim(), 'a'.repeat(1024 * 1024 - 1)); + assert.strictEqual(stderr, ''); + })); +} + +const unicode = '中文测试'; // length = 4, byte length = 12 + +{ + execNode( + `-e "console.log('${unicode}');"`, + { maxBuffer: 10 }, + common.mustCall((err, stdout, stderr) => { + runChecks(err, { stdout, stderr }, 'stdout', '中文测试\n'); + }) + ); +} + +{ + execNode( + `-e "console.error('${unicode}');"`, + { maxBuffer: 3 }, + common.mustCall((err, stdout, stderr) => { + runChecks(err, { stdout, stderr }, 'stderr', '中文测'); + }) + ); +} + +{ + const child = execNode( + `-e "console.log('${unicode}');"`, + { encoding: null, maxBuffer: 10 }, + common.mustCall((err, stdout, stderr) => { + runChecks(err, { stdout, stderr }, 'stdout', '中文测试\n'); + }) + ); + + child.stdout.setEncoding('utf-8'); +} + +{ + const child = execNode( + `-e "console.error('${unicode}');"`, + { encoding: null, maxBuffer: 3 }, + common.mustCall((err, stdout, stderr) => { + runChecks(err, { stdout, stderr }, 'stderr', '中文测'); + }) + ); + + child.stderr.setEncoding('utf-8'); +} + +{ + execNode( + `-e "console.error('${unicode}');"`, + { encoding: null, maxBuffer: 5 }, + common.mustCall((err, stdout, stderr) => { + const buf = Buffer.from(unicode).slice(0, 5); + runChecks(err, { stdout, stderr }, 'stderr', buf); + }) + ); +} diff --git a/test/js/node/test/parallel/test-child-process-execfile-maxbuf.js b/test/js/node/test/parallel/test-child-process-execfile-maxbuf.js new file mode 100644 index 0000000000..22fb9264ea --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-execfile-maxbuf.js @@ -0,0 +1,92 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { execFile } = require('child_process'); + +function checkFactory(streamName) { + return common.mustCall((err) => { + assert(err instanceof RangeError); + assert.strictEqual(err.message, `${streamName} maxBuffer length exceeded`); + assert.strictEqual(err.code, 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'); + }); +} + +// default value +{ + execFile( + process.execPath, + ['-e', 'console.log("a".repeat(1024 * 1024))'], + checkFactory('stdout') + ); +} + +// default value +{ + execFile( + process.execPath, + ['-e', 'console.log("a".repeat(1024 * 1024 - 1))'], + common.mustSucceed((stdout, stderr) => { + assert.strictEqual(stdout.trim(), 'a'.repeat(1024 * 1024 - 1)); + assert.strictEqual(stderr, ''); + }) + ); +} + +{ + const options = { maxBuffer: Infinity }; + + execFile( + process.execPath, + ['-e', 'console.log("hello world");'], + options, + common.mustSucceed((stdout, stderr) => { + assert.strictEqual(stdout.trim(), 'hello world'); + assert.strictEqual(stderr, ''); + }) + ); +} + +{ + execFile('echo', ['hello world'], { maxBuffer: 5 }, checkFactory('stdout')); +} + +const unicode = '中文测试'; // length = 4, byte length = 12 + +{ + execFile( + process.execPath, + ['-e', `console.log('${unicode}');`], + { maxBuffer: 10 }, + checkFactory('stdout')); +} + +{ + execFile( + process.execPath, + ['-e', `console.error('${unicode}');`], + { maxBuffer: 10 }, + checkFactory('stderr') + ); +} + +{ + const child = execFile( + process.execPath, + ['-e', `console.log('${unicode}');`], + { encoding: null, maxBuffer: 10 }, + checkFactory('stdout') + ); + + child.stdout.setEncoding('utf-8'); +} + +{ + const child = execFile( + process.execPath, + ['-e', `console.error('${unicode}');`], + { encoding: null, maxBuffer: 10 }, + checkFactory('stderr') + ); + + child.stderr.setEncoding('utf-8'); +} diff --git a/test/js/node/test/parallel/test-child-process-execfilesync-maxbuf.js b/test/js/node/test/parallel/test-child-process-execfilesync-maxbuf.js new file mode 100644 index 0000000000..63f8cc26eb --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-execfilesync-maxbuf.js @@ -0,0 +1,53 @@ +'use strict'; +require('../common'); + +// This test checks that the maxBuffer option for child_process.execFileSync() +// works as expected. + +const assert = require('assert'); +const { getSystemErrorName } = require('util'); +const { execFileSync } = require('child_process'); +const msgOut = 'this is stdout'; +const msgOutBuf = Buffer.from(`${msgOut}\n`); + +const args = [ + '-e', + `console.log("${msgOut}");`, +]; + +// Verify that an error is returned if maxBuffer is surpassed. +{ + assert.throws(() => { + execFileSync(process.execPath, args, { maxBuffer: 1 }); + }, (e) => { + assert.ok(e, 'maxBuffer should error'); + assert.strictEqual(e.code, 'ENOBUFS'); + assert.strictEqual(getSystemErrorName(e.errno), 'ENOBUFS'); + // We can have buffers larger than maxBuffer because underneath we alloc 64k + // that matches our read sizes. + assert.deepStrictEqual(e.stdout, msgOutBuf); + return true; + }); +} + +// Verify that a maxBuffer size of Infinity works. +{ + const ret = execFileSync(process.execPath, args, { maxBuffer: Infinity }); + + assert.deepStrictEqual(ret, msgOutBuf); +} + +// Default maxBuffer size is 1024 * 1024. +{ + assert.throws(() => { + execFileSync( + process.execPath, + ['-e', "console.log('a'.repeat(1024 * 1024))"] + ); + }, (e) => { + assert.ok(e, 'maxBuffer should error'); + assert.strictEqual(e.code, 'ENOBUFS'); + assert.strictEqual(getSystemErrorName(e.errno), 'ENOBUFS'); + return true; + }); +} diff --git a/test/js/node/test/parallel/test-child-process-execsync-maxbuf.js b/test/js/node/test/parallel/test-child-process-execsync-maxbuf.js new file mode 100644 index 0000000000..5700d02ab6 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-execsync-maxbuf.js @@ -0,0 +1,60 @@ +'use strict'; +const { escapePOSIXShell } = require('../common'); + +// This test checks that the maxBuffer option for child_process.spawnSync() +// works as expected. + +const assert = require('assert'); +const { getSystemErrorName } = require('util'); +const { execSync } = require('child_process'); +const msgOut = 'this is stdout'; +const msgOutBuf = Buffer.from(`${msgOut}\n`); + +const [cmd, opts] = escapePOSIXShell`"${process.execPath}" -e "${`console.log('${msgOut}')`}"`; + +// Verify that an error is returned if maxBuffer is surpassed. +{ + assert.throws(() => { + execSync(cmd, { ...opts, maxBuffer: 1 }); + }, (e) => { + assert.ok(e, 'maxBuffer should error'); + assert.strictEqual(e.code, 'ENOBUFS'); + assert.strictEqual(getSystemErrorName(e.errno), 'ENOBUFS'); + // We can have buffers larger than maxBuffer because underneath we alloc 64k + // that matches our read sizes. + assert.deepStrictEqual(e.stdout, msgOutBuf); + return true; + }); +} + +// Verify that a maxBuffer size of Infinity works. +{ + const ret = execSync( + cmd, + { ...opts, maxBuffer: Infinity }, + ); + + assert.deepStrictEqual(ret, msgOutBuf); +} + +// Default maxBuffer size is 1024 * 1024. +{ + assert.throws(() => { + execSync(...escapePOSIXShell`"${process.execPath}" -e "console.log('a'.repeat(1024 * 1024))"`); + }, (e) => { + assert.ok(e, 'maxBuffer should error'); + assert.strictEqual(e.code, 'ENOBUFS'); + assert.strictEqual(getSystemErrorName(e.errno), 'ENOBUFS'); + return true; + }); +} + +// Default maxBuffer size is 1024 * 1024. +{ + const ret = execSync(...escapePOSIXShell`"${process.execPath}" -e "console.log('a'.repeat(1024 * 1024 - 1))"`); + + assert.deepStrictEqual( + ret.toString().trim(), + 'a'.repeat(1024 * 1024 - 1) + ); +} diff --git a/test/js/node/test/parallel/test-child-process-fork-closed-channel-segfault.js b/test/js/node/test/parallel/test-child-process-fork-closed-channel-segfault.js new file mode 100644 index 0000000000..47eb87c45f --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-fork-closed-channel-segfault.js @@ -0,0 +1,88 @@ +'use strict'; +const common = require('../common'); + +// Before https://github.com/nodejs/node/pull/2847 a child process trying +// (asynchronously) to use the closed channel to it's creator caused a segfault. + +const assert = require('assert'); +const cluster = require('cluster'); +const net = require('net'); + +if (!cluster.isPrimary) { + // Exit on first received handle to leave the queue non-empty in primary + process.on('message', function() { + process.exit(1); + }); + return; +} + +const server = net + .createServer(function(s) { + if (common.isWindows) { + s.on('error', function(err) { + // Prevent possible ECONNRESET errors from popping up + if (err.code !== 'ECONNRESET') throw err; + }); + } + setTimeout(function() { + s.destroy(); + }, 100); + }) + .listen(0, function() { + const worker = cluster.fork(); + + worker.on('error', function(err) { + if ( + err.code !== 'ECONNRESET' && + err.code !== 'ECONNREFUSED' && + err.code !== 'EMFILE' + ) { + throw err; + } + }); + + function send(callback) { + const s = net.connect(server.address().port, function() { + worker.send({}, s, callback); + }); + + // https://github.com/nodejs/node/issues/3635#issuecomment-157714683 + // ECONNREFUSED or ECONNRESET errors can happen if this connection is + // still establishing while the server has already closed. + // EMFILE can happen if the worker __and__ the server had already closed. + s.on('error', function(err) { + if ( + err.code !== 'ECONNRESET' && + err.code !== 'ECONNREFUSED' && + err.code !== 'EMFILE' + ) { + throw err; + } + }); + } + + worker.process.once( + 'close', + common.mustCall(function() { + // Otherwise the crash on `channel.fd` access may happen + assert.strictEqual(worker.process.channel, null); + server.close(); + }) + ); + + worker.on('online', function() { + send(function(err) { + assert.ifError(err); + send(function(err) { + // Ignore errors when sending the second handle because the worker + // may already have exited. + if (err && err.code !== 'ERR_IPC_CHANNEL_CLOSED' && + err.code !== 'ECONNRESET' && + err.code !== 'ECONNREFUSED' && + err.code !== 'EMFILE') { + throw err; + } + }); + }); + }); + }); diff --git a/test/js/node/test/parallel/test-child-process-spawnsync-maxbuf.js b/test/js/node/test/parallel/test-child-process-spawnsync-maxbuf.js new file mode 100644 index 0000000000..3f452a41e6 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-spawnsync-maxbuf.js @@ -0,0 +1,58 @@ +'use strict'; +require('../common'); + +// This test checks that the maxBuffer option for child_process.spawnSync() +// works as expected. + +const assert = require('assert'); +const spawnSync = require('child_process').spawnSync; +const { getSystemErrorName } = require('util'); +const msgOut = 'this is stdout'; +const msgOutBuf = Buffer.from(`${msgOut}\n`); + +const args = [ + '-e', + `console.log("${msgOut}");`, +]; + +// Verify that an error is returned if maxBuffer is surpassed. +{ + const ret = spawnSync(process.execPath, args, { maxBuffer: 1 }); + + assert.ok(ret.error, 'maxBuffer should error'); + assert.strictEqual(ret.error.code, 'ENOBUFS'); + assert.strictEqual(getSystemErrorName(ret.error.errno), 'ENOBUFS'); + // We can have buffers larger than maxBuffer because underneath we alloc 64k + // that matches our read sizes. + assert.deepStrictEqual(ret.stdout, msgOutBuf); +} + +// Verify that a maxBuffer size of Infinity works. +{ + const ret = spawnSync(process.execPath, args, { maxBuffer: Infinity }); + + assert.ifError(ret.error); + assert.deepStrictEqual(ret.stdout, msgOutBuf); +} + +// Default maxBuffer size is 1024 * 1024. +{ + const args = ['-e', "console.log('a'.repeat(1024 * 1024))"]; + const ret = spawnSync(process.execPath, args); + + assert.ok(ret.error, 'maxBuffer should error'); + assert.strictEqual(ret.error.code, 'ENOBUFS'); + assert.strictEqual(getSystemErrorName(ret.error.errno), 'ENOBUFS'); +} + +// Default maxBuffer size is 1024 * 1024. +{ + const args = ['-e', "console.log('a'.repeat(1024 * 1024 - 1))"]; + const ret = spawnSync(process.execPath, args); + + assert.ifError(ret.error); + assert.deepStrictEqual( + ret.stdout.toString().trim(), + 'a'.repeat(1024 * 1024 - 1) + ); +}