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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,43 @@ Data is stored in [Supabase](https://supabase.com) (open source Firebase alterna

On Windows without Developer Mode (MSYS2 / Git Bash), `setup` falls back to file copies instead of symlinks because `ln -snf` produces frozen copies that don't refresh on `git pull`. **Re-run `cd ~/.claude/skills/gstack && ./setup` after every `git pull`** so your skill files match the repo. `setup` prints a one-line note reminding you. Unix and WSL keep symlinks and don't need the re-run.

### Playwright browser install hangs

**Symptom:** `./setup` prints `Installing Playwright Chromium...`, the download progress bar reaches 100%, and then nothing happens for a long time. The installer is stuck in its *extraction* step — it emits no error and silently re-downloads the whole archive when it retries, so it looks like a slow connection rather than a hang.

`setup` caps this at 30 minutes and aborts with instructions instead of blocking forever. Raise the ceiling with `GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=3600 ./setup` if your connection genuinely needs longer for a ~280 MB download. If you saw the download hit 100% first, a longer timeout won't help — extraction never finishes.

**Manual fix.** Unzipping the archives by hand takes a few seconds. You need two values, both printed in your own output:

- `VER` — from the installer's download line: `Downloading Chrome for Testing 145.0.7632.6 ...`
- `REV` — from the directory name in the launch error: `...chromium_headless_shell-1208...`

```bash
BROWSERS="${PLAYWRIGHT_BROWSERS_PATH:-$HOME/AppData/Local/ms-playwright}"
VER=145.0.7632.6 # from the installer's download line
REV=1208 # from chromium_headless_shell-<REV>
PLAT=win64 # copy the platform segment from the installer's own URL
BASE="https://cdn.playwright.dev/builds/cft/$VER/$PLAT"

curl -L -o /tmp/chrome.zip "$BASE/chrome-$PLAT.zip"
curl -L -o /tmp/shell.zip "$BASE/chrome-headless-shell-$PLAT.zip"

mkdir -p "$BROWSERS/chromium-$REV" "$BROWSERS/chromium_headless_shell-$REV"
unzip -q -o /tmp/chrome.zip -d "$BROWSERS/chromium-$REV"
unzip -q -o /tmp/shell.zip -d "$BROWSERS/chromium_headless_shell-$REV"

# Playwright treats a browser dir without this marker as a partial install.
touch "$BROWSERS/chromium-$REV/INSTALLATION_COMPLETE"
touch "$BROWSERS/chromium_headless_shell-$REV/INSTALLATION_COMPLETE"
```

Then re-run `./setup`. Notes:

- The default browsers root differs per platform: `~/AppData/Local/ms-playwright` (Windows), `~/Library/Caches/ms-playwright` (macOS), `~/.cache/ms-playwright` (Linux). `PLAYWRIGHT_BROWSERS_PATH` wins when set.
- The `win64` platform string above is verified; for macOS/Linux copy the exact segment out of the installer's own download URL rather than guessing.
- `/browse` needs the **headless shell** build, not just `chromium`. Extracting only `chromium-<REV>` still fails with `Executable doesn't exist at ...chrome-headless-shell.exe`.
- If an earlier attempt left a partial install behind, delete the incomplete `chromium*-<REV>` directories and any `__dirlock` in the browsers root before extracting.

**Claude says it can't see the skills?** Make sure your project's `CLAUDE.md` has a gstack section. Add this:

```
Expand Down
90 changes: 88 additions & 2 deletions setup
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,81 @@ if [ "$INSTALL_CODEX" -eq 1 ]; then
migrate_direct_codex_install "$SOURCE_GSTACK_DIR" "$CODEX_GSTACK"
fi

# ─── Helper: run a command under a wall-clock ceiling ─────────────────────────
# Portable stand-in for GNU coreutils `timeout`, which macOS does not ship (only
# `gtimeout`, via Homebrew coreutils). Returns the command's own exit status, or
# 124 when it had to be killed — same convention as timeout(1).
#
# Why this exists: `playwright install` can download a browser archive to 100%
# and then hang indefinitely in its extraction step, emitting no error and
# silently re-downloading the whole archive on retry. Without a ceiling, `setup`
# blocks forever with no output, which reads as "still downloading" rather than
# "stuck". See README.md → Troubleshooting → "Playwright browser install hangs".
#
# Poll granularity is 1s: the kill stays responsive and ~1800 `sleep 1` spawns
# over a 30-minute ceiling costs nothing next to a 280 MB download.
_run_with_timeout() {
local secs="$1"; shift

# Job control gives the child its own process group, so the kill reaches the
# installer's descendants (playwright spawns node) rather than orphaning a
# hung grandchild that still holds Playwright's __dirlock.
set -m
"$@" &
local pid=$!
set +m

local waited=0
while kill -0 "$pid" 2>/dev/null; do
if [ "$waited" -ge "$secs" ]; then
# Negative pid targets the process group, plain pid the direct child. Send
# BOTH unconditionally rather than chaining with `||`: MSYS/Git Bash
# emulates process groups imperfectly, and a group kill there can report
# success without actually signalling the child — which would make an
# `||` fallback short-circuit and never fire.
kill -TERM "-$pid" 2>/dev/null || true
kill -TERM "$pid" 2>/dev/null || true
# Brief grace period, then escalate. Breaks early when TERM was enough.
local grace=0
while [ "$grace" -lt 4 ]; do
kill -0 "$pid" 2>/dev/null || break
sleep 0.5
grace=$((grace + 1))
done
kill -KILL "-$pid" 2>/dev/null || true
kill -KILL "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
return 124
fi
sleep 1
waited=$((waited + 1))
done

# Report the command's real status. Assigned via `||` so an errexit-enabled
# caller sees the return value instead of aborting inside the helper.
local status=0
wait "$pid" || status=$?
return "$status"
}

# Printed when the browser install blows through the ceiling above. Raising the
# timeout does NOT help — the extraction step itself never completes — so the
# message points at the manual install instead of suggesting a longer wait.
_print_playwright_install_hang_help() {
echo "gstack setup failed: the Playwright browser install exceeded ${PLAYWRIGHT_INSTALL_TIMEOUT}s and was killed." >&2
echo "" >&2
echo " Symptom: the download reaches 100%, then extraction hangs with no error" >&2
echo " and the installer silently re-downloads the archive. If that is what you" >&2
echo " saw, a longer timeout will not help — extraction never finishes." >&2
echo "" >&2
echo " Fix: install the browsers by hand (unzip takes a few seconds), then" >&2
echo " re-run ./setup. Step-by-step instructions:" >&2
echo " README.md → Troubleshooting → \"Playwright browser install hangs\"" >&2
echo "" >&2
echo " If your connection genuinely needs longer for a ~280 MB download, raise" >&2
echo " the ceiling: GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=3600 ./setup" >&2
}

ensure_playwright_browser() {
if [ "$IS_WINDOWS" -eq 1 ]; then
# On Windows, Bun can't launch Chromium due to broken pipe handling
Expand Down Expand Up @@ -476,12 +551,23 @@ if [ "$INSTALL_OPENCODE" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
fi

# 2. Ensure Playwright's Chromium is available
# Wall-clock ceiling for the browser install. Generous enough for a ~280 MB
# download on a slow line, bounded so a hung extraction can't block setup
# forever. Override with GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=<seconds>.
PLAYWRIGHT_INSTALL_TIMEOUT="${GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT:-1800}"

if ! ensure_playwright_browser; then
echo "Installing Playwright Chromium..."
playwright_install_status=0
(
cd "$SOURCE_GSTACK_DIR"
bunx playwright install chromium
)
_run_with_timeout "$PLAYWRIGHT_INSTALL_TIMEOUT" bunx playwright install chromium
) || playwright_install_status=$?

if [ "$playwright_install_status" -eq 124 ]; then
_print_playwright_install_hang_help
exit 1
fi

if [ "$IS_WINDOWS" -eq 1 ]; then
# On Windows, Node.js launches Chromium (not Bun — see oven-sh/bun#4253).
Expand Down
100 changes: 100 additions & 0 deletions test/setup-playwright-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';

const ROOT = path.resolve(import.meta.dir, '..');
const SETUP_SCRIPT = path.join(ROOT, 'setup');
const SETUP_SRC = fs.readFileSync(SETUP_SCRIPT, 'utf-8');

// Slice out the _run_with_timeout helper body via anchors so the test survives
// line-number drift in setup.
function extractHelper(): string {
const start = SETUP_SRC.indexOf('_run_with_timeout() {');
const end = SETUP_SRC.indexOf('\n}\n', start);
if (start < 0 || end < 0) throw new Error('Could not locate _run_with_timeout() in setup');
return SETUP_SRC.slice(start, end + 2);
}

describe('setup: Playwright install timeout invariant', () => {
test('helper and hang-help printer are defined', () => {
expect(SETUP_SRC).toContain('_run_with_timeout() {');
expect(SETUP_SRC).toContain('_print_playwright_install_hang_help() {');
});

// The load-bearing tripwire: `playwright install` downloads to 100% and then
// can hang forever in extraction with no error output. An unguarded call makes
// ./setup block indefinitely and look like a slow download. If a refactor drops
// the guard, fail CI here rather than shipping a silent infinite hang.
test('every `playwright install` invocation runs under the timeout guard', () => {
const offending: { lineNo: number; line: string }[] = [];
SETUP_SRC.split('\n').forEach((line, idx) => {
const trimmed = line.trim();
if (trimmed.startsWith('#')) return; // prose mentions in comments are fine
if (!/playwright\s+install/.test(line)) return;
if (!line.includes('_run_with_timeout')) {
offending.push({ lineNo: idx + 1, line: trimmed });
}
});
expect(offending).toEqual([]);
});

test('timeout is env-overridable and has a bounded default', () => {
expect(SETUP_SRC).toContain('GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT');
const m = SETUP_SRC.match(
/PLAYWRIGHT_INSTALL_TIMEOUT="\$\{GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT:-(\d+)\}"/,
);
expect(m).not.toBeNull();
// Generous enough for a ~280 MB download on a slow line, still finite.
expect(Number(m![1])).toBeGreaterThanOrEqual(600);
expect(Number(m![1])).toBeLessThanOrEqual(7200);
});

test('call site treats 124 as the hang case and aborts with guidance', () => {
const idx = SETUP_SRC.indexOf('_run_with_timeout "$PLAYWRIGHT_INSTALL_TIMEOUT"');
expect(idx).toBeGreaterThan(-1);
const after = SETUP_SRC.slice(idx, idx + 600);
expect(after).toContain('-eq 124');
expect(after).toContain('_print_playwright_install_hang_help');
});

test('kill path signals both the process group and the direct child', () => {
const helper = extractHelper();
// Chaining these with `||` would short-circuit: on MSYS/Git Bash a group
// kill can report success without signalling the child, so the direct kill
// must not be conditional on the group kill failing.
for (const sig of ['TERM', 'KILL']) {
expect(helper).toContain(`kill -${sig} "-$pid" 2>/dev/null || true`);
expect(helper).toContain(`kill -${sig} "$pid" 2>/dev/null || true`);
}
});
});

describe('setup: _run_with_timeout — behavior matrix', () => {
function runHelper(secs: string, cmd: string): { status: number | null; stderr: string } {
const helper = extractHelper();
const script = `${helper}\n_run_with_timeout ${secs} ${cmd}\n`;
const result = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 });
return { status: result.status, stderr: result.stderr };
}

test('command finishing inside the ceiling returns 0', () => {
expect(runHelper('5', 'true').status).toBe(0);
});

test("command's own non-zero exit status is preserved, not masked as 124", () => {
expect(runHelper('5', `sh -c 'exit 3'`).status).toBe(3);
});

test('command exceeding the ceiling is killed and reports 124', () => {
// secs=0 trips the deadline on the first poll, so this stays fast.
expect(runHelper('0', 'sleep 30').status).toBe(124);
});

test('killed command does not leave the helper blocked on wait', () => {
const start = Date.now();
expect(runHelper('0', 'sleep 30').status).toBe(124);
// A missed kill would make `wait` block for the full 30s sleep.
expect(Date.now() - start).toBeLessThan(15_000);
});
});