Four agents reviewed this in parallel -- correctness, GNOME integration, edge cases, security. Everything below was reproduced before being fixed; several findings that survived the first reading did not survive a probe and are not here. State machine, the two that mattered most. A pending permission prompt was erased by any subagent bookkeeping event: SubagentStop or the next PreToolUse recomputed the state from scratch, so a session sat at "working" with a dialog open and nothing ever raised it again. Blocked now outlives everything except evidence the question was answered. Separately, the stale-event guard refused whole events, including the subagent counter's increments and decrements -- but those are deltas and deltas commute, so a "+1" that lost a timestamp race left the count short and the batch freed the session while a subagent was still running. The guard now gates the state decision only. Corrupt or hostile state files could wedge the panel or take the hook down for every session: a non-numeric pid raised inside sweep_dead before the hook wrote its own file, so one bad byte stopped new sessions appearing at all. Numbers read back from disk are coerced, one unreadable file no longer aborts the sweep, and a stored timestamp far in the future -- corruption, or a clock stepped backwards by NTP -- no longer refuses every later event forever. Resource bounds, all in the compositor process. A state file was read whole with no size check: a symlink to /dev/zero took a test process past 4 GB in three seconds, which in gnome-shell ends the session. Sizes are checked before the read, sessions and zellij subprocesses are capped, labels ellipsize, and cwd and messages are truncated at the hook. Teardown hung off an overridden destroy(), which only runs when JS calls it. An actor destroyed any other way -- another extension rebuilding the panel boxes -- left the timer and the file monitor running against a disposed actor. It is a destroy signal now. The zellij child is killed rather than merely abandoned. The glyph was pinned to physical pixels and rendered half-size on HiDPI; size comes from the stylesheet, and the foreground colour is normalised by inspection rather than assuming which colour struct the shell hands back. Chip labels: non-Latin names all collapsed to "?", because the split treated every Cyrillic letter as a separator -- notable for a tool whose own README is Russian. Seniority also ranked by time-in-state rather than session age, so after a shell restart the older session could take the digit; the hook now records when the session began. zellij: a dump ends with new_tab_template and swap_tiled_layout blocks whose tab lines carry no name, and their panes were being attached to the last real tab -- which then answered for every unmatched directory, confidently and wrongly. install.py no longer widens the mode of a settings.json someone narrowed to 0600, no longer overwrites the pristine .bak on a second run, no longer replaces a symlink out of a dotfiles repository with a regular file, and quotes the hook path. The debug log is capped and README now says plainly that it records prompts verbatim. Not fixed, deliberately: the panel does push the clock about 70 px left with three labelled chips, which is inherent to putting them in the centre box; two different projects abbreviating alike still read as one project with a digit; GNOME 48 remains unverified for the colour struct and for St.BoxLayout's vertical property, both flagged rather than guessed at.
137 lines
5.9 KiB
JavaScript
137 lines
5.9 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);
|
|
// Left by a session that ended: the hook keeps the lock inode deliberately,
|
|
// so nobody but the reader would ever clear it.
|
|
GLib.file_set_contents(GLib.build_filenamev([STATE, 's-gone.json.lock']), '');
|
|
GLib.spawn_command_line_sync(
|
|
`touch -d '1 hour ago' ${GLib.build_filenamev([STATE, 's-gone.json.lock'])}`);
|
|
// A state file far larger than any real one must not be read at all.
|
|
GLib.file_set_contents(GLib.build_filenamev([STATE, 'huge.json']),
|
|
`{"session_id":"huge","state":"waiting","pid":1,"cwd":"${'x'.repeat(70000)}"}`);
|
|
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('oversized state file not loaded', !s.some(x => x.sessionId === 'huge'));
|
|
check('orphaned lock swept',
|
|
!GLib.file_test(GLib.build_filenamev([STATE, 's-gone.json.lock']), GLib.FileTest.EXISTS));
|
|
|
|
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);
|