fix(sessions): tmux -t prefix-matching let orphan ops reach the user's own sessions
Round two of adversarial review. The skeptic confirmed the session_activity finding
with extra evidence from this host and raised three things the first pass missed.
SECURITY — `tmux -t <name>` resolves exact name, then NAME PREFIX, then fnmatch.
The whole module rests on "we only ever touch `web_` + a UUID v4, so a tmux session
the user made for their own work is never enumerated and never offered for
deletion". Prefix matching goes around that: a session named `web_<uuid>_mine` is
refused by parseSessionList (its suffix is not a UUID) and so never appears in the
listing — but `-t web_<uuid>` matches it. So a DELETE aimed at an id that does not
exist would end the user's session, and a preview would print its screen.
Verified on tmux 3.6a, isolated socket:
has-session -t web_<uuid> → succeeds against web_<uuid>_MINE
has-session -t =web_<uuid> → "can't find session"
capture-pane -p -t web_<uuid> → printed the DECOY's screen
capture-pane -p -t =web_<uuid>: → "can't find session"
Two target forms are needed, because `=` only qualifies the session part of a
target: session targets (has-session, kill-session) take `=name`, while
capture-pane takes a PANE and rejects a bare `=name` outright ("can't find pane"),
so it needs `=name:`. Both are now the only way a name reaches tmux, with an
integration test that stands up a real decoy and asserts the listing omits it and
both the preview and the DELETE report 404 while it stays alive.
CORRECTNESS — take max(window_activity, session_activity), not window_activity
alone. The previous commit swapped one clock for the other, but they are not
ordered: window_activity tracks pane output while an attach with no output bumps
only session_activity. On this host one session's session_activity was 4 s NEWER
than its window_activity (and another's window_activity was 25 days newer). The
question is "has anything happened here", so the answer is the later of the two.
Also documents the caveat that window_activity resolves against the session's
current window only — exact for the single-window sessions this app creates, and
bounded by the max for anything else.
CORRECTNESS — parseSessionList accepted an empty numeric field. Number('') is 0
and 0 is finite, so a blank clock parsed as "created at the epoch, idle ever
since": instantly eligible for the idle cleanup. Fields must now be actual digits.
SAFETY — bulk cleanup re-reads the world immediately before killing. The candidate
list is a snapshot and the kill loop takes time, so a session could be attached, or
adopted into the table, inside that window. This is the irreversible path; it now
verifies each candidate against a fresh listing rather than trusting the snapshot.
PERFORMANCE — captureOrphan no longer probes with hasSession first. That was a
second tmux spawn per thumbnail, and a SYNCHRONOUS one on the path a whole grid
refreshes, buying nothing: capture-pane already fails cleanly on a missing session
and we already turn that into null. The guard splits into a pure half
(mayActOnOrphan: UUID shape + not in the table) used by both paths, and the
existence probe, which only the destructive path needs.
Tests: 2213 unit, 9 orphan integration (incl. the decoy regression). Confirmed
`has-session -t =<name>` still resolves the 69 real sessions, so the Case 3.5
re-attach path is unaffected.
Note: test/integration/server.test.ts "H1 shell state survives a server restart"
flakes ~1 run in 3 when all 27 run together on a loaded machine, and passes 4/4 in
isolation. That is the pre-existing real-PTY timing flake already recorded in
PROGRESS_LOG, not this change — it exercises Case 3.5, which is verified above.
This commit is contained in:
@@ -431,12 +431,18 @@ export function createSessionManager(
|
||||
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 {
|
||||
/** The pure half of the guard: is this an id we are allowed to act on at all?
|
||||
* No tmux call, so it is free to run on a polled path. */
|
||||
function mayActOnOrphan(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));
|
||||
return !sessions.has(id); // tracked ⇒ use get()/killById instead
|
||||
}
|
||||
|
||||
/** …plus an existence probe. Only for the DESTRUCTIVE path, which wants to
|
||||
* distinguish "already gone" (report false) from "killed it". */
|
||||
function isActionableOrphan(id: string): boolean {
|
||||
return mayActOnOrphan(id) && hasSession(tmuxName(id));
|
||||
}
|
||||
|
||||
async function listOrphans(): Promise<OrphanSessionInfo[]> {
|
||||
@@ -454,9 +460,14 @@ export function createSessionManager(
|
||||
}));
|
||||
}
|
||||
|
||||
/** The orphan's current screen, or null. Read-only: never attaches. */
|
||||
/** The orphan's current screen, or null. Read-only: never attaches.
|
||||
*
|
||||
* Deliberately does NOT probe with hasSession first. That was a second tmux spawn
|
||||
* per thumbnail — and a SYNCHRONOUS one, on the path a grid of cards refreshes —
|
||||
* buying nothing: `capture-pane` against a missing session already fails cleanly
|
||||
* and we already turn that into null. */
|
||||
async function captureOrphan(id: string): Promise<string | null> {
|
||||
if (!isActionableOrphan(id)) return null;
|
||||
if (!mayActOnOrphan(id)) return null;
|
||||
return capturePane(tmuxName(id));
|
||||
}
|
||||
|
||||
@@ -476,10 +487,20 @@ export function createSessionManager(
|
||||
* "this server does not track it" is not evidence that it is abandoned.
|
||||
*/
|
||||
async function killOrphansIdleSince(cutoffMs: number): Promise<string[]> {
|
||||
const candidates = (await listOrphans()).filter(
|
||||
(o) => !o.attached && o.lastActivityAt < cutoffMs,
|
||||
);
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
// Re-read the world immediately before killing. The list above is a snapshot,
|
||||
// and the loop that follows takes time: a session can be attached, or adopted
|
||||
// into our table, in that window — and this is the irreversible path, so it
|
||||
// re-checks rather than trusting the snapshot.
|
||||
const fresh = new Map((await listOrphans()).map((o) => [o.id, o]));
|
||||
const killed: string[] = [];
|
||||
for (const o of await listOrphans()) {
|
||||
if (o.attached) continue;
|
||||
if (o.lastActivityAt >= cutoffMs) continue;
|
||||
for (const o of candidates) {
|
||||
const now = fresh.get(o.id);
|
||||
if (now === undefined || now.attached) continue; // gone, adopted, or now in use
|
||||
if (killOrphan(o.id)) killed.push(o.id);
|
||||
}
|
||||
return killed;
|
||||
|
||||
@@ -28,19 +28,35 @@ 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.
|
||||
* BOTH activity clocks are requested, because neither alone answers "has anything
|
||||
* happened here" and they are not ordered:
|
||||
*
|
||||
* - `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 6 s it still equalled session_created
|
||||
* while window_activity had advanced. Using it alone as the liveness signal makes
|
||||
* idle-cleanup kill precisely the long-running, unattended, actively-working
|
||||
* sessions this feature exists to protect — on one real host, 3 of them.
|
||||
* - `window_activity` tracks pane OUTPUT, but an attach with no output bumps only
|
||||
* session_activity. Observed on the same host: one session whose session_activity
|
||||
* was 4 s newer than its window_activity, another whose window_activity was 25
|
||||
* days newer.
|
||||
*
|
||||
* So the parser takes max(window_activity, session_activity).
|
||||
*
|
||||
* CAVEAT: in `list-sessions`, `#{window_activity}` resolves against the session's
|
||||
* CURRENT window only. Every session this app creates is single-window (plain
|
||||
* `new-session`), so it is exact here; a user who opens a second window inside one
|
||||
* (Ctrl-b c) and leaves output only in a background window could still read as
|
||||
* quieter than it is. Taking the max limits that to "as stale as session_activity",
|
||||
* never staler.
|
||||
*/
|
||||
const LIST_FORMAT = [
|
||||
'#{session_name}',
|
||||
'#{session_created}',
|
||||
'#{window_activity}',
|
||||
'#{session_activity}',
|
||||
'#{session_attached}',
|
||||
'#{window_width}',
|
||||
'#{window_height}',
|
||||
@@ -78,10 +94,38 @@ export function tmuxName(sessionId: string): string {
|
||||
return `web_${sessionId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A tmux `-t` target that matches ONE session by exact name.
|
||||
*
|
||||
* Without the `=`, tmux resolves a target as exact name, then NAME PREFIX, then
|
||||
* fnmatch. That is a hole in this module's central promise: a session named
|
||||
* `web_<uuid>_mine` is refused by parseSessionList (its suffix is not a UUID) and so
|
||||
* is never listed — but `-t web_<uuid>` prefix-matches it, so a kill aimed at a
|
||||
* non-existent id would end the user's own session instead. Verified on tmux 3.6a:
|
||||
* `has-session -t web_<uuid>` succeeds against `web_<uuid>_MINE`, and fails with the
|
||||
* `=` prefix. Every -t in this file goes through here.
|
||||
*/
|
||||
function exact(name: string): string {
|
||||
return `=${name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The same, for a target-PANE. `capture-pane` takes a pane, and tmux rejects a bare
|
||||
* `=name` there outright ("can't find pane: =web_x") — the `=` only qualifies the
|
||||
* session part of a target, so the session has to be named as such with a trailing
|
||||
* `:`. Verified on tmux 3.6a against a decoy named `web_<uuid>_decoy`: with the real
|
||||
* session gone, `-t web_<uuid>` printed the DECOY's screen while `-t =web_<uuid>:`
|
||||
* failed with "can't find session". Without this the preview leaks the contents of a
|
||||
* session the user created for their own work.
|
||||
*/
|
||||
function exactPane(name: string): string {
|
||||
return `=${name}:`
|
||||
}
|
||||
|
||||
/** 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], {
|
||||
execFileSync('tmux', ['has-session', '-t', exact(name)], {
|
||||
stdio: 'ignore',
|
||||
timeout: TMUX_TIMEOUT_MS,
|
||||
})
|
||||
@@ -94,7 +138,7 @@ 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], {
|
||||
execFileSync('tmux', ['kill-session', '-t', exact(name)], {
|
||||
stdio: 'ignore',
|
||||
timeout: TMUX_TIMEOUT_MS,
|
||||
})
|
||||
@@ -120,9 +164,10 @@ export function parseSessionList(stdout: string): TmuxSessionSummary[] {
|
||||
for (const line of stdout.split('\n')) {
|
||||
if (line === '') continue
|
||||
const parts = line.split('\t')
|
||||
if (parts.length < 6) continue
|
||||
if (parts.length < 7) continue
|
||||
|
||||
const [name, created, activity, attached, width, height] = parts as [
|
||||
const [name, created, winActivity, sessActivity, attached, width, height] = parts as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
@@ -134,24 +179,36 @@ export function parseSessionList(stdout: string): TmuxSessionSummary[] {
|
||||
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)
|
||||
// Number('') is 0, which IS finite — so an empty field would sail through as
|
||||
// "created at the epoch, idle ever since", i.e. instantly eligible for the idle
|
||||
// cleanup. Require actual digits.
|
||||
const num = (v: string): number => (/^\d+$/.test(v) ? Number(v) : NaN)
|
||||
const createdSec = num(created)
|
||||
const winSec = num(winActivity)
|
||||
const sessSec = num(sessActivity)
|
||||
const cols = num(width)
|
||||
const rows_ = num(height)
|
||||
if (
|
||||
!Number.isFinite(createdSec) ||
|
||||
!Number.isFinite(activitySec) ||
|
||||
!Number.isFinite(winSec) ||
|
||||
!Number.isFinite(sessSec) ||
|
||||
!Number.isFinite(cols) ||
|
||||
!Number.isFinite(rows_)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
// The two clocks are NOT ordered: window_activity tracks pane output, while an
|
||||
// attach bumps session_activity without producing any. Observed on a real host:
|
||||
// one session with session_activity 4s NEWER than window_activity, another with
|
||||
// window_activity 25 DAYS newer. The question being asked is "has anything
|
||||
// happened here", so take whichever is later.
|
||||
const activitySec = Math.max(winSec, sessSec)
|
||||
|
||||
rows.push({
|
||||
id,
|
||||
createdAtMs: createdSec * 1000,
|
||||
lastActivityAtMs: activitySec * 1000,
|
||||
attached: attached !== '0' && attached !== '',
|
||||
attached: attached !== '0',
|
||||
cols,
|
||||
rows: rows_,
|
||||
})
|
||||
@@ -192,7 +249,7 @@ export async function listSessions(): Promise<TmuxSessionSummary[]> {
|
||||
*/
|
||||
export async function capturePane(name: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('tmux', ['capture-pane', '-p', '-e', '-t', name], {
|
||||
const { stdout } = await execFileAsync('tmux', ['capture-pane', '-p', '-e', '-t', exactPane(name)], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: CAPTURE_MAX_BUFFER,
|
||||
timeout: TMUX_TIMEOUT_MS,
|
||||
|
||||
Reference in New Issue
Block a user