From 103bab99d580345af8b7e5a1f2d0cc318a36b9ae Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 5 Aug 2026 22:40:06 -0300 Subject: [PATCH] fix(cli): prefer IPv4 DNS for spawned servers (#9209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates — only pre-existing audit.test.ts flake). --- bin/cli/commands/serve.mjs | 6 +- bin/cli/runtime/processSupervisor.mjs | 7 +- changelog.d/fixes/9209-cli-ipv4-first-dns.md | 1 + scripts/build/runtime-env.mjs | 14 ++++ tests/unit/cli-ipv4-first-dns-2699.test.ts | 81 ++++++++++++++++++++ 5 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 changelog.d/fixes/9209-cli-ipv4-first-dns.md create mode 100644 tests/unit/cli-ipv4-first-dns-2699.test.ts diff --git a/bin/cli/commands/serve.mjs b/bin/cli/commands/serve.mjs index b68fc727c4..4ed5ac55cf 100644 --- a/bin/cli/commands/serve.mjs +++ b/bin/cli/commands/serve.mjs @@ -16,7 +16,7 @@ import { resolveMaxOldSpaceMb, calibrateHeapFallbackMb, buildServerNodeOptions, - buildNodeHeapArgs, + buildNodeRuntimeArgs, } from "../../../scripts/build/runtime-env.mjs"; import { resolveTlsOptions } from "../../../scripts/dev/tls-options.mjs"; @@ -269,7 +269,7 @@ function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) { // heap via NODE_OPTIONS (a CLI arg would shadow/override their value). const server = spawn( process.versions.bun ? process.execPath : "node", - [...(process.versions.bun ? [] : buildNodeHeapArgs(process.env, memoryLimit)), serverJs], + process.versions.bun ? [serverJs] : buildNodeRuntimeArgs(process.env, memoryLimit, serverJs), { cwd: APP_DIR, env, @@ -289,7 +289,7 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, // heap via NODE_OPTIONS (a CLI arg would shadow/override their value). const server = spawn( process.versions.bun ? process.execPath : "node", - [...(process.versions.bun ? [] : buildNodeHeapArgs(process.env, memoryLimit)), serverJs], + process.versions.bun ? [serverJs] : buildNodeRuntimeArgs(process.env, memoryLimit, serverJs), { cwd: APP_DIR, env, diff --git a/bin/cli/runtime/processSupervisor.mjs b/bin/cli/runtime/processSupervisor.mjs index 3c79bd213c..7277f9de67 100644 --- a/bin/cli/runtime/processSupervisor.mjs +++ b/bin/cli/runtime/processSupervisor.mjs @@ -8,7 +8,7 @@ import { computeRestartDelayMs, waitUntilPortFree, } from "./supervisorPolicy.mjs"; -import { buildNodeHeapArgs } from "../../../scripts/build/runtime-env.mjs"; +import { buildNodeRuntimeArgs } from "../../../scripts/build/runtime-env.mjs"; import { stopProcessGracefully } from "../../../src/shared/platform/windowsProcess.ts"; import { isFatalInstrumentationHookFailure, @@ -47,7 +47,6 @@ export class ServerSupervisor { // #5238: skip the explicit CLI --max-old-space-size when the user pinned the // heap via NODE_OPTIONS (a CLI arg would shadow/override their value). The // calibrated heap is already carried by env.NODE_OPTIONS either way. - const heapArgs = buildNodeHeapArgs(process.env, this.memoryLimit); // #6321: stdout used to be discarded (`"ignore"`) whenever `--log`/OMNIROUTE_SHOW_LOG // wasn't set (the default) — any debug/pino output written to stdout vanished // silently, so a boot that never becomes ready looked like a dead hang with zero @@ -55,7 +54,9 @@ export class ServerSupervisor { // stderr so a readiness timeout can surface what the child actually printed. this.child = spawn( process.versions.bun ? process.execPath : "node", - [...(process.versions.bun ? [] : heapArgs), this.serverPath], + process.versions.bun + ? [this.serverPath] + : buildNodeRuntimeArgs(process.env, this.memoryLimit, this.serverPath), { cwd: dirname(this.serverPath), env: this.env, diff --git a/changelog.d/fixes/9209-cli-ipv4-first-dns.md b/changelog.d/fixes/9209-cli-ipv4-first-dns.md new file mode 100644 index 0000000000..b0e602eed6 --- /dev/null +++ b/changelog.d/fixes/9209-cli-ipv4-first-dns.md @@ -0,0 +1 @@ +- **fix(cli):** prefer IPv4 DNS for spawned Node servers. (thanks @dsitmilis) diff --git a/scripts/build/runtime-env.mjs b/scripts/build/runtime-env.mjs index fb519773f0..d8dbe45765 100644 --- a/scripts/build/runtime-env.mjs +++ b/scripts/build/runtime-env.mjs @@ -86,6 +86,20 @@ export function buildNodeHeapArgs(env = process.env, memoryLimit) { return envHasExplicitHeapFlag(env) ? [] : [`${MAX_OLD_SPACE_FLAG}=${memoryLimit}`]; } +/** + * Build the complete argument list for spawning the Node.js server runtime. + * Prefer IPv4 DNS results before starting the application so undici does not + * stall on hosts whose IPv6 route silently drops outbound connections. + * + * @param {NodeJS.ProcessEnv | Record} [env] + * @param {number} memoryLimit — calibrated V8 heap ceiling (MB) + * @param {string} serverPath — standalone server entrypoint + * @returns {string[]} + */ +export function buildNodeRuntimeArgs(env = process.env, memoryLimit, serverPath) { + return ["--dns-result-order=ipv4first", ...buildNodeHeapArgs(env, memoryLimit), serverPath]; +} + /** * @param {NodeJS.ProcessEnv | Record} [fromEnv] * Defaults to process.env. Pass bootstrap `merged` so project `.env` PORT applies before spawn. diff --git a/tests/unit/cli-ipv4-first-dns-2699.test.ts b/tests/unit/cli-ipv4-first-dns-2699.test.ts new file mode 100644 index 0000000000..30aa2cd297 --- /dev/null +++ b/tests/unit/cli-ipv4-first-dns-2699.test.ts @@ -0,0 +1,81 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createRequire, syncBuiltinESMExports } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { buildNodeRuntimeArgs } from "../../scripts/build/runtime-env.mjs"; + +const require = createRequire(import.meta.url); +const childProcess = require("node:child_process"); +const originalSpawn = childProcess.spawn; + +test.afterEach(() => { + childProcess.spawn = originalSpawn; + syncBuiltinESMExports(); +}); + +test("Node server runtime prefers IPv4 DNS before the server entrypoint", () => { + assert.deepEqual(buildNodeRuntimeArgs({}, 2048, "/app/server.js"), [ + "--dns-result-order=ipv4first", + "--max-old-space-size=2048", + "/app/server.js", + ]); +}); + +test("Node server runtime preserves an explicit heap setting while preferring IPv4", () => { + assert.deepEqual( + buildNodeRuntimeArgs( + { NODE_OPTIONS: "--enable-source-maps --max-old-space-size=8192" }, + 512, + "/app/server.js" + ), + ["--dns-result-order=ipv4first", "/app/server.js"] + ); +}); + +test("ServerSupervisor starts Node with IPv4-first DNS", async () => { + const spawnCalls: Array<{ command: string; args: string[] }> = []; + const child = Object.assign(new EventEmitter(), { + pid: 2699, + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }); + childProcess.spawn = (command: string, args: string[]) => { + spawnCalls.push({ command, args }); + return child; + }; + syncBuiltinESMExports(); + + const dataDir = mkdtempSync(join(tmpdir(), "omniroute-ipv4-first-")); + const previousDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = dataDir; + + try { + const moduleUrl = pathToFileURL( + join(process.cwd(), "bin/cli/runtime/processSupervisor.mjs") + ).href; + const { ServerSupervisor } = await import(`${moduleUrl}?ipv4-first=${Date.now()}`); + const supervisor = new ServerSupervisor({ + serverPath: "/app/server.js", + env: {}, + memoryLimit: 2048, + }); + + supervisor.start(); + + assert.deepEqual(spawnCalls, [ + { + command: "node", + args: ["--dns-result-order=ipv4first", "--max-old-space-size=2048", "/app/server.js"], + }, + ]); + } finally { + if (previousDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = previousDataDir; + rmSync(dataDir, { recursive: true, force: true }); + } +});