Files
claude-code-gnome-extension/tests/test-hook.sh
T
av 75bfe77950 Survive a crash, and a reboot after one
kill, a closed window, a reboot: no SessionEnd arrives and the state file
stays. Each case was tried rather than reasoned about, and one of the
three was broken.

A killed session was already handled -- the process is gone, so the file
and its lock are removed within the 20 s liveness tick. An interrupted
hook write left its temporary file behind forever; those are now swept
once they are five minutes old, which is late enough that a hook part-way
through writing one does not lose the update.

The reboot case was the broken one. State files outlive a reboot and pids
are handed out afresh, so "does /proc/<pid> exist" only answers "is some
process wearing that number". Verified by giving an unrelated live process
the pid of a dead session: the ghost sat in the panel as a session waiting
for input, and would have stayed there forever, asking for an answer
nobody could give. The pid is now pinned to the process start time from
/proc/<pid>/stat, recorded when the state is written and compared when it
is read.

Files written before that field existed compare only on existence, as
before, so a session open across the upgrade is not evicted.

An abandoned flock needed nothing: the kernel drops it when the holder
dies, so there is no deadlock to recover from.
2026-08-09 19:43:31 +03:00

179 lines
7.3 KiB
Bash
Executable File

#!/usr/bin/env bash
# Exercises the hook's state machine and its behaviour under concurrency.
#
# Run: tests/test-hook.sh
set -u
HOOK="$(cd "$(dirname "$0")/.." && pwd)/hooks/claude-status-hook.py"
export XDG_STATE_HOME="$(mktemp -d)"
DIR="$XDG_STATE_HOME/claude-code-status"
SID="test-session"
FILE="$DIR/$SID.json"
failures=0
check() { # name expected actual
if [ "$2" = "$3" ]; then
echo "ok $1"
else
echo "FAIL $1 (expected '$2', got '$3')"
failures=$((failures + 1))
fi
}
emit() { # json
echo "$1" | "$HOOK"
}
field() { # key
python3 -c "import json,sys;print(json.load(open('$FILE')).get('$1',''))" 2>/dev/null
}
ev() { # event [extra json]
echo "{\"session_id\":\"$SID\",\"hook_event_name\":\"$1\",\"cwd\":\"/tmp/proj\"${2:+,$2}}"
}
# --- state machine ---------------------------------------------------------
emit "$(ev SessionStart '"source":"startup"')"
check "SessionStart -> waiting" "waiting" "$(field state)"
# Pins the recorded pid to one process, so a state file that outlives a reboot
# cannot be revived by whatever inherits that pid number next.
start=$(field pid_start)
check "process start time recorded" "yes" "$([ -n "$start" ] && [ "$start" != "0" ] && echo yes || echo no)"
emit "$(ev UserPromptSubmit '"prompt":"hi"')"
check "UserPromptSubmit -> busy" "busy" "$(field state)"
before=$(field event_ts)
emit "$(ev PostToolUse '"tool_name":"Bash"')"
check "PostToolUse while busy does not rewrite" "$before" "$(field event_ts)"
emit "$(ev Notification '"notification_type":"permission_prompt","message":"run rm -rf"')"
check "permission_prompt -> blocked" "blocked" "$(field state)"
check "permission message kept" "run rm -rf" "$(field message)"
emit "$(ev PostToolUse '"tool_name":"Bash"')"
check "PostToolUse clears blocked" "busy" "$(field state)"
emit "$(ev Notification '"notification_type":"auth_success","message":"logged in"')"
check "auth_success ignored" "busy" "$(field state)"
emit "$(ev Stop)"
check "Stop -> waiting" "waiting" "$(field state)"
before=$(field event_ts)
emit "$(ev Notification '"notification_type":"idle_prompt","message":"waiting for input"')"
check "idle_prompt while waiting does not rewrite" "$before" "$(field event_ts)"
# Subagent activity must not touch the state. The background case is the one
# that bites: the main agent has already stopped, so a subagent's tool call
# arriving afterwards would claim the session is working when it is waiting.
emit "$(ev Stop '"agent_id":"sub-1"')"
check "subagent Stop ignored" "waiting" "$(field state)"
emit "$(ev PostToolUse '"tool_name":"Bash","agent_id":"sub-1","agent_type":"general-purpose"')"
check "background subagent does not un-wait the session" "waiting" "$(field state)"
# ...but a subagent that gets stuck still needs a human, so its notification
# must come through.
emit "$(ev Notification '"notification_type":"permission_prompt","message":"x","agent_id":"sub-1"')"
check "subagent permission prompt still blocks" "blocked" "$(field state)"
emit "$(ev PostToolUse '"tool_name":"Bash"')"
check "main agent tool call clears it again" "busy" "$(field state)"
emit "$(ev Stop)"
check "back to waiting" "waiting" "$(field state)"
# Deliberately checked from "busy": compaction raises SessionStart mid-turn,
# and taking it at face value would drop a working session back to waiting.
emit "$(ev UserPromptSubmit '"prompt":"go"')"
since_before=$(field since)
emit "$(ev SessionStart '"source":"compact"')"
check "compaction does not reset a live session" "busy" "$(field state)"
check "compaction does not reset the clock" "$since_before" "$(field since)"
# --- subagents -------------------------------------------------------------
# The scenario this exists for: you ask for a batch, the main agent launches it
# and ends its turn, and the session waits for the results to consolidate them.
# It is working, not waiting for you, and must not call you over.
emit "$(ev UserPromptSubmit '"prompt":"launch a batch"')"
check "a new turn resets the count" "0" "$(field agents)"
emit "$(ev PreToolUse '"tool_name":"Agent"')"
emit "$(ev PreToolUse '"tool_name":"Agent"')"
check "two launches counted" "2" "$(field agents)"
emit "$(ev PreToolUse '"tool_name":"TaskCreate"')"
check "a tool that merely starts with Task is not a subagent" "2" "$(field agents)"
emit "$(ev Stop)"
check "main agent stopping does not free a running batch" "busy" "$(field state)"
emit "$(ev SubagentStop '"agent_id":"sub-1","agent_type":"general-purpose"')"
check "one down, still working" "busy" "$(field state)"
check "count decremented" "1" "$(field agents)"
emit "$(ev SubagentStop '"agent_id":"sub-2","agent_type":"general-purpose"')"
check "last subagent finishing frees the session" "waiting" "$(field state)"
check "count back to zero" "0" "$(field agents)"
# A batch that finishes while the main agent is still mid-turn must not free it.
emit "$(ev UserPromptSubmit '"prompt":"again"')"
emit "$(ev PreToolUse '"tool_name":"Agent"')"
emit "$(ev SubagentStop '"agent_id":"sub-3"')"
check "batch done mid-turn leaves the session working" "busy" "$(field state)"
# An idle nudge while a batch runs must not claim the session is free either.
emit "$(ev PreToolUse '"tool_name":"Agent"')"
emit "$(ev Stop)"
emit "$(ev Notification '"notification_type":"idle_prompt","message":"still there?"')"
check "idle nudge cannot free a running batch" "busy" "$(field state)"
emit "$(ev SubagentStop '"agent_id":"sub-4"')"
check "and it frees properly once the batch ends" "waiting" "$(field state)"
# --- concurrency -----------------------------------------------------------
# The hazard: the last PostToolUse of a turn is async and can still be running
# when the turn's Stop fires. Two mechanisms defend against it and they are
# tested separately, because the burst below passes on the timestamp guard
# alone -- it does not prove the lock is doing anything.
emit "$(ev UserPromptSubmit '"prompt":"go"')"
for _ in $(seq 30); do
emit "$(ev PostToolUse '"tool_name":"Bash"')" &
done
sleep 0.3 # let the racers start, so Stop's timestamp is genuinely later
emit "$(ev Stop)"
wait
check "Stop survives a burst of concurrent PostToolUse" "waiting" "$(field state)"
python3 -c "import json;json.load(open('$FILE'))" 2>/dev/null
check "state file is still valid JSON" "0" "$?"
# The lock itself: what the timestamp guard cannot cover is one hook reading the
# old state, being descheduled while another writes, then writing its own stale
# decision on top. Holding the lock from outside makes that window observable --
# a hook must wait for it rather than read-and-write straight through.
emit "$(ev UserPromptSubmit '"prompt":"go"')"
python3 -c "
import fcntl, time
with open('$FILE.lock', 'w') as fh:
fcntl.flock(fh, fcntl.LOCK_EX)
time.sleep(1.5)
" &
holder=$!
sleep 0.4
emit "$(ev Stop)" &
sleep 0.5
check "hook blocks while the lock is held" "busy" "$(field state)"
wait $holder
wait
check "hook proceeds once the lock is released" "waiting" "$(field state)"
# --- teardown --------------------------------------------------------------
emit "$(ev SessionEnd '"reason":"other"')"
[ -e "$FILE" ]; check "SessionEnd removes the state file" "1" "$?"
[ -e "$FILE.lock" ]; check "SessionEnd removes the lock file" "1" "$?"
rm -rf "$XDG_STATE_HOME"
if [ "$failures" -gt 0 ]; then
echo; echo "$failures failure(s)"; exit 1
fi
echo; echo "all passed"