Files
web-terminal/public/launcher.ts
Yaojia Wang f6ef19ebf6 fix(sessions): orphan cleanup was reading the wrong tmux clock
Adversarial review of the previous commit found ten defects. The critical one
would have destroyed exactly the work this feature exists to protect.

CRITICAL — `#{session_activity}` is a CLIENT clock, not an output clock.
It advances on attach and on keypresses, and never moves for a detached session
however much its shell is printing. Measured on tmux 3.6a: a detached session
running `while true; do echo tick; sleep 1; done` still reported
session_activity == session_created after 6 s, while `#{window_activity}` tracked
the output exactly. So "idle for 7 days" was really "nobody has typed for 7 days",
and `Clean up idle 7d+` would have killed long-running unattended sessions that
were busy working — the walk-away case the whole app is built around.
Measured against this host's 69 sessions: the old clock condemned 36, the new one
condemns 33, so three sessions doing real work were one click from deletion.
Now reads window_activity, with a test that fails if the format string regresses.

HIGH — no timeout on any tmux call. maxBuffer bounds a flood but nothing bounded
the wait, and a synchronous exec cannot be interrupted by the event loop, so a
tmux server stopped mid-syscall hung the whole server permanently. All five calls
(including the three that predate this feature) now carry one.

HIGH — listSessions and capturePane were synchronous on a POLLED path. Measured
73 ms and 58 ms per call on this host; every home screen on every device refreshes
on a 5 s timer, and each of those was 73 ms with the event loop stopped, i.e.
73 ms in which no PTY byte reached any terminal anywhere. Both are async now, so
the manager's listOrphans/captureOrphan/killOrphansIdleSince are too. The server
is a byte-shuttle first: nothing polled may block it.

HIGH — GET /orphan-sessions had no rate limiter although, unlike every other read
route, it spawns a subprocess. It shares the preview bucket now.

HIGH — the cleanup confirmation counted CARDS, and the grid is capped at 24 while
the cleanup matches across every session the server enumerates. On this host it
would have said "24" and ended 33 shells. New GET /orphan-sessions/count-idle
answers the real question, and the dialog states that number, or refuses to
proceed if it cannot be determined.

MEDIUM — fetchOrphanSessions collapsed every failure into [], so one 429 or 500
read as "all recovered sessions are gone", tearing down every mounted xterm and
rebuilding it on the next success. It returns null for failure now, and the
refresh leaves the section untouched.

MEDIUM — the preview latch was "card was created", not "preview succeeded", so a
card that lost its single request stayed blank forever. Latches on success.

MEDIUM — overlapping refreshes. refresh() is entered from a 5 s timer, from
killOrphan and from cleanup, with awaits inside and no guard, so a slow poll could
resurrect cards for sessions already killed or re-mount them into a root that
setVisible(false) had just cleared. A generation counter, bumped on teardown and
checked after every await, invalidates stale runs.

MEDIUM — renderPreview could write to a disposed xterm (it throws "Object has
been disposed"). PreviewCard carries an explicit `disposed` flag set by a new
disposeCard() helper, so no caller can dispose a terminal and forget the flag.
Deliberately NOT el.isConnected: a card not yet appended is alive, and two
existing preview-grid tests correctly caught that conflation.

LOW — kill and cleanup discarded their results, so a refusal (403 behind a proxy
whose Origin differs, 404 on a lost race) looked like a dead button. Both report.

Tests: 2209 unit, 27 e2e, 8 orphan integration. Verified live against all 69 real
sessions with none harmed; the window_activity fix confirmed on an isolated tmux
socket (-L) so the developer's sessions were never involved.
2026-07-30 11:28:39 +02:00

305 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* public/launcher.ts — the home "session chooser" shown when no tab is open.
*
* Opening the app no longer auto-creates or auto-restores tabs. Instead this
* start screen lists the host's running sessions as live preview thumbnails
* (read-only xterm, same as the manage page) so the user picks which to open —
* or starts a new one. Sessions persist server-side, so opening one replays its
* full scrollback. The thumbnail card + preview plumbing is shared with the
* manage page via public/preview-grid.ts (DRY).
*/
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'
export interface LauncherHooks {
onOpen: (id: string) => void
onNew: () => void
}
const THUMB_W = 320
const THUMB_MAX_H = 200
const REFRESH_MS = 5000
export interface Launcher {
setVisible(v: boolean): void
refresh(): void
}
export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher {
const root = el('div', 'launcher')
root.style.display = 'none'
host.appendChild(root)
const head = el('div', 'launcher-head')
head.append(el('div', 'launcher-title', 'Your sessions'))
const sub = el('div', 'launcher-sub', '')
head.append(sub)
root.append(head)
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')
newTile.title = 'Start a new session'
newTile.setAttribute('aria-label', 'New session')
newTile.append(el('div', 'mg-new-plus', ''), el('div', 'mg-new-label', 'New session'))
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> {
await fetch(`/live-sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }).catch(() => {})
void refresh()
}
function makeCard(s: LiveSessionInfo): PreviewCard {
const kill = el('button', 'mg-kill', 'Kill ✕')
kill.title = 'Kill this session'
kill.addEventListener('click', () => void killOne(s.id))
return makePreviewCard(s, {
onOpen: (id) => hooks.onOpen(id),
extraActions: () => [kill],
})
}
async function refresh(): Promise<void> {
const sessions = await fetchLiveSessions()
sub.textContent = sessions.length
? `${sessions.length} running on this host — pick one to open`
: 'No sessions running yet'
// Keep the New-session tile as the first grid cell.
if (grid.firstChild !== newTile) grid.prepend(newTile)
const seen = new Set<string>()
for (const s of sessions) {
seen.add(s.id)
let card = cards.get(s.id)
if (!card) {
card = makeCard(s)
cards.set(s.id, card)
grid.append(card.el)
}
updatePreviewCard(card, s)
card.meta.textContent = `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old`
void loadPreviewInto(s.id, card, THUMB_W, THUMB_MAX_H)
}
for (const [id, card] of cards) {
if (!seen.has(id)) {
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 sessions 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'
if (v) {
void refresh()
if (timer === null) {
timer = setInterval(() => {
if (root.style.display !== 'none') void refresh()
}, REFRESH_MS)
}
} else {
if (timer !== null) {
clearInterval(timer)
timer = null
}
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 {
void refresh()
},
}
}