diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index b3eaefecbf..7e6b829bbd 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -6960,6 +6960,8 @@ declare module "bun" { resourceUsage: ResourceUsage; signalCode?: string; + exitedDueToTimeout?: true; + pid: number; } /** diff --git a/src/bun.js/api/Timer.zig b/src/bun.js/api/Timer.zig index f68932496a..8cb49a11e2 100644 --- a/src/bun.js/api/Timer.zig +++ b/src/bun.js/api/Timer.zig @@ -1191,6 +1191,7 @@ pub const EventLoopTimer = struct { WTFTimer, PostgresSQLConnectionTimeout, PostgresSQLConnectionMaxLifetime, + SubprocessTimeout, pub fn Type(comptime T: Tag) type { return switch (T) { @@ -1205,6 +1206,7 @@ pub const EventLoopTimer = struct { .WTFTimer => WTFTimer, .PostgresSQLConnectionTimeout => JSC.Postgres.PostgresSQLConnection, .PostgresSQLConnectionMaxLifetime => JSC.Postgres.PostgresSQLConnection, + .SubprocessTimeout => JSC.Subprocess, }; } } else enum { @@ -1218,6 +1220,7 @@ pub const EventLoopTimer = struct { DNSResolver, PostgresSQLConnectionTimeout, PostgresSQLConnectionMaxLifetime, + SubprocessTimeout, pub fn Type(comptime T: Tag) type { return switch (T) { @@ -1231,6 +1234,7 @@ pub const EventLoopTimer = struct { .DNSResolver => DNSResolver, .PostgresSQLConnectionTimeout => JSC.Postgres.PostgresSQLConnection, .PostgresSQLConnectionMaxLifetime => JSC.Postgres.PostgresSQLConnection, + .SubprocessTimeout => JSC.Subprocess, }; } }; @@ -1322,6 +1326,10 @@ pub const EventLoopTimer = struct { return container.checkTimeouts(now, vm); } + if (comptime t.Type() == JSC.Subprocess) { + return container.timeoutCallback(); + } + return container.callback(container); }, } diff --git a/src/bun.js/api/bun/process.zig b/src/bun.js/api/bun/process.zig index 4a03e7e099..5f15e5556c 100644 --- a/src/bun.js/api/bun/process.zig +++ b/src/bun.js/api/bun/process.zig @@ -1236,7 +1236,7 @@ pub fn spawnProcessPosix( } if (options.cwd.len > 0) { - actions.chdir(options.cwd) catch return error.ChangingDirectoryFailed; + try actions.chdir(options.cwd); } var spawned = PosixSpawnResult{}; var extra_fds = std.ArrayList(bun.FileDescriptor).init(bun.default_allocator); diff --git a/src/bun.js/api/bun/subprocess.zig b/src/bun.js/api/bun/subprocess.zig index d185e5a8f8..fb19d960f0 100644 --- a/src/bun.js/api/bun/subprocess.zig +++ b/src/bun.js/api/bun/subprocess.zig @@ -35,6 +35,16 @@ weak_file_sink_stdin_ptr: ?*JSC.WebCore.FileSink = null, ref_count: u32 = 1, abort_signal: ?*JSC.AbortSignal = null, +event_loop_timer_refd: bool = false, +event_loop_timer: JSC.API.Bun.Timer.EventLoopTimer = .{ + .tag = .SubprocessTimeout, + .next = .{ + .sec = 0, + .nsec = 0, + }, +}, +killSignal: SignalCode, + pub const Flags = packed struct { is_sync: bool = false, killed: bool = false, @@ -198,7 +208,7 @@ pub const StdioKind = enum { pub fn onAbortSignal(subprocess_ctx: ?*anyopaque, _: JSC.JSValue) callconv(.C) void { var this: *Subprocess = @ptrCast(@alignCast(subprocess_ctx.?)); this.clearAbortSignal(); - _ = this.tryKill(SignalCode.default); + _ = this.tryKill(this.killSignal); } pub fn resourceUsage( @@ -577,7 +587,7 @@ pub fn asyncDispose( this.stdout.unref(); this.stderr.unref(); - switch (this.tryKill(SignalCode.default)) { + switch (this.tryKill(this.killSignal)) { .result => {}, .err => |err| { // Signal 9 should always be fine, but just in case that somehow fails. @@ -588,6 +598,63 @@ pub fn asyncDispose( return this.getExited(global); } +fn setEventLoopTimerRefd(this: *Subprocess, refd: bool) void { + if (this.event_loop_timer_refd == refd) return; + this.event_loop_timer_refd = refd; + if (refd) { + this.globalThis.bunVM().timer.incrementTimerRef(1); + } else { + this.globalThis.bunVM().timer.incrementTimerRef(-1); + } +} + +pub fn timeoutCallback(this: *Subprocess) JSC.API.Bun.Timer.EventLoopTimer.Arm { + this.setEventLoopTimerRefd(false); + if (this.event_loop_timer.state == .CANCELLED) return .disarm; + if (this.hasExited()) { + this.event_loop_timer.state = .CANCELLED; + return .disarm; + } + this.event_loop_timer.state = .FIRED; + _ = this.tryKill(this.killSignal); + return .disarm; +} + +fn parseSignal(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; +} + pub fn kill( this: *Subprocess, globalThis: *JSGlobalObject, @@ -595,42 +662,10 @@ pub fn kill( ) bun.JSError!JSValue { this.this_jsvalue = callframe.this(); - var arguments = callframe.arguments_old(1); + const arguments = callframe.arguments_old(1); // If signal is 0, then no actual signal is sent, but error checking // is still performed. - const sig: i32 = brk: { - if (arguments.ptr[0].getNumber()) |sig64| { - // Node does this: - if (std.math.isNan(sig64)) { - break :brk 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", .{}); - } - - break :brk @intFromFloat(sig64); - } else if (arguments.ptr[0].isString()) { - if (arguments.ptr[0].asString().length() == 0) { - break :brk SignalCode.default; - } - const signal_code = try arguments.ptr[0].toEnum(globalThis, "signal", SignalCode); - break :brk @intFromEnum(signal_code); - } else if (!arguments.ptr[0].isEmptyOrUndefinedOrNull()) { - return globalThis.throwInvalidArguments("Invalid signal: must be a string or an integer", .{}); - } - - break :brk SignalCode.default; - }; + const sig: SignalCode = try parseSignal(arguments.ptr[0], globalThis); if (globalThis.hasException()) return .zero; @@ -649,11 +684,11 @@ pub fn hasKilled(this: *const Subprocess) bool { return this.process.hasKilled(); } -pub fn tryKill(this: *Subprocess, sig: i32) JSC.Maybe(void) { +pub fn tryKill(this: *Subprocess, sig: SignalCode) JSC.Maybe(void) { if (this.hasExited()) { return .{ .result = {} }; } - return this.process.kill(@intCast(sig)); + return this.process.kill(@intFromEnum(sig)); } fn hasCalledGetter(this: *Subprocess, comptime getter: @Type(.enum_literal)) bool { @@ -693,24 +728,63 @@ pub fn onStdinDestroyed(this: *Subprocess) void { pub fn doSend(this: *Subprocess, global: *JSC.JSGlobalObject, callFrame: *JSC.CallFrame) bun.JSError!JSValue { IPClog("Subprocess#doSend", .{}); + var message, var handle, var options_, var callback = callFrame.argumentsAsArray(4); + + if (handle.isFunction()) { + callback = handle; + handle = .undefined; + options_ = .undefined; + } else if (options_.isFunction()) { + callback = options_; + options_ = .undefined; + } else if (!options_.isUndefined()) { + try global.validateObject("options", options_, .{}); + } + + const S = struct { + fn impl(globalThis: *JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSValue { + const arguments_ = callframe.arguments_old(1).slice(); + const ex = arguments_[0]; + JSC.VirtualMachine.Process__emitErrorEvent(globalThis, ex); + return .undefined; + } + }; + + const zigGlobal: *JSC.ZigGlobalObject = @ptrCast(global); const ipc_data = &(this.ipc_data orelse { if (this.hasExited()) { - return global.throw("Subprocess.send() cannot be used after the process has exited.", .{}); + return global.ERR_IPC_CHANNEL_CLOSED("Subprocess.send() cannot be used after the process has exited.", .{}).throw(); } else { return global.throw("Subprocess.send() can only be used if an IPC channel is open.", .{}); } }); - if (callFrame.argumentsCount() == 0) { - return global.throwInvalidArguments("Subprocess.send() requires one argument", .{}); + if (message.isUndefined()) { + return global.throwMissingArgumentsValue(&.{"message"}); + } + if (!message.isString() and !message.isObject() and !message.isNumber() and !message.isBoolean() and !message.isNull()) { + return global.throwInvalidArgumentTypeValueOneOf("message", "string, object, number, or boolean", message); } - const value = callFrame.argument(0); + const good = ipc_data.serializeAndSend(global, message); - const success = ipc_data.serializeAndSend(global, value); - if (!success) return .zero; + if (good) { + if (callback.isFunction()) { + JSC.Bun__Process__queueNextTick1(zigGlobal, callback, .null); + // we need to wait until the send is actually completed to trigger the callback + } + } else { + const ex = global.createTypeErrorInstance("process.send() failed", .{}); + ex.put(global, JSC.ZigString.static("syscall"), bun.String.static("write").toJS(global)); + if (callback.isFunction()) { + JSC.Bun__Process__queueNextTick1(zigGlobal, callback, ex); + } else { + const fnvalue = JSC.JSFunction.create(global, "", S.impl, 1, .{}); + JSC.Bun__Process__queueNextTick1(zigGlobal, fnvalue, ex); + } + } - return .undefined; + return .false; } pub fn disconnectIPC(this: *Subprocess, nextTick: bool) void { const ipc_data = this.ipc() orelse return; @@ -1452,6 +1526,11 @@ pub fn onProcessExit(this: *Subprocess, process: *Process, status: bun.spawn.Sta defer this.deref(); defer this.disconnectIPC(true); + if (this.event_loop_timer.state == .ACTIVE) { + jsc_vm.timer.remove(&this.event_loop_timer); + } + this.setEventLoopTimerRefd(false); + jsc_vm.onSubprocessExit(process); var stdin: ?*JSC.WebCore.FileSink = this.weak_file_sink_stdin_ptr; @@ -1472,6 +1551,12 @@ pub fn onProcessExit(this: *Subprocess, process: *Process, status: bun.spawn.Sta if (this.stdin == .buffer) { this.stdin.buffer.close(); } + if (this.stdout == .pipe) { + this.stdout.pipe.close(); + } + if (this.stderr == .pipe) { + this.stderr.pipe.close(); + } if (existing_stdin_value != .zero) { JSC.WebCore.FileSink.JSSink.setDestroyCallback(existing_stdin_value, 0); @@ -1626,6 +1711,11 @@ pub fn finalize(this: *Subprocess) callconv(.C) void { this.process.detach(); this.process.deref(); + if (this.event_loop_timer.state == .ACTIVE) { + this.globalThis.bunVM().timer.remove(&this.event_loop_timer); + } + this.setEventLoopTimerRefd(false); + this.flags.finalized = true; this.deref(); } @@ -1690,7 +1780,7 @@ extern "C" const BUN_DEFAULT_PATH_FOR_SPAWN: [*:0]const u8; // 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, argv0: ?[*:0]const u8, first_cmd: JSValue, allocator: std.mem.Allocator) bun.JSError!struct { +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, } { @@ -1702,10 +1792,7 @@ fn getArgv0(globalThis: *JSC.JSGlobalObject, PATH: []const u8, cwd: []const u8, var actual_argv0: [:0]const u8 = ""; - const argv0_to_use: []const u8 = if (argv0) |_argv0| - bun.sliceTo(_argv0, 0) - else - arg0.slice(); + 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 @@ -1732,7 +1819,7 @@ fn getArgv0(globalThis: *JSC.JSGlobalObject, PATH: []const u8, cwd: []const u8, return .{ .argv0 = actual_argv0, - .arg0 = try allocator.dupeZ(u8, arg0.slice()), + .arg0 = if (pretend_argv0) |p| try allocator.dupeZ(u8, bun.sliceTo(p, 0)) else try allocator.dupeZ(u8, arg0.slice()), }; } @@ -1817,6 +1904,8 @@ pub fn spawnMaybeSync( 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 windows_hide: bool = false; var windows_verbatim_arguments: bool = false; @@ -2009,6 +2098,16 @@ pub fn spawnMaybeSync( } } } + + if (try args.get(globalThis, "timeout")) |val| { + if (val.isNumber()) { + timeout = @max(val.coerce(i32, globalThis), 1); + } + } + + if (try args.get(globalThis, "killSignal")) |val| { + killSignal = try parseSignal(val, globalThis); + } } else { try getArgv(globalThis, cmd_value, PATH, cwd, &argv0, allocator, &argv); } @@ -2129,15 +2228,16 @@ pub fn spawnMaybeSync( .err => |err| { spawn_options.deinit(); switch (err.getErrno()) { - .ACCES, .NOENT, .PERM, .ISDIR, .NOTDIR => { - const display_path: [:0]const u8 = if (argv0 != null) - std.mem.sliceTo(argv0.?, 0) - else if (argv.items.len > 0 and argv.items[0] != null) + .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) - return globalThis.throwValue(err.withPath(display_path).toJSC(globalThis)); + if (display_path.len > 0) { + var systemerror = err.withPath(display_path).toSystemError(); + if (errno == .NOENT) systemerror.errno = -bun.C.UV_ENOENT; + return globalThis.throwValue(systemerror.toErrorInstance(globalThis)); + } }, else => {}, } @@ -2166,6 +2266,7 @@ pub fn spawnMaybeSync( .flags = .{ .is_sync = is_sync, }, + .killSignal = undefined, }); const posix_ipc_fd = if (Environment.isPosix and !is_sync and maybe_ipc_mode != null) @@ -2221,6 +2322,7 @@ pub fn spawnMaybeSync( .flags = .{ .is_sync = is_sync, }, + .killSignal = killSignal, }; subprocess.process.setExitHandler(subprocess); @@ -2261,7 +2363,7 @@ pub fn spawnMaybeSync( } subprocess.stdio_pipes.items[@intCast(ipc_channel)] = .unavailable; } - ipc_data.writeVersionPacket(); + ipc_data.writeVersionPacket(globalThis); } if (subprocess.stdin == .pipe) { @@ -2320,6 +2422,12 @@ pub fn spawnMaybeSync( should_close_memfd = false; + 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) { // 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 @@ -2389,6 +2497,8 @@ pub fn spawnMaybeSync( const stdout = try subprocess.stdout.toBufferedValue(globalThis); 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 resultPid = JSC.JSValue.jsNumberFromInt32(subprocess.pid()); subprocess.finalize(); if (globalThis.hasException()) { @@ -2405,6 +2515,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); + sync_value.put(globalThis, JSC.ZigString.static("pid"), resultPid); return sync_value; } @@ -2413,6 +2525,7 @@ fn throwCommandNotFound(globalThis: *JSC.JSGlobalObject, command: []const u8) bu const err = JSC.SystemError{ .message = bun.String.createFormat("Executable not found in $PATH: \"{s}\"", .{command}) catch bun.outOfMemory(), .code = bun.String.static("ENOENT"), + .errno = -bun.C.UV_ENOENT, .path = bun.String.createUTF8(command), }; return globalThis.throwValue(err.toErrorInstance(globalThis)); @@ -2470,6 +2583,9 @@ pub fn handleIPCClose(this: *Subprocess) void { pub fn ipc(this: *Subprocess) ?*IPC.IPCData { return &(this.ipc_data orelse return null); } +pub fn getGlobalThis(this: *Subprocess) ?*JSC.JSGlobalObject { + return this.globalThis; +} pub const IPCHandler = IPC.NewIPCHandler(Subprocess); diff --git a/src/bun.js/bindings/BunProcess.cpp b/src/bun.js/bindings/BunProcess.cpp index a7a3a0ace8..4bc3aef7c8 100644 --- a/src/bun.js/bindings/BunProcess.cpp +++ b/src/bun.js/bindings/BunProcess.cpp @@ -1034,17 +1034,10 @@ extern "C" int Bun__handleUnhandledRejection(JSC::JSGlobalObject* lexicalGlobalO } } -extern "C" void Bun__setChannelRef(GlobalObject* globalObject, bool enabled) -{ - auto process = jsCast(globalObject->processObject()); - process->wrapped().m_hasIPCRef = enabled; +extern "C" void Bun__refChannelUnlessOverridden(JSC::JSGlobalObject* globalObject); +extern "C" void Bun__unrefChannelUnlessOverridden(JSC::JSGlobalObject* globalObject); +extern "C" bool Bun__shouldIgnoreOneDisconnectEventListener(JSC::JSGlobalObject* globalObject); - if (enabled) { - process->scriptExecutionContext()->refEventLoop(); - } else { - process->scriptExecutionContext()->unrefEventLoop(); - } -} extern "C" void Bun__ensureSignalHandler(); extern "C" bool Bun__isMainThreadVM(); extern "C" void Bun__onPosixSignal(int signalNumber); @@ -1054,15 +1047,23 @@ static void onDidChangeListeners(EventEmitter& eventEmitter, const Identifier& e // IPC handlers if (eventName.string() == "message"_s || eventName.string() == "disconnect"_s) { auto* global = jsCast(eventEmitter.scriptExecutionContext()->jsGlobalObject()); + auto& vm = JSC::getVM(global); + auto messageListenerCount = eventEmitter.listenerCount(vm.propertyNames->message); + auto disconnectListenerCount = eventEmitter.listenerCount(Identifier::fromString(vm, "disconnect"_s)); + if (disconnectListenerCount >= 1 && Bun__shouldIgnoreOneDisconnectEventListener(global)) { + disconnectListenerCount--; + } + auto totalListenerCount = messageListenerCount + disconnectListenerCount; if (isAdded) { if (Bun__GlobalObject__hasIPC(global) - && eventEmitter.listenerCount(eventName) == 1) { + && totalListenerCount == 1) { Bun__ensureProcessIPCInitialized(global); - Bun__setChannelRef(global, true); + Bun__refChannelUnlessOverridden(global); } } else { - if (eventEmitter.listenerCount(eventName) == 0) { - Bun__setChannelRef(global, false); + if (Bun__GlobalObject__hasIPC(global) + && totalListenerCount == 0) { + Bun__unrefChannelUnlessOverridden(global); } } return; diff --git a/src/bun.js/bindings/ErrorCode.ts b/src/bun.js/bindings/ErrorCode.ts index b2876cfc21..01fc093937 100644 --- a/src/bun.js/bindings/ErrorCode.ts +++ b/src/bun.js/bindings/ErrorCode.ts @@ -95,6 +95,7 @@ const errors: ErrorCodeMapping = [ ["ERR_HTTP_INVALID_STATUS_CODE", RangeError], ["ERR_HTTP_INVALID_HEADER_VALUE", TypeError], ["ERR_SERVER_ALREADY_LISTEN", Error], + ["ERR_CHILD_PROCESS_IPC_REQUIRED", Error], // Bun-specific ["ERR_FORMDATA_PARSE_ERROR", TypeError], diff --git a/src/bun.js/bindings/JSGlobalObject.zig b/src/bun.js/bindings/JSGlobalObject.zig index b3f8001af8..1321ed2571 100644 --- a/src/bun.js/bindings/JSGlobalObject.zig +++ b/src/bun.js/bindings/JSGlobalObject.zig @@ -137,6 +137,18 @@ pub const JSGlobalObject = opaque { return this.ERR_INVALID_ARG_TYPE("The \"{s}\" argument must be {s}. Received {}", .{ argname, typename, actual_string_value }).throw(); } + /// "The argument must be one of type . Received " + pub fn throwInvalidArgumentTypeValueOneOf( + this: *JSGlobalObject, + argname: []const u8, + typename: []const u8, + value: JSValue, + ) bun.JSError { + const actual_string_value = try determineSpecificType(this, value); + defer actual_string_value.deref(); + return this.ERR_INVALID_ARG_TYPE("The \"{s}\" argument must be one of type {s}. Received {}", .{ argname, typename, actual_string_value }).throw(); + } + pub fn throwInvalidArgumentRangeValue( this: *JSGlobalObject, argname: []const u8, diff --git a/src/bun.js/bindings/webcore/EventEmitter.h b/src/bun.js/bindings/webcore/EventEmitter.h index dbd5166bac..989239a2aa 100644 --- a/src/bun.js/bindings/webcore/EventEmitter.h +++ b/src/bun.js/bindings/webcore/EventEmitter.h @@ -94,8 +94,6 @@ public: } } - bool m_hasIPCRef { false }; - private: EventEmitter(ScriptExecutionContext& context) : ContextDestructionObserver(&context) diff --git a/src/bun.js/event_loop.zig b/src/bun.js/event_loop.zig index baf7dabbef..6a3e9e81ae 100644 --- a/src/bun.js/event_loop.zig +++ b/src/bun.js/event_loop.zig @@ -204,6 +204,12 @@ pub const ManagedTask = struct { bun.default_allocator.destroy(this); } + pub fn cancel(this: *ManagedTask) void { + this.callback = &struct { + fn f(_: *anyopaque) void {} + }.f; + } + pub fn New(comptime Type: type, comptime Callback: anytype) type { return struct { pub fn init(ctx: *Type) Task { diff --git a/src/bun.js/ipc.zig b/src/bun.js/ipc.zig index 565b498698..05d20587f6 100644 --- a/src/bun.js/ipc.zig +++ b/src/bun.js/ipc.zig @@ -230,8 +230,13 @@ const json = struct { } } - const deserialized = str.toJSByParseJSON(globalThis); - if (deserialized == .zero) return error.InvalidFormat; + const deserialized = str.toJSByParseJSON(globalThis) catch |e| switch (e) { + error.JSError => { + globalThis.clearException(); + return IPCDecodeError.InvalidFormat; + }, + error.OutOfMemory => return bun.outOfMemory(), + }; return switch (kind) { 1 => .{ @@ -335,7 +340,10 @@ const SocketIPCData = struct { internal_msg_queue: node_cluster_binding.InternalMsgHolder = .{}, disconnected: bool = false, is_server: bool = false, - pub fn writeVersionPacket(this: *SocketIPCData) void { + keep_alive: bun.Async.KeepAlive = .{}, + close_next_tick: ?JSC.Task = null, + + pub fn writeVersionPacket(this: *SocketIPCData, global: *JSC.JSGlobalObject) void { if (Environment.allow_assert) { bun.assert(this.has_written_version == 0); } @@ -344,6 +352,8 @@ const SocketIPCData = struct { const n = this.socket.write(bytes, false); if (n >= 0 and n < @as(i32, @intCast(bytes.len))) { this.outgoing.write(bytes[@intCast(n)..]) catch bun.outOfMemory(); + // more remaining; need to ref event loop + this.keep_alive.ref(global.bunVM()); } } if (Environment.allow_assert) { @@ -370,6 +380,8 @@ const SocketIPCData = struct { ipc_data.outgoing.reset(); } else if (n > 0) { ipc_data.outgoing.cursor = @intCast(n); + // more remaining; need to ref event loop + ipc_data.keep_alive.ref(global.bunVM()); } } @@ -395,6 +407,8 @@ const SocketIPCData = struct { ipc_data.outgoing.reset(); } else if (n > 0) { ipc_data.outgoing.cursor = @intCast(n); + // more remaining; need to ref event loop + ipc_data.keep_alive.ref(global.bunVM()); } } @@ -406,7 +420,9 @@ const SocketIPCData = struct { if (this.disconnected) return; this.disconnected = true; if (nextTick) { - JSC.VirtualMachine.get().enqueueTask(JSC.ManagedTask.New(SocketIPCData, closeTask).init(this)); + if (this.close_next_tick != null) return; + this.close_next_tick = JSC.ManagedTask.New(SocketIPCData, closeTask).init(this); + JSC.VirtualMachine.get().enqueueTask(this.close_next_tick.?); } else { this.closeTask(); } @@ -414,9 +430,9 @@ const SocketIPCData = struct { pub fn closeTask(this: *SocketIPCData) void { log("SocketIPCData#closeTask", .{}); - if (this.disconnected) { - this.socket.close(.normal); - } + this.close_next_tick = null; + bun.assert(this.disconnected); + this.socket.close(.normal); } }; @@ -496,7 +512,7 @@ const NamedPipeIPCData = struct { } } - pub fn writeVersionPacket(this: *NamedPipeIPCData) void { + pub fn writeVersionPacket(this: *NamedPipeIPCData, _: *JSC.JSGlobalObject) void { if (Environment.allow_assert) { bun.assert(this.has_written_version == 0); } @@ -666,6 +682,7 @@ fn NewSocketIPCHandler(comptime Context: type) type { _: *anyopaque, _: Socket, ) void { + log("onOpen", .{}); // it is NOT safe to use the first argument here because it has not been initialized yet. // ideally we would call .ipc.writeVersionPacket() here, and we need that to handle the // theoretical write failure, but since the .ipc.outgoing buffer isn't available, that @@ -681,8 +698,21 @@ fn NewSocketIPCHandler(comptime Context: type) type { _: c_int, _: ?*anyopaque, ) void { + log("onClose", .{}); + const ipc = this.ipc() orelse return; + // unref if needed + ipc.keep_alive.unref((this.getGlobalThis() orelse return).bunVM()); // Note: uSockets has already freed the underlying socket, so calling Socket.close() can segfault log("NewSocketIPCHandler#onClose\n", .{}); + + if (ipc.close_next_tick) |close_next_tick_task| { + const managed: *bun.JSC.ManagedTask = close_next_tick_task.as(bun.JSC.ManagedTask); + managed.cancel(); + ipc.close_next_tick = null; + } + // after onClose(), socketIPCData.close should never be called again because socketIPCData may be freed. just in case, set disconnected to true. + ipc.disconnected = true; + this.handleIPCClose(); } @@ -721,8 +751,6 @@ fn NewSocketIPCHandler(comptime Context: type) type { return; }, error.InvalidFormat => { - Output.printErrorln("InvalidFormatError during IPC message handling", .{}); - this.handleIPCClose(); socket.close(.failure); return; }, @@ -751,8 +779,6 @@ fn NewSocketIPCHandler(comptime Context: type) type { return; }, error.InvalidFormat => { - Output.printErrorln("InvalidFormatError during IPC message handling", .{}); - this.handleIPCClose(); socket.close(.failure); return; }, @@ -774,42 +800,61 @@ fn NewSocketIPCHandler(comptime Context: type) type { context: *Context, socket: Socket, ) void { + log("onWritable", .{}); const ipc = context.ipc() orelse return; const to_write = ipc.outgoing.slice(); if (to_write.len == 0) { ipc.outgoing.reset(); + // done sending message; unref event loop + ipc.keep_alive.unref((context.getGlobalThis() orelse return).bunVM()); return; } const n = socket.write(to_write, false); if (n == to_write.len) { ipc.outgoing.reset(); + // almost done sending message; unref event loop + ipc.keep_alive.unref((context.getGlobalThis() orelse return).bunVM()); } else if (n > 0) { ipc.outgoing.cursor += @intCast(n); } } pub fn onTimeout( - _: *Context, + context: *Context, _: Socket, - ) void {} + ) void { + log("onTimeout", .{}); + const ipc = context.ipc() orelse return; + // unref if needed + ipc.keep_alive.unref((context.getGlobalThis() orelse return).bunVM()); + } pub fn onLongTimeout( - _: *Context, + context: *Context, _: Socket, - ) void {} + ) void { + log("onLongTimeout", .{}); + const ipc = context.ipc() orelse return; + // unref if needed + ipc.keep_alive.unref((context.getGlobalThis() orelse return).bunVM()); + } pub fn onConnectError( _: *anyopaque, _: Socket, _: c_int, ) void { + log("onConnectError", .{}); // context has not been initialized } pub fn onEnd( _: *Context, - _: Socket, - ) void {} + s: Socket, + ) void { + log("onEnd", .{}); + s.close(.failure); + } }; } @@ -865,7 +910,6 @@ fn NewNamedPipeIPCHandler(comptime Context: type) type { return; }, error.InvalidFormat => { - Output.printErrorln("InvalidFormatError during IPC message handling", .{}); ipc.close(false); return; }, diff --git a/src/bun.js/javascript.zig b/src/bun.js/javascript.zig index 27e9c1558f..397a0b774d 100644 --- a/src/bun.js/javascript.zig +++ b/src/bun.js/javascript.zig @@ -418,7 +418,7 @@ pub export fn Bun__GlobalObject__hasIPC(global: *JSGlobalObject) bool { return global.bunVM().ipc != null; } -extern fn Bun__Process__queueNextTick1(*ZigGlobalObject, JSValue, JSValue) void; +pub extern fn Bun__Process__queueNextTick1(*ZigGlobalObject, JSValue, JSValue) void; comptime { const Bun__Process__send = JSC.toJSHostFunction(Bun__Process__send_); @@ -464,7 +464,7 @@ pub fn Bun__Process__send_(globalObject: *JSGlobalObject, callFrame: *JSC.CallFr if (message.isUndefined()) { return globalObject.throwMissingArgumentsValue(&.{"message"}); } - if (!message.isString() and !message.isObject() and !message.isNumber() and !message.isBoolean()) { + if (!message.isString() and !message.isObject() and !message.isNumber() and !message.isBoolean() and !message.isNull()) { return globalObject.throwInvalidArgumentTypeValue("message", "string, object, number, or boolean", message); } @@ -472,7 +472,7 @@ pub fn Bun__Process__send_(globalObject: *JSGlobalObject, callFrame: *JSC.CallFr if (good) { if (callback.isFunction()) { - Bun__Process__queueNextTick1(zigGlobal, callback, .zero); + Bun__Process__queueNextTick1(zigGlobal, callback, .null); } } else { const ex = globalObject.createTypeErrorInstance("process.send() failed", .{}); @@ -726,7 +726,7 @@ const AutoKiller = struct { while (this.processes.popOrNull()) |process| { if (!process.key.hasExited()) { log("process.kill {d}", .{process.key.pid}); - count += @as(u32, @intFromBool(process.key.kill(bun.SignalCode.default) == .result)); + count += @as(u32, @intFromBool(process.key.kill(@intFromEnum(bun.SignalCode.default)) == .result)); } } return count; @@ -900,6 +900,15 @@ pub const VirtualMachine = struct { is_inside_deferred_task_queue: bool = false, + // defaults off. .on("message") will set it to true unles overridden + // process.channel.unref() will set it to false and mark it overridden + // on disconnect it will be disabled + channel_ref: bun.Async.KeepAlive = .{}, + // if process.channel.ref() or unref() has been called, this is set to true + channel_ref_overridden: bool = false, + // if one disconnect event listener should be ignored + channel_ref_should_ignore_one_disconnect_event_listener: bool = false, + pub const OnUnhandledRejection = fn (*VirtualMachine, globalObject: *JSGlobalObject, JSValue) void; pub const OnException = fn (*ZigException) void; @@ -4369,7 +4378,7 @@ pub const VirtualMachine = struct { extern fn Process__emitMessageEvent(global: *JSGlobalObject, value: JSValue) void; extern fn Process__emitDisconnectEvent(global: *JSGlobalObject) void; - extern fn Process__emitErrorEvent(global: *JSGlobalObject, value: JSValue) void; + pub extern fn Process__emitErrorEvent(global: *JSGlobalObject, value: JSValue) void; pub const IPCInstanceUnion = union(enum) { /// IPC is put in this "enabled but not started" state when IPC is detected @@ -4394,6 +4403,9 @@ pub const VirtualMachine = struct { pub fn ipc(this: *IPCInstance) ?*IPC.IPCData { return &this.data; } + pub fn getGlobalThis(this: *IPCInstance) ?*JSGlobalObject { + return this.globalThis; + } pub fn handleIPCMessage(this: *IPCInstance, message: IPC.DecodedIPCMessage) void { JSC.markBinding(@src()); @@ -4433,19 +4445,13 @@ pub const VirtualMachine = struct { if (Environment.isPosix) { uws.us_socket_context_free(0, this.context); } + vm.channel_ref.disable(); this.destroy(); } - extern fn Bun__setChannelRef(*JSGlobalObject, bool) void; - export fn Bun__closeChildIPC(global: *JSGlobalObject) void { - if (global.bunVM().ipc) |*current_ipc| { - switch (current_ipc.*) { - .initialized => |instance| { - instance.data.close(true); - }, - .waiting => {}, - } + if (global.bunVM().getIPCInstance()) |current_ipc| { + current_ipc.data.close(true); } } @@ -4512,7 +4518,7 @@ pub const VirtualMachine = struct { }, }; - instance.data.writeVersionPacket(); + instance.data.writeVersionPacket(this.global); return instance; } diff --git a/src/bun.js/node/node_cluster_binding.zig b/src/bun.js/node/node_cluster_binding.zig index 34bde68225..508fb1025c 100644 --- a/src/bun.js/node/node_cluster_binding.zig +++ b/src/bun.js/node/node_cluster_binding.zig @@ -260,8 +260,6 @@ pub fn handleInternalMessagePrimary(globalThis: *JSC.JSGlobalObject, subprocess: // // -extern fn Bun__setChannelRef(*JSC.JSGlobalObject, bool) void; - pub fn setRef(globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { const arguments = callframe.arguments_old(1).ptr; @@ -273,6 +271,34 @@ pub fn setRef(globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun. } const enabled = arguments[0].toBoolean(); - Bun__setChannelRef(globalObject, enabled); + const vm = globalObject.bunVM(); + vm.channel_ref_overridden = true; + if (enabled) { + vm.channel_ref.ref(vm); + } else { + vm.channel_ref.unref(vm); + } return .undefined; } + +export fn Bun__refChannelUnlessOverridden(globalObject: *JSC.JSGlobalObject) void { + const vm = globalObject.bunVM(); + if (!vm.channel_ref_overridden) { + vm.channel_ref.ref(vm); + } +} +export fn Bun__unrefChannelUnlessOverridden(globalObject: *JSC.JSGlobalObject) void { + const vm = globalObject.bunVM(); + if (!vm.channel_ref_overridden) { + vm.channel_ref.unref(vm); + } +} +pub fn channelIgnoreOneDisconnectEventListener(globalObject: *JSC.JSGlobalObject, _: *JSC.CallFrame) bun.JSError!JSC.JSValue { + const vm = globalObject.bunVM(); + vm.channel_ref_should_ignore_one_disconnect_event_listener = true; + return .false; +} +export fn Bun__shouldIgnoreOneDisconnectEventListener(globalObject: *JSC.JSGlobalObject) bool { + const vm = globalObject.bunVM(); + return vm.channel_ref_should_ignore_one_disconnect_event_listener; +} diff --git a/src/bun.js/node/node_util_binding.zig b/src/bun.js/node/node_util_binding.zig index 97ac1b2207..d9f76e3f5b 100644 --- a/src/bun.js/node/node_util_binding.zig +++ b/src/bun.js/node/node_util_binding.zig @@ -109,6 +109,10 @@ pub fn internalErrorName(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFr return fmtstring.transferToJS(globalThis); } +pub fn etimedoutErrorCode(_: *JSC.JSGlobalObject, _: *JSC.CallFrame) bun.JSError!JSC.JSValue { + return JSC.JSValue.jsNumberFromInt32(-bun.C.UV_ETIMEDOUT); +} + /// `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/bun.js/webcore/blob.zig b/src/bun.js/webcore/blob.zig index 10d4177e4e..306ba8ebd2 100644 --- a/src/bun.js/webcore/blob.zig +++ b/src/bun.js/webcore/blob.zig @@ -5302,7 +5302,7 @@ pub const Blob = struct { return toStringWithBytes(this, global, view_, lifetime); } - pub fn toJSON(this: *Blob, global: *JSGlobalObject, comptime lifetime: Lifetime) JSValue { + pub fn toJSON(this: *Blob, global: *JSGlobalObject, comptime lifetime: Lifetime) bun.JSError!JSValue { if (this.needsToReadFile()) { return this.doReadFile(toJSONWithBytes, global); } @@ -5315,7 +5315,7 @@ pub const Blob = struct { return toJSONWithBytes(this, global, view_, lifetime); } - pub fn toJSONWithBytes(this: *Blob, global: *JSGlobalObject, raw_bytes: []const u8, comptime lifetime: Lifetime) JSValue { + pub fn toJSONWithBytes(this: *Blob, global: *JSGlobalObject, raw_bytes: []const u8, comptime lifetime: Lifetime) bun.JSError!JSValue { const bom, const buf = strings.BOM.detectAndSplit(raw_bytes); if (buf.len == 0) return global.createSyntaxErrorInstance("Unexpected end of JSON input", .{}); @@ -5913,7 +5913,7 @@ pub const AnyBlob = union(enum) { promise.wrap(globalThis, toActionValue, .{ this, globalThis, action }); } - pub fn toJSON(this: *AnyBlob, global: *JSGlobalObject, comptime lifetime: JSC.WebCore.Lifetime) JSValue { + pub fn toJSON(this: *AnyBlob, global: *JSGlobalObject, comptime lifetime: JSC.WebCore.Lifetime) bun.JSError!JSValue { switch (this.*) { .Blob => return this.Blob.toJSON(global, lifetime), // .InlineBlob => { @@ -5953,7 +5953,7 @@ pub const AnyBlob = union(enum) { } } - pub fn toJSONShare(this: *AnyBlob, global: *JSGlobalObject) JSValue { + pub fn toJSONShare(this: *AnyBlob, global: *JSGlobalObject) bun.JSError!JSValue { return this.toJSON(global, .share); } diff --git a/src/bun.zig b/src/bun.zig index 1b8aaf7459..b00c2433d3 100644 --- a/src/bun.zig +++ b/src/bun.zig @@ -1226,7 +1226,7 @@ pub const SignalCode = enum(u8) { // 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 = @intFromEnum(SignalCode.SIGTERM); + pub const default = SignalCode.SIGTERM; pub const Map = ComptimeEnumMap(SignalCode); pub fn name(value: SignalCode) ?[]const u8 { if (@intFromEnum(value) <= @intFromEnum(SignalCode.SIGSYS)) { diff --git a/src/js/internal/cluster/child.ts b/src/js/internal/cluster/child.ts index c1e6ebdb38..6e9a94686d 100644 --- a/src/js/internal/cluster/child.ts +++ b/src/js/internal/cluster/child.ts @@ -34,6 +34,9 @@ cluster._setupWorker = function () { cluster.worker = worker; + // make sure the process.once("disconnect") doesn't count as a ref + // 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", () => { worker.emit("disconnect"); diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index e4e5793883..8bfba5bc1e 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -178,9 +178,6 @@ function spawn(file, args, options) { abortChildProcess(child, killSignal, signal.reason); } } - process.nextTick(() => { - child.emit("spawn"); - }); return child; } @@ -295,11 +292,11 @@ function execFile(file, args, options, callback) { if (args?.length) cmd += ` ${ArrayPrototypeJoin.$call(args, " ")}`; if (!ex) { + const { getSystemErrorName } = require("node:util"); let message = `Command failed: ${cmd}`; if (stderr) message += `\n${stderr}`; ex = genericNodeError(message, { - // code: code < 0 ? getSystemErrorName(code) : code, // TODO: Add getSystemErrorName - code: code, + code: code < 0 ? getSystemErrorName(code) : code, killed: child.killed || killed, signal: signal, }); @@ -565,13 +562,21 @@ function spawnSync(file, args, options) { success, exitCode, signalCode, + exitedDueToTimeout, + pid, } = Bun.spawnSync({ - cmd: options.args, + // normalizeSpawnargs has already prepended argv0 to the spawnargs array + // Bun.spawn() expects cmd[0] to be the command to run, and argv0 to replace the first arg when running the command, + // so we have to set argv0 to spawnargs[0] and cmd[0] to file + cmd: [options.file, ...Array.prototype.slice.$call(options.args, 1)], env: options.env || undefined, cwd: options.cwd || undefined, stdio: bunStdio, windowsVerbatimArguments: options.windowsVerbatimArguments, windowsHide: options.windowsHide, + argv0: options.args[0], + timeout: options.timeout, + killSignal: options.killSignal, }); } catch (err) { error = err; @@ -584,6 +589,7 @@ function spawnSync(file, args, options) { status: exitCode, // TODO: Need to expose extra pipes from Bun.spawnSync to child_process output: [null, stdout, stderr], + pid, }; if (error) { @@ -601,16 +607,24 @@ function spawnSync(file, args, options) { result.stdout = result.output[1]; result.stderr = result.output[2]; - if (!success && error == null) { - result.error = new SystemError(result.output[2], options.file, "spawnSync", -1, result.status); + if (exitedDueToTimeout && error == null) { + result.error = new SystemError( + "spawnSync " + options.file + " ETIMEDOUT", + options.file, + "spawnSync " + options.file, + etimedoutErrorCode(), + "ETIMEDOUT", + ); } if (result.error) { + result.error.syscall = "spawnSync " + options.file; result.error.spawnargs = ArrayPrototypeSlice.$call(options.args, 1); } return result; } +const etimedoutErrorCode = $newZigFunction("node_util_binding.zig", "etimedoutErrorCode", 0); /** * Spawns a file as a shell synchronously. @@ -770,7 +784,7 @@ function fork(modulePath, args = [], options) { // and stderr from the parent if silent isn't set. options.stdio = stdioStringToArray(options.silent ? "pipe" : "inherit", "ipc"); } else if (!ArrayPrototypeIncludes.$call(options.stdio, "ipc")) { - throw ERR_CHILD_PROCESS_IPC_REQUIRED("options.stdio"); + throw $ERR_CHILD_PROCESS_IPC_REQUIRED("options.stdio"); } return spawn(options.execPath, args, options); @@ -870,6 +884,7 @@ function normalizeExecArgs(command, options, callback) { }; } +const kBunEnv = Symbol("bunEnv"); function normalizeSpawnArguments(file, args, options) { validateString(file, "file"); validateArgumentNullCheck(file, "file"); @@ -966,7 +981,7 @@ function normalizeSpawnArguments(file, args, options) { } const env = options.env || process.env; - const envPairs = {}; + const bunEnv = {}; // // process.env.NODE_V8_COVERAGE always propagates, making it possible to // // collect coverage for programs that spawn with white-listed environment. @@ -995,7 +1010,7 @@ function normalizeSpawnArguments(file, args, options) { if (value !== undefined) { validateArgumentNullCheck(key, `options.env['${key}']`); validateArgumentNullCheck(value, `options.env['${key}']`); - envPairs[key] = value; + bunEnv[key] = value; } } @@ -1005,11 +1020,13 @@ function normalizeSpawnArguments(file, args, options) { ...options, args, cwd, + detached: !!options.detached, - envPairs, + [kBunEnv]: bunEnv, file, windowsHide: !!options.windowsHide, windowsVerbatimArguments: !!windowsVerbatimArguments, + argv0: options.argv0, }; } @@ -1026,6 +1043,15 @@ function checkExecSyncError(ret, args, cmd?) { } return err; } +function parseEnvPairs(envPairs: string[] | undefined): Record | undefined { + if (!envPairs) return undefined; + const resEnv = {}; + for (const line of envPairs) { + const [key, ...value] = line.split("=", 2); + resEnv[key] = value.join("="); + } + return resEnv; +} //------------------------------------------------------------------------------ // Section 3. ChildProcess class @@ -1241,28 +1267,19 @@ class ChildProcess extends EventEmitter { validateOneOf(options.serialization, "options.serialization", [undefined, "json", "advanced"]); const serialization = options.serialization || "json"; - validateString(options.file, "options.file"); - // NOTE: This is confusing... So node allows you to pass a file name - // But also allows you to pass a command in the args and it should execute - // To add another layer of confusion, they also give the option to pass an explicit "argv0" - // which overrides the actual command of the spawned process... - var file; - file = this.spawnfile = options.file; - - var spawnargs; - if (options.args == null) { - spawnargs = this.spawnargs = []; - } else { - validateArray(options.args, "options.args"); - spawnargs = this.spawnargs = options.args; - } - const stdio = options.stdio || ["pipe", "pipe", "pipe"]; const bunStdio = getBunStdioFromOptions(stdio); - const argv0 = file || options.argv0; - const has_ipc = $isJSArray(stdio) && stdio[3] === "ipc"; - var env = options.envPairs || process.env; + const has_ipc = $isJSArray(stdio) && stdio.includes("ipc"); + + // validate options.envPairs but only if has_ipc. for some reason. + if (has_ipc) { + if (options.envPairs !== undefined) { + validateArray(options.envPairs, "options.envPairs"); + } + } + + var env = options[kBunEnv] || parseEnvPairs(options.envPairs) || process.env; const detachedOption = options.detached; this.#encoding = options.encoding || undefined; @@ -1270,55 +1287,84 @@ class ChildProcess extends EventEmitter { const stdioCount = stdio.length; const hasSocketsToEagerlyLoad = stdioCount >= 3; - this.#handle = Bun.spawn({ - cmd: spawnargs, - stdio: bunStdio, - cwd: options.cwd || undefined, - env: env, - detached: typeof detachedOption !== "undefined" ? !!detachedOption : false, - onExit: (handle, exitCode, signalCode, err) => { - this.#handle = handle; - this.pid = this.#handle.pid; - $debug("ChildProcess: onExit", exitCode, signalCode, err, this.pid); + validateString(options.file, "options.file"); + var file; + file = this.spawnfile = options.file; - if (hasSocketsToEagerlyLoad) { - process.nextTick(() => { - this.stdio; - $debug("ChildProcess: onExit", exitCode, signalCode, err, this.pid); - }); - } - - process.nextTick( - (exitCode, signalCode, err) => this.#handleOnExit(exitCode, signalCode, err), - exitCode, - signalCode, - err, - ); - }, - lazy: true, - ipc: has_ipc ? this.#emitIpcMessage.bind(this) : undefined, - onDisconnect: has_ipc ? ok => this.#disconnect(ok) : undefined, - serialization, - argv0, - windowsHide: !!options.windowsHide, - windowsVerbatimArguments: !!options.windowsVerbatimArguments, - }); - this.pid = this.#handle.pid; - - $debug("ChildProcess: spawn", this.pid, spawnargs); - - onSpawnNT(this); - - if (has_ipc) { - this.send = this.#send; - this.disconnect = this.#disconnect; - if (options[kFromNode]) this.#closesNeeded += 1; + var spawnargs; + if (options.args === undefined) { + spawnargs = this.spawnargs = []; + // how is this allowed? + } else { + validateArray(options.args, "options.args"); + spawnargs = this.spawnargs = options.args; } + // normalizeSpawnargs has already prepended argv0 to the spawnargs array + // Bun.spawn() expects cmd[0] to be the command to run, and argv0 to replace the first arg when running the command, + // so we have to set argv0 to spawnargs[0] and cmd[0] to file - if (hasSocketsToEagerlyLoad) { - for (let item of this.stdio) { - item?.ref?.(); + try { + this.#handle = Bun.spawn({ + cmd: [file, ...Array.prototype.slice.$call(spawnargs, 1)], + stdio: bunStdio, + cwd: options.cwd || undefined, + env: env, + detached: typeof detachedOption !== "undefined" ? !!detachedOption : false, + onExit: (handle, exitCode, signalCode, err) => { + this.#handle = handle; + this.pid = this.#handle.pid; + $debug("ChildProcess: onExit", exitCode, signalCode, err, this.pid); + + if (hasSocketsToEagerlyLoad) { + process.nextTick(() => { + this.stdio; + $debug("ChildProcess: onExit", exitCode, signalCode, err, this.pid); + }); + } + + process.nextTick( + (exitCode, signalCode, err) => this.#handleOnExit(exitCode, signalCode, err), + exitCode, + signalCode, + err, + ); + }, + lazy: true, + ipc: has_ipc ? this.#emitIpcMessage.bind(this) : undefined, + onDisconnect: has_ipc ? ok => this.#onDisconnect(ok) : undefined, + serialization, + argv0: spawnargs[0], + windowsHide: !!options.windowsHide, + windowsVerbatimArguments: !!options.windowsVerbatimArguments, + }); + this.pid = this.#handle.pid; + + $debug("ChildProcess: spawn", this.pid, spawnargs); + + process.nextTick(() => { + this.emit("spawn"); + }); + + if (has_ipc) { + this.send = this.#send; + this.disconnect = this.#disconnect; + if (options[kFromNode]) this.#closesNeeded += 1; } + + if (hasSocketsToEagerlyLoad) { + for (let item of this.stdio) { + item?.ref?.(); + } + } + } catch (ex) { + if (ex == null || typeof ex !== "object" || !Object.hasOwn(ex, "errno")) throw ex; + this.#handle = null; + ex.syscall = "spawn " + this.spawnfile; + ex.spawnargs = Array.prototype.slice.$call(this.spawnargs, 1); + process.nextTick(() => { + this.emit("error", ex); + this.emit("close", (ex as SystemError).errno ?? -1); + }); } } @@ -1352,7 +1398,7 @@ class ChildProcess extends EventEmitter { // Bun does not handle handles yet try { this.#handle.send(message); - if (callback) process.nextTick(callback); + if (callback) process.nextTick(callback, null); return true; } catch (error) { if (callback) { @@ -1364,18 +1410,21 @@ class ChildProcess extends EventEmitter { } } - #disconnect(ok) { - if (ok == null) { - $assert(this.connected); - this.#handle.disconnect(); - } else if (!ok) { + #onDisconnect(firstTime: boolean) { + if (!firstTime) { + // strange + return; + } + $assert(!this.connected); + this.#maybeClose(); + process.nextTick(() => this.emit("disconnect")); + } + #disconnect() { + if (!this.connected) { this.emit("error", $ERR_IPC_DISCONNECTED()); return; } this.#handle.disconnect(); - $assert(!this.connected); - process.nextTick(() => this.emit("disconnect")); - this.#maybeClose(); } kill(sig?) { @@ -1552,10 +1601,6 @@ function normalizeStdio(stdio): string[] { } } -function onSpawnNT(self) { - self.emit("spawn"); -} - function abortChildProcess(child, killSignal, reason) { if (!child) return; try { @@ -1599,6 +1644,10 @@ class ShimmedStdioOutStream extends EventEmitter { destroy() { return this; } + + setEncoding() { + return this; + } } //------------------------------------------------------------------------------ @@ -1695,11 +1744,10 @@ var Error = globalThis.Error; var TypeError = globalThis.TypeError; var RangeError = globalThis.RangeError; -function genericNodeError(message, options) { +function genericNodeError(message, errorProperties) { + // eslint-disable-next-line no-restricted-syntax const err = new Error(message); - err.code = options.code; - err.killed = options.killed; - err.signal = options.signal; + ObjectAssign(err, errorProperties); return err; } @@ -1852,12 +1900,6 @@ function ERR_INVALID_OPT_VALUE(name, value) { return err; } -function ERR_CHILD_PROCESS_IPC_REQUIRED(name) { - const err = new TypeError(`Forked processes must have an IPC channel, missing value 'ipc' in ${name}`); - err.code = "ERR_CHILD_PROCESS_IPC_REQUIRED"; - return err; -} - class SystemError extends Error { path; syscall; diff --git a/src/js/node/cluster.ts b/src/js/node/cluster.ts index a3f7b0595b..1238d42764 100644 --- a/src/js/node/cluster.ts +++ b/src/js/node/cluster.ts @@ -12,8 +12,6 @@ function initializeClusterIPC() { cluster._setupWorker(); // Make sure it's not accidentally inherited by child processes. delete process.env.NODE_UNIQUE_ID; - - process.channel.unref(); } } diff --git a/src/string.zig b/src/string.zig index 7b0265682d..5452b00463 100644 --- a/src/string.zig +++ b/src/string.zig @@ -635,9 +635,11 @@ pub const String = extern struct { this: *String, ) JSC.JSValue; - pub fn toJSByParseJSON(self: *String, globalObject: *JSC.JSGlobalObject) JSC.JSValue { + pub fn toJSByParseJSON(self: *String, globalObject: *JSC.JSGlobalObject) bun.JSError!JSC.JSValue { JSC.markBinding(@src()); - return BunString__toJSON(globalObject, self); + const result = BunString__toJSON(globalObject, self); + if (result == .zero) return error.JSError; + return result; } pub fn encodeInto(self: String, out: []u8, comptime enc: JSC.Node.Encoding) !usize { diff --git a/test/js/node/test/parallel/test-child-process-advanced-serialization-largebuffer.js b/test/js/node/test/parallel/test-child-process-advanced-serialization-largebuffer.js new file mode 100644 index 0000000000..4e80bce405 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-advanced-serialization-largebuffer.js @@ -0,0 +1,27 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const child_process = require('child_process'); + +// Regression test for https://github.com/nodejs/node/issues/34797 +const eightMB = 8 * 1024 * 1024; + +if (process.argv[2] === 'child') { + for (let i = 0; i < 4; i++) { + process.send(new Uint8Array(eightMB).fill(i)); + } +} else { + const child = child_process.spawn(process.execPath, [__filename, 'child'], { + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + serialization: 'advanced' + }); + const received = []; + child.on('message', common.mustCall((chunk) => { + assert.deepStrictEqual(chunk, new Uint8Array(eightMB).fill(chunk[0])); + + received.push(chunk[0]); + if (received.length === 4) { + assert.deepStrictEqual(received, [0, 1, 2, 3]); + } + }, 4)); +} diff --git a/test/js/node/test/parallel/test-child-process-advanced-serialization.js b/test/js/node/test/parallel/test-child-process-advanced-serialization.js new file mode 100644 index 0000000000..14c9b2be87 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-advanced-serialization.js @@ -0,0 +1,48 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const child_process = require('child_process'); +const { once } = require('events'); +const { inspect } = require('util'); + +if (process.argv[2] !== 'child') { + for (const value of [null, 42, Infinity, 'foo']) { + assert.throws(() => { + child_process.spawn(process.execPath, [], { serialization: value }); + }, { + code: 'ERR_INVALID_ARG_VALUE', + message: "The argument 'options.serialization' " + + "must be one of: undefined, 'json', 'advanced'. " + + `Received ${inspect(value)}` + }); + } + + (async () => { + const cp = child_process.spawn(process.execPath, [__filename, 'child'], + { + stdio: ['ipc', 'inherit', 'inherit'], + serialization: 'advanced' + }); + + const circular = {}; + circular.circular = circular; + for await (const message of [ + { uint8: new Uint8Array(4) }, + { float64: new Float64Array([ Math.PI ]) }, + { buffer: Buffer.from('Hello!') }, + { map: new Map([{ a: 1 }, { b: 2 }]) }, + { bigInt: 1337n }, + circular, + new Error('Something went wrong'), + new RangeError('Something range-y went wrong'), + ]) { + cp.send(message); + const [ received ] = await once(cp, 'message'); + assert.deepStrictEqual(received, message); + } + + cp.disconnect(); + })().then(common.mustCall()); +} else { + process.on('message', (msg) => process.send(msg)); +} diff --git a/test/js/node/test/parallel/test-child-process-constructor.js b/test/js/node/test/parallel/test-child-process-constructor.js new file mode 100644 index 0000000000..75bc157e07 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-constructor.js @@ -0,0 +1,90 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { ChildProcess } = require('child_process'); +assert.strictEqual(typeof ChildProcess, 'function'); + +{ + // Verify that invalid options to spawn() throw. + const child = new ChildProcess(); + + [undefined, null, 'foo', 0, 1, NaN, true, false].forEach((options) => { + assert.throws(() => { + child.spawn(options); + }, { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: 'The "options" argument must be of type object.' + + `${common.invalidArgTypeHelper(options)}` + }); + }); +} + +{ + // Verify that spawn throws if file is not a string. + const child = new ChildProcess(); + + [undefined, null, 0, 1, NaN, true, false, {}].forEach((file) => { + assert.throws(() => { + child.spawn({ file }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: 'The "options.file" property must be of type string.' + + `${common.invalidArgTypeHelper(file)}` + }); + }); +} + +{ + // Verify that spawn throws if envPairs is not an array or undefined. + const child = new ChildProcess(); + + [null, 0, 1, NaN, true, false, {}, 'foo'].forEach((envPairs) => { + assert.throws(() => { + child.spawn({ envPairs, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: 'The "options.envPairs" property must be of type Array.' + + common.invalidArgTypeHelper(envPairs) + }); + }); +} + +{ + // Verify that spawn throws if args is not an array or undefined. + const child = new ChildProcess(); + + [null, 0, 1, NaN, true, false, {}, 'foo'].forEach((args) => { + assert.throws(() => { + child.spawn({ file: 'foo', args }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: 'The "options.args" property must be of type Array.' + + common.invalidArgTypeHelper(args) + }); + }); +} + +// Test that we can call spawn +const child = new ChildProcess(); +child.spawn({ + file: process.execPath, + args: ['--interactive'], + cwd: process.cwd(), + stdio: 'pipe' +}); + +assert.strictEqual(Object.hasOwn(child, 'pid'), true); +assert(Number.isInteger(child.pid)); + +// Try killing with invalid signal +assert.throws( + () => { child.kill('foo'); }, + { code: 'ERR_UNKNOWN_SIGNAL', name: 'TypeError' } +); + +assert.strictEqual(child.kill(), true); diff --git a/test/js/node/test/parallel/test-child-process-cwd.js b/test/js/node/test/parallel/test-child-process-cwd.js new file mode 100644 index 0000000000..e876361b16 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-cwd.js @@ -0,0 +1,102 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; + +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +const assert = require('assert'); +const { spawn } = require('child_process'); + +// Spawns 'pwd' with given options, then test +// - whether the child pid is undefined or number, +// - whether the exit code equals expectCode, +// - optionally whether the trimmed stdout result matches expectData +function testCwd(options, expectPidType, expectCode = 0, expectData) { + const child = spawn(...common.pwdCommand, options); + + assert.strictEqual(typeof child.pid, expectPidType); + + child.stdout.setEncoding('utf8'); + + // No need to assert callback since `data` is asserted. + let data = ''; + child.stdout.on('data', function(chunk) { + data += chunk; + }); + + // Can't assert callback, as stayed in to API: + // _The 'exit' event may or may not fire after an error has occurred._ + child.on('exit', function(code, signal) { + assert.strictEqual(code, expectCode); + }); + + child.on('close', common.mustCall(function() { + if (expectData) { + // In Windows, compare without considering case + if (common.isWindows) { + assert.strictEqual(data.trim().toLowerCase(), expectData.toLowerCase()); + } else { + assert.strictEqual(data.trim(), expectData); + } + } + })); + + return child; +} + + +// Assume does-not-exist doesn't exist, expect exitCode=-1 and errno=ENOENT +{ + testCwd({ cwd: 'does-not-exist' }, 'undefined', -1) + .on('error', common.mustCall(function(e) { + assert.strictEqual(e.code, 'ENOENT'); + })); +} + +{ + assert.throws(() => { + testCwd({ + cwd: new URL('http://example.com/'), + }, 'number', 0, tmpdir.path); + }, /The URL must be of scheme file/); + + if (process.platform !== 'win32') { + assert.throws(() => { + testCwd({ + cwd: new URL('file://host/dev/null'), + }, 'number', 0, tmpdir.path); + }, /File URL host must be "localhost" or empty on/); + } +} + +// Assume these exist, and 'pwd' gives us the right directory back +testCwd({ cwd: tmpdir.path }, 'number', 0, tmpdir.path); +const shouldExistDir = common.isWindows ? process.env.windir : '/dev'; +testCwd({ cwd: shouldExistDir }, 'number', 0, shouldExistDir); +testCwd({ cwd: tmpdir.fileURL() }, 'number', 0, tmpdir.path); + +// Spawn() shouldn't try to chdir() to invalid arg, so this should just work +testCwd({ cwd: '' }, 'number'); +testCwd({ cwd: undefined }, 'number'); +testCwd({ cwd: null }, 'number'); diff --git a/test/js/node/test/parallel/test-child-process-disconnect.js b/test/js/node/test/parallel/test-child-process-disconnect.js new file mode 100644 index 0000000000..fb8a2dd0ea --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-disconnect.js @@ -0,0 +1,115 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const fork = require('child_process').fork; +const net = require('net'); + +// child +if (process.argv[2] === 'child') { + + // Check that the 'disconnect' event is deferred to the next event loop tick. + const disconnect = process.disconnect; + process.disconnect = function() { + disconnect.apply(this, arguments); + // If the event is emitted synchronously, we're too late by now. + process.once('disconnect', common.mustCall(disconnectIsNotAsync)); + // The funky function name makes it show up legible in mustCall errors. + function disconnectIsNotAsync() {} + }; + + const server = net.createServer(); + + server.on('connection', function(socket) { + + socket.resume(); + + process.on('disconnect', function() { + socket.end((process.connected).toString()); + }); + + // When the socket is closed, we will close the server + // allowing the process to self terminate + socket.on('end', function() { + server.close(); + }); + + socket.write('ready'); + }); + + // When the server is ready tell parent + server.on('listening', function() { + process.send({ msg: 'ready', port: server.address().port }); + }); + + server.listen(0); + +} else { + // testcase + const child = fork(process.argv[1], ['child']); + + let childFlag = false; + let parentFlag = false; + + // When calling .disconnect the event should emit + // and the disconnected flag should be true. + child.on('disconnect', common.mustCall(function() { + parentFlag = child.connected; + })); + + // The process should also self terminate without using signals + child.on('exit', common.mustCall()); + + // When child is listening + child.on('message', function(obj) { + if (obj && obj.msg === 'ready') { + + // Connect to child using TCP to know if disconnect was emitted + const socket = net.connect(obj.port); + + socket.on('data', function(data) { + data = data.toString(); + + // Ready to be disconnected + if (data === 'ready') { + child.disconnect(); + assert.throws( + child.disconnect.bind(child), + { + code: 'ERR_IPC_DISCONNECTED' + }); + return; + } + + // 'disconnect' is emitted + childFlag = (data === 'true'); + }); + + } + }); + + process.on('exit', function() { + assert.strictEqual(childFlag, false); + assert.strictEqual(parentFlag, false); + }); +} diff --git a/test/js/node/test/parallel/test-child-process-double-pipe.js b/test/js/node/test/parallel/test-child-process-double-pipe.js new file mode 100644 index 0000000000..7a432d3892 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-double-pipe.js @@ -0,0 +1,122 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const { + isWindows, + mustCall, + mustCallAtLeast, +} = require('../common'); +const assert = require('assert'); +const os = require('os'); +const spawn = require('child_process').spawn; +const debug = require('util').debuglog('test'); + +// We're trying to reproduce: +// $ echo "hello\nnode\nand\nworld" | grep o | sed s/o/a/ + +let grep, sed, echo; + +if (isWindows) { + grep = spawn('grep', ['--binary', 'o']); + sed = spawn('sed', ['--binary', 's/o/O/']); + echo = spawn('cmd.exe', + ['/c', 'echo', 'hello&&', 'echo', + 'node&&', 'echo', 'and&&', 'echo', 'world']); +} else { + grep = spawn('grep', ['o']); + sed = spawn('sed', ['s/o/O/']); + echo = spawn('echo', ['hello\nnode\nand\nworld\n']); +} + +// If the spawn function leaks file descriptors to subprocesses, grep and sed +// hang. +// This happens when calling pipe(2) and then forgetting to set the +// FD_CLOEXEC flag on the resulting file descriptors. +// +// This test checks child processes exit, meaning they don't hang like +// explained above. + + +// pipe echo | grep +echo.stdout.on('data', mustCallAtLeast((data) => { + debug(`grep stdin write ${data.length}`); + if (!grep.stdin.write(data)) { + echo.stdout.pause(); + } +})); + +// TODO(@jasnell): This does not appear to ever be +// emitted. It's not clear if it is necessary. +grep.stdin.on('drain', (data) => { + echo.stdout.resume(); +}); + +// Propagate end from echo to grep +echo.stdout.on('end', mustCall((code) => { + grep.stdin.end(); +})); + +echo.on('exit', mustCall(() => { + debug('echo exit'); +})); + +grep.on('exit', mustCall(() => { + debug('grep exit'); +})); + +sed.on('exit', mustCall(() => { + debug('sed exit'); +})); + + +// pipe grep | sed +grep.stdout.on('data', mustCallAtLeast((data) => { + debug(`grep stdout ${data.length}`); + if (!sed.stdin.write(data)) { + grep.stdout.pause(); + } +})); + +// TODO(@jasnell): This does not appear to ever be +// emitted. It's not clear if it is necessary. +sed.stdin.on('drain', (data) => { + grep.stdout.resume(); +}); + +// Propagate end from grep to sed +grep.stdout.on('end', mustCall((code) => { + debug('grep stdout end'); + sed.stdin.end(); +})); + + +let result = ''; + +// print sed's output +sed.stdout.on('data', mustCallAtLeast((data) => { + result += data.toString('utf8', 0, data.length); + debug(data); +})); + +sed.stdout.on('end', mustCall((code) => { + assert.strictEqual(result, `hellO${os.EOL}nOde${os.EOL}wOrld${os.EOL}`); +})); diff --git a/test/js/node/test/parallel/test-child-process-exec-error.js b/test/js/node/test/parallel/test-child-process-exec-error.js new file mode 100644 index 0000000000..cd45f3071c --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-exec-error.js @@ -0,0 +1,44 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const child_process = require('child_process'); + +function test(fn, code, expectPidType = 'number') { + const child = fn('does-not-exist', common.mustCall(function(err) { + assert.strictEqual(err.code, code); + assert(err.cmd.includes('does-not-exist')); + })); + + assert.strictEqual(typeof child.pid, expectPidType); +} + +// With `shell: true`, expect pid (of the shell) +if (common.isWindows) { + test(child_process.exec, 1, 'number'); // Exit code of cmd.exe +} else { + test(child_process.exec, 127, 'number'); // Exit code of /bin/sh +} + +// With `shell: false`, expect no pid +test(child_process.execFile, 'ENOENT', 'undefined'); diff --git a/test/js/node/test/parallel/test-child-process-execfile.js b/test/js/node/test/parallel/test-child-process-execfile.js new file mode 100644 index 0000000000..c4dba6b3f9 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-execfile.js @@ -0,0 +1,127 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { execFile, execFileSync } = require('child_process'); +const { getEventListeners } = require('events'); +const { getSystemErrorName } = require('util'); +const fixtures = require('../common/fixtures'); +const os = require('os'); + +const fixture = fixtures.path('exit.js'); +const echoFixture = fixtures.path('echo.js'); +const execOpts = { encoding: 'utf8', shell: true, env: { ...process.env, NODE: process.execPath, FIXTURE: fixture } }; + +{ + execFile( + process.execPath, + [fixture, 42], + common.mustCall((e) => { + // Check that arguments are included in message + assert.strictEqual(e.message.trim(), + `Command failed: ${process.execPath} ${fixture} 42`); + assert.strictEqual(e.code, 42); + }) + ); +} + +{ + // Verify that negative exit codes can be translated to UV error names. + const errorString = `Error: Command failed: ${process.execPath}`; + const code = -1; + const callback = common.mustCall((err, stdout, stderr) => { + assert.strictEqual(err.toString().trim(), errorString); + assert.strictEqual(err.code, getSystemErrorName(code)); + assert.strictEqual(err.killed, true); + assert.strictEqual(err.signal, null); + assert.strictEqual(err.cmd, process.execPath); + assert.strictEqual(stdout.trim(), ''); + assert.strictEqual(stderr.trim(), ''); + }); + const child = execFile(process.execPath, callback); + + child.kill(); + child.emit('close', code, null); +} + +{ + // Verify the shell option works properly + execFile( + `"${common.isWindows ? execOpts.env.NODE : '$NODE'}"`, + [`"${common.isWindows ? execOpts.env.FIXTURE : '$FIXTURE'}"`, 0], + execOpts, + common.mustSucceed(), + ); +} + +{ + // Verify that the signal option works properly + const ac = new AbortController(); + const { signal } = ac; + + const test = () => { + const check = common.mustCall((err) => { + assert.strictEqual(err.code, 'ABORT_ERR'); + assert.strictEqual(err.name, 'AbortError'); + assert.strictEqual(err.signal, undefined); + }); + execFile(process.execPath, [echoFixture, 0], { signal }, check); + }; + + // Verify that it still works the same way now that the signal is aborted. + test(); + ac.abort(); +} + +{ + // Verify that does not spawn a child if already aborted + const signal = AbortSignal.abort(); + + const check = common.mustCall((err) => { + assert.strictEqual(err.code, 'ABORT_ERR'); + assert.strictEqual(err.name, 'AbortError'); + assert.strictEqual(err.signal, undefined); + }); + execFile(process.execPath, [echoFixture, 0], { signal }, check); +} + +{ + // Verify that if something different than Abortcontroller.signal + // is passed, ERR_INVALID_ARG_TYPE is thrown + assert.throws(() => { + const callback = common.mustNotCall(); + + execFile(process.execPath, [echoFixture, 0], { signal: 'hello' }, callback); + }, { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError' }); +} +{ + // Verify that the process completing removes the abort listener + const ac = new AbortController(); + const { signal } = ac; + + const callback = common.mustCall((err) => { + assert.strictEqual(getEventListeners(ac.signal).length, 0); + assert.strictEqual(err, null); + }); + execFile(process.execPath, [fixture, 0], { signal }, callback); +} + +// Verify the execFile() stdout is the same as execFileSync(). +{ + const file = 'echo'; + const args = ['foo', 'bar']; + + // Test with and without `{ shell: true }` + [ + // Skipping shell-less test on Windows because its echo command is a shell built-in command. + ...(common.isWindows ? [] : [{ encoding: 'utf8' }]), + { shell: true, encoding: 'utf8' }, + ].forEach((options) => { + const execFileSyncStdout = execFileSync(file, args, options); + assert.strictEqual(execFileSyncStdout, `foo bar${os.EOL}`); + + execFile(file, args, options, common.mustCall((_, stdout) => { + assert.strictEqual(stdout, execFileSyncStdout); + })); + }); +} diff --git a/test/js/node/test/parallel/test-child-process-fork-ref.js b/test/js/node/test/parallel/test-child-process-fork-ref.js new file mode 100644 index 0000000000..bbf4432af8 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-fork-ref.js @@ -0,0 +1,59 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +require('../common'); +const assert = require('assert'); +const fork = require('child_process').fork; + +if (process.argv[2] === 'child') { + process.send('1'); + + // Check that child don't instantly die + setTimeout(function() { + process.send('2'); + }, 200); + + process.on('disconnect', function() { + process.stdout.write('3'); + }); + +} else { + const child = fork(__filename, ['child'], { silent: true }); + + const ipc = []; + let stdout = ''; + + child.on('message', function(msg) { + ipc.push(msg); + + if (msg === '2') child.disconnect(); + }); + + child.stdout.on('data', function(chunk) { + stdout += chunk; + }); + + child.once('exit', function() { + assert.deepStrictEqual(ipc, ['1', '2']); + assert.strictEqual(stdout, '3'); + }); +} diff --git a/test/js/node/test/parallel/test-child-process-fork-ref2.js b/test/js/node/test/parallel/test-child-process-fork-ref2.js new file mode 100644 index 0000000000..8f27e58fb0 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-fork-ref2.js @@ -0,0 +1,50 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const { + mustCall, + mustNotCall, + platformTimeout, +} = require('../common'); +const fork = require('child_process').fork; +const debug = require('util').debuglog('test'); + +if (process.argv[2] === 'child') { + debug('child -> call disconnect'); + process.disconnect(); + + setTimeout(() => { + debug('child -> will this keep it alive?'); + process.on('message', mustNotCall()); + }, platformTimeout(400)); + +} else { + const child = fork(__filename, ['child']); + + child.on('disconnect', mustCall(() => { + debug('parent -> disconnect'); + })); + + child.once('exit', mustCall(() => { + debug('parent -> exit'); + })); +} diff --git a/test/js/node/test/parallel/test-child-process-fork-timeout-kill-signal.js b/test/js/node/test/parallel/test-child-process-fork-timeout-kill-signal.js new file mode 100644 index 0000000000..ef08d4b12a --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-fork-timeout-kill-signal.js @@ -0,0 +1,50 @@ +'use strict'; + +const { mustCall } = require('../common'); +const { strictEqual, throws } = require('assert'); +const fixtures = require('../common/fixtures'); +const { fork } = require('child_process'); +const { getEventListeners } = require('events'); + +{ + // Verify default signal + const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), { + timeout: 5, + }); + cp.on('exit', mustCall((code, ks) => strictEqual(ks, 'SIGTERM'))); +} + +{ + // Verify correct signal + closes after at least 4 ms. + const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), { + timeout: 5, + killSignal: 'SIGKILL', + }); + cp.on('exit', mustCall((code, ks) => strictEqual(ks, 'SIGKILL'))); +} + +{ + // Verify timeout verification + throws(() => fork(fixtures.path('child-process-stay-alive-forever.js'), { + timeout: 'badValue', + }), /ERR_OUT_OF_RANGE/); + + throws(() => fork(fixtures.path('child-process-stay-alive-forever.js'), { + timeout: {}, + }), /ERR_OUT_OF_RANGE/); +} + +{ + // Verify abort signal gets unregistered + const signal = new EventTarget(); + signal.aborted = false; + + const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), { + timeout: 6, + signal, + }); + strictEqual(getEventListeners(signal, 'abort').length, 1); + cp.on('exit', mustCall(() => { + strictEqual(getEventListeners(signal, 'abort').length, 0); + })); +} diff --git a/test/js/node/test/parallel/test-child-process-fork.js b/test/js/node/test/parallel/test-child-process-fork.js new file mode 100644 index 0000000000..a357f4fbc1 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-fork.js @@ -0,0 +1,63 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. +// Flags: --no-warnings +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { fork } = require('child_process'); +const args = ['foo', 'bar']; +const fixtures = require('../common/fixtures'); +const debug = require('util').debuglog('test'); + +const n = fork(fixtures.path('child-process-spawn-node.js'), args); + +assert.strictEqual(n.channel, n._channel); +assert.deepStrictEqual(args, ['foo', 'bar']); + +n.on('message', (m) => { + debug('PARENT got message:', m); + assert.ok(m.foo); +}); + +// https://github.com/joyent/node/issues/2355 - JSON.stringify(undefined) +// returns "undefined" but JSON.parse() cannot parse that... +assert.throws(() => n.send(undefined), { + name: 'TypeError', + message: 'The "message" argument must be specified', + code: 'ERR_MISSING_ARGS' +}); +assert.throws(() => n.send(), { + name: 'TypeError', + message: 'The "message" argument must be specified', + code: 'ERR_MISSING_ARGS' +}); + +assert.throws(() => n.send(Symbol()), { + name: 'TypeError', + message: 'The "message" argument must be one of type string,' + + ' object, number, or boolean. Received type symbol (Symbol())', + code: 'ERR_INVALID_ARG_TYPE' +}); +n.send({ hello: 'world' }); + +n.on('exit', common.mustCall((c) => { + assert.strictEqual(c, 0); +})); diff --git a/test/js/node/test/parallel/test-child-process-ipc-next-tick.js b/test/js/node/test/parallel/test-child-process-ipc-next-tick.js new file mode 100644 index 0000000000..b23aefc85d --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-ipc-next-tick.js @@ -0,0 +1,39 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const cp = require('child_process'); +const NUM_MESSAGES = 10; +const values = []; + +for (let i = 0; i < NUM_MESSAGES; ++i) { + values[i] = i; +} + +if (process.argv[2] === 'child') { + const received = values.map(() => { return false; }); + + process.on('uncaughtException', common.mustCall((err) => { + received[err] = true; + const done = received.every((element) => { return element === true; }); + + if (done) + process.disconnect(); + }, NUM_MESSAGES)); + + process.on('message', (msg) => { + // If messages are handled synchronously, throwing should break the IPC + // message processing. + throw msg; + }); + + process.send('ready'); +} else { + const child = cp.fork(__filename, ['child']); + + child.on('message', common.mustCall((msg) => { + assert.strictEqual(msg, 'ready'); + values.forEach((value) => { + child.send(value); + }); + })); +} diff --git a/test/js/node/test/parallel/test-child-process-net-reuseport.js b/test/js/node/test/parallel/test-child-process-net-reuseport.js new file mode 100644 index 0000000000..52309edd1b --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-net-reuseport.js @@ -0,0 +1,35 @@ +'use strict'; +const common = require('../common'); +const { checkSupportReusePort, options } = require('../common/net'); +const assert = require('assert'); +const child_process = require('child_process'); +const net = require('net'); + +if (!process.env.isWorker) { + checkSupportReusePort().then(() => { + const server = net.createServer(); + server.listen(options, common.mustCall(() => { + const port = server.address().port; + const workerOptions = { env: { ...process.env, isWorker: 1, port } }; + let count = 2; + for (let i = 0; i < 2; i++) { + const worker = child_process.fork(__filename, workerOptions); + worker.on('exit', common.mustCall((code) => { + assert.strictEqual(code, 0); + if (--count === 0) { + server.close(); + } + })); + } + })); + }, () => { + common.skip('The `reusePort` option is not supported'); + }); + return; +} + +const server = net.createServer(); + +server.listen({ ...options, port: +process.env.port }, common.mustCall(() => { + server.close(); +})); diff --git a/test/js/node/test/parallel/test-child-process-promisified.js b/test/js/node/test/parallel/test-child-process-promisified.js new file mode 100644 index 0000000000..208ee50816 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-promisified.js @@ -0,0 +1,63 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const child_process = require('child_process'); +const { promisify } = require('util'); + +const exec = promisify(child_process.exec); +const execFile = promisify(child_process.execFile); + +{ + const promise = exec(...common.escapePOSIXShell`"${process.execPath}" -p 42`); + + assert(promise.child instanceof child_process.ChildProcess); + promise.then(common.mustCall((obj) => { + assert.deepStrictEqual(obj, { stdout: '42\n', stderr: '' }); + })); +} + +{ + const promise = execFile(process.execPath, ['-p', '42']); + + assert(promise.child instanceof child_process.ChildProcess); + promise.then(common.mustCall((obj) => { + assert.deepStrictEqual(obj, { stdout: '42\n', stderr: '' }); + })); +} + +{ + const promise = exec('doesntexist'); + + assert(promise.child instanceof child_process.ChildProcess); + promise.catch(common.mustCall((err) => { + assert(err.message.includes('doesntexist')); + })); +} + +{ + const promise = execFile('doesntexist', ['-p', '42']); + + assert(promise.child instanceof child_process.ChildProcess); + promise.catch(common.mustCall((err) => { + assert(err.message.includes('doesntexist')); + })); +} +const failingCodeWithStdoutErr = + 'console.log(42);console.error(43);process.exit(1)'; +{ + exec(...common.escapePOSIXShell`"${process.execPath}" -e "${failingCodeWithStdoutErr}"`) + .catch(common.mustCall((err) => { + assert.strictEqual(err.code, 1); + assert.strictEqual(err.stdout, '42\n'); + assert.strictEqual(err.stderr, '43\n'); + })); +} + +{ + execFile(process.execPath, ['-e', failingCodeWithStdoutErr]) + .catch(common.mustCall((err) => { + assert.strictEqual(err.code, 1); + assert.strictEqual(err.stdout, '42\n'); + assert.strictEqual(err.stderr, '43\n'); + })); +} diff --git a/test/js/node/test/parallel/test-child-process-send-after-close.js b/test/js/node/test/parallel/test-child-process-send-after-close.js new file mode 100644 index 0000000000..01e4d34d1f --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-send-after-close.js @@ -0,0 +1,31 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const cp = require('child_process'); +const fixtures = require('../common/fixtures'); + +const fixture = fixtures.path('empty.js'); +const child = cp.fork(fixture); + +child.on('close', common.mustCall((code, signal) => { + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + + const testError = common.expectsError({ + name: 'Error', + // message: 'Channel closed', + code: 'ERR_IPC_CHANNEL_CLOSED' + }, 2); + + child.on('error', testError); + + { + const result = child.send('ping'); + assert.strictEqual(result, false); + } + + { + const result = child.send('pong', testError); + assert.strictEqual(result, false); + } +})); diff --git a/test/js/node/test/parallel/test-child-process-send-cb.js b/test/js/node/test/parallel/test-child-process-send-cb.js new file mode 100644 index 0000000000..daecd72253 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-send-cb.js @@ -0,0 +1,19 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const fork = require('child_process').fork; + +if (process.argv[2] === 'child') { + process.send('ok', common.mustCall((err) => { + assert.strictEqual(err, null); + })); +} else { + const child = fork(process.argv[1], ['child']); + child.on('message', common.mustCall((message) => { + assert.strictEqual(message, 'ok'); + })); + child.on('exit', common.mustCall((exitCode, signalCode) => { + assert.strictEqual(exitCode, 0); + assert.strictEqual(signalCode, null); + })); +} diff --git a/test/js/node/test/parallel/test-child-process-send-utf8.js b/test/js/node/test/parallel/test-child-process-send-utf8.js new file mode 100644 index 0000000000..2609dd0d6d --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-send-utf8.js @@ -0,0 +1,35 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const fork = require('child_process').fork; + +const expected = 'ßßßß'.repeat(1e5 - 1); +if (process.argv[2] === 'child') { + process.send(expected); +} else { + const child = fork(process.argv[1], ['child']); + child.on('message', common.mustCall((actual) => { + assert.strictEqual(actual, expected); + })); +} diff --git a/test/js/node/test/parallel/test-child-process-spawn-argv0.js b/test/js/node/test/parallel/test-child-process-spawn-argv0.js new file mode 100644 index 0000000000..3348969496 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-spawn-argv0.js @@ -0,0 +1,27 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const cp = require('child_process'); + +// This test spawns itself with an argument to indicate when it is a child to +// easily and portably print the value of argv[0] +if (process.argv[2] === 'child') { + console.log(process.argv0); + return; +} + +const noArgv0 = cp.spawnSync(process.execPath, [__filename, 'child']); +assert.strictEqual(noArgv0.stdout.toString().trim(), process.execPath); + +const withArgv0 = cp.spawnSync(process.execPath, [__filename, 'child'], + { argv0: 'withArgv0' }); +assert.strictEqual(withArgv0.stdout.toString().trim(), 'withArgv0'); + +assert.throws(() => { + cp.spawnSync(process.execPath, [__filename, 'child'], { argv0: [] }); +}, { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: 'The "options.argv0" property must be of type string.' + + common.invalidArgTypeHelper([]) +}); diff --git a/test/js/node/test/parallel/test-child-process-spawn-error.js b/test/js/node/test/parallel/test-child-process-spawn-error.js new file mode 100644 index 0000000000..ed1c8fac9f --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-spawn-error.js @@ -0,0 +1,55 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const { getSystemErrorName } = require('util'); +const spawn = require('child_process').spawn; +const assert = require('assert'); +const fs = require('fs'); + +const enoentPath = 'foo123'; +const spawnargs = ['bar']; +assert.strictEqual(fs.existsSync(enoentPath), false); + +const enoentChild = spawn(enoentPath, spawnargs); + +// Verify that stdio is setup if the error is not EMFILE or ENFILE. +assert.notStrictEqual(enoentChild.stdin, undefined); +assert.notStrictEqual(enoentChild.stdout, undefined); +assert.notStrictEqual(enoentChild.stderr, undefined); +assert(Array.isArray(enoentChild.stdio)); +assert.strictEqual(enoentChild.stdio[0], enoentChild.stdin); +assert.strictEqual(enoentChild.stdio[1], enoentChild.stdout); +assert.strictEqual(enoentChild.stdio[2], enoentChild.stderr); + +// Verify pid is not assigned. +assert.strictEqual(enoentChild.pid, undefined); + +enoentChild.on('spawn', common.mustNotCall()); + +enoentChild.on('error', common.mustCall(function(err) { + assert.strictEqual(err.code, 'ENOENT'); + assert.strictEqual(getSystemErrorName(err.errno), 'ENOENT'); + assert.strictEqual(err.syscall, `spawn ${enoentPath}`); + assert.strictEqual(err.path, enoentPath); + assert.deepStrictEqual(err.spawnargs, spawnargs); +})); diff --git a/test/js/node/test/parallel/test-child-process-spawn-shell.js b/test/js/node/test/parallel/test-child-process-spawn-shell.js new file mode 100644 index 0000000000..d96127678b --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-spawn-shell.js @@ -0,0 +1,65 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const cp = require('child_process'); + +// Verify that a shell is, in fact, executed +const doesNotExist = cp.spawn('does-not-exist', { shell: true }); + +assert.notStrictEqual(doesNotExist.spawnfile, 'does-not-exist'); +doesNotExist.on('error', common.mustNotCall()); +doesNotExist.on('exit', common.mustCall((code, signal) => { + assert.strictEqual(signal, null); + + if (common.isWindows) + assert.strictEqual(code, 1); // Exit code of cmd.exe + else + assert.strictEqual(code, 127); // Exit code of /bin/sh +})); + +// Verify that passing arguments works +const echo = cp.spawn('echo', ['foo'], { + encoding: 'utf8', + shell: true +}); +let echoOutput = ''; + +assert.strictEqual(echo.spawnargs[echo.spawnargs.length - 1].replace(/"/g, ''), + 'echo foo'); +echo.stdout.on('data', (data) => { + echoOutput += data; +}); +echo.on('close', common.mustCall((code, signal) => { + assert.strictEqual(echoOutput.trim(), 'foo'); +})); + +// Verify that shell features can be used +const cmd = 'echo bar | cat'; +const command = cp.spawn(cmd, { + encoding: 'utf8', + shell: true +}); +let commandOutput = ''; + +command.stdout.on('data', (data) => { + commandOutput += data; +}); +command.on('close', common.mustCall((code, signal) => { + assert.strictEqual(commandOutput.trim(), 'bar'); +})); + +// Verify that the environment is properly inherited +// edited to change `-pe` flag to `-p` +const env = cp.spawn(`"${common.isWindows ? process.execPath : '$NODE'}" -p process.env.BAZ`, { + env: { ...process.env, BAZ: 'buzz', NODE: process.execPath }, + encoding: 'utf8', + shell: true +}); +let envOutput = ''; + +env.stdout.on('data', (data) => { + envOutput += data; +}); +env.on('close', common.mustCall((code, signal) => { + assert.strictEqual(envOutput.trim(), 'buzz'); +})); diff --git a/test/js/node/test/parallel/test-child-process-spawn-timeout-kill-signal.js b/test/js/node/test/parallel/test-child-process-spawn-timeout-kill-signal.js new file mode 100644 index 0000000000..59a974d0e0 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-spawn-timeout-kill-signal.js @@ -0,0 +1,50 @@ +'use strict'; + +const { mustCall } = require('../common'); +const { strictEqual, throws } = require('assert'); +const fixtures = require('../common/fixtures'); +const { spawn } = require('child_process'); +const { getEventListeners } = require('events'); + +const aliveForeverFile = 'child-process-stay-alive-forever.js'; +{ + // Verify default signal + closes + const cp = spawn(process.execPath, [fixtures.path(aliveForeverFile)], { + timeout: 5, + }); + cp.on('exit', mustCall((code, ks) => strictEqual(ks, 'SIGTERM'))); +} + +{ + // Verify SIGKILL signal + closes + const cp = spawn(process.execPath, [fixtures.path(aliveForeverFile)], { + timeout: 6, + killSignal: 'SIGKILL', + }); + cp.on('exit', mustCall((code, ks) => strictEqual(ks, 'SIGKILL'))); +} + +{ + // Verify timeout verification + throws(() => spawn(process.execPath, [fixtures.path(aliveForeverFile)], { + timeout: 'badValue', + }), /ERR_OUT_OF_RANGE/); + + throws(() => spawn(process.execPath, [fixtures.path(aliveForeverFile)], { + timeout: {}, + }), /ERR_OUT_OF_RANGE/); +} + +{ + // Verify abort signal gets unregistered + const controller = new AbortController(); + const { signal } = controller; + const cp = spawn(process.execPath, [fixtures.path(aliveForeverFile)], { + timeout: 6, + signal, + }); + strictEqual(getEventListeners(signal, 'abort').length, 1); + cp.on('exit', mustCall(() => { + strictEqual(getEventListeners(signal, 'abort').length, 0); + })); +} diff --git a/test/js/node/test/parallel/test-child-process-spawn-typeerror.js b/test/js/node/test/parallel/test-child-process-spawn-typeerror.js new file mode 100644 index 0000000000..545e2e2b3a --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-spawn-typeerror.js @@ -0,0 +1,196 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { spawn, fork, execFile } = require('child_process'); +const fixtures = require('../common/fixtures'); +const cmd = common.isWindows ? 'rundll32' : 'ls'; +const invalidcmd = 'hopefully_you_dont_have_this_on_your_machine'; + +const empty = fixtures.path('empty.js'); + +const invalidArgValueError = { + code: 'ERR_INVALID_ARG_VALUE', + name: 'TypeError' +}; + +const invalidArgTypeError = { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError' +}; + +assert.throws(function() { + spawn(invalidcmd, 'this is not an array'); +}, invalidArgTypeError); + +// Verify that valid argument combinations do not throw. +spawn(cmd); +spawn(cmd, []); +spawn(cmd, {}); +spawn(cmd, [], {}); + +// Verify that invalid argument combinations throw. +assert.throws(function() { + spawn(); +}, invalidArgTypeError); + +assert.throws(function() { + spawn(''); +}, invalidArgValueError); + +assert.throws(function() { + const file = { toString() { return null; } }; + spawn(file); +}, invalidArgTypeError); + +assert.throws(function() { + spawn(cmd, true); +}, invalidArgTypeError); + +assert.throws(function() { + spawn(cmd, [], null); +}, invalidArgTypeError); + +assert.throws(function() { + spawn(cmd, [], 1); +}, invalidArgTypeError); + +assert.throws(function() { + spawn(cmd, [], { uid: 2 ** 63 }); +}, invalidArgTypeError); + +assert.throws(function() { + spawn(cmd, [], { gid: 2 ** 63 }); +}, invalidArgTypeError); + +// Argument types for combinatorics. +const a = []; +const o = {}; +function c() {} +const s = 'string'; +const u = undefined; +const n = null; + +// Function spawn(file=f [,args=a] [, options=o]) has valid combinations: +// (f) +// (f, a) +// (f, a, o) +// (f, o) +spawn(cmd); +spawn(cmd, a); +spawn(cmd, a, o); +spawn(cmd, o); + +// Variants of undefined as explicit 'no argument' at a position. +spawn(cmd, u, o); +spawn(cmd, n, o); +spawn(cmd, a, u); + +assert.throws(() => { spawn(cmd, a, n); }, invalidArgTypeError); +assert.throws(() => { spawn(cmd, s); }, invalidArgTypeError); +assert.throws(() => { spawn(cmd, a, s); }, invalidArgTypeError); +assert.throws(() => { spawn(cmd, a, a); }, invalidArgTypeError); + + +// Verify that execFile has same argument parsing behavior as spawn. +// +// function execFile(file=f [,args=a] [, options=o] [, callback=c]) has valid +// combinations: +// (f) +// (f, a) +// (f, a, o) +// (f, a, o, c) +// (f, a, c) +// (f, o) +// (f, o, c) +// (f, c) +execFile(cmd); +execFile(cmd, a); +execFile(cmd, a, o); +execFile(cmd, a, o, c); +execFile(cmd, a, c); +execFile(cmd, o); +execFile(cmd, o, c); +execFile(cmd, c); + +// Variants of undefined as explicit 'no argument' at a position. +execFile(cmd, u, o, c); +execFile(cmd, a, u, c); +execFile(cmd, a, o, u); +execFile(cmd, n, o, c); +execFile(cmd, a, n, c); +execFile(cmd, a, o, n); +execFile(cmd, u, u, u); +execFile(cmd, u, u, c); +execFile(cmd, u, o, u); +execFile(cmd, a, u, u); +execFile(cmd, n, n, n); +execFile(cmd, n, n, c); +execFile(cmd, n, o, n); +execFile(cmd, a, n, n); +execFile(cmd, a, u); +execFile(cmd, a, n); +execFile(cmd, o, u); +execFile(cmd, o, n); +execFile(cmd, c, u); +execFile(cmd, c, n); + +// String is invalid in arg position (this may seem strange, but is +// consistent across node API, cf. `net.createServer('not options', 'not +// callback')`. +assert.throws(() => { execFile(cmd, s, o, c); }, invalidArgTypeError); +assert.throws(() => { execFile(cmd, a, s, c); }, invalidArgTypeError); +assert.throws(() => { execFile(cmd, a, o, s); }, invalidArgTypeError); +assert.throws(() => { execFile(cmd, a, s); }, invalidArgTypeError); +assert.throws(() => { execFile(cmd, o, s); }, invalidArgTypeError); +assert.throws(() => { execFile(cmd, u, u, s); }, invalidArgTypeError); +assert.throws(() => { execFile(cmd, n, n, s); }, invalidArgTypeError); +assert.throws(() => { execFile(cmd, a, u, s); }, invalidArgTypeError); +assert.throws(() => { execFile(cmd, a, n, s); }, invalidArgTypeError); +assert.throws(() => { execFile(cmd, u, o, s); }, invalidArgTypeError); +assert.throws(() => { execFile(cmd, n, o, s); }, invalidArgTypeError); +assert.throws(() => { execFile(cmd, a, a); }, invalidArgTypeError); + +execFile(cmd, c, s); // Should not throw. + +// Verify that fork has same argument parsing behavior as spawn. +// +// function fork(file=f [,args=a] [, options=o]) has valid combinations: +// (f) +// (f, a) +// (f, a, o) +// (f, o) +fork(empty); +fork(empty, a); +fork(empty, a, o); +fork(empty, o); +fork(empty, u, u); +fork(empty, u, o); +fork(empty, a, u); +fork(empty, n, n); +fork(empty, n, o); +fork(empty, a, n); + +assert.throws(() => { fork(empty, s); }, invalidArgTypeError); +assert.throws(() => { fork(empty, a, s); }, invalidArgTypeError); +assert.throws(() => { fork(empty, a, a); }, invalidArgTypeError); diff --git a/test/js/node/test/parallel/test-child-process-spawnsync-timeout.js b/test/js/node/test/parallel/test-child-process-spawnsync-timeout.js new file mode 100644 index 0000000000..426ac05a43 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-spawnsync-timeout.js @@ -0,0 +1,58 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); + +const spawnSync = require('child_process').spawnSync; +const { debuglog, getSystemErrorName } = require('util'); +const debug = debuglog('test'); + +const TIMER = 200; +let SLEEP = common.platformTimeout(5000); + +if (common.isWindows) { + // Some of the windows machines in the CI need more time to launch + // and receive output from child processes. + // https://github.com/nodejs/build/issues/3014 + SLEEP = common.platformTimeout(15000); +} + +switch (process.argv[2]) { + case 'child': + setTimeout(() => { + debug('child fired'); + process.exit(1); + }, SLEEP); + break; + default: { + const start = Date.now(); + const ret = spawnSync(process.execPath, [__filename, 'child'], + { timeout: TIMER }); + assert.strictEqual(ret.error.code, 'ETIMEDOUT'); + assert.strictEqual(getSystemErrorName(ret.error.errno), 'ETIMEDOUT'); + const end = Date.now() - start; + assert(end < SLEEP); + assert(ret.status > 128 || ret.signal); + break; + } +} diff --git a/test/js/node/test/parallel/test-child-process-spawnsync-validation-errors.js b/test/js/node/test/parallel/test-child-process-spawnsync-validation-errors.js new file mode 100644 index 0000000000..a099ecfb63 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-spawnsync-validation-errors.js @@ -0,0 +1,216 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const spawnSync = require('child_process').spawnSync; +const signals = require('os').constants.signals; +const rootUser = common.isWindows ? false : + common.isIBMi ? true : process.getuid() === 0; + +const invalidArgTypeError = { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError' }; +const invalidRangeError = { code: 'ERR_OUT_OF_RANGE', name: 'RangeError' }; + +function pass(option, value) { + // Run the command with the specified option. Since it's not a real command, + // spawnSync() should run successfully but return an ENOENT error. + const child = spawnSync('not_a_real_command', { [option]: value }); + + assert.strictEqual(child.error.code, 'ENOENT'); +} + +function fail(option, value, message) { + assert.throws(() => { + spawnSync('not_a_real_command', { [option]: value }); + }, message); +} + +{ + // Validate the cwd option + pass('cwd', undefined); + pass('cwd', null); + pass('cwd', __dirname); + fail('cwd', 0, invalidArgTypeError); + fail('cwd', 1, invalidArgTypeError); + fail('cwd', true, invalidArgTypeError); + fail('cwd', false, invalidArgTypeError); + fail('cwd', [], invalidArgTypeError); + fail('cwd', {}, invalidArgTypeError); + fail('cwd', common.mustNotCall(), invalidArgTypeError); +} + +{ + // Validate the detached option + pass('detached', undefined); + pass('detached', null); + pass('detached', true); + pass('detached', false); + fail('detached', 0, invalidArgTypeError); + fail('detached', 1, invalidArgTypeError); + fail('detached', __dirname, invalidArgTypeError); + fail('detached', [], invalidArgTypeError); + fail('detached', {}, invalidArgTypeError); + fail('detached', common.mustNotCall(), invalidArgTypeError); +} + +if (!common.isWindows) { + { + // Validate the uid option + if (!rootUser) { + pass('uid', undefined); + pass('uid', null); + pass('uid', process.getuid()); + fail('uid', __dirname, invalidArgTypeError); + fail('uid', true, invalidArgTypeError); + fail('uid', false, invalidArgTypeError); + fail('uid', [], invalidArgTypeError); + fail('uid', {}, invalidArgTypeError); + fail('uid', common.mustNotCall(), invalidArgTypeError); + fail('uid', NaN, invalidArgTypeError); + fail('uid', Infinity, invalidArgTypeError); + fail('uid', 3.1, invalidArgTypeError); + fail('uid', -3.1, invalidArgTypeError); + } + } + + { + // Validate the gid option + if (process.getgid() !== 0) { + pass('gid', undefined); + pass('gid', null); + pass('gid', process.getgid()); + fail('gid', __dirname, invalidArgTypeError); + fail('gid', true, invalidArgTypeError); + fail('gid', false, invalidArgTypeError); + fail('gid', [], invalidArgTypeError); + fail('gid', {}, invalidArgTypeError); + fail('gid', common.mustNotCall(), invalidArgTypeError); + fail('gid', NaN, invalidArgTypeError); + fail('gid', Infinity, invalidArgTypeError); + fail('gid', 3.1, invalidArgTypeError); + fail('gid', -3.1, invalidArgTypeError); + } + } +} + +{ + // Validate the shell option + pass('shell', undefined); + pass('shell', null); + pass('shell', false); + fail('shell', 0, invalidArgTypeError); + fail('shell', 1, invalidArgTypeError); + fail('shell', [], invalidArgTypeError); + fail('shell', {}, invalidArgTypeError); + fail('shell', common.mustNotCall(), invalidArgTypeError); +} + +{ + // Validate the argv0 option + pass('argv0', undefined); + pass('argv0', null); + pass('argv0', 'myArgv0'); + fail('argv0', 0, invalidArgTypeError); + fail('argv0', 1, invalidArgTypeError); + fail('argv0', true, invalidArgTypeError); + fail('argv0', false, invalidArgTypeError); + fail('argv0', [], invalidArgTypeError); + fail('argv0', {}, invalidArgTypeError); + fail('argv0', common.mustNotCall(), invalidArgTypeError); +} + +{ + // Validate the windowsHide option + pass('windowsHide', undefined); + pass('windowsHide', null); + pass('windowsHide', true); + pass('windowsHide', false); + fail('windowsHide', 0, invalidArgTypeError); + fail('windowsHide', 1, invalidArgTypeError); + fail('windowsHide', __dirname, invalidArgTypeError); + fail('windowsHide', [], invalidArgTypeError); + fail('windowsHide', {}, invalidArgTypeError); + fail('windowsHide', common.mustNotCall(), invalidArgTypeError); +} + +{ + // Validate the windowsVerbatimArguments option + pass('windowsVerbatimArguments', undefined); + pass('windowsVerbatimArguments', null); + pass('windowsVerbatimArguments', true); + pass('windowsVerbatimArguments', false); + fail('windowsVerbatimArguments', 0, invalidArgTypeError); + fail('windowsVerbatimArguments', 1, invalidArgTypeError); + fail('windowsVerbatimArguments', __dirname, invalidArgTypeError); + fail('windowsVerbatimArguments', [], invalidArgTypeError); + fail('windowsVerbatimArguments', {}, invalidArgTypeError); + fail('windowsVerbatimArguments', common.mustNotCall(), invalidArgTypeError); +} + +{ + // Validate the timeout option + pass('timeout', undefined); + pass('timeout', null); + pass('timeout', 1); + pass('timeout', 0); + fail('timeout', -1, invalidRangeError); + fail('timeout', true, invalidRangeError); + fail('timeout', false, invalidRangeError); + fail('timeout', __dirname, invalidRangeError); + fail('timeout', [], invalidRangeError); + fail('timeout', {}, invalidRangeError); + fail('timeout', common.mustNotCall(), invalidRangeError); + fail('timeout', NaN, invalidRangeError); + fail('timeout', Infinity, invalidRangeError); + fail('timeout', 3.1, invalidRangeError); + fail('timeout', -3.1, invalidRangeError); +} + +{ + // Validate the maxBuffer option + pass('maxBuffer', undefined); + pass('maxBuffer', null); + pass('maxBuffer', 1); + pass('maxBuffer', 0); + pass('maxBuffer', Infinity); + pass('maxBuffer', 3.14); + fail('maxBuffer', -1, invalidRangeError); + fail('maxBuffer', NaN, invalidRangeError); + fail('maxBuffer', -Infinity, invalidRangeError); + fail('maxBuffer', true, invalidRangeError); + fail('maxBuffer', false, invalidRangeError); + fail('maxBuffer', __dirname, invalidRangeError); + fail('maxBuffer', [], invalidRangeError); + fail('maxBuffer', {}, invalidRangeError); + fail('maxBuffer', common.mustNotCall(), invalidRangeError); +} + +{ + // Validate the killSignal option + const unknownSignalErr = { code: 'ERR_UNKNOWN_SIGNAL', name: 'TypeError' }; + + pass('killSignal', undefined); + pass('killSignal', null); + pass('killSignal', 'SIGKILL'); + fail('killSignal', 'SIGNOTAVALIDSIGNALNAME', unknownSignalErr); + fail('killSignal', true, invalidArgTypeError); + fail('killSignal', false, invalidArgTypeError); + fail('killSignal', [], invalidArgTypeError); + fail('killSignal', {}, invalidArgTypeError); + fail('killSignal', common.mustNotCall(), invalidArgTypeError); + + // Invalid signal names and numbers should fail + fail('killSignal', 500, unknownSignalErr); + fail('killSignal', 0, unknownSignalErr); + fail('killSignal', -200, unknownSignalErr); + fail('killSignal', 3.14, unknownSignalErr); + + Object.getOwnPropertyNames(Object.prototype).forEach((property) => { + fail('killSignal', property, unknownSignalErr); + }); + + // Valid signal names and numbers should pass + for (const signalName in signals) { + pass('killSignal', signals[signalName]); + pass('killSignal', signalName); + pass('killSignal', signalName.toLowerCase()); + } +} diff --git a/test/js/node/test/parallel/test-child-process-spawnsync.js b/test/js/node/test/parallel/test-child-process-spawnsync.js new file mode 100644 index 0000000000..9ec125ea89 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-spawnsync.js @@ -0,0 +1,67 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +const assert = require('assert'); +const { spawnSync } = require('child_process'); +const { getSystemErrorName } = require('util'); + +// `sleep` does different things on Windows and Unix, but in both cases, it does +// more-or-less nothing if there are no parameters +const ret = spawnSync('sleep', ['0']); +assert.strictEqual(ret.status, 0); + +// Error test when command does not exist +const ret_err = spawnSync('command_does_not_exist', ['bar']).error; + +assert.strictEqual(ret_err.code, 'ENOENT'); +assert.strictEqual(getSystemErrorName(ret_err.errno), 'ENOENT'); +assert.strictEqual(ret_err.syscall, 'spawnSync command_does_not_exist'); +assert.strictEqual(ret_err.path, 'command_does_not_exist'); +assert.deepStrictEqual(ret_err.spawnargs, ['bar']); + +{ + // Test the cwd option + const cwd = tmpdir.path; + const response = spawnSync(...common.pwdCommand, { cwd }); + + assert.strictEqual(response.stdout.toString().trim(), cwd); +} + + +{ + // Assert Buffer is the default encoding + const retDefault = spawnSync(...common.pwdCommand); + const retBuffer = spawnSync(...common.pwdCommand, { encoding: 'buffer' }); + assert.deepStrictEqual(retDefault.output, retBuffer.output); + + const retUTF8 = spawnSync(...common.pwdCommand, { encoding: 'utf8' }); + const stringifiedDefault = [ + null, + retDefault.stdout.toString(), + retDefault.stderr.toString(), + ]; + assert.deepStrictEqual(retUTF8.output, stringifiedDefault); +} diff --git a/test/js/node/test/parallel/test-child-process-stdout-ipc.js b/test/js/node/test/parallel/test-child-process-stdout-ipc.js new file mode 100644 index 0000000000..c916be95b7 --- /dev/null +++ b/test/js/node/test/parallel/test-child-process-stdout-ipc.js @@ -0,0 +1,18 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); + +const spawn = require('child_process').spawn; + +if (process.argv[2] === 'child') { + process.send('hahah'); + return; +} + +const proc = spawn(process.execPath, [__filename, 'child'], { + stdio: ['inherit', 'ipc', 'inherit'] +}); + +proc.on('exit', common.mustCall(function(code) { + assert.strictEqual(code, 0); +})); diff --git a/test/js/node/test/parallel/test-stdin-pause-resume-sync.js b/test/js/node/test/parallel/test-stdin-pause-resume-sync.js new file mode 100644 index 0000000000..3fae349ab3 --- /dev/null +++ b/test/js/node/test/parallel/test-stdin-pause-resume-sync.js @@ -0,0 +1,33 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +require('../common'); +console.error('before opening stdin'); +process.stdin.resume(); +console.error('stdin opened'); +console.error('pausing stdin'); +process.stdin.pause(); +console.error('opening again'); +process.stdin.resume(); +console.error('pausing again'); +process.stdin.pause(); +console.error('should exit now'); diff --git a/test/js/node/test/parallel/test-stdin-resume-pause.js b/test/js/node/test/parallel/test-stdin-resume-pause.js new file mode 100644 index 0000000000..eec01d390e --- /dev/null +++ b/test/js/node/test/parallel/test-stdin-resume-pause.js @@ -0,0 +1,25 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +require('../common'); +process.stdin.resume(); +process.stdin.pause(); diff --git a/test/js/node/test/parallel/test-stdin-script-child.js b/test/js/node/test/parallel/test-stdin-script-child.js new file mode 100644 index 0000000000..1a0cecccb0 --- /dev/null +++ b/test/js/node/test/parallel/test-stdin-script-child.js @@ -0,0 +1,32 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); + +const { spawn } = require('child_process'); +for (const args of typeof Bun != null ? [['-']] : [[], ['-']]) { + const child = spawn(process.execPath, args, { + env: { ...process.env, + NODE_DEBUG: process.argv[2] } + }); + const wanted = `${child.pid}\n`; + let found = ''; + + child.stdout.setEncoding('utf8'); + child.stdout.on('data', function(c) { + found += c; + }); + + child.stderr.setEncoding('utf8'); + child.stderr.on('data', function(c) { + console.error(`> ${c.trim().split('\n').join('\n> ')}`); + }); + + child.on('close', common.mustCall(function(c) { + assert.strictEqual(c, 0); + assert.strictEqual(found, wanted); + })); + + setTimeout(function() { + child.stdin.end('console.log(process.pid)'); + }, 1); +} diff --git a/test/js/node/test/parallel/test-stdio-pipe-access.js b/test/js/node/test/parallel/test-stdio-pipe-access.js new file mode 100644 index 0000000000..ac0e22c399 --- /dev/null +++ b/test/js/node/test/parallel/test-stdio-pipe-access.js @@ -0,0 +1,38 @@ +'use strict'; +const common = require('../common'); +if (!common.isMainThread) + common.skip("Workers don't have process-like stdio"); + +// Test if Node handles accessing process.stdin if it is a redirected +// pipe without deadlocking +const { spawn, spawnSync } = require('child_process'); + +const numTries = 5; +const who = process.argv.length <= 2 ? 'runner' : process.argv[2]; + +switch (who) { + case 'runner': + for (let num = 0; num < numTries; ++num) { + spawnSync(process.argv0, + [process.argv[1], 'parent'], + { 'stdio': 'inherit' }); + } + break; + case 'parent': { + const middle = spawn(process.argv0, + [process.argv[1], 'middle'], + { 'stdio': 'pipe' }); + middle.stdout.on('data', () => {}); + break; + } + case 'middle': + spawn(process.argv0, + [process.argv[1], 'bottom'], + { 'stdio': [ process.stdin, + process.stdout, + process.stderr ] }); + break; + case 'bottom': + process.stdin; // eslint-disable-line no-unused-expressions + break; +} diff --git a/test/js/node/test/parallel/test-strace-openat-openssl.js b/test/js/node/test/parallel/test-strace-openat-openssl.js deleted file mode 100644 index 13882e67ae..0000000000 --- a/test/js/node/test/parallel/test-strace-openat-openssl.js +++ /dev/null @@ -1,61 +0,0 @@ -'use strict'; - -const common = require('../common'); -const { spawn, spawnSync } = require('node:child_process'); -const { createInterface } = require('node:readline'); -const assert = require('node:assert'); - -if (!common.hasCrypto) - common.skip('missing crypto'); -if (!common.isLinux) - common.skip('linux only'); -if (common.isASan) - common.skip('strace does not work well with address sanitizer builds'); -if (spawnSync('strace').error !== undefined) { - common.skip('missing strace'); -} - -{ - const allowedOpenCalls = new Set([ - '/etc/ssl/openssl.cnf', - ]); - const strace = spawn('strace', [ - '-f', '-ff', - '-e', 'trace=open,openat', - '-s', '512', - '-D', process.execPath, '-e', 'require("crypto")', - ]); - - // stderr is the default for strace - const rl = createInterface({ input: strace.stderr }); - rl.on('line', (line) => { - if (!line.startsWith('open')) { - return; - } - - const file = line.match(/"(.*?)"/)[1]; - // skip .so reading attempt - if (file.match(/.+\.so(\.?)/) !== null) { - return; - } - // skip /proc/* - if (file.match(/\/proc\/.+/) !== null) { - return; - } - - assert(allowedOpenCalls.delete(file), `${file} is not in the list of allowed openat calls`); - }); - const debugOutput = []; - strace.stderr.setEncoding('utf8'); - strace.stderr.on('data', (chunk) => { - debugOutput.push(chunk.toString()); - }); - strace.on('error', common.mustNotCall()); - strace.on('exit', common.mustCall((code) => { - assert.strictEqual(code, 0, debugOutput); - const missingKeys = Array.from(allowedOpenCalls.keys()); - if (missingKeys.length) { - assert.fail(`The following openat call are missing: ${missingKeys.join(',')}`); - } - })); -} diff --git a/test/js/node/test/sequential/test-child-process-execsync.js b/test/js/node/test/sequential/test-child-process-execsync.js new file mode 100644 index 0000000000..dc65d735ae --- /dev/null +++ b/test/js/node/test/sequential/test-child-process-execsync.js @@ -0,0 +1,162 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +const assert = require('assert'); + +const { execFileSync, execSync, spawnSync } = require('child_process'); +const { getSystemErrorName } = require('util'); + +const TIMER = 200; +let SLEEP = 2000; +if (common.isWindows) { + // Some of the windows machines in the CI need more time to launch + // and receive output from child processes. + // https://github.com/nodejs/build/issues/3014 + SLEEP = 10000; +} + +// Verify that stderr is not accessed when a bad shell is used +assert.throws( + function() { execSync('exit -1', { shell: 'bad_shell' }); }, + /spawnSync bad_shell ENOENT|Executable not found in \$PATH: "bad_shell"/ +); +assert.throws( + function() { execFileSync('exit -1', { shell: 'bad_shell' }); }, + /spawnSync bad_shell ENOENT|Executable not found in \$PATH: "bad_shell"/ +); + +let caught = false; +let ret, err; +const start = Date.now(); +try { + const cmd = `"${common.isWindows ? process.execPath : '$NODE'}" -e "setTimeout(function(){}, ${SLEEP});"`; + ret = execSync(cmd, { env: { ...process.env, NODE: process.execPath }, timeout: TIMER }); +} catch (e) { + const end = Date.now() - start; + caught = true; + assert.strictEqual(getSystemErrorName(e.errno), 'ETIMEDOUT'); + err = e; +} finally { + assert.strictEqual(ret, undefined, + `should not have a return value, received ${ret}`); + assert.ok(caught, 'execSync should throw'); + const end = Date.now() - start; + assert(end < SLEEP); + assert(err.status > 128 || err.signal, `status: ${err.status}, signal: ${err.signal}`); +} + +assert.throws(function() { + execSync('iamabadcommand'); +}, /Command failed: iamabadcommand|iamabadcommand: command not found/); + +const msg = 'foobar'; +const msgBuf = Buffer.from(`${msg}\n`); + +// console.log ends every line with just '\n', even on Windows. + +const cmd = `"${common.isWindows ? process.execPath : '$NODE'}" -e "console.log('${msg}');"`; +const env = common.isWindows ? process.env : { ...process.env, NODE: process.execPath }; + +{ + const ret = execSync(cmd, common.isWindows ? undefined : { env }); + assert.strictEqual(ret.length, msgBuf.length); + assert.deepStrictEqual(ret, msgBuf); +} + +{ + const ret = execSync(cmd, { encoding: 'utf8', env }); + assert.strictEqual(ret, `${msg}\n`); +} + +const args = [ + '-e', + `console.log("${msg}");`, +]; +{ + const ret = execFileSync(process.execPath, args); + assert.deepStrictEqual(ret, msgBuf); +} + +{ + const ret = execFileSync(process.execPath, args, { encoding: 'utf8' }); + assert.strictEqual(ret, `${msg}\n`); +} + +// Verify that the cwd option works. +// See https://github.com/nodejs/node-v0.x-archive/issues/7824. +{ + const cwd = tmpdir.path; + const cmd = common.isWindows ? 'echo %cd%' : 'pwd'; + const response = execSync(cmd, { cwd }); + + assert.strictEqual(response.toString().trim(), cwd); +} + +// Verify that stderr is not accessed when stdio = 'ignore'. +// See https://github.com/nodejs/node-v0.x-archive/issues/7966. +{ + assert.throws(function() { + execSync('exit -1', { stdio: 'ignore' }); + }, /Command failed: exit -1/); +} + +// Verify the execFileSync() behavior when the child exits with a non-zero code. +{ + const args = ['-e', 'process.exit(1)']; + const spawnSyncResult = spawnSync(process.execPath, args); + const spawnSyncKeys = Object.keys(spawnSyncResult).sort(); + assert.deepStrictEqual(spawnSyncKeys, [ + 'output', + 'pid', + 'signal', + 'status', + 'stderr', + 'stdout', + ]); + + assert.throws(() => { + execFileSync(process.execPath, args); + }, (err) => { + const msg = `Command failed: ${process.execPath} ${args.join(' ')}`; + + assert(err instanceof Error); + assert.strictEqual(err.message, msg); + console.log(err); + assert.strictEqual(err.status, 1); + assert.strictEqual(typeof err.pid, 'number'); + spawnSyncKeys + .filter((key) => key !== 'pid') + .forEach((key) => { + assert.deepStrictEqual(err[key], spawnSyncResult[key]); + }); + return true; + }); +} + +// Verify the shell option works properly +execFileSync(`"${common.isWindows ? process.execPath : '$NODE'}"`, [], { + encoding: 'utf8', shell: true, env +});