diff --git a/changelog.d/fixes/pack-boot-runtimetimeouts-sibling.md b/changelog.d/fixes/pack-boot-runtimetimeouts-sibling.md new file mode 100644 index 00000000000..3fd32f4f029 --- /dev/null +++ b/changelog.d/fixes/pack-boot-runtimetimeouts-sibling.md @@ -0,0 +1 @@ +- **Build**: the packed tarball boots again — #7191's `../../src/…runtimeTimeouts.ts` import in `standalone-server-ws.mjs` escaped the package after the dist-root copy (`ERR_MODULE_NOT_FOUND` on every boot, #7065 class, caught live by the new `check:pack-boot` gate); the helper now lives in the shipped sibling `main-server-timeouts.mjs` (parity-tested against the canonical TS implementation) and the closure test bans package-escaping `../` imports in npm-shipped wrappers diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index b0d8892b274..7afe168b29a 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -135,6 +135,11 @@ const EXTRA_MODULE_ENTRIES = [ src: ["scripts", "dev", "peer-stamp.mjs"], dest: ["peer-stamp.mjs"], }, + { + label: "main-server timeouts (server-ws.mjs dependency, #7003/#7065-class)", + src: ["scripts", "dev", "main-server-timeouts.mjs"], + dest: ["main-server-timeouts.mjs"], + }, { label: "HTTP method guard (server-ws.mjs dependency)", src: ["scripts", "dev", "http-method-guard.cjs"], diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index 9adde22c1f6..cc60babe71a 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -46,6 +46,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [ "open-sse/services/compression/engines/llmlingua/onnxWorker.js", "package.json", "peer-stamp.mjs", + "main-server-timeouts.mjs", "responses-ws-proxy.mjs", "scripts/dev/sync-env.mjs", "scripts/dev/tls-options.mjs", @@ -152,6 +153,7 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ "dist/server-ws.mjs", "dist/responses-ws-proxy.mjs", "dist/peer-stamp.mjs", + "dist/main-server-timeouts.mjs", "dist/http-method-guard.cjs", // #5452: regression guard — make check:pack-artifact fail loudly if the TLS // opt-in sidecar (imported by dist/server-ws.mjs) ever vanishes from the tarball. diff --git a/scripts/dev/main-server-timeouts.mjs b/scripts/dev/main-server-timeouts.mjs new file mode 100644 index 00000000000..a0019fca2da --- /dev/null +++ b/scripts/dev/main-server-timeouts.mjs @@ -0,0 +1,47 @@ +// Main-server keepAlive/headers timeouts (#7003) — SIBLING module of +// standalone-server-ws.mjs. The shipped server-ws.mjs may only import +// siblings copied next to it by assembleStandalone (peer-stamp, tls-options, +// the guards): a ../../src/... import resolves OUTSIDE the package after the +// copy to the dist root and crashes boot with ERR_MODULE_NOT_FOUND (caught +// live by check:pack-boot on 2026-07-15 — the #7065 class). +// Parity with src/shared/utils/runtimeTimeouts.ts#getMainServerTimeoutConfig +// is enforced by tests/unit/main-server-timeouts-parity.test.ts. + +export const DEFAULT_MAIN_SERVER_KEEPALIVE_TIMEOUT_MS = 65_000; +export const DEFAULT_MAIN_SERVER_HEADERS_TIMEOUT_MS = 66_000; + +function readTimeoutMs(env, name, defaultValue, { allowZero = false, logger } = {}) { + const raw = env[name]; + if (raw == null || raw.trim() === "") return defaultValue; + const parsed = Number(raw); + const isValid = Number.isFinite(parsed) && (allowZero ? parsed >= 0 : parsed > 0); + if (!isValid) { + logger?.(`Invalid ${name}="${raw}". Using default ${defaultValue}ms.`); + return defaultValue; + } + return Math.floor(parsed); +} + +export function getMainServerTimeoutConfig(env = process.env, logger) { + const keepAliveTimeoutMs = readTimeoutMs( + env, + "MAIN_SERVER_KEEPALIVE_TIMEOUT_MS", + DEFAULT_MAIN_SERVER_KEEPALIVE_TIMEOUT_MS, + { allowZero: true, logger } + ); + const headersTimeoutMs = readTimeoutMs( + env, + "MAIN_SERVER_HEADERS_TIMEOUT_MS", + DEFAULT_MAIN_SERVER_HEADERS_TIMEOUT_MS, + { allowZero: true, logger } + ); + return { + keepAliveTimeoutMs, + // Node requires headersTimeout > keepAliveTimeout; keep both configurable + // but always coherent (mirrors the canonical TS implementation). + headersTimeoutMs: + headersTimeoutMs > 0 && keepAliveTimeoutMs > 0 + ? Math.max(headersTimeoutMs, keepAliveTimeoutMs + 1_000) + : headersTimeoutMs, + }; +} diff --git a/scripts/dev/run-next.mjs b/scripts/dev/run-next.mjs index fffdb06cfb1..54c33e56df4 100644 --- a/scripts/dev/run-next.mjs +++ b/scripts/dev/run-next.mjs @@ -14,7 +14,7 @@ import headResponseGuard from "./head-response-guard.cjs"; import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs"; import { isTurbopackCacheCorruption, purgeAllTurbopackCaches } from "./turbopackCacheHeal.mjs"; import { randomUUID } from "node:crypto"; -import { getMainServerTimeoutConfig } from "../../src/shared/utils/runtimeTimeouts.ts"; +import { getMainServerTimeoutConfig } from "./main-server-timeouts.mjs"; const { maybeHandleDisallowedMethod } = methodGuard; const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard; diff --git a/scripts/dev/standalone-server-ws.mjs b/scripts/dev/standalone-server-ws.mjs index ebbca999369..439a9c51710 100644 --- a/scripts/dev/standalone-server-ws.mjs +++ b/scripts/dev/standalone-server-ws.mjs @@ -7,7 +7,7 @@ import { maybeHandleWebdav } from "./webdav-handler.mjs"; import methodGuard from "./http-method-guard.cjs"; import headResponseGuard from "./head-response-guard.cjs"; import { resolveTlsOptions, createServerListener } from "./tls-options.mjs"; -import { getMainServerTimeoutConfig } from "../../src/shared/utils/runtimeTimeouts.ts"; +import { getMainServerTimeoutConfig } from "./main-server-timeouts.mjs"; const originalCreateServer = http.createServer.bind(http); const proxiesByPort = new Map(); diff --git a/tests/unit/main-server-timeouts-parity.test.ts b/tests/unit/main-server-timeouts-parity.test.ts new file mode 100644 index 00000000000..7a8374c9422 --- /dev/null +++ b/tests/unit/main-server-timeouts-parity.test.ts @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert"; +import { getMainServerTimeoutConfig as mjsImpl } from "../../scripts/dev/main-server-timeouts.mjs"; +import { getMainServerTimeoutConfig as tsImpl } from "../../src/shared/utils/runtimeTimeouts.ts"; + +// The shipped server-ws.mjs uses the SIBLING scripts/dev/main-server-timeouts.mjs +// (a ../../src import escapes the package after the dist copy — 2026-07-15 boot +// crash, #7065 class). This parity matrix is the anti-drift guard between the +// sibling and the canonical src/shared/utils/runtimeTimeouts.ts implementation. +const ENV_MATRIX: Record[] = [ + {}, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "70000" }, + { MAIN_SERVER_HEADERS_TIMEOUT_MS: "80000" }, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "90000", MAIN_SERVER_HEADERS_TIMEOUT_MS: "10000" }, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "0", MAIN_SERVER_HEADERS_TIMEOUT_MS: "0" }, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "abc" }, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: " " }, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "-5" }, + { MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "1234.9" }, +]; + +test("sibling main-server-timeouts.mjs stays in parity with runtimeTimeouts.ts", () => { + for (const env of ENV_MATRIX) { + assert.deepStrictEqual( + mjsImpl(env), + tsImpl(env), + `divergence for env ${JSON.stringify(env)}` + ); + } +}); + +test("invalid values log through the provided logger in both implementations", () => { + const logsA: string[] = []; + const logsB: string[] = []; + mjsImpl({ MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "bogus" }, (m) => logsA.push(m)); + tsImpl({ MAIN_SERVER_KEEPALIVE_TIMEOUT_MS: "bogus" }, (m) => logsB.push(m)); + assert.strictEqual(logsA.length, 1); + assert.deepStrictEqual(logsA, logsB); +}); diff --git a/tests/unit/pack-artifact-entrypoint-closures.test.ts b/tests/unit/pack-artifact-entrypoint-closures.test.ts index 78b4826417e..94a037a68cb 100644 --- a/tests/unit/pack-artifact-entrypoint-closures.test.ts +++ b/tests/unit/pack-artifact-entrypoint-closures.test.ts @@ -56,6 +56,16 @@ function localImports(filePath: string): string[] { return [...new Set(patterns.flatMap((re) => [...src.matchAll(re)].map((m) => m[1])))]; } +/** Parent-relative specifiers (../) in a wrapper file — ALWAYS a packaging bug. */ +export function parentRelativeImports(src: string): string[] { + const patterns = [ + /from\s+["'](\.\.\/[^"']+)["']/g, + /import\(\s*["'](\.\.\/[^"']+)["']\s*\)/g, + /require\(\s*["'](\.\.\/[^"']+)["']\s*\)/g, + ]; + return [...new Set(patterns.flatMap((re) => [...src.matchAll(re)].map((m) => m[1])))]; +} + // Wrappers that ship in the npm channel are exactly those whose dest survives the prune. // Wrappers intentionally outside the npm tarball (e.g. healthcheck.mjs, Docker-only) are // excluded: their imports live or die with them, consistently. @@ -123,3 +133,20 @@ test("every bin/omniroute.mjs local import is enforced by check:pack-artifact", `add bin/ to PACK_ARTIFACT_REQUIRED_PATHS: ${missing.join(", ")}` ); }); + +test("no npm-shipped wrapper uses a parent-relative (../) import — it escapes the package after the dist-root copy", () => { + // 2026-07-15 live incident: standalone-server-ws.mjs imported + // ../../src/shared/utils/runtimeTimeouts.ts (merged in #7191); copied to the dist + // root, the specifier resolved to node_modules/src/... OUTSIDE the package and + // every boot of the packed tarball crashed with ERR_MODULE_NOT_FOUND (#7065 + // class — caught by check:pack-boot). Wrapper dependencies must be SIBLINGS + // (./x.mjs) with their own EXTRA_MODULE_ENTRIES copy + pack allowlist entry. + for (const wrapper of npmShippedWrappers()) { + const escaping = parentRelativeImports(fs.readFileSync(path.join(ROOT, wrapper.src), "utf8")); + assert.deepEqual( + escaping, + [], + `${wrapper.src} has package-escaping imports: ${escaping.join(", ")} — extract to a sibling module instead` + ); + } +}); diff --git a/tests/unit/pack-artifact-policy.test.ts b/tests/unit/pack-artifact-policy.test.ts index ae24d23deae..9738342e7ed 100644 --- a/tests/unit/pack-artifact-policy.test.ts +++ b/tests/unit/pack-artifact-policy.test.ts @@ -112,6 +112,7 @@ test("findMissingArtifactPaths flags missing root runtime files in the tarball", "bin/nodeRuntimeSupport.mjs", "dist/head-response-guard.cjs", "dist/http-method-guard.cjs", + "dist/main-server-timeouts.mjs", "dist/open-sse/services/compression/engines/rtk/filters/generic-output.json", "dist/open-sse/services/compression/rules/en/filler.json", "dist/peer-stamp.mjs", diff --git a/tests/unit/standalone-server-ws-keepalive-timeout-7003.test.ts b/tests/unit/standalone-server-ws-keepalive-timeout-7003.test.ts index 076e84b1e4e..b69784738e6 100644 --- a/tests/unit/standalone-server-ws-keepalive-timeout-7003.test.ts +++ b/tests/unit/standalone-server-ws-keepalive-timeout-7003.test.ts @@ -33,11 +33,15 @@ const source = fs.readFileSync( ); test("standalone-server-ws.mjs imports getMainServerTimeoutConfig", () => { + // The wrapper must import the SIBLING ./main-server-timeouts.mjs — a + // ../../src/... import escapes the package after the dist-root copy and + // crashed every packed boot (2026-07-15, #7065 class). Parity with the + // canonical runtimeTimeouts.ts is guarded by main-server-timeouts-parity.test.ts. assert.match( source, - /import\s*\{\s*getMainServerTimeoutConfig\s*\}\s*from\s*["'][^"']*runtimeTimeouts(?:\.ts)?["']/, - "expected the production server wrapper to import getMainServerTimeoutConfig, " + - "the same helper run-next.mjs uses" + /import\s*\{\s*getMainServerTimeoutConfig\s*\}\s*from\s*["']\.\/main-server-timeouts\.mjs["']/, + "expected the production server wrapper to import getMainServerTimeoutConfig " + + "from its shipped sibling module (./main-server-timeouts.mjs)" ); });