Merge orphan-tmux-sessions: surface and manage untracked tmux sessions
This commit is contained in:
@@ -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<string, PreviewCard>()
|
||||
const orphanCards = new Map<string, PreviewCard>()
|
||||
/** 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<string>()
|
||||
let timer: ReturnType<typeof setInterval> | 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<void> {
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<string>()
|
||||
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_<id>`
|
||||
: ''
|
||||
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 {
|
||||
|
||||
@@ -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<SessionPreview | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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<LiveSessionInfo[]> {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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<OrphanSessionInfo[] | null> {
|
||||
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<number | null> {
|
||||
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<SessionPreview | null> {
|
||||
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<boolean> {
|
||||
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<string[]> {
|
||||
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).
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> } {
|
||||
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<void> } {
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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<OrphanSessionInfo[]> {
|
||||
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<string | null> {
|
||||
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<string[]> {
|
||||
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<number> {
|
||||
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,
|
||||
|
||||
@@ -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<TmuxSessionSummary[]> {
|
||||
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<string | null> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
51
src/types.ts
51
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<OrphanSessionInfo[]>;
|
||||
/** An orphan's current screen via `tmux capture-pane` (no attach). */
|
||||
captureOrphan(id: string): Promise<string | null>;
|
||||
/** 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<string[]>;
|
||||
/** How many killOrphansIdleSince would kill — for a truthful confirmation. */
|
||||
countOrphansIdleSince(cutoffMs: number): Promise<number>;
|
||||
/** 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;
|
||||
|
||||
208
test/integration/orphan-sessions.test.ts
Normal file
208
test/integration/orphan-sessions.test.ts
Normal file
@@ -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<number> {
|
||||
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<void> }
|
||||
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<void>((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<void> }
|
||||
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<void>((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<void>((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)
|
||||
})
|
||||
})
|
||||
304
test/manager-orphans.test.ts
Normal file
304
test/manager-orphans.test.ts
Normal file
@@ -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<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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user