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:
+59
-6
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user