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.
This commit is contained in:
av
2026-08-09 19:43:31 +03:00
parent ca42d69704
commit 75bfe77950
5 changed files with 130 additions and 18 deletions
+30 -4
View File
@@ -169,8 +169,32 @@ def find_claude_pid():
return 0
def alive(pid):
return pid > 0 and os.path.exists("/proc/%d" % pid)
def pid_start_time(pid):
"""Field 22 of /proc/<pid>/stat: when the process started, in clock ticks.
Pins a pid to one particular process. Pids are reused, and state files
outlive reboots -- without this, a file left by a crashed session whose pid
is later handed to something unrelated reads as a live session forever.
"""
try:
with open("/proc/%d/stat" % pid) as fh:
data = fh.read()
except OSError:
return 0
# Field 2 is the command name, parenthesised, and may itself contain spaces
# and a ')'. Everything after the last ')' is field 3 onwards.
tail = data[data.rfind(")") + 2:].split()
try:
return int(tail[19])
except (IndexError, ValueError):
return 0
def alive(pid, start=0):
if pid <= 0 or not os.path.exists("/proc/%d" % pid):
return False
# A file written before start times were recorded has nothing to compare.
return not start or pid_start_time(pid) == start
def sweep_dead(keep):
@@ -190,12 +214,13 @@ def sweep_dead(keep):
path = os.path.join(STATE_DIR, name)
try:
with open(path, "r") as fh:
pid = json.load(fh).get("pid", 0)
stale = json.load(fh)
pid = stale.get("pid", 0)
except (OSError, ValueError, AttributeError):
continue
# pid 0 means the hook could not identify the process; there is nothing
# to test for liveness, so leave it to the reader's age cutoff.
if pid and not alive(pid):
if pid and not alive(pid, stale.get("pid_start", 0)):
for victim in (path, path + ".lock"):
try:
os.unlink(victim)
@@ -300,6 +325,7 @@ def apply_event(event, state, path, now):
"event_ts": now,
# 0 means "could not tell"; the reader must not take that for "dead".
"pid": claude_pid,
"pid_start": pid_start_time(claude_pid) if claude_pid else 0,
"event": event.get("hook_event_name", ""),
"notification_type": event.get("notification_type", ""),
"message": message,