test(#4515): migrate installer-runs batch to named timeout constants (#4575)

* test(#4515): migrate installer-runs batch to named timeout constants

Batch 4 of the ad hoc timeout literal migration (epic #4445). Replaces
every bare numeric timeout/timeoutMs object-literal property across
tests/install.test.cjs, tests/install-minimal-hooks.test.cjs,
tests/fragment-single-edit-propagation.install.test.cjs,
tests/install-regressions.test.cjs, tests/install-runtime-artifacts.test.cjs,
tests/npm-integrity-gate.test.cjs, tests/faulty-deps.test.cjs,
tests/release-tarball-smoke.install.test.cjs,
tests/install-write-confinement.test.cjs, tests/plugin-manifest.test.cjs,
and tests/packaging-shipped-scripts-require-only-shipped.test.cjs with a
named constant, per eslint-rules/no-adhoc-timeout-literal.cjs. Removes
these 11 files from the rule's allowlist.

Adds one new shared constant to tests/helpers/timeouts.cjs for a
fixture-JSON value (in seconds, not ms) mimicking a Claude Code
settings.json hook-entry's own timeout field, shared across two files in
this batch. Every other new constant is file-local, each carrying a
comment explaining why its call site is a distinct operational class from
the existing shared norms even where its digits numerically coincide with
one. No src/bin file touched, no numeric timeout value changed anywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: combine gsd-worktree-path-guard's git rev-parse calls to cut subprocess count under CI load

Discovered while verifying PR #4575 (a full test (windows-latest) failure in an unrelated test, tests/kilo-upgrades.test.cjs's worktree-path-guard rejection test). Root cause: this hook's up-to-4 sequential git spawns (git-dir, branch, worktree-toplevel, file-toplevel), invoked inside a native-plugin's own 8000ms-capped subprocess wrapper, left zero margin for node/git startup overhead — the guard is deliberately fail-open on any timeout, so CI-load-induced latency in this chain silently downgrades a security block into an allow. Combines the first three git rev-parse calls (git-dir, branch via --abbrev-ref, worktree-toplevel) into ONE spawn instead of three, using git's documented multi-flag rev-parse output (one line per flag, in order) — verified empirically including the detached-HEAD edge case. No timeout value changed; this reduces subprocess COUNT, not any tolerance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs: add changeset fragment for the worktree-path-guard fix

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-09-09 11:45:44 -04:00
committed by GitHub
parent 5823d2ec7a
commit 30468f16fe
15 changed files with 221 additions and 97 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 4575
---
**The worktree-path guard no longer fails open under CI/process load** — it combined three sequential `git` subprocess spawns into one, cutting the worktree-escape check's worst-case latency so a busy runner can no longer push the guard past its own timeout into a silent allow. (#4515)

View File

@@ -34,10 +34,8 @@
"tests/emitted-ack-trailer.test.cjs", "tests/emitted-ack-trailer.test.cjs",
"tests/emitted-attribution.test.cjs", "tests/emitted-attribution.test.cjs",
"tests/execute-wave-post-gate-pipeline-e2e.test.cjs", "tests/execute-wave-post-gate-pipeline-e2e.test.cjs",
"tests/faulty-deps.test.cjs",
"tests/feat-2483-review-claude-mds-guard.test.cjs", "tests/feat-2483-review-claude-mds-guard.test.cjs",
"tests/federated-config.test.cjs", "tests/federated-config.test.cjs",
"tests/fragment-single-edit-propagation.install.test.cjs",
"tests/gate-predicate-evaluator.test.cjs", "tests/gate-predicate-evaluator.test.cjs",
"tests/gemini-runtime-removed.test.cjs", "tests/gemini-runtime-removed.test.cjs",
"tests/gen-context-index.test.cjs", "tests/gen-context-index.test.cjs",
@@ -54,11 +52,6 @@
"tests/hooks-commonjs-marker.test.cjs", "tests/hooks-commonjs-marker.test.cjs",
"tests/hooks-crash-policy.test.cjs", "tests/hooks-crash-policy.test.cjs",
"tests/init.test.cjs", "tests/init.test.cjs",
"tests/install-minimal-hooks.test.cjs",
"tests/install-regressions.test.cjs",
"tests/install-runtime-artifacts.test.cjs",
"tests/install-write-confinement.test.cjs",
"tests/install.test.cjs",
"tests/kilo-upgrades.test.cjs", "tests/kilo-upgrades.test.cjs",
"tests/kimi-upgrades.test.cjs", "tests/kimi-upgrades.test.cjs",
"tests/kimi-variant-disambiguation.test.cjs", "tests/kimi-variant-disambiguation.test.cjs",
@@ -71,9 +64,7 @@
"tests/loop-walk.qa.test.cjs", "tests/loop-walk.qa.test.cjs",
"tests/milestone-lock.test.cjs", "tests/milestone-lock.test.cjs",
"tests/no-pending-3212-markers.test.cjs", "tests/no-pending-3212-markers.test.cjs",
"tests/npm-integrity-gate.test.cjs",
"tests/opencode-plugin-adapter.test.cjs", "tests/opencode-plugin-adapter.test.cjs",
"tests/packaging-shipped-scripts-require-only-shipped.test.cjs",
"tests/pattern.test.cjs", "tests/pattern.test.cjs",
"tests/perf-316-state-lock-buffer-alloc.test.cjs", "tests/perf-316-state-lock-buffer-alloc.test.cjs",
"tests/perf-317-context-monitor-fs.test.cjs", "tests/perf-317-context-monitor-fs.test.cjs",
@@ -83,7 +74,6 @@
"tests/plan-phase-stall-detection.test.cjs", "tests/plan-phase-stall-detection.test.cjs",
"tests/plan-pre-hook-e2e.test.cjs", "tests/plan-pre-hook-e2e.test.cjs",
"tests/plan-review-convergence.test.cjs", "tests/plan-review-convergence.test.cjs",
"tests/plugin-manifest.test.cjs",
"tests/policy-160-route0-resume.test.cjs", "tests/policy-160-route0-resume.test.cjs",
"tests/prohibition-enforcement.test.cjs", "tests/prohibition-enforcement.test.cjs",
"tests/prompt-injection-scan.security.test.cjs", "tests/prompt-injection-scan.security.test.cjs",
@@ -92,7 +82,6 @@
"tests/read-guard.test.cjs", "tests/read-guard.test.cjs",
"tests/read-injection-scanner.property.test.cjs", "tests/read-injection-scanner.property.test.cjs",
"tests/read-injection-scanner.security.test.cjs", "tests/read-injection-scanner.security.test.cjs",
"tests/release-tarball-smoke.install.test.cjs",
"tests/representative-corpus.test.cjs", "tests/representative-corpus.test.cjs",
"tests/review-lane-invocation.test.cjs", "tests/review-lane-invocation.test.cjs",
"tests/reviewer-manifest-body.test.cjs", "tests/reviewer-manifest-body.test.cjs",

View File

@@ -163,17 +163,37 @@ process.stdin.on('end', () => {
// returns a path containing .git/worktrees/ as a component. // returns a path containing .git/worktrees/ as a component.
// In the main repo or a submodule it returns .git (or a path without /worktrees/). // In the main repo or a submodule it returns .git (or a path without /worktrees/).
// This approach works even when cwd is a subdirectory of the worktree. // This approach works even when cwd is a subdirectory of the worktree.
const gitDirResult = git(['rev-parse', '--git-dir'], cwd); // Combined into one spawn — git rev-parse accepts multiple query flags in
// one invocation and prints one line of output per flag, in the exact
// order given, reducing this guard's worst-case subprocess count under
// CI/load contention (three spawns collapse into one). `--abbrev-ref HEAD`
// is used instead of `symbolic-ref --short HEAD` because it is combinable
// (a single `rev-parse` call) and behaviorally equivalent for this guard's
// branch-acceptance check, including on detached HEAD: `--abbrev-ref`
// returns the literal string `HEAD` there (exit 0), which the acceptance
// regex below also rejects — the same guard outcome as symbolic-ref's
// exit-128/empty-stdout failure. Do not change any timeout value as part
// of this change, only the spawn count.
const combinedResult = git(['rev-parse', '--git-dir', '--abbrev-ref', 'HEAD', '--show-toplevel'], cwd);
// #3911: a timeout/spawn-failure result is indistinguishable from a clean // #3911: a timeout/spawn-failure result is indistinguishable from a clean
// "not a git repo" answer by status/stdout alone — reportIfUndetermined // "not a git repo" answer by status/stdout alone — reportIfUndetermined
// is a no-op on a genuine negative and only fires the diagnostic when the // is a no-op on a genuine negative and only fires the diagnostic when the
// probe itself could not run. The allow() below is UNCHANGED either way. // probe itself could not run. The allow() below is UNCHANGED either way.
reportIfUndetermined('gsd-worktree-path-guard', 'git rev-parse --git-dir', gitDirResult); reportIfUndetermined(
if (gitDirResult.status !== 0 || !gitDirResult.stdout) { 'gsd-worktree-path-guard',
'git rev-parse --git-dir --abbrev-ref HEAD --show-toplevel',
combinedResult
);
if (combinedResult.status !== 0 || !combinedResult.stdout) {
allow(undefined); // not a git repo — pass through allow(undefined); // not a git repo — pass through
} }
const gitDir = gitDirResult.stdout.trim(); const combinedLines = combinedResult.stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
if (combinedLines.length < 3) {
allow(undefined); // malformed/short output — can't determine root, fail open
}
const [gitDir, branch, wtTopRaw] = combinedLines;
// A linked worktree's --git-dir contains .git/worktrees/ as a path component // A linked worktree's --git-dir contains .git/worktrees/ as a path component
const isLinkedWorktree = /[/\\]\.git[/\\]worktrees[/\\]/.test(gitDir); const isLinkedWorktree = /[/\\]\.git[/\\]worktrees[/\\]/.test(gitDir);
if (!isLinkedWorktree) { if (!isLinkedWorktree) {
@@ -186,23 +206,14 @@ process.stdin.on('end', () => {
// created linked worktree (plain non-GSD work, e.g. Claude Code plan-mode) is // created linked worktree (plain non-GSD work, e.g. Claude Code plan-mode) is
// on the user's own branch, so the guard must be a no-op there. Detached HEAD // on the user's own branch, so the guard must be a no-op there. Detached HEAD
// / error → not GSD-managed → no-op. // / error → not GSD-managed → no-op.
const branchResult = git(['symbolic-ref', '--short', 'HEAD'], cwd);
reportIfUndetermined('gsd-worktree-path-guard', 'git symbolic-ref --short HEAD', branchResult);
const branch = branchResult.status === 0 && branchResult.stdout ? branchResult.stdout.trim() : '';
// #3021: accept worktree-wf_<runid>-<n> branches (Workflow backend's naming). // #3021: accept worktree-wf_<runid>-<n> branches (Workflow backend's naming).
if (!/^((worktree-)?agent-|worktree-wf_)[A-Za-z0-9._/-]+$/.test(branch)) { if (!/^((worktree-)?agent-|worktree-wf_)[A-Za-z0-9._/-]+$/.test(branch)) {
allow(undefined); // not a GSD-managed executor worktree — no-op allow(undefined); // not a GSD-managed executor worktree — no-op
} }
// Get the raw --show-toplevel output for the worktree (cwd). // wtTopRaw: the raw --show-toplevel output for the worktree (cwd).
// We keep it raw (not path.resolve'd) to compare directly with the // We keep it raw (not path.resolve'd) to compare directly with the
// file's toplevel — same git binary, same format, no normalization needed. // file's toplevel — same git binary, same format, no normalization needed.
const wtTopResult = git(['rev-parse', '--show-toplevel'], cwd);
reportIfUndetermined('gsd-worktree-path-guard', 'git rev-parse --show-toplevel (worktree cwd)', wtTopResult);
if (wtTopResult.status !== 0 || !wtTopResult.stdout) {
allow(undefined); // can't determine root — fail open
}
const wtTopRaw = wtTopResult.stdout.trim();
// #2595 (review Major 3): read the field TYPED. `?.file_path || ''` let a // #2595 (review Major 3): read the field TYPED. `?.file_path || ''` let a
// non-string through — `[]` and `{}` are truthy, so they survived the // non-string through — `[]` and `{}` are truthy, so they survived the

View File

@@ -33,6 +33,17 @@ const { defaultPhaseCleanCommitTimesMs } = require(path.join(__dirname, '..', 'g
const { createTempGitProject, createTempDir, cleanup } = require('./helpers.cjs'); const { createTempGitProject, createTempDir, cleanup } = require('./helpers.cjs');
const { makeFaultyGit, withFaultyFs } = require('./helpers/faulty-deps.cjs'); const { makeFaultyGit, withFaultyFs } = require('./helpers/faulty-deps.cjs');
// A bound so small process creation cannot complete inside it, making the
// ETIMEDOUT kill path deterministic on a real (unmocked) git call; used at
// both sentinel sites in this file.
const DETERMINISTIC_TIMEOUT_SENTINEL_MS = 1;
// Bounds a real (unmocked) `git var GIT_EDITOR` call proving env passthrough;
// deliberately not GIT_TIMEOUT_MS (15000ms) since this site's pre-existing
// value differs and this migration never raises a bound without a fresh
// bench citation.
const GIT_VAR_PROBE_TIMEOUT_MS = 5000;
// ─── A. execGit normalization (#3071) ────────────────────────────────────── // ─── A. execGit normalization (#3071) ──────────────────────────────────────
describe('A. execGit normalization (#3071)', () => { describe('A. execGit normalization (#3071)', () => {
@@ -71,7 +82,7 @@ describe('A. execGit normalization (#3071)', () => {
// complete inside 1ms, so the kill path is deterministic, matching the // complete inside 1ms, so the kill path is deterministic, matching the
// existing "wall-clock timeout" pattern used for dispatchGsdCommand in // existing "wall-clock timeout" pattern used for dispatchGsdCommand in
// shell-command-projection-dispatch.test.cjs. // shell-command-projection-dispatch.test.cjs.
const result = execGit(['status', '--porcelain'], { cwd: tmpDir, timeout: 1 }); const result = execGit(['status', '--porcelain'], { cwd: tmpDir, timeout: DETERMINISTIC_TIMEOUT_SENTINEL_MS });
assert.strictEqual(result.timedOut, true); assert.strictEqual(result.timedOut, true);
assert.strictEqual(result.error && result.error.code, 'ETIMEDOUT'); assert.strictEqual(result.error && result.error.code, 'ETIMEDOUT');
}); });
@@ -117,7 +128,7 @@ describe('A. execGit normalization (#3071)', () => {
const result = execGit(['var', 'GIT_EDITOR'], { const result = execGit(['var', 'GIT_EDITOR'], {
cwd: tmpDir, cwd: tmpDir,
env: { GIT_EDITOR: 'fault-3056-sentinel-editor' }, env: { GIT_EDITOR: 'fault-3056-sentinel-editor' },
timeout: 5000, timeout: GIT_VAR_PROBE_TIMEOUT_MS,
}); });
assert.strictEqual(result.exitCode, 0); assert.strictEqual(result.exitCode, 0);
assert.strictEqual(result.stdout, 'fault-3056-sentinel-editor'); assert.strictEqual(result.stdout, 'fault-3056-sentinel-editor');
@@ -128,7 +139,7 @@ describe('A. execGit normalization (#3071)', () => {
t.after(() => cleanup(tmpDir)); t.after(() => cleanup(tmpDir));
const success = execGit(['status', '--porcelain'], { cwd: tmpDir }); const success = execGit(['status', '--porcelain'], { cwd: tmpDir });
const enoent = execTool('definitely-not-a-real-program-fault-3056', []); const enoent = execTool('definitely-not-a-real-program-fault-3056', []);
const timeout = execGit(['status', '--porcelain'], { cwd: tmpDir, timeout: 1 }); const timeout = execGit(['status', '--porcelain'], { cwd: tmpDir, timeout: DETERMINISTIC_TIMEOUT_SENTINEL_MS });
assert.strictEqual(typeof success.exitCode, 'number'); assert.strictEqual(typeof success.exitCode, 'number');
assert.strictEqual(typeof enoent.exitCode, 'number'); assert.strictEqual(typeof enoent.exitCode, 'number');
assert.strictEqual(typeof timeout.exitCode, 'number'); assert.strictEqual(typeof timeout.exitCode, 'number');

View File

@@ -60,6 +60,7 @@ const { gitOrThrow } = require('./helpers/git-fixture.cjs');
const { cleanup, createTempDir, readFileNormalized } = require('./helpers.cjs'); const { cleanup, createTempDir, readFileNormalized } = require('./helpers.cjs');
const { RUNTIME_META, installerEnv } = require('./helpers/install-shared.cjs'); const { RUNTIME_META, installerEnv } = require('./helpers/install-shared.cjs');
const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const { const {
buildOverlayRepo, buildOverlayRepo,
REPO_ROOT, REPO_ROOT,
@@ -199,6 +200,37 @@ const ORIGINAL_WORKFLOW_CONTENT = readFileNormalized(PILOT_WORKFLOW_PATH);
const FRAGMENT_SENTINEL = 'GSD-2933-FRAGMENT-EDIT-SENTINEL-4c1a9f'; const FRAGMENT_SENTINEL = 'GSD-2933-FRAGMENT-EDIT-SENTINEL-4c1a9f';
/**
* Bounds `runOverlayCheck`/`runOverlayGenerate` — a single overlay
* `scripts/gen-*.cjs` generator invocation (never the installer proper).
* The digits coincide with `INSTALL_TIMEOUT_MS` (also 120000ms), but this
* is a different operation class kept as its own local constant —
* coincidence, not collision.
*/
const GENERATOR_SCRIPT_TIMEOUT_MS = 120000;
/**
* Bounds `trackedFileSet`'s `git ls-files` over the FULL overlay tree.
* Deliberately NOT the shared `GIT_TIMEOUT_MS` (15000ms, `helpers/timeouts.cjs`),
* which describes small-fixture-repo git plumbing — a much lighter shape
* than scanning this file's overlay tree.
*/
const TRACKED_FILE_SET_TIMEOUT_MS = 120000;
/**
* Bounds the real `npm run regen:derived` spawn (row 21) — a full build
* plus eight generators, the single heaviest subprocess in this suite.
* 300000 (5min) was observed to be killed (status: null) near the very end
* of a genuinely completed run on a loaded bench (linux-node22), not from a
* real hang. 900000 (15min) is deliberately generous so this can never
* again flake on load while still catching a true hang.
* allow-spawn-timeout-ceiling: regen:derived is a full build plus eight
* generators; 300000 was observed killing a genuinely-completed run near
* the end on a loaded bench, not a real hang, so 900000 is deliberately
* above the 600000 ceiling to never repeat that flake.
*/
const REGEN_DERIVED_TIMEOUT_MS = 900000;
// ─── Helpers ──────────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────────
/** /**
@@ -226,7 +258,7 @@ function installOverlay(overlayRoot, runtime, extraArgs = []) {
const result = runNode(args, { const result = runNode(args, {
cwd: root, cwd: root,
env: installerEnv({ HOME: root, USERPROFILE: root }), env: installerEnv({ HOME: root, USERPROFILE: root }),
timeoutMs: 120000, timeoutMs: INSTALL_TIMEOUT_MS,
}); });
result.status = result.exitCode; result.status = result.exitCode;
assert.equal( assert.equal(
@@ -262,7 +294,7 @@ function installOverlayExpectingFailure(overlayRoot, runtime, extraArgs = []) {
const result = runNode(args, { const result = runNode(args, {
cwd: root, cwd: root,
env: installerEnv({ HOME: root, USERPROFILE: root }), env: installerEnv({ HOME: root, USERPROFILE: root }),
timeoutMs: 120000, timeoutMs: INSTALL_TIMEOUT_MS,
}); });
result.status = result.exitCode; result.status = result.exitCode;
return { configDir: root, root, result }; return { configDir: root, root, result };
@@ -281,7 +313,7 @@ function runOverlayCheck(overlayRoot, scriptRelPath, extraArgs = []) {
const result = runNode([scriptPath, '--check', ...extraArgs], { const result = runNode([scriptPath, '--check', ...extraArgs], {
cwd: overlayRoot, cwd: overlayRoot,
env: installerEnv(), env: installerEnv(),
timeoutMs: 120000, timeoutMs: GENERATOR_SCRIPT_TIMEOUT_MS,
}); });
result.status = result.exitCode; result.status = result.exitCode;
return result; return result;
@@ -299,7 +331,7 @@ function runOverlayGenerate(overlayRoot, scriptRelPath) {
const result = runNode([scriptPath], { const result = runNode([scriptPath], {
cwd: overlayRoot, cwd: overlayRoot,
env: installerEnv(), env: installerEnv(),
timeoutMs: 120000, timeoutMs: GENERATOR_SCRIPT_TIMEOUT_MS,
}); });
result.status = result.exitCode; result.status = result.exitCode;
return result; return result;
@@ -327,7 +359,7 @@ function runOverlayGenerate(overlayRoot, scriptRelPath) {
function trackedFileSet(repoRoot) { function trackedFileSet(repoRoot) {
const stdout = gitOrThrow(['-c', 'safe.directory=*', 'ls-files'], { const stdout = gitOrThrow(['-c', 'safe.directory=*', 'ls-files'], {
cwd: repoRoot, cwd: repoRoot,
timeoutMs: 120000, timeoutMs: TRACKED_FILE_SET_TIMEOUT_MS,
}); });
return stdout.split('\n').map((line) => line.trim()).filter(Boolean); return stdout.split('\n').map((line) => line.trim()).filter(Boolean);
} }
@@ -1198,17 +1230,9 @@ test('regenDerivedPropagatesSingleFragmentEditWithNoSecondSourceSurface', {
cwd: overlay, cwd: overlay,
encoding: 'utf8', encoding: 'utf8',
env: installerEnv(), env: installerEnv(),
// `regen:derived` chains a full `npm run build` plus eight generators — // See REGEN_DERIVED_TIMEOUT_MS's own doc comment (top of file) for the
// the single heaviest subprocess in this suite. 300_000 (5min) was // full rationale, including the allow-spawn-timeout-ceiling annotation.
// observed to be killed (status: null) near the very end of a genuinely timeout: REGEN_DERIVED_TIMEOUT_MS,
// completed run on a loaded bench (linux-node22), not from a real hang.
// 900_000 (15min) is deliberately generous so this can never again flake
// on load while still catching a true hang.
// allow-spawn-timeout-ceiling: regen:derived is a full build plus eight
// generators; 300_000 was observed killing a genuinely-completed run
// near the end on a loaded bench, not a real hang, so 900_000 is
// deliberately above the 600000 ceiling to never repeat that flake.
timeout: 900000,
maxBuffer: 64 * 1024 * 1024, maxBuffer: 64 * 1024 * 1024,
}); });
assert.equal( assert.equal(

View File

@@ -127,6 +127,20 @@ const SEAM_DEFAULT_TIMEOUT_MS = 60000;
*/ */
const QUICK_SPAWN_TIMEOUT_MS = 10000; const QUICK_SPAWN_TIMEOUT_MS = 10000;
/**
* NOT a subprocess spawn timeout. This is fixture DATA -- the numeric value
* placed inside a fixture JSON object that mimics a Claude Code
* `settings.json` hook-entry's own `timeout` field, whose schema expresses
* that field in SECONDS. Do not pass this into a `spawnSync`/`execFileSync`
* options object -- every other constant in this file is milliseconds, this
* one is not.
*
* Shared across >=2 files in batch #4515 of the ad hoc timeout literal
* migration, epic #4445 -- that is why it lives here rather than as a
* file-local constant.
*/
const FIXTURE_HOOK_TIMEOUT_SECONDS = 5;
module.exports = { module.exports = {
PROBE_TIMEOUT_MS, PROBE_TIMEOUT_MS,
HOOK_FANOUT_TIMEOUT_MS, HOOK_FANOUT_TIMEOUT_MS,
@@ -136,4 +150,5 @@ module.exports = {
INSTALL_TIMEOUT_MS, INSTALL_TIMEOUT_MS,
SEAM_DEFAULT_TIMEOUT_MS, SEAM_DEFAULT_TIMEOUT_MS,
QUICK_SPAWN_TIMEOUT_MS, QUICK_SPAWN_TIMEOUT_MS,
FIXTURE_HOOK_TIMEOUT_SECONDS,
}; };

View File

@@ -29,6 +29,21 @@ const os = require('node:os');
const { runNode } = require('./helpers/process-seam.cjs'); const { runNode } = require('./helpers/process-seam.cjs');
const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const {
PROBE_TIMEOUT_MS,
INSTALL_TIMEOUT_MS,
FIXTURE_HOOK_TIMEOUT_SECONDS,
} = require('./helpers/timeouts.cjs');
/**
* Bounds executing a single already-staged hook script directly (not the
* installer itself, just the emitted script under a real node process).
* 30000ms digits coincide with the shared BUILD_TIMEOUT_MS, but this is a
* different operation class (running staged output vs. bundling it), so it
* is kept as its own local constant rather than aliased onto that norm.
*/
const INSTALLED_HOOK_EXEC_TIMEOUT_MS = 30000;
const { createTempDir, cleanup } = require('./helpers.cjs'); const { createTempDir, cleanup } = require('./helpers.cjs');
const { const {
@@ -115,7 +130,7 @@ describe('install-profiles: MINIMAL_SKILL_ALLOWLIST', () => {
describe('install: --help profile counts match PROFILES (#834)', () => { describe('install: --help profile counts match PROFILES (#834)', () => {
function helpText() { function helpText() {
const r = runNode([INSTALL_SCRIPT, '--help'], { env: installerEnv(), timeoutMs: 15000 }); const r = runNode([INSTALL_SCRIPT, '--help'], { env: installerEnv(), timeoutMs: PROBE_TIMEOUT_MS });
throwIfFailed(r, `node ${INSTALL_SCRIPT} --help`); throwIfFailed(r, `node ${INSTALL_SCRIPT} --help`);
return r.stdout; return r.stdout;
} }
@@ -410,7 +425,7 @@ function sharedMinimalManifestInstall() {
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-minimal-shared-')); const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-minimal-shared-'));
runNode( runNode(
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, '--minimal'], [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, '--minimal'],
{ env: installerEnv(), timeoutMs: 120000 }, { env: installerEnv(), timeoutMs: INSTALL_TIMEOUT_MS },
); );
const manifestPath = path.join(targetDir, MANIFEST_NAME); const manifestPath = path.join(targetDir, MANIFEST_NAME);
const m = fs.existsSync(manifestPath) ? JSON.parse(fs.readFileSync(manifestPath, 'utf8')) : {}; const m = fs.existsSync(manifestPath) ? JSON.parse(fs.readFileSync(manifestPath, 'utf8')) : {};
@@ -433,7 +448,7 @@ describe('install: manifest records mode for both profiles', () => {
try { try {
runNode( runNode(
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, ...extraArgs], [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, ...extraArgs],
{ env: installerEnv(), timeoutMs: 120000 }, { env: installerEnv(), timeoutMs: INSTALL_TIMEOUT_MS },
); );
const manifestPath = path.join(targetDir, MANIFEST_NAME); const manifestPath = path.join(targetDir, MANIFEST_NAME);
if (!fs.existsSync(manifestPath)) return { mode: '<no manifest>', skillCount: 0, agentCount: 0 }; if (!fs.existsSync(manifestPath)) return { mode: '<no manifest>', skillCount: 0, agentCount: 0 };
@@ -486,7 +501,7 @@ describe('install-minimal-backcompat: --minimal and --profile=core produce same
try { try {
runNode( runNode(
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, ...extraArgs], [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, ...extraArgs],
{ env: installerEnv(), timeoutMs: 120000 }, { env: installerEnv(), timeoutMs: INSTALL_TIMEOUT_MS },
); );
const manifestPath = path.join(targetDir, MANIFEST_NAME); const manifestPath = path.join(targetDir, MANIFEST_NAME);
if (!fs.existsSync(manifestPath)) return { mode: null, skillCount: 0, profileMarker: null }; if (!fs.existsSync(manifestPath)) return { mode: null, skillCount: 0, profileMarker: null };
@@ -562,7 +577,7 @@ describe('install: Codex full → minimal downgrade cleans stale agent state', (
// config.toml (both under targetDir), so the sandbox has no effect on intent. // config.toml (both under targetDir), so the sandbox has no effect on intent.
const result = runNode( const result = runNode(
[INSTALL_SCRIPT, '--codex', '--global', '--config-dir', targetDir, '--minimal'], [INSTALL_SCRIPT, '--codex', '--global', '--config-dir', targetDir, '--minimal'],
{ env: installerEnv({ HOME: targetDir, USERPROFILE: targetDir }), timeoutMs: 120000 }, { env: installerEnv({ HOME: targetDir, USERPROFILE: targetDir }), timeoutMs: INSTALL_TIMEOUT_MS },
); );
assert.ok(result.stdout || result.stderr); assert.ok(result.stdout || result.stderr);
@@ -599,7 +614,7 @@ describe('install: Claude full → minimal downgrade removes stale agents', () =
runNode( runNode(
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, '--minimal'], [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, '--minimal'],
{ env: installerEnv(), timeoutMs: 120000 }, { env: installerEnv(), timeoutMs: INSTALL_TIMEOUT_MS },
); );
const remaining = fs.existsSync(agentsDir) ? fs.readdirSync(agentsDir) : []; const remaining = fs.existsSync(agentsDir) ? fs.readdirSync(agentsDir) : [];
@@ -691,7 +706,7 @@ describe('#1821/#2305: ZCode receives no dead hook files; Kilo/OpenCode/Claude k
try { try {
const result = runNode( const result = runNode(
[INSTALL_SCRIPT, `--${runtime}`, '--global', '--config-dir', targetDir], [INSTALL_SCRIPT, `--${runtime}`, '--global', '--config-dir', targetDir],
{ env: installerEnv(), timeoutMs: 120000 }, { env: installerEnv(), timeoutMs: INSTALL_TIMEOUT_MS },
); );
assert.strictEqual(result.exitCode, 0, assert.strictEqual(result.exitCode, 0,
`installer exited with status ${result.exitCode} for --${runtime} --global\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); `installer exited with status ${result.exitCode} for --${runtime} --global\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
@@ -2851,7 +2866,7 @@ function runInstaller(configDir) {
// in install-smoke.yml). // in install-smoke.yml).
throwIfFailed( throwIfFailed(
runNode([INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], { runNode([INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], {
timeoutMs: 120000, timeoutMs: INSTALL_TIMEOUT_MS,
env: { env: {
...process.env, ...process.env,
CLAUDE_CONFIG_DIR: configDir, CLAUDE_CONFIG_DIR: configDir,
@@ -2970,7 +2985,7 @@ describe('#4087 regression: Codex install stages the hook helpers its hooks requ
// child's env. // child's env.
throwIfFailed( throwIfFailed(
runNode([INSTALL_SCRIPT, '--codex', '--global', '--yes', '--no-sdk', '--config-dir', configDir], { runNode([INSTALL_SCRIPT, '--codex', '--global', '--yes', '--no-sdk', '--config-dir', configDir], {
timeoutMs: 120000, timeoutMs: INSTALL_TIMEOUT_MS,
env: { ...process.env, HOME: configDir, USERPROFILE: configDir }, env: { ...process.env, HOME: configDir, USERPROFILE: configDir },
}), }),
`node ${INSTALL_SCRIPT} --codex --global --config-dir ${configDir}`, `node ${INSTALL_SCRIPT} --codex --global --config-dir ${configDir}`,
@@ -3224,7 +3239,7 @@ describe('#4087 review: Windsurf install stages the hook helpers its hooks requi
// HOME/USERPROFILE sandboxed for the CHILD, for the same reason as installCodex. // HOME/USERPROFILE sandboxed for the CHILD, for the same reason as installCodex.
throwIfFailed( throwIfFailed(
runNode([INSTALL_SCRIPT, '--windsurf', '--global', '--yes', '--config-dir', configDir], { runNode([INSTALL_SCRIPT, '--windsurf', '--global', '--yes', '--config-dir', configDir], {
timeoutMs: 120000, timeoutMs: INSTALL_TIMEOUT_MS,
env: { ...process.env, HOME: configDir, USERPROFILE: configDir }, env: { ...process.env, HOME: configDir, USERPROFILE: configDir },
}), }),
`node ${INSTALL_SCRIPT} --windsurf --global --config-dir ${configDir}`, `node ${INSTALL_SCRIPT} --windsurf --global --config-dir ${configDir}`,
@@ -3238,7 +3253,7 @@ describe('#4087 review: Windsurf install stages the hook helpers its hooks requi
for (const script of ['gsd-windsurf-pre-write.js', 'gsd-windsurf-pre-command.js']) { for (const script of ['gsd-windsurf-pre-write.js', 'gsd-windsurf-pre-command.js']) {
const hook = path.join(hooksDir, script); const hook = path.join(hooksDir, script);
assert.ok(fs.existsSync(hook), `precondition: ${script} must be staged`); assert.ok(fs.existsSync(hook), `precondition: ${script} must be staged`);
const result = runNode([hook], { timeoutMs: 30000, input: '{}', env: { ...process.env } }); const result = runNode([hook], { timeoutMs: INSTALLED_HOOK_EXEC_TIMEOUT_MS, input: '{}', env: { ...process.env } });
assert.strictEqual(result.outcome, 'exited', assert.strictEqual(result.outcome, 'exited',
`${script} must run to completion, not time out or be killed. outcome=${result.outcome}`); `${script} must run to completion, not time out or be killed. outcome=${result.outcome}`);
assert.strictEqual(result.exitCode, 0, assert.strictEqual(result.exitCode, 0,
@@ -3391,7 +3406,7 @@ describe('bug #3981: blocking-guard timeout budget + migration', () => {
hooks: { hooks: {
PreToolUse: [{ PreToolUse: [{
matcher: 'Write|Edit', matcher: 'Write|Edit',
hooks: [{ type: 'command', command: `node ${path.join(targetDir, 'hooks', guard)}`, timeout: 5 }], hooks: [{ type: 'command', command: `node ${path.join(targetDir, 'hooks', guard)}`, timeout: FIXTURE_HOOK_TIMEOUT_SECONDS }],
}], }],
}, },
}; };
@@ -3423,7 +3438,7 @@ describe('bug #3981: blocking-guard timeout budget + migration', () => {
}); });
test('non-managed timeout:5 entries are left alone (#3981)', () => { test('non-managed timeout:5 entries are left alone (#3981)', () => {
const mine = { type: 'command', command: 'node /usr/local/bin/my-own-hook.js', timeout: 5 }; const mine = { type: 'command', command: 'node /usr/local/bin/my-own-hook.js', timeout: FIXTURE_HOOK_TIMEOUT_SECONDS };
const settings = { hooks: { PreToolUse: [{ matcher: 'Write', hooks: [mine] }] } }; const settings = { hooks: { PreToolUse: [{ matcher: 'Write', hooks: [mine] }] } };
const localCmd = (hookFile) => `node ${path.join(targetDir, 'hooks', hookFile)}`; const localCmd = (hookFile) => `node ${path.join(targetDir, 'hooks', hookFile)}`;
captureConsole(() => { captureConsole(() => {

View File

@@ -21,7 +21,12 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { runNode } = require('./helpers/process-seam.cjs'); const { runNode } = require('./helpers/process-seam.cjs');
const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const { INSTALL_TIMEOUT_MS, FIXTURE_HOOK_TIMEOUT_SECONDS } = require('./helpers/timeouts.cjs');
// Bounds a scoped/targeted runNode([INSTALL_SCRIPT, ...]) invocation — a
// lighter shape than the shared full-install INSTALL_TIMEOUT_MS class
// (120000ms), so kept as its own constant rather than raised to that bound.
const SCOPED_INSTALL_TIMEOUT_MS = 60_000;
const { createTempDir, cleanup, mockPartialWriteThenThrow } = require('./helpers.cjs'); const { createTempDir, cleanup, mockPartialWriteThenThrow } = require('./helpers.cjs');
const { const {
@@ -95,7 +100,7 @@ describe('#2429 regression: Codex local skills stay project-scoped', () => {
const result = runNode( const result = runNode(
[INSTALL_SCRIPT, '--codex', '--local', '--profile=core'], [INSTALL_SCRIPT, '--codex', '--local', '--profile=core'],
{ cwd: projectDir, env, timeoutMs: 60_000 }, { cwd: projectDir, env, timeoutMs: SCOPED_INSTALL_TIMEOUT_MS },
); );
assert.strictEqual( assert.strictEqual(
@@ -1118,7 +1123,7 @@ describe('#1004 regression: installer does not duplicate managed hooks when regi
{ {
type: 'http', type: 'http',
url: hookUrl, url: hookUrl,
timeout: 5, timeout: FIXTURE_HOOK_TIMEOUT_SECONDS,
}, },
], ],
}, },
@@ -1970,7 +1975,7 @@ describe('#1874 F6 (#338 migration): a malformed settings.local.json is preserve
delete env.GSD_TEST_MODE; delete env.GSD_TEST_MODE;
const result = runNode( const result = runNode(
[INSTALL_SCRIPT, '--claude', '--local'], [INSTALL_SCRIPT, '--claude', '--local'],
{ cwd: root, env, timeoutMs: 60_000 }, { cwd: root, env, timeoutMs: SCOPED_INSTALL_TIMEOUT_MS },
); );
assert.strictEqual(result.exitCode, 0, assert.strictEqual(result.exitCode, 0,
`installer exited ${result.exitCode}\n${result.stdout}\n${result.stderr}`); `installer exited ${result.exitCode}\n${result.stdout}\n${result.stderr}`);
@@ -2018,7 +2023,7 @@ describe('#1874 F6 adjacent: malformed settings.local.json does not crash the in
delete env.GSD_TEST_MODE; delete env.GSD_TEST_MODE;
const result = runNode( const result = runNode(
[INSTALL_SCRIPT, '--claude', '--local'], [INSTALL_SCRIPT, '--claude', '--local'],
{ cwd: root, env, timeoutMs: 60_000 }, { cwd: root, env, timeoutMs: SCOPED_INSTALL_TIMEOUT_MS },
); );
assert.strictEqual(result.exitCode, 0, assert.strictEqual(result.exitCode, 0,

View File

@@ -32,6 +32,20 @@ const { splitLines, joinLines } = require('../gsd-core/bin/lib/text-lines.cjs');
const { createTempDir, cleanup, writePackageSourceMarkerFixture } = require('./helpers.cjs'); const { createTempDir, cleanup, writePackageSourceMarkerFixture } = require('./helpers.cjs');
const { runNode } = require('./helpers/process-seam.cjs'); const { runNode } = require('./helpers/process-seam.cjs');
const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
// Bound the writer subprocess so a regression that hangs the writer
// (or the dispatcher) cannot deadlock CI (PR #3003 CR feedback).
// 30s is generous for what should complete in <1s; if it trips,
// surface that as a clear test failure rather than CI hanging.
const WRITER_SUBPROCESS_TIMEOUT_MS = 30_000;
// Bounds a real `gsd-tools.cjs capability install` subprocess run against a
// fully-installed tree; digits coincide with the shared
// HOOK_FANOUT_TIMEOUT_MS/SEAM_DEFAULT_TIMEOUT_MS constants but this is
// neither a hook fan-out nor an omitted-default seam call, so kept as its
// own constant.
const CAPABILITY_INSTALL_TIMEOUT_MS = 60000;
const { const {
INSTALL_SCRIPT, INSTALL_SCRIPT,
MANIFEST_NAME, MANIFEST_NAME,
@@ -5564,11 +5578,9 @@ describe('Bug #2973: dev-preferences default writer path is skills/gsd-dev-prefe
// landed in the developer's live config dir instead of tmpHome. // landed in the developer's live config dir instead of tmpHome.
env: Object.assign({}, process.env, TEST_ENV_BASE, { HOME: tmpHome, USERPROFILE: tmpHome }), env: Object.assign({}, process.env, TEST_ENV_BASE, { HOME: tmpHome, USERPROFILE: tmpHome }),
encoding: 'utf-8', encoding: 'utf-8',
// Bound the subprocess so a regression that hangs the writer // See WRITER_SUBPROCESS_TIMEOUT_MS's own doc comment (top of file)
// (or the dispatcher) cannot deadlock CI (PR #3003 CR feedback). // for the full rationale.
// 30s is generous for what should complete in <1s; if it trips, timeout: WRITER_SUBPROCESS_TIMEOUT_MS,
// surface that as a clear test failure rather than CI hanging.
timeout: 30_000,
}); });
assert.equal(result.signal, null, assert.equal(result.signal, null,
`writer subprocess was killed by signal ${result.signal} (likely timeout): ${result.stderr}`); `writer subprocess was killed by signal ${result.signal} (likely timeout): ${result.stderr}`);
@@ -6948,7 +6960,7 @@ function realInstall() {
const res = spawnSync( const res = spawnSync(
process.execPath, process.execPath,
[INSTALL, '--claude', '--global', '--config-dir', dir], [INSTALL, '--claude', '--global', '--config-dir', dir],
{ encoding: 'utf8', timeout: 120000, env: childEnv }, { encoding: 'utf8', timeout: INSTALL_TIMEOUT_MS, env: childEnv },
); );
assert.strictEqual(res.status, 0, `install --claude failed: ${res.stderr || res.stdout}`); assert.strictEqual(res.status, 0, `install --claude failed: ${res.stderr || res.stdout}`);
return dir; return dir;
@@ -6992,7 +7004,7 @@ describe('Gap 1 (end-to-end CLI): installed capability install uses the real hos
const res = spawnSync( const res = spawnSync(
process.execPath, process.execPath,
[installedTools, 'capability', 'install', src, '--scope', 'global', '--yes', '--json'], [installedTools, 'capability', 'install', src, '--scope', 'global', '--yes', '--json'],
{ cwd, env, encoding: 'utf8', timeout: 60000 }, { cwd, env, encoding: 'utf8', timeout: CAPABILITY_INSTALL_TIMEOUT_MS },
); );
const combined = `${res.stdout || ''}\n${res.stderr || ''}`; const combined = `${res.stdout || ''}\n${res.stderr || ''}`;
assert.doesNotMatch( assert.doesNotMatch(

View File

@@ -2048,6 +2048,7 @@ const os = require('node:os');
const path = require('node:path'); const path = require('node:path');
const { runNode } = require('./helpers/process-seam.cjs'); const { runNode } = require('./helpers/process-seam.cjs');
const { cleanup } = require('./helpers.cjs'); const { cleanup } = require('./helpers.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const ROOT = path.resolve(__dirname, '..'); const ROOT = path.resolve(__dirname, '..');
const DRIFT_LINT = path.join(ROOT, 'scripts', 'lint-shell-command-projection-drift.cjs'); const DRIFT_LINT = path.join(ROOT, 'scripts', 'lint-shell-command-projection-drift.cjs');
@@ -2055,7 +2056,7 @@ const DRIFT_LINT = path.join(ROOT, 'scripts', 'lint-shell-command-projection-dri
function runLint(targetFile) { function runLint(targetFile) {
const result = runNode([DRIFT_LINT, targetFile], { const result = runNode([DRIFT_LINT, targetFile], {
cwd: ROOT, cwd: ROOT,
timeoutMs: 15000, timeoutMs: PROBE_TIMEOUT_MS,
}); });
result.status = result.exitCode; result.status = result.exitCode;
return result; return result;
@@ -3836,6 +3837,7 @@ const crypto = require('node:crypto');
const ROOT = path.join(__dirname, '..'); const ROOT = path.join(__dirname, '..');
const INSTALL = require(path.join(ROOT, 'bin', 'install.js')); const INSTALL = require(path.join(ROOT, 'bin', 'install.js'));
const { cleanup, sandboxHome, scrubConfigLocationEnv } = require('./helpers.cjs'); const { cleanup, sandboxHome, scrubConfigLocationEnv } = require('./helpers.cjs');
const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const MANIFEST_NAME = 'gsd-file-manifest.json'; const MANIFEST_NAME = 'gsd-file-manifest.json';
const PATCHES_DIR_NAME = 'gsd-local-patches'; const PATCHES_DIR_NAME = 'gsd-local-patches';
@@ -3977,7 +3979,7 @@ describe('Bug #4086: saveLocalPatches resolves skills/ manifest keys at the runt
assert.deepEqual(modifiedList, [], 'without a runtime the old skip behavior applies'); assert.deepEqual(modifiedList, [], 'without a runtime the old skip behavior applies');
}); });
test('end-to-end codex reinstall backs up the modified skill (#4086)', { timeout: 120_000 }, () => { test('end-to-end codex reinstall backs up the modified skill (#4086)', { timeout: INSTALL_TIMEOUT_MS }, () => {
const origLog = console.log; const origLog = console.log;
const origWarn = console.warn; const origWarn = console.warn;
console.log = () => {}; console.log = () => {};

View File

@@ -34,7 +34,20 @@ const { runNode } = require('./helpers/process-seam.cjs');
const pkg = require('../package.json'); const pkg = require('../package.json');
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. // #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const { PROBE_TIMEOUT_MS, QUICK_SPAWN_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
// Fixture-JSON value (seconds, not ms) simulating the PRE-migration legacy
// settings.json hook-entry timeout under test (#3981-style migration test).
// Deliberately a different value than the shared FIXTURE_HOOK_TIMEOUT_SECONDS
// constant (5) since this site tests the old value specifically (batch #4515,
// epic #4445).
const FIXTURE_LEGACY_HOOK_TIMEOUT_SECONDS = 10;
// Bounds an `install.js --dry-run` run (no file writes) — a lighter shape than
// the shared full-write INSTALL_TIMEOUT_MS class (120000ms), kept separate
// rather than raised. Shared by both dry-run sites in this file (batch #4515,
// epic #4445).
const DRY_RUN_INSTALL_TIMEOUT_MS = 30_000;
const { const {
getConfigDirFromHome, getConfigDirFromHome,
@@ -1127,7 +1140,7 @@ describe('install — fix-slash-commands.cjs lands at scripts/fix-slash-commands
const result = spawnSync( const result = spawnSync(
process.execPath, process.execPath,
[gsdToolsPath, 'query', 'init.new-project'], [gsdToolsPath, 'query', 'init.new-project'],
{ encoding: 'utf8', timeout: 15000 }, { encoding: 'utf8', timeout: PROBE_TIMEOUT_MS },
); );
assert.ok( assert.ok(
!result.stderr.includes('MODULE_NOT_FOUND'), !result.stderr.includes('MODULE_NOT_FOUND'),
@@ -1215,7 +1228,7 @@ describe('readCmdNames() — tolerates missing commands/gsd directory (#1223)',
const spawnResult = spawnSync(process.execPath, ['-e', script], { const spawnResult = spawnSync(process.execPath, ['-e', script], {
encoding: 'utf8', encoding: 'utf8',
timeout: 10000, timeout: QUICK_SPAWN_TIMEOUT_MS,
env: { ...process.env, GSD_TEST_MODE: '1' }, env: { ...process.env, GSD_TEST_MODE: '1' },
}); });
assert.ok( assert.ok(
@@ -5568,7 +5581,7 @@ describe('#338 case 3: migration of prior local install GSD entries from setting
{ {
type: 'command', type: 'command',
command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-context-monitor.js')}`, command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-context-monitor.js')}`,
timeout: 10, timeout: FIXTURE_LEGACY_HOOK_TIMEOUT_SECONDS,
} }
] ]
} }
@@ -7368,7 +7381,7 @@ function installAndRead(runtime) {
const res = spawnSync( const res = spawnSync(
process.execPath, process.execPath,
[INSTALL, `--${runtime}`, '--global', '--config-dir', dir], [INSTALL, `--${runtime}`, '--global', '--config-dir', dir],
{ encoding: 'utf8', timeout: 120000, env: { ...process.env, HOME: dir, USERPROFILE: dir } }, { encoding: 'utf8', timeout: INSTALL_TIMEOUT_MS, env: { ...process.env, HOME: dir, USERPROFILE: dir } },
); );
assert.strictEqual(res.status, 0, `install --${runtime} failed: ${res.stderr || res.stdout}`); assert.strictEqual(res.status, 0, `install --${runtime} failed: ${res.stderr || res.stdout}`);
const wf = path.join(dir, 'gsd-core', 'workflows', 'execute-phase.md'); const wf = path.join(dir, 'gsd-core', 'workflows', 'execute-phase.md');
@@ -7809,7 +7822,7 @@ describe('#3026: installer --help documents every accepted runtime flag', () =>
test('every accepted runtime flag appears in --help output', () => { test('every accepted runtime flag appears in --help output', () => {
// Derive accepted flags behaviorally from the installer's argument parser. // Derive accepted flags behaviorally from the installer's argument parser.
const r = spawnSync(process.execPath, [INSTALL_PATH, '--help'], { encoding: 'utf-8', timeout: 10000 }); const r = spawnSync(process.execPath, [INSTALL_PATH, '--help'], { encoding: 'utf-8', timeout: QUICK_SPAWN_TIMEOUT_MS });
const helpText = r.stdout; const helpText = r.stdout;
// The installer's getRuntimeArgs defines which --<runtime> flags it accepts. // The installer's getRuntimeArgs defines which --<runtime> flags it accepts.
@@ -7976,7 +7989,7 @@ describe('#607 --dry-run flag: spawned installer exits 0 and mutates nothing', (
}, },
cwd: REPO_ROOT, cwd: REPO_ROOT,
encoding: 'utf8', encoding: 'utf8',
timeout: 30_000, timeout: DRY_RUN_INSTALL_TIMEOUT_MS,
} }
); );
@@ -8051,7 +8064,7 @@ describe('#607 --dry-run flag: spawned installer exits 0 and mutates nothing', (
}, },
cwd: REPO_ROOT, cwd: REPO_ROOT,
encoding: 'utf8', encoding: 'utf8',
timeout: 30_000, timeout: DRY_RUN_INSTALL_TIMEOUT_MS,
} }
); );

View File

@@ -23,11 +23,18 @@ const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process'); const { spawnSync } = require('node:child_process');
const path = require('node:path'); const path = require('node:path');
const { runNode } = require('./helpers/process-seam.cjs'); const { runNode } = require('./helpers/process-seam.cjs');
const { QUICK_SPAWN_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const ROOT = path.resolve(__dirname, '..'); const ROOT = path.resolve(__dirname, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'check-npm-integrity.cjs'); const SCRIPT = path.join(ROOT, 'scripts', 'check-npm-integrity.cjs');
const FIXTURES = path.join(__dirname, 'fixtures', 'npm-integrity'); const FIXTURES = path.join(__dirname, 'fixtures', 'npm-integrity');
// Bounds a real check-npm-integrity.cjs run via the process seam; digits
// coincide with the shared BUILD_TIMEOUT_MS but this is a different
// operation class (an integrity check, not a hooks build), so kept as its
// own constant rather than aliased.
const NPM_INTEGRITY_CHECK_TIMEOUT_MS = 30_000;
/** /**
* Run the integrity gate script against a fixture directory. * Run the integrity gate script against a fixture directory.
* *
@@ -37,7 +44,7 @@ const FIXTURES = path.join(__dirname, 'fixtures', 'npm-integrity');
*/ */
function runGate(fixtureName, extraArgs = []) { function runGate(fixtureName, extraArgs = []) {
const fixtureDir = path.join(FIXTURES, fixtureName); const fixtureDir = path.join(FIXTURES, fixtureName);
const r = runNode([SCRIPT, ...extraArgs], { cwd: fixtureDir, timeoutMs: 30_000 }); const r = runNode([SCRIPT, ...extraArgs], { cwd: fixtureDir, timeoutMs: NPM_INTEGRITY_CHECK_TIMEOUT_MS });
return { return {
status: r.exitCode ?? 1, status: r.exitCode ?? 1,
stdout: r.stdout, stdout: r.stdout,
@@ -145,7 +152,7 @@ describe('#114: npm integrity gate — --help output', () => {
const result = spawnSync(process.execPath, [SCRIPT, '--help'], { const result = spawnSync(process.execPath, [SCRIPT, '--help'], {
cwd: ROOT, cwd: ROOT,
encoding: 'utf-8', encoding: 'utf-8',
timeout: 10_000, timeout: QUICK_SPAWN_TIMEOUT_MS,
}); });
assert.strictEqual(result.status, 0, '--help should exit 0'); assert.strictEqual(result.status, 0, '--help should exit 0');
}); });
@@ -154,7 +161,7 @@ describe('#114: npm integrity gate — --help output', () => {
const result = spawnSync(process.execPath, [SCRIPT, '--help'], { const result = spawnSync(process.execPath, [SCRIPT, '--help'], {
cwd: ROOT, cwd: ROOT,
encoding: 'utf-8', encoding: 'utf-8',
timeout: 10_000, timeout: QUICK_SPAWN_TIMEOUT_MS,
}); });
// The .cjs script writes --help to stdout. // The .cjs script writes --help to stdout.
const helpText = (result.stdout ?? '') + (result.stderr ?? ''); const helpText = (result.stdout ?? '') + (result.stderr ?? '');

View File

@@ -23,6 +23,19 @@ const REPO_ROOT = path.join(__dirname, '..');
const { extractRequires } = require('./helpers/copy-script-fixture.cjs'); const { extractRequires } = require('./helpers/copy-script-fixture.cjs');
/**
* package.json's `prepack`/`prepare` runs a full `npm run build:lib` (tsc)
* before `npm pack` computes the file list — a bounded but non-trivial
* subprocess (~2s in isolation). Under the full parallel 28,000+-test CI
* matrix this has been observed to take 60.6s and trip a 60_000ms bound
* (#2931 gsd-test run d52d2ee4, linux-node24, duration_ms 60637.56),
* aborting this file's `before()` hook and cascading every sibling test to
* "cancelled". 120s keeps the bound finite (never unbounded, per the
* subprocess-timeout convention) while giving real headroom for a
* contended CI box.
*/
const NPM_PACK_TIMEOUT_MS = 120_000;
/** /**
* Resolve the tarball file list via `npm pack --dry-run --json`. * Resolve the tarball file list via `npm pack --dry-run --json`.
* This is the ACTUAL set of files that ship — not a hardcoded list — so adding * This is the ACTUAL set of files that ship — not a hardcoded list — so adding
@@ -34,16 +47,7 @@ function resolveTarballFiles() {
encoding: 'utf-8', encoding: 'utf-8',
shell: true, // Windows: npm is npm.cmd and needs a shell shell: true, // Windows: npm is npm.cmd and needs a shell
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
// package.json's `prepack`/`prepare` runs a full `npm run build:lib` timeout: NPM_PACK_TIMEOUT_MS,
// (tsc) before `npm pack` computes the file list — a bounded but
// non-trivial subprocess (~2s in isolation). Under the full parallel
// 28,000+-test CI matrix this has been observed to take 60.6s and trip
// a 60_000ms bound (#2931 gsd-test run d52d2ee4, linux-node24,
// duration_ms 60637.56), aborting this file's `before()` hook and
// cascading every sibling test to "cancelled". 120s keeps the bound
// finite (never unbounded, per the subprocess-timeout convention) while
// giving real headroom for a contended CI box.
timeout: 120_000,
}); });
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
return packListToPathSet(parsed); return packListToPathSet(parsed);

View File

@@ -25,10 +25,16 @@ const identity = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'package-iden
const pkg = require(path.join(ROOT, 'package.json')); const pkg = require(path.join(ROOT, 'package.json'));
const { MANAGED_HOOKS } = require(path.join(ROOT, 'hooks', 'managed-hooks-registry.cjs')); const { MANAGED_HOOKS } = require(path.join(ROOT, 'hooks', 'managed-hooks-registry.cjs'));
const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs'); const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json'); const PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json');
const HOOKS_JSON_PATH = path.join(ROOT, 'hooks', 'hooks.json'); const HOOKS_JSON_PATH = path.join(ROOT, 'hooks', 'hooks.json');
// Bounds a `claude --version` PATH probe; kept separate from the shared
// PROBE_TIMEOUT_MS (15000ms) since this site's pre-existing value differs
// and this migration never raises a bound without a fresh bench citation.
const CLAUDE_VERSION_PROBE_TIMEOUT_MS = 5000;
// ─── Section A: plugin.json ─────────────────────────────────────────────────── // ─── Section A: plugin.json ───────────────────────────────────────────────────
describe('A: .claude-plugin/plugin.json', () => { describe('A: .claude-plugin/plugin.json', () => {
@@ -401,7 +407,7 @@ describe('C: plugin.json schema validation', () => {
try { try {
const result = spawnSync('claude', ['--version'], { const result = spawnSync('claude', ['--version'], {
encoding: 'utf-8', encoding: 'utf-8',
timeout: 5000, timeout: CLAUDE_VERSION_PROBE_TIMEOUT_MS,
env: claudeCliEnv(), env: claudeCliEnv(),
}); });
return result.status === 0; return result.status === 0;
@@ -524,7 +530,7 @@ describe('C: plugin.json schema validation', () => {
const result = spawnSync('claude', ['plugin', 'validate', pluginRoot, '--strict'], { const result = spawnSync('claude', ['plugin', 'validate', pluginRoot, '--strict'], {
cwd: ROOT, cwd: ROOT,
encoding: 'utf-8', encoding: 'utf-8',
timeout: 15000, timeout: PROBE_TIMEOUT_MS,
env: claudeCliEnv(), env: claudeCliEnv(),
}); });
assert.equal( assert.equal(

View File

@@ -474,6 +474,11 @@ const { execFileSync } = require('node:child_process');
// The helpers under test. // The helpers under test.
const { isolatedNpmEnv, cleanup } = require('./helpers.cjs'); const { isolatedNpmEnv, cleanup } = require('./helpers.cjs');
// Bounds the `node -e` npm-harness spawn pattern shared by the three
// execFileSync(process.execPath, ['-e', script], ...) sites in this file,
// each of which requires helpers.cjs and calls runNpm(...).
const NPM_HARNESS_SPAWN_TIMEOUT_MS = 30_000;
// Resolve a filesystem path to its canonical (symlink-free) form even if the // Resolve a filesystem path to its canonical (symlink-free) form even if the
// leaf does not exist yet (e.g. ~/.npm before npm has written its cache). // leaf does not exist yet (e.g. ~/.npm before npm has written its cache).
// Walks up to the nearest existing ancestor, resolves that, then re-appends // Walks up to the nearest existing ancestor, resolves that, then re-appends
@@ -540,7 +545,7 @@ describe('bug-131: runNpm isolates HOME from the caller environment', () => {
try { try {
stdout = execFileSync(process.execPath, ['-e', script], { stdout = execFileSync(process.execPath, ['-e', script], {
encoding: 'utf-8', encoding: 'utf-8',
timeout: 30_000, timeout: NPM_HARNESS_SPAWN_TIMEOUT_MS,
}); });
} catch (err) { } catch (err) {
stdout = err.stdout || ''; stdout = err.stdout || '';
@@ -594,7 +599,7 @@ describe('bug-131: runNpm isolates HOME from the caller environment', () => {
try { try {
stdout = execFileSync(process.execPath, ['-e', script], { stdout = execFileSync(process.execPath, ['-e', script], {
encoding: 'utf-8', encoding: 'utf-8',
timeout: 30_000, timeout: NPM_HARNESS_SPAWN_TIMEOUT_MS,
}); });
} catch (err) { } catch (err) {
stdout = err.stdout || ''; stdout = err.stdout || '';
@@ -728,7 +733,7 @@ describe('bug-131: runNpm isolates HOME from the caller environment', () => {
try { try {
stdout = execFileSync(process.execPath, ['-e', script], { stdout = execFileSync(process.execPath, ['-e', script], {
encoding: 'utf-8', encoding: 'utf-8',
timeout: 30_000, timeout: NPM_HARNESS_SPAWN_TIMEOUT_MS,
}); });
} catch (err) { } catch (err) {
stdout = err.stdout || ''; stdout = err.stdout || '';