Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 92 additions & 10 deletions setup
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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).
Expand Down
36 changes: 36 additions & 0 deletions test/setup-playwright-deadline.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});