Skip to content

date: 2026-07-04 tags: [pest-browser, playwright, hang, session-kill, pkill, self-kill, hook, footgun, bug-501] status: active graduated_to: .claude/hooks/block-playwright-selfkill.sh

Hand-rolled pkill -f playwright self-kills the whole Claude Code session

Symptom — A web session running the browser suite errored out with "An error occurred while executing Claude Code" mid-command. The command in flight was a hand-rolled reap-and-retry: pkill -f "playwright" 2>/dev/null; sleep 1; php artisan test …Browser. It reads like the browser test "hanging", but the test never got to run — the pkill took the session down first.

Root causepkill -f "playwright" matches against the full command line of every process, and the string playwright is right there in the command the agent is running. So it matches its own shell and the Claude Code Bash wrapper that spawned it, and kills them. Proven directly: pgrep -f "playwright-…-probe" from inside a shell whose cmdline contains that marker returns both the running shell's PID and its parent wrapper's PID. Killing those is exactly the "session died mid-run" error. This is the self-kill footgun already flagged in [[2026-06-13-playwright-run-server-orphan-hang]] — which said "never pkill -f playwright run-server" — but a guideline in a learning doc didn't stop an agent from hand-rolling it anyway.

Fix — Made it a wall, not a guideline: a PreToolUse Bash hook (.claude/hooks/block-playwright-selfkill.sh, registered in settings.json) denies a pkill/killall whose own arguments name playwright, and a grep/pgrep for playwright fed into a kill (pipe, $( ), or for/while loop) — including the classic ps aux | grep playwright | xargs kill, which self-matches for the same reason the reaper greps [p]laywright — before it can launch, pointing the agent at the safe reaper instead. Detection is target-scoped: it reads the kill verb's arguments only up to the next ;/&&/||/|/&, so a pkill chained after the reaper (browser:reap && pkill -f playwright) is still caught, an unrelated kill on the same line (grep playwright docs && killall node) is left alone, and the [p]laywright character-class pattern — which has no literal "playwright" substring — never trips it. A leading sudo/env VAR=… on the kill verb is tolerated too; a deeper re-quoting wrapper (bash -c "pkill …", a kill split across two tool calls) is accepted residual — this is an accident-guard, not an anti-adversary control. Tested by block-playwright-selfkill.test.sh (33 cases, incl. the reap-then-pkill, loop-form, ps|grep|kill, and sudo/env-prefixed bypasses).

The deeper fix — close the gap that makes agents reach for pkill at all. The hook is only the backstop. The reaper ran before composer test:browser and at SessionStart, but not before a bare php artisan test tests/Browser/… — the filtered inner-loop run CLAUDE.md steers agents toward — so that path could still wedge on a prior run's orphan, which is the hang that triggers the reflex. composer test:browser is now a single compound command (browser:reap && … && pest tests/Browser), so a trailing -- --filter=Name forwards onto the final pest — giving a reap-first filtered inner loop (composer test:browser -- --filter=Foo). CLAUDE.md + testing.md now steer narrowing through it rather than a bare pest/artisan test. (Composer appends trailing args to the tail of a compound script command, so they land on pest, not the earlier reap/build steps — verified with an equivalent probe and a real --filter=__NoSuchTest__ run that matched 0 tests in 4 ms.)

Guard — Never reap Playwright with a raw pkill -f playwright; run php artisan browser:reap, which force-kills stray run-server processes via a [p]laywright pattern and clears the stale endpoint file. It's already the first step of composer test:browser and runs in the SessionStart hook, so manual reaping is rarely needed. The hook now blocks the footgun deterministically.

Aside — "isn't Playwright CI-only?" No. Mutation testing is CI-only (it OOM-kills the session — block-local-mutation.sh). The browser/Playwright suite is meant to run in web sessions: the SessionStart hook provisions Chromium + deps precisely so composer test:browser works here ([[2026-06-12-pest-browser-works-in-web-container]]). The two are easy to conflate; only mutation is off-limits locally.