Testing the reboot case exposed a live regression, and the identity check added for reboots is what made it visible. Matching "claude" anywhere in an ancestor's command line was too loose. The hook is spawned as `/bin/sh -c /.../claude-status-hook.py`, so its parent's command line contains "claude" -- in the path to this very script -- while being a shell that exits milliseconds later. That shell's pid was being recorded as the session's. Existence checks alone hid it: the pid was dead, the file was deleted, the next event recreated it, and the session flickered. Once the pid was pinned to a process start time the session vanished outright. The rule was broadened in the first place to cover npm-style installs that run `node .../claude-code/cli.js`, which was a real gap. It is now matched per argument instead: argv[0] named claude, or a cli.js under a claude path. Both installs pass, and the spawning shell does not. The pid is also resolved before the unchanged-check rather than after, so a pid that has changed forces a write. A session resumed under a new pid -- which is exactly what --resume does, and what this session had done -- kept its old pid for as long as its state happened not to change, and the reader would drop it as dead. The debug log now records the process ancestry, which is what made this diagnosable at all rather than guessable.
204 lines
8.4 KiB
Bash
Executable File
204 lines
8.4 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}}"
|
|
}
|
|
|
|
# --- identifying the claude process ----------------------------------------
|
|
# The hook is spawned as `/bin/sh -c /.../claude-status-hook.py`, so its
|
|
# parent's command line contains "claude" in a path without being claude.
|
|
# Matching on the raw string latches onto that shell, which exits at once.
|
|
python3 - "$HOOK" <<'PY'
|
|
import importlib.util, sys
|
|
spec = importlib.util.spec_from_file_location("h", sys.argv[1])
|
|
h = importlib.util.module_from_spec(spec); spec.loader.exec_module(h)
|
|
cases = [
|
|
("/bin/sh -c /home/u/claude-code-gnome-extension/hooks/claude-status-hook.py ", False),
|
|
("/home/u/.local/bin/claude --resume ", True),
|
|
("bash /home/u/bin/claude ", True),
|
|
("node /usr/lib/node_modules/@anthropic-ai/claude-code/cli.js ", True),
|
|
("/home/u/bin/zellij --server /run/user/1000/zellij/x ", False),
|
|
("nvim /home/u/.claude/settings.json ", False),
|
|
]
|
|
bad = 0
|
|
for cmd, want in cases:
|
|
got = h.looks_like_claude(cmd)
|
|
print(("ok " if got == want else "FAIL ") + "claude in %r -> %s" % (cmd[:46], got))
|
|
bad += got != want
|
|
sys.exit(1 if bad else 0)
|
|
PY
|
|
check "command lines classified correctly" "0" "$?"
|
|
|
|
# --- 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"
|