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
+59 -6
View File
@@ -27,6 +27,12 @@ const LIVENESS_INTERVAL = 20; // seconds
// 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;
@@ -128,7 +134,7 @@ export const SessionStore = GObject.registerClass({
let enumerator;
try {
enumerator = await this._dir.enumerate_children_async(
'standard::name', Gio.FileQueryInfoFlags.NONE,
'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.
@@ -145,10 +151,12 @@ export const SessionStore = GObject.registerClass({
break;
for (const info of batch) {
const name = info.get_name();
// ".tmp" files are half-written state; "debug" is the hook's
// opt-in event log and is not a session.
// 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);
}
}
@@ -161,6 +169,23 @@ export const SessionStore = GObject.registerClass({
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;
@@ -176,12 +201,13 @@ export const SessionStore = GObject.registerClass({
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) : age > UNKNOWN_PID_MAX_AGE;
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
@@ -234,6 +260,33 @@ export const SessionStore = GObject.registerClass({
}
});
function isAlive(pid) {
return pid > 0 && GLib.file_test(`/proc/${pid}`, GLib.FileTest.EXISTS);
/** 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;
}
}