mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-23 13:20:48 +02:00
bound cross-platform runtime deadlines
This commit is contained in:
+7
-2
@@ -415,7 +415,10 @@ export class ContextClient {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
|
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
|
||||||
const timeout = setTimeout(() => controller.abort(), 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;
|
let response;
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
@@ -733,7 +736,9 @@ function raceWithAbort(promise, signal) {
|
|||||||
async function withTimeout(promise, timeoutMs) {
|
async function withTimeout(promise, timeoutMs) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
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 {
|
try {
|
||||||
return await raceWithAbort(promise, controller.signal);
|
return await raceWithAbort(promise, controller.signal);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+66
-6
@@ -349,6 +349,7 @@ export async function installManagedRuntime(options = {}) {
|
|||||||
version,
|
version,
|
||||||
nodeCommand: options.nodeCommand ?? process.env.GSTACK_NODE ?? "node",
|
nodeCommand: options.nodeCommand ?? process.env.GSTACK_NODE ?? "node",
|
||||||
run: options.runCommand ?? runCommand,
|
run: options.runCommand ?? runCommand,
|
||||||
|
commandTimeoutMs: options.commandTimeoutMs,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
beforeActivate: async ({ active, previous, previousExists, destination }) => {
|
beforeActivate: async ({ active, previous, previousExists, destination }) => {
|
||||||
@@ -549,7 +550,8 @@ export async function validateRuntimeBundle(directory, context = {}) {
|
|||||||
export async function smokeRuntimeBundle(directory, options = {}) {
|
export async function smokeRuntimeBundle(directory, options = {}) {
|
||||||
const command = options.nodeCommand ?? process.env.GSTACK_NODE ?? "node";
|
const command = options.nodeCommand ?? process.env.GSTACK_NODE ?? "node";
|
||||||
const run = options.run ?? runCommand;
|
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 versionText = `${version?.stdout ?? ""}${version?.stderr ?? ""}`.trim();
|
||||||
const nodeMajor = Number(versionText.match(/v?(\d+)\./)?.[1]);
|
const nodeMajor = Number(versionText.match(/v?(\d+)\./)?.[1]);
|
||||||
if (!Number.isInteger(nodeMajor) || nodeMajor < 18) {
|
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"], {
|
const result = await run(command, [path.join(directory, "bin", "gstack"), "--version"], {
|
||||||
cwd: directory,
|
cwd: directory,
|
||||||
capture: true,
|
capture: true,
|
||||||
|
timeoutMs,
|
||||||
});
|
});
|
||||||
if (!/gstack/i.test(`${result?.stdout ?? ""}${result?.stderr ?? ""}`)) {
|
if (!/gstack/i.test(`${result?.stdout ?? ""}${result?.stderr ?? ""}`)) {
|
||||||
throw installError("Runtime launcher smoke test returned an unexpected response", "INSTALL_SMOKE_FAILED");
|
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, [
|
await run(command, [
|
||||||
"--input-type=module",
|
"--input-type=module",
|
||||||
"--eval",
|
"--eval",
|
||||||
nativeImports.map((packageName) => `await import(${JSON.stringify(packageName)});`).join(" "),
|
`${nativeImports.map((packageName) => `await import(${JSON.stringify(packageName)});`).join(" ")} process.exit(0);`,
|
||||||
], { cwd: directory, capture: true });
|
], { cwd: directory, capture: true, timeoutMs });
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
throw installError("Runtime native dependency smoke test failed", "INSTALL_SMOKE_FAILED", 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.stderr?.setEncoding("utf8");
|
||||||
child.stdout?.on("data", (chunk) => { stdout += chunk; });
|
child.stdout?.on("data", (chunk) => { stdout += chunk; });
|
||||||
child.stderr?.on("data", (chunk) => { stderr += 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) => {
|
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 {
|
else {
|
||||||
const error = new Error(`Command failed (${signal ?? code}): ${command} ${args.join(" ")}`);
|
const error = new Error(`Command failed (${signal ?? code}): ${command} ${args.join(" ")}`);
|
||||||
error.code = "INSTALL_COMMAND_FAILED";
|
error.code = "INSTALL_COMMAND_FAILED";
|
||||||
@@ -1281,9 +1321,29 @@ export function runCommand(command, args, options = {}) {
|
|||||||
error.signal = signal;
|
error.signal = signal;
|
||||||
error.stdout = stdout;
|
error.stdout = stdout;
|
||||||
error.stderr = stderr;
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,8 +173,8 @@ describe("GStack 2 managed runtime installer", () => {
|
|||||||
const browserDependencies = await runCommand("node", [
|
const browserDependencies = await runCommand("node", [
|
||||||
"--input-type=module",
|
"--input-type=module",
|
||||||
"--eval",
|
"--eval",
|
||||||
'await import("@anthropic-ai/sdk"); await import("sharp"); await import("@ngrok/ngrok");',
|
'await import("@anthropic-ai/sdk"); await import("sharp"); await import("@ngrok/ngrok"); process.exit(0);',
|
||||||
], { capture: true, cwd: result.path });
|
], { capture: true, cwd: result.path, timeoutMs: 15_000 });
|
||||||
expect(browserDependencies.code).toBe(0);
|
expect(browserDependencies.code).toBe(0);
|
||||||
|
|
||||||
const next = await runCommand(path.join(home, "bin", "gstack-next-version"), ["--help"], { capture: true });
|
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 });
|
}, { createDefaultSource: false });
|
||||||
}, 30_000);
|
}, 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", () => {
|
test("selects one deterministic native dependency closure per supported host", () => {
|
||||||
expect(runtimeNativePackagePaths({ platform: "darwin", arch: "arm64" })).toEqual([
|
expect(runtimeNativePackagePaths({ platform: "darwin", arch: "arm64" })).toEqual([
|
||||||
"node_modules/@img/colour",
|
"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 () => {
|
test("default runtime smoke explicitly invokes Node, not the host running the installer", async () => {
|
||||||
await withFixture(async ({ source, home }) => {
|
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", {
|
await installFixture(source, home, "1.0.0", {
|
||||||
runCommand: async (command: string, args: string[]) => {
|
commandTimeoutMs: 4_321,
|
||||||
calls.push({ command, args });
|
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: "" };
|
if (args[0] === "--version") return { code: 0, stdout: "v20.18.0\n", stderr: "" };
|
||||||
return { code: 0, stdout: "gstack runtime fixture\n", stderr: "" };
|
return { code: 0, stdout: "gstack runtime fixture\n", stderr: "" };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(calls).toHaveLength(2);
|
expect(calls).toHaveLength(2);
|
||||||
expect(calls.every((call) => call.command === "node")).toBe(true);
|
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"]);
|
expect(calls[0].args).toEqual(["--version"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user