From 9b3188e74b0d628899b2568f41fb94f51f5cff3f Mon Sep 17 00:00:00 2001 From: Sinabina Date: Fri, 17 Jul 2026 14:07:50 -0700 Subject: [PATCH] bound cross-platform runtime deadlines --- runtime/context.js | 9 +++- runtime/install.js | 72 +++++++++++++++++++++++++--- test/gstack2-runtime-install.test.ts | 37 ++++++++++++-- 3 files changed, 105 insertions(+), 13 deletions(-) diff --git a/runtime/context.js b/runtime/context.js index 1d807c438..c3f7365ff 100644 --- a/runtime/context.js +++ b/runtime/context.js @@ -415,7 +415,10 @@ export class ContextClient { const controller = new AbortController(); const timeoutMs = options.timeoutMs ?? this.timeoutMs; const timeout = setTimeout(() => controller.abort(), timeoutMs); - timeout.unref?.(); + // This timer is part of the public operation contract, so keep it + // referenced until the request settles. Bun on Windows may otherwise + // leave a caller awaiting an unresolving fetch/body without delivering + // the unref'ed abort timer. let response; try { try { @@ -733,7 +736,9 @@ function raceWithAbort(promise, signal) { async function withTimeout(promise, timeoutMs) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); - timeout.unref?.(); + // Keep the deadline referenced while the caller is awaiting `promise`. + // An unref'ed timer is only appropriate for optional background cleanup; + // this timer is the mechanism that guarantees the operation completes. try { return await raceWithAbort(promise, controller.signal); } finally { diff --git a/runtime/install.js b/runtime/install.js index 4f212b7b7..d6ae3de77 100644 --- a/runtime/install.js +++ b/runtime/install.js @@ -349,6 +349,7 @@ export async function installManagedRuntime(options = {}) { version, nodeCommand: options.nodeCommand ?? process.env.GSTACK_NODE ?? "node", run: options.runCommand ?? runCommand, + commandTimeoutMs: options.commandTimeoutMs, }); }, beforeActivate: async ({ active, previous, previousExists, destination }) => { @@ -549,7 +550,8 @@ export async function validateRuntimeBundle(directory, context = {}) { export async function smokeRuntimeBundle(directory, options = {}) { const command = options.nodeCommand ?? process.env.GSTACK_NODE ?? "node"; const run = options.run ?? runCommand; - const version = await run(command, ["--version"], { capture: true }); + const timeoutMs = options.commandTimeoutMs ?? 15_000; + const version = await run(command, ["--version"], { capture: true, timeoutMs }); const versionText = `${version?.stdout ?? ""}${version?.stderr ?? ""}`.trim(); const nodeMajor = Number(versionText.match(/v?(\d+)\./)?.[1]); if (!Number.isInteger(nodeMajor) || nodeMajor < 18) { @@ -558,6 +560,7 @@ export async function smokeRuntimeBundle(directory, options = {}) { const result = await run(command, [path.join(directory, "bin", "gstack"), "--version"], { cwd: directory, capture: true, + timeoutMs, }); if (!/gstack/i.test(`${result?.stdout ?? ""}${result?.stderr ?? ""}`)) { throw installError("Runtime launcher smoke test returned an unexpected response", "INSTALL_SMOKE_FAILED"); @@ -573,8 +576,8 @@ export async function smokeRuntimeBundle(directory, options = {}) { await run(command, [ "--input-type=module", "--eval", - nativeImports.map((packageName) => `await import(${JSON.stringify(packageName)});`).join(" "), - ], { cwd: directory, capture: true }); + `${nativeImports.map((packageName) => `await import(${JSON.stringify(packageName)});`).join(" ")} process.exit(0);`, + ], { cwd: directory, capture: true, timeoutMs }); } catch (cause) { throw installError("Runtime native dependency smoke test failed", "INSTALL_SMOKE_FAILED", cause); } @@ -1271,9 +1274,46 @@ export function runCommand(command, args, options = {}) { child.stderr?.setEncoding("utf8"); child.stdout?.on("data", (chunk) => { stdout += chunk; }); child.stderr?.on("data", (chunk) => { stderr += chunk; }); - child.once("error", reject); + let settled = false; + let timeout = null; + let killGrace = null; + let timeoutError = null; + const finish = (callback, value) => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + if (killGrace) clearTimeout(killGrace); + callback(value); + }; + const timeoutMs = Number(options.timeoutMs); + const killGraceMs = Number.isFinite(Number(options.killGraceMs)) && Number(options.killGraceMs) > 0 + ? Number(options.killGraceMs) + : 5_000; + child.once("error", (error) => { + if (!timeoutError) return finish(reject, error); + // A kill error is evidence that termination is not yet confirmed. Keep + // waiting for `exit` or the bounded kill-grace deadline. + timeoutError.cause ??= error; + }); child.once("exit", (code, signal) => { - if (code === 0) resolve({ code, stdout, stderr }); + if (!timeoutError && timeout) { + clearTimeout(timeout); + timeout = null; + } + // A timeout is not complete until the direct child is confirmed dead. + // Timed installer probes are deliberately single-process commands; this + // helper does not claim to supervise commands that daemonize descendants. + if (timeoutError) { + timeoutError.exitCode = code; + timeoutError.signal = signal; + child.stdout?.destroy(); + child.stderr?.destroy(); + finish(reject, timeoutError); + } + }); + child.once("close", (code, signal) => { + if (timeoutError) return finish(reject, timeoutError); + if (code === 0) finish(resolve, { code, stdout, stderr }); else { const error = new Error(`Command failed (${signal ?? code}): ${command} ${args.join(" ")}`); error.code = "INSTALL_COMMAND_FAILED"; @@ -1281,9 +1321,29 @@ export function runCommand(command, args, options = {}) { error.signal = signal; error.stdout = stdout; error.stderr = stderr; - reject(error); + finish(reject, error); } }); + if (Number.isFinite(timeoutMs) && timeoutMs > 0) { + timeout = setTimeout(() => { + timeoutError = new Error(`Command timed out after ${timeoutMs}ms: ${command}`); + timeoutError.code = "INSTALL_COMMAND_TIMEOUT"; + timeoutError.timeoutMs = timeoutMs; + try { + child.kill("SIGKILL"); + } catch (cause) { + timeoutError.cause = cause; + } + killGrace = setTimeout(() => { + const error = new Error(`Timed-out command did not terminate within ${killGraceMs}ms: ${command}`); + error.code = "INSTALL_COMMAND_KILL_TIMEOUT"; + error.timeoutMs = timeoutMs; + error.killGraceMs = killGraceMs; + error.cause = timeoutError; + finish(reject, error); + }, killGraceMs); + }, timeoutMs); + } }); } diff --git a/test/gstack2-runtime-install.test.ts b/test/gstack2-runtime-install.test.ts index 3d3c848f8..ad750fdb8 100644 --- a/test/gstack2-runtime-install.test.ts +++ b/test/gstack2-runtime-install.test.ts @@ -173,8 +173,8 @@ describe("GStack 2 managed runtime installer", () => { const browserDependencies = await runCommand("node", [ "--input-type=module", "--eval", - 'await import("@anthropic-ai/sdk"); await import("sharp"); await import("@ngrok/ngrok");', - ], { capture: true, cwd: result.path }); + 'await import("@anthropic-ai/sdk"); await import("sharp"); await import("@ngrok/ngrok"); process.exit(0);', + ], { capture: true, cwd: result.path, timeoutMs: 15_000 }); expect(browserDependencies.code).toBe(0); const next = await runCommand(path.join(home, "bin", "gstack-next-version"), ["--help"], { capture: true }); @@ -196,6 +196,31 @@ describe("GStack 2 managed runtime installer", () => { }, { createDefaultSource: false }); }, 30_000); + test("bounded subprocess execution confirms a timed-out command has exited", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-command-timeout-")); + const pidPath = path.join(root, "pid.txt"); + try { + await expect(runCommand(process.execPath, [ + "--eval", + `require("node:fs").writeFileSync(${JSON.stringify(pidPath)}, String(process.pid)); setInterval(() => {}, 1000);`, + ], { + capture: true, + timeoutMs: 1_500, + })).rejects.toMatchObject({ code: "INSTALL_COMMAND_TIMEOUT", timeoutMs: 1_500 }); + + const pid = Number(await fs.readFile(pidPath, "utf8")); + let alive = true; + try { + process.kill(pid, 0); + } catch { + alive = false; + } + expect(alive).toBe(false); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + test("selects one deterministic native dependency closure per supported host", () => { expect(runtimeNativePackagePaths({ platform: "darwin", arch: "arm64" })).toEqual([ "node_modules/@img/colour", @@ -515,16 +540,18 @@ describe("GStack 2 managed runtime installer", () => { test("default runtime smoke explicitly invokes Node, not the host running the installer", async () => { await withFixture(async ({ source, home }) => { - const calls: Array<{ command: string; args: string[] }> = []; + const calls: Array<{ command: string; args: string[]; options: { timeoutMs?: number } }> = []; await installFixture(source, home, "1.0.0", { - runCommand: async (command: string, args: string[]) => { - calls.push({ command, args }); + commandTimeoutMs: 4_321, + runCommand: async (command: string, args: string[], options: { timeoutMs?: number }) => { + calls.push({ command, args, options }); if (args[0] === "--version") return { code: 0, stdout: "v20.18.0\n", stderr: "" }; return { code: 0, stdout: "gstack runtime fixture\n", stderr: "" }; }, }); expect(calls).toHaveLength(2); expect(calls.every((call) => call.command === "node")).toBe(true); + expect(calls.every((call) => call.options.timeoutMs === 4_321)).toBe(true); expect(calls[0].args).toEqual(["--version"]); }); });