diff --git a/setup b/setup index 275236cd36..a285037303 100755 --- a/setup +++ b/setup @@ -30,6 +30,82 @@ case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*|Windows_NT) IS_WINDOWS=1 ;; esac +# Only one setup may mutate an install/HOME at a time. An atomic directory is +# portable to macOS, Linux, and Git Bash (unlike flock), and the pid lets a +# later run recover after a crash. +SETUP_LOCK_DIR="${GSTACK_HOME:-$HOME/.gstack}/.setup.lock.d" +mkdir -p "$(dirname "$SETUP_LOCK_DIR")" +SETUP_LOCK_ACQUIRED=0 +if mkdir "$SETUP_LOCK_DIR" 2>/dev/null; then + SETUP_LOCK_ACQUIRED=1 +else + _setup_lock_pid="$(cat "$SETUP_LOCK_DIR/pid" 2>/dev/null || true)" + if [ -n "$_setup_lock_pid" ] && ! kill -0 "$_setup_lock_pid" 2>/dev/null; then + rm -rf "$SETUP_LOCK_DIR" + if mkdir "$SETUP_LOCK_DIR" 2>/dev/null; then + SETUP_LOCK_ACQUIRED=1 + fi + fi +fi +if [ "$SETUP_LOCK_ACQUIRED" -ne 1 ]; then + echo "gstack setup is already running for this install; waiting is not necessary." >&2 + exit 1 +fi +echo "$$" > "$SETUP_LOCK_DIR/pid" + +cleanup_setup() { + if command -v cleanup_copied_bun >/dev/null 2>&1; then + cleanup_copied_bun + fi + if [ "$(cat "$SETUP_LOCK_DIR/pid" 2>/dev/null || true)" = "$$" ]; then + rm -rf "$SETUP_LOCK_DIR" + fi +} +trap cleanup_setup EXIT + +# Kill a command and its descendants after a deadline. `timeout` is absent on +# stock macOS, so setup cannot rely on coreutils being installed yet. +terminate_process_tree() { + local parent="$1" child + if [ "$IS_WINDOWS" -eq 1 ] && command -v taskkill.exe >/dev/null 2>&1; then + taskkill.exe //PID "$parent" //T //F >/dev/null 2>&1 || true + return + fi + if command -v pgrep >/dev/null 2>&1; then + for child in $(pgrep -P "$parent" 2>/dev/null || true); do + terminate_process_tree "$child" + done + fi + kill -TERM "$parent" 2>/dev/null || true + sleep 0.1 + kill -KILL "$parent" 2>/dev/null || true +} + +run_with_deadline() { + local seconds="$1" marker pid watcher status + shift + marker="${TMPDIR:-/tmp}/gstack-setup-timeout.$$.$RANDOM" + rm -f "$marker" + "$@" & + pid=$! + ( + sleep "$seconds" + if kill -0 "$pid" 2>/dev/null; then + terminate_process_tree "$pid" + : > "$marker" + fi + ) & + watcher=$! + if wait "$pid"; then status=0; else status=$?; fi + kill "$watcher" 2>/dev/null || true + wait "$watcher" 2>/dev/null || true + if [ -f "$marker" ]; then + rm -f "$marker" + return 124 + fi + return "$status" +} + # ─── Symlink-or-copy helper ─────────────────────────────────── # On macOS/Linux: create a symlink (existing behavior). # On Windows without Developer Mode (MSYS2/Git Bash): plain ln -snf silently @@ -250,17 +326,19 @@ if [ "$INSTALL_CODEX" -eq 1 ]; then fi ensure_playwright_browser() { - if [ "$IS_WINDOWS" -eq 1 ]; then - # On Windows, Bun can't launch Chromium due to broken pipe handling - # (oven-sh/bun#4253). Use Node.js to verify Chromium works instead. + local probe_timeout="${GSTACK_PLAYWRIGHT_PROBE_TIMEOUT_SECONDS:-15}" + [ -n "${GSTACK_UNDER_TEST:-}" ] && probe_timeout="${GSTACK_PLAYWRIGHT_PROBE_TIMEOUT_SECONDS:-2}" + if [ "$IS_WINDOWS" -eq 1 ] || [ "$(uname -s)" = "Darwin" ]; then + # Bun can hang while launching Chromium because of pipe handling on Windows + # and macOS. Node is also what the Playwright CLI itself uses. ( cd "$SOURCE_GSTACK_DIR" - node -e "const { chromium } = require('playwright'); (async () => { const b = await chromium.launch(); await b.close(); })()" 2>/dev/null + run_with_deadline "$probe_timeout" node -e "const { chromium } = require('playwright'); (async () => { const b = await chromium.launch(); await b.close(); })()" 2>/dev/null ) else ( cd "$SOURCE_GSTACK_DIR" - bun --eval 'import { chromium } from "playwright"; const browser = await chromium.launch(); await browser.close();' + run_with_deadline "$probe_timeout" bun --eval 'import { chromium } from "playwright"; const browser = await chromium.launch(); await browser.close();' ) >/dev/null 2>&1 fi } @@ -372,7 +450,6 @@ cleanup_copied_bun() { } prepare_bun_for_windows_compile -trap cleanup_copied_bun EXIT # 1. Build browse binary if needed (smart rebuild: stale sources, package.json, lock) NEEDS_BUILD=0 @@ -478,10 +555,15 @@ fi # 2. Ensure Playwright's Chromium is available if ! ensure_playwright_browser; then echo "Installing Playwright Chromium..." - ( + _playwright_install_timeout="${GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT_SECONDS:-300}" + [ -n "${GSTACK_UNDER_TEST:-}" ] && _playwright_install_timeout="${GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT_SECONDS:-2}" + if ! ( cd "$SOURCE_GSTACK_DIR" - bunx playwright install chromium - ) + run_with_deadline "$_playwright_install_timeout" bunx playwright install chromium + ); then + echo " warning: Chromium installation failed or timed out after ${_playwright_install_timeout}s." >&2 + echo " Skills will still be registered; rerun ./setup to retry browser installation." >&2 + fi if [ "$IS_WINDOWS" -eq 1 ]; then # On Windows, Node.js launches Chromium (not Bun — see oven-sh/bun#4253). @@ -512,7 +594,7 @@ if ! ensure_playwright_browser; then else echo "gstack setup failed: Playwright Chromium could not be launched" >&2 fi - exit 1 + echo " Browser-backed skills will be unavailable until Chromium setup succeeds." >&2 fi # 2b. Ensure a color-emoji font is installed so make-pdf emoji render (Linux). diff --git a/test/setup-playwright-deadline.test.ts b/test/setup-playwright-deadline.test.ts new file mode 100644 index 0000000000..90d3a96b9a --- /dev/null +++ b/test/setup-playwright-deadline.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const SETUP = readFileSync(join(import.meta.dir, '..', 'setup'), 'utf8'); + +describe('setup Playwright failure containment', () => { + test('serializes setup with a portable stale-pid lock', () => { + expect(SETUP).toContain('SETUP_LOCK_DIR="${GSTACK_HOME:-$HOME/.gstack}/.setup.lock.d"'); + expect(SETUP).toContain('if mkdir "$SETUP_LOCK_DIR" 2>/dev/null; then'); + expect(SETUP).toContain('kill -0 "$_setup_lock_pid"'); + expect(SETUP).toContain('gstack setup is already running for this install'); + expect(SETUP).toContain('trap cleanup_setup EXIT'); + }); + + test('bounds browser launch and install and kills descendants', () => { + expect(SETUP).toContain('run_with_deadline "$probe_timeout" node -e'); + expect(SETUP).toContain('run_with_deadline "$probe_timeout" bun --eval'); + expect(SETUP).toContain('run_with_deadline "$_playwright_install_timeout" bunx playwright install chromium'); + expect(SETUP).toContain('terminate_process_tree "$child"'); + expect(SETUP).toContain('GSTACK_PLAYWRIGHT_PROBE_TIMEOUT_SECONDS'); + expect(SETUP).toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT_SECONDS'); + }); + + test('uses Node for the macOS launch probe', () => { + expect(SETUP).toContain('[ "$IS_WINDOWS" -eq 1 ] || [ "$(uname -s)" = "Darwin" ]'); + }); + + test('continues to skill registration after Chromium failure', () => { + const failure = SETUP.indexOf('Browser-backed skills will be unavailable until Chromium setup succeeds.'); + const registration = SETUP.indexOf('link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"'); + expect(failure).toBeGreaterThan(-1); + expect(registration).toBeGreaterThan(failure); + expect(SETUP.slice(failure, registration)).not.toMatch(/^\s*exit 1\s*$/m); + }); +});