fix(setup): Chromium-install lock reclaim is atomic and pid-validated; abandoned locks expire; the tree kill walks /proc without pgrep

- A pid file holding "", "-1" or "0" counted as a live holder (kill -0 -1
  signals every process and succeeds), locking Chromium out for good. A pid
  must be a positive integer; anything else is stale.
- Two setups judging the same lock stale raced on rm -rf + mkdir and the
  loser deleted the winner's fresh lock. The stale dir is renamed first
  (atomic), so exactly one reclaims.
- A lock dir with no pid file (killed between mkdir and echo) was never
  reclaimed; it now expires once older than the install bound.
- _kill_tree needed pgrep; debian-slim and git-bash ship none, so the bound
  killed only the wrapper subshell and the installer kept running. Without
  pgrep the children are found by walking /proc/*/stat.
- The timeout knob is normalized in one place with one comment; the trap's
  exit 130 is the only exit the block may contain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-04 17:55:07 +00:00
co-authored by Claude Fable 5.1
parent 73506b59c6
commit f1a580e1f0
2 changed files with 107 additions and 20 deletions
+30 -12
View File
@@ -569,6 +569,13 @@ _kill_tree() {
for child in $(pgrep -P "$pid" 2>/dev/null); do
_kill_tree "$child"
done
elif [ -d /proc ]; then
# debian-slim and git-bash ship no pgrep: walk /proc for children. The
# comm field "(name)" may contain spaces, so strip through the closing
# paren before reading the ppid (second field after it).
for child in $(awk -v p="$pid" '{ s=$0; sub(/^[^)]*\) /, "", s); split(s, f, " "); if (f[2]==p) print $1 }' /proc/[0-9]*/stat 2>/dev/null); do
_kill_tree "$child"
done
fi
kill -9 "$pid" 2>/dev/null || true
}
@@ -935,8 +942,9 @@ fi
# setup is installing Chromium right now, so this run registers skills and
# re-probes next time. The download is bounded (default 600s, env
# GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT) because Playwright's own retries cover a
# flaky socket but not a wedged bunx; the killed tree is the whole process
# group via _kill_tree. Reason codes: skipped, chromium-install,
# flaky socket but not a wedged bunx; a wedged installer is killed with its
# child tree (_kill_tree: pgrep-walked, /proc-walked where pgrep is missing).
# Reason codes: skipped, chromium-install,
# chromium-install-timeout, chromium-install-locked, windows-no-node,
# windows-node-modules, post-install-launch.
# test/setup-playwright-best-effort.test.ts pins this block.
@@ -947,13 +955,12 @@ _pw_fail() {
echo " Chromium bootstrap: $code — $*" >&2
}
_PW_INSTALL_TIMEOUT="${GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT:-600}"
# Positive seconds only: 0 would kill the install on the first poll, so it
# (and anything non-numeric) falls back to the default.
# Normalize to a plain positive integer or fall back to the default: empty and
# non-numeric are garbage; "0"/"000" would kill the install on the first poll;
# "0600" is 600; anything past nine digits is not a deadline (a value bash
# cannot compare would leave the install unbounded — the exact failure the
# bound exists to prevent).
case "$_PW_INSTALL_TIMEOUT" in ''|*[!0-9]*) _PW_INSTALL_TIMEOUT=600 ;; esac
# Normalize to a plain positive integer: "000"/"0" mean the default (never
# kill-on-first-poll), "0600" is 600, and anything past nine digits is garbage
# rather than a deadline (a value bash cannot compare would leave the install
# unbounded — the exact failure the bound exists to prevent).
[ "${#_PW_INSTALL_TIMEOUT}" -le 9 ] || _PW_INSTALL_TIMEOUT=600
_PW_INSTALL_TIMEOUT=$((10#$_PW_INSTALL_TIMEOUT))
[ "$_PW_INSTALL_TIMEOUT" -gt 0 ] || _PW_INSTALL_TIMEOUT=600
@@ -972,10 +979,21 @@ elif ! ensure_playwright_browser; then
# reclaim instead of telling the user to rmdir by hand.
if [ -d "$_PW_LOCK" ] && [ -f "$_PW_LOCK/pid" ]; then
_PW_HOLDER=$(cat "$_PW_LOCK/pid" 2>/dev/null || true)
if [ -n "$_PW_HOLDER" ] && ! kill -0 "$_PW_HOLDER" 2>/dev/null; then
echo " reclaiming stale Chromium-install lock (holder pid $_PW_HOLDER is gone)" >&2
rm -rf "$_PW_LOCK" 2>/dev/null || true
# A pid must be a positive integer: "", "-1" (kill -0 -1 signals every
# process and "succeeds") or "0" (the process group) are stale, not live.
case "$_PW_HOLDER" in ''|*[!0-9]*) _PW_HOLDER=0 ;; esac
if [ "$_PW_HOLDER" -eq 0 ] || ! kill -0 "$_PW_HOLDER" 2>/dev/null; then
echo " reclaiming stale Chromium-install lock (holder pid ${_PW_HOLDER:-?} is gone)" >&2
# Rename first: two setups judging the same lock stale race on rm -rf +
# mkdir, and the loser would delete the winner's fresh lock. mv of a
# directory is atomic, so exactly one of them reclaims.
if mv "$_PW_LOCK" "$_PW_LOCK.stale.$$" 2>/dev/null; then rm -rf "$_PW_LOCK.stale.$$" 2>/dev/null || true; fi
fi
elif [ -d "$_PW_LOCK" ] && [ -n "$(find "$_PW_LOCK" -maxdepth 0 -mmin +$(( _PW_INSTALL_TIMEOUT / 60 + 1 )) 2>/dev/null)" ]; then
# A lock with no pid file (killed between mkdir and echo) has no holder to
# probe; once it is older than the install bound nobody is using it.
echo " reclaiming abandoned Chromium-install lock (no holder recorded, older than the install bound)" >&2
if mv "$_PW_LOCK" "$_PW_LOCK.stale.$$" 2>/dev/null; then rm -rf "$_PW_LOCK.stale.$$" 2>/dev/null || true; fi
fi
if mkdir "$_PW_LOCK" 2>/dev/null; then
echo "$$" > "$_PW_LOCK/pid" 2>/dev/null || true
@@ -2857,7 +2875,7 @@ fi
# ─── Chromium bootstrap summary (best-effort browser, see # 2) ───────────────
# Printed LAST so it is the thing the user sees, after every skill registered.
_PW_BROWSER_SKILLS="/qa, /qa-only, /design-review, /browse, make-pdf, /pair-agent"
_PW_BROWSER_SKILLS="/qa, /qa-only, /design-review, /browse, make-pdf, /pair-agent, and any other skill that drives the browser"
if [ "${_PW_FAIL_REASON:-}" = "skipped" ]; then
# An explicit opt-out is not a failure: say what is unavailable and stop.
log ""
+77 -8
View File
@@ -77,7 +77,6 @@ describe('setup: Chromium bootstrap static invariants', () => {
test('lock contention is a reason code, not a fatal', () => {
expect(codeLines).toContain('_pw_fail chromium-install-locked');
expect(codeLines).not.toMatch(/another gstack setup is already installing[\s\S]*exit 1/);
expect(block).toContain('GSTACK_SKIP_PLAYWRIGHT');
});
@@ -115,6 +114,7 @@ function runBlock(opts: {
bunx: string; // body of the bunx stub
env?: Record<string, string>;
preLockPid?: string; // pre-create the install lock held by this pid
preLockNoPidAgeMin?: number; // pre-create a lock dir with NO pid file, this many minutes old
markKill?: boolean; // record _kill_tree invocations to $MARK
isWindows?: '0' | '1';
prelude?: string; // extra shell lines (node/npm stubs) injected before the block
@@ -131,6 +131,12 @@ function runBlock(opts: {
fs.mkdirSync(lock);
fs.writeFileSync(path.join(lock, 'pid'), opts.preLockPid);
}
if (opts.preLockNoPidAgeMin !== undefined) {
const lock = path.join(tmp, 'gstack-playwright-install.lock');
fs.mkdirSync(lock);
const t = new Date(Date.now() - opts.preLockNoPidAgeMin * 60_000);
fs.utimesSync(lock, t, t);
}
const probeFn = opts.probe === 'fail-then-ok'
? 'ensure_playwright_browser() { if [ -f "$MARK.probed" ]; then return 0; fi; : > "$MARK.probed"; return 1; }'
: `ensure_playwright_browser() { ${opts.probe === 'ok' ? 'return 0' : 'return 1'}; }`;
@@ -196,7 +202,7 @@ describe('setup: Chromium bootstrap block executes best-effort', () => {
expect(r.stdout).toContain('REACHED_END=1');
expect(r.elapsedMs).toBeLessThan(20_000);
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('killed');
});
}, 15_000);
test('non-numeric timeout knob falls back to the default instead of erroring', () => {
const r = runBlock({ probe: 'fail', bunx: 'exit 3', env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: 'soon' } });
@@ -216,12 +222,64 @@ describe('setup: Chromium bootstrap block executes best-effort', () => {
});
test('stale lock (dead pid) is reclaimed and the install proceeds', () => {
const r = runBlock({ probe: 'fail', bunx: 'exit 0', preLockPid: '999999' });
const r = runBlock({ probe: 'fail', bunx: 'exit 0', preLockPid: '4194305' /* above Linux's largest pid_max: never a live pid */ });
expect(r.status).toBe(0);
expect(r.stderr).toContain('reclaiming stale Chromium-install lock');
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('bunx-called');
});
test('a lock whose pid file is garbage (-1, abc, 0) is stale, not "locked": kill -0 -1 would signal everything and "succeed"', () => {
for (const pid of ['-1', 'abc', '0']) {
const r = runBlock({ probe: 'fail', bunx: 'exit 0', preLockPid: pid });
expect(r.status).toBe(0);
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('bunx-called');
expect(r.stdout).not.toContain('chromium-install-locked');
}
}, 15_000);
test('a lock dir with NO pid file is reclaimed once older than the install bound, and honored while fresh', () => {
const old = runBlock({ probe: 'fail', bunx: 'exit 0', preLockNoPidAgeMin: 30, env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '60' } });
expect(old.status).toBe(0);
expect(fs.readFileSync(path.join(old.tmp, 'mark'), 'utf-8')).toContain('bunx-called');
expect(old.stdout).not.toContain('chromium-install-locked');
const fresh = runBlock({ probe: 'fail', bunx: 'exit 0', preLockNoPidAgeMin: 0, env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: '600' } });
expect(fresh.status).toBe(0);
expect(fresh.stdout).toContain('REASON=chromium-install-locked\n');
}, 15_000);
test('_kill_tree without pgrep on PATH still kills the grandchild (walks /proc)', () => {
if (!fs.existsSync('/proc')) return;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-killtree-'));
try {
const bin = path.join(tmp, 'bin');
fs.mkdirSync(bin);
for (const name of ['bash', 'sleep', 'awk']) {
const real = (spawnSync('which', [name], { encoding: 'utf-8', timeout: 10_000 }).stdout ?? '').trim();
if (real) fs.symlinkSync(real, path.join(bin, name));
}
const script = [
`export PATH="${bin}"`,
'hash -r',
'command -v pgrep >/dev/null 2>&1 && { echo "PGREP_PRESENT"; exit 0; }',
extractFn('_kill_tree'),
// two commands so bash forks a real subshell instead of exec-ing sleep directly
'( sleep 30; true ) & pid=$!',
'sleep 0.3',
// find the sleep grandchild via /proc, the same way the fallback does
'child=$(awk -v p="$pid" \'{ s=$0; sub(/^[^)]*\\) /, "", s); split(s, f, " "); if (f[2]==p) { print $1; exit } }\' /proc/[0-9]*/stat 2>/dev/null)',
'[ -n "$child" ] || { echo "NO_CHILD"; exit 0; }',
'_kill_tree "$pid"',
'sleep 0.3',
'if kill -0 "$child" 2>/dev/null; then echo "CHILD_ALIVE"; else echo "CHILD_DEAD"; fi',
].join('\n');
const r = spawnSync('/bin/bash', ['-c', script], { encoding: 'utf-8', timeout: 20_000 });
expect(r.stdout).toContain('CHILD_DEAD');
expect(r.stdout).not.toContain('PGREP_PRESENT');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}, 30_000);
test('install succeeds but the post-install probe fails: reason post-install-launch with the userns hint', () => {
const r = runBlock({ probe: 'fail', bunx: 'exit 0' });
expect(r.status).toBe(0);
@@ -333,6 +391,7 @@ function runEmojiStep(reason: string, fontOk: boolean): { stdout: string; stderr
'echo "REACHED_END=1"',
].join('\n');
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 10_000 });
if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`);
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 };
}
@@ -378,6 +437,8 @@ function runSummary(reason: string, telemetry: 'ok' | 'fail' | 'missing'): { std
'echo "REACHED_END=1"',
].join('\n');
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 10_000 });
if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`);
if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`);
const argv = fs.existsSync(argvFile) ? fs.readFileSync(argvFile, 'utf-8') : '';
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1, argv };
} finally {
@@ -395,8 +456,8 @@ describe('setup: Chromium bootstrap summary block executes', () => {
}
expect(timeout.stdout).toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=1800');
expect(timeout.stdout).not.toContain('GSTACK_CHROMIUM_NO_SANDBOX');
// Reason code only, never a path or command line; a synthetic session id
// keeps the one-shot event from sweeping other sessions' pending markers.
// Reason code only, never a path or command line; --no-sweep keeps the
// one-shot event from finalizing other sessions' pending markers.
expect(timeout.argv.trim()).toBe('--event-type onboarding --skill _setup_playwright --outcome chromium-install-timeout --no-sweep');
const launch = runSummary('post-install-launch', 'ok');
@@ -483,17 +544,25 @@ function runLinker(opts: {
extractFn('_claude_entry_is_ours'),
extractFn('_write_owned_marker'),
extractFn('_gstack_generated_header'),
extractFn('_claude_entry_owned_strongly'),
extractFn('_backup_skill_md'),
extractFn('_cleanup_weak_dir'),
extractFn('_gstack_dir_only_links'),
extractFn('_cleanup_linked_dir'),
'_BACKED_UP_SKILL_MDS=()',
'_SKILL_BACKUP_ROOT="$HOME/.gstack/backups/skills/test"',
extractFn('link_claude_skill_dirs'),
extractFn('cleanup_old_claude_symlinks'),
`link_claude_skill_dirs "${payload}" "${skills}"`,
'echo "FOREIGN=${_FOREIGN_SKIPPED_ENTRIES[*]:-}"',
].join('\n');
const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 10_000, env: { PATH: process.env.PATH ?? '', HOME: tmp } });
if (/command not found/.test(r.stderr ?? '')) throw new Error(`harness drift (missing extracted helper):\n${r.stderr}`);
return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '', skills, payload, tmp };
}
describe.skipIf(process.platform === 'win32')('setup: .gstack-owned ownership marker for Windows copy installs (#2119)', () => {
test('IS_WINDOWS=1 writes the marker beside a COPIED SKILL.md; IS_WINDOWS=0 writes no marker beside a symlinked one', () => {
test('the marker is written beside a COPIED SKILL.md (IS_WINDOWS=1) and beside a symlinked one (IS_WINDOWS=0): a directory we created is ours on every platform', () => {
const win = runLinker({ isWindows: '1', payload: ['qa', 'ship'] });
try {
expect(win.status).toBe(0);
@@ -511,7 +580,7 @@ describe.skipIf(process.platform === 'win32')('setup: .gstack-owned ownership ma
try {
expect(unix.status).toBe(0);
expect(fs.lstatSync(path.join(unix.skills, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
expect(fs.existsSync(path.join(unix.skills, 'qa', '.gstack-owned'))).toBe(false);
expect(fs.existsSync(path.join(unix.skills, 'qa', '.gstack-owned'))).toBe(true);
} finally {
fs.rmSync(unix.tmp, { recursive: true, force: true });
}
@@ -535,7 +604,7 @@ describe.skipIf(process.platform === 'win32')('setup: .gstack-owned ownership ma
fs.writeFileSync(path.join(r.skills, 'my-own', 'SKILL.md'), '---\nname: my-own\n---\n');
const flip = spawnSync('bash', ['-c', [
'set -e', 'IS_WINDOWS=1',
extractFn('_gstack_generated_header'),
extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_gstack_dir_only_links'), extractFn('_cleanup_linked_dir'), extractFn('_gstack_generated_header'), extractFn('_cleanup_weak_dir'),
extractFn('cleanup_old_claude_symlinks'),
`cleanup_old_claude_symlinks "${r.payload}" "${r.skills}"`,
].join('\n')], { encoding: 'utf-8', timeout: 10_000 });