From bd44f41e9ed317d15c5075a3103f40d65cfa1bb9 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 16:05:33 -0500 Subject: [PATCH] fix(runner): bound Rosetta interpreter build probes at 30 seconds Co-Authored-By: Paperclip --- .../scripts/build-node-startup-timeout.mjs | 6 +++++ .../build-node-startup-timeout.test.mjs | 24 +++++++++++++++++++ .../scripts/build-provider-pack.mjs | 3 ++- .../scripts/materialize-pi-distribution.mjs | 7 +++--- 4 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 packages/paperclip-runner/scripts/build-node-startup-timeout.mjs create mode 100644 packages/paperclip-runner/scripts/build-node-startup-timeout.test.mjs diff --git a/packages/paperclip-runner/scripts/build-node-startup-timeout.mjs b/packages/paperclip-runner/scripts/build-node-startup-timeout.mjs new file mode 100644 index 0000000000..fe1a48c460 --- /dev/null +++ b/packages/paperclip-runner/scripts/build-node-startup-timeout.mjs @@ -0,0 +1,6 @@ +/** Build-time probes only; provider/session admission deadlines are unchanged. */ +export function buildNodeStartupTimeout(platform = process.platform, architecture = process.arch) { + // A freshly copied x64 Node can spend more than 10s in Rosetta's first launch. + // Keep this bounded and target-specific instead of weakening other probes. + return platform === "darwin" && architecture === "x64" ? 30_000 : 10_000; +} diff --git a/packages/paperclip-runner/scripts/build-node-startup-timeout.test.mjs b/packages/paperclip-runner/scripts/build-node-startup-timeout.test.mjs new file mode 100644 index 0000000000..b79659e95e --- /dev/null +++ b/packages/paperclip-runner/scripts/build-node-startup-timeout.test.mjs @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs"; + +test("cold Rosetta Node startup has a bounded build-only allowance", () => { + assert.equal(buildNodeStartupTimeout("darwin", "x64"), 30_000); + for (const [platform, architecture] of [["darwin", "arm64"], ["linux", "x64"], ["linux", "arm64"], ["win32", "x64"]]) { + assert.equal(buildNodeStartupTimeout(platform, architecture), 10_000); + } + assert.equal(buildNodeStartupTimeout(), buildNodeStartupTimeout(process.platform, process.arch)); +}); + +test("both pack and private Pi Node probes use the allowance without changing other operation deadlines", () => { + const pack = readFileSync(new URL("./build-provider-pack.mjs", import.meta.url), "utf8"); + const pi = readFileSync(new URL("./materialize-pi-distribution.mjs", import.meta.url), "utf8"); + assert.match(pack, /spawnSync\(stableNodeCommand, \["--version"\], \{[^}]*env: \{[^}]*\}[^}]*timeout: buildNodeStartupTimeout\(\)/); + for (const probe of ['run(node, ["--version"]', 'run(node, ["-p", "process.versions.undici"]', 'run(copiedNode, ["--version"]']) { + const line = pi.split("\n").find(line => line.includes(probe)); + assert.ok(line?.includes("timeout: buildNodeStartupTimeout()"), probe); + } + assert.match(pi, /run\("git", \["apply", "--check", patchPath\], \{[^}]*timeout: 10_000/); + assert.match(pi, /run\("\/usr\/bin\/otool", \["-L", copiedNode\], \{ env: \{\}, timeout: 10_000/); +}); diff --git a/packages/paperclip-runner/scripts/build-provider-pack.mjs b/packages/paperclip-runner/scripts/build-provider-pack.mjs index 0e6e036bf9..0a9b64625d 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -19,6 +19,7 @@ import { dirname, join, relative, resolve } from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { parseProviderPackArguments, materializeCandidateProviderPack } from "./candidate-provider-pack.mjs"; +import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const workspaceRoot = resolve(packageRoot, "../.."); @@ -165,7 +166,7 @@ try { copyFileSync(process.execPath, stableNodeCommand); chmodSync(stableNodeCommand, 0o755); const relocatedNode = spawnSync(stableNodeCommand, ["--version"], { - cwd: temporaryRoot, env: { PATH: "/usr/bin:/bin" }, encoding: "utf8", timeout: 10_000, + cwd: temporaryRoot, env: { PATH: "/usr/bin:/bin" }, encoding: "utf8", timeout: buildNodeStartupTimeout(), }); if (relocatedNode.status !== 0 || relocatedNode.stdout.trim() !== `v${process.versions.node}`) { throw new Error("Provider pack Node is not portable after relocation; build with a standalone Node distribution"); diff --git a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs index a685c29bb5..c0b9be0830 100644 --- a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs +++ b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs @@ -8,6 +8,7 @@ import { promisify } from "node:util"; import { gunzipSync } from "node:zlib"; import { PI_DISTRIBUTION_CLOSURE_SHA256 } from "../src/drivers/acpx/pi-closure-pins.ts"; import { PI_NODE_DISTRIBUTIONS, PI_NODE_VERSION } from "../src/drivers/acpx/pi-node-pins.ts"; +import { buildNodeStartupTimeout } from "./build-node-startup-timeout.mjs"; import { QUALIFIED_ACPX_PROFILES } from "../src/drivers/acpx/qualified-profiles.ts"; import { inventoryPiRuntimeFiles, verifyPiRuntimeManifest } from "../src/drivers/acpx/pi-verified-runtime.ts"; @@ -208,9 +209,9 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np node = join(nodeRoot, "node"); } if (hash(await readFile(node)) !== nodePin.executableSha256 || (await lstat(node)).size !== nodePin.executableSize) throw new Error("Pi Node executable does not match its target release pin"); - const version = (await run(node, ["--version"], { env: {}, timeout: 10_000 })).stdout.trim(); + const version = (await run(node, ["--version"], { env: {}, timeout: buildNodeStartupTimeout() })).stdout.trim(); if (version !== `v${PI_NODE_VERSION}`) throw new Error("Pi distribution requires exact pinned Node version"); - if ((await run(node, ["-p", "process.versions.undici"], { env: {}, timeout: 10_000 })).stdout.trim() !== PI_DISTRIBUTION_PINS.nodeBundledUndici) throw new Error("Pi Node bundled Undici differs from its reviewed security pin"); + if ((await run(node, ["-p", "process.versions.undici"], { env: {}, timeout: buildNodeStartupTimeout() })).stdout.trim() !== PI_DISTRIBUTION_PINS.nodeBundledUndici) throw new Error("Pi Node bundled Undici differs from its reviewed security pin"); await mkdir(runtimeRoot); await Promise.all(["package.json", "package-lock.json"].map((name) => copyFile(join(lockDirectory, name), join(runtimeRoot, name)))); await writeFile(join(runtimeRoot, ".npmrc"), "registry=https://registry.npmjs.org/\nignore-scripts=true\naudit=false\nfund=false\n"); @@ -244,7 +245,7 @@ export async function materializePiDistribution({ outputRoot, nodeExecutable, np ? (await run("/usr/bin/otool", ["-L", copiedNode], { env: {}, timeout: 10_000 })).stdout : (await run("ldd", [copiedNode], { env: { PATH: "/usr/bin:/bin" }, timeout: 10_000 })).stdout; assertPiNodeSystemDependencies(dependencyListing, process.platform); - if ((await run(copiedNode, ["--version"], { env: {}, timeout: 10_000 })).stdout.trim() !== version) throw new Error("Copied Pi Node cannot execute after relocation"); + if ((await run(copiedNode, ["--version"], { env: {}, timeout: buildNodeStartupTimeout() })).stdout.trim() !== version) throw new Error("Copied Pi Node cannot execute after relocation"); await rm(buildHome, { recursive: true }); // npm-generated .bin links and hidden lock metadata are not package payload // files. No launch uses PATH; excluding them makes the closure regular-only.