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.
125 lines
5.1 KiB
JavaScript
125 lines
5.1 KiB
JavaScript
#!/usr/bin/gjs -m
|
|
// Exercises SessionStore against real files, under real GJS/GIO.
|
|
//
|
|
// sessions.js deliberately imports nothing from resource:///org/gnome/shell,
|
|
// which is what makes this runnable outside the compositor -- the part of the
|
|
// extension most likely to be wrong (liveness, ordering, monitoring, partial
|
|
// reads) is also the part that can be tested for real.
|
|
//
|
|
// Run: gjs -m tests/test-sessions.js
|
|
|
|
import GLib from 'gi://GLib';
|
|
import Gio from 'gi://Gio';
|
|
|
|
import { SessionStore, stateRank } from '../lib/sessions.js';
|
|
|
|
const DIR = GLib.build_filenamev([GLib.get_tmp_dir(), `ccs-test-${GLib.random_int()}`]);
|
|
GLib.setenv('XDG_STATE_HOME', DIR, true);
|
|
const STATE = GLib.build_filenamev([DIR, 'claude-code-status']);
|
|
GLib.mkdir_with_parents(STATE, 0o755);
|
|
|
|
let failures = 0;
|
|
function check(name, condition, detail = '') {
|
|
const mark = condition ? 'ok ' : 'FAIL';
|
|
if (!condition)
|
|
failures++;
|
|
print(`${mark} ${name}${detail ? ` (${detail})` : ''}`);
|
|
}
|
|
|
|
const now = GLib.get_real_time() / 1e6;
|
|
function write(id, state, cwd, agoSeconds, pid, pidStart = 0) {
|
|
const payload = {
|
|
session_id: id, state, cwd, since: now - agoSeconds,
|
|
event_ts: now, pid, pid_start: pidStart, event: 'test',
|
|
notification_type: '', message: '', zellij_session: 'ztest',
|
|
zellij_pane: '1', transcript: '',
|
|
};
|
|
GLib.file_set_contents(
|
|
GLib.build_filenamev([STATE, `${id}.json`]), JSON.stringify(payload));
|
|
}
|
|
|
|
// A process that exists for the duration of the test, and one that does not.
|
|
const livePid = new TextDecoder().decode(
|
|
GLib.file_get_contents('/proc/self/stat')[1]).split(' ')[0];
|
|
const deadPid = 4194303; // above the default pid_max, so it cannot exist
|
|
|
|
write('s-busy', 'busy', '/home/u/proj-busy', 30, livePid);
|
|
write('s-blocked', 'blocked', '/home/u/proj-blocked', 10, livePid);
|
|
write('s-wait-new', 'waiting', '/home/u/proj-new', 60, livePid);
|
|
write('s-wait-old', 'waiting', '/home/u/proj-old', 3600, livePid);
|
|
write('s-dead', 'waiting', '/home/u/proj-dead', 5, deadPid);
|
|
// Written by the older hook, which had a fourth state. Files like this
|
|
// survive an upgrade in a session that was already open.
|
|
write('s-legacy', 'idle', '/home/u/proj-legacy', 5, livePid);
|
|
// Survived a reboot: the pid exists again, but belongs to something else now.
|
|
// Without an identity check this sits in the panel forever as a live session.
|
|
write('s-ghost', 'waiting', '/home/u/proj-ghost', 99999, livePid, 1);
|
|
GLib.file_set_contents(GLib.build_filenamev([STATE, 'notes.txt']), 'ignored');
|
|
GLib.file_set_contents(GLib.build_filenamev([STATE, 'half.json']), '{"broken');
|
|
|
|
const loop = new GLib.MainLoop(null, false);
|
|
const store = new SessionStore();
|
|
let round = 0;
|
|
|
|
store.connect('changed', () => {
|
|
round++;
|
|
const s = store.sessions;
|
|
|
|
if (round === 1) {
|
|
check('ranks order blocked before waiting before busy',
|
|
stateRank('blocked') < stateRank('waiting') &&
|
|
stateRank('waiting') < stateRank('busy'));
|
|
|
|
check('dead session dropped', !s.some(x => x.sessionId === 's-dead'));
|
|
check('reused pid from a previous boot dropped',
|
|
!s.some(x => x.sessionId === 's-ghost'));
|
|
check('and its file removed',
|
|
!GLib.file_test(GLib.build_filenamev([STATE, 's-ghost.json']), GLib.FileTest.EXISTS));
|
|
check('truncated file skipped, others survive', s.length === 5,
|
|
`got ${s.length}: ${s.map(x => x.sessionId).join(',')}`);
|
|
check('non-json ignored', !s.some(x => x.sessionId.includes('notes')));
|
|
|
|
check('blocked sorts first', s[0]?.sessionId === 's-blocked', s[0]?.sessionId);
|
|
check('longest wait precedes newer wait',
|
|
s[1]?.sessionId === 's-wait-old' && s[2]?.sessionId === 's-wait-new',
|
|
`${s[1]?.sessionId}, ${s[2]?.sessionId}`);
|
|
const legacy = s.find(x => x.sessionId === 's-legacy');
|
|
check('a retired state reads as waiting, not dropped',
|
|
legacy?.state === 'waiting', legacy?.state);
|
|
check('and sorts by age among the waiting ones',
|
|
s[3]?.sessionId === 's-legacy', s[3]?.sessionId);
|
|
check('busy after waiting', s[4]?.sessionId === 's-busy', s[4]?.sessionId);
|
|
|
|
check('dead session file removed from disk',
|
|
!GLib.file_test(GLib.build_filenamev([STATE, 's-dead.json']), GLib.FileTest.EXISTS));
|
|
|
|
// A hook writing a new session must reach the panel without polling.
|
|
write('s-fresh', 'blocked', '/home/u/proj-fresh', 1, livePid);
|
|
return;
|
|
}
|
|
|
|
if (s.some(x => x.sessionId === 's-fresh')) {
|
|
check('directory monitor picked up a new session', true);
|
|
check('new blocked session takes the top slot',
|
|
s[0].state === 'blocked', s[0].sessionId);
|
|
store.destroy();
|
|
loop.quit();
|
|
}
|
|
});
|
|
|
|
store.start();
|
|
|
|
GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 10, () => {
|
|
check('finished before timeout', false, 'monitor never fired');
|
|
loop.quit();
|
|
return GLib.SOURCE_REMOVE;
|
|
});
|
|
|
|
loop.run();
|
|
|
|
Gio.File.new_for_path(DIR).trash_async?.(GLib.PRIORITY_LOW, null, null);
|
|
GLib.spawn_command_line_sync(`rm -rf ${DIR}`);
|
|
|
|
print(failures ? `\n${failures} failure(s)` : '\nall passed');
|
|
imports.system.exit(failures ? 1 : 0);
|