Adversarial review of the previous commit found ten defects. The critical one
would have destroyed exactly the work this feature exists to protect.
CRITICAL — `#{session_activity}` is a CLIENT clock, not an output clock.
It advances on attach and on keypresses, and never moves for a detached session
however much its shell is printing. Measured on tmux 3.6a: a detached session
running `while true; do echo tick; sleep 1; done` still reported
session_activity == session_created after 6 s, while `#{window_activity}` tracked
the output exactly. So "idle for 7 days" was really "nobody has typed for 7 days",
and `Clean up idle 7d+` would have killed long-running unattended sessions that
were busy working — the walk-away case the whole app is built around.
Measured against this host's 69 sessions: the old clock condemned 36, the new one
condemns 33, so three sessions doing real work were one click from deletion.
Now reads window_activity, with a test that fails if the format string regresses.
HIGH — no timeout on any tmux call. maxBuffer bounds a flood but nothing bounded
the wait, and a synchronous exec cannot be interrupted by the event loop, so a
tmux server stopped mid-syscall hung the whole server permanently. All five calls
(including the three that predate this feature) now carry one.
HIGH — listSessions and capturePane were synchronous on a POLLED path. Measured
73 ms and 58 ms per call on this host; every home screen on every device refreshes
on a 5 s timer, and each of those was 73 ms with the event loop stopped, i.e.
73 ms in which no PTY byte reached any terminal anywhere. Both are async now, so
the manager's listOrphans/captureOrphan/killOrphansIdleSince are too. The server
is a byte-shuttle first: nothing polled may block it.
HIGH — GET /orphan-sessions had no rate limiter although, unlike every other read
route, it spawns a subprocess. It shares the preview bucket now.
HIGH — the cleanup confirmation counted CARDS, and the grid is capped at 24 while
the cleanup matches across every session the server enumerates. On this host it
would have said "24" and ended 33 shells. New GET /orphan-sessions/count-idle
answers the real question, and the dialog states that number, or refuses to
proceed if it cannot be determined.
MEDIUM — fetchOrphanSessions collapsed every failure into [], so one 429 or 500
read as "all recovered sessions are gone", tearing down every mounted xterm and
rebuilding it on the next success. It returns null for failure now, and the
refresh leaves the section untouched.
MEDIUM — the preview latch was "card was created", not "preview succeeded", so a
card that lost its single request stayed blank forever. Latches on success.
MEDIUM — overlapping refreshes. refresh() is entered from a 5 s timer, from
killOrphan and from cleanup, with awaits inside and no guard, so a slow poll could
resurrect cards for sessions already killed or re-mount them into a root that
setVisible(false) had just cleared. A generation counter, bumped on teardown and
checked after every await, invalidates stale runs.
MEDIUM — renderPreview could write to a disposed xterm (it throws "Object has
been disposed"). PreviewCard carries an explicit `disposed` flag set by a new
disposeCard() helper, so no caller can dispose a terminal and forget the flag.
Deliberately NOT el.isConnected: a card not yet appended is alive, and two
existing preview-grid tests correctly caught that conflation.
LOW — kill and cleanup discarded their results, so a refusal (403 behind a proxy
whose Origin differs, 404 on a lost race) looked like a dead button. Both report.
Tests: 2209 unit, 27 e2e, 8 orphan integration. Verified live against all 69 real
sessions with none harmed; the window_activity fix confirmed on an isolated tmux
socket (-L) so the developer's sessions were never involved.
305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
/**
|
||
* A — orphan tmux sessions: the manager side.
|
||
*
|
||
* An "orphan" is a `web_*` tmux session on the host that this server does NOT have
|
||
* in its table (it outlived the process that made it). These tests pin the three
|
||
* rules that keep the feature safe:
|
||
*
|
||
* 1. never enumerate or act on a tmux session that is not ours (`web_` + UUID v4);
|
||
* 2. never act on an id the table already owns — that path must stay killById, or
|
||
* we would kill the shell out from under a live PTY and leave a zombie entry;
|
||
* 3. do nothing at all when tmux is off, so the non-tmux deployment is unchanged.
|
||
*
|
||
* Both node-pty and the tmux CLI wrappers are mocked: no shell, no tmux binary.
|
||
*/
|
||
|
||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
|
||
import type { Config, Dims, WebSocketLike } from '../src/types.js';
|
||
import { WS_OPEN } from '../src/types.js';
|
||
import { createMockPty, type MockIPty } from './helpers/mock-pty.js';
|
||
|
||
let nextPty: MockIPty = createMockPty();
|
||
|
||
vi.mock('node-pty', () => ({
|
||
spawn: () => nextPty,
|
||
}));
|
||
|
||
// ── tmux CLI mock ────────────────────────────────────────────────────────────
|
||
const tmuxList = vi.fn<() => Promise<unknown[]>>(async () => []);
|
||
const tmuxCapture = vi.fn<(name: string) => Promise<string | null>>(async () => null);
|
||
const tmuxKill = vi.fn<(name: string) => void>(() => {});
|
||
const tmuxHas = vi.fn<(name: string) => boolean>(() => true);
|
||
|
||
vi.mock('../src/session/tmux.js', () => ({
|
||
tmuxAvailable: () => true,
|
||
tmuxName: (id: string) => `web_${id}`,
|
||
hasSession: (n: string) => tmuxHas(n),
|
||
killSession: (n: string) => tmuxKill(n),
|
||
listSessions: () => tmuxList(),
|
||
capturePane: (n: string) => tmuxCapture(n),
|
||
}));
|
||
|
||
const { createSessionManager } = await import('../src/session/manager.js');
|
||
|
||
const U1 = '05caa88b-1f5f-45d9-bbbe-fc5955314870';
|
||
const U2 = '3a63f3c7-acfe-4081-992f-c4dbd55b3d6b';
|
||
|
||
const BASE_CFG = {
|
||
port: 3000,
|
||
bindHost: '0.0.0.0',
|
||
shellPath: '/bin/zsh',
|
||
homeDir: '/home/tester',
|
||
idleTtlMs: 10_000,
|
||
scrollbackBytes: 2 * 1024 * 1024,
|
||
maxPayloadBytes: 1024 * 1024,
|
||
wsPath: '/term',
|
||
maxSessions: 50,
|
||
maxMsgsPerSec: 2000,
|
||
permTimeoutMs: 300_000,
|
||
reapIntervalMs: 60_000,
|
||
previewBytes: 24 * 1024,
|
||
useTmux: true,
|
||
allowedOrigins: [],
|
||
projectRoots: ['/home/tester'],
|
||
projectScanDepth: 4,
|
||
projectScanTtlMs: 10_000,
|
||
projectDirtyCheck: true,
|
||
editorCmd: 'code',
|
||
vapidPublicKey: undefined,
|
||
vapidPrivateKey: undefined,
|
||
vapidSubject: undefined,
|
||
pushStorePath: '/home/tester/.push.json',
|
||
pushMaxSubs: 50,
|
||
notifyDone: true,
|
||
notifyDnd: false,
|
||
decisionTokenTtlMs: 300_000,
|
||
timelineMax: 200,
|
||
timelineEnabled: true,
|
||
stuckTtlMs: 10_000,
|
||
stuckAlert: true,
|
||
diffTimeoutMs: 2000,
|
||
diffMaxBytes: 2 * 1024 * 1024,
|
||
diffMaxFiles: 300,
|
||
statuslineTtlMs: 30_000,
|
||
worktreeEnabled: true,
|
||
worktreeRoot: undefined,
|
||
worktreeTimeoutMs: 10_000,
|
||
maxFanoutLanes: 6,
|
||
defaultPermissionMode: 'default' as const,
|
||
allowAutoMode: false,
|
||
queueEnabled: true,
|
||
queueMaxItems: 10,
|
||
queueItemMaxBytes: 4096,
|
||
queueSettleMs: 1500,
|
||
} satisfies Config;
|
||
|
||
const CFG: Config = BASE_CFG;
|
||
const CFG_NO_TMUX: Config = { ...BASE_CFG, useTmux: false };
|
||
const DIMS: Dims = { cols: 80, rows: 24 };
|
||
|
||
function createMockWs(): WebSocketLike & { readyState: number } {
|
||
return {
|
||
readyState: WS_OPEN,
|
||
send() {},
|
||
close() {},
|
||
};
|
||
}
|
||
|
||
/** A tmux summary row as src/session/tmux.ts would produce it. */
|
||
function summary(id: string, activityMs = 5_000, attached = false) {
|
||
return {
|
||
id,
|
||
createdAtMs: 1_000,
|
||
lastActivityAtMs: activityMs,
|
||
attached,
|
||
cols: 120,
|
||
rows: 40,
|
||
};
|
||
}
|
||
|
||
beforeEach(() => {
|
||
nextPty = createMockPty();
|
||
tmuxList.mockReset().mockResolvedValue([]);
|
||
tmuxCapture.mockReset().mockResolvedValue(null);
|
||
tmuxKill.mockReset();
|
||
tmuxHas.mockReset().mockReturnValue(true);
|
||
});
|
||
|
||
describe('listOrphans', () => {
|
||
it('reports a tmux session the table does not know about', async () => {
|
||
tmuxList.mockResolvedValue([summary(U1, 9_000, true)]);
|
||
const mgr = createSessionManager(CFG);
|
||
|
||
expect(await mgr.listOrphans()).toEqual([
|
||
{
|
||
id: U1,
|
||
createdAt: 1_000,
|
||
lastActivityAt: 9_000,
|
||
attached: true,
|
||
cols: 120,
|
||
rows: 40,
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('excludes sessions the table already owns — those are live, not orphans', async () => {
|
||
const mgr = createSessionManager(CFG);
|
||
const live = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
|
||
tmuxList.mockResolvedValue([summary(live.meta.id), summary(U2)]);
|
||
|
||
expect((await mgr.listOrphans()).map((o) => o.id)).toEqual([U2]);
|
||
});
|
||
|
||
it('is empty when tmux is off — the non-tmux deployment is untouched', async () => {
|
||
tmuxList.mockResolvedValue([summary(U1)]);
|
||
const mgr = createSessionManager(CFG_NO_TMUX);
|
||
|
||
expect(await mgr.listOrphans()).toEqual([]);
|
||
expect(tmuxList).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('captureOrphan', () => {
|
||
it('returns the captured screen for an untracked session', async () => {
|
||
tmuxCapture.mockResolvedValue('screen contents');
|
||
const mgr = createSessionManager(CFG);
|
||
|
||
expect(await mgr.captureOrphan(U1)).toBe('screen contents');
|
||
expect(tmuxCapture).toHaveBeenCalledWith(`web_${U1}`);
|
||
});
|
||
|
||
it('refuses an id that is not a UUID v4 without invoking tmux at all', async () => {
|
||
const mgr = createSessionManager(CFG);
|
||
|
||
expect(await mgr.captureOrphan('../../etc/passwd')).toBeNull();
|
||
expect(await mgr.captureOrphan('-t')).toBeNull();
|
||
expect(tmuxCapture).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('refuses when no such tmux session exists', async () => {
|
||
tmuxHas.mockReturnValue(false);
|
||
const mgr = createSessionManager(CFG);
|
||
|
||
expect(await mgr.captureOrphan(U1)).toBeNull();
|
||
expect(tmuxCapture).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('refuses an id the table owns — a live session previews from its ring buffer', async () => {
|
||
const mgr = createSessionManager(CFG);
|
||
const live = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
|
||
|
||
expect(await mgr.captureOrphan(live.meta.id)).toBeNull();
|
||
expect(tmuxCapture).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('returns null when tmux is off', async () => {
|
||
const mgr = createSessionManager(CFG_NO_TMUX);
|
||
expect(await mgr.captureOrphan(U1)).toBeNull();
|
||
expect(tmuxCapture).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('killOrphan', () => {
|
||
it('kills the tmux session by its prefixed name', () => {
|
||
const mgr = createSessionManager(CFG);
|
||
|
||
expect(mgr.killOrphan(U1)).toBe(true);
|
||
expect(tmuxKill).toHaveBeenCalledWith(`web_${U1}`);
|
||
});
|
||
|
||
it('refuses a non-UUID id without invoking tmux', () => {
|
||
const mgr = createSessionManager(CFG);
|
||
|
||
expect(mgr.killOrphan('web_x')).toBe(false);
|
||
expect(mgr.killOrphan('-t')).toBe(false);
|
||
expect(mgr.killOrphan('')).toBe(false);
|
||
expect(tmuxKill).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('refuses an id the table owns — that is killById’s job', () => {
|
||
const mgr = createSessionManager(CFG);
|
||
const live = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
|
||
|
||
expect(mgr.killOrphan(live.meta.id)).toBe(false);
|
||
expect(tmuxKill).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('reports false when the session is already gone', () => {
|
||
tmuxHas.mockReturnValue(false);
|
||
const mgr = createSessionManager(CFG);
|
||
|
||
expect(mgr.killOrphan(U1)).toBe(false);
|
||
expect(tmuxKill).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('does nothing when tmux is off', () => {
|
||
const mgr = createSessionManager(CFG_NO_TMUX);
|
||
expect(mgr.killOrphan(U1)).toBe(false);
|
||
expect(tmuxKill).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('killOrphansIdleSince', () => {
|
||
it('kills only sessions whose last activity is older than the cutoff', async () => {
|
||
tmuxList.mockResolvedValue([summary(U1, 1_000), summary(U2, 9_000)]);
|
||
const mgr = createSessionManager(CFG);
|
||
|
||
expect(await mgr.killOrphansIdleSince(5_000)).toEqual([U1]);
|
||
expect(tmuxKill).toHaveBeenCalledWith(`web_${U1}`);
|
||
expect(tmuxKill).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('never kills a session someone is attached to from a terminal', async () => {
|
||
// "Untracked by this server" is not "abandoned" — a plain `tmux attach` or a
|
||
// second server process both show up as attached, and bulk cleanup must not
|
||
// reach into either.
|
||
tmuxList.mockResolvedValue([summary(U1, 1_000, true)]);
|
||
const mgr = createSessionManager(CFG);
|
||
|
||
expect(await mgr.killOrphansIdleSince(5_000)).toEqual([]);
|
||
expect(tmuxKill).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('never kills a session the table owns, however idle tmux thinks it is', async () => {
|
||
const mgr = createSessionManager(CFG);
|
||
const live = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
|
||
tmuxList.mockResolvedValue([summary(live.meta.id, 0)]);
|
||
|
||
expect(await mgr.killOrphansIdleSince(5_000)).toEqual([]);
|
||
expect(tmuxKill).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('does nothing when tmux is off', async () => {
|
||
tmuxList.mockResolvedValue([summary(U1, 0)]);
|
||
const mgr = createSessionManager(CFG_NO_TMUX);
|
||
|
||
expect(await mgr.killOrphansIdleSince(5_000)).toEqual([]);
|
||
expect(tmuxKill).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('countOrphansIdleSince', () => {
|
||
it('counts exactly what killOrphansIdleSince would kill, killing nothing', async () => {
|
||
// The UI confirmation must state this number, not the number of cards it is
|
||
// showing: the grid is capped, so counting cards understates the blast radius.
|
||
tmuxList.mockResolvedValue([summary(U1, 1_000), summary(U2, 9_000)]);
|
||
const mgr = createSessionManager(CFG);
|
||
|
||
expect(await mgr.countOrphansIdleSince(5_000)).toBe(1);
|
||
expect(tmuxKill).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('does not count attached sessions', async () => {
|
||
tmuxList.mockResolvedValue([summary(U1, 1_000, true), summary(U2, 1_000)]);
|
||
const mgr = createSessionManager(CFG);
|
||
|
||
expect(await mgr.countOrphansIdleSince(5_000)).toBe(1);
|
||
});
|
||
|
||
it('is 0 when tmux is off', async () => {
|
||
const mgr = createSessionManager(CFG_NO_TMUX);
|
||
expect(await mgr.countOrphansIdleSince(5_000)).toBe(0);
|
||
});
|
||
});
|