Skip to content

Commit a851236

Browse files
authored
Add timeout guards to setup JS child processes (#53507)
1 parent 73e0bc7 commit a851236

12 files changed

Lines changed: 194 additions & 19 deletions

‎actions/setup/js/apply_samples.cjs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,12 @@ const os = require("os");
3838
const { getErrorMessage } = require("./error_helpers.cjs");
3939
const { ERR_VALIDATION, ERR_PARSE, ERR_SYSTEM, ERR_API, ERR_CONFIG } = require("./error_codes.cjs");
4040
const { findRepoCheckout } = require("./find_repo_checkout.cjs");
41+
const { getSetupTimeoutMs } = require("./child_process_timeouts.cjs");
4142

4243
const DEFAULT_BASE_BRANCH = process.env.GH_AW_CUSTOM_BASE_BRANCH || process.env.GITHUB_BASE_REF || process.env.GITHUB_REF_NAME || "main";
4344
const PATCH_SIDECAR_TOOLS = new Set(["create_pull_request", "push_to_pull_request_branch"]);
44-
const FETCH_TIMEOUT_MS = 120_000;
45+
const FETCH_TIMEOUT_MS = getSetupTimeoutMs("applySamplesFetch");
46+
const GIT_COMMAND_TIMEOUT_MS = getSetupTimeoutMs("applySamplesGit");
4547

4648
/**
4749
* @typedef {Object} SampleEntry
@@ -95,7 +97,7 @@ function loadSamples() {
9597
*/
9698
function runGit(args, cwd) {
9799
const { spawnSync } = require("child_process");
98-
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
100+
const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: GIT_COMMAND_TIMEOUT_MS });
99101
if (result.error) {
100102
throw result.error;
101103
}
@@ -628,6 +630,9 @@ async function main() {
628630
stdio: ["pipe", "pipe", "inherit"],
629631
env: process.env,
630632
});
633+
child.on("error", err => {
634+
core.error(`apply_samples: failed to launch MCP server: ${getErrorMessage(err)}`);
635+
});
631636

632637
const stdoutIter = lineIterator(child.stdout);
633638
let nextId = 1;

‎actions/setup/js/artifact_client.cjs‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,18 @@ const { pipeline } = require("stream/promises");
1616
const { spawnSync } = require("child_process");
1717

1818
const { getErrorMessage } = require("./error_helpers.cjs");
19+
const { getSetupTimeoutMs } = require("./child_process_timeouts.cjs");
1920

2021
const DEFAULT_RETRY_ATTEMPTS = 5;
2122
const RETRY_DELAY_MS = 5000;
2223
const RESULTS_SCOPE_PREFIX = "Actions.Results:";
2324
const TWIRP_ARTIFACT_SERVICE = "github.actions.results.api.v1.ArtifactService";
2425
const MAX_ARTIFACTS = 1000;
2526
const PAGE_SIZE = 100;
26-
const FETCH_TIMEOUT_MS = 120_000;
27-
const FETCH_TRANSFER_TIMEOUT_MS = 300_000;
27+
const FETCH_TIMEOUT_MS = getSetupTimeoutMs("artifactFetch");
28+
const FETCH_TRANSFER_TIMEOUT_MS = getSetupTimeoutMs("artifactTransfer");
29+
const ARCHIVE_COMMAND_TIMEOUT_MS = getSetupTimeoutMs("artifactArchive");
30+
const ARCHIVE_PROBE_TIMEOUT_MS = getSetupTimeoutMs("artifactArchiveProbe");
2831

2932
function sleep(ms) {
3033
return new Promise(resolve => setTimeout(resolve, ms));
@@ -172,7 +175,7 @@ async function streamToFile(response, filePath) {
172175
}
173176

174177
function ensureZipAvailable() {
175-
const result = spawnSync("zip", ["-v"], { stdio: "ignore" });
178+
const result = spawnSync("zip", ["-v"], { stdio: "ignore", timeout: ARCHIVE_PROBE_TIMEOUT_MS });
176179
if (result.error) {
177180
throw result.error;
178181
}
@@ -182,7 +185,7 @@ function ensureZipAvailable() {
182185
}
183186

184187
function ensureUnzipAvailable() {
185-
const result = spawnSync("unzip", ["-v"], { stdio: "ignore" });
188+
const result = spawnSync("unzip", ["-v"], { stdio: "ignore", timeout: ARCHIVE_PROBE_TIMEOUT_MS });
186189
if (result.error) {
187190
throw result.error;
188191
}
@@ -201,6 +204,7 @@ function createZipFromFiles(files, rootDirectory, outputPath) {
201204
const result = spawnSync("zip", ["-q", "-r", outputPath, ...relativeFiles], {
202205
cwd: rootDirectory,
203206
encoding: "utf8",
207+
timeout: ARCHIVE_COMMAND_TIMEOUT_MS,
204208
});
205209
if (result.error) {
206210
throw result.error;
@@ -372,7 +376,7 @@ class DefaultArtifactClient {
372376
const tempZip = path.join(tempDownloadDir, "artifact.zip");
373377
try {
374378
digest = await streamToFile(blobResponse, tempZip);
375-
const unzipResult = spawnSync("unzip", ["-q", tempZip, "-d", destination], { encoding: "utf8" });
379+
const unzipResult = spawnSync("unzip", ["-q", tempZip, "-d", destination], { encoding: "utf8", timeout: ARCHIVE_COMMAND_TIMEOUT_MS });
376380
if (unzipResult.error) {
377381
throw unzipResult.error;
378382
}

‎actions/setup/js/artifact_client.test.cjs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,11 @@ describe("DefaultArtifactClient.uploadArtifact", () => {
343343
output: [],
344344
signal: null,
345345
});
346+
vi.stubEnv("GH_AW_ARTIFACT_ARCHIVE_PROBE_TIMEOUT_MS", "12345");
347+
vi.stubEnv("GH_AW_ARTIFACT_ARCHIVE_TIMEOUT_MS", "67890");
348+
const artifactClientPath = req.resolve("./artifact_client.cjs");
349+
delete req.cache[artifactClientPath];
350+
const { DefaultArtifactClient: FreshArtifactClient } = req("./artifact_client.cjs");
346351

347352
let capturedName;
348353
const mockFetch = vi.fn().mockImplementation(async (url, opts) => {
@@ -360,7 +365,7 @@ describe("DefaultArtifactClient.uploadArtifact", () => {
360365
vi.stubEnv("ACTIONS_RUNTIME_TOKEN", buildFakeToken("runId:jobId"));
361366
vi.stubEnv("ACTIONS_RESULTS_URL", "https://results.example.com");
362367

363-
const client = new DefaultArtifactClient();
368+
const client = new FreshArtifactClient();
364369
// Will fail at zip creation (no real zip output), but we still capture the name from CreateArtifact
365370
try {
366371
await client.uploadArtifact("archive-name", [filePath], tmpDir);
@@ -369,6 +374,9 @@ describe("DefaultArtifactClient.uploadArtifact", () => {
369374
}
370375

371376
expect(capturedName).toBe("archive-name");
377+
expect(spawnSyncSpy).toHaveBeenNthCalledWith(1, "zip", ["-v"], expect.objectContaining({ timeout: 12_345 }));
378+
expect(spawnSyncSpy).toHaveBeenNthCalledWith(2, "zip", expect.any(Array), expect.objectContaining({ timeout: 67_890 }));
372379
spawnSyncSpy.mockRestore();
380+
delete req.cache[artifactClientPath];
373381
});
374382
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// @ts-check
2+
"use strict";
3+
4+
/**
5+
* Largest timeout Node.js can represent as a timer delay (2^31 - 1 ms, ~24.8 days).
6+
* Larger delays overflow and are silently converted to 1 ms, so overrides above
7+
* this bound are clamped to it.
8+
*/
9+
const MAX_SETUP_TIMEOUT_MS = 2_147_483_647;
10+
11+
/**
12+
* Setup/runtime command timeout defaults, in milliseconds. Each timeout can be
13+
* overridden with its environment variable by setting a positive integer value.
14+
*/
15+
const SETUP_TIMEOUTS = Object.freeze({
16+
applySamplesFetch: { env: "GH_AW_APPLY_SAMPLES_FETCH_TIMEOUT_MS", defaultMs: 120_000 },
17+
applySamplesGit: { env: "GH_AW_APPLY_SAMPLES_GIT_TIMEOUT_MS", defaultMs: 120_000 },
18+
artifactArchive: { env: "GH_AW_ARTIFACT_ARCHIVE_TIMEOUT_MS", defaultMs: 300_000 },
19+
artifactArchiveProbe: { env: "GH_AW_ARTIFACT_ARCHIVE_PROBE_TIMEOUT_MS", defaultMs: 15_000 },
20+
artifactFetch: { env: "GH_AW_ARTIFACT_FETCH_TIMEOUT_MS", defaultMs: 120_000 },
21+
artifactTransfer: { env: "GH_AW_ARTIFACT_TRANSFER_TIMEOUT_MS", defaultMs: 300_000 },
22+
gitBranch: { env: "GH_AW_GIT_BRANCH_TIMEOUT_MS", defaultMs: 15_000 },
23+
importGit: { env: "GH_AW_IMPORT_GIT_TIMEOUT_MS", defaultMs: 300_000 },
24+
mcpConfigConverter: { env: "GH_AW_MCP_CONFIG_CONVERTER_TIMEOUT_MS", defaultMs: 120_000 },
25+
mcpContainerStatus: { env: "GH_AW_MCP_CONTAINER_STATUS_TIMEOUT_MS", defaultMs: 15_000 },
26+
mcpDockerCleanup: { env: "GH_AW_MCP_DOCKER_CLEANUP_TIMEOUT_MS", defaultMs: 30_000 },
27+
mcpServerCheck: { env: "GH_AW_MCP_SERVER_CHECK_TIMEOUT_MS", defaultMs: 120_000 },
28+
outcomeGh: { env: "GH_AW_OUTCOME_GH_TIMEOUT_MS", defaultMs: 300_000 },
29+
safeoutputsCli: { env: "GH_AW_SAFEOUTPUTS_CLI_TIMEOUT_MS", defaultMs: 120_000 },
30+
});
31+
32+
/**
33+
* @param {string} envName
34+
* @param {number} defaultMs
35+
* @param {NodeJS.ProcessEnv} [env]
36+
* @returns {number}
37+
*/
38+
function getPositiveEnvIntOrDefault(envName, defaultMs, env = process.env) {
39+
const raw = env[envName];
40+
if (!raw || !raw.trim()) {
41+
return defaultMs;
42+
}
43+
const trimmed = raw.trim();
44+
if (!/^\d+$/.test(trimmed)) {
45+
return defaultMs;
46+
}
47+
const parsed = Number(trimmed);
48+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
49+
return defaultMs;
50+
}
51+
return Math.min(parsed, MAX_SETUP_TIMEOUT_MS);
52+
}
53+
54+
/**
55+
* @param {keyof typeof SETUP_TIMEOUTS} name
56+
* @param {NodeJS.ProcessEnv} [env]
57+
* @returns {number}
58+
*/
59+
function getSetupTimeoutMs(name, env = process.env) {
60+
const timeout = SETUP_TIMEOUTS[name];
61+
if (!timeout) {
62+
throw new Error(`Unknown setup timeout: ${String(name)}`);
63+
}
64+
return getPositiveEnvIntOrDefault(timeout.env, timeout.defaultMs, env);
65+
}
66+
67+
module.exports = {
68+
MAX_SETUP_TIMEOUT_MS,
69+
SETUP_TIMEOUTS,
70+
getPositiveEnvIntOrDefault,
71+
getSetupTimeoutMs,
72+
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from "vitest";
2+
import { createRequire } from "module";
3+
4+
const req = createRequire(import.meta.url);
5+
const { MAX_SETUP_TIMEOUT_MS, SETUP_TIMEOUTS, getPositiveEnvIntOrDefault, getSetupTimeoutMs } = req("./child_process_timeouts.cjs");
6+
7+
describe("child process timeout helpers", () => {
8+
it("uses defaults when overrides are unset or invalid", () => {
9+
expect(getPositiveEnvIntOrDefault("MISSING_TIMEOUT", 123, {})).toBe(123);
10+
expect(getPositiveEnvIntOrDefault("BAD_TIMEOUT", 123, { BAD_TIMEOUT: "0" })).toBe(123);
11+
expect(getPositiveEnvIntOrDefault("BAD_TIMEOUT", 123, { BAD_TIMEOUT: "-1" })).toBe(123);
12+
expect(getPositiveEnvIntOrDefault("BAD_TIMEOUT", 123, { BAD_TIMEOUT: "10s" })).toBe(123);
13+
});
14+
15+
it("accepts positive integer millisecond overrides", () => {
16+
expect(getPositiveEnvIntOrDefault("CUSTOM_TIMEOUT", 123, { CUSTOM_TIMEOUT: " 456 " })).toBe(456);
17+
});
18+
19+
it("clamps overrides above Node's maximum timer delay", () => {
20+
expect(MAX_SETUP_TIMEOUT_MS).toBe(2_147_483_647);
21+
expect(getPositiveEnvIntOrDefault("BIG_TIMEOUT", 123, { BIG_TIMEOUT: String(MAX_SETUP_TIMEOUT_MS) })).toBe(MAX_SETUP_TIMEOUT_MS);
22+
expect(getPositiveEnvIntOrDefault("BIG_TIMEOUT", 123, { BIG_TIMEOUT: String(MAX_SETUP_TIMEOUT_MS + 1) })).toBe(MAX_SETUP_TIMEOUT_MS);
23+
expect(getPositiveEnvIntOrDefault("BIG_TIMEOUT", 123, { BIG_TIMEOUT: "99999999999999999999" })).toBe(123);
24+
});
25+
26+
it("documents an environment override for every setup timeout", () => {
27+
for (const [name, timeout] of Object.entries(SETUP_TIMEOUTS)) {
28+
expect(name).toBeTruthy();
29+
expect(timeout.env).toMatch(/^GH_AW_[A-Z0-9_]+_TIMEOUT_MS$/);
30+
expect(timeout.defaultMs).toBeGreaterThan(0);
31+
expect(timeout.defaultMs).toBeLessThanOrEqual(MAX_SETUP_TIMEOUT_MS);
32+
expect(getSetupTimeoutMs(name, { [timeout.env]: "9876" })).toBe(9876);
33+
expect(getSetupTimeoutMs(name, { [timeout.env]: "9999999999" })).toBe(MAX_SETUP_TIMEOUT_MS);
34+
}
35+
});
36+
});

‎actions/setup/js/evaluate_outcomes.cjs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const fs = require("fs");
2929
const path = require("path");
3030
const crypto = require("crypto");
3131
const { execFileSync } = require("child_process");
32+
const { getSetupTimeoutMs } = require("./child_process_timeouts.cjs");
3233

3334
// ---------------------------------------------------------------------------
3435
// Paths
@@ -48,6 +49,7 @@ const CLOSING_COMMENT_KEYWORDS = ["not planned", "won't fix", "wontfix", "duplic
4849

4950
const DEFAULT_ISSUE_IMMEDIATE_CLOSE_WINDOW_SEC = 60 * 60;
5051
const DEFAULT_LABEL_RETENTION_WINDOW_SEC = 24 * 60 * 60;
52+
const GH_COMMAND_TIMEOUT_MS = getSetupTimeoutMs("outcomeGh");
5153

5254
const POSITIVE_REACTIONS = ["+1", "heart", "hooray", "rocket"];
5355
const NEGATIVE_REACTIONS = ["-1", "confused"];
@@ -79,7 +81,7 @@ const LABEL_RETENTION_WINDOW_SEC = getEnvPositiveIntOrDefault("OUTCOME_LABEL_RET
7981
*/
8082
function gh(args) {
8183
try {
82-
return execFileSync("gh", args, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
84+
return execFileSync("gh", args, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: GH_COMMAND_TIMEOUT_MS }).trim();
8385
} catch {
8486
return null;
8587
}

‎actions/setup/js/get_current_branch.cjs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33

44
const { execSync } = require("child_process");
55
const { ERR_CONFIG } = require("./error_codes.cjs");
6+
const { getSetupTimeoutMs } = require("./child_process_timeouts.cjs");
7+
8+
const GIT_BRANCH_TIMEOUT_MS = getSetupTimeoutMs("gitBranch");
69

710
/**
811
* Get the current git branch name
@@ -19,6 +22,7 @@ function getCurrentBranch(customCwd) {
1922
encoding: "utf8",
2023
cwd: cwd,
2124
stdio: ["pipe", "pipe", "pipe"],
25+
timeout: GIT_BRANCH_TIMEOUT_MS,
2226
}).trim();
2327
// "HEAD" means the repo is in a detached-HEAD state (common with the
2428
// default actions/checkout behaviour). It is not a valid branch name;

‎actions/setup/js/merge_remote_agent_github_folder.cjs‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ require("./shim.cjs");
2727

2828
const { getErrorMessage } = require("./error_helpers.cjs");
2929
const { ERR_CONFIG, ERR_PARSE, ERR_SYSTEM, ERR_VALIDATION } = require("./error_codes.cjs");
30+
const { getSetupTimeoutMs } = require("./child_process_timeouts.cjs");
31+
32+
const GIT_COMMAND_TIMEOUT_MS = getSetupTimeoutMs("importGit");
3033

3134
/**
3235
* Parse the agent import specification to extract repository details
@@ -188,11 +191,11 @@ function sparseCheckoutGithubFolder(owner, repo, ref, tempDir) {
188191

189192
try {
190193
// Initialize git repository
191-
execFileSync("git", ["init"], { cwd: tempDir, stdio: "pipe" });
194+
execFileSync("git", ["init"], { cwd: tempDir, stdio: "pipe", timeout: GIT_COMMAND_TIMEOUT_MS });
192195
core.info("Initialized temporary git repository");
193196

194197
// Configure sparse checkout
195-
execFileSync("git", ["config", "core.sparseCheckout", "true"], { cwd: tempDir, stdio: "pipe" });
198+
execFileSync("git", ["config", "core.sparseCheckout", "true"], { cwd: tempDir, stdio: "pipe", timeout: GIT_COMMAND_TIMEOUT_MS });
196199
core.info("Enabled sparse checkout");
197200

198201
// Set sparse checkout pattern to only include .github folder
@@ -201,15 +204,15 @@ function sparseCheckoutGithubFolder(owner, repo, ref, tempDir) {
201204
core.info("Configured sparse checkout pattern: .github/");
202205

203206
// Add remote - using execFileSync prevents shell injection
204-
execFileSync("git", ["remote", "add", "origin", repoUrl], { cwd: tempDir, stdio: "pipe" });
207+
execFileSync("git", ["remote", "add", "origin", repoUrl], { cwd: tempDir, stdio: "pipe", timeout: GIT_COMMAND_TIMEOUT_MS });
205208
core.info(`Added remote: ${repoUrl}`);
206209

207210
// Fetch and checkout - using execFileSync with validated ref
208211
core.info(`Fetching ref: ${ref}`);
209-
execFileSync("git", ["fetch", "--depth", "1", "origin", ref], { cwd: tempDir, stdio: "pipe" });
212+
execFileSync("git", ["fetch", "--depth", "1", "origin", ref], { cwd: tempDir, stdio: "pipe", timeout: GIT_COMMAND_TIMEOUT_MS });
210213

211214
core.info("Checking out .github folder");
212-
execFileSync("git", ["checkout", "FETCH_HEAD"], { cwd: tempDir, stdio: "pipe" });
215+
execFileSync("git", ["checkout", "FETCH_HEAD"], { cwd: tempDir, stdio: "pipe", timeout: GIT_COMMAND_TIMEOUT_MS });
213216

214217
core.info("Sparse checkout completed successfully");
215218
} catch (error) {

‎actions/setup/js/safeoutputs_cli.cjs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
const childProcess = require("child_process");
1616
const fs = require("fs");
1717
const { ERR_VALIDATION, ERR_SYSTEM } = require("./error_codes.cjs");
18+
const { getSetupTimeoutMs } = require("./child_process_timeouts.cjs");
19+
20+
const SAFEOUTPUTS_CLI_TIMEOUT_MS = getSetupTimeoutMs("safeoutputsCli");
1821

1922
/**
2023
* @typedef {(toolName: string, args: Record<string, string>) => void} RunSafeOutputsCLILike
@@ -38,7 +41,7 @@ function runSafeOutputsCLI(toolName, args) {
3841
commandArgs.push(value);
3942
}
4043
try {
41-
childProcess.execFileSync(command, commandArgs, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
44+
childProcess.execFileSync(command, commandArgs, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: SAFEOUTPUTS_CLI_TIMEOUT_MS });
4245
} catch (error) {
4346
const err = /** @type {{message?: string, stderr?: string | Buffer}} */ error ?? {};
4447
const stderr = typeof err.stderr === "string" ? err.stderr.trim() : Buffer.isBuffer(err.stderr) ? err.stderr.toString("utf8").trim() : "";

0 commit comments

Comments
 (0)