diff --git a/public/launcher.ts b/public/launcher.ts index b778349..e54bd2a 100644 --- a/public/launcher.ts +++ b/public/launcher.ts @@ -9,14 +9,22 @@ * manage page via public/preview-grid.ts (DRY). */ -import type { LiveSessionInfo } from '../src/types.js' +import type { LiveSessionInfo, OrphanSessionInfo } from '../src/types.js' import { el, relTime, makePreviewCard, updatePreviewCard, loadPreviewInto, + disposeCard, fetchLiveSessions, + fetchOrphanSessions, + fetchOrphanPreview, + countIdleOrphans, + renderPreview, + orphanAsLive, + killOrphanReq, + cleanupOrphansReq, type PreviewCard, } from './preview-grid.js' @@ -48,6 +56,24 @@ export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher const grid = el('div', 'mg-grid') root.append(grid) + // A: tmux sessions that outlived the server process. Its own section, below the + // live grid, because these are recoveries rather than things you are working on + // — and it stays hidden entirely when there are none (the common case, and the + // only case when tmux is off). + const orphanSection = el('div', 'launcher-orphans') + orphanSection.style.display = 'none' + const orphanHead = el('div', 'launcher-orphans-head') + const orphanTitle = el('div', 'launcher-orphans-title', 'Recoverable') + const orphanSub = el('div', 'launcher-orphans-sub', '') + const cleanupBtn = el('button', 'launcher-cleanup', 'Clean up idle 7d+') + cleanupBtn.title = 'Kill unattached recoverable sessions with no tmux activity for 7 days' + orphanHead.append(orphanTitle, orphanSub, cleanupBtn) + const orphanGrid = el('div', 'mg-grid') + const orphanMore = el('div', 'launcher-orphans-more', '') + orphanMore.style.display = 'none' + orphanSection.append(orphanHead, orphanGrid, orphanMore) + root.append(orphanSection) + // "New session" is a tile that leads the grid (matches the session cards), // instead of a heavy header button. Re-prepended on every refresh. const newTile = el('button', 'mg-new-card') @@ -57,7 +83,22 @@ export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher newTile.addEventListener('click', () => hooks.onNew()) const cards = new Map() + const orphanCards = new Map() + /** Ids whose preview has actually RENDERED. Latching on "card exists" instead + * left a card that lost its one request (429, or a 404 racing the list) blank + * forever, because nothing ever asked again. */ + const orphanPreviewed = new Set() let timer: ReturnType | null = null + /** Bumped by every teardown and every new refresh. An in-flight refresh compares + * it after each await and bails if it is stale — otherwise a slow poll could + * resurrect cards for sessions already killed, or re-mount them into a root that + * setVisible(false) has just cleared. */ + let generation = 0 + + const CLEANUP_IDLE_DAYS = 7 + /** Thumbnails are xterm instances; a host with dozens of stale tmux sessions + * would otherwise mount dozens of terminals on the home screen. */ + const MAX_ORPHAN_CARDS = 24 /** Kill a session (DELETE /live-sessions/:id) then refresh the grid. */ async function killOne(id: string): Promise { @@ -100,14 +141,136 @@ export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher } for (const [id, card] of cards) { if (!seen.has(id)) { - card.term.dispose() - card.el.remove() + disposeCard(card) cards.delete(id) } } + await refreshOrphans() } + /** Kill one orphan, then refresh. A refusal (403 from the Origin guard behind a + * proxy, 404 from a lost race) must be visible — silently doing nothing reads as + * a broken button. */ + async function killOrphan(id: string): Promise { + const ok = await killOrphanReq(id) + if (!ok) orphanSub.textContent = `Could not end session ${id.slice(0, 8)} — it may already be gone.` + void refresh() + } + + /** Opening an orphan revives it: attaching with its id re-adopts the tmux + * session in place, after which it appears in the live grid above. */ + function makeOrphanCard(o: OrphanSessionInfo): PreviewCard { + const kill = el('button', 'mg-kill', 'Kill ✕') + kill.title = 'End this session’s shell' + kill.addEventListener('click', () => void killOrphan(o.id)) + return makePreviewCard(orphanAsLive(o), { + onOpen: (id) => hooks.onOpen(id), + extraActions: () => [kill], + }) + } + + async function refreshOrphans(): Promise { + const myGen = generation + const orphans = await fetchOrphanSessions() + if (myGen !== generation) return // torn down or superseded while fetching + + // null = the request failed. Leave the section exactly as it is: reporting + // "none" would tear down every mounted xterm and rebuild it on recovery. + if (orphans === null) return + + if (orphans.length === 0) { + orphanSection.style.display = 'none' + for (const card of orphanCards.values()) disposeCard(card) + orphanCards.clear() + orphanPreviewed.clear() + return + } + + orphanSection.style.display = '' + const idle = orphans.filter((o) => !o.attached).length + orphanSub.textContent = + `${orphans.length} tmux session${orphans.length === 1 ? '' : 's'} not open here — ` + + `open one to take it over${idle > 0 ? `, ${idle} unattended` : ''}` + + // Server-sorted most-recently-active first, so the cap keeps the ones you are + // plausibly looking for. The tail is what the cleanup button is for. + const shown = orphans.slice(0, MAX_ORPHAN_CARDS) + const hidden = orphans.length - shown.length + + const seen = new Set() + for (const o of shown) { + seen.add(o.id) + let card = orphanCards.get(o.id) + if (card === undefined) { + card = makeOrphanCard(o) + orphanCards.set(o.id, card) + orphanGrid.append(card.el) + } + updatePreviewCard(card, orphanAsLive(o)) + card.meta.textContent = + `${o.cols}×${o.rows} · active ${relTime(o.lastActivityAt)} ago · ` + + `${relTime(o.createdAt)} old` + // ONCE per card, not on every 5 s tick: each preview spawns a tmux + // capture-pane, and re-capturing every card every tick would mean dozens of + // processes a minute for screens that are, by definition, not being driven + // from here. Fire-and-forget so a slow capture never stalls the next card. + if (!orphanPreviewed.has(o.id)) { + const target = card + const targetId = o.id + void fetchOrphanPreview(targetId).then((p) => { + if (p === null || myGen !== generation) return + renderPreview(target, p, THUMB_W, THUMB_MAX_H) + orphanPreviewed.add(targetId) // only now stop asking + }) + } + } + for (const [id, card] of orphanCards) { + if (!seen.has(id)) { + disposeCard(card) + orphanCards.delete(id) + orphanPreviewed.delete(id) + } + } + + // Never truncate silently. + orphanMore.textContent = + hidden > 0 + ? `${hidden} older session${hidden === 1 ? '' : 's'} not shown — clean up, or attach by id from a terminal with: tmux attach -t web_` + : '' + orphanMore.style.display = hidden > 0 ? '' : 'none' + } + + cleanupBtn.addEventListener('click', () => { + // Ask the SERVER how many it would end. orphanCards is capped at + // MAX_ORPHAN_CARDS while the cleanup matches across every session the server + // enumerates, so counting cards would understate the blast radius — on a host + // with 69 orphans the dialog would have said 24. + void countIdleOrphans(CLEANUP_IDLE_DAYS).then((n) => { + if (n === null) { + orphanSub.textContent = 'Could not check how many sessions are idle — not cleaning up.' + return + } + if (n === 0) { + orphanSub.textContent = `Nothing to clean up: no unattended session has been idle ${CLEANUP_IDLE_DAYS} days.` + return + } + if ( + !window.confirm( + `End ${n} session${n === 1 ? '' : 's'}?\n\n` + + `These are recoverable sessions with no tmux activity for ${CLEANUP_IDLE_DAYS} days. ` + + `Their shells will be terminated. Sessions someone is attached to are never touched.`, + ) + ) { + return + } + void cleanupOrphansReq(CLEANUP_IDLE_DAYS).then((ids) => { + if (ids.length === 0) orphanSub.textContent = 'Cleanup ended no sessions.' + void refresh() + }) + }) + }) + return { setVisible(v: boolean): void { root.style.display = v ? 'block' : 'none' @@ -123,9 +286,15 @@ export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher clearInterval(timer) timer = null } - for (const card of cards.values()) card.term.dispose() + for (const card of cards.values()) disposeCard(card) cards.clear() grid.replaceChildren() + generation += 1 // invalidate any refresh still in flight + for (const card of orphanCards.values()) disposeCard(card) + orphanCards.clear() + orphanPreviewed.clear() + orphanGrid.replaceChildren() + orphanSection.style.display = 'none' } }, refresh(): void { diff --git a/public/preview-grid.ts b/public/preview-grid.ts index 48419c5..214f371 100644 --- a/public/preview-grid.ts +++ b/public/preview-grid.ts @@ -9,7 +9,12 @@ */ import { Terminal } from '@xterm/xterm' -import type { LiveSessionInfo, StatusTelemetry, ClaudeStatus } from '../src/types.js' +import type { + LiveSessionInfo, + OrphanSessionInfo, + StatusTelemetry, + ClaudeStatus, +} from '../src/types.js' /** Shape of GET /live-sessions/:id/preview. */ export interface SessionPreview { @@ -62,6 +67,10 @@ export function sessionName(s: LiveSessionInfo): string { export interface PreviewCard { el: HTMLElement term: Terminal + /** Set by disposeCard. A preview is fetched fire-and-forget, so a card can be + * torn down while its request is in flight, and xterm throws "Object has been + * disposed" on a disposed terminal. Checked by renderPreview. */ + disposed: boolean inner: HTMLElement thumb: HTMLElement watch: HTMLElement @@ -120,7 +129,7 @@ export function makePreviewCard(s: LiveSessionInfo, opts: MakeCardOpts): Preview term.open(inner) cardEl.append(head, thumb, meta, actions) - return { el: cardEl, term, inner, thumb, watch, status, meta } + return { el: cardEl, term, inner, thumb, watch, status, meta, disposed: false } } /** Re-apply a session's live status/watch/meta fields to its card in place. */ @@ -131,6 +140,14 @@ export function updatePreviewCard(card: PreviewCard, s: LiveSessionInfo): void { card.watch.textContent = `👁 ${s.clientCount}` } +/** Tear a card down: mark it, dispose its terminal, detach its element. One place, + * so no caller can dispose the terminal and forget to set the flag. */ +export function disposeCard(card: PreviewCard): void { + card.disposed = true + card.term.dispose() + card.el.remove() +} + /** Scale the rendered xterm down so the full screen fits `thumbW` px wide. */ export function fitThumb(card: PreviewCard, thumbW: number, thumbMaxH: number): void { const w = card.inner.offsetWidth @@ -152,9 +169,17 @@ export async function fetchPreview(id: string): Promise { } } -/** Render a preview into a card's xterm, resizing + scaling to fit. */ +/** Render a preview into a card's xterm, resizing + scaling to fit. + * cols/rows of 0 mean "size unknown, keep what the card already has" — the + * orphan preview omits them because its dimensions came with the list, and + * resizing to Math.max(2, 0) would collapse the thumbnail to 2×2. */ export function renderPreview(card: PreviewCard, p: SessionPreview, thumbW: number, thumbMaxH: number): void { - if (card.term.cols !== Math.max(2, p.cols) || card.term.rows !== Math.max(2, p.rows)) { + // A preview is fetched fire-and-forget, so the card can be torn down while its + // request is in flight, and xterm throws "Object has been disposed". An explicit + // flag, not el.isConnected: a card that has not been appended YET is alive, and + // conflating the two would drop the first render. + if (card.disposed) return + if (p.cols > 0 && p.rows > 0 && (card.term.cols !== p.cols || card.term.rows !== p.rows)) { card.term.resize(Math.max(2, p.cols), Math.max(2, p.rows)) } card.term.reset() @@ -183,6 +208,98 @@ export async function fetchLiveSessions(): Promise { } } +/* ── A: orphan tmux sessions ───────────────────────────────────────────────── + Sessions that outlived the server process which created them. They reuse the + card machinery above through orphanAsLive, so there is one thumbnail + implementation rather than two that drift. */ + +/** + * Fetch untracked tmux sessions. Returns null on FAILURE, distinct from [] meaning + * "there are none" — collapsing the two would let a 429, a 500 or a dropped request + * read as "all recovered sessions are gone", tearing the grid down and re-mounting + * every xterm on the next success. + */ +export async function fetchOrphanSessions(): Promise { + try { + const res = await fetch('/orphan-sessions') + if (!res.ok) return null + const data: unknown = await res.json() + return Array.isArray(data) ? (data as OrphanSessionInfo[]) : null + } catch { + return null + } +} + +/** How many sessions the bulk cleanup would actually end. null on failure. */ +export async function countIdleOrphans(days: number): Promise { + try { + const res = await fetch(`/orphan-sessions/count-idle?idleDays=${encodeURIComponent(String(days))}`) + if (!res.ok) return null + const body = (await res.json()) as { count?: unknown } + return typeof body.count === 'number' ? body.count : null + } catch { + return null + } +} + +/** Fetch an orphan's screen (tmux capture-pane). null on any failure. */ +export async function fetchOrphanPreview(id: string): Promise { + try { + const res = await fetch(`/orphan-sessions/${encodeURIComponent(id)}/preview`) + if (!res.ok) return null + return (await res.json()) as SessionPreview + } catch { + return null + } +} + +/** + * Present an orphan as a LiveSessionInfo so makePreviewCard can render it. + * + * `status` is 'unknown', never 'idle': status comes from Claude Code's hooks into + * a session this server is streaming, and for an orphan we have not looked — so + * claiming 'idle' would assert something we do not know. `cwd` is null for the + * same reason. `clientCount` mirrors tmux's client count, so a session someone is + * attached to from a real terminal reads as watched rather than abandoned. + */ +export function orphanAsLive(o: OrphanSessionInfo): LiveSessionInfo { + return { + id: o.id, + createdAt: o.createdAt, + clientCount: o.attached ? 1 : 0, + status: 'unknown', + exited: false, + cwd: null, + cols: o.cols, + rows: o.rows, + lastOutputAt: o.lastActivityAt, + } +} + +/** Kill one orphan. Resolves true on 204. */ +export async function killOrphanReq(id: string): Promise { + try { + const res = await fetch(`/orphan-sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }) + return res.status === 204 + } catch { + return false + } +} + +/** Bulk-kill unattached orphans idle longer than `days`. Returns ids killed. */ +export async function cleanupOrphansReq(days: number): Promise { + try { + const res = await fetch(`/orphan-sessions?idleDays=${encodeURIComponent(String(days))}`, { + method: 'DELETE', + }) + if (!res.ok) return [] + const body = (await res.json()) as { ids?: unknown } + return Array.isArray(body.ids) ? (body.ids as string[]) : [] + } catch { + return [] + } +} + /** * Render a telemetry gauge into `container` (clears first). * diff --git a/public/style.css b/public/style.css index 7db0aa1..7b3ba29 100644 --- a/public/style.css +++ b/public/style.css @@ -1192,6 +1192,60 @@ body { color: var(--text-dim); font-size: 13px; } +/* A: recoverable (orphan) tmux sessions — a secondary section under the live grid, + separated by a rule so it never competes with what you are actually working on. + Hidden entirely when the list is empty. */ +.launcher-orphans { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid var(--border); +} +.launcher-orphans-head { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px 16px; + margin-bottom: 16px; +} +.launcher-orphans-title { + font-size: 11px; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--text-faint); +} +.launcher-orphans-sub { + flex: 1 1 auto; + color: var(--text-dim); + font-size: 13px; +} +/* Destructive, so it is the ghost role rather than a filled button — the design + reserves the accent fill for the action you actually came to perform. */ +.launcher-cleanup { + font: inherit; + font-size: 12.5px; + cursor: pointer; + padding: 7px 15px; + border-radius: 7px; + border: 1px solid var(--border-strong); + background: transparent; + color: var(--text-dim); + flex: none; +} +.launcher-cleanup:hover { + border-color: var(--red); + color: var(--red); +} +.launcher-cleanup:focus-visible { + outline: 2px solid var(--text); + outline-offset: 2px; +} +/* The grid is capped, so say so rather than let the tail vanish silently. */ +.launcher-orphans-more { + margin-top: 14px; + font-family: var(--mono-font); + font-size: 11.5px; + color: var(--text-faint); +} .launcher-actions { display: flex; gap: 8px; diff --git a/src/server.ts b/src/server.ts index 7a934c2..ebf497d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -95,6 +95,9 @@ const RATE_LIMIT_WINDOW_MS = 60_000 const DECISION_RATE_MAX = 10 // POST /hook/decision ≤ 10/min/IP const SUBSCRIBE_RATE_MAX = 5 // POST/DELETE /push/subscribe ≤ 5/min/IP const QUEUE_RATE_MAX = 20 // W2: POST/DELETE /live-sessions/:id/queue ≤ 20/min/IP +// A: GET /orphan-sessions/:id/preview ≤ 240/min/IP — one tmux capture-pane each, +// and a full cleanup grid refreshes every visible card at once. +const ORPHAN_PREVIEW_RATE_MAX = 240 const GIT_WRITE_RATE_MAX = 30 // W4: POST /projects/git/{stage,commit} ≤ 30/min/IP const GIT_PUSH_RATE_MAX = 6 // W4: POST /projects/git/push ≤ 6/min/IP (network-bound) const AUTH_RATE_MAX = 10 // w5-access-token: POST /auth + GET /?token= ≤ 10/min/IP (brute-force guard) @@ -251,6 +254,10 @@ export function startServer(cfg: Config): { close(): Promise } { const decisionLimiter = createRateLimiter(DECISION_RATE_MAX, RATE_LIMIT_WINDOW_MS) const subscribeLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS) const queueLimiter = createRateLimiter(QUEUE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W2 + // A: /orphan-sessions/:id/preview spawns one `tmux capture-pane` per call, and a + // cleanup grid can ask for many at once — its own bucket, sized for a full grid + // refresh, so it never eats the queue or git budgets. + const orphanLimiter = createRateLimiter(ORPHAN_PREVIEW_RATE_MAX, RATE_LIMIT_WINDOW_MS) const gitWriteLimiter = createRateLimiter(GIT_WRITE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 stage/commit const gitPushLimiter = createRateLimiter(GIT_PUSH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 push // w6/G2 fetch gets its OWN bucket: it is the panel's refresh button, so a burst @@ -582,6 +589,86 @@ export function startServer(cfg: Config): { close(): Promise } { // Preview (v0.4 manage page) — the tail of a session's scrollback so the grid // can render a live read-only thumbnail of its current screen. No client/attach. + // ── Orphan tmux sessions (A) — `web_*` tmux sessions this server does NOT + // track, because they outlived the process that created them. Read-only routes + // carry no Origin guard (same threat model as /live-sessions); the DELETEs do, + // and every route re-validates the id via manager, which requires UUID v4 and + // refuses anything the table already owns. + // + // Deliberately a separate path from /live-sessions rather than an extra field on + // it: an orphan supports none of the live-session operations (no replay, status, + // telemetry or inject queue), and the Android/iOS clients decode + // /live-sessions — adding a variant shape there would break them. + // Rate-limited even though it is read-only: unlike the other read routes this one + // spawns a subprocess, so an unbounded caller is a spawn loop against the host. + app.get('/orphan-sessions', async (req, res) => { + if (!orphanLimiter(req.socket.remoteAddress ?? '', Date.now())) { + res.status(429).end() + return + } + res.json(await manager.listOrphans()) + }) + + // How many the bulk cleanup would kill, so the client can confirm with a real + // number instead of counting the cards it happens to be showing. + app.get('/orphan-sessions/count-idle', async (req, res) => { + if (!orphanLimiter(req.socket.remoteAddress ?? '', Date.now())) { + res.status(429).end() + return + } + const days = Number(req.query['idleDays']) + if (!Number.isFinite(days) || days < 1) { + res.status(400).json({ error: 'idleDays must be a number >= 1' }) + return + } + res.json({ count: await manager.countOrphansIdleSince(Date.now() - days * 86_400_000) }) + }) + + // Each call spawns a short-lived `tmux capture-pane`, so it gets the same per-IP + // bucket discipline as the other process-spawning routes. + app.get('/orphan-sessions/:id/preview', async (req, res) => { + if (!orphanLimiter(req.socket.remoteAddress ?? '', Date.now())) { + res.status(429).end() + return + } + const id = req.params.id + if (!SESSION_ID_RE.test(id)) { + res.status(400).json({ error: 'invalid session id' }) + return + } + const data = await manager.captureOrphan(id) + if (data === null) { + res.status(404).end() + return + } + // Same shape as the live preview so the frontend renders both with one path. + res.json({ id, cols: 0, rows: 0, data }) + }) + + // Bulk cleanup — kill UNATTACHED orphans idle longer than `idleDays`. Registered + // BEFORE /orphan-sessions/:id so it is not captured as an :id. `idleDays` is + // required and must be >= 1: there is no "delete everything" form of this route. + app.delete('/orphan-sessions', async (req, res) => { + if (!requireAllowedOrigin(req, res)) return + const days = Number(req.query['idleDays']) + if (!Number.isFinite(days) || days < 1) { + res.status(400).json({ error: 'idleDays must be a number >= 1' }) + return + } + const killed = await manager.killOrphansIdleSince(Date.now() - days * 86_400_000) + res.json({ killed: killed.length, ids: killed }) + }) + + app.delete('/orphan-sessions/:id', (req, res) => { + if (!requireAllowedOrigin(req, res)) return + const id = req.params.id + if (!SESSION_ID_RE.test(id)) { + res.status(400).json({ error: 'invalid session id' }) + return + } + res.status(manager.killOrphan(id) ? 204 : 404).end() + }) + app.get('/live-sessions/:id/preview', (req, res) => { const session = manager.get(req.params.id) if (session === undefined) { diff --git a/src/session/manager.ts b/src/session/manager.ts index cf44005..3f86574 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -32,6 +32,7 @@ import type { Dims, EnqueueResult, LiveSessionInfo, + OrphanSessionInfo, NotifyService, PermissionGate, ServerMessage, @@ -44,7 +45,8 @@ import { WS_OPEN } from '../types.js'; import { serialize } from '../protocol.js'; import { createSession, attachWs, broadcast, kill, writeInput } from './session.js'; import { appendEvent, makeTimelineEvent } from './timeline.js'; -import { hasSession, tmuxName } from './tmux.js'; +import { hasSession, tmuxName, killSession, listSessions, capturePane } from './tmux.js'; +import { SESSION_ID_RE } from '../protocol.js'; /** * Send a serialised ServerMessage to `ws` only when it is OPEN (M5). @@ -415,10 +417,94 @@ export function createSessionManager( sessions = new Map(); } + /* ── A: tmux sessions this server does not track ─────────────────────────── + A `web_*` tmux session that outlived the process which made it is invisible + to everything above: list() only walks our own table, and so does reapIdle. + These four functions are the whole of the read/act surface for them. They + never adopt: no PTY is spawned and nothing is attached, because attaching + would resize the tmux window to the new client and SIGWINCH whatever is + running inside it. Joining (which DOES adopt, via handleAttach's re-attach + path) stays an explicit user action. + + One guard, applied identically everywhere: the id must satisfy + SESSION_ID_RE and must NOT be in our table. The first is what stops a + hostile or malformed name reaching `tmux -t`; the second keeps a live + session's shell from being killed behind its own PTY's back. */ + + /** True when `id` is a real, untracked tmux session of ours that we may act on. */ + function isActionableOrphan(id: string): boolean { + if (!cfg.useTmux) return false; + if (!SESSION_ID_RE.test(id)) return false; + if (sessions.has(id)) return false; // tracked ⇒ use get()/killById instead + return hasSession(tmuxName(id)); + } + + async function listOrphans(): Promise { + if (!cfg.useTmux) return []; + const all = await listSessions(); + return all + .filter((t) => !sessions.has(t.id)) + .map((t) => ({ + id: t.id, + createdAt: t.createdAtMs, + lastActivityAt: t.lastActivityAtMs, + attached: t.attached, + cols: t.cols, + rows: t.rows, + })); + } + + /** The orphan's current screen, or null. Read-only: never attaches. */ + async function captureOrphan(id: string): Promise { + if (!isActionableOrphan(id)) return null; + return capturePane(tmuxName(id)); + } + + /** End an orphan's shell. False when it is not ours, not there, or is tracked. */ + function killOrphan(id: string): boolean { + if (!isActionableOrphan(id)) return false; + killSession(tmuxName(id)); + return true; + } + + /** + * Bulk cleanup: kill every UNATTACHED orphan whose last tmux activity predates + * `cutoffMs`. Returns the ids killed. + * + * Attached sessions are skipped on purpose — tmux reporting a client means + * someone is in there from a real terminal or another server process, and + * "this server does not track it" is not evidence that it is abandoned. + */ + async function killOrphansIdleSince(cutoffMs: number): Promise { + const killed: string[] = []; + for (const o of await listOrphans()) { + if (o.attached) continue; + if (o.lastActivityAt >= cutoffMs) continue; + if (killOrphan(o.id)) killed.push(o.id); + } + return killed; + } + + /** How many orphans `killOrphansIdleSince(cutoffMs)` would kill, without killing + * anything. The UI needs this to state a truthful number in its confirmation: + * the card grid is capped, so counting cards understates the blast radius. */ + async function countOrphansIdleSince(cutoffMs: number): Promise { + let n = 0; + for (const o of await listOrphans()) { + if (!o.attached && o.lastActivityAt < cutoffMs) n += 1; + } + return n; + } + return { handleAttach, get, list, + listOrphans, + captureOrphan, + killOrphan, + killOrphansIdleSince, + countOrphansIdleSince, killById, handleHookEvent, handleStatusLine, diff --git a/src/session/tmux.ts b/src/session/tmux.ts index 451120f..d4d23e1 100644 --- a/src/session/tmux.ts +++ b/src/session/tmux.ts @@ -9,12 +9,64 @@ * treated as "false"/no-op). */ -import { execFileSync } from 'node:child_process' +import { execFile, execFileSync } from 'node:child_process' +import { promisify } from 'node:util' +import { SESSION_ID_RE } from '../protocol.js' + +const execFileAsync = promisify(execFile) + +/** Upper bound on one `capture-pane` payload. A pane is one screen, so this is + * generous; it exists so a wedged tmux can never hand us unbounded output. */ +const CAPTURE_MAX_BUFFER = 512 * 1024 + +/** Every tmux call is bounded. maxBuffer caps a flood but nothing caps the WAIT, + * and a tmux server stopped mid-syscall (SIGSTOP, a hung filesystem) would + * otherwise block forever — fatally so for the synchronous calls, which the event + * loop cannot interrupt. */ +const TMUX_TIMEOUT_MS = 3000 + +/** + * Tab-separated, machine-readable. tmux reports both clocks in unix SECONDS. + * + * The activity clock is `window_activity`, NOT `session_activity`. That is not a + * detail: `session_activity` tracks CLIENT activity — it advances on attach and on + * keypresses — and never moves for a detached session no matter how much its shell + * is printing. Measured on tmux 3.6a with a detached session in a `while true; do + * echo tick; sleep 1; done` loop: after 6s, session_activity was still equal to + * session_created while window_activity had advanced. Using session_activity as the + * liveness signal would make the idle-cleanup kill precisely the long-running, + * unattended, actively-working sessions this whole feature exists to protect. + */ +const LIST_FORMAT = [ + '#{session_name}', + '#{session_created}', + '#{window_activity}', + '#{session_attached}', + '#{window_width}', + '#{window_height}', +].join('\t') + +/** + * One `web_*` tmux session as tmux itself describes it (A). + * + * `attached` is tmux's own client count, NOT "this server is streaming it": a + * session can be attached from a plain terminal, or by a second web-terminal + * process. So "we do not track it" and "nobody is using it" are different + * questions, and the UI must not conflate them. + */ +export interface TmuxSessionSummary { + readonly id: string + readonly createdAtMs: number + readonly lastActivityAtMs: number + readonly attached: boolean + readonly cols: number + readonly rows: number +} /** Is the tmux binary available on PATH? */ export function tmuxAvailable(): boolean { try { - execFileSync('tmux', ['-V'], { stdio: 'ignore' }) + execFileSync('tmux', ['-V'], { stdio: 'ignore', timeout: TMUX_TIMEOUT_MS }) return true } catch { return false @@ -29,7 +81,10 @@ export function tmuxName(sessionId: string): string { /** Does a tmux session with this name already exist (e.g. after a restart)? */ export function hasSession(name: string): boolean { try { - execFileSync('tmux', ['has-session', '-t', name], { stdio: 'ignore' }) + execFileSync('tmux', ['has-session', '-t', name], { + stdio: 'ignore', + timeout: TMUX_TIMEOUT_MS, + }) return true } catch { return false @@ -39,8 +94,111 @@ export function hasSession(name: string): boolean { /** Kill a tmux session (ends the shell). No-op if it's already gone. */ export function killSession(name: string): void { try { - execFileSync('tmux', ['kill-session', '-t', name], { stdio: 'ignore' }) + execFileSync('tmux', ['kill-session', '-t', name], { + stdio: 'ignore', + timeout: TMUX_TIMEOUT_MS, + }) } catch { // already gone — fine } } + +/** + * Parse `tmux list-sessions -F LIST_FORMAT` output. Pure, so the parsing rules are + * testable without a tmux binary. + * + * Two filters, both load-bearing: + * - the name must start with `web_`, so we never enumerate (and therefore never + * offer to kill) a tmux session the user created for their own work; + * - the remainder must be a UUID v4 per SESSION_ID_RE — the same gate the attach + * protocol applies (M7). Anything else cannot have been created by this app, and + * refusing it here is also what keeps a hostile name from reaching `tmux -t` + * (e.g. a literal `-t`, or a name full of path traversal). + */ +export function parseSessionList(stdout: string): TmuxSessionSummary[] { + const rows: TmuxSessionSummary[] = [] + for (const line of stdout.split('\n')) { + if (line === '') continue + const parts = line.split('\t') + if (parts.length < 6) continue + + const [name, created, activity, attached, width, height] = parts as [ + string, + string, + string, + string, + string, + string, + ] + if (!name.startsWith('web_')) continue + const id = name.slice('web_'.length) + if (!SESSION_ID_RE.test(id)) continue + + const createdSec = Number(created) + const activitySec = Number(activity) + const cols = Number(width) + const rows_ = Number(height) + if ( + !Number.isFinite(createdSec) || + !Number.isFinite(activitySec) || + !Number.isFinite(cols) || + !Number.isFinite(rows_) + ) { + continue + } + + rows.push({ + id, + createdAtMs: createdSec * 1000, + lastActivityAtMs: activitySec * 1000, + attached: attached !== '0' && attached !== '', + cols, + rows: rows_, + }) + } + return rows.sort((a, b) => b.lastActivityAtMs - a.lastActivityAtMs) +} + +/** + * Every `web_*` tmux session on the host, most recently active first. Never + * throws; [] when tmux is missing or no tmux server is running. + * + * ASYNC on purpose. This is polled — every device's home screen refreshes on a + * timer — and the spawn costs ~73 ms on a host with 69 sessions. Done + * synchronously that is 73 ms with the event loop stopped, i.e. 73 ms during which + * no PTY byte reaches any terminal on any device. The server is a byte-shuttle + * first; nothing on a polling path may block it. + */ +export async function listSessions(): Promise { + try { + const { stdout } = await execFileAsync('tmux', ['list-sessions', '-F', LIST_FORMAT], { + encoding: 'utf8', + maxBuffer: CAPTURE_MAX_BUFFER, + timeout: TMUX_TIMEOUT_MS, + }) + return parseSessionList(stdout) + } catch { + return [] + } +} + +/** + * The session's CURRENT screen, without attaching. Returns null if it is gone. + * + * `-p` prints to stdout and `-e` keeps the escape sequences, so the bytes can go + * straight into a read-only xterm and keep their colour. Deliberately NOT + * `attach-session`: attaching would make tmux resize the window to the new + * client's dimensions and SIGWINCH whatever is running inside it. + */ +export async function capturePane(name: string): Promise { + try { + const { stdout } = await execFileAsync('tmux', ['capture-pane', '-p', '-e', '-t', name], { + encoding: 'utf8', + maxBuffer: CAPTURE_MAX_BUFFER, + timeout: TMUX_TIMEOUT_MS, + }) + return stdout + } catch { + return null + } +} diff --git a/src/types.ts b/src/types.ts index c7e5f96..5c70277 100644 --- a/src/types.ts +++ b/src/types.ts @@ -318,6 +318,37 @@ export interface LiveSessionInfo { readonly queueLength?: number; } +/** + * A tmux session on this host that the running server does NOT have in its table + * (A). It outlived the process that created it — a server restart, a different + * server process, or the desktop app quitting. + * + * It has no PTY, no ring buffer and no live stream here, so it is deliberately a + * DIFFERENT type from LiveSessionInfo rather than a flag on it: everything the + * manage grid does with a live session (replay, status, telemetry, inject queue) + * is unavailable, and a shared shape would invite callers to assume otherwise. + * What it does support: being listed, previewed via `tmux capture-pane` (no + * attach), killed, and joined — attaching with this id revives it in place + * through the existing re-attach path, at which point it becomes a real + * LiveSessionInfo. + * + * Before this existed such a session was unreachable from the UI forever: nothing + * enumerated tmux, so an id no client still remembered could not be listed, + * previewed, joined or killed, and the idle reaper never saw it either. + */ +export interface OrphanSessionInfo { + id: string; + createdAt: number; // epoch ms + /** tmux's OWN activity clock (epoch ms) — survives server restarts, unlike the + * in-memory lastOutputAt, so it is the only usable age for a cleanup policy. */ + lastActivityAt: number; + /** tmux client count > 0: attached from a real terminal or another server + * process. "Untracked here" does NOT mean "abandoned". */ + attached: boolean; + cols: number; + rows: number; +} + /* ───────────────── W5 fan-out board (parallel agent lanes) ───────────────── */ /** A group of running sessions sharing a repo/worktree-root (fan-out discovery). @@ -442,6 +473,26 @@ export interface SessionManager { get(id: string): Session | undefined; /** Live sessions for multi-device discovery (newest first). */ list(): LiveSessionInfo[]; + /* ── A: tmux sessions NOT in this server's table ────────────────────────── + All four are no-ops (empty / null / false) when cfg.useTmux is off, and all + reject an id that is not a UUID v4 or that the table already owns — a + tracked session must go through get()/killById so its PTY and table entry + stay in step. None of them adopt: nothing is attached, so a running TUI is + never resized. + + The tmux-spawning ones are ASYNC: they sit on a polled path and each spawn + costs tens of milliseconds, which done synchronously is time the event loop + is stopped and no PTY byte reaches any terminal. */ + /** Untracked `web_*` tmux sessions, most recently active first. */ + listOrphans(): Promise; + /** An orphan's current screen via `tmux capture-pane` (no attach). */ + captureOrphan(id: string): Promise; + /** End an orphan's shell. False when it is not ours, gone, or tracked. */ + killOrphan(id: string): boolean; + /** Kill every UNATTACHED orphan last active before `cutoffMs`; returns the ids. */ + killOrphansIdleSince(cutoffMs: number): Promise; + /** How many killOrphansIdleSince would kill — for a truthful confirmation. */ + countOrphansIdleSince(cutoffMs: number): Promise; /** Kill a session by id (manage page): close its clients, kill the PTY, drop it. * Returns true if a session was found and killed. */ killById(id: string): boolean; diff --git a/test/integration/orphan-sessions.test.ts b/test/integration/orphan-sessions.test.ts new file mode 100644 index 0000000..5d5aa6f --- /dev/null +++ b/test/integration/orphan-sessions.test.ts @@ -0,0 +1,208 @@ +/** + * Integration test for the orphan-session routes (A): + * GET /orphan-sessions + * GET /orphan-sessions/:id/preview + * DELETE /orphan-sessions/:id + * + * SAFETY: this file NEVER issues `DELETE /orphan-sessions` (the bulk cleanup), + * because these tests run against the host's real tmux server and would end the + * developer's own long-running sessions. Bulk cleanup is covered with mocks in + * test/manager-orphans.test.ts. The only session touched here is one this file + * creates under a fresh UUID and tears down itself. + */ + +import net from 'node:net' +import { execFileSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { loadConfig } from '../../src/config.js' +import { startServer } from '../../src/server.js' +import type { OrphanSessionInfo } from '../../src/types.js' + +const TMUX_AVAILABLE = (() => { + try { + execFileSync('tmux', ['-V'], { stdio: 'ignore' }) + return true + } catch { + return false + } +})() +const itTmux = TMUX_AVAILABLE ? it : it.skip + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer() + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() + if (addr === null || typeof addr === 'string') { + srv.close() + reject(new Error('unexpected address type')) + return + } + const port = addr.port + srv.close(() => resolve(port)) + }) + srv.on('error', reject) + }) +} + +/* ── tmux off: the whole feature must be inert ──────────────────────────────── */ + +describe('orphan sessions — tmux disabled', () => { + let port: number + let handle: { close(): Promise } + const origin = (): string => `http://127.0.0.1:${port}` + + beforeAll(async () => { + port = await getFreePort() + handle = startServer( + loadConfig({ + PORT: String(port), + BIND_HOST: '127.0.0.1', + SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', + ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, + USE_TMUX: '0', + IDLE_TTL: '86400', + }), + ) + await new Promise((r) => setTimeout(r, 100)) + }) + + afterAll(async () => { + await handle.close() + }) + + it('lists nothing, and does not require an Origin header (read-only)', async () => { + const res = await fetch(`${origin()}/orphan-sessions`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + + it('404s a preview instead of shelling out to tmux', async () => { + const res = await fetch(`${origin()}/orphan-sessions/${randomUUID()}/preview`) + expect(res.status).toBe(404) + }) + + it('404s a kill', async () => { + const res = await fetch(`${origin()}/orphan-sessions/${randomUUID()}`, { + method: 'DELETE', + headers: { Origin: origin() }, + }) + expect(res.status).toBe(404) + }) +}) + +/* ── tmux on: real session, real capture, real kill ─────────────────────────── */ + +describe('orphan sessions — tmux enabled', () => { + let port: number + let handle: { close(): Promise } + const origin = (): string => `http://127.0.0.1:${port}` + + /** Our throwaway session. Never any other. */ + const id = randomUUID() + const name = `web_${id}` + let created = false + + beforeAll(async () => { + port = await getFreePort() + handle = startServer( + loadConfig({ + PORT: String(port), + BIND_HOST: '127.0.0.1', + SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', + ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, + USE_TMUX: '1', + IDLE_TTL: '86400', + }), + ) + await new Promise((r) => setTimeout(r, 100)) + + if (TMUX_AVAILABLE) { + // Detached, printing a known marker so capture-pane has something to find. + execFileSync( + 'tmux', + ['new-session', '-d', '-s', name, `printf 'ORPHAN_MARKER_%s' OK; sleep 300`], + { stdio: 'ignore' }, + ) + created = true + await new Promise((r) => setTimeout(r, 400)) + } + }) + + afterAll(async () => { + if (created) { + try { + execFileSync('tmux', ['kill-session', '-t', name], { stdio: 'ignore' }) + } catch { + // the kill test already removed it — fine + } + } + await handle.close() + }) + + itTmux('lists our untracked tmux session as an orphan', async () => { + const res = await fetch(`${origin()}/orphan-sessions`) + expect(res.status).toBe(200) + const body = (await res.json()) as OrphanSessionInfo[] + const mine = body.find((o) => o.id === id) + expect(mine).toBeDefined() + expect(mine!.attached).toBe(false) // created with -d, nobody attached + expect(mine!.cols).toBeGreaterThan(0) + expect(mine!.lastActivityAt).toBeGreaterThan(0) + }) + + itTmux('previews the live screen without attaching to it', async () => { + const res = await fetch(`${origin()}/orphan-sessions/${id}/preview`) + expect(res.status).toBe(200) + const body = (await res.json()) as { id: string; data: string } + expect(body.id).toBe(id) + expect(body.data).toContain('ORPHAN_MARKER_OK') + + // The capture must not have left a client behind: still unattached. + const after = execFileSync('tmux', ['ls', '-F', '#{session_name} #{session_attached}'], { + encoding: 'utf8', + }) + expect(after).toContain(`${name} 0`) + }) + + itTmux('rejects a non-UUID id with 400, never reaching tmux', async () => { + for (const bad of ['-t', 'not-a-uuid', '../../etc/passwd']) { + const res = await fetch(`${origin()}/orphan-sessions/${encodeURIComponent(bad)}/preview`) + expect(res.status).toBe(400) + } + }) + + itTmux('refuses a kill with a foreign Origin (CSRF guard)', async () => { + const res = await fetch(`${origin()}/orphan-sessions/${id}`, { + method: 'DELETE', + headers: { Origin: 'http://evil.example' }, + }) + expect(res.status).toBe(403) + // and it is still alive + expect( + execFileSync('tmux', ['ls', '-F', '#{session_name}'], { encoding: 'utf8' }), + ).toContain(name) + }) + + itTmux('kills it, and 404s the second time', async () => { + const first = await fetch(`${origin()}/orphan-sessions/${id}`, { + method: 'DELETE', + headers: { Origin: origin() }, + }) + expect(first.status).toBe(204) + + const listing = execFileSync('tmux', ['ls', '-F', '#{session_name}'], { + encoding: 'utf8', + }).split('\n') + expect(listing).not.toContain(name) + + const second = await fetch(`${origin()}/orphan-sessions/${id}`, { + method: 'DELETE', + headers: { Origin: origin() }, + }) + expect(second.status).toBe(404) + }) +}) diff --git a/test/manager-orphans.test.ts b/test/manager-orphans.test.ts new file mode 100644 index 0000000..928c07f --- /dev/null +++ b/test/manager-orphans.test.ts @@ -0,0 +1,304 @@ +/** + * 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>(async () => []); +const tmuxCapture = vi.fn<(name: string) => Promise>(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); + }); +}); diff --git a/test/tmux.test.ts b/test/tmux.test.ts index cea0dea..626dd90 100644 --- a/test/tmux.test.ts +++ b/test/tmux.test.ts @@ -8,14 +8,23 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' const mockExec = vi.fn() +// promisify(execFile) reads the custom symbol, so give it one that returns our mock. +const mockExecAsync = vi.fn() +const fakeExecFile = Object.assign( + (...a: unknown[]) => mockExecAsync(...a), + { [Symbol.for('nodejs.util.promisify.custom')]: (...a: unknown[]) => mockExecAsync(...a) }, +) vi.mock('node:child_process', () => ({ execFileSync: (...a: unknown[]) => mockExec(...a), + execFile: fakeExecFile, })) -const { tmuxAvailable, tmuxName, hasSession, killSession } = await import('../src/session/tmux.js') +const { tmuxAvailable, tmuxName, hasSession, killSession, parseSessionList, listSessions, capturePane } = + await import('../src/session/tmux.js') beforeEach(() => { mockExec.mockReset() + mockExecAsync.mockReset() }) describe('tmuxName', () => { @@ -28,7 +37,7 @@ describe('tmuxAvailable', () => { it('returns true when `tmux -V` succeeds', () => { mockExec.mockReturnValue(Buffer.from('tmux 3.4')) expect(tmuxAvailable()).toBe(true) - expect(mockExec).toHaveBeenCalledWith('tmux', ['-V'], { stdio: 'ignore' }) + expect(mockExec).toHaveBeenCalledWith('tmux', ['-V'], expect.objectContaining({ stdio: 'ignore' })) }) it('returns false when tmux is missing (exec throws)', () => { @@ -43,7 +52,11 @@ describe('hasSession', () => { it('returns true when has-session exits 0', () => { mockExec.mockReturnValue(Buffer.from('')) expect(hasSession('web_x')).toBe(true) - expect(mockExec).toHaveBeenCalledWith('tmux', ['has-session', '-t', 'web_x'], { stdio: 'ignore' }) + expect(mockExec).toHaveBeenCalledWith( + 'tmux', + ['has-session', '-t', 'web_x'], + expect.objectContaining({ stdio: 'ignore' }), + ) }) it('returns false when has-session throws (no such session)', () => { @@ -58,7 +71,11 @@ describe('killSession', () => { it('invokes tmux kill-session for the name', () => { mockExec.mockReturnValue(Buffer.from('')) killSession('web_x') - expect(mockExec).toHaveBeenCalledWith('tmux', ['kill-session', '-t', 'web_x'], { stdio: 'ignore' }) + expect(mockExec).toHaveBeenCalledWith( + 'tmux', + ['kill-session', '-t', 'web_x'], + expect.objectContaining({ stdio: 'ignore' }), + ) }) it('swallows errors when the session is already gone', () => { @@ -68,3 +85,118 @@ describe('killSession', () => { expect(() => killSession('web_gone')).not.toThrow() }) }) + +/* ── A: enumerate + preview tmux sessions the server does not track ─────────── */ + +const U1 = '05caa88b-1f5f-45d9-bbbe-fc5955314870' +const U2 = '3a63f3c7-acfe-4081-992f-c4dbd55b3d6b' + +describe('parseSessionList', () => { + it('parses tmux rows into summaries, converting seconds to ms', () => { + const out = `web_${U1}\t1783911991\t1783911992\t0\t80\t23\n` + expect(parseSessionList(out)).toEqual([ + { + id: U1, + createdAtMs: 1783911991_000, + lastActivityAtMs: 1783911992_000, + attached: false, + cols: 80, + rows: 23, + }, + ]) + }) + + it('sorts most-recently-active first', () => { + const out = [`web_${U1}\t100\t100\t0\t80\t24`, `web_${U2}\t100\t900\t1\t80\t24`].join('\n') + expect(parseSessionList(out).map((s) => s.id)).toEqual([U2, U1]) + }) + + it('reports a session attached from a real terminal', () => { + const out = `web_${U1}\t100\t100\t1\t80\t24` + expect(parseSessionList(out)[0]!.attached).toBe(true) + }) + + it('ignores sessions that are not ours — never touch the user’s own tmux', () => { + const out = ['mywork\t100\t100\t0\t80\t24', `web_${U1}\t100\t100\t0\t80\t24`].join('\n') + expect(parseSessionList(out).map((s) => s.id)).toEqual([U1]) + }) + + it('ignores a web_-prefixed name whose suffix is not a UUID v4', () => { + // Only ids the protocol could have produced are actionable: anything else is + // someone else's session that merely starts with web_. + const out = ['web_../../etc\t100\t100\t0\t80\t24', 'web_-t\t100\t100\t0\t80\t24'].join('\n') + expect(parseSessionList(out)).toEqual([]) + }) + + it('skips malformed and blank rows instead of throwing', () => { + const out = ['', 'garbage', `web_${U1}\tNaN\t100\t0\t80\t24`, `web_${U2}\t100\t100\t0\t80\t24`].join('\n') + expect(parseSessionList(out).map((s) => s.id)).toEqual([U2]) + }) + + it('returns an empty list for empty output (no tmux server)', () => { + expect(parseSessionList('')).toEqual([]) + }) +}) + +describe('listSessions', () => { + it('asks tmux for a machine-readable list', async () => { + mockExecAsync.mockResolvedValue({ stdout: `web_${U1}\t100\t100\t0\t80\t24\n`, stderr: '' }) + expect((await listSessions()).map((s) => s.id)).toEqual([U1]) + const [file, args] = mockExecAsync.mock.calls[0] as [string, string[]] + expect(file).toBe('tmux') + expect(args[0]).toBe('list-sessions') + expect(args).toContain('-F') + }) + + it('asks for window_activity, NOT session_activity', async () => { + // session_activity is a CLIENT clock: it never advances for a detached session + // however much its shell prints. Using it would make idle-cleanup kill exactly + // the unattended, actively-working sessions this feature exists to protect. + mockExecAsync.mockResolvedValue({ stdout: '', stderr: '' }) + await listSessions() + const [, args] = mockExecAsync.mock.calls[0] as [string, string[]] + const fmt = args[args.indexOf('-F') + 1]! + expect(fmt).toContain('#{window_activity}') + expect(fmt).not.toContain('#{session_activity}') + }) + + it('bounds the call so a wedged tmux cannot hang the server', async () => { + mockExecAsync.mockResolvedValue({ stdout: '', stderr: '' }) + await listSessions() + const opts = mockExecAsync.mock.calls[0]![2] as { timeout?: number; maxBuffer?: number } + expect(opts.timeout).toBeGreaterThan(0) + expect(opts.maxBuffer).toBeGreaterThan(0) + }) + + it('returns [] when there is no tmux server (exec rejects)', async () => { + mockExecAsync.mockRejectedValue(new Error('no server running')) + expect(await listSessions()).toEqual([]) + }) +}) + +describe('capturePane', () => { + it('captures the current screen WITHOUT attaching', async () => { + mockExecAsync.mockResolvedValue({ stdout: 'hello\n', stderr: '' }) + expect(await capturePane('web_x')).toBe('hello\n') + const [file, args] = mockExecAsync.mock.calls[0] as [string, string[]] + expect(file).toBe('tmux') + expect(args[0]).toBe('capture-pane') + expect(args).toContain('-p') // print to stdout — read-only, no client + expect(args).toContain('-e') // keep escape sequences so colour survives + expect(args).toContain('web_x') + // Attaching would resize the session and SIGWINCH whatever is running in it. + expect(args).not.toContain('attach-session') + }) + + it('returns null when the session is gone', async () => { + mockExecAsync.mockRejectedValue(new Error("can't find session")) + expect(await capturePane('web_gone')).toBeNull() + }) + + it('bounds the call', async () => { + mockExecAsync.mockResolvedValue({ stdout: '', stderr: '' }) + await capturePane('web_x') + const opts = mockExecAsync.mock.calls[0]![2] as { timeout?: number } + expect(opts.timeout).toBeGreaterThan(0) + }) +})