Files
claude-code-gnome-extension/lib/sessions.js
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

293 lines
11 KiB
JavaScript

// Reads the per-session state files written by the Claude Code hook and keeps
// them in sync with the filesystem.
//
// The hook only writes on an actual state change, so a directory monitor is
// enough and there is nothing to poll. The timer here exists for two other
// reasons: displayed ages go stale on their own, and a session whose terminal
// was killed never sends SessionEnd, so liveness has to be rechecked.
import GObject from 'gi://GObject';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
Gio._promisify(Gio.File.prototype, 'enumerate_children_async');
Gio._promisify(Gio.FileEnumerator.prototype, 'next_files_async');
Gio._promisify(Gio.File.prototype, 'load_contents_async');
// Aggregation order: a session blocked on a permission prompt is the only one
// that is actually stuck, so it outranks one that merely finished its turn.
export const STATES = ['blocked', 'waiting', 'busy'];
const KNOWN = new Set(STATES);
const LIVENESS_INTERVAL = 20; // seconds
// Fallback for sessions whose process could not be identified (pid 0): there is
// nothing to test for liveness, so age is the only signal left. Long enough
// that a session genuinely left waiting overnight is still listed in the
// morning, which is exactly the case this indicator exists for.
const UNKNOWN_PID_MAX_AGE = 36 * 3600; // seconds
// A hook killed between writing its temporary file and renaming it leaves the
// temporary behind. Old ones are swept; recent ones are left alone, because a
// hook may be part-way through writing one right now and deleting it would
// lose that update.
const TMP_MAX_AGE = 300; // seconds
export function stateRank(state) {
const i = STATES.indexOf(state);
return i < 0 ? STATES.length : i;
}
export function stateDir() {
const base = GLib.getenv('XDG_STATE_HOME') ||
GLib.build_filenamev([GLib.get_home_dir(), '.local', 'state']);
return GLib.build_filenamev([base, 'claude-code-status']);
}
export const SessionStore = GObject.registerClass({
Signals: { 'changed': {} },
}, class SessionStore extends GObject.Object {
_init() {
super._init();
this._dir = Gio.File.new_for_path(stateDir());
this._sessions = [];
this._monitor = null;
this._debounceId = 0;
this._timerId = 0;
this._cancellable = new Gio.Cancellable();
this._loading = false;
this._loadAgain = false;
}
get sessions() {
return this._sessions;
}
start() {
// The directory is created by the first hook run, which may not have
// happened yet; monitoring a missing directory still reports its
// creation, so there is nothing to wait for.
try {
this._monitor = this._dir.monitor_directory(Gio.FileMonitorFlags.WATCH_MOVES, null);
this._monitor.connect('changed', () => this._scheduleLoad());
} catch (e) {
logError(e, 'claude-code-status: cannot monitor state directory');
}
this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, LIVENESS_INTERVAL, () => {
// Ages advance and processes die without any file changing, so this
// tick is what makes a killed terminal disappear from the panel.
this._load();
return GLib.SOURCE_CONTINUE;
});
this._load();
}
// One atomic write lands as several monitor events (created, moved, changed).
// Collapsing them keeps a burst of five sessions from causing five reloads.
_scheduleLoad() {
if (this._debounceId)
GLib.Source.remove(this._debounceId);
this._debounceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 120, () => {
this._debounceId = 0;
this._load();
return GLib.SOURCE_REMOVE;
});
}
async _load() {
if (this._loading) {
this._loadAgain = true;
return;
}
this._loading = true;
const cancellable = this._cancellable;
try {
const sessions = await this._readAll(cancellable);
if (cancellable.is_cancelled())
return;
sessions.sort((a, b) => {
const byState = stateRank(a.state) - stateRank(b.state);
// Oldest first within a state: the session you forgot about is
// the one that has been waiting longest, not the latest one.
return byState !== 0 ? byState : a.since - b.since;
});
this._sessions = sessions;
// Emitted unconditionally: even with no structural change the
// displayed ages have advanced, and redrawing a handful of labels
// is cheaper than tracking what moved.
this.emit('changed');
} catch (e) {
if (!cancellable.is_cancelled())
logError(e, 'claude-code-status: failed to read session state');
} finally {
this._loading = false;
if (this._loadAgain) {
this._loadAgain = false;
this._load();
}
}
}
async _readAll(cancellable) {
let enumerator;
try {
enumerator = await this._dir.enumerate_children_async(
'standard::name,time::modified', Gio.FileQueryInfoFlags.NONE,
GLib.PRIORITY_DEFAULT, cancellable);
} catch (e) {
// No directory yet means no sessions have ever run; not an error.
if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
return [];
throw e;
}
const names = [];
for (;;) {
const batch = await enumerator.next_files_async(
32, GLib.PRIORITY_DEFAULT, cancellable);
if (!batch.length)
break;
for (const info of batch) {
const name = info.get_name();
// Only ".json" is state. ".lock" belongs to the hook, "debug"
// is its opt-in event log, and ".tmp" is an interrupted write.
if (name.endsWith('.json'))
names.push(name);
else if (name.endsWith('.tmp'))
this._sweepTemp(info, name);
}
}
const sessions = [];
for (const name of names) {
const session = await this._readOne(name, cancellable);
if (session)
sessions.push(session);
}
return sessions;
}
/** Delete an abandoned temporary file, once it is old enough to be sure. */
_sweepTemp(info, name) {
const modified = info.get_modification_date_time?.();
if (!modified)
return;
const age = GLib.DateTime.new_now_local().difference(modified) / 1e6;
if (age < TMP_MAX_AGE)
return;
this._dir.get_child(name).delete_async(GLib.PRIORITY_LOW, null, (obj, res) => {
try {
obj.delete_finish(res);
} catch (e) {
// Gone already, or not ours to remove.
}
});
}
async _readOne(name, cancellable) {
const file = this._dir.get_child(name);
let raw;
try {
const [contents] = await file.load_contents_async(cancellable);
raw = JSON.parse(new TextDecoder().decode(contents));
} catch (e) {
// A file replaced mid-read, or truncated by a crash: skip it and
// let the next monitor event pick up the good version.
return null;
}
if (!raw || typeof raw !== 'object')
return null;
const pid = Number(raw.pid) || 0;
const pidStart = Number(raw.pid_start) || 0;
const eventTs = Number(raw.event_ts) || 0;
const age = GLib.get_real_time() / 1e6 - eventTs;
// pid 0 is "the hook could not tell", not "dead": treating it as dead
// would hide a perfectly live session, so those fall back to an age
// cutoff instead.
const gone = pid > 0 ? !isAlive(pid, pidStart) : age > UNKNOWN_PID_MAX_AGE;
if (gone) {
// The terminal was killed without a SessionEnd hook. Removing the
// file here (rather than only hiding it) keeps the directory from
// growing forever across reboots.
// The hook's lock file goes with it; dropping only the state file
// would leave one empty ".lock" behind per session, forever.
for (const victim of [file, this._dir.get_child(`${name}.lock`)]) {
victim.delete_async(GLib.PRIORITY_LOW, null, (obj, res) => {
try {
obj.delete_finish(res);
} catch (e) {
// Already gone: the hook's own sweep got there first.
}
});
}
return null;
}
// Anything unrecognised reads as "waiting", which also migrates files
// left on disk by the older hook: those still say "idle", and a
// session open since before the upgrade must not vanish from the panel.
const state = KNOWN.has(raw.state) ? raw.state : 'waiting';
return {
sessionId: String(raw.session_id ?? name.replace(/\.json$/, '')),
state,
cwd: String(raw.cwd ?? ''),
since: Number(raw.since) || 0,
pid,
message: String(raw.message ?? ''),
agents: Math.max(0, Number(raw.agents) || 0),
notificationType: String(raw.notification_type ?? ''),
zellijSession: String(raw.zellij_session ?? ''),
zellijPane: String(raw.zellij_pane ?? ''),
};
}
destroy() {
this._cancellable.cancel();
if (this._debounceId) {
GLib.Source.remove(this._debounceId);
this._debounceId = 0;
}
if (this._timerId) {
GLib.Source.remove(this._timerId);
this._timerId = 0;
}
this._monitor?.cancel();
this._monitor = null;
this._sessions = [];
}
});
/** Is this pid still the process the hook recorded?
*
* Existence alone is not enough. State files outlive reboots, and a pid from a
* previous boot is very likely to belong to something else now -- a session
* that died in a crash would otherwise sit in the panel forever, waiting for
* an answer nobody can give. The start time pins the pid to one process.
*/
function isAlive(pid, startTime) {
if (pid <= 0 || !GLib.file_test(`/proc/${pid}`, GLib.FileTest.EXISTS))
return false;
// Files written before start times were recorded have nothing to compare.
if (!startTime)
return true;
return readStartTime(pid) === startTime;
}
function readStartTime(pid) {
try {
const [ok, bytes] = GLib.file_get_contents(`/proc/${pid}/stat`);
if (!ok)
return 0;
const data = new TextDecoder().decode(bytes);
// The command name is parenthesised and may contain spaces and ')',
// so fields are counted from after the last one.
const tail = data.slice(data.lastIndexOf(')') + 2).split(' ');
return Number(tail[19]) || 0;
} catch (e) {
return 0;
}
}