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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user