From fd75ca7585554eefd3a201a275f8dce809690c9a Mon Sep 17 00:00:00 2001 From: Meghan Denny Date: Sun, 18 Aug 2024 00:12:42 -0700 Subject: [PATCH] implement `node:cluster` (#11492) Co-authored-by: Jarred Sumner Co-authored-by: nektro Co-authored-by: cirospaciari --- CMakeLists.txt | 1 + docs/runtime/nodejs-apis.md | 4 +- docs/runtime/web-apis.md | 6 +- scripts/runner.node.mjs | 13 +- src/bun.js/api/BunObject.classes.ts | 19 +- src/bun.js/api/bun/socket.zig | 3 +- src/bun.js/api/bun/subprocess.zig | 65 +++- src/bun.js/api/server.zig | 51 ++- src/bun.js/bindings/BunProcess.cpp | 166 ++++++++- src/bun.js/bindings/BunProcess.h | 10 + src/bun.js/bindings/ErrorCode.cpp | 38 +++ src/bun.js/bindings/JSNextTickQueue.cpp | 1 - src/bun.js/bindings/bindings.zig | 58 +++- src/bun.js/event_loop.zig | 31 +- src/bun.js/ipc.zig | 250 ++++++++++---- src/bun.js/javascript.zig | 130 +++++-- src/bun.js/node/node_cluster_binding.zig | 280 +++++++++++++++ src/bun.js/node/node_error_binding.zig | 28 ++ src/bun.js/node/node_http_binding.zig | 39 +++ src/bun.js/webcore/response.zig | 4 +- src/cli/test_command.zig | 2 +- src/io/PipeWriter.zig | 14 +- src/js/builtins/ProcessObjectInternals.ts | 18 + src/js/internal/cluster/RoundRobinHandle.ts | 139 ++++++++ src/js/internal/cluster/Worker.ts | 45 +++ src/js/internal/cluster/child.ts | 281 ++++++++++++++++ src/js/internal/cluster/isPrimary.ts | 4 + src/js/internal/cluster/primary.ts | 318 ++++++++++++++++++ src/js/internal/linkedlist.ts | 45 +++ src/js/internal/primordials.js | 3 + src/js/internal/shared.ts | 39 +++ src/js/node/child_process.ts | 45 ++- src/js/node/cluster.ts | 46 +-- src/js/node/dgram.ts | 2 +- src/js/node/http.ts | 111 +++++- src/js/node/net.ts | 207 +++++++++--- src/js/node/stream.ts | 2 + src/js/node/v8.ts | 2 + src/js/thirdparty/ws.js | 2 +- test/js/bun/http/serve.test.ts | 14 +- test/js/node/cluster/common.ts | 37 ++ test/js/node/cluster/test-docs-http-server.ts | 43 +++ .../node/cluster/test-worker-no-exit-http.ts | 61 ++++ .../node/cluster/upstream/common/countdown.js | 27 ++ test/js/node/cluster/upstream/common/index.js | 248 ++++++++++++++ .../js/node/cluster/upstream/common/tmpdir.js | 88 +++++ .../test-cluster-advanced-serialization.js | 28 ++ .../test-cluster-bind-privileged-port.js | 68 ++++ .../parallel/test-cluster-call-and-destroy.js | 18 + .../test-cluster-child-index-dgram.js | 43 +++ .../parallel/test-cluster-child-index-net.js | 35 ++ .../test-cluster-concurrent-disconnect.js | 58 ++++ .../upstream/parallel/test-cluster-cwd.js | 29 ++ .../test-cluster-disconnect-before-exit.js | 37 ++ ...r-disconnect-exitedAfterDisconnect-race.js | 33 ++ .../test-cluster-disconnect-idle-worker.js | 36 ++ .../parallel/test-cluster-disconnect-leak.js | 27 ++ ...test-cluster-disconnect-with-no-workers.js | 38 +++ .../parallel/test-cluster-fork-env.js | 66 ++++ .../parallel/test-cluster-fork-windowsHide.js | 76 +++++ .../parallel/test-cluster-invalid-message.js | 28 ++ .../parallel/test-cluster-kill-disconnect.js | 34 ++ .../test-cluster-kill-infinite-loop.js | 27 ++ .../parallel/test-cluster-listening-port.js | 22 ++ .../parallel/test-cluster-primary-error.js | 116 +++++++ .../parallel/test-cluster-primary-kill.js | 85 +++++ .../test-cluster-process-disconnect.js | 24 ++ .../parallel/test-cluster-rr-domain-listen.js | 49 +++ .../test-cluster-rr-handle-keep-loop-alive.js | 26 ++ .../upstream/parallel/test-cluster-rr-ref.js | 20 ++ .../parallel/test-cluster-send-deadlock.js | 78 +++++ .../test-cluster-setup-primary-argv.js | 41 +++ .../test-cluster-setup-primary-cumulative.js | 62 ++++ .../test-cluster-setup-primary-emit.js | 55 +++ .../test-cluster-setup-primary-multiple.js | 69 ++++ .../parallel/test-cluster-setup-primary.js | 93 +++++ ...ster-shared-handle-bind-privileged-port.js | 58 ++++ .../test-cluster-uncaught-exception.js | 56 +++ .../test-cluster-worker-constructor.js | 49 +++ .../parallel/test-cluster-worker-death.js | 44 +++ .../parallel/test-cluster-worker-destroy.js | 54 +++ ...test-cluster-worker-disconnect-on-error.js | 44 +++ .../test-cluster-worker-disconnect.js | 118 +++++++ .../parallel/test-cluster-worker-events.js | 78 +++++ .../parallel/test-cluster-worker-exit.js | 142 ++++++++ .../test-cluster-worker-forced-exit.js | 75 +++++ .../parallel/test-cluster-worker-init.js | 52 +++ .../parallel/test-cluster-worker-isdead.js | 34 ++ .../test-cluster-worker-kill-signal.js | 56 +++ .../parallel/test-cluster-worker-kill.js | 126 +++++++ .../parallel/test-cluster-worker-no-exit.js | 79 +++++ .../socket.io/socket.io-close.test.ts | 22 +- test/regression/issue/11297/11297.test.ts | 1 + 93 files changed, 5255 insertions(+), 294 deletions(-) create mode 100644 src/bun.js/node/node_cluster_binding.zig create mode 100644 src/bun.js/node/node_error_binding.zig create mode 100644 src/bun.js/node/node_http_binding.zig create mode 100644 src/js/internal/cluster/RoundRobinHandle.ts create mode 100644 src/js/internal/cluster/Worker.ts create mode 100644 src/js/internal/cluster/child.ts create mode 100644 src/js/internal/cluster/isPrimary.ts create mode 100644 src/js/internal/cluster/primary.ts create mode 100644 src/js/internal/linkedlist.ts create mode 100644 test/js/node/cluster/common.ts create mode 100644 test/js/node/cluster/test-docs-http-server.ts create mode 100644 test/js/node/cluster/test-worker-no-exit-http.ts create mode 100644 test/js/node/cluster/upstream/common/countdown.js create mode 100644 test/js/node/cluster/upstream/common/index.js create mode 100644 test/js/node/cluster/upstream/common/tmpdir.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-advanced-serialization.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-bind-privileged-port.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-call-and-destroy.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-child-index-dgram.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-child-index-net.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-concurrent-disconnect.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-cwd.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-disconnect-before-exit.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-disconnect-exitedAfterDisconnect-race.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-disconnect-idle-worker.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-disconnect-leak.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-disconnect-with-no-workers.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-fork-env.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-fork-windowsHide.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-invalid-message.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-kill-disconnect.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-kill-infinite-loop.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-listening-port.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-primary-error.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-primary-kill.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-process-disconnect.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-rr-domain-listen.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-rr-handle-keep-loop-alive.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-rr-ref.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-send-deadlock.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-setup-primary-argv.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-setup-primary-cumulative.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-setup-primary-emit.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-setup-primary-multiple.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-setup-primary.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-shared-handle-bind-privileged-port.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-uncaught-exception.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-worker-constructor.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-worker-death.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-worker-destroy.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-worker-disconnect-on-error.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-worker-disconnect.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-worker-events.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-worker-exit.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-worker-forced-exit.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-worker-init.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-worker-isdead.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-worker-kill-signal.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-worker-kill.js create mode 100644 test/js/node/cluster/upstream/parallel/test-cluster-worker-no-exit.js diff --git a/CMakeLists.txt b/CMakeLists.txt index 5bb26734dbb50c..af4c25b35c03fc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -828,6 +828,7 @@ if(NOT NO_CODEGEN) "${BUN_SRC}/js/thirdparty/*.ts" "${BUN_SRC}/js/internal/*.js" "${BUN_SRC}/js/internal/*.ts" + "${BUN_SRC}/js/internal/cluster/*.ts" "${BUN_SRC}/js/internal/util/*.js" "${BUN_SRC}/js/internal/fs/*.ts" "${BUN_SRC}/js/node/*.js" diff --git a/docs/runtime/nodejs-apis.md b/docs/runtime/nodejs-apis.md index ef201aa2f2f1d1..e21160e8fad5ce 100644 --- a/docs/runtime/nodejs-apis.md +++ b/docs/runtime/nodejs-apis.md @@ -22,7 +22,7 @@ This page is updated regularly to reflect compatibility status of the latest ver ### [`node:cluster`](https://nodejs.org/api/cluster.html) -🔴 Not implemented. +🟡 Handles and file descriptors cannot be passed between workers, which means load-balancing HTTP requests across processes is only supported on Linux at this time (via `SO_REUSEPORT`). Otherwise, implemented but not battle-tested. ### [`node:console`](https://nodejs.org/api/console.html) @@ -341,7 +341,7 @@ The table below lists all globals implemented by Node.js and Bun's current compa ### [`process`](https://nodejs.org/api/process.html) -🟡 Missing `domain` `initgroups` `setegid` `seteuid` `setgid` `setgroups` `setuid` `allowedNodeEnvironmentFlags` `getActiveResourcesInfo` `setActiveResourcesInfo` `moduleLoadList` `setSourceMapsEnabled` `channel`. `process.binding` is partially implemented. +🟡 Missing `domain` `initgroups` `setegid` `seteuid` `setgid` `setgroups` `setuid` `allowedNodeEnvironmentFlags` `getActiveResourcesInfo` `setActiveResourcesInfo` `moduleLoadList` `setSourceMapsEnabled`. `process.binding` is partially implemented. ### [`queueMicrotask()`](https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask) diff --git a/docs/runtime/web-apis.md b/docs/runtime/web-apis.md index 4280aa40783766..16cb2d6cc92935 100644 --- a/docs/runtime/web-apis.md +++ b/docs/runtime/web-apis.md @@ -27,7 +27,8 @@ The following Web APIs are partially or completely supported. [`self.postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope/postMessage) [`structuredClone`](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone) [`MessagePort`](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort) - [`MessageChannel`](https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel), [`BroadcastChannel`](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel). + [`MessageChannel`](https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel) + [`BroadcastChannel`](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel). --- @@ -70,7 +71,8 @@ The following Web APIs are partially or completely supported. --- - Intervals -- [`setInterval`](https://developer.mozilla.org/en-US/docs/Web/API/setInterval)[`clearInterval`](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval) +- [`setInterval`](https://developer.mozilla.org/en-US/docs/Web/API/setInterval) + [`clearInterval`](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval) --- diff --git a/scripts/runner.node.mjs b/scripts/runner.node.mjs index f28a38365cfd16..abec27bcd1a8b8 100755 --- a/scripts/runner.node.mjs +++ b/scripts/runner.node.mjs @@ -21,7 +21,7 @@ import { } from "node:fs"; import { spawn, spawnSync } from "node:child_process"; import { tmpdir, hostname, userInfo, homedir } from "node:os"; -import { join, basename, dirname, relative } from "node:path"; +import { join, basename, dirname, relative, sep } from "node:path"; import { normalize as normalizeWindows } from "node:path/win32"; import { isIP } from "node:net"; import { parseArgs } from "node:util"; @@ -529,10 +529,11 @@ async function spawnBun(execPath, { args, cwd, timeout, env, stdout, stderr }) { async function spawnBunTest(execPath, testPath) { const timeout = getTestTimeout(testPath); const perTestTimeout = Math.ceil(timeout / 2); + const isReallyTest = isTestStrict(testPath); const { ok, error, stdout } = await spawnBun(execPath, { - args: ["test", `--timeout=${perTestTimeout}`, testPath], + args: isReallyTest ? ["test", `--timeout=${perTestTimeout}`, testPath] : [testPath], cwd: cwd, - timeout, + timeout: isReallyTest ? timeout : 30_000, env: { GITHUB_ACTIONS: "true", // always true so annotations are parsed }, @@ -811,6 +812,12 @@ function isJavaScript(path) { * @returns {boolean} */ function isTest(path) { + if (path.replaceAll(sep, "/").includes("/test-cluster-") && path.endsWith(".js")) return true; + if (path.replaceAll(sep, "/").startsWith("js/node/cluster/test-") && path.endsWith(".ts")) return true; + return isTestStrict(path); +} + +function isTestStrict(path) { return isJavaScript(path) && /\.test|spec\./.test(basename(path)); } diff --git a/src/bun.js/api/BunObject.classes.ts b/src/bun.js/api/BunObject.classes.ts index 8b01189f37ba4d..79cb755e8ad042 100644 --- a/src/bun.js/api/BunObject.classes.ts +++ b/src/bun.js/api/BunObject.classes.ts @@ -62,6 +62,10 @@ export default [ getter: "getStdout", cache: true, }, + stderr: { + getter: "getStderr", + cache: true, + }, writable: { getter: "getStdin", cache: "stdin", @@ -70,11 +74,6 @@ export default [ getter: "getStdout", cache: "stdout", }, - stderr: { - getter: "getStderr", - cache: true, - }, - ref: { fn: "doRef", length: 0, @@ -91,20 +90,24 @@ export default [ fn: "doSend", length: 1, }, - kill: { fn: "kill", length: 1, }, + disconnect: { + fn: "disconnect", + length: 0, + }, + connected: { + getter: "getConnected", + }, "@@asyncDispose": { fn: "asyncDispose", length: 1, }, - killed: { getter: "getKilled", }, - exitCode: { getter: "getExitCode", }, diff --git a/src/bun.js/api/bun/socket.zig b/src/bun.js/api/bun/socket.zig index 94c3529b2f0235..ca53ac00e1285d 100644 --- a/src/bun.js/api/bun/socket.zig +++ b/src/bun.js/api/bun/socket.zig @@ -868,7 +868,7 @@ pub const Listener = struct { } pub fn finalize(this: *Listener) callconv(.C) void { - log("Finalize", .{}); + log("finalize", .{}); if (this.listener) |listener| { this.listener = null; listener.close(this.ssl); @@ -878,6 +878,7 @@ pub const Listener = struct { } pub fn deinit(this: *Listener) void { + log("deinit", .{}); this.strong_self.deinit(); this.strong_data.deinit(); this.poll_ref.unref(this.handlers.vm); diff --git a/src/bun.js/api/bun/subprocess.zig b/src/bun.js/api/bun/subprocess.zig index 93a4c2e64b665e..68ee768891982d 100644 --- a/src/bun.js/api/bun/subprocess.zig +++ b/src/bun.js/api/bun/subprocess.zig @@ -20,6 +20,7 @@ const windows = bun.windows; const uv = windows.libuv; const LifecycleScriptSubprocess = bun.install.LifecycleScriptSubprocess; const Body = JSC.WebCore.Body; +const IPClog = Output.scoped(.IPC, false); const PosixSpawn = bun.posix.spawn; const Rusage = bun.posix.spawn.Rusage; @@ -128,10 +129,7 @@ pub const ResourceUsage = struct { }; pub fn appendEnvpFromJS(globalThis: *JSC.JSGlobalObject, object: JSC.JSValue, envp: *std.ArrayList(?[*:0]const u8), PATH: *[]const u8) !void { - var object_iter = JSC.JSPropertyIterator(.{ - .skip_empty_name = false, - .include_value = true, - }).init(globalThis, object); + var object_iter = JSC.JSPropertyIterator(.{ .skip_empty_name = false, .include_value = true }).init(globalThis, object); defer object_iter.deinit(); try envp.ensureTotalCapacityPrecise(object_iter.len + // +1 incase there's IPC @@ -185,6 +183,7 @@ pub const Subprocess = struct { exit_promise: JSC.Strong = .{}, on_exit_callback: JSC.Strong = .{}, + on_disconnect_callback: JSC.Strong = .{}, globalThis: *JSC.JSGlobalObject, observable_getters: std.enums.EnumSet(enum { @@ -715,6 +714,7 @@ pub const Subprocess = struct { } pub fn doSend(this: *Subprocess, global: *JSC.JSGlobalObject, callFrame: *JSC.CallFrame) JSValue { + IPClog("Subprocess#doSend", .{}); const ipc_data = &(this.ipc_data orelse { if (this.hasExited()) { global.throw("Subprocess.send() cannot be used after the process has exited.", .{}); @@ -737,10 +737,18 @@ pub const Subprocess = struct { return .undefined; } - pub fn disconnect(this: *Subprocess) void { - const ipc_data = this.ipc_data orelse return; - ipc_data.socket.close(.normal); + pub fn disconnect(this: *Subprocess, globalThis: *JSGlobalObject, callframe: *JSC.CallFrame) JSValue { + _ = globalThis; + _ = callframe; + const ipc_data = this.ipc_maybe() orelse return .undefined; + ipc_data.close(); this.ipc_data = null; + return .undefined; + } + + pub fn getConnected(this: *Subprocess, globalThis: *JSGlobalObject) JSValue { + _ = globalThis; + return JSValue.jsBoolean(this.ipc_maybe() != null); } pub fn pid(this: *const Subprocess) i32 { @@ -1546,6 +1554,7 @@ pub const Subprocess = struct { this.exit_promise.deinit(); this.on_exit_callback.deinit(); + this.on_disconnect_callback.deinit(); } pub fn finalize(this: *Subprocess) callconv(.C) void { @@ -1635,10 +1644,7 @@ pub const Subprocess = struct { var allocator = arena.allocator(); var override_env = false; - var env_array = std.ArrayListUnmanaged(?[*:0]const u8){ - .items = &.{}, - .capacity = 0, - }; + var env_array = std.ArrayListUnmanaged(?[*:0]const u8){}; var jsc_vm = globalThis.bunVM(); var cwd = jsc_vm.bundler.fs.top_level_dir; @@ -1655,6 +1661,7 @@ pub const Subprocess = struct { } var lazy = false; var on_exit_callback = JSValue.zero; + var on_disconnect_callback = JSValue.zero; var PATH = jsc_vm.bundler.env.get("PATH") orelse ""; var argv = std.ArrayList(?[*:0]const u8).init(allocator); var cmd_value = JSValue.zero; @@ -1772,7 +1779,6 @@ pub const Subprocess = struct { } if (args != .zero and args.isObject()) { - // This must run before the stdio parsing happens if (!is_sync) { if (args.getTruthy(globalThis, "ipc")) |val| { @@ -1801,6 +1807,18 @@ pub const Subprocess = struct { } } + if (args.getTruthy(globalThis, "onDisconnect")) |onDisconnect_| { + if (!onDisconnect_.isCell() or !onDisconnect_.isCallable(globalThis.vm())) { + globalThis.throwInvalidArguments("onDisconnect must be a function or undefined", .{}); + return .zero; + } + + on_disconnect_callback = if (comptime is_sync) + onDisconnect_ + else + onDisconnect_.withAsyncContextIfNeeded(globalThis); + } + if (args.getTruthy(globalThis, "cwd")) |cwd_| { const cwd_str = cwd_.getZigString(globalThis); if (cwd_str.len > 0) { @@ -2120,6 +2138,7 @@ pub const Subprocess = struct { ), .stdio_pipes = spawned.extra_pipes.moveToUnmanaged(), .on_exit_callback = if (on_exit_callback != .zero) JSC.Strong.create(on_exit_callback, globalThis) else .{}, + .on_disconnect_callback = if (on_disconnect_callback != .zero) JSC.Strong.create(on_disconnect_callback, globalThis) else .{}, .ipc_data = if (!is_sync) if (maybe_ipc_mode) |ipc_mode| if (Environment.isWindows) .{ @@ -2266,10 +2285,13 @@ pub const Subprocess = struct { return sync_value; } + const node_cluster_binding = @import("./../../node/node_cluster_binding.zig"); + pub fn handleIPCMessage( this: *Subprocess, message: IPC.DecodedIPCMessage, ) void { + IPClog("Subprocess#handleIPCMessage", .{}); switch (message) { // In future versions we can read this in order to detect version mismatches, // or disable future optimizations if the subprocess is old. @@ -2287,17 +2309,34 @@ pub const Subprocess = struct { ); } }, + .internal => |data| { + IPC.log("Received IPC internal message from child", .{}); + node_cluster_binding.handleInternalMessagePrimary(this.globalThis, this, data); + }, } } pub fn handleIPCClose(this: *Subprocess) void { - this.ipc_data = null; + IPClog("Subprocess#handleIPCClose", .{}); this.updateHasPendingActivity(); + const ok = this.ipc_data != null; + if (ok) this.ipc().internal_msg_queue.deinit(); + this.ipc_data = null; + + const this_jsvalue = this.this_jsvalue; + this_jsvalue.ensureStillAlive(); + if (this.on_disconnect_callback.trySwap()) |callback| { + this.globalThis.bunVM().eventLoop().runCallback(callback, this.globalThis, this_jsvalue, &.{JSValue.jsBoolean(ok)}); + } } pub fn ipc(this: *Subprocess) *IPC.IPCData { return &this.ipc_data.?; } + pub fn ipc_maybe(this: *Subprocess) ?*IPC.IPCData { + return &(this.ipc_data orelse return null); + } + pub const IPCHandler = IPC.NewIPCHandler(Subprocess); }; diff --git a/src/bun.js/api/server.zig b/src/bun.js/api/server.zig index 5f5aaac3849dd6..64c3e0ffbf7509 100644 --- a/src/bun.js/api/server.zig +++ b/src/bun.js/api/server.zig @@ -89,6 +89,7 @@ const SendfileContext = struct { }; const linux = std.os.linux; const Async = bun.Async; +const httplog = Output.scoped(.Server, false); const BlobFileContentResult = struct { data: [:0]const u8, @@ -5296,8 +5297,6 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp pub const App = uws.NewApp(ssl_enabled); - const httplog = Output.scoped(.Server, false); - listener: ?*App.ListenSocket = null, thisObject: JSC.JSValue = JSC.JSValue.zero, app: *App = undefined, @@ -5307,6 +5306,7 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp config: ServerConfig = ServerConfig{}, pending_requests: usize = 0, request_pool_allocator: *RequestContext.RequestContextStackAllocator = undefined, + all_closed_promise: JSC.JSPromise.Strong = .{}, listen_callback: JSC.AnyTask = undefined, allocator: std.mem.Allocator, @@ -5316,10 +5316,11 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp cached_hostname: bun.String = bun.String.empty, cached_protocol: bun.String = bun.String.empty, - flags: packed struct(u3) { + flags: packed struct(u4) { deinit_scheduled: bool = false, terminated: bool = false, has_js_deinited: bool = false, + has_handled_all_closed_promise: bool = false, } = .{}, pub const doStop = JSC.wrapInstanceMethod(ThisServer, "stopFromJS", false); @@ -5965,6 +5966,24 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp pub fn deinitIfWeCan(this: *ThisServer) void { httplog("deinitIfWeCan", .{}); + + const vm = this.globalThis.bunVM(); + + if (this.pending_requests == 0 and this.listener == null and !this.hasActiveWebSockets() and !this.flags.has_handled_all_closed_promise and this.all_closed_promise.strong.has()) { + const event_loop = vm.eventLoop(); + + // use a flag here instead of `this.all_closed_promise.get().isHandled(vm)` to prevent the race condition of this block being called + // again before the task has run. + this.flags.has_handled_all_closed_promise = true; + + const task = ServerAllConnectionsClosedTask.new(.{ + .globalObject = this.globalThis, + .promise = this.all_closed_promise, + .tracker = JSC.AsyncTaskTracker.init(vm), + }); + this.all_closed_promise = .{}; + event_loop.enqueueTask(JSC.Task.init(task)); + } if (this.pending_requests == 0 and this.listener == null and this.flags.has_js_deinited and !this.hasActiveWebSockets()) { if (this.config.websocket) |*ws| { ws.handler.app = null; @@ -6566,6 +6585,32 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp }; } +pub const ServerAllConnectionsClosedTask = struct { + globalObject: *JSC.JSGlobalObject, + promise: JSC.JSPromise.Strong, + tracker: JSC.AsyncTaskTracker, + + pub usingnamespace bun.New(@This()); + + pub fn runFromJSThread(this: *ServerAllConnectionsClosedTask, vm: *JSC.VirtualMachine) void { + httplog("ServerAllConnectionsClosedTask runFromJSThread", .{}); + + const globalObject = this.globalObject; + const tracker = this.tracker; + tracker.willDispatch(globalObject); + defer tracker.didDispatch(globalObject); + + var promise = this.promise; + this.destroy(); + + if (!vm.isShuttingDown()) { + promise.resolve(globalObject, .undefined); + } else { + promise.deinit(); + } + } +}; + pub const HTTPServer = NewServer(JSC.Codegen.JSHTTPServer, false, false); pub const HTTPSServer = NewServer(JSC.Codegen.JSHTTPSServer, true, false); pub const DebugHTTPServer = NewServer(JSC.Codegen.JSDebugHTTPServer, false, true); diff --git a/src/bun.js/bindings/BunProcess.cpp b/src/bun.js/bindings/BunProcess.cpp index 6aa54597f44b9b..8fa943778bb112 100644 --- a/src/bun.js/bindings/BunProcess.cpp +++ b/src/bun.js/bindings/BunProcess.cpp @@ -31,6 +31,8 @@ #include "wtf/text/ASCIILiteral.h" #include "wtf/text/OrdinalNumber.h" +#include "AsyncContextFrame.h" + #ifndef WIN32 #include #include @@ -105,17 +107,21 @@ JSC_DECLARE_CUSTOM_GETTER(Process_getTitle); JSC_DECLARE_CUSTOM_GETTER(Process_getPID); JSC_DECLARE_CUSTOM_GETTER(Process_getPPID); JSC_DECLARE_HOST_FUNCTION(Process_functionCwd); +JSC_DEFINE_HOST_FUNCTION(jsFunction_ERR_IPC_DISCONNECTED, (JSC::JSGlobalObject * globalObject, JSC::CallFrame*)); static bool processIsExiting = false; extern "C" uint8_t Bun__getExitCode(void*); extern "C" uint8_t Bun__setExitCode(void*, uint8_t); extern "C" void* Bun__getVM(); +extern "C" bool Bun__closeChildIPC(JSGlobalObject*); extern "C" Zig::GlobalObject* Bun__getDefaultGlobalObject(); extern "C" bool Bun__GlobalObject__hasIPC(JSGlobalObject*); extern "C" bool Bun__ensureProcessIPCInitialized(JSGlobalObject*); extern "C" const char* Bun__githubURL; BUN_DECLARE_HOST_FUNCTION(Bun__Process__send); -BUN_DECLARE_HOST_FUNCTION(Bun__Process__disconnect); + +extern "C" void Process__emitDisconnectEvent(Zig::GlobalObject* global); +extern "C" void Process__emitErrorEvent(Zig::GlobalObject* global, EncodedJSValue value); static JSValue constructArch(VM& vm, JSObject* processObject) { @@ -892,22 +898,33 @@ 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; + + if (enabled) { + process->scriptExecutionContext()->refEventLoop(); + } else { + process->scriptExecutionContext()->unrefEventLoop(); + } +} + static void onDidChangeListeners(EventEmitter& eventEmitter, const Identifier& eventName, bool isAdded) { if (eventEmitter.scriptExecutionContext()->isMainThread()) { // IPC handlers - if (eventName.string() == "message"_s) { + if (eventName.string() == "message"_s || eventName.string() == "disconnect"_s) { + auto* global = jsCast(eventEmitter.scriptExecutionContext()->jsGlobalObject()); if (isAdded) { - auto* global = eventEmitter.scriptExecutionContext()->jsGlobalObject(); if (Bun__GlobalObject__hasIPC(global) && eventEmitter.listenerCount(eventName) == 1) { Bun__ensureProcessIPCInitialized(global); - eventEmitter.scriptExecutionContext()->refEventLoop(); - eventEmitter.m_hasIPCRef = true; + Bun__setChannelRef(global, true); } } else { - if (eventEmitter.listenerCount(eventName) == 0 && eventEmitter.m_hasIPCRef) { - eventEmitter.scriptExecutionContext()->unrefEventLoop(); + if (eventEmitter.listenerCount(eventName) == 0) { + Bun__setChannelRef(global, false); } } return; @@ -1916,6 +1933,29 @@ static JSValue constructProcessSend(VM& vm, JSObject* processObject) } } +JSC_DEFINE_HOST_FUNCTION(processDisonnectFinish, (JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + Bun__closeChildIPC(globalObject); + return JSC::JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(Bun__Process__disconnect, (JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto& vm = globalObject->vm(); + auto global = jsCast(globalObject); + + if (!Bun__GlobalObject__hasIPC(globalObject)) { + Process__emitErrorEvent(global, jsFunction_ERR_IPC_DISCONNECTED(globalObject, nullptr)); + return JSC::JSValue::encode(jsUndefined()); + } + + auto finishFn = JSC::JSFunction::create(vm, globalObject, 0, String("finish"_s), processDisonnectFinish, ImplementationVisibility::Public); + auto process = jsCast(global->processObject()); + + process->queueNextTick(vm, globalObject, finishFn); + return JSC::JSValue::encode(jsUndefined()); +} + static JSValue constructProcessDisconnect(VM& vm, JSObject* processObject) { auto* globalObject = processObject->globalObject(); @@ -1926,6 +1966,36 @@ static JSValue constructProcessDisconnect(VM& vm, JSObject* processObject) } } +static JSValue constructProcessChannel(VM& vm, JSObject* processObject) +{ + auto* globalObject = processObject->globalObject(); + if (Bun__GlobalObject__hasIPC(globalObject)) { + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSC::JSFunction* getControl = JSC::JSFunction::create(vm, globalObject, processObjectInternalsGetChannelCodeGenerator(vm), globalObject); + JSC::MarkedArgumentBuffer args; + JSC::CallData callData = JSC::getCallData(getControl); + + NakedPtr returnedException = nullptr; + auto result = JSC::call(globalObject, getControl, callData, globalObject->globalThis(), args, returnedException); + RETURN_IF_EXCEPTION(scope, {}); + + if (auto* exception = returnedException.get()) { +#if BUN_DEBUG + Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception); +#endif + scope.throwException(globalObject, exception->value()); + returnedException.clear(); + return {}; + } + + return result; + } else { + return jsUndefined(); + } +} + #if OS(WINDOWS) #define getpid _getpid #endif @@ -2184,6 +2254,7 @@ void Process::visitChildrenImpl(JSCell* cell, Visitor& visitor) ASSERT_GC_OBJECT_INHERITS(thisObject, info()); Base::visitChildren(thisObject, visitor); visitor.append(thisObject->m_uncaughtExceptionCaptureCallback); + visitor.append(thisObject->m_nextTickFunction); thisObject->m_cpuUsageStructure.visit(visitor); thisObject->m_memoryUsageStructure.visit(visitor); thisObject->m_bindingUV.visit(visitor); @@ -2595,10 +2666,52 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionDrainMicrotaskQueue, (JSC::JSGlobalObject * g return JSValue::encode(jsUndefined()); } -static JSValue constructProcessNextTickFn(VM& vm, JSObject* processObject) +void Process::queueNextTick(JSC::VM& vm, JSC::JSGlobalObject* globalObject, const ArgList& args) +{ + auto scope = DECLARE_THROW_SCOPE(vm); + if (!this->m_nextTickFunction) { + this->get(globalObject, Identifier::fromString(vm, "nextTick"_s)); + RETURN_IF_EXCEPTION(scope, void()); + } + + ASSERT(!args.isEmpty()); + JSObject* nextTickFn = this->m_nextTickFunction.get(); + AsyncContextFrame::call(globalObject, nextTickFn, jsUndefined(), args); + RELEASE_AND_RETURN(scope, void()); +} + +void Process::queueNextTick(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSValue value) +{ + ASSERT_WITH_MESSAGE(value.isCallable(), "Must be a function for us to call"); + MarkedArgumentBuffer args; + if (value != 0) + args.append(value); + this->queueNextTick(vm, globalObject, args); +} + +void Process::queueNextTick(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSValue value, JSValue arg1) +{ + ASSERT_WITH_MESSAGE(value.isCallable(), "Must be a function for us to call"); + MarkedArgumentBuffer args; + if (value != 0) { + args.append(value); + if (arg1 != 0) { + args.append(arg1); + } + } + this->queueNextTick(vm, globalObject, args); +} + +extern "C" void Bun__Process__queueNextTick1(GlobalObject* globalObject, EncodedJSValue value, EncodedJSValue arg1) +{ + auto process = jsCast(globalObject->processObject()); + auto& vm = globalObject->vm(); + process->queueNextTick(vm, globalObject, JSValue::decode(value), JSValue::decode(arg1)); +} +JSC_DECLARE_HOST_FUNCTION(Bun__Process__queueNextTick1); + +JSValue Process::constructNextTickFn(JSC::VM& vm, Zig::GlobalObject* globalObject) { - JSGlobalObject* lexicalGlobalObject = processObject->globalObject(); - Zig::GlobalObject* globalObject = jsCast(lexicalGlobalObject); JSValue nextTickQueueObject; if (!globalObject->m_nextTickQueue) { nextTickQueueObject = Bun::JSNextTickQueue::create(globalObject); @@ -2607,15 +2720,27 @@ static JSValue constructProcessNextTickFn(VM& vm, JSObject* processObject) nextTickQueueObject = jsCast(globalObject->m_nextTickQueue.get()); } - JSC::JSFunction* initializer = JSC::JSFunction::create(vm, globalObject, processObjectInternalsInitializeNextTickQueueCodeGenerator(vm), lexicalGlobalObject); + JSC::JSFunction* initializer = JSC::JSFunction::create(vm, globalObject, processObjectInternalsInitializeNextTickQueueCodeGenerator(vm), globalObject); JSC::MarkedArgumentBuffer args; - args.append(processObject); + args.append(this); args.append(nextTickQueueObject); args.append(JSC::JSFunction::create(vm, globalObject, 1, String(), jsFunctionDrainMicrotaskQueue, ImplementationVisibility::Private)); args.append(JSC::JSFunction::create(vm, globalObject, 1, String(), jsFunctionReportUncaughtException, ImplementationVisibility::Private)); - return JSC::call(globalObject, initializer, JSC::getCallData(initializer), globalObject->globalThis(), args); + JSValue nextTickFunction = JSC::call(globalObject, initializer, JSC::getCallData(initializer), globalObject->globalThis(), args); + if (nextTickFunction && nextTickFunction.isObject()) { + this->m_nextTickFunction.set(vm, this, nextTickFunction.getObject()); + } + + return nextTickFunction; +} + +static JSValue constructProcessNextTickFn(VM& vm, JSObject* processObject) +{ + JSGlobalObject* lexicalGlobalObject = processObject->globalObject(); + Zig::GlobalObject* globalObject = jsCast(lexicalGlobalObject); + return jsCast(processObject)->constructNextTickFn(globalObject->vm(), globalObject); } static JSValue constructFeatures(VM& vm, JSObject* processObject) @@ -2775,14 +2900,12 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionKill, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); - int pid = callFrame->argument(0).toInt32(globalObject); RETURN_IF_EXCEPTION(scope, {}); if (pid < 0) { throwNodeRangeError(globalObject, scope, "pid must be a positive integer"_s); return JSValue::encode(jsUndefined()); } - JSC::JSValue signalValue = callFrame->argument(1); int signal = SIGTERM; if (signalValue.isNumber()) { @@ -2797,7 +2920,6 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionKill, throwNodeRangeError(globalObject, scope, "Unknown signal name"_s); return JSValue::encode(jsUndefined()); } - RETURN_IF_EXCEPTION(scope, {}); } else if (!signalValue.isUndefinedOrNull()) { throwTypeError(globalObject, scope, "signal must be a string or number"_s); @@ -2860,6 +2982,17 @@ extern "C" void Process__emitDisconnectEvent(Zig::GlobalObject* global) } } +extern "C" void Process__emitErrorEvent(Zig::GlobalObject* global, EncodedJSValue value) +{ + auto* process = static_cast(global->processObject()); + auto& vm = global->vm(); + if (process->wrapped().hasEventListeners(vm.propertyNames->error)) { + JSC::MarkedArgumentBuffer args; + args.append(JSValue::decode(value)); + process->wrapped().emit(vm.propertyNames->error, args); + } +} + /* Source for Process.lut.h @begin processObjectTable abort Process_functionAbort Function 1 @@ -2871,6 +3004,7 @@ extern "C" void Process__emitDisconnectEvent(Zig::GlobalObject* global) binding Process_functionBinding Function 1 browser constructBrowser PropertyCallback chdir Process_functionChdir Function 1 + channel constructProcessChannel PropertyCallback config constructProcessConfigObject PropertyCallback connected processConnected CustomAccessor constrainedMemory Process_functionConstrainedMemory Function 0 diff --git a/src/bun.js/bindings/BunProcess.h b/src/bun.js/bindings/BunProcess.h index 6b11936372bb85..957dc2f401d5b0 100644 --- a/src/bun.js/bindings/BunProcess.h +++ b/src/bun.js/bindings/BunProcess.h @@ -6,6 +6,10 @@ #include "BunClientData.h" #include "JSEventEmitter.h" +namespace Zig { +class GlobalObject; +} + namespace Bun { // TODO: find a better place for this @@ -21,6 +25,7 @@ class Process : public WebCore::JSEventEmitter { LazyProperty m_bindingUV; LazyProperty m_bindingNatives; WriteBarrier m_uncaughtExceptionCaptureCallback; + WriteBarrier m_nextTickFunction; public: Process(JSC::Structure* structure, WebCore::JSDOMGlobalObject& globalObject, Ref&& impl) @@ -41,6 +46,11 @@ class Process : public WebCore::JSEventEmitter { static constexpr unsigned StructureFlags = Base::StructureFlags | HasStaticPropertyTable; + JSValue constructNextTickFn(JSC::VM& vm, Zig::GlobalObject* globalObject); + void queueNextTick(JSC::VM& vm, JSC::JSGlobalObject* globalObject, const ArgList& args); + void queueNextTick(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSValue); + void queueNextTick(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSValue, JSValue); + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) { diff --git a/src/bun.js/bindings/ErrorCode.cpp b/src/bun.js/bindings/ErrorCode.cpp index 2a89fa0cafc03b..20fa1ab8a17f3c 100644 --- a/src/bun.js/bindings/ErrorCode.cpp +++ b/src/bun.js/bindings/ErrorCode.cpp @@ -15,6 +15,8 @@ #include "JavaScriptCore/JSString.h" #include "JavaScriptCore/JSType.h" #include "JavaScriptCore/Symbol.h" +#include "wtf/Assertions.h" +#include "wtf/text/ASCIIFastPath.h" #include "wtf/text/ASCIILiteral.h" #include "wtf/text/MakeString.h" #include "wtf/text/WTFString.h" @@ -67,6 +69,7 @@ static JSC::JSObject* createErrorPrototype(JSC::VM& vm, JSC::JSGlobalObject* glo extern "C" JSC::EncodedJSValue Bun__ERR_INVALID_ARG_TYPE(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue val_arg_name, JSC::EncodedJSValue val_expected_type, JSC::EncodedJSValue val_actual_value); extern "C" JSC::EncodedJSValue Bun__ERR_INVALID_ARG_TYPE_static(JSC::JSGlobalObject* globalObject, const ZigString* val_arg_name, const ZigString* val_expected_type, JSC::EncodedJSValue val_actual_value); extern "C" JSC::EncodedJSValue Bun__ERR_MISSING_ARGS(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue arg1, JSC::EncodedJSValue arg2, JSC::EncodedJSValue arg3); +extern "C" JSC::EncodedJSValue Bun__ERR_MISSING_ARGS_static(JSC::JSGlobalObject* globalObject, const ZigString* arg1, const ZigString* arg2, const ZigString* arg3); extern "C" JSC::EncodedJSValue Bun__ERR_IPC_CHANNEL_CLOSED(JSC::JSGlobalObject* globalObject); // clang-format on @@ -317,6 +320,41 @@ extern "C" JSC::EncodedJSValue Bun__ERR_MISSING_ARGS(JSC::JSGlobalObject* global auto message = makeString("The \""_s, name1, "\", \""_s, name2, "\", and \""_s, name3, "\" arguments must be specified"_s); return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_MISSING_ARGS, message)); } +extern "C" JSC::EncodedJSValue Bun__ERR_MISSING_ARGS_static(JSC::JSGlobalObject* globalObject, const ZigString* arg1, const ZigString* arg2, const ZigString* arg3) +{ + JSC::VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (arg1 == 0) { + JSC::throwTypeError(globalObject, scope, "requires at least 1 argument"_s); + return {}; + } + + auto name1 = std::span(arg1->ptr, arg1->len); + ASSERT(WTF::charactersAreAllASCII(name1)); + + if (arg2 == nullptr) { + // 1 arg name passed + auto message = makeString("The \""_s, name1, "\" argument must be specified"_s); + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_MISSING_ARGS, message)); + } + + auto name2 = std::span(arg2->ptr, arg2->len); + ASSERT(WTF::charactersAreAllASCII(name2)); + + if (arg3 == nullptr) { + // 2 arg names passed + auto message = makeString("The \""_s, name1, "\" and \""_s, name2, "\" arguments must be specified"_s); + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_MISSING_ARGS, message)); + } + + auto name3 = std::span(arg3->ptr, arg3->len); + ASSERT(WTF::charactersAreAllASCII(name3)); + + // 3 arg names passed + auto message = makeString("The \""_s, name1, "\", \""_s, name2, "\", and \""_s, name3, "\" arguments must be specified"_s); + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_MISSING_ARGS, message)); +} JSC_DEFINE_HOST_FUNCTION(jsFunction_ERR_IPC_CHANNEL_CLOSED, (JSC::JSGlobalObject * globalObject, JSC::CallFrame*)) { diff --git a/src/bun.js/bindings/JSNextTickQueue.cpp b/src/bun.js/bindings/JSNextTickQueue.cpp index 18963645cb4be7..6c3557177bd3b2 100644 --- a/src/bun.js/bindings/JSNextTickQueue.cpp +++ b/src/bun.js/bindings/JSNextTickQueue.cpp @@ -87,7 +87,6 @@ void JSNextTickQueue::drain(JSC::VM& vm, JSC::JSGlobalObject* globalObject) globalObject->m_asyncContextData.get()->putInternalField(vm, 0, jsUndefined()); } auto* drainFn = internalField(2).get().getObject(); - auto throwScope = DECLARE_THROW_SCOPE(vm); MarkedArgumentBuffer drainArgs; JSC::call(globalObject, drainFn, drainArgs, "Failed to drain next tick queue"_s); diff --git a/src/bun.js/bindings/bindings.zig b/src/bun.js/bindings/bindings.zig index b7f5deabfd062f..9d5574e61fb65b 100644 --- a/src/bun.js/bindings/bindings.zig +++ b/src/bun.js/bindings/bindings.zig @@ -1145,6 +1145,7 @@ pub const DOMFormData = opaque { "createFromURLQuery", }; }; + pub const FetchHeaders = opaque { pub const shim = Shimmer("WebCore", "FetchHeaders", @This()); @@ -2884,10 +2885,7 @@ pub const JSGlobalObject = opaque { comptime field: []const u8, comptime typename: []const u8, ) JSC.JSValue { - return this.ERR_INVALID_ARG_TYPE( - comptime std.fmt.comptimePrint("Expected {s} to be a {s} for '{s}'.", .{ field, typename, name_ }), - .{}, - ).toJS(); + return this.ERR_INVALID_ARG_TYPE(comptime std.fmt.comptimePrint("Expected {s} to be a {s} for '{s}'.", .{ field, typename, name_ }), .{}).toJS(); } pub fn toJS(this: *JSC.JSGlobalObject, value: anytype, comptime lifetime: JSC.Lifetime) JSC.JSValue { @@ -2905,13 +2903,13 @@ pub const JSGlobalObject = opaque { pub fn throwInvalidArgumentTypeValue( this: *JSGlobalObject, - field: []const u8, + argname: []const u8, typename: []const u8, value: JSValue, ) JSValue { const ty_str = value.jsTypeString(this).toSlice(this, bun.default_allocator); defer ty_str.deinit(); - this.ERR_INVALID_ARG_TYPE("The \"{s}\" argument must be of type {s}. Received {}", .{ field, typename, bun.fmt.quote(ty_str.slice()) }).throw(); + this.ERR_INVALID_ARG_TYPE("The \"{s}\" argument must be of type {s}. Received {}", .{ argname, typename, bun.fmt.quote(ty_str.slice()) }).throw(); return .zero; } @@ -2921,12 +2919,7 @@ pub const JSGlobalObject = opaque { comptime expected: usize, got: usize, ) JSC.JSValue { - return JSC.toTypeError( - .ERR_MISSING_ARGS, - "Not enough arguments to '" ++ name_ ++ "'. Expected {d}, got {d}.", - .{ expected, got }, - this, - ); + return JSC.toTypeError(.ERR_MISSING_ARGS, "Not enough arguments to '" ++ name_ ++ "'. Expected {d}, got {d}.", .{ expected, got }, this); } pub fn throwNotEnoughArguments( @@ -3004,7 +2997,7 @@ pub const JSGlobalObject = opaque { return err; } - fn createTypeErrorInstance(this: *JSGlobalObject, comptime fmt: [:0]const u8, args: anytype) JSValue { + pub fn createTypeErrorInstance(this: *JSGlobalObject, comptime fmt: [:0]const u8, args: anytype) JSValue { if (comptime std.meta.fieldNames(@TypeOf(args)).len > 0) { var stack_fallback = std.heap.stackFallback(1024 * 4, this.allocator()); var buf = bun.MutableString.init2048(stack_fallback.get()) catch unreachable; @@ -3277,12 +3270,42 @@ pub const JSGlobalObject = opaque { if (bun.Environment.allow_assert) this.bunVM().assertOnJSThread(); } + // returns false if it throws + pub fn validateObject( + this: *JSGlobalObject, + comptime arg_name: [:0]const u8, + value: JSValue, + opts: struct { + allowArray: bool = false, + allowFunction: bool = false, + nullable: bool = false, + }, + ) bool { + if ((!opts.nullable and value.isNull()) or + (!opts.allowArray and value.isArray()) or + (!value.isObject() and (!opts.allowFunction or !value.isFunction()))) + { + this.throwValue(this.ERR_INVALID_ARG_TYPE_static( + ZigString.static(arg_name), + ZigString.static("object"), + value, + )); + return false; + } + return true; + } + extern fn Bun__ERR_INVALID_ARG_TYPE_static(*JSGlobalObject, *const ZigString, *const ZigString, JSValue) JSValue; /// Caller asserts 'arg_name' and 'etype' are utf-8 literals. pub fn ERR_INVALID_ARG_TYPE_static(this: *JSGlobalObject, arg_name: *const ZigString, etype: *const ZigString, atype: JSValue) JSValue { return Bun__ERR_INVALID_ARG_TYPE_static(this, arg_name, etype, atype); } + extern fn Bun__ERR_MISSING_ARGS_static(*JSGlobalObject, *const ZigString, ?*const ZigString, ?*const ZigString) JSValue; + pub fn ERR_MISSING_ARGS_static(this: *JSGlobalObject, arg1: *const ZigString, arg2: ?*const ZigString, arg3: ?*const ZigString) JSValue { + return Bun__ERR_MISSING_ARGS_static(this, arg1, arg2, arg3); + } + pub usingnamespace @import("ErrorCode").JSGlobalObjectExtensions; }; @@ -3367,6 +3390,7 @@ pub const JSMap = opaque { }; pub const JSValueReprInt = i64; + pub const JSValue = enum(JSValueReprInt) { zero = 0, undefined = 0xa, @@ -4615,6 +4639,12 @@ pub const JSValue = enum(JSValueReprInt) { pub inline fn isObject(this: JSValue) bool { return this.isCell() and this.jsType().isObject(); } + pub inline fn isArray(this: JSValue) bool { + return this.isCell() and this.jsType().isArray(); + } + pub inline fn isFunction(this: JSValue) bool { + return this.isCell() and this.jsType().isFunction(); + } pub fn isObjectEmpty(this: JSValue, globalObject: *JSGlobalObject) bool { const type_of_value = this.jsType(); // https://github.com/jestjs/jest/blob/main/packages/jest-get-type/src/index.ts#L26 @@ -6055,7 +6085,7 @@ pub const CallFrame = opaque { len: usize, pub inline fn init(comptime i: usize, ptr: [*]const JSC.JSValue) @This() { var args: [max]JSC.JSValue = std.mem.zeroes([max]JSC.JSValue); - args[0..comptime i].* = ptr[0..i].*; + args[0..i].* = ptr[0..i].*; return @This(){ .ptr = args, diff --git a/src/bun.js/event_loop.zig b/src/bun.js/event_loop.zig index dcd9fa3c8fe28f..c441bed170475f 100644 --- a/src/bun.js/event_loop.zig +++ b/src/bun.js/event_loop.zig @@ -402,6 +402,8 @@ const ProcessWaiterThreadTask = if (Environment.isPosix) bun.spawn.WaiterThread. const ProcessMiniEventLoopWaiterThreadTask = if (Environment.isPosix) bun.spawn.WaiterThread.ProcessMiniEventLoopQueue.ResultTask else opaque {}; const ShellAsyncSubprocessDone = bun.shell.Interpreter.Cmd.ShellAsyncSubprocessDone; const RuntimeTranspilerStore = JSC.RuntimeTranspilerStore; +const ServerAllConnectionsClosedTask = @import("./api/server.zig").ServerAllConnectionsClosedTask; + // Task.get(ReadFileTask) -> ?ReadFileTask pub const Task = TaggedPointerUnion(.{ FetchTasklet, @@ -481,6 +483,7 @@ pub const Task = TaggedPointerUnion(.{ ProcessWaiterThreadTask, RuntimeTranspilerStore, + ServerAllConnectionsClosedTask, }); const UnboundedQueue = @import("./unbounded_queue.zig").UnboundedQueue; pub const ConcurrentTask = struct { @@ -878,7 +881,7 @@ pub const EventLoop = struct { } } - pub fn tickQueueWithCount(this: *EventLoop, comptime queue_name: []const u8) u32 { + fn tickQueueWithCount(this: *EventLoop, virtual_machine: *VirtualMachine, comptime queue_name: []const u8) u32 { var global = this.global; const global_vm = global.vm(); var counter: usize = 0; @@ -1041,7 +1044,7 @@ pub const EventLoop = struct { any.run(global); }, @field(Task.Tag, typeBaseName(@typeName(PollPendingModulesTask))) => { - this.virtual_machine.modules.onPoll(); + virtual_machine.modules.onPoll(); }, @field(Task.Tag, typeBaseName(@typeName(GetAddrInfoRequestTask))) => { if (Environment.os == .windows) @panic("This should not be reachable on Windows"); @@ -1229,7 +1232,11 @@ pub const EventLoop = struct { }, @field(Task.Tag, typeBaseName(@typeName(TimerObject))) => { var any: *TimerObject = task.get(TimerObject).?; - any.runImmediateTask(this.virtual_machine); + any.runImmediateTask(virtual_machine); + }, + @field(Task.Tag, typeBaseName(@typeName(ServerAllConnectionsClosedTask))) => { + var any: *ServerAllConnectionsClosedTask = task.get(ServerAllConnectionsClosedTask).?; + any.runFromJSThread(virtual_machine); }, else => if (Environment.allow_assert) { @@ -1247,15 +1254,15 @@ pub const EventLoop = struct { return @as(u32, @truncate(counter)); } - pub fn tickWithCount(this: *EventLoop) u32 { - return this.tickQueueWithCount("tasks"); + fn tickWithCount(this: *EventLoop, virtual_machine: *VirtualMachine) u32 { + return this.tickQueueWithCount(virtual_machine, "tasks"); } - pub fn tickImmediateTasks(this: *EventLoop) void { - _ = this.tickQueueWithCount("immediate_tasks"); + pub fn tickImmediateTasks(this: *EventLoop, virtual_machine: *VirtualMachine) void { + _ = this.tickQueueWithCount(virtual_machine, "immediate_tasks"); } - pub fn tickConcurrent(this: *EventLoop) void { + fn tickConcurrent(this: *EventLoop) void { _ = this.tickConcurrentWithCount(); } @@ -1335,7 +1342,7 @@ pub const EventLoop = struct { var loop = this.usocketsLoop(); this.flushImmediateQueue(); - this.tickImmediateTasks(); + this.tickImmediateTasks(ctx); if (comptime Environment.isPosix) { // Some tasks need to keep the event loop alive for one more tick. @@ -1426,7 +1433,7 @@ pub const EventLoop = struct { var loop = this.usocketsLoop(); var ctx = this.virtual_machine; this.flushImmediateQueue(); - this.tickImmediateTasks(); + this.tickImmediateTasks(ctx); if (comptime Environment.isPosix) { const pending_unref = ctx.pending_unref_counter; @@ -1475,7 +1482,7 @@ pub const EventLoop = struct { const global_vm = ctx.jsc; while (true) { - while (this.tickWithCount() > 0) : (this.global.handleRejectedPromises()) { + while (this.tickWithCount(ctx) > 0) : (this.global.handleRejectedPromises()) { this.tickConcurrent(); } else { this.drainMicrotasksWithGlobal(global, global_vm); @@ -1485,7 +1492,7 @@ pub const EventLoop = struct { break; } - while (this.tickWithCount() > 0) { + while (this.tickWithCount(ctx) > 0) { this.tickConcurrent(); } diff --git a/src/bun.js/ipc.zig b/src/bun.js/ipc.zig index f1f9639a751aad..b11c1aae65edd8 100644 --- a/src/bun.js/ipc.zig +++ b/src/bun.js/ipc.zig @@ -12,6 +12,8 @@ const JSC = bun.JSC; const JSValue = JSC.JSValue; const JSGlobalObject = JSC.JSGlobalObject; +const node_cluster_binding = @import("./node/node_cluster_binding.zig"); + pub const log = Output.scoped(.IPC, false); /// Mode of Inter-Process Communication. @@ -36,6 +38,7 @@ pub const Mode = enum { pub const DecodedIPCMessage = union(enum) { version: u32, data: JSValue, + internal: JSValue, }; pub const DecodeIPCMessageResult = struct { @@ -64,6 +67,7 @@ const advanced = struct { pub const IPCMessageType = enum(u8) { Version = 1, SerializedMessage = 2, + SerializedInternalMessage = 3, _, }; @@ -112,7 +116,25 @@ const advanced = struct { .message = .{ .data = deserialized }, }; }, - else => { + .SerializedInternalMessage => { + if (data.len < (header_length + message_len)) { + log("Not enough bytes to decode IPC message body of len {d}, have {d} bytes", .{ message_len, data.len }); + return IPCDecodeError.NotEnoughBytes; + } + + const message = data[header_length .. header_length + message_len]; + const deserialized = JSValue.deserialize(message, global); + + if (deserialized == .zero) { + return IPCDecodeError.InvalidFormat; + } + + return .{ + .bytes_consumed = header_length + message_len, + .message = .{ .internal = deserialized }, + }; + }, + _ => { return IPCDecodeError.InvalidFormat; }, } @@ -139,6 +161,24 @@ const advanced = struct { return payload_length; } + + pub fn serializeInternal(_: *IPCData, writer: anytype, global: *JSC.JSGlobalObject, value: JSValue) !usize { + const serialized = value.serialize(global) orelse + return IPCSerializationError.SerializationFailed; + defer serialized.deinit(); + + const size: u32 = @intCast(serialized.data.len); + + const payload_length: usize = @sizeOf(IPCMessageType) + @sizeOf(u32) + size; + + try writer.ensureUnusedCapacity(payload_length); + + writer.writeTypeAsBytesAssumeCapacity(IPCMessageType, .SerializedInternalMessage); + writer.writeTypeAsBytesAssumeCapacity(u32, size); + writer.writeAssumeCapacity(serialized.data); + + return payload_length; + } }; const json = struct { @@ -150,12 +190,28 @@ const json = struct { return &.{}; } + // In order to not have to do a property lookup json messages sent from Bun will have a single u8 prepended to them + // to be able to distinguish whether it is a regular json message or an internal one for cluster ipc communication. + // 1 is regular + // 2 is internal + pub fn decodeIPCMessage( data: []const u8, globalThis: *JSC.JSGlobalObject, ) IPCDecodeError!DecodeIPCMessageResult { if (bun.strings.indexOfChar(data, '\n')) |idx| { - const json_data = data[0..idx]; + var kind = data[0]; + var json_data = data[1..idx]; + + switch (kind) { + 1, 2 => {}, + else => { + // if the message being recieved is from a node process then it wont have the leading marker byte + // assume full message will be json + kind = 1; + json_data = data[0..idx]; + }, + } const is_ascii = bun.strings.isAllASCII(json_data); var was_ascii_string_freed = false; @@ -176,9 +232,16 @@ const json = struct { const deserialized = str.toJSByParseJSON(globalThis); - return .{ - .bytes_consumed = idx + 1, - .message = .{ .data = deserialized }, + return switch (kind) { + 1 => .{ + .bytes_consumed = idx + 1, + .message = .{ .data = deserialized }, + }, + 2 => .{ + .bytes_consumed = idx + 1, + .message = .{ .internal = deserialized }, + }, + else => @panic("invalid ipc json message kind this is a bug in Bun."), }; } return IPCDecodeError.NotEnoughBytes; @@ -197,12 +260,35 @@ const json = struct { const slice = str.slice(); - try writer.ensureUnusedCapacity(slice.len + 1); + try writer.ensureUnusedCapacity(1 + slice.len + 1); + + writer.writeAssumeCapacity(&.{1}); + writer.writeAssumeCapacity(slice); + writer.writeAssumeCapacity("\n"); + + return 1 + slice.len + 1; + } + + pub fn serializeInternal(_: *IPCData, writer: anytype, global: *JSC.JSGlobalObject, value: JSValue) !usize { + var out: bun.String = undefined; + value.jsonStringify(global, 0, &out); + defer out.deref(); + + if (out.tag == .Dead) return IPCSerializationError.SerializationFailed; + + // TODO: it would be cool to have a 'toUTF8Into' which can write directly into 'ipc_data.outgoing.list' + const str = out.toUTF8(bun.default_allocator); + defer str.deinit(); + + const slice = str.slice(); + + try writer.ensureUnusedCapacity(1 + slice.len + 1); + writer.writeAssumeCapacity(&.{2}); writer.writeAssumeCapacity(slice); writer.writeAssumeCapacity("\n"); - return slice.len + 1; + return 1 + slice.len + 1; } }; @@ -228,6 +314,14 @@ pub fn serialize(data: *IPCData, writer: anytype, global: *JSC.JSGlobalObject, v }; } +/// Given a writer interface, serialize and write a value. +/// Returns true if the value was written, false if it was not. +pub fn serializeInternal(data: *IPCData, writer: anytype, global: *JSC.JSGlobalObject, value: JSValue) !usize { + return switch (data.mode) { + inline else => |t| @field(@This(), @tagName(t)).serializeInternal(data, writer, global, value), + }; +} + pub const Socket = uws.NewSocketHandler(false); /// Used on POSIX @@ -237,8 +331,8 @@ const SocketIPCData = struct { incoming: bun.ByteList = .{}, // Maybe we should use StreamBuffer here as well outgoing: bun.io.StreamBuffer = .{}, - has_written_version: if (Environment.allow_assert) u1 else u0 = 0, + internal_msg_queue: node_cluster_binding.InternalMsgHolder = .{}, pub fn writeVersionPacket(this: *SocketIPCData) void { if (Environment.allow_assert) { @@ -264,8 +358,7 @@ const SocketIPCData = struct { // TODO: probably we should not direct access ipc_data.outgoing.list.items here const start_offset = ipc_data.outgoing.list.items.len; - const payload_length = serialize(ipc_data, &ipc_data.outgoing, global, value) catch - return false; + const payload_length = serialize(ipc_data, &ipc_data.outgoing, global, value) catch return false; bun.assert(ipc_data.outgoing.list.items.len == start_offset + payload_length); @@ -281,6 +374,35 @@ const SocketIPCData = struct { return true; } + + pub fn serializeAndSendInternal(ipc_data: *SocketIPCData, global: *JSGlobalObject, value: JSValue) bool { + if (Environment.allow_assert) { + bun.assert(ipc_data.has_written_version == 1); + } + + // TODO: probably we should not direct access ipc_data.outgoing.list.items here + const start_offset = ipc_data.outgoing.list.items.len; + + const payload_length = serializeInternal(ipc_data, &ipc_data.outgoing, global, value) catch return false; + + bun.assert(ipc_data.outgoing.list.items.len == start_offset + payload_length); + + if (start_offset == 0) { + bun.assert(ipc_data.outgoing.cursor == 0); + const n = ipc_data.socket.write(ipc_data.outgoing.list.items.ptr[start_offset..payload_length], false); + if (n == payload_length) { + ipc_data.outgoing.reset(); + } else if (n > 0) { + ipc_data.outgoing.cursor = @intCast(n); + } + } + + return true; + } + + pub fn close(this: *SocketIPCData) void { + this.socket.close(.normal); + } }; /// Used on Windows @@ -294,11 +416,13 @@ const NamedPipeIPCData = struct { incoming: bun.ByteList = .{}, // Maybe we should use IPCBuffer here as well connected: bool = false, + disconnected: bool = false, + has_sended_first_message: bool = false, connect_req: uv.uv_connect_t = std.mem.zeroes(uv.uv_connect_t), server: ?*uv.Pipe = null, onClose: ?CloseHandler = null, - has_written_version: if (Environment.allow_assert) u1 else u0 = 0, + internal_msg_queue: node_cluster_binding.InternalMsgHolder = .{}, const CloseHandler = struct { callback: *const fn (*anyopaque) void, @@ -314,16 +438,17 @@ const NamedPipeIPCData = struct { } fn onClientClose(this: *NamedPipeIPCData) void { - log("onClisentClose", .{}); + log("onClientClose", .{}); this.connected = false; if (this.server) |server| { // we must close the server too server.close(onServerClose); } else { if (this.onClose) |handler| { + this.onClose = null; + handler.callback(handler.context); // deinit dont free the instance of IPCData we should call it before the onClose callback actually frees it this.deinit(); - handler.callback(handler.context); } } } @@ -338,9 +463,10 @@ const NamedPipeIPCData = struct { return; } if (this.onClose) |handler| { + this.onClose = null; + handler.callback(handler.context); // deinit dont free the instance of IPCData we should call it before the onClose callback actually frees it this.deinit(); - handler.callback(handler.context); } } @@ -366,11 +492,37 @@ const NamedPipeIPCData = struct { if (Environment.allow_assert) { bun.assert(this.has_written_version == 1); } + if (this.disconnected) { + return false; + } const start_offset = this.writer.outgoing.list.items.len; - const payload_length: usize = serialize(this, &this.writer.outgoing, global, value) catch + const payload_length: usize = serialize(this, &this.writer.outgoing, global, value) catch return false; + + bun.assert(this.writer.outgoing.list.items.len == start_offset + payload_length); + + if (start_offset == 0) { + bun.assert(this.writer.outgoing.cursor == 0); + if (this.connected) { + _ = this.writer.flush(); + } + } + + return true; + } + + pub fn serializeAndSendInternal(this: *NamedPipeIPCData, global: *JSGlobalObject, value: JSValue) bool { + if (Environment.allow_assert) { + bun.assert(this.has_written_version == 1); + } + if (this.disconnected) { return false; + } + + const start_offset = this.writer.outgoing.list.items.len; + + const payload_length: usize = serializeInternal(this, &this.writer.outgoing, global, value) catch return false; bun.assert(this.writer.outgoing.list.items.len == start_offset + payload_length); @@ -385,10 +537,20 @@ const NamedPipeIPCData = struct { } pub fn close(this: *NamedPipeIPCData) void { + log("NamedPipeIPCData#close", .{}); if (this.server) |server| { server.close(onServerClose); } else { + this.disconnected = true; + JSC.VirtualMachine.get().enqueueTask(JSC.ManagedTask.New(NamedPipeIPCData, closeTask).init(this)); + } + } + + pub fn closeTask(this: *NamedPipeIPCData) void { + log("NamedPipeIPCData#closeTask", .{}); + if (this.disconnected) { this.writer.close(); + this.onClientClose(); } } @@ -493,7 +655,7 @@ fn NewSocketIPCHandler(comptime Context: type) type { _: ?*anyopaque, ) void { // Note: uSockets has already freed the underlying socket, so calling Socket.close() can segfault - log("onClose\n", .{}); + log("NewSocketIPCHandler#onClose\n", .{}); this.handleIPCClose(); } @@ -627,7 +789,6 @@ fn NewSocketIPCHandler(comptime Context: type) type { /// Used on Windows fn NewNamedPipeIPCHandler(comptime Context: type) type { - const uv = bun.windows.libuv; return struct { fn onReadAlloc(this: *Context, suggested_size: usize) []u8 { const ipc = this.ipc(); @@ -636,19 +797,26 @@ fn NewNamedPipeIPCHandler(comptime Context: type) type { ipc.incoming.ensureUnusedCapacity(bun.default_allocator, suggested_size) catch bun.outOfMemory(); available = ipc.incoming.available(); } - log("onReadAlloc {d}", .{suggested_size}); + log("NewNamedPipeIPCHandler#onReadAlloc {d}", .{suggested_size}); return available.ptr[0..suggested_size]; } fn onReadError(this: *Context, err: bun.C.E) void { - log("onReadError {}", .{err}); + log("NewNamedPipeIPCHandler#onReadError {}", .{err}); this.ipc().close(); + onClose(this); } fn onRead(this: *Context, buffer: []const u8) void { const ipc = this.ipc(); + if (!ipc.has_sended_first_message) { + // the server will wait to send the first flush (aka the version) after receiving the first message (which is the client version) + // this works like a handshake to ensure that both ends are listening to the messages + _ = ipc.writer.flush(); + ipc.has_sended_first_message = true; + } - log("onRead {d}", .{buffer.len}); + log("NewNamedPipeIPCHandler#onRead {d}", .{buffer.len}); ipc.incoming.len += @as(u32, @truncate(buffer.len)); var slice = ipc.incoming.slice(); @@ -694,48 +862,8 @@ fn NewNamedPipeIPCHandler(comptime Context: type) type { } } - pub fn onNewClientConnect(this: *Context, status: uv.ReturnCode) void { - const ipc = this.ipc(); - log("onNewClientConnect {d}", .{status.int()}); - if (status.errEnum()) |_| { - Output.printErrorln("Failed to connect IPC pipe", .{}); - return; - } - const server = ipc.server orelse { - Output.printErrorln("Failed to connect IPC pipe", .{}); - return; - }; - var client = bun.default_allocator.create(uv.Pipe) catch bun.outOfMemory(); - client.init(uv.Loop.get(), true).unwrap() catch { - bun.default_allocator.destroy(client); - Output.printErrorln("Failed to connect IPC pipe", .{}); - return; - }; - - ipc.writer.startWithPipe(client).unwrap() catch { - bun.default_allocator.destroy(client); - Output.printErrorln("Failed to start IPC pipe", .{}); - return; - }; - - switch (server.accept(client)) { - .err => { - ipc.close(); - return; - }, - .result => { - ipc.connected = true; - client.readStart(this, onReadAlloc, onReadError, onRead).unwrap() catch { - ipc.close(); - Output.printErrorln("Failed to connect IPC pipe", .{}); - return; - }; - _ = ipc.writer.flush(); - }, - } - } - pub fn onClose(this: *Context) void { + log("NewNamedPipeIPCHandler#onClose\n", .{}); this.handleIPCClose(); } }; diff --git a/src/bun.js/javascript.zig b/src/bun.js/javascript.zig index 0449756c3674f8..24749cbd35ec32 100644 --- a/src/bun.js/javascript.zig +++ b/src/bun.js/javascript.zig @@ -426,39 +426,88 @@ pub export fn Bun__GlobalObject__hasIPC(global: *JSC.JSGlobalObject) bool { return global.bunVM().ipc != null; } +extern fn Bun__Process__queueNextTick1(*JSC.ZigGlobalObject, JSC.JSValue, JSC.JSValue) void; + pub export fn Bun__Process__send( globalObject: *JSGlobalObject, callFrame: *JSC.CallFrame, ) callconv(JSC.conv) JSValue { JSC.markBinding(@src()); - if (callFrame.argumentsCount() < 1) { - globalObject.throwInvalidArguments("process.send requires at least one argument", .{}); - return .zero; - } + var message, var handle, var options_, var callback = callFrame.arguments(4).ptr; + + if (message == .zero) message = .undefined; + if (handle == .zero) handle = .undefined; + if (options_ == .zero) options_ = .undefined; + if (callback == .zero) callback = .undefined; + + if (handle.isFunction()) { + callback = handle; + handle = .undefined; + options_ = .undefined; + } else if (options_.isFunction()) { + callback = options_; + options_ = .undefined; + } else if (!options_.isUndefined()) { + if (!globalObject.validateObject("options", options_, .{})) return .zero; + } + + const S = struct { + fn impl(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(JSC.conv) JSC.JSValue { + const arguments_ = callframe.arguments(1).slice(); + const ex = arguments_[0]; + VirtualMachine.Process__emitErrorEvent(globalThis, ex); + return .undefined; + } + }; + const vm = globalObject.bunVM(); - if (vm.getIPCInstance()) |ipc_instance| { - const success = ipc_instance.data.serializeAndSend(globalObject, callFrame.argument(0)); - return if (success) .undefined else .zero; + const zigGlobal: *JSC.ZigGlobalObject = @ptrCast(globalObject); + const ipc_instance = vm.getIPCInstance() orelse { + const ex = globalObject.ERR_IPC_CHANNEL_CLOSED("Channel closed.", .{}).toJS(); + if (callback.isFunction()) { + Bun__Process__queueNextTick1(zigGlobal, callback, ex); + } else { + const fnvalue = JSC.JSFunction.create(globalObject, "", S.impl, 1, .{}); + Bun__Process__queueNextTick1(zigGlobal, fnvalue, ex); + } + return .false; + }; + + if (message.isUndefined()) { + return globalObject.throwValueRet(globalObject.ERR_MISSING_ARGS_static(ZigString.static("message"), null, null)); + } + if (!message.isString() and !message.isObject() and !message.isNumber() and !message.isBoolean()) { + return globalObject.throwValueRet(globalObject.ERR_INVALID_ARG_TYPE_static( + ZigString.static("message"), + ZigString.static("string, object, number, or boolean"), + message, + )); + } + + const good = ipc_instance.data.serializeAndSend(globalObject, message); + + if (good) { + if (callback.isFunction()) { + Bun__Process__queueNextTick1(zigGlobal, callback, .zero); + } } else { - globalObject.throw("IPC Socket is no longer open.", .{}); - return .zero; + const ex = globalObject.createTypeErrorInstance("process.send() failed", .{}); + ex.put(globalObject, ZigString.static("syscall"), ZigString.static("write").toJS(globalObject)); + if (callback.isFunction()) { + Bun__Process__queueNextTick1(zigGlobal, callback, ex); + } else { + const fnvalue = JSC.JSFunction.create(globalObject, "", S.impl, 1, .{}); + Bun__Process__queueNextTick1(zigGlobal, fnvalue, ex); + } } + + return .true; } pub export fn Bun__isBunMain(globalObject: *JSGlobalObject, str: *const bun.String) bool { return str.eqlUTF8(globalObject.bunVM().main); } -pub export fn Bun__Process__disconnect( - globalObject: *JSGlobalObject, - callFrame: *JSC.CallFrame, -) callconv(JSC.conv) JSValue { - JSC.markBinding(@src()); - _ = callFrame; - _ = globalObject; - return .undefined; -} - /// When IPC environment variables are passed, the socket is not immediately opened, /// but rather we wait for process.on('message') or process.send() to be called, THEN /// we open the socket. This is to avoid missing messages at the start of the program. @@ -3726,6 +3775,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 const IPCInstanceUnion = union(enum) { /// IPC is put in this "enabled but not started" state when IPC is detected @@ -3741,15 +3791,21 @@ pub const VirtualMachine = struct { globalThis: ?*JSGlobalObject, context: if (Environment.isPosix) *uws.SocketContext else void, data: IPC.IPCData, + has_disconnect_called: bool = false, pub usingnamespace bun.New(@This()); + const node_cluster_binding = @import("./node/node_cluster_binding.zig"); + pub fn ipc(this: *IPCInstance) *IPC.IPCData { return &this.data; } pub fn handleIPCMessage(this: *IPCInstance, message: IPC.DecodedIPCMessage) void { JSC.markBinding(@src()); + const globalThis = this.globalThis orelse return; + const event_loop = JSC.VirtualMachine.get().eventLoop(); + switch (message) { // In future versions we can read this in order to detect version mismatches, // or disable future optimizations if the subprocess is old. @@ -3758,25 +3814,45 @@ pub const VirtualMachine = struct { }, .data => |data| { IPC.log("Received IPC message from parent", .{}); - if (this.globalThis) |global| { - Process__emitMessageEvent(global, data); - } + event_loop.enter(); + defer event_loop.exit(); + Process__emitMessageEvent(globalThis, data); + }, + .internal => |data| { + IPC.log("Received IPC internal message from parent", .{}); + event_loop.enter(); + defer event_loop.exit(); + node_cluster_binding.handleInternalMessageChild(globalThis, data); }, } } pub fn handleIPCClose(this: *IPCInstance) void { - if (this.globalThis) |global| { - var vm = global.bunVM(); - vm.ipc = null; - Process__emitDisconnectEvent(global); - } + IPC.log("IPCInstance#handleIPCClose", .{}); + var vm = VirtualMachine.get(); + vm.ipc = null; + const event_loop = vm.eventLoop(); + node_cluster_binding.child_singleton.deinit(); + event_loop.enter(); + Process__emitDisconnectEvent(vm.global); + event_loop.exit(); if (Environment.isPosix) { uws.us_socket_context_free(0, this.context); } this.destroy(); } + extern fn Bun__setChannelRef(*JSC.JSGlobalObject, bool) void; + + export fn Bun__closeChildIPC(global: *JSGlobalObject) void { + const ipc_data = &global.bunVM().ipc.?.initialized.data; + JSC.VirtualMachine.get().enqueueImmediateTask(JSC.ManagedTask.New(IPC.IPCData, closeReal).init(ipc_data)); + } + + fn closeReal(ipc_data: *IPC.IPCData) void { + ipc_data.close(); + } + pub const Handlers = IPC.NewIPCHandler(IPCInstance); }; diff --git a/src/bun.js/node/node_cluster_binding.zig b/src/bun.js/node/node_cluster_binding.zig new file mode 100644 index 00000000000000..b892aaab96e8ea --- /dev/null +++ b/src/bun.js/node/node_cluster_binding.zig @@ -0,0 +1,280 @@ +const std = @import("std"); +const bun = @import("root").bun; +const Environment = bun.Environment; +const JSC = bun.JSC; +const string = bun.string; +const Output = bun.Output; +const ZigString = JSC.ZigString; +const log = Output.scoped(.IPC, false); + +extern fn Bun__Process__queueNextTick1(*JSC.JSGlobalObject, JSC.JSValue, JSC.JSValue) void; +extern fn Process__emitErrorEvent(global: *JSC.JSGlobalObject, value: JSC.JSValue) void; + +pub var child_singleton: InternalMsgHolder = .{}; + +pub fn sendHelperChild(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(JSC.conv) JSC.JSValue { + log("sendHelperChild", .{}); + + const arguments = callframe.arguments(3).ptr; + const message = arguments[0]; + const handle = arguments[1]; + const callback = arguments[2]; + + const vm = globalThis.bunVM(); + + if (vm.ipc == null) { + return .false; + } + if (message.isUndefined()) { + return globalThis.throwValueRet(globalThis.ERR_MISSING_ARGS_static(ZigString.static("message"), null, null)); + } + if (!handle.isNull()) { + globalThis.throw("passing 'handle' not implemented yet", .{}); + return .zero; + } + if (!message.isObject()) { + return globalThis.throwValueRet(globalThis.ERR_INVALID_ARG_TYPE_static( + ZigString.static("message"), + ZigString.static("object"), + message, + )); + } + if (callback.isFunction()) { + child_singleton.callbacks.put(bun.default_allocator, child_singleton.seq, JSC.Strong.create(callback, globalThis)) catch bun.outOfMemory(); + } + + // sequence number for InternalMsgHolder + message.put(globalThis, ZigString.static("seq"), JSC.JSValue.jsNumber(child_singleton.seq)); + child_singleton.seq +%= 1; + + // similar code as Bun__Process__send + var formatter = JSC.ConsoleObject.Formatter{ .globalThis = globalThis }; + if (Environment.isDebug) log("child: {}", .{message.toFmt(&formatter)}); + + const ipc_instance = vm.getIPCInstance().?; + + const S = struct { + fn impl(globalThis_: *JSC.JSGlobalObject, callframe_: *JSC.CallFrame) callconv(JSC.conv) JSC.JSValue { + const arguments_ = callframe_.arguments(1).slice(); + const ex = arguments_[0]; + Process__emitErrorEvent(globalThis_, ex); + return .undefined; + } + }; + + const good = ipc_instance.data.serializeAndSendInternal(globalThis, message); + + if (!good) { + const ex = globalThis.createTypeErrorInstance("sendInternal() failed", .{}); + ex.put(globalThis, ZigString.static("syscall"), ZigString.static("write").toJS(globalThis)); + const fnvalue = JSC.JSFunction.create(globalThis, "", S.impl, 1, .{}); + Bun__Process__queueNextTick1(globalThis, fnvalue, ex); + return .false; + } + + return .true; +} + +pub fn onInternalMessageChild(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(JSC.conv) JSC.JSValue { + log("onInternalMessageChild", .{}); + const arguments = callframe.arguments(2).ptr; + child_singleton.worker = JSC.Strong.create(arguments[0], globalThis); + child_singleton.cb = JSC.Strong.create(arguments[1], globalThis); + child_singleton.flush(globalThis); + return .undefined; +} + +pub fn handleInternalMessageChild(globalThis: *JSC.JSGlobalObject, message: JSC.JSValue) void { + log("handleInternalMessageChild", .{}); + + child_singleton.dispatch(message, globalThis); +} + +// +// +// + +/// Queue for messages sent between parent and child processes in an IPC environment. node:cluster sends json serialized messages +/// to describe different events it performs. It will send a message with an incrementing sequence number and then call a callback +/// when a message is recieved with an 'ack' property of the same sequence number. +pub const InternalMsgHolder = struct { + seq: i32 = 0, + callbacks: std.AutoArrayHashMapUnmanaged(i32, JSC.Strong) = .{}, + + worker: JSC.Strong = .{}, + cb: JSC.Strong = .{}, + messages: std.ArrayListUnmanaged(JSC.Strong) = .{}, + + pub fn isReady(this: *InternalMsgHolder) bool { + return this.worker.has() and this.cb.has(); + } + + pub fn enqueue(this: *InternalMsgHolder, message: JSC.JSValue, globalThis: *JSC.JSGlobalObject) void { + //TODO: .addOne is workaround for .append causing crash/ dependency loop in zig compiler + const new_item_ptr = this.messages.addOne(bun.default_allocator) catch bun.outOfMemory(); + new_item_ptr.* = JSC.Strong.create(message, globalThis); + } + + pub fn dispatch(this: *InternalMsgHolder, message: JSC.JSValue, globalThis: *JSC.JSGlobalObject) void { + if (!this.isReady()) { + this.enqueue(message, globalThis); + return; + } + this.dispatchUnsafe(message, globalThis); + } + + fn dispatchUnsafe(this: *InternalMsgHolder, message: JSC.JSValue, globalThis: *JSC.JSGlobalObject) void { + const cb = this.cb.get().?; + const worker = this.worker.get().?; + + const event_loop = globalThis.bunVM().eventLoop(); + + if (message.get(globalThis, "ack")) |p| { + if (!p.isUndefined()) { + const ack = p.toInt32(); + if (this.callbacks.getEntry(ack)) |entry| { + var cbstrong = entry.value_ptr.*; + if (cbstrong.get()) |callback| { + defer cbstrong.deinit(); + _ = this.callbacks.swapRemove(ack); + event_loop.runCallback(callback, globalThis, this.worker.get().?, &.{ + message, + .null, // handle + }); + return; + } + return; + } + } + } + event_loop.runCallback(cb, globalThis, worker, &.{ + message, + .null, // handle + }); + } + + pub fn flush(this: *InternalMsgHolder, globalThis: *JSC.JSGlobalObject) void { + bun.assert(this.isReady()); + var messages = this.messages; + this.messages = .{}; + for (messages.items) |*strong| { + if (strong.get()) |message| { + this.dispatchUnsafe(message, globalThis); + } + strong.deinit(); + } + messages.deinit(bun.default_allocator); + } + + pub fn deinit(this: *InternalMsgHolder) void { + for (this.callbacks.values()) |*strong| strong.deinit(); + this.callbacks.deinit(bun.default_allocator); + this.worker.deinit(); + this.cb.deinit(); + for (this.messages.items) |*strong| strong.deinit(); + this.messages.deinit(bun.default_allocator); + } +}; + +pub fn sendHelperPrimary(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(JSC.conv) JSC.JSValue { + log("sendHelperPrimary", .{}); + + const arguments = callframe.arguments(4).ptr; + const subprocess = arguments[0].as(bun.JSC.Subprocess).?; + const message = arguments[1]; + const handle = arguments[2]; + const callback = arguments[3]; + + const ipc_data = subprocess.ipc_maybe() orelse return .false; + + if (message.isUndefined()) { + return globalThis.throwValueRet(globalThis.ERR_MISSING_ARGS_static(ZigString.static("message"), null, null)); + } + if (!message.isObject()) { + return globalThis.throwValueRet(globalThis.ERR_INVALID_ARG_TYPE_static( + ZigString.static("message"), + ZigString.static("object"), + message, + )); + } + if (callback.isFunction()) { + ipc_data.internal_msg_queue.callbacks.put(bun.default_allocator, ipc_data.internal_msg_queue.seq, JSC.Strong.create(callback, globalThis)) catch bun.outOfMemory(); + } + + // sequence number for InternalMsgHolder + message.put(globalThis, ZigString.static("seq"), JSC.JSValue.jsNumber(ipc_data.internal_msg_queue.seq)); + ipc_data.internal_msg_queue.seq +%= 1; + + // similar code as bun.JSC.Subprocess.doSend + var formatter = JSC.ConsoleObject.Formatter{ .globalThis = globalThis }; + if (Environment.isDebug) log("primary: {}", .{message.toFmt(&formatter)}); + + _ = handle; + const success = ipc_data.serializeAndSendInternal(globalThis, message); + if (!success) return .false; + + return .true; +} + +pub fn onInternalMessagePrimary(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(JSC.conv) JSC.JSValue { + const arguments = callframe.arguments(3).ptr; + const subprocess = arguments[0].as(bun.JSC.Subprocess).?; + const ipc_data = subprocess.ipc(); + ipc_data.internal_msg_queue.worker = JSC.Strong.create(arguments[1], globalThis); + ipc_data.internal_msg_queue.cb = JSC.Strong.create(arguments[2], globalThis); + return .undefined; +} + +pub fn handleInternalMessagePrimary(globalThis: *JSC.JSGlobalObject, subprocess: *JSC.Subprocess, message: JSC.JSValue) void { + const ipc_data = subprocess.ipc(); + + const event_loop = globalThis.bunVM().eventLoop(); + + if (message.get(globalThis, "ack")) |p| { + if (!p.isUndefined()) { + const ack = p.toInt32(); + if (ipc_data.internal_msg_queue.callbacks.getEntry(ack)) |entry| { + var cbstrong = entry.value_ptr.*; + defer cbstrong.clear(); + _ = ipc_data.internal_msg_queue.callbacks.swapRemove(ack); + const cb = cbstrong.get().?; + event_loop.runCallback(cb, globalThis, ipc_data.internal_msg_queue.worker.get().?, &.{ + message, + .null, // handle + }); + return; + } + } + } + const cb = ipc_data.internal_msg_queue.cb.get().?; + event_loop.runCallback(cb, globalThis, ipc_data.internal_msg_queue.worker.get().?, &.{ + message, + .null, // handle + }); + return; +} + +// +// +// + +extern fn Bun__setChannelRef(*JSC.JSGlobalObject, bool) void; + +pub fn setRef(globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) JSC.JSValue { + const arguments = callframe.arguments(1).ptr; + + if (arguments.len == 0) { + return globalObject.throwValueRet(globalObject.ERR_MISSING_ARGS_1(ZigString.static("enabled").toJS(globalObject))); + } + if (!arguments[0].isBoolean()) { + return globalObject.throwValueRet(globalObject.ERR_INVALID_ARG_TYPE_static( + ZigString.static("enabled"), + ZigString.static("boolean"), + arguments[0], + )); + } + + const enabled = arguments[0].toBoolean(); + Bun__setChannelRef(globalObject, enabled); + return .undefined; +} diff --git a/src/bun.js/node/node_error_binding.zig b/src/bun.js/node/node_error_binding.zig new file mode 100644 index 00000000000000..8c6de402a1471e --- /dev/null +++ b/src/bun.js/node/node_error_binding.zig @@ -0,0 +1,28 @@ +const std = @import("std"); +const bun = @import("root").bun; +const Environment = bun.Environment; +const JSC = bun.JSC; +const string = bun.string; +const Output = bun.Output; +const ZigString = JSC.ZigString; +const createTypeError = JSC.JSGlobalObject.createTypeErrorInstanceWithCode; +const createError = JSC.JSGlobalObject.createErrorInstanceWithCode; +const createRangeError = JSC.JSGlobalObject.createRangeErrorInstanceWithCode; + +pub const ERR_INVALID_HANDLE_TYPE = createSimpleError(createTypeError, .ERR_INVALID_HANDLE_TYPE, "This handle type cannot be sent"); +pub const ERR_CHILD_CLOSED_BEFORE_REPLY = createSimpleError(createError, .ERR_CHILD_CLOSED_BEFORE_REPLY, "Child closed before reply received"); + +fn createSimpleError(comptime createFn: anytype, comptime code: JSC.Node.ErrorCode, comptime message: string) JSC.JS2NativeFunctionType { + const R = struct { + pub fn cbb(global: *JSC.JSGlobalObject) callconv(JSC.conv) JSC.JSValue { + const S = struct { + fn cb(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(JSC.conv) JSC.JSValue { + _ = callframe; + return createFn(globalThis, code, message, .{}); + } + }; + return JSC.JSFunction.create(global, @tagName(code), S.cb, 0, .{}); + } + }; + return R.cbb; +} diff --git a/src/bun.js/node/node_http_binding.zig b/src/bun.js/node/node_http_binding.zig new file mode 100644 index 00000000000000..a4f98307560290 --- /dev/null +++ b/src/bun.js/node/node_http_binding.zig @@ -0,0 +1,39 @@ +const std = @import("std"); +const bun = @import("root").bun; +const Environment = bun.Environment; +const JSC = bun.JSC; +const string = bun.string; +const Output = bun.Output; +const ZigString = JSC.ZigString; +const uv = bun.windows.libuv; + +pub fn getBunServerAllClosedPromise(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(JSC.conv) JSC.JSValue { + const arguments = callframe.arguments(1).slice(); + if (arguments.len < 1) { + globalThis.throwNotEnoughArguments("getBunServerAllClosePromise", 1, arguments.len); + return .zero; + } + + const value = arguments[0]; + + inline for ([_]type{ + JSC.API.HTTPServer, + JSC.API.HTTPSServer, + JSC.API.DebugHTTPServer, + JSC.API.DebugHTTPSServer, + }) |Server| { + if (value.as(Server)) |server| { + if (server.listener == null and server.pending_requests == 0) { + return JSC.JSPromise.resolvedPromise(globalThis, .undefined).asValue(globalThis); + } + const prom = &server.all_closed_promise; + if (prom.strong.has()) { + return prom.value(); + } + prom.* = JSC.JSPromise.Strong.init(globalThis); + return prom.value(); + } + } + + return globalThis.throwInvalidArgumentTypeValue("server", "bun.Server", value); +} diff --git a/src/bun.js/webcore/response.zig b/src/bun.js/webcore/response.zig index 17be0284860ca8..f6fc924d11a99b 100644 --- a/src/bun.js/webcore/response.zig +++ b/src/bun.js/webcore/response.zig @@ -1492,7 +1492,7 @@ pub const Fetch = struct { return switch (this.body_size) { .content_length => @truncate(this.body_size.content_length), .total_received => @truncate(this.body_size.total_received), - else => 0, + .unknown => 0, }; } @@ -1795,7 +1795,7 @@ pub const Fetch = struct { task.http.?.* = async_http.*; task.http.?.response_buffer = async_http.response_buffer; - log("callback success {} has_more {} bytes {}", .{ result.isSuccess(), result.has_more, result.body.?.list.items.len }); + log("callback success={} has_more={} bytes={}", .{ result.isSuccess(), result.has_more, result.body.?.list.items.len }); const prev_metadata = task.result.metadata; const prev_cert_info = task.result.certificate_info; diff --git a/src/cli/test_command.zig b/src/cli/test_command.zig index e0acfbd6584aea..d072ce7df92005 100644 --- a/src/cli/test_command.zig +++ b/src/cli/test_command.zig @@ -1250,7 +1250,7 @@ pub const TestCommand = struct { if (!jest.Jest.runner.?.has_pending_tests) break; vm.eventLoop().tick(); } else { - vm.eventLoop().tickImmediateTasks(); + vm.eventLoop().tickImmediateTasks(vm); } while (prev_unhandled_count < vm.unhandled_error_counter) { diff --git a/src/io/PipeWriter.zig b/src/io/PipeWriter.zig index 33a7767505291b..b9162efad9bd47 100644 --- a/src/io/PipeWriter.zig +++ b/src/io/PipeWriter.zig @@ -1155,7 +1155,8 @@ pub fn WindowsStreamingWriter( fn onCloseSource(this: *WindowsWriter) void { this.source = null; - if (!this.closed_without_reporting) { + if (this.closed_without_reporting) { + this.closed_without_reporting = false; onClose(this.parent); } } @@ -1306,7 +1307,7 @@ pub fn WindowsStreamingWriter( // clean both buffers if needed this.outgoing.deinit(); this.current_payload.deinit(); - this.close(); + this.closeWithoutReporting(); } fn writeInternal(this: *WindowsWriter, buffer: anytype, comptime writeFn: anytype) WriteResult { @@ -1383,13 +1384,12 @@ pub fn WindowsStreamingWriter( return; } - this.is_done = true; this.closed_without_reporting = false; - // if we are done we can call close if not we wait all the data to be flushed - if (this.isDone()) { - if (!this.owns_fd) return; - this.close(); + if (!this.owns_fd) { + this.is_done = true; + return; } + this.close(); } }; } diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 93b8975de727d8..ec6cae506f8b21 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -403,3 +403,21 @@ export function windowsEnv(internalEnv: InternalEnvMap, envMapList: Array { + throw new Error("ERR_INTERNAL_ASSERTION"); +}; + +export default class RoundRobinHandle { + key; + all; + free; + handles; + handle; + server; + + constructor(key, address, { port, fd, flags, backlog, readableAll, writableAll }) { + this.key = key; + this.all = new Map(); + this.free = new Map(); + this.handles = init({ __proto__: null }); + this.handle = null; + this.server = net.createServer(assert_fail); + + if (fd >= 0) this.server.listen({ fd, backlog }); + else if (port >= 0) { + this.server.listen({ + port, + host: address, + // Currently, net module only supports `ipv6Only` option in `flags`. + ipv6Only: Boolean(flags & UV_TCP_IPV6ONLY), + backlog, + }); + } else + this.server.listen({ + path: address, + backlog, + readableAll, + writableAll, + }); // UNIX socket path. + this.server.once("listening", () => { + this.handle = this.server._handle; + this.handle.onconnection = (err, handle) => this.distribute(err, handle); + this.server._handle = null; + this.server = null; + }); + } + + add(worker, send) { + // $assert(this.all.has(worker.id) === false); + this.all.set(worker.id, worker); + + const done = () => { + if (this.handle.getsockname) { + const out = {}; + this.handle.getsockname(out); + // TODO(bnoordhuis) Check err. + send(null, { sockname: out }, null); + } else { + send(null, null, null); // UNIX socket. + } + + this.handoff(worker); // In case there are connections pending. + }; + + if (this.server === null) return done(); + + // Still busy binding. + this.server.once("listening", done); + this.server.once("error", err => { + send(err.errno, null); + }); + } + + remove(worker) { + const existed = this.all.delete(worker.id); + + if (!existed) return false; + + this.free.delete(worker.id); + + if (this.all.size !== 0) return false; + + while (!isEmpty(this.handles)) { + const handle = peek(this.handles); + handle.close(); + remove(handle); + } + + this.handle.close(); + this.handle = null; + return true; + } + + distribute(err, handle) { + // If `accept` fails just skip it (handle is undefined) + if (err) { + return; + } + append(this.handles, handle); + // eslint-disable-next-line node-core/no-array-destructuring + const [workerEntry] = this.free; // this.free is a SafeMap + + if (ArrayIsArray(workerEntry)) { + const { 0: workerId, 1: worker } = workerEntry; + this.free.delete(workerId); + this.handoff(worker); + } + } + + handoff(worker) { + if (!this.all.has(worker.id)) { + return; // Worker is closing (or has closed) the server. + } + + const handle = peek(this.handles); + + if (handle === null) { + this.free.set(worker.id, worker); // Add to ready queue again. + return; + } + + remove(handle); + + const message = { act: "newconn", key: this.key }; + + sendHelper(worker.process[kHandle], message, handle, reply => { + if (reply.accepted) handle.close(); + else this.distribute(0, handle); // Worker is shutting down. Send to another. + + this.handoff(worker); + }); + } +} diff --git a/src/js/internal/cluster/Worker.ts b/src/js/internal/cluster/Worker.ts new file mode 100644 index 00000000000000..b1940f151e0629 --- /dev/null +++ b/src/js/internal/cluster/Worker.ts @@ -0,0 +1,45 @@ +const EventEmitter = require("node:events"); + +const ObjectFreeze = Object.freeze; +const ObjectSetPrototypeOf = Object.setPrototypeOf; + +const kEmptyObject = ObjectFreeze({ __proto__: null }); + +function Worker(options) { + if (!(this instanceof Worker)) return new Worker(options); + + EventEmitter.$apply(this, []); + + if (options === null || typeof options !== "object") options = kEmptyObject; + + this.exitedAfterDisconnect = undefined; + + this.state = options.state || "none"; + this.id = options.id | 0; + + if (options.process) { + this.process = options.process; + this.process.on("error", (code, signal) => this.emit("error", code, signal)); + this.process.on("message", (message, handle) => this.emit("message", message, handle)); + } +} +Worker.prototype = {}; +ObjectSetPrototypeOf(Worker.prototype, EventEmitter.prototype); + +Worker.prototype.kill = function () { + this.destroy.$apply(this, arguments); +}; + +Worker.prototype.send = function () { + return this.process.send.$apply(this.process, arguments); +}; + +Worker.prototype.isDead = function () { + return this.process.exitCode != null || this.process.signalCode != null; +}; + +Worker.prototype.isConnected = function () { + return this.process.connected; +}; + +export default Worker; diff --git a/src/js/internal/cluster/child.ts b/src/js/internal/cluster/child.ts new file mode 100644 index 00000000000000..c1e6ebdb38b183 --- /dev/null +++ b/src/js/internal/cluster/child.ts @@ -0,0 +1,281 @@ +const EventEmitter = require("node:events"); +const Worker = require("internal/cluster/Worker"); +const path = require("node:path"); + +const sendHelper = $newZigFunction("node_cluster_binding.zig", "sendHelperChild", 3); +const onInternalMessage = $newZigFunction("node_cluster_binding.zig", "onInternalMessageChild", 2); + +const FunctionPrototype = Function.prototype; +const ArrayPrototypeJoin = Array.prototype.join; +const ObjectAssign = Object.assign; + +const cluster = new EventEmitter(); +const handles = new Map(); +const indexes = new Map(); +const noop = FunctionPrototype; +const TIMEOUT_MAX = 2 ** 31 - 1; +const kNoFailure = 0; +const owner_symbol = Symbol("owner_symbol"); + +export default cluster; + +cluster.isWorker = true; +cluster.isMaster = false; // Deprecated alias. Must be same as isPrimary. +cluster.isPrimary = false; +cluster.worker = null; +cluster.Worker = Worker; + +cluster._setupWorker = function () { + const worker = new Worker({ + id: +process.env.NODE_UNIQUE_ID | 0, + process: process, + state: "online", + }); + + cluster.worker = worker; + + process.once("disconnect", () => { + worker.emit("disconnect"); + + if (!worker.exitedAfterDisconnect) { + // Unexpected disconnect, primary exited, or some such nastiness, so + // worker exits immediately. + process.exit(kNoFailure); + } + }); + + onInternalMessage(worker, onmessage); + send({ act: "online" }); + + function onmessage(message, handle) { + if (message.act === "newconn") onconnection(message, handle); + else if (message.act === "disconnect") worker._disconnect(true); + } +}; + +// `obj` is a net#Server or a dgram#Socket object. +cluster._getServer = function (obj, options, cb) { + let address = options.address; + + // Resolve unix socket paths to absolute paths + if (options.port < 0 && typeof address === "string" && process.platform !== "win32") address = path.resolve(address); + + const indexesKey = ArrayPrototypeJoin.$call([address, options.port, options.addressType, options.fd], ":"); + + let indexSet = indexes.get(indexesKey); + + if (indexSet === undefined) { + indexSet = { nextIndex: 0, set: new Set() }; + indexes.set(indexesKey, indexSet); + } + const index = indexSet.nextIndex++; + indexSet.set.add(index); + + const message = { + act: "queryServer", + index, + data: null, + ...options, + }; + + message.address = address; + + // Set custom data on handle (i.e. tls tickets key) + if (obj._getServerData) message.data = obj._getServerData(); + + send(message, (reply, handle) => { + if (typeof obj._setServerData === "function") obj._setServerData(reply.data); + + if (handle) { + // Shared listen socket + shared(reply, { handle, indexesKey, index }, cb); + } else { + // Round-robin. + rr(reply, { indexesKey, index }, cb); + } + }); + + obj.once("listening", () => { + // short-lived sockets might have been closed + if (!indexes.has(indexesKey)) { + return; + } + cluster.worker.state = "listening"; + const address = obj.address(); + message.act = "listening"; + message.port = (address && address.port) || options.port; + send(message); + }); +}; + +function removeIndexesKey(indexesKey, index) { + const indexSet = indexes.get(indexesKey); + if (!indexSet) { + return; + } + + indexSet.set.delete(index); + if (indexSet.set.size === 0) { + indexes.delete(indexesKey); + } +} + +// Shared listen socket. +function shared(message, { handle, indexesKey, index }, cb) { + const key = message.key; + // Monkey-patch the close() method so we can keep track of when it's + // closed. Avoids resource leaks when the handle is short-lived. + const close = handle.close; + + handle.close = function () { + send({ act: "close", key }); + handles.delete(key); + removeIndexesKey(indexesKey, index); + return close.$apply(handle, arguments); + }; + $assert(handles.has(key) === false); + handles.set(key, handle); + cb(message.errno, handle); +} + +// Round-robin. Master distributes handles across workers. +function rr(message, { indexesKey, index }, cb) { + if (message.errno) return cb(message.errno, null); + + let key = message.key; + + let fakeHandle: number | null = null; + + function ref() { + if (!fakeHandle) { + fakeHandle = setInterval(noop, TIMEOUT_MAX); + } + } + + function unref() { + if (fakeHandle) { + clearInterval(fakeHandle); + fakeHandle = null; + } + } + + function listen(backlog) { + // TODO(bnoordhuis) Send a message to the primary that tells it to + // update the backlog size. The actual backlog should probably be + // the largest requested size by any worker. + return 0; + } + + function close() { + // lib/net.js treats server._handle.close() as effectively synchronous. + // That means there is a time window between the call to close() and + // the ack by the primary process in which we can still receive handles. + // onconnection() below handles that by sending those handles back to + // the primary. + if (key === undefined) return; + unref(); + // If the handle is the last handle in process, + // the parent process will delete the handle when worker process exits. + // So it is ok if the close message get lost. + // See the comments of https://github.com/nodejs/node/pull/46161 + send({ act: "close", key }); + handles.delete(key); + removeIndexesKey(indexesKey, index); + key = undefined; + } + + function getsockname(out) { + if (key) ObjectAssign(out, message.sockname); + + return 0; + } + + // Faux handle. net.Server is not associated with handle, + // so we control its state(ref or unref) by setInterval. + const handle = { close, listen, ref, unref }; + handle.ref(); + if (message.sockname) { + handle.getsockname = getsockname; // TCP handles only. + } + + $assert(handles.has(key) === false); + handles.set(key, handle); + cb(0, handle); +} + +// Round-robin connection. +function onconnection(message, handle) { + const key = message.key; + const server = handles.get(key); + let accepted = server !== undefined; + + if (accepted && server[owner_symbol]) { + const self = server[owner_symbol]; + if (self.maxConnections != null && self._connections >= self.maxConnections) { + accepted = false; + } + } + + send({ ack: message.seq, accepted }); + + if (accepted) server.onconnection(0, handle); + else handle.close(); +} + +function send(message, cb?) { + return sendHelper(message, null, cb); +} + +// Extend generic Worker with methods specific to worker processes. +Worker.prototype.disconnect = function () { + if (this.state !== "disconnecting" && this.state !== "destroying") { + this.state = "disconnecting"; + this._disconnect(); + } + + return this; +}; + +Worker.prototype._disconnect = function (this: typeof Worker, primaryInitiated?) { + this.exitedAfterDisconnect = true; + let waitingCount = 1; + + function checkWaitingCount() { + waitingCount--; + + if (waitingCount === 0) { + // If disconnect is worker initiated, wait for ack to be sure + // exitedAfterDisconnect is properly set in the primary, otherwise, if + // it's primary initiated there's no need to send the + // exitedAfterDisconnect message + if (primaryInitiated) { + process.disconnect(); + } else { + send({ act: "exitedAfterDisconnect" }, () => process.disconnect()); + } + } + } + + handles.forEach(handle => { + waitingCount++; + + if (handle[owner_symbol]) handle[owner_symbol].close(checkWaitingCount); + else handle.close(checkWaitingCount); + }); + + handles.clear(); + checkWaitingCount(); +}; + +Worker.prototype.destroy = function () { + if (this.state === "destroying") return; + + this.exitedAfterDisconnect = true; + if (!this.isConnected()) { + process.exit(kNoFailure); + } else { + this.state = "destroying"; + send({ act: "exitedAfterDisconnect" }, () => process.disconnect()); + process.once("disconnect", () => process.exit(kNoFailure)); + } +}; diff --git a/src/js/internal/cluster/isPrimary.ts b/src/js/internal/cluster/isPrimary.ts new file mode 100644 index 00000000000000..f7116833fb00c6 --- /dev/null +++ b/src/js/internal/cluster/isPrimary.ts @@ -0,0 +1,4 @@ +// tiny module to shortcut getting access to this boolean without loading the entire node:cluster module +export default { + isPrimary: Bun.env.NODE_UNIQUE_ID == null, +}; diff --git a/src/js/internal/cluster/primary.ts b/src/js/internal/cluster/primary.ts new file mode 100644 index 00000000000000..b7d241041a9a7d --- /dev/null +++ b/src/js/internal/cluster/primary.ts @@ -0,0 +1,318 @@ +const EventEmitter = require("node:events"); +const child_process = require("node:child_process"); +const Worker = require("internal/cluster/Worker"); +const RoundRobinHandle = require("internal/cluster/RoundRobinHandle"); +const path = require("node:path"); +const { throwNotImplemented, kHandle } = require("internal/shared"); + +const sendHelper = $newZigFunction("node_cluster_binding.zig", "sendHelperPrimary", 4); +const onInternalMessage = $newZigFunction("node_cluster_binding.zig", "onInternalMessagePrimary", 3); + +const ArrayPrototypeSlice = Array.prototype.slice; +const ObjectValues = Object.values; +const ObjectKeys = Object.keys; + +const cluster = new EventEmitter(); +const intercom = new EventEmitter(); +const SCHED_NONE = 1; +const SCHED_RR = 2; + +export default cluster; + +const handles = new Map(); +cluster.isWorker = false; +cluster.isMaster = true; // Deprecated alias. Must be same as isPrimary. +cluster.isPrimary = true; +cluster.Worker = Worker; +cluster.workers = {}; +cluster.settings = {}; +cluster.SCHED_NONE = SCHED_NONE; // Leave it to the operating system. +cluster.SCHED_RR = SCHED_RR; // Primary distributes connections. + +let ids = 0; +let initialized = false; + +// XXX(bnoordhuis) Fold cluster.schedulingPolicy into cluster.settings? +const schedulingPolicyEnv = process.env.NODE_CLUSTER_SCHED_POLICY; +let schedulingPolicy = 0; +if (schedulingPolicyEnv === "rr") schedulingPolicy = SCHED_RR; +else if (schedulingPolicyEnv === "none") schedulingPolicy = SCHED_NONE; +else if (process.platform === "win32") { + // // Round-robin doesn't perform well on + // // Windows due to the way IOCP is wired up. + // schedulingPolicy = SCHED_NONE; + // TODO + schedulingPolicy = SCHED_RR; +} else schedulingPolicy = SCHED_RR; +cluster.schedulingPolicy = schedulingPolicy; + +cluster.setupPrimary = function (options) { + const settings = { + args: ArrayPrototypeSlice.$call(process.argv, 2), + exec: process.argv[1], + execArgv: process.execArgv, + silent: false, + ...cluster.settings, + ...options, + }; + + cluster.settings = settings; + + if (initialized === true) return process.nextTick(setupSettingsNT, settings); + + initialized = true; + schedulingPolicy = cluster.schedulingPolicy; // Freeze policy. + if (!(schedulingPolicy === SCHED_NONE || schedulingPolicy === SCHED_RR)) + throw new Error(`Bad cluster.schedulingPolicy: ${schedulingPolicy}`); + + process.nextTick(setupSettingsNT, settings); +}; + +// Deprecated alias must be same as setupPrimary +cluster.setupMaster = cluster.setupPrimary; + +function setupSettingsNT(settings) { + cluster.emit("setup", settings); +} + +function createWorkerProcess(id, env) { + const workerEnv = { ...process.env, ...env, NODE_UNIQUE_ID: `${id}` }; + const execArgv = [...cluster.settings.execArgv]; + + // if (cluster.settings.inspectPort === null) { + // throw new ERR_SOCKET_BAD_PORT("Port", null, true); + // } + // if (isUsingInspector(cluster.settings.execArgv)) { + // ArrayPrototypePush(execArgv, `--inspect-port=${getInspectPort(cluster.settings.inspectPort)}`); + // } + + return child_process.fork(cluster.settings.exec, cluster.settings.args, { + cwd: cluster.settings.cwd, + env: workerEnv, + serialization: cluster.settings.serialization, + silent: cluster.settings.silent, + windowsHide: cluster.settings.windowsHide, + execArgv: execArgv, + stdio: cluster.settings.stdio, + gid: cluster.settings.gid, + uid: cluster.settings.uid, + }); +} + +function removeWorker(worker) { + if (!worker) throw new Error("ERR_INTERNAL_ASSERTION"); + delete cluster.workers[worker.id]; + + if (ObjectKeys(cluster.workers).length === 0) { + if (!(handles.size === 0)) throw new Error("Resource leak detected."); + intercom.emit("disconnect"); + } +} + +function removeHandlesForWorker(worker) { + if (!worker) throw new Error("ERR_INTERNAL_ASSERTION"); + + handles.forEach((handle, key) => { + if (handle.remove(worker)) handles.delete(key); + }); +} + +cluster.fork = function (env) { + cluster.setupPrimary(); + const id = ++ids; + const workerProcess = createWorkerProcess(id, env); + const worker = new Worker({ + id: id, + process: workerProcess, + }); + + worker.on("message", function (message, handle) { + cluster.emit("message", this, message, handle); + }); + + // FIXME: throwing an error in this function does not get caught + // at least in the cases where #handle has become null + // may be always; don't have time to investigate right now + worker.process.once("exit", (exitCode, signalCode) => { + /* + * Remove the worker from the workers list only + * if it has disconnected, otherwise we might + * still want to access it. + */ + if (!worker.isConnected()) { + removeHandlesForWorker(worker); + removeWorker(worker); + } + + worker.exitedAfterDisconnect = !!worker.exitedAfterDisconnect; + worker.state = "dead"; + worker.emit("exit", exitCode, signalCode); + cluster.emit("exit", worker, exitCode, signalCode); + }); + + worker.process.once("disconnect", () => { + /* + * Now is a good time to remove the handles + * associated with this worker because it is + * not connected to the primary anymore. + */ + removeHandlesForWorker(worker); + + /* + * Remove the worker from the workers list only + * if its process has exited. Otherwise, we might + * still want to access it. + */ + if (worker.isDead()) removeWorker(worker); + + worker.exitedAfterDisconnect = !!worker.exitedAfterDisconnect; + worker.state = "disconnected"; + worker.emit("disconnect"); + cluster.emit("disconnect", worker); + }); + + onInternalMessage(worker.process[kHandle], worker, onmessage); + process.nextTick(emitForkNT, worker); + cluster.workers[worker.id] = worker; + return worker; +}; + +function emitForkNT(worker) { + cluster.emit("fork", worker); +} + +cluster.disconnect = function (cb) { + const workers = ObjectKeys(cluster.workers); + + if (workers.length === 0) { + process.nextTick(() => intercom.emit("disconnect")); + } else { + for (const worker of ObjectValues(cluster.workers)) { + if (worker.isConnected()) { + worker.disconnect(); + } + } + } + + if (typeof cb === "function") intercom.once("disconnect", cb); +}; + +const methodMessageMapping = { + close, + exitedAfterDisconnect, + listening, + online, + queryServer, +}; + +function onmessage(message, handle) { + const worker = this; + + const fn = methodMessageMapping[message.act]; + + if (typeof fn === "function") fn(worker, message); +} + +function online(worker) { + worker.state = "online"; + worker.emit("online"); + cluster.emit("online", worker); +} + +function exitedAfterDisconnect(worker, message) { + worker.exitedAfterDisconnect = true; + send(worker, { ack: message.seq }); +} + +function queryServer(worker, message) { + // Stop processing if worker already disconnecting + if (worker.exitedAfterDisconnect) return; + + const key = `${message.address}:${message.port}:${message.addressType}:` + `${message.fd}:${message.index}`; + let handle = handles.get(key); + + if (handle === undefined) { + let address = message.address; + + // Find shortest path for unix sockets because of the ~100 byte limit + if (message.port < 0 && typeof address === "string" && process.platform !== "win32") { + address = path.relative(process.cwd(), address); + + if (message.address.length < address.length) address = message.address; + } + + // UDP is exempt from round-robin connection balancing for what should + // be obvious reasons: it's connectionless. There is nothing to send to + // the workers except raw datagrams and that's pointless. + if (schedulingPolicy !== SCHED_RR || message.addressType === "udp4" || message.addressType === "udp6") { + throwNotImplemented("node:cluster SCHED_NONE"); + } else { + handle = new RoundRobinHandle(key, address, message); + } + + handles.set(key, handle); + } + + if (!handle.data) handle.data = message.data; + + // Set custom server data + handle.add(worker, (errno, reply, handle) => { + const { data } = handles.get(key); + + if (errno) handles.delete(key); // Gives other workers a chance to retry. + + send( + worker, + { + errno, + key, + ack: message.seq, + data, + ...reply, + }, + handle, + ); + }); +} + +function listening(worker, message) { + const info = { + addressType: message.addressType, + address: message.address, + port: message.port, + fd: message.fd, + }; + + worker.state = "listening"; + worker.emit("listening", info); + cluster.emit("listening", worker, info); +} + +// Server in worker is closing, remove from list. The handle may have been +// removed by a prior call to removeHandlesForWorker() so guard against that. +function close(worker, message) { + const key = message.key; + const handle = handles.get(key); + + if (handle && handle.remove(worker)) handles.delete(key); +} + +function send(worker, message, handle?, cb?) { + return sendHelper(worker.process[kHandle], message, handle, cb); +} + +// Extend generic Worker with methods specific to the primary process. +Worker.prototype.disconnect = function () { + this.exitedAfterDisconnect = true; + send(this, { act: "disconnect" }); + removeHandlesForWorker(this); + removeWorker(this); + return this; +}; + +Worker.prototype.destroy = function (signo) { + const proc = this.process; + const signal = signo || "SIGTERM"; + + proc.kill(signal); +}; diff --git a/src/js/internal/linkedlist.ts b/src/js/internal/linkedlist.ts new file mode 100644 index 00000000000000..b5ed1b4246aae3 --- /dev/null +++ b/src/js/internal/linkedlist.ts @@ -0,0 +1,45 @@ +export function init(list) { + list._idleNext = list; + list._idlePrev = list; + return list; +} + +// Show the most idle item. +export function peek(list) { + if (list._idlePrev === list) return null; + return list._idlePrev; +} + +// Remove an item from its list. +export function remove(item) { + if (item._idleNext) { + item._idleNext._idlePrev = item._idlePrev; + } + + if (item._idlePrev) { + item._idlePrev._idleNext = item._idleNext; + } + + item._idleNext = null; + item._idlePrev = null; +} + +// Remove an item from its list and place at the end. +export function append(list, item) { + if (item._idleNext || item._idlePrev) { + remove(item); + } + + // Items are linked with _idleNext -> (older) and _idlePrev -> (newer). + // Note: This linkage (next being older) may seem counter-intuitive at first. + item._idleNext = list._idleNext; + item._idlePrev = list; + + // The list _idleNext points to tail (newest) and _idlePrev to head (oldest). + list._idleNext._idlePrev = item; + list._idleNext = item; +} + +export function isEmpty(list) { + return list._idleNext === list; +} diff --git a/src/js/internal/primordials.js b/src/js/internal/primordials.js index 59baa9e058277e..95745088b5b79c 100644 --- a/src/js/internal/primordials.js +++ b/src/js/internal/primordials.js @@ -196,7 +196,10 @@ export default { SymbolPrototypeValueOf: uncurryThis(Symbol.prototype.valueOf), FunctionPrototypeToString: uncurryThis(Function.prototype.toString), FunctionPrototypeBind: uncurryThis(Function.prototype.bind), + SymbolDispose: Symbol.dispose, + SymbolAsyncDispose: Symbol.asyncDispose, SymbolIterator: Symbol.iterator, + SymbolAsyncIterator: Symbol.asyncIterator, SymbolFor: Symbol.for, SymbolToStringTag: Symbol.toStringTag, TypedArrayPrototypeGetLength: getGetter(Uint8Array, "length"), diff --git a/src/js/internal/shared.ts b/src/js/internal/shared.ts index 729e71df2a2622..df0f652ee9a523 100644 --- a/src/js/internal/shared.ts +++ b/src/js/internal/shared.ts @@ -44,10 +44,49 @@ function warnNotImplementedOnce(feature: string, issue?: number) { const fileSinkSymbol = Symbol("fileSink"); +// + +let util; +class ExceptionWithHostPort extends Error { + errno: number; + syscall: string; + port?: number; + + constructor(err, syscall, address, port) { + // TODO(joyeecheung): We have to use the type-checked + // getSystemErrorName(err) to guard against invalid arguments from users. + // This can be replaced with [ code ] = errmap.get(err) when this method + // is no longer exposed to user land. + util ??= require("node:util"); + const code = util.getSystemErrorName(err); + let details = ""; + if (port && port > 0) { + details = ` ${address}:${port}`; + } else if (address) { + details = ` ${address}`; + } + + super(`${syscall} ${code}${details}`); + + this.errno = err; + this.code = code; + this.syscall = syscall; + this.address = address; + if (port) { + this.port = port; + } + } +} + +// + export default { NotImplementedError, throwNotImplemented, hideFromStack, warnNotImplementedOnce, fileSinkSymbol, + ExceptionWithHostPort, + kHandle: Symbol("kHandle"), + kAutoDestroyed: Symbol("kAutoDestroyed"), }; diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 2e6e427ff24a76..2f9c6f2a8deb5b 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -32,6 +32,7 @@ var StringPrototypeSlice = String.prototype.slice; var Uint8ArrayPrototypeIncludes = Uint8Array.prototype.includes; const MAX_BUFFER = 1024 * 1024; +const kFromNode = Symbol("kFromNode"); // Pass DEBUG_CHILD_PROCESS=1 to enable debug output if ($debug) { @@ -134,11 +135,12 @@ function spawn(file, args, options) { const child = new ChildProcess(); $debug("spawn", options); + options[kFromNode] = true; child.spawn(options); const timeout = options.timeout; if (timeout && timeout > 0) { - let timeoutId = setTimeout(() => { + let timeoutId: Timer | null = setTimeout(() => { if (timeoutId) { timeoutId = null; @@ -255,7 +257,7 @@ function execFile(file, args, options, callback) { let cmd = file; - function exitHandler(code, signal) { + function exitHandler(code = 0, signal?: number | null) { if (exited) return; exited = true; @@ -987,7 +989,6 @@ class ChildProcess extends EventEmitter { #closesNeeded = 1; #closesGot = 0; - connected = false; signalCode = null; exitCode = null; spawnfile; @@ -1189,6 +1190,16 @@ class ChildProcess extends EventEmitter { return (this.#stdioObject ??= this.#createStdioObject()); } + get connected() { + const handle = this.#handle; + if (handle === null) return false; + return handle.connected ?? false; + } + + get [kHandle]() { + return this.#handle; + } + spawn(options) { validateObject(options, "options"); @@ -1215,21 +1226,20 @@ class ChildProcess extends EventEmitter { const bunStdio = getBunStdioFromOptions(stdio); const argv0 = file || options.argv0; - // TODO: better ipc support - const ipc = $isJSArray(stdio) && stdio[3] === "ipc"; - var env = options.envPairs || undefined; + const has_ipc = $isJSArray(stdio) && stdio[3] === "ipc"; + var env = options.envPairs || process.env; + const detachedOption = options.detached; this.#encoding = options.encoding || undefined; this.#stdioOptions = bunStdio; const stdioCount = stdio.length; const hasSocketsToEagerlyLoad = stdioCount >= 3; - this.#closesNeeded = 1; this.#handle = Bun.spawn({ cmd: spawnargs, stdio: bunStdio, cwd: options.cwd || undefined, - env: env || process.env, + env: env, detached: typeof detachedOption !== "undefined" ? !!detachedOption : false, onExit: (handle, exitCode, signalCode, err) => { if (hasSocketsToEagerlyLoad) { @@ -1247,7 +1257,8 @@ class ChildProcess extends EventEmitter { ); }, lazy: true, - ipc: ipc ? this.#emitIpcMessage.bind(this) : undefined, + ipc: has_ipc ? this.#emitIpcMessage.bind(this) : undefined, + onDisconnect: has_ipc ? ok => this.#disconnect(ok) : undefined, serialization, argv0, windowsHide: !!options.windowsHide, @@ -1259,9 +1270,10 @@ class ChildProcess extends EventEmitter { onSpawnNT(this); - if (ipc) { + if (has_ipc) { this.send = this.#send; this.disconnect = this.#disconnect; + if (options[kFromNode]) this.#closesNeeded += 1; } if (hasSocketsToEagerlyLoad) { @@ -1313,13 +1325,18 @@ class ChildProcess extends EventEmitter { } } - #disconnect() { - if (!this.connected) { - this.emit("error", new TypeError("Process was closed while trying to send message")); + #disconnect(ok) { + if (ok == null) { + $assert(this.connected); + this.#handle.disconnect(); + } else if (!ok) { + this.emit("error", ERR_IPC_DISCONNECTED()); return; } - this.connected = false; this.#handle.disconnect(); + $assert(!this.connected); + process.nextTick(() => this.emit("disconnect")); + this.#maybeClose(); } kill(sig?) { diff --git a/src/js/node/cluster.ts b/src/js/node/cluster.ts index 3b71f7439cb928..a3f7b0595bff29 100644 --- a/src/js/node/cluster.ts +++ b/src/js/node/cluster.ts @@ -1,40 +1,22 @@ // Hardcoded module "node:cluster" -// This is a stub -// We leave it in here to provide a better error message -// TODO: implement node cluster -const EventEmitter = require("node:events"); -const { throwNotImplemented } = require("internal/shared"); -// TODO: is it okay for this to be a class? -class Cluster extends EventEmitter { - isWorker = false; - isPrimary = true; - isMaster = true; - workers = {}; - settings = {}; - SCHED_NONE = 1; - SCHED_RR = 2; - schedulingPolicy = 2; +const { isPrimary } = require("internal/cluster/isPrimary"); +const cluster = isPrimary ? require("internal/cluster/primary") : require("internal/cluster/child"); +export default cluster; - Worker = function Worker() { - throwNotImplemented("node:cluster Worker", 2428); - }; +// +// - setupPrimary() { - throwNotImplemented("node:cluster", 2428); - } - - setupMaster() { - throwNotImplemented("node:cluster", 2428); - } +function initializeClusterIPC() { + if (process.argv[1] && process.env.NODE_UNIQUE_ID) { + cluster._setupWorker(); + // Make sure it's not accidentally inherited by child processes. + delete process.env.NODE_UNIQUE_ID; - fork() { - throwNotImplemented("node:cluster", 2428); - } - - disconnect() { - throwNotImplemented("node:cluster", 2428); + process.channel.unref(); } } -export default new Cluster(); +if (Bun.isMainThread) { + initializeClusterIPC(); +} diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index c549ffb4cf1440..4093197f11794a 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -140,7 +140,7 @@ function validateString(value, name) { } hideFromStack(validateString); -function validateNumber(value, name, min = undefined, max) { +function validateNumber(value, name, min?, max?) { if (typeof value !== "number") throw new ERR_INVALID_ARG_TYPE(name, "number", value); if ( diff --git a/src/js/node/http.ts b/src/js/node/http.ts index f35f0eaab5e768..d32a1669a8a397 100644 --- a/src/js/node/http.ts +++ b/src/js/node/http.ts @@ -3,6 +3,8 @@ const EventEmitter = require("node:events"); const { isTypedArray } = require("node:util/types"); const { Duplex, Readable, Writable } = require("node:stream"); const { ERR_INVALID_ARG_TYPE } = require("internal/errors"); +const { isPrimary } = require("internal/cluster/isPrimary"); +const { kAutoDestroyed } = require("internal/shared"); const { getHeader, @@ -24,6 +26,10 @@ const { headersTuple: any; }; +let cluster; +const sendHelper = $newZigFunction("node_cluster_binding.zig", "sendHelperChild", 3); +const getBunServerAllClosedPromise = $newZigFunction("node_http_binding.zig", "getBunServerAllClosedPromise", 1); + // TODO: make this more robust. function isAbortError(err) { return err?.name === "AbortError"; @@ -78,6 +84,7 @@ const kEmptyObject = Object.freeze(Object.create(null)); const kEndCalled = Symbol.for("kEndCalled"); const kAbortController = Symbol.for("kAbortController"); const kClearTimeout = Symbol("kClearTimeout"); +const kRealListen = Symbol("kRealListen"); // Primordials const StringPrototypeSlice = String.prototype.slice; @@ -93,6 +100,7 @@ const NODE_HTTP_WARNING = var _defaultHTTPSAgent; var kInternalRequest = Symbol("kInternalRequest"); const kInternalSocketData = Symbol.for("::bunternal::"); +const serverSymbol = Symbol.for("::bunternal::"); const kfakeSocket = Symbol("kfakeSocket"); const kEmptyBuffer = Buffer.alloc(0); @@ -127,7 +135,7 @@ function validateFunction(callable: any, field: string) { type FakeSocket = InstanceType; var FakeSocket = class Socket extends Duplex { - [kInternalSocketData]!: [import("bun").Server, typeof OutgoingMessage, typeof Request]; + [kInternalSocketData]!: [typeof Server, typeof OutgoingMessage, typeof Request]; bytesRead = 0; bytesWritten = 0; connecting = false; @@ -138,7 +146,8 @@ var FakeSocket = class Socket extends Duplex { address() { // Call server.requestIP() without doing any propety getter twice. var internalData; - return (this.#address ??= (internalData = this[kInternalSocketData])?.[0]?.requestIP(internalData[2]) ?? {}); + return (this.#address ??= + (internalData = this[kInternalSocketData])?.[0]?.[serverSymbol].requestIP(internalData[2]) ?? {}); } get bufferSize() { @@ -149,7 +158,11 @@ var FakeSocket = class Socket extends Duplex { return this; } - _destroy(err, callback) {} + _destroy(err, callback) { + const socketData = this[kInternalSocketData]; + if (!socketData) return; // sometimes 'this' is Socket not FakeSocket + if (!socketData[1]["req"][kAutoDestroyed]) socketData[1].end(); + } _final(callback) {} @@ -329,14 +342,14 @@ function emitListeningNextTick(self, hostname, port) { var tlsSymbol = Symbol("tls"); var isTlsSymbol = Symbol("is_tls"); var optionsSymbol = Symbol("options"); -var serverSymbol = Symbol("server"); + function Server(options, callback) { if (!(this instanceof Server)) return new Server(options, callback); EventEmitter.$call(this); this.listening = false; this._unref = false; - this[serverSymbol] = undefined; + this[kInternalSocketData] = undefined; if (typeof options === "function") { callback = options; @@ -429,7 +442,6 @@ Server.prototype = { } this[serverSymbol] = undefined; server.stop(true); - process.nextTick(emitCloseNT, this); }, closeIdleConnections() { @@ -446,7 +458,6 @@ Server.prototype = { this[serverSymbol] = undefined; if (typeof optionalCallback === "function") this.once("close", optionalCallback); server.stop(); - process.nextTick(emitCloseNT, this); }, [Symbol.asyncDispose]() { @@ -500,15 +511,76 @@ Server.prototype = { port = 0; } + if (typeof port === "string") { + const portNumber = parseInt(port); + if (!Number.isNaN(portNumber)) { + port = portNumber; + } + } + if ($isCallable(arguments[arguments.length - 1])) { onListen = arguments[arguments.length - 1]; } - const ResponseClass = this[optionsSymbol].ServerResponse || ServerResponse; - const RequestClass = this[optionsSymbol].IncomingMessage || IncomingMessage; - let isHTTPS = false; - try { + // listenInCluster + + if (isPrimary) { + server[kRealListen](tls, port, host, socketPath, false, onListen); + return this; + } + + if (cluster === undefined) cluster = require("node:cluster"); + + // TODO: our net.Server and http.Server use different Bun APIs and our IPC doesnt support sending and receiving handles yet. use reusePort instead for now. + + // const serverQuery = { + // // address: address, + // port: port, + // addressType: 4, + // // fd: fd, + // // flags, + // // backlog, + // // ...options, + // }; + // cluster._getServer(server, serverQuery, function listenOnPrimaryHandle(err, handle) { + // // err = checkBindError(err, port, handle); + // // if (err) { + // // throw new ExceptionWithHostPort(err, "bind", address, port); + // // } + // if (err) { + // throw err; + // } + // server[kRealListen](port, host, socketPath, onListen); + // }); + + server.once("listening", () => { + cluster.worker.state = "listening"; + const address = server.address(); + const message = { + act: "listening", + port: (address && address.port) || port, + data: null, + addressType: 4, + }; + sendHelper(message, null); + }); + + server[kRealListen](tls, port, host, socketPath, true, onListen); + } catch (err) { + setTimeout(() => server.emit("error", err), 1); + } + + return this; + }, + + [kRealListen](tls, port, host, socketPath, reusePort, onListen) { + { + const ResponseClass = this[optionsSymbol].ServerResponse || ServerResponse; + const RequestClass = this[optionsSymbol].IncomingMessage || IncomingMessage; + let isHTTPS = false; + let server = this; + if (tls) { this.serverName = tls.serverName || host || "localhost"; } @@ -517,6 +589,7 @@ Server.prototype = { port, hostname: host, unix: socketPath, + reusePort, // Bindings to be used for WS Server websocket: { open(ws) { @@ -570,7 +643,7 @@ Server.prototype = { const http_res = new ResponseClass(http_req, reply); - http_req.socket[kInternalSocketData] = [_server, http_res, req]; + http_req.socket[kInternalSocketData] = [server, http_res, req]; server.emit("connection", http_req.socket); const rejectFn = err => reject(err); @@ -595,6 +668,7 @@ Server.prototype = { return promise; }, }); + getBunServerAllClosedPromise(this[serverSymbol]).$then(emitCloseNTServer.bind(this)); isHTTPS = this[serverSymbol].protocol === "https"; if (this?._unref) { @@ -606,11 +680,7 @@ Server.prototype = { } setTimeout(emitListeningNextTick, 1, this, this[serverSymbol].hostname, this[serverSymbol].port); - } catch (err) { - server.emit("error", err); } - - return this; }, setTimeout(msecs, callback) { @@ -1411,10 +1481,12 @@ class ClientRequest extends OutgoingMessage { this.#bodyChunks.push(...chunks); callback(); } + _destroy(err, callback) { this.destroyed = true; // If request is destroyed we abort the current response this[kAbortController]?.abort?.(); + this.socket.destroy(); emitErrorNextTick(this, err, callback); } @@ -2136,6 +2208,13 @@ function request(url, options, cb) { return new ClientRequest(url, options, cb); } +function emitCloseServer(self: Server) { + self.emit("close"); +} +function emitCloseNTServer(this: Server) { + process.nextTick(emitCloseServer, this); +} + /** * Makes a `GET` HTTP request. * @param {string | URL} url diff --git a/src/js/node/net.ts b/src/js/node/net.ts index bcdeae1fc3ff4f..72f86c37296c6d 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -22,6 +22,8 @@ const { Duplex } = require("node:stream"); const EventEmitter = require("node:events"); const { addServerName } = require("../internal/net"); +const { ExceptionWithHostPort } = require("internal/shared"); +const { ERR_SERVER_NOT_RUNNING } = require("internal/errors"); // IPv4 Segment const v4Seg = "(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])"; @@ -32,6 +34,9 @@ var IPv4Reg; const v6Seg = "(?:[0-9a-fA-F]{1,4})"; var IPv6Reg; +const DEFAULT_IPV4_ADDR = "0.0.0.0"; +const DEFAULT_IPV6_ADDR = "::"; + function isIPv4(s) { return (IPv4Reg ??= new RegExp(`^${v4Str}$`)).test(s); } @@ -66,8 +71,14 @@ const bunSocketServerConnections = Symbol.for("::bunnetserverconnections::"); const bunSocketServerOptions = Symbol.for("::bunnetserveroptions::"); const bunSocketInternal = Symbol.for("::bunnetsocketinternal::"); +const kServerSocket = Symbol("kServerSocket"); const bunTLSConnectOptions = Symbol.for("::buntlsconnectoptions::"); +const kRealListen = Symbol("kRealListen"); + +function closeNT(self) { + self.emit("close"); +} function endNT(socket, callback, err) { socket.end(); callback(err); @@ -243,14 +254,15 @@ const Socket = (function (InternalSocket) { data: Socket.#Handlers.data, close(socket) { Socket.#Handlers.close(socket); - this.data[bunSocketServerConnections]--; + this.data.server[bunSocketServerConnections]--; + this.data.server._emitCloseIfDrained(); }, end(socket) { Socket.#Handlers.end(socket); - this.data[bunSocketServerConnections]--; }, open(socket) { const self = this.data; + socket[kServerSocket] = self[bunSocketInternal]; const options = self[bunSocketServerOptions]; const { pauseOnConnect, connectionListener, InternalSocketClass, requestCert, rejectUnauthorized } = options; const _socket = new InternalSocketClass({}); @@ -802,26 +814,35 @@ class Server extends EventEmitter { } close(callback) { - if (this[bunSocketInternal]) { - this[bunSocketInternal].stop(true); - this[bunSocketInternal] = null; - this[bunSocketServerConnections] = 0; - this.emit("close"); - if (typeof callback === "function") { - callback(); + if (typeof callback === "function") { + if (!this[bunSocketInternal]) { + this.once("close", function close() { + callback(new ERR_SERVER_NOT_RUNNING()); + }); + } else { + this.once("close", callback); } - - return this; } - if (typeof callback === "function") { - const error = new Error("Server is not running"); - error.code = "ERR_SERVER_NOT_RUNNING"; - callback(error); + if (this[bunSocketInternal]) { + this[bunSocketInternal].stop(false); + this[bunSocketInternal] = null; } + + this._emitCloseIfDrained(); + return this; } + _emitCloseIfDrained() { + if (this[bunSocketInternal] || this[bunSocketServerConnections] > 0) { + return; + } + process.nextTick(() => { + this.emit("close"); + }); + } + address() { const server = this[bunSocketInternal]; if (server) { @@ -952,45 +973,76 @@ class Server extends EventEmitter { } else { options.InternalSocketClass = SocketClass; } - this[bunSocketInternal] = Bun.listen( - path - ? { - exclusive, - unix: path, - tls, - socket: SocketClass[bunSocketServerHandlers], - } - : { - exclusive, - port, - hostname, - tls, - socket: SocketClass[bunSocketServerHandlers], - }, + + listenInCluster( + this, + null, + port, + 4, + backlog, + undefined, + exclusive, + undefined, + undefined, + path, + hostname, + tls, + contexts, + onListen, ); + } catch (err) { + setTimeout(emitErrorNextTick, 1, this, err); + } + return this; + } - //make this instance available on handlers - this[bunSocketInternal].data = this; + [kRealListen](path, port, hostname, exclusive, tls, contexts, onListen) { + if (path) { + this[bunSocketInternal] = Bun.listen({ + unix: path, + tls, + socket: SocketClass[bunSocketServerHandlers], + }); + } else { + this[bunSocketInternal] = Bun.listen({ + exclusive, + port, + hostname, + tls, + socket: SocketClass[bunSocketServerHandlers], + }); + } - if (contexts) { - for (const [name, context] of contexts) { - addServerName(this[bunSocketInternal], name, context); - } - } + //make this instance available on handlers + this[bunSocketInternal].data = this; - // We must schedule the emitListeningNextTick() only after the next run of - // the event loop's IO queue. Otherwise, the server may not actually be listening - // when the 'listening' event is emitted. - // - // That leads to all sorts of confusion. - // - // process.nextTick() is not sufficient because it will run before the IO queue. - setTimeout(emitListeningNextTick, 1, this, onListen); - } catch (err) { - setTimeout(emitErrorNextTick, 1, this, err); + if (contexts) { + for (const [name, context] of contexts) { + addServerName(this[bunSocketInternal], name, context); + } } + + // We must schedule the emitListeningNextTick() only after the next run of + // the event loop's IO queue. Otherwise, the server may not actually be listening + // when the 'listening' event is emitted. + // + // That leads to all sorts of confusion. + // + // process.nextTick() is not sufficient because it will run before the IO queue. + setTimeout(emitListeningNextTick, 1, this, onListen?.bind(this)); + } + + get _handle() { return this; } + set _handle(new_handle) { + //nothing + } + + getsockname(out) { + out.port = this.address().port; + return out; + } } function emitErrorNextTick(self, error) { @@ -1013,6 +1065,50 @@ function emitListeningNextTick(self, onListen) { self.emit("listening"); } +let cluster; +function listenInCluster( + server, + address, + port, + addressType, + backlog, + fd, + exclusive, + flags, + options, + path, + hostname, + tls, + contexts, + onListen, +) { + exclusive = !!exclusive; + + if (cluster === undefined) cluster = require("node:cluster"); + + if (cluster.isPrimary || exclusive) { + server[kRealListen](path, port, hostname, exclusive, tls, contexts, onListen); + return; + } + + const serverQuery = { + address: address, + port: port, + addressType: addressType, + fd: fd, + flags, + backlog, + ...options, + }; + cluster._getServer(server, serverQuery, function listenOnPrimaryHandle(err, handle) { + err = checkBindError(err, port, handle); + if (err) { + throw new ExceptionWithHostPort(err, "bind", address, port); + } + server[kRealListen](path, port, hostname, exclusive, tls, contexts, onListen); + }); +} + function createServer(options, connectionListener) { return new Server(options, connectionListener); } @@ -1046,6 +1142,23 @@ function normalizeArgs(args) { return arr; } +function checkBindError(err, port, handle) { + // EADDRINUSE may not be reported until we call listen() or connect(). + // To complicate matters, a failed bind() followed by listen() or connect() + // will implicitly bind to a random port. Ergo, check that the socket is + // bound to the expected port before calling listen() or connect(). + if (err === 0 && port > 0 && handle.getsockname) { + const out = {}; + err = handle.getsockname(out); + if (err === 0 && port !== out.port) { + $debug(`checkBindError, bound to ${out.port} instead of ${port}`); + const UV_EADDRINUSE = -4091; + err = UV_EADDRINUSE; + } + } + return err; +} + function isPipeName(s) { return typeof s === "string" && toNumber(s) === false; } diff --git a/src/js/node/stream.ts b/src/js/node/stream.ts index 961bdb7228f998..78379f16cccc59 100644 --- a/src/js/node/stream.ts +++ b/src/js/node/stream.ts @@ -28,6 +28,7 @@ const kPaused = Symbol("kPaused"); const StringDecoder = require("node:string_decoder").StringDecoder; const transferToNativeReadable = $newCppFunction("ReadableStream.cpp", "jsFunctionTransferToNativeReadableStream", 1); +const { kAutoDestroyed } = require("internal/shared"); const ObjectSetPrototypeOf = Object.setPrototypeOf; @@ -3574,6 +3575,7 @@ var require_readable = __commonJS({ const wState = stream._writableState; const autoDestroy = !wState || (wState.autoDestroy && (wState.finished || wState.writable === false)); if (autoDestroy) { + stream[kAutoDestroyed] = true; // workaround for node:http Server not using node:net Server stream.destroy(); } } diff --git a/src/js/node/v8.ts b/src/js/node/v8.ts index d70a7bc73c0cd9..ada998d747c752 100644 --- a/src/js/node/v8.ts +++ b/src/js/node/v8.ts @@ -139,6 +139,8 @@ export default { startupSnapshot, Deserializer, Serializer, + DefaultDeserializer, + DefaultSerializer, }; hideFromStack( diff --git a/src/js/thirdparty/ws.js b/src/js/thirdparty/ws.js index 2b86283300b57e..64ea131afd031c 100644 --- a/src/js/thirdparty/ws.js +++ b/src/js/thirdparty/ws.js @@ -1120,7 +1120,7 @@ class WebSocketServer extends EventEmitter { * @private */ completeUpgrade(extensions, key, protocols, request, socket, head, cb) { - const [server, response, req] = socket[kBunInternals]; + const [{ [kBunInternals]: server }, response, req] = socket[kBunInternals]; if (this._state > RUNNING) return abortHandshake(response, 503); let protocol = ""; diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 8a75659382f649..e4738c3c22239e 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -2,7 +2,18 @@ import { file, gc, Serve, serve, Server } from "bun"; import { afterEach, describe, it, expect, afterAll, mock } from "bun:test"; import { readFileSync, writeFileSync } from "fs"; import { join, resolve } from "path"; -import { bunExe, bunEnv, dumpStats, isPosix, isIPv6, tmpdirSync, isIPv4, rejectUnauthorizedScope, tls } from "harness"; +import { + bunExe, + bunEnv, + dumpStats, + isPosix, + isIPv6, + tmpdirSync, + isIPv4, + rejectUnauthorizedScope, + tls, + isWindows, +} from "harness"; // import { renderToReadableStream } from "react-dom/server"; // import app_jsx from "./app.jsx"; import { spawn } from "child_process"; @@ -1607,6 +1618,7 @@ it("should work with dispose keyword", async () => { expect(fetch(url)).rejects.toThrow(); }); +// prettier-ignore it("should be able to stop in the middle of a file response", async () => { async function doRequest(url: string) { try { diff --git a/test/js/node/cluster/common.ts b/test/js/node/cluster/common.ts new file mode 100644 index 00000000000000..d17e86937d9233 --- /dev/null +++ b/test/js/node/cluster/common.ts @@ -0,0 +1,37 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import assert from "node:assert"; +import util from "node:util"; +import process from "node:process"; + +export const isWindows = process.platform === "win32"; + +export function tmpdirSync(pattern: string = "bun.test.") { + return fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), pattern)); +} + +export function isAlive(pid) { + try { + process.kill(pid, "SIGCONT"); + return true; + } catch { + return false; + } +} + +export function mustNotCall(msg?) { + return function mustNotCall(...args) { + const argsInfo = args.length > 0 ? `\ncalled with arguments: ${args.map(arg => util.inspect(arg)).join(", ")}` : ""; + assert.fail(`${msg || "function should not have been called"} ` + argsInfo); + }; +} + +export function patchEmitter(emitter: any, prefix: string) { + var oldEmit = emitter.emit; + + emitter.emit = function () { + console.log([prefix, arguments[0]]); + oldEmit.apply(emitter, arguments); + }; +} diff --git a/test/js/node/cluster/test-docs-http-server.ts b/test/js/node/cluster/test-docs-http-server.ts new file mode 100644 index 00000000000000..1288a89a94e910 --- /dev/null +++ b/test/js/node/cluster/test-docs-http-server.ts @@ -0,0 +1,43 @@ +import cluster from "node:cluster"; +import http from "node:http"; +import { availableParallelism } from "node:os"; +import assert from "node:assert"; + +const numCPUs = availableParallelism(); +let workers = 0; + +if (cluster.isPrimary) { + console.log(`Primary ${process.pid} is running`); + + // Fork workers. + for (let i = 0; i < numCPUs; i++) { + cluster.fork(); + } + + cluster.on("message", (_, msg) => { + assert.strictEqual(msg, "hello"); + workers += 1; + }); + cluster.on("exit", (worker, code, signal) => { + console.log(`worker ${worker.process.pid} died`); + }); + process.on("exit", code => { + assert.strictEqual(code, 0); + assert.strictEqual(workers, numCPUs); + }); +} else { + // Workers can share any TCP connection + // In this case it is an HTTP server + const server = http + .createServer((req, res) => { + res.writeHead(200); + res.end("hello world\n"); + }) + .listen(8000, () => { + process.send("hello"); + server.close(); + process.disconnect(); + }); + + console.log(`Worker ${process.pid} started`); +} diff --git a/test/js/node/cluster/test-worker-no-exit-http.ts b/test/js/node/cluster/test-worker-no-exit-http.ts new file mode 100644 index 00000000000000..661c439861b2e0 --- /dev/null +++ b/test/js/node/cluster/test-worker-no-exit-http.ts @@ -0,0 +1,61 @@ +const assert = require("assert"); +const cluster = require("cluster"); +const http = require("http"); +import { patchEmitter } from "./common"; + +let destroyed; +let success; +let worker; +let server; + +// Workers do not exit on disconnect, they exit under normal node rules: when +// they have nothing keeping their loop alive, like an active connection +// +// test this by: +// +// 1 creating a server, so worker can make a connection to something +// 2 disconnecting worker +// 3 wait to confirm it did not exit +// 4 destroy connection +// 5 confirm it does exit +if (cluster.isPrimary) { + server = http + .createServer(function (req, res) { + server.close(); + worker.disconnect(); + worker + .once("disconnect", function () { + setTimeout(function () { + req.destroy(); + destroyed = true; + }, 1000); + }) + .once("exit", function () { + // Worker should not exit while it has a connection + assert(destroyed, "worker exited before connection destroyed"); + success = true; + }); + }) + .listen(0, function () { + const port = this.address().port; + + worker = cluster.fork(); + worker.on("online", function () { + this.send({ port }); + }); + }); + patchEmitter(server, "server"); + process.on("exit", function () { + assert(success); + }); +} else { + process.on("message", function (msg) { + console.log(2, msg); + // We shouldn't exit, not while a network connection exists + const req = http.get(`http://localhost:${msg.port}/`, res => { + console.log(4, res.constructor.name); + }); + console.log(3, req.constructor.name); + }); + console.log(1); +} diff --git a/test/js/node/cluster/upstream/common/countdown.js b/test/js/node/cluster/upstream/common/countdown.js new file mode 100644 index 00000000000000..9853c9fa47b71d --- /dev/null +++ b/test/js/node/cluster/upstream/common/countdown.js @@ -0,0 +1,27 @@ +"use strict"; + +const assert = require("assert"); +const kLimit = Symbol("limit"); +const kCallback = Symbol("callback"); +const common = require("./"); + +class Countdown { + constructor(limit, cb) { + assert.strictEqual(typeof limit, "number"); + assert.strictEqual(typeof cb, "function"); + this[kLimit] = limit; + this[kCallback] = common.mustCall(cb); + } + + dec() { + assert(this[kLimit] > 0, "Countdown expired"); + if (--this[kLimit] === 0) this[kCallback](); + return this[kLimit]; + } + + get remaining() { + return this[kLimit]; + } +} + +module.exports = Countdown; diff --git a/test/js/node/cluster/upstream/common/index.js b/test/js/node/cluster/upstream/common/index.js new file mode 100644 index 00000000000000..46df52172677cc --- /dev/null +++ b/test/js/node/cluster/upstream/common/index.js @@ -0,0 +1,248 @@ +// 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. + +// /* eslint-disable node-core/crypto-check */ +"use strict"; +const process = global.process; // Some tests tamper with the process global. + +const assert = require("assert"); +const { exec, execSync, spawn, spawnSync } = require("child_process"); +const fs = require("fs"); +const net = require("net"); +// Do not require 'os' until needed so that test-os-checked-function can +// monkey patch it. If 'os' is required here, that test will fail. +const path = require("path"); +const { inspect } = require("util"); +const { isMainThread } = require("worker_threads"); + +// Some tests assume a umask of 0o022 so set that up front. Tests that need a +// different umask will set it themselves. +// +// Workers can read, but not set the umask, so check that this is the main +// thread. +if (isMainThread) process.umask(0o022); + +const noop = () => {}; + +const isWindows = process.platform === "win32"; +const isSunOS = process.platform === "sunos"; +const isFreeBSD = process.platform === "freebsd"; +const isOpenBSD = process.platform === "openbsd"; +const isLinux = process.platform === "linux"; +const isOSX = process.platform === "darwin"; +const isPi = (() => { + try { + // Normal Raspberry Pi detection is to find the `Raspberry Pi` string in + // the contents of `/sys/firmware/devicetree/base/model` but that doesn't + // work inside a container. Match the chipset model number instead. + const cpuinfo = fs.readFileSync("/proc/cpuinfo", { encoding: "utf8" }); + const ok = /^Hardware\s*:\s*(.*)$/im.exec(cpuinfo)?.[1] === "BCM2835"; + /^/.test(""); // Clear RegExp.$_, some tests expect it to be empty. + return ok; + } catch { + return false; + } +})(); + +const isDumbTerminal = process.env.TERM === "dumb"; + +const mustCallChecks = []; + +function runCallChecks(exitCode) { + if (exitCode !== 0) return; + + const failed = mustCallChecks.filter(function (context) { + if ("minimum" in context) { + context.messageSegment = `at least ${context.minimum}`; + return context.actual < context.minimum; + } + context.messageSegment = `exactly ${context.exact}`; + return context.actual !== context.exact; + }); + + failed.forEach(function (context) { + console.log( + "Mismatched %s function calls. Expected %s, actual %d.", + context.name, + context.messageSegment, + context.actual, + ); + console.log(context.stack.split("\n").slice(2).join("\n")); + }); + + if (failed.length) process.exit(1); +} + +function mustCall(fn, exact) { + return _mustCallInner(fn, exact, "exact"); +} + +function mustSucceed(fn, exact) { + return mustCall(function (err, ...args) { + assert.ifError(err); + if (typeof fn === "function") return fn.apply(this, args); + }, exact); +} + +function _mustCallInner(fn, criteria = 1, field) { + if (process._exiting) throw new Error("Cannot use common.mustCall*() in process exit handler"); + if (typeof fn === "number") { + criteria = fn; + fn = noop; + } else if (fn === undefined) { + fn = noop; + } + + if (typeof criteria !== "number") throw new TypeError(`Invalid ${field} value: ${criteria}`); + + const context = { + [field]: criteria, + actual: 0, + stack: inspect(new Error()), + name: fn.name || "", + }; + + // Add the exit listener only once to avoid listener leak warnings + if (mustCallChecks.length === 0) process.on("exit", runCallChecks); + + mustCallChecks.push(context); + + const _return = function () { + // eslint-disable-line func-style + context.actual++; + return fn.apply(this, arguments); + }; + // Function instances have own properties that may be relevant. + // Let's replicate those properties to the returned function. + // Refs: https://tc39.es/ecma262/#sec-function-instances + Object.defineProperties(_return, { + name: { + value: fn.name, + writable: false, + enumerable: false, + configurable: true, + }, + length: { + value: fn.length, + writable: false, + enumerable: false, + configurable: true, + }, + }); + return _return; +} + +function getCallSite(top) { + const originalStackFormatter = Error.prepareStackTrace; + Error.prepareStackTrace = (err, stack) => `${stack[0].getFileName()}:${stack[0].getLineNumber()}`; + const err = new Error(); + Error.captureStackTrace(err, top); + // With the V8 Error API, the stack is not formatted until it is accessed + err.stack; // eslint-disable-line no-unused-expressions + Error.prepareStackTrace = originalStackFormatter; + return err.stack; +} + +function mustNotCall(msg) { + const callSite = getCallSite(mustNotCall); + return function mustNotCall(...args) { + const argsInfo = args.length > 0 ? `\ncalled with arguments: ${args.map(arg => inspect(arg)).join(", ")}` : ""; + assert.fail(`${msg || "function should not have been called"} at ${callSite}` + argsInfo); + }; +} + +function printSkipMessage(msg) { + console.log(`1..0 # Skipped: ${msg}`); +} + +function skip(msg) { + printSkipMessage(msg); + process.exit(0); +} + +function isAlive(pid) { + try { + process.kill(pid, "SIGCONT"); + return true; + } catch { + return false; + } +} + +function skipIf32Bits() { + if (bits < 64) { + skip("The tested feature is not available in 32bit builds"); + } +} + +function skipIfWorker() { + if (!isMainThread) { + skip("This test only works on a main thread"); + } +} + +function skipIfDumbTerminal() { + if (isDumbTerminal) { + skip("skipping - dumb terminal"); + } +} + +const common = { + isAlive, + isDumbTerminal, + isFreeBSD, + isLinux, + isMainThread, + isOpenBSD, + isOSX, + isPi, + isSunOS, + isWindows, + mustCall, + mustNotCall, + mustSucceed, + printSkipMessage, + skip, + skipIf32Bits, + skipIfDumbTerminal, + // On IBMi, process.platform and os.platform() both return 'aix', + // when built with Python versions earlier than 3.9. + // It is not enough to differentiate between IBMi and real AIX system. + get isAIX() { + return require("os").type() === "AIX"; + }, + + get isIBMi() { + return require("os").type() === "OS400"; + }, + + get isLinuxPPCBE() { + return process.platform === "linux" && process.arch === "ppc64" && require("os").endianness() === "BE"; + }, +}; + +const validProperties = new Set(Object.keys(common)); +module.exports = new Proxy(common, { + get(obj, prop) { + if (!validProperties.has(prop)) throw new Error(`Using invalid common property: '${prop}'`); + return obj[prop]; + }, +}); diff --git a/test/js/node/cluster/upstream/common/tmpdir.js b/test/js/node/cluster/upstream/common/tmpdir.js new file mode 100644 index 00000000000000..7de0b113a32654 --- /dev/null +++ b/test/js/node/cluster/upstream/common/tmpdir.js @@ -0,0 +1,88 @@ +"use strict"; + +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const { pathToFileURL } = require("url"); +const { isMainThread } = require("worker_threads"); + +function rmSync(pathname, useSpawn) { + if (useSpawn) { + const escapedPath = pathname.replaceAll("\\", "\\\\"); + spawnSync(process.execPath, [ + "-e", + `require("fs").rmSync("${escapedPath}", { maxRetries: 3, recursive: true, force: true });`, + ]); + } else { + fs.rmSync(pathname, { maxRetries: 3, recursive: true, force: true }); + } +} + +const testRoot = process.env.NODE_TEST_DIR ? fs.realpathSync(process.env.NODE_TEST_DIR) : path.resolve(__dirname, ".."); + +// Using a `.` prefixed name, which is the convention for "hidden" on POSIX, +// gets tools to ignore it by default or by simple rules, especially eslint. +const tmpdirName = ".tmp." + (process.env.TEST_SERIAL_ID || process.env.TEST_THREAD_ID || "0"); +const tmpPath = path.join(testRoot, tmpdirName); + +let firstRefresh = true; +function refresh(useSpawn = false) { + rmSync(tmpPath, useSpawn); + fs.mkdirSync(tmpPath); + + if (firstRefresh) { + firstRefresh = false; + // Clean only when a test uses refresh. This allows for child processes to + // use the tmpdir and only the parent will clean on exit. + process.on("exit", () => { + return onexit(useSpawn); + }); + } +} + +function onexit(useSpawn) { + // Change directory to avoid possible EBUSY + if (isMainThread) process.chdir(testRoot); + + try { + rmSync(tmpPath, useSpawn); + } catch (e) { + console.error("Can't clean tmpdir:", tmpPath); + + const files = fs.readdirSync(tmpPath); + console.error("Files blocking:", files); + + if (files.some(f => f.startsWith(".nfs"))) { + // Warn about NFS "silly rename" + console.error('Note: ".nfs*" might be files that were open and ' + "unlinked but not closed."); + console.error("See http://nfs.sourceforge.net/#faq_d2 for details."); + } + + console.error(); + throw e; + } +} + +function resolve(...paths) { + return path.resolve(tmpPath, ...paths); +} + +function hasEnoughSpace(size) { + const { bavail, bsize } = fs.statfsSync(tmpPath); + return bavail >= Math.ceil(size / bsize); +} + +function fileURL(...paths) { + // When called without arguments, add explicit trailing slash + const fullPath = path.resolve(tmpPath + path.sep, ...paths); + + return pathToFileURL(fullPath); +} + +module.exports = { + fileURL, + hasEnoughSpace, + path: tmpPath, + refresh, + resolve, +}; diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-advanced-serialization.js b/test/js/node/cluster/upstream/parallel/test-cluster-advanced-serialization.js new file mode 100644 index 00000000000000..8a368d44c7175d --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-advanced-serialization.js @@ -0,0 +1,28 @@ +"use strict"; +const common = require("../common"); +const assert = require("assert"); +const cluster = require("cluster"); + +if (cluster.isPrimary) { + cluster.settings.serialization = "advanced"; + const worker = cluster.fork(); + const circular = {}; + circular.circular = circular; + + worker.on( + "online", + common.mustCall(() => { + worker.send(circular); + + worker.on( + "message", + common.mustCall(msg => { + assert.deepStrictEqual(msg, circular); + worker.kill(); + }), + ); + }), + ); +} else { + process.on("message", msg => process.send(msg)); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-bind-privileged-port.js b/test/js/node/cluster/upstream/parallel/test-cluster-bind-privileged-port.js new file mode 100644 index 00000000000000..f3a788984b7834 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-bind-privileged-port.js @@ -0,0 +1,68 @@ +// 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"); +if (common.isLinux) return; // TODO: bun +const assert = require("assert"); +const cluster = require("cluster"); +const net = require("net"); +const { readFileSync } = require("fs"); + +if (common.isLinux) { + try { + const unprivilegedPortStart = parseInt(readFileSync("/proc/sys/net/ipv4/ip_unprivileged_port_start")); + if (unprivilegedPortStart <= 42) { + common.skip("Port 42 is unprivileged"); + } + } catch { + // Do nothing, feature doesn't exist, minimum is 1024 so 42 is usable. + // Continue... + } +} + +// Skip on OS X Mojave. https://github.com/nodejs/node/issues/21679 +if (common.isOSX) common.skip("macOS may allow ordinary processes to use any port"); + +if (common.isIBMi) common.skip("IBMi may allow ordinary processes to use any port"); + +if (common.isWindows) common.skip("not reliable on Windows."); + +if (process.getuid() === 0) common.skip("Test is not supposed to be run as root."); + +if (cluster.isPrimary) { + cluster.fork().on( + "exit", + common.mustCall(exitCode => { + assert.strictEqual(exitCode, 0); + }), + ); +} else { + const s = net.createServer(common.mustNotCall()); + s.listen(42, common.mustNotCall("listen should have failed")); + s.on( + "error", + common.mustCall(err => { + assert.strictEqual(err.code, "EACCES"); + process.disconnect(); + }), + ); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-call-and-destroy.js b/test/js/node/cluster/upstream/parallel/test-cluster-call-and-destroy.js new file mode 100644 index 00000000000000..6d9ff44e674539 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-call-and-destroy.js @@ -0,0 +1,18 @@ +"use strict"; +const common = require("../common"); +const cluster = require("cluster"); +const assert = require("assert"); + +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on( + "disconnect", + common.mustCall(() => { + assert.strictEqual(worker.isConnected(), false); + worker.destroy(); + }), + ); +} else { + assert.strictEqual(cluster.worker.isConnected(), true); + cluster.worker.disconnect(); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-child-index-dgram.js b/test/js/node/cluster/upstream/parallel/test-cluster-child-index-dgram.js new file mode 100644 index 00000000000000..426c8de9f4783e --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-child-index-dgram.js @@ -0,0 +1,43 @@ +"use strict"; +const common = require("../common"); +const Countdown = require("../common/countdown"); +if (common.isWindows) common.skip("dgram clustering is currently not supported on Windows."); + +const cluster = require("cluster"); +const dgram = require("dgram"); + +// Test an edge case when using `cluster` and `dgram.Socket.bind()` +// the port of `0`. +const kPort = 0; + +function child() { + const kTime = 2; + const countdown = new Countdown(kTime * 2, () => { + process.exit(0); + }); + for (let i = 0; i < kTime; i += 1) { + const socket = new dgram.Socket("udp4"); + socket.bind( + kPort, + common.mustCall(() => { + // `process.nextTick()` or `socket2.close()` would throw + // ERR_SOCKET_DGRAM_NOT_RUNNING + process.nextTick(() => { + socket.close(countdown.dec()); + const socket2 = new dgram.Socket("udp4"); + socket2.bind( + kPort, + common.mustCall(() => { + process.nextTick(() => { + socket2.close(countdown.dec()); + }); + }), + ); + }); + }), + ); + } +} + +if (cluster.isMaster) cluster.fork(__filename); +else child(); diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-child-index-net.js b/test/js/node/cluster/upstream/parallel/test-cluster-child-index-net.js new file mode 100644 index 00000000000000..961924a2d6882e --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-child-index-net.js @@ -0,0 +1,35 @@ +"use strict"; +const common = require("../common"); +const Countdown = require("../common/countdown"); +const cluster = require("cluster"); +const net = require("net"); + +// Test an edge case when using `cluster` and `net.Server.listen()` to +// the port of `0`. +const kPort = 0; + +function child() { + const kTime = 2; + const countdown = new Countdown(kTime * 2, () => { + process.exit(0); + }); + for (let i = 0; i < kTime; i += 1) { + const server = net.createServer(); + server.listen( + kPort, + common.mustCall(() => { + server.close(countdown.dec()); + const server2 = net.createServer(); + server2.listen( + kPort, + common.mustCall(() => { + server2.close(countdown.dec()); + }), + ); + }), + ); + } +} + +if (cluster.isMaster) cluster.fork(__filename); +else child(); diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-concurrent-disconnect.js b/test/js/node/cluster/upstream/parallel/test-cluster-concurrent-disconnect.js new file mode 100644 index 00000000000000..1e1daa9ef4b836 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-concurrent-disconnect.js @@ -0,0 +1,58 @@ +"use strict"; + +// Ref: https://github.com/nodejs/node/issues/32106 + +const common = require("../common"); +if (common.isLinux) return; // TODO: bun +if (common.isWindows) return; // TODO: bun + +const assert = require("assert"); +const cluster = require("cluster"); +const os = require("os"); + +if (cluster.isPrimary) { + const workers = []; + const numCPUs = os.availableParallelism(); + let waitOnline = numCPUs; + for (let i = 0; i < numCPUs; i++) { + const worker = cluster.fork(); + workers[i] = worker; + worker.once( + "online", + common.mustCall(() => { + if (--waitOnline === 0) + for (const worker of workers) if (worker.isConnected()) worker.send(i % 2 ? "disconnect" : "destroy"); + }), + ); + + // These errors can occur due to the nature of the test, we might be trying + // to send messages when the worker is disconnecting. + worker.on("error", err => { + assert.strictEqual(err.syscall, "write"); + if (common.isOSX) { + assert(["EPIPE", "ENOTCONN"].includes(err.code), err); + } else { + assert(["EPIPE", "ECONNRESET"].includes(err.code), err); + } + }); + + worker.once( + "disconnect", + common.mustCall(() => { + for (const worker of workers) if (worker.isConnected()) worker.send("disconnect"); + }), + ); + + worker.once( + "exit", + common.mustCall((code, signal) => { + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }), + ); + } +} else { + process.on("message", msg => { + if (cluster.worker.isConnected()) cluster.worker[msg](); + }); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-cwd.js b/test/js/node/cluster/upstream/parallel/test-cluster-cwd.js new file mode 100644 index 00000000000000..d28bfdf5451b52 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-cwd.js @@ -0,0 +1,29 @@ +"use strict"; +const common = require("../common"); +const assert = require("assert"); +const cluster = require("cluster"); +const tmpdir = require("../common/tmpdir"); + +if (cluster.isPrimary) { + tmpdir.refresh(); + + assert.strictEqual(cluster.settings.cwd, undefined); + cluster.fork().on( + "message", + common.mustCall(msg => { + assert.strictEqual(msg, process.cwd()); + }), + ); + + cluster.setupPrimary({ cwd: tmpdir.path }); + assert.strictEqual(cluster.settings.cwd, tmpdir.path); + cluster.fork().on( + "message", + common.mustCall(msg => { + assert.strictEqual(msg, tmpdir.path); + }), + ); +} else { + process.send(process.cwd()); + process.disconnect(); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-disconnect-before-exit.js b/test/js/node/cluster/upstream/parallel/test-cluster-disconnect-before-exit.js new file mode 100644 index 00000000000000..bb5dda7aa71e88 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-disconnect-before-exit.js @@ -0,0 +1,37 @@ +// 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 cluster = require("cluster"); + +if (cluster.isPrimary) { + const worker = cluster.fork().on("online", common.mustCall(disconnect)); + + function disconnect() { + worker.disconnect(); + // The worker remains in cluster.workers until both disconnect AND exit. + // Disconnect is supposed to disconnect all workers, but not workers that + // are already disconnected, since calling disconnect() on an already + // disconnected worker would error. + worker.on("disconnect", common.mustCall(cluster.disconnect)); + } +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-disconnect-exitedAfterDisconnect-race.js b/test/js/node/cluster/upstream/parallel/test-cluster-disconnect-exitedAfterDisconnect-race.js new file mode 100644 index 00000000000000..f461734eb623ee --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-disconnect-exitedAfterDisconnect-race.js @@ -0,0 +1,33 @@ +"use strict"; + +const common = require("../common"); + +// Test should fail in Node.js 5.4.1 and pass in later versions. + +const assert = require("assert"); +const cluster = require("cluster"); + +if (cluster.isPrimary) { + cluster.on("exit", (worker, code) => { + assert.strictEqual(code, 0, `worker exited with code: ${code}, expected 0`); + }); + + return cluster.fork(); +} + +let eventFired = false; + +cluster.worker.disconnect(); + +process.nextTick( + common.mustCall(() => { + assert.ok(!eventFired, "disconnect event should wait for ack"); + }), +); + +cluster.worker.on( + "disconnect", + common.mustCall(() => { + eventFired = true; + }), +); diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-disconnect-idle-worker.js b/test/js/node/cluster/upstream/parallel/test-cluster-disconnect-idle-worker.js new file mode 100644 index 00000000000000..f20bacdede12b3 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-disconnect-idle-worker.js @@ -0,0 +1,36 @@ +// 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 cluster = require("cluster"); +const fork = cluster.fork; + +if (cluster.isPrimary) { + fork(); // It is intentionally called `fork` instead of + fork(); // `cluster.fork` to test that `this` is not used + cluster.disconnect( + common.mustCall(() => { + assert.deepStrictEqual(Object.keys(cluster.workers), []); + }), + ); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-disconnect-leak.js b/test/js/node/cluster/upstream/parallel/test-cluster-disconnect-leak.js new file mode 100644 index 00000000000000..d1b48120043594 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-disconnect-leak.js @@ -0,0 +1,27 @@ +"use strict"; + +// Test fails in Node v5.4.0 and passes in v5.4.1 and newer. + +const common = require("../common"); +const net = require("net"); +const cluster = require("cluster"); + +cluster.schedulingPolicy = cluster.SCHED_NONE; + +if (cluster.isPrimary) { + const worker = cluster.fork(); + + // This is the important part of the test: Confirm that `disconnect` fires. + worker.on("disconnect", common.mustCall()); + + // These are just some extra stuff we're checking for good measure... + worker.on("exit", common.mustCall()); + cluster.on("exit", common.mustCall()); + + cluster.disconnect(); + return; +} + +const server = net.createServer(); + +server.listen(0); diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-disconnect-with-no-workers.js b/test/js/node/cluster/upstream/parallel/test-cluster-disconnect-with-no-workers.js new file mode 100644 index 00000000000000..865dd25f3fffbc --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-disconnect-with-no-workers.js @@ -0,0 +1,38 @@ +// 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 cluster = require("cluster"); + +let disconnected; + +process.on("exit", function () { + assert(disconnected); +}); + +cluster.disconnect(function () { + disconnected = true; +}); + +// Assert that callback is not sometimes synchronous +assert(!disconnected); diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-fork-env.js b/test/js/node/cluster/upstream/parallel/test-cluster-fork-env.js new file mode 100644 index 00000000000000..8bf9ef15d7469b --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-fork-env.js @@ -0,0 +1,66 @@ +// 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"); + +// This test checks that arguments provided to cluster.fork() will create +// new environment variables and override existing environment variables +// in the created worker process. + +const assert = require("assert"); +const cluster = require("cluster"); + +if (cluster.isWorker) { + const result = cluster.worker.send({ + prop: process.env.cluster_test_prop, + overwrite: process.env.cluster_test_overwrite, + }); + + assert.strictEqual(result, true); +} else if (cluster.isPrimary) { + const checks = { + using: false, + overwrite: false, + }; + + // To check that the cluster extend on the process.env we will overwrite a + // property + process.env.cluster_test_overwrite = "old"; + + // Fork worker + const worker = cluster.fork({ + "cluster_test_prop": "custom", + "cluster_test_overwrite": "new", + }); + + // Checks worker env + worker.on("message", function (data) { + checks.using = data.prop === "custom"; + checks.overwrite = data.overwrite === "new"; + process.exit(0); + }); + + process.once("exit", function () { + assert.ok(checks.using, "The worker did not receive the correct env."); + assert.ok(checks.overwrite, "The custom environment did not overwrite the existing environment."); + }); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-fork-windowsHide.js b/test/js/node/cluster/upstream/parallel/test-cluster-fork-windowsHide.js new file mode 100644 index 00000000000000..273e8146a77fa3 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-fork-windowsHide.js @@ -0,0 +1,76 @@ +"use strict"; +const common = require("../common"); +if (common.isWindows) return; // TODO: bun +const assert = require("assert"); +const child_process = require("child_process"); +const cluster = require("cluster"); + +if (!process.argv[2]) { + // It seems Windows only allocate new console window for + // attaching processes spawned by detached processes. i.e. + // - If process D is spawned by process C with `detached: true`, + // and process W is spawned by process D with `detached: false`, + // W will get a new black console window popped up. + // - If D is spawned by C with `detached: false` or W is spawned + // by D with `detached: true`, no console window will pop up for W. + // + // So, we have to spawn a detached process first to run the actual test. + const primary = child_process.spawn(process.argv[0], [process.argv[1], "--cluster"], { + detached: true, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + + const messageHandlers = { + workerOnline: common.mustCall(), + mainWindowHandle: common.mustCall(msg => { + assert.match(msg.value, /0\s*/); + }), + workerExit: common.mustCall(msg => { + assert.strictEqual(msg.code, 0); + assert.strictEqual(msg.signal, null); + }), + }; + + primary.on("message", msg => { + const handler = messageHandlers[msg.type]; + assert.ok(handler); + handler(msg); + }); + + primary.on( + "exit", + common.mustCall((code, signal) => { + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }), + ); +} else if (cluster.isPrimary) { + cluster.setupPrimary({ + silent: true, + windowsHide: true, + }); + + const worker = cluster.fork(); + worker.on("exit", (code, signal) => { + process.send({ type: "workerExit", code: code, signal: signal }); + }); + + worker.on("online", msg => { + process.send({ type: "workerOnline" }); + + let output = "0"; + if (process.platform === "win32") { + output = child_process.execSync( + "powershell -NoProfile -c " + `"(Get-Process -Id ${worker.process.pid}).MainWindowHandle"`, + { windowsHide: true, encoding: "utf8" }, + ); + } + + process.send({ type: "mainWindowHandle", value: output }); + worker.send("shutdown"); + }); +} else { + cluster.worker.on("message", msg => { + cluster.worker.disconnect(); + }); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-invalid-message.js b/test/js/node/cluster/upstream/parallel/test-cluster-invalid-message.js new file mode 100644 index 00000000000000..fdfe1ada6284e6 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-invalid-message.js @@ -0,0 +1,28 @@ +"use strict"; +const common = require("../common"); +const assert = require("assert"); +const cluster = require("cluster"); + +if (cluster.isPrimary) { + const worker = cluster.fork(); + + worker.on( + "exit", + common.mustCall((code, signal) => { + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + }), + ); + + worker.on("online", () => { + worker.send( + { + cmd: "NODE_CLUSTER", + ack: -1, + }, + () => { + worker.disconnect(); + }, + ); + }); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-kill-disconnect.js b/test/js/node/cluster/upstream/parallel/test-cluster-kill-disconnect.js new file mode 100644 index 00000000000000..e1c0a313e2d3ab --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-kill-disconnect.js @@ -0,0 +1,34 @@ +"use strict"; +const common = require("../common"); + +// Check that cluster works perfectly for both `kill` and `disconnect` cases. +// Also take into account that the `disconnect` event may be received after the +// `exit` event. +// https://github.com/nodejs/node/issues/3238 + +const assert = require("assert"); +const cluster = require("cluster"); + +if (cluster.isPrimary) { + function forkWorker(action) { + const worker = cluster.fork({ action }); + worker.on( + "disconnect", + common.mustCall(() => { + assert.strictEqual(worker.exitedAfterDisconnect, true); + }), + ); + + worker.on( + "exit", + common.mustCall(() => { + assert.strictEqual(worker.exitedAfterDisconnect, true); + }), + ); + } + + forkWorker("disconnect"); + forkWorker("kill"); +} else { + cluster.worker[process.env.action](); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-kill-infinite-loop.js b/test/js/node/cluster/upstream/parallel/test-cluster-kill-infinite-loop.js new file mode 100644 index 00000000000000..837b11f2a1f3fb --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-kill-infinite-loop.js @@ -0,0 +1,27 @@ +"use strict"; +const common = require("../common"); +const cluster = require("cluster"); +const assert = require("assert"); + +if (cluster.isPrimary) { + const worker = cluster.fork(); + + worker.on( + "online", + common.mustCall(() => { + // Use worker.process.kill() instead of worker.kill() because the latter + // waits for a graceful disconnect, which will never happen. + worker.process.kill(); + }), + ); + + worker.on( + "exit", + common.mustCall((code, signal) => { + assert.strictEqual(code, null); + assert.strictEqual(signal, "SIGTERM"); + }), + ); +} else { + while (true); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-listening-port.js b/test/js/node/cluster/upstream/parallel/test-cluster-listening-port.js new file mode 100644 index 00000000000000..ecf9398cd7a51d --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-listening-port.js @@ -0,0 +1,22 @@ +"use strict"; +const common = require("../common"); +const assert = require("assert"); +const cluster = require("cluster"); +const net = require("net"); + +if (cluster.isPrimary) { + cluster.fork(); + cluster.on( + "listening", + common.mustCall(function (worker, address) { + const port = address.port; + // Ensure that the port is not 0 or null + assert(port); + // Ensure that the port is numerical + assert.strictEqual(typeof port, "number"); + worker.kill(); + }), + ); +} else { + net.createServer(common.mustNotCall()).listen(0); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-primary-error.js b/test/js/node/cluster/upstream/parallel/test-cluster-primary-error.js new file mode 100644 index 00000000000000..763ae3eab3e531 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-primary-error.js @@ -0,0 +1,116 @@ +// 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 cluster = require("cluster"); + +const totalWorkers = 2; + +// Cluster setup +if (cluster.isWorker) { + const http = require("http"); + http.Server(() => {}).listen(0, "127.0.0.1"); +} else if (process.argv[2] === "cluster") { + // Send PID to testcase process + let forkNum = 0; + cluster.on( + "fork", + common.mustCall(function forkEvent(worker) { + // Send PID + process.send({ + cmd: "worker", + workerPID: worker.process.pid, + }); + + // Stop listening when done + if (++forkNum === totalWorkers) { + cluster.removeListener("fork", forkEvent); + } + }, totalWorkers), + ); + + // Throw accidental error when all workers are listening + let listeningNum = 0; + cluster.on( + "listening", + common.mustCall(function listeningEvent() { + // When all workers are listening + if (++listeningNum === totalWorkers) { + // Stop listening + cluster.removeListener("listening", listeningEvent); + + // Throw accidental error + process.nextTick(() => { + throw new Error("accidental error"); + }); + } + }, totalWorkers), + ); + + // Startup a basic cluster + cluster.fork(); + cluster.fork(); +} else { + // This is the testcase + + const fork = require("child_process").fork; + + // List all workers + const workers = []; + + // Spawn a cluster process + const primary = fork(process.argv[1], ["cluster"], { silent: true }); + + // Handle messages from the cluster + primary.on( + "message", + common.mustCall(data => { + // Add worker pid to list and progress tracker + if (data.cmd === "worker") { + workers.push(data.workerPID); + } + }, totalWorkers), + ); + + // When cluster is dead + primary.on( + "exit", + common.mustCall(code => { + // Check that the cluster died accidentally (non-zero exit code) + assert.strictEqual(code, 1); + + // XXX(addaleax): The fact that this uses raw PIDs makes the test inherently + // flaky – another process might end up being started right after the + // workers finished and receive the same PID. + const pollWorkers = () => { + // When primary is dead all workers should be dead too + if (workers.some(pid => common.isAlive(pid))) { + setTimeout(pollWorkers, 50); + } + }; + + // Loop indefinitely until worker exit + pollWorkers(); + }), + ); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-primary-kill.js b/test/js/node/cluster/upstream/parallel/test-cluster-primary-kill.js new file mode 100644 index 00000000000000..1a3a26f34dbdba --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-primary-kill.js @@ -0,0 +1,85 @@ +// 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 cluster = require("cluster"); + +if (cluster.isWorker) { + // Keep the worker alive + const http = require("http"); + http.Server().listen(0, "127.0.0.1"); +} else if (process.argv[2] === "cluster") { + const worker = cluster.fork(); + + // send PID info to testcase process + process.send({ + pid: worker.process.pid, + }); + + // Terminate the cluster process + worker.once( + "listening", + common.mustCall(() => { + setTimeout(() => { + process.exit(0); + }, 1000); + }), + ); +} else { + // This is the testcase + const fork = require("child_process").fork; + + // Spawn a cluster process + const primary = fork(process.argv[1], ["cluster"]); + + // get pid info + let pid = null; + primary.once("message", data => { + pid = data.pid; + }); + + // When primary is dead + let alive = true; + primary.on( + "exit", + common.mustCall(code => { + // Make sure that the primary died on purpose + assert.strictEqual(code, 0); + + // Check worker process status + const pollWorker = () => { + alive = common.isAlive(pid); + if (alive) { + setTimeout(pollWorker, 50); + } + }; + // Loop indefinitely until worker exit. + pollWorker(); + }), + ); + + process.once("exit", () => { + assert.strictEqual(typeof pid, "number", `got ${pid} instead of a worker pid`); + assert.strictEqual(alive, false, `worker was alive after primary died (alive = ${alive})`); + }); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-process-disconnect.js b/test/js/node/cluster/upstream/parallel/test-cluster-process-disconnect.js new file mode 100644 index 00000000000000..bcaf7df146a251 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-process-disconnect.js @@ -0,0 +1,24 @@ +"use strict"; +const common = require("../common"); +const assert = require("assert"); +const cluster = require("cluster"); + +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on( + "exit", + common.mustCall((code, signal) => { + assert.strictEqual(code, 0, `Worker did not exit normally with code: ${code}`); + assert.strictEqual(signal, null, `Worker did not exit normally with signal: ${signal}`); + }), + ); +} else { + const net = require("net"); + const server = net.createServer(); + server.listen( + 0, + common.mustCall(() => { + process.disconnect(); + }), + ); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-rr-domain-listen.js b/test/js/node/cluster/upstream/parallel/test-cluster-rr-domain-listen.js new file mode 100644 index 00000000000000..c48ed0c55cba21 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-rr-domain-listen.js @@ -0,0 +1,49 @@ +// 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 cluster = require("cluster"); +const domain = require("domain"); + +// RR is the default for v0.11.9+ so the following line is redundant: +// cluster.schedulingPolicy = cluster.SCHED_RR; + +if (cluster.isWorker) { + const d = domain.create(); + d.run(() => {}); + + const http = require("http"); + http.Server(() => {}).listen(0, "127.0.0.1"); +} else if (cluster.isPrimary) { + // Kill worker when listening + cluster.on("listening", function () { + worker.kill(); + }); + + // Kill process when worker is killed + cluster.on("exit", function () { + process.exit(0); + }); + + // Create worker + const worker = cluster.fork(); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-rr-handle-keep-loop-alive.js b/test/js/node/cluster/upstream/parallel/test-cluster-rr-handle-keep-loop-alive.js new file mode 100644 index 00000000000000..8bb183af337c5e --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-rr-handle-keep-loop-alive.js @@ -0,0 +1,26 @@ +"use strict"; + +const common = require("../common"); +const cluster = require("cluster"); +const net = require("net"); +const assert = require("assert"); + +cluster.schedulingPolicy = cluster.SCHED_RR; + +if (cluster.isPrimary) { + let exited = false; + const worker = cluster.fork(); + worker.on("exit", () => { + exited = true; + }); + setTimeout(() => { + assert.ok(!exited); + worker.kill(); + }, 3000); +} else { + const server = net.createServer(common.mustNotCall()); + server.listen( + 0, + common.mustCall(() => process.channel.unref()), + ); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-rr-ref.js b/test/js/node/cluster/upstream/parallel/test-cluster-rr-ref.js new file mode 100644 index 00000000000000..d5f0cbd083c507 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-rr-ref.js @@ -0,0 +1,20 @@ +"use strict"; + +const common = require("../common"); +const cluster = require("cluster"); +const net = require("net"); + +if (cluster.isPrimary) { + cluster.fork().on("message", function (msg) { + if (msg === "done") this.kill(); + }); +} else { + const server = net.createServer(common.mustNotCall()); + server.listen(0, function () { + server.unref(); + server.ref(); + server.close(function () { + process.send("done"); + }); + }); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-send-deadlock.js b/test/js/node/cluster/upstream/parallel/test-cluster-send-deadlock.js new file mode 100644 index 00000000000000..c5838a666c9635 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-send-deadlock.js @@ -0,0 +1,78 @@ +// 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"; +// Testing mutual send of handles: from primary to worker, and from worker to +// primary. + +require("../common"); +const assert = require("assert"); +const cluster = require("cluster"); +const net = require("net"); + +if (cluster.isPrimary) { + const worker = cluster.fork(); + worker.on("exit", (code, signal) => { + assert.strictEqual(code, 0, `Worker exited with an error code: ${code}`); + assert(!signal, `Worker exited by a signal: ${signal}`); + server.close(); + }); + + const server = net.createServer(socket => { + worker.send("handle", socket); + }); + + server.listen(0, () => { + worker.send({ message: "listen", port: server.address().port }); + }); +} else { + process.on("message", (msg, handle) => { + if (msg.message && msg.message === "listen") { + assert(msg.port); + const client1 = net.connect( + { + host: "localhost", + port: msg.port, + }, + () => { + const client2 = net.connect( + { + host: "localhost", + port: msg.port, + }, + () => { + client1.on("close", onclose); + client2.on("close", onclose); + client1.end(); + client2.end(); + }, + ); + }, + ); + let waiting = 2; + const onclose = () => { + if (--waiting === 0) cluster.worker.disconnect(); + }; + } else { + process.send("reply", handle); + } + }); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-setup-primary-argv.js b/test/js/node/cluster/upstream/parallel/test-cluster-setup-primary-argv.js new file mode 100644 index 00000000000000..8908aa7372fce2 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-setup-primary-argv.js @@ -0,0 +1,41 @@ +// 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 cluster = require("cluster"); + +setTimeout(common.mustNotCall("setup not emitted"), 1000).unref(); + +cluster.on( + "setup", + common.mustCall(function () { + const clusterArgs = cluster.settings.args; + const realArgs = process.argv; + assert.strictEqual(clusterArgs[clusterArgs.length - 1], realArgs[realArgs.length - 1]); + }), +); + +assert.notStrictEqual(process.argv[process.argv.length - 1], "OMG,OMG"); +process.argv.push("OMG,OMG"); +process.argv.push("OMG,OMG"); +cluster.setupPrimary(); diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-setup-primary-cumulative.js b/test/js/node/cluster/upstream/parallel/test-cluster-setup-primary-cumulative.js new file mode 100644 index 00000000000000..f9b43121fb4631 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-setup-primary-cumulative.js @@ -0,0 +1,62 @@ +// 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 cluster = require("cluster"); + +assert(cluster.isPrimary); + +// cluster.settings should not be initialized until needed +assert.deepStrictEqual(cluster.settings, {}); + +cluster.setupPrimary(); +assert.deepStrictEqual(cluster.settings, { + args: process.argv.slice(2), + exec: process.argv[1], + execArgv: process.execArgv, + silent: false, +}); +console.log("ok sets defaults"); + +cluster.setupPrimary({ exec: "overridden" }); +assert.strictEqual(cluster.settings.exec, "overridden"); +console.log("ok overrides defaults"); + +cluster.setupPrimary({ args: ["foo", "bar"] }); +assert.strictEqual(cluster.settings.exec, "overridden"); +assert.deepStrictEqual(cluster.settings.args, ["foo", "bar"]); + +cluster.setupPrimary({ execArgv: ["baz", "bang"] }); +assert.strictEqual(cluster.settings.exec, "overridden"); +assert.deepStrictEqual(cluster.settings.args, ["foo", "bar"]); +assert.deepStrictEqual(cluster.settings.execArgv, ["baz", "bang"]); +console.log("ok preserves unchanged settings on repeated calls"); + +cluster.setupPrimary(); +assert.deepStrictEqual(cluster.settings, { + args: ["foo", "bar"], + exec: "overridden", + execArgv: ["baz", "bang"], + silent: false, +}); +console.log("ok preserves current settings"); diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-setup-primary-emit.js b/test/js/node/cluster/upstream/parallel/test-cluster-setup-primary-emit.js new file mode 100644 index 00000000000000..305ebfced272b6 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-setup-primary-emit.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 assert = require("assert"); +const cluster = require("cluster"); + +assert(cluster.isPrimary); + +function emitAndCatch(next) { + cluster.once( + "setup", + common.mustCall(function (settings) { + assert.strictEqual(settings.exec, "new-exec"); + setImmediate(next); + }), + ); + cluster.setupPrimary({ exec: "new-exec" }); +} + +function emitAndCatch2(next) { + cluster.once( + "setup", + common.mustCall(function (settings) { + assert("exec" in settings); + setImmediate(next); + }), + ); + cluster.setupPrimary(); +} + +emitAndCatch( + common.mustCall(function () { + emitAndCatch2(common.mustCall()); + }), +); diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-setup-primary-multiple.js b/test/js/node/cluster/upstream/parallel/test-cluster-setup-primary-multiple.js new file mode 100644 index 00000000000000..381642cf586720 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-setup-primary-multiple.js @@ -0,0 +1,69 @@ +// 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 cluster = require("cluster"); +const debug = require("util").debuglog("test"); + +assert(cluster.isPrimary); + +// The cluster.settings object is cloned even though the current implementation +// makes that unnecessary. This is to make the test less fragile if the +// implementation ever changes such that cluster.settings is mutated instead of +// replaced. +const cheapClone = obj => JSON.parse(JSON.stringify(obj)); + +const configs = []; + +// Capture changes +cluster.on("setup", () => { + debug(`"setup" emitted ${JSON.stringify(cluster.settings)}`); + configs.push(cheapClone(cluster.settings)); +}); + +const execs = ["node-next", "node-next-2", "node-next-3"]; + +process.on("exit", () => { + // Tests that "setup" is emitted for every call to setupPrimary + assert.strictEqual(configs.length, execs.length); + + assert.strictEqual(configs[0].exec, execs[0]); + assert.strictEqual(configs[1].exec, execs[1]); + assert.strictEqual(configs[2].exec, execs[2]); +}); + +// Make changes to cluster settings +execs.forEach((v, i) => { + setTimeout(() => { + cluster.setupPrimary({ exec: v }); + }, i * 100); +}); + +// Cluster emits 'setup' asynchronously, so we must stay alive long +// enough for that to happen +setTimeout( + () => { + debug("cluster setup complete"); + }, + (execs.length + 1) * 100, +); diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-setup-primary.js b/test/js/node/cluster/upstream/parallel/test-cluster-setup-primary.js new file mode 100644 index 00000000000000..ccb103cf08fa4b --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-setup-primary.js @@ -0,0 +1,93 @@ +// 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 cluster = require("cluster"); + +if (cluster.isWorker) { + // Just keep the worker alive + process.send(process.argv[2]); +} else if (cluster.isPrimary) { + const checks = { + args: false, + setupEvent: false, + settingsObject: false, + }; + + const totalWorkers = 2; + let settings; + + // Setup primary + cluster.setupPrimary({ + args: ["custom argument"], + silent: true, + }); + + cluster.once("setup", function () { + checks.setupEvent = true; + + settings = cluster.settings; + if ( + settings && + settings.args && + settings.args[0] === "custom argument" && + settings.silent === true && + settings.exec === process.argv[1] + ) { + checks.settingsObject = true; + } + }); + + let correctInput = 0; + + cluster.on( + "online", + common.mustCall(function listener(worker) { + worker.once("message", function (data) { + correctInput += data === "custom argument" ? 1 : 0; + if (correctInput === totalWorkers) { + checks.args = true; + } + worker.kill(); + }); + }, totalWorkers), + ); + + // Start all workers + cluster.fork(); + cluster.fork(); + + // Check all values + process.once("exit", function () { + const argsMsg = + "Arguments was not send for one or more worker. " + + `${correctInput} workers receive argument, ` + + `but ${totalWorkers} were expected.`; + assert.ok(checks.args, argsMsg); + + assert.ok(checks.setupEvent, "The setup event was never emitted"); + + const settingObjectMsg = "The settingsObject do not have correct " + `properties : ${JSON.stringify(settings)}`; + assert.ok(checks.settingsObject, settingObjectMsg); + }); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-shared-handle-bind-privileged-port.js b/test/js/node/cluster/upstream/parallel/test-cluster-shared-handle-bind-privileged-port.js new file mode 100644 index 00000000000000..e69c79d69764d6 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-shared-handle-bind-privileged-port.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"); +if (common.isLinux) return; // TODO: bun + +// Skip on OS X Mojave. https://github.com/nodejs/node/issues/21679 +if (common.isOSX) common.skip("macOS may allow ordinary processes to use any port"); + +if (common.isIBMi) common.skip("IBMi may allow ordinary processes to use any port"); + +if (common.isWindows) common.skip("not reliable on Windows"); + +if (process.getuid() === 0) common.skip("as this test should not be run as `root`"); + +const assert = require("assert"); +const cluster = require("cluster"); +const net = require("net"); + +if (cluster.isPrimary) { + // Primary opens and binds the socket and shares it with the worker. + cluster.schedulingPolicy = cluster.SCHED_NONE; + cluster.fork().on( + "exit", + common.mustCall(function (exitCode) { + assert.strictEqual(exitCode, 0); + }), + ); +} else { + const s = net.createServer(common.mustNotCall()); + s.listen(42, common.mustNotCall("listen should have failed")); + s.on( + "error", + common.mustCall(function (err) { + assert.strictEqual(err.code, "EACCES"); + process.disconnect(); + }), + ); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-uncaught-exception.js b/test/js/node/cluster/upstream/parallel/test-cluster-uncaught-exception.js new file mode 100644 index 00000000000000..ee1dee617e4860 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-uncaught-exception.js @@ -0,0 +1,56 @@ +// 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"; +// Installing a custom uncaughtException handler should override the default +// one that the cluster module installs. +// https://github.com/joyent/node/issues/2556 + +const common = require("../common"); +const assert = require("assert"); +const cluster = require("cluster"); +const fork = require("child_process").fork; + +const MAGIC_EXIT_CODE = 42; + +const isTestRunner = process.argv[2] !== "child"; + +if (isTestRunner) { + const primary = fork(__filename, ["child"]); + primary.on( + "exit", + common.mustCall(code => { + assert.strictEqual(code, MAGIC_EXIT_CODE); + }), + ); +} else if (cluster.isPrimary) { + process.on( + "uncaughtException", + common.mustCall(() => { + process.nextTick(() => process.exit(MAGIC_EXIT_CODE)); + }), + ); + cluster.fork(); + throw new Error("kill primary"); +} else { + // worker + process.exit(); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-worker-constructor.js b/test/js/node/cluster/upstream/parallel/test-cluster-worker-constructor.js new file mode 100644 index 00000000000000..904eb2e2ab6098 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-worker-constructor.js @@ -0,0 +1,49 @@ +// 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"; +// test-cluster-worker-constructor.js +// validates correct behavior of the cluster.Worker constructor + +require("../common"); +const assert = require("assert"); +const cluster = require("cluster"); +let worker; + +worker = new cluster.Worker(); +assert.strictEqual(worker.exitedAfterDisconnect, undefined); +assert.strictEqual(worker.state, "none"); +assert.strictEqual(worker.id, 0); +assert.strictEqual(worker.process, undefined); + +worker = new cluster.Worker({ + id: 3, + state: "online", + process: process, +}); +assert.strictEqual(worker.exitedAfterDisconnect, undefined); +assert.strictEqual(worker.state, "online"); +assert.strictEqual(worker.id, 3); +assert.strictEqual(worker.process, process); + +worker = cluster.Worker.call({}, { id: 5 }); +assert(worker instanceof cluster.Worker); +assert.strictEqual(worker.id, 5); diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-worker-death.js b/test/js/node/cluster/upstream/parallel/test-cluster-worker-death.js new file mode 100644 index 00000000000000..bab5c8df8a1364 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-worker-death.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 cluster = require("cluster"); + +if (!cluster.isPrimary) { + process.exit(42); +} else { + const worker = cluster.fork(); + worker.on( + "exit", + common.mustCall(function (exitCode, signalCode) { + assert.strictEqual(exitCode, 42); + assert.strictEqual(signalCode, null); + }), + ); + cluster.on( + "exit", + common.mustCall(function (worker_) { + assert.strictEqual(worker_, worker); + }), + ); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-worker-destroy.js b/test/js/node/cluster/upstream/parallel/test-cluster-worker-destroy.js new file mode 100644 index 00000000000000..ebffb4fb04d746 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-worker-destroy.js @@ -0,0 +1,54 @@ +// 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"; + +// The goal of this test is to cover the Workers' implementation of +// Worker.prototype.destroy. Worker.prototype.destroy is called within +// the worker's context: once when the worker is still connected to the +// primary, and another time when it's not connected to it, so that we cover +// both code paths. + +const common = require("../common"); +const assert = require("assert"); +const cluster = require("cluster"); +let worker1, worker2; + +if (cluster.isPrimary) { + worker1 = cluster.fork(); + worker2 = cluster.fork(); + + [worker1, worker2].forEach(function (worker) { + worker.on("disconnect", common.mustCall()); + worker.on("exit", common.mustCall()); + }); +} else if (cluster.worker.id === 1) { + // Call destroy when worker is disconnected + cluster.worker.process.on("disconnect", function () { + cluster.worker.destroy(); + }); + + const w = cluster.worker.disconnect(); + assert.strictEqual(w, cluster.worker); +} else { + // Call destroy when worker is not disconnected yet + cluster.worker.destroy(); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-worker-disconnect-on-error.js b/test/js/node/cluster/upstream/parallel/test-cluster-worker-disconnect-on-error.js new file mode 100644 index 00000000000000..f9e3a0de2c1b74 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-worker-disconnect-on-error.js @@ -0,0 +1,44 @@ +"use strict"; +const common = require("../common"); +const http = require("http"); +const cluster = require("cluster"); +const assert = require("assert"); + +cluster.schedulingPolicy = cluster.SCHED_NONE; + +const server = http.createServer(); +if (cluster.isPrimary) { + let worker; + + server.listen( + 0, + common.mustSucceed(() => { + assert(worker); + + worker.send({ port: server.address().port }); + }), + ); + + worker = cluster.fork(); + worker.on( + "exit", + common.mustCall(() => { + server.close(); + }), + ); +} else { + process.on( + "message", + common.mustCall(msg => { + assert(msg.port); + + server.listen(msg.port); + server.on( + "error", + common.mustCall(e => { + cluster.worker.disconnect(); + }), + ); + }), + ); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-worker-disconnect.js b/test/js/node/cluster/upstream/parallel/test-cluster-worker-disconnect.js new file mode 100644 index 00000000000000..35cae334d97808 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-worker-disconnect.js @@ -0,0 +1,118 @@ +// 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 cluster = require("cluster"); + +if (cluster.isWorker) { + const http = require("http"); + http.Server(() => {}).listen(0, "127.0.0.1"); + + cluster.worker.on( + "disconnect", + common.mustCall(() => { + process.exit(42); + }), + ); +} else if (cluster.isPrimary) { + const checks = { + cluster: { + emitDisconnect: false, + emitExit: false, + callback: false, + }, + worker: { + emitDisconnect: false, + emitDisconnectInsideWorker: false, + emitExit: false, + state: false, + voluntaryMode: false, + died: false, + }, + }; + + // start worker + const worker = cluster.fork(); + + // Disconnect worker when it is ready + worker.once( + "listening", + common.mustCall(() => { + const w = worker.disconnect(); + assert.strictEqual(worker, w, `${worker.id} did not return a reference`); + }), + ); + + // Check cluster events + cluster.once( + "disconnect", + common.mustCall(() => { + checks.cluster.emitDisconnect = true; + }), + ); + cluster.once( + "exit", + common.mustCall(() => { + checks.cluster.emitExit = true; + }), + ); + + // Check worker events and properties + worker.once( + "disconnect", + common.mustCall(() => { + checks.worker.emitDisconnect = true; + checks.worker.voluntaryMode = worker.exitedAfterDisconnect; + checks.worker.state = worker.state; + }), + ); + + // Check that the worker died + worker.once( + "exit", + common.mustCall(code => { + checks.worker.emitExit = true; + checks.worker.died = !common.isAlive(worker.process.pid); + checks.worker.emitDisconnectInsideWorker = code === 42; + }), + ); + + process.once("exit", () => { + const w = checks.worker; + const c = checks.cluster; + + // events + assert.ok(w.emitDisconnect, "Disconnect event did not emit"); + assert.ok(w.emitDisconnectInsideWorker, "Disconnect event did not emit inside worker"); + assert.ok(c.emitDisconnect, "Disconnect event did not emit"); + assert.ok(w.emitExit, "Exit event did not emit"); + assert.ok(c.emitExit, "Exit event did not emit"); + + // flags + assert.strictEqual(w.state, "disconnected"); + assert.strictEqual(w.voluntaryMode, true); + + // is process alive + assert.ok(w.died, "The worker did not die"); + }); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-worker-events.js b/test/js/node/cluster/upstream/parallel/test-cluster-worker-events.js new file mode 100644 index 00000000000000..aaf355a5815f92 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-worker-events.js @@ -0,0 +1,78 @@ +// 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 cluster = require("cluster"); + +const OK = 2; + +if (cluster.isPrimary) { + const worker = cluster.fork(); + + worker.on("exit", code => { + assert.strictEqual(code, OK); + process.exit(0); + }); + + const result = worker.send("SOME MESSAGE"); + assert.strictEqual(result, true); + + return; +} + +// Messages sent to a worker will be emitted on both the process object and the +// process.worker object. + +assert(cluster.isWorker); + +let sawProcess; +let sawWorker; + +const messages = []; + +const check = m => { + messages.push(m); + + if (messages.length < 2) return; + + assert.deepStrictEqual(messages[0], messages[1]); + + cluster.worker.once("error", e => { + assert.strictEqual(e, "HI"); + process.exit(OK); + }); + + process.emit("error", "HI"); +}; + +process.on("message", m => { + assert(!sawProcess); + sawProcess = true; + check(m); +}); + +cluster.worker.on("message", m => { + assert(!sawWorker); + sawWorker = true; + check(m); +}); diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-worker-exit.js b/test/js/node/cluster/upstream/parallel/test-cluster-worker-exit.js new file mode 100644 index 00000000000000..e6e61ca6042ae3 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-worker-exit.js @@ -0,0 +1,142 @@ +// 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"; +// test-cluster-worker-exit.js +// verifies that, when a child process exits (by calling `process.exit(code)`) +// - the primary receives the proper events in the proper order, no duplicates +// - the exitCode and signalCode are correct in the 'exit' event +// - the worker.exitedAfterDisconnect flag, and worker.state are correct +// - the worker process actually goes away + +const common = require("../common"); +const assert = require("assert"); +const cluster = require("cluster"); + +const EXIT_CODE = 42; + +if (cluster.isWorker) { + const http = require("http"); + const server = http.Server(() => {}); + + server.once( + "listening", + common.mustCall(() => { + process.exit(EXIT_CODE); + }), + ); + server.listen(0, "127.0.0.1"); +} else if (cluster.isPrimary) { + const expected_results = { + cluster_emitDisconnect: [1, "the cluster did not emit 'disconnect'"], + cluster_emitExit: [1, "the cluster did not emit 'exit'"], + cluster_exitCode: [EXIT_CODE, "the cluster exited w/ incorrect exitCode"], + cluster_signalCode: [null, "the cluster exited w/ incorrect signalCode"], + worker_emitDisconnect: [1, "the worker did not emit 'disconnect'"], + worker_emitExit: [1, "the worker did not emit 'exit'"], + worker_state: ["disconnected", "the worker state is incorrect"], + worker_exitedAfterDisconnect: [false, "the .exitedAfterDisconnect flag is incorrect"], + worker_died: [true, "the worker is still running"], + worker_exitCode: [EXIT_CODE, "the worker exited w/ incorrect exitCode"], + worker_signalCode: [null, "the worker exited w/ incorrect signalCode"], + }; + const results = { + cluster_emitDisconnect: 0, + cluster_emitExit: 0, + worker_emitDisconnect: 0, + worker_emitExit: 0, + }; + + // start worker + const worker = cluster.fork(); + + // Check cluster events + cluster.on( + "disconnect", + common.mustCall(() => { + results.cluster_emitDisconnect += 1; + }), + ); + cluster.on( + "exit", + common.mustCall(worker => { + results.cluster_exitCode = worker.process.exitCode; + results.cluster_signalCode = worker.process.signalCode; + results.cluster_emitExit += 1; + }), + ); + + // Check worker events and properties + worker.on( + "disconnect", + common.mustCall(() => { + results.worker_emitDisconnect += 1; + results.worker_exitedAfterDisconnect = worker.exitedAfterDisconnect; + results.worker_state = worker.state; + if (results.worker_emitExit > 0) { + process.nextTick(() => finish_test()); + } + }), + ); + + // Check that the worker died + worker.once( + "exit", + common.mustCall((exitCode, signalCode) => { + results.worker_exitCode = exitCode; + results.worker_signalCode = signalCode; + results.worker_emitExit += 1; + results.worker_died = !common.isAlive(worker.process.pid); + if (results.worker_emitDisconnect > 0) { + process.nextTick(() => finish_test()); + } + }), + ); + + const finish_test = () => { + try { + checkResults(expected_results, results); + } catch (exc) { + if (exc.name !== "AssertionError") { + console.trace(exc); + } + + process.exit(1); + return; + } + process.exit(0); + }; +} + +// Some helper functions ... + +function checkResults(expected_results, results) { + for (const k in expected_results) { + const actual = results[k]; + const expected = expected_results[k]; + + assert.strictEqual( + actual, + expected && expected.length ? expected[0] : expected, + `${expected[1] || ""} [expected: ${expected[0]} / actual: ${actual}]`, + ); + } +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-worker-forced-exit.js b/test/js/node/cluster/upstream/parallel/test-cluster-worker-forced-exit.js new file mode 100644 index 00000000000000..901868973d4b3e --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-worker-forced-exit.js @@ -0,0 +1,75 @@ +// 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 cluster = require("cluster"); + +const SENTINEL = 42; + +// Workers forcibly exit when control channel is disconnected, if +// their .exitedAfterDisconnect flag isn't set +// +// test this by: +// +// 1 setup worker to wait a short time after disconnect, and exit +// with a sentinel value +// 2 disconnect worker with cluster's disconnect, confirm sentinel +// 3 disconnect worker with child_process's disconnect, confirm +// no sentinel value +if (cluster.isWorker) { + process.on("disconnect", msg => { + setTimeout(() => process.exit(SENTINEL), 10); + }); + return; +} + +checkUnforced(); +checkForced(); + +function checkUnforced() { + const worker = cluster.fork(); + worker + .on( + "online", + common.mustCall(() => worker.disconnect()), + ) + .on( + "exit", + common.mustCall(status => { + assert.strictEqual(status, SENTINEL); + }), + ); +} + +function checkForced() { + const worker = cluster.fork(); + worker + .on( + "online", + common.mustCall(() => worker.process.disconnect()), + ) + .on( + "exit", + common.mustCall(status => assert.strictEqual(status, 0)), + ); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-worker-init.js b/test/js/node/cluster/upstream/parallel/test-cluster-worker-init.js new file mode 100644 index 00000000000000..f2dafdf66d96be --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-worker-init.js @@ -0,0 +1,52 @@ +// 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"; +// test-cluster-worker-init.js +// verifies that, when a child process is forked, the cluster.worker +// object can receive messages as expected + +const common = require("../common"); +const assert = require("assert"); +const cluster = require("cluster"); +const msg = "foo"; + +if (cluster.isPrimary) { + const worker = cluster.fork(); + + worker.on( + "message", + common.mustCall(message => { + assert.strictEqual(message, true); + const w = worker.disconnect(); + assert.strictEqual(worker, w); + }), + ); + + worker.on("online", () => { + worker.send(msg); + }); +} else { + // https://github.com/nodejs/node-v0.x-archive/issues/7998 + cluster.worker.on("message", message => { + process.send(message === msg); + }); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-worker-isdead.js b/test/js/node/cluster/upstream/parallel/test-cluster-worker-isdead.js new file mode 100644 index 00000000000000..079a154443e467 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-worker-isdead.js @@ -0,0 +1,34 @@ +"use strict"; +require("../common"); +const cluster = require("cluster"); +const assert = require("assert"); + +if (cluster.isPrimary) { + const worker = cluster.fork(); + let workerDead = worker.isDead(); + assert.ok( + !workerDead, + `isDead() returned ${workerDead}. isDead() should return ` + "false right after the worker has been created.", + ); + + worker.on("exit", function () { + workerDead = worker.isDead(); + assert.ok( + workerDead, + `isDead() returned ${workerDead}. After an event has been ` + "emitted, isDead should return true", + ); + }); + + worker.on("message", function (msg) { + if (msg === "readyToDie") { + worker.kill(); + } + }); +} else if (cluster.isWorker) { + const workerDead = cluster.worker.isDead(); + assert.ok( + !workerDead, + `isDead() returned ${workerDead}. isDead() should return ` + "false when called from within a worker", + ); + process.send("readyToDie"); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-worker-kill-signal.js b/test/js/node/cluster/upstream/parallel/test-cluster-worker-kill-signal.js new file mode 100644 index 00000000000000..1562a5e9f38fad --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-worker-kill-signal.js @@ -0,0 +1,56 @@ +"use strict"; +// test-cluster-worker-kill-signal.js +// verifies that when we're killing a worker using Worker.prototype.kill +// and the worker's process was killed with the given signal (SIGKILL) + +const common = require("../common"); +const assert = require("assert"); +const cluster = require("cluster"); + +if (cluster.isWorker) { + // Make the worker run something + const http = require("http"); + const server = http.Server(() => {}); + + server.once("listening", common.mustCall()); + server.listen(0, "127.0.0.1"); +} else if (cluster.isMaster) { + const KILL_SIGNAL = "SIGKILL"; + + // Start worker + const worker = cluster.fork(); + + // When the worker is up and running, kill it + worker.once( + "listening", + common.mustCall(() => { + worker.kill(KILL_SIGNAL); + }), + ); + + // Check worker events and properties + worker.on( + "disconnect", + common.mustCall(() => { + assert.strictEqual(worker.exitedAfterDisconnect, false); + assert.strictEqual(worker.state, "disconnected"); + }, 1), + ); + + // Check that the worker died + worker.once( + "exit", + common.mustCall((exitCode, signalCode) => { + const isWorkerProcessStillAlive = common.isAlive(worker.process.pid); + const numOfRunningWorkers = Object.keys(cluster.workers).length; + + assert.strictEqual(exitCode, null); + assert.strictEqual(signalCode, KILL_SIGNAL); + assert.strictEqual(isWorkerProcessStillAlive, false); + assert.strictEqual(numOfRunningWorkers, 0); + }, 1), + ); + + // Check if the cluster was killed as well + cluster.on("exit", common.mustCall(1)); +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-worker-kill.js b/test/js/node/cluster/upstream/parallel/test-cluster-worker-kill.js new file mode 100644 index 00000000000000..1ba588b8740b6f --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-worker-kill.js @@ -0,0 +1,126 @@ +// 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"; +// test-cluster-worker-kill.js +// verifies that, when a child process is killed (we use SIGKILL) +// - the primary receives the proper events in the proper order, no duplicates +// - the exitCode and signalCode are correct in the 'exit' event +// - the worker.exitedAfterDisconnect flag, and worker.state are correct +// - the worker process actually goes away + +const common = require("../common"); +const assert = require("assert"); +const cluster = require("cluster"); + +if (cluster.isWorker) { + const http = require("http"); + const server = http.Server(() => {}); + server.once("listening", common.mustCall()); + server.listen(0, "127.0.0.1"); +} else if (cluster.isPrimary) { + const KILL_SIGNAL = "SIGKILL"; + const expected_results = { + cluster_emitDisconnect: [1, "the cluster did not emit 'disconnect'"], + cluster_emitExit: [1, "the cluster did not emit 'exit'"], + cluster_exitCode: [null, "the cluster exited w/ incorrect exitCode"], + cluster_signalCode: [KILL_SIGNAL, "the cluster exited w/ incorrect signalCode"], + worker_emitDisconnect: [1, "the worker did not emit 'disconnect'"], + worker_emitExit: [1, "the worker did not emit 'exit'"], + worker_state: ["disconnected", "the worker state is incorrect"], + worker_exitedAfter: [false, "the .exitedAfterDisconnect flag is incorrect"], + worker_died: [true, "the worker is still running"], + worker_exitCode: [null, "the worker exited w/ incorrect exitCode"], + worker_signalCode: [KILL_SIGNAL, "the worker exited w/ incorrect signalCode"], + }; + const results = { + cluster_emitDisconnect: 0, + cluster_emitExit: 0, + worker_emitDisconnect: 0, + worker_emitExit: 0, + }; + + // start worker + const worker = cluster.fork(); + // When the worker is up and running, kill it + worker.once( + "listening", + common.mustCall(() => { + worker.process.kill(KILL_SIGNAL); + }), + ); + + // Check cluster events + cluster.on( + "disconnect", + common.mustCall(() => { + results.cluster_emitDisconnect += 1; + }), + ); + cluster.on( + "exit", + common.mustCall(worker => { + results.cluster_exitCode = worker.process.exitCode; + results.cluster_signalCode = worker.process.signalCode; + results.cluster_emitExit += 1; + }), + ); + + // Check worker events and properties + worker.on( + "disconnect", + common.mustCall(() => { + results.worker_emitDisconnect += 1; + results.worker_exitedAfter = worker.exitedAfterDisconnect; + results.worker_state = worker.state; + }), + ); + + // Check that the worker died + worker.once( + "exit", + common.mustCall((exitCode, signalCode) => { + results.worker_exitCode = exitCode; + results.worker_signalCode = signalCode; + results.worker_emitExit += 1; + results.worker_died = !common.isAlive(worker.process.pid); + }), + ); + + process.on("exit", () => { + checkResults(expected_results, results); + }); +} + +// Some helper functions ... + +function checkResults(expected_results, results) { + for (const k in expected_results) { + const actual = results[k]; + const expected = expected_results[k]; + + assert.strictEqual( + actual, + expected && expected.length ? expected[0] : expected, + `${expected[1] || ""} [expected: ${expected[0]} / actual: ${actual}]`, + ); + } +} diff --git a/test/js/node/cluster/upstream/parallel/test-cluster-worker-no-exit.js b/test/js/node/cluster/upstream/parallel/test-cluster-worker-no-exit.js new file mode 100644 index 00000000000000..8dcfc45f2c1b60 --- /dev/null +++ b/test/js/node/cluster/upstream/parallel/test-cluster-worker-no-exit.js @@ -0,0 +1,79 @@ +// 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"); +if (common.isOSX) return; // TODO: bun +if (common.isLinux) return; // TODO: bun +if (common.isWindows) return; // TODO: bun +const assert = require("assert"); +const cluster = require("cluster"); +const net = require("net"); + +let destroyed; +let success; +let worker; +let server; + +// Workers do not exit on disconnect, they exit under normal node rules: when +// they have nothing keeping their loop alive, like an active connection +// +// test this by: +// +// 1 creating a server, so worker can make a connection to something +// 2 disconnecting worker +// 3 wait to confirm it did not exit +// 4 destroy connection +// 5 confirm it does exit +if (cluster.isPrimary) { + server = net + .createServer(function (conn) { + server.close(); + worker.disconnect(); + worker + .once("disconnect", function () { + setTimeout(function () { + conn.destroy(); + destroyed = true; + }, 1000); + }) + .once("exit", function () { + // Worker should not exit while it has a connection + assert(destroyed, "worker exited before connection destroyed"); + success = true; + }); + }) + .listen(0, function () { + const port = this.address().port; + + worker = cluster.fork().on("online", function () { + this.send({ port }); + }); + }); + process.on("exit", function () { + assert(success); + }); +} else { + process.on("message", function (msg) { + // We shouldn't exit, not while a network connection exists + net.connect(msg.port); + }); +} diff --git a/test/js/third_party/socket.io/socket.io-close.test.ts b/test/js/third_party/socket.io/socket.io-close.test.ts index feac0fbad1c97b..de20474f753d1c 100644 --- a/test/js/third_party/socket.io/socket.io-close.test.ts +++ b/test/js/third_party/socket.io/socket.io-close.test.ts @@ -117,17 +117,18 @@ describe("close", () => { }); describe("protocol violations", () => { - it("should close the connection when receiving several CONNECT packets", done => { + it("should close the connection when receiving several CONNECT packets", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); const httpServer = createServer(); const io = new Server(httpServer); httpServer.listen(0); let timeout = setTimeout(() => { - fail(done, io, new Error("timeout")); + fail(reject, io, new Error("timeout")); }, 1500); - (async () => { + await (async () => { const sid = await eioHandshake(httpServer); // send a first CONNECT packet await eioPush(httpServer, sid, "40"); @@ -144,20 +145,22 @@ describe("close", () => { expect(body).toBe("6\u001e1"); io.close(); - success(done, io); + success(resolve, io); } catch (err) { - fail(done, io, err); + fail(reject, io, err); } + return promise; }); }); - it("should close the connection when receiving an EVENT packet while not connected", done => { + it("should close the connection when receiving an EVENT packet while not connected", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); const httpServer = createServer(); const io = new Server(httpServer); httpServer.listen(0); let timeout = setTimeout(() => { - fail(done, io, new Error("timeout")); + fail(reject, io, new Error("timeout")); }, 1500); (async () => { @@ -172,10 +175,11 @@ describe("close", () => { expect(body).toBe("6\u001e1"); io.close(); - success(done, io); + success(resolve, io); } catch (err) { - fail(done, io, err); + fail(reject, io, err); } + return promise; }); }); diff --git a/test/regression/issue/11297/11297.test.ts b/test/regression/issue/11297/11297.test.ts index a068b9b07d0f5f..785c11790d3cb2 100644 --- a/test/regression/issue/11297/11297.test.ts +++ b/test/regression/issue/11297/11297.test.ts @@ -1,5 +1,6 @@ import { test, expect } from "bun:test"; import { join } from "path"; +import "harness"; test("issue #11297", async () => { expect([join(import.meta.dir, "./11297.fixture.ts")]).toRun();