feat(v0.3): H1 — tmux keepalive (sessions survive a server restart)
- src/session/tmux.ts: sync tmux CLI wrappers (available/has/kill), best-effort - config: useTmux from USE_TMUX env (1/0/auto→detect tmux on PATH) - session: when useTmux, spawn 'tmux new-session -A -s web_<id> <shell>' (the node-pty proc is a tmux CLIENT); createSession takes an optional id for re-attach; Session.tmuxName; kill() runs tmux kill-session - manager: handleAttach re-attaches to a surviving 'web_<id>' tmux session after a restart (Case 3.5); shutdown kills only the client pty for tmux sessions so the shell keeps running - tests: USE_TMUX:0 in shared integration cfg (no tmux leakage); unit CFGs useTmux:false; integration 'H1' (real tmux): set var → restart server → re-attach → var survived. 208 tests green.
This commit is contained in:
@@ -8,6 +8,15 @@
|
|||||||
|
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import type { Config, EnvLike } from './types.js'
|
import type { Config, EnvLike } from './types.js'
|
||||||
|
import { tmuxAvailable } from './session/tmux.js'
|
||||||
|
|
||||||
|
/** USE_TMUX: '1'/'true'/'on' → on, '0'/'false'/'off' → off, else auto-detect tmux. */
|
||||||
|
function resolveUseTmux(raw: string | undefined): boolean {
|
||||||
|
const v = raw?.trim().toLowerCase()
|
||||||
|
if (v === '1' || v === 'true' || v === 'on') return true
|
||||||
|
if (v === '0' || v === 'false' || v === 'off') return false
|
||||||
|
return tmuxAvailable() // unset / 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
// ── constants ─────────────────────────────────────────────────────────────────
|
// ── constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -141,6 +150,8 @@ export function loadConfig(env: EnvLike): Config {
|
|||||||
|
|
||||||
const wsPath = env['WS_PATH'] ?? DEFAULT_WS_PATH
|
const wsPath = env['WS_PATH'] ?? DEFAULT_WS_PATH
|
||||||
|
|
||||||
|
const useTmux = resolveUseTmux(env['USE_TMUX'])
|
||||||
|
|
||||||
const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS'])
|
const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS'])
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
@@ -152,6 +163,7 @@ export function loadConfig(env: EnvLike): Config {
|
|||||||
scrollbackBytes,
|
scrollbackBytes,
|
||||||
maxPayloadBytes,
|
maxPayloadBytes,
|
||||||
wsPath,
|
wsPath,
|
||||||
|
useTmux,
|
||||||
allowedOrigins,
|
allowedOrigins,
|
||||||
} satisfies Config)
|
} satisfies Config)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import type {
|
|||||||
import { WS_OPEN } from '../types.js';
|
import { WS_OPEN } from '../types.js';
|
||||||
import { serialize } from '../protocol.js';
|
import { serialize } from '../protocol.js';
|
||||||
import { createSession, attachWs, kill } from './session.js';
|
import { createSession, attachWs, kill } from './session.js';
|
||||||
|
import { hasSession, tmuxName } from './tmux.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a serialised ServerMessage to `ws` only when it is OPEN (M5).
|
* Send a serialised ServerMessage to `ws` only when it is OPEN (M5).
|
||||||
@@ -113,6 +114,17 @@ export function createSessionManager(cfg: Config): SessionManager {
|
|||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Case 3.5 (H1): not in our table, but a tmux session survived a restart ─
|
||||||
|
// Re-attach with the SAME id so `tmux new-session -A -s web_<id>` reconnects
|
||||||
|
// to the still-running shell instead of spawning a fresh one.
|
||||||
|
if (cfg.useTmux && hasSession(tmuxName(sessionId))) {
|
||||||
|
const revived = createSession(cfg, dims, now, onSessionExit, sessionId);
|
||||||
|
const kickedRevived = attachWs(revived, ws);
|
||||||
|
if (kickedRevived !== null) kickedRevived.close();
|
||||||
|
sessions = new Map(sessions).set(revived.meta.id, revived);
|
||||||
|
return revived;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Case 4: session not found → create a new one ─────────────────────────
|
// ── Case 4: session not found → create a new one ─────────────────────────
|
||||||
// M4: createSession may throw. Do NOT catch here.
|
// M4: createSession may throw. Do NOT catch here.
|
||||||
const session = createSession(cfg, dims, now, onSessionExit);
|
const session = createSession(cfg, dims, now, onSessionExit);
|
||||||
@@ -180,10 +192,18 @@ export function createSessionManager(cfg: Config): SessionManager {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kill every live session and clear the table (called on SIGINT/SIGTERM). */
|
/**
|
||||||
|
* Called on SIGINT/SIGTERM/close. For non-tmux sessions, kill the PTY. For
|
||||||
|
* tmux sessions (H1), kill only the client pty — the tmux server keeps the
|
||||||
|
* shell alive so it survives the restart and can be re-attached.
|
||||||
|
*/
|
||||||
function shutdown(): void {
|
function shutdown(): void {
|
||||||
for (const session of sessions.values()) {
|
for (const session of sessions.values()) {
|
||||||
kill(session);
|
if (session.tmuxName !== null) {
|
||||||
|
session.pty.kill(); // detach client; tmux keeps the shell
|
||||||
|
} else {
|
||||||
|
kill(session);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sessions = new Map();
|
sessions = new Map();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import type {
|
|||||||
import { WS_OPEN } from '../types.js';
|
import { WS_OPEN } from '../types.js';
|
||||||
import { serialize } from '../protocol.js';
|
import { serialize } from '../protocol.js';
|
||||||
import { createRingBuffer } from './ring-buffer.js';
|
import { createRingBuffer } from './ring-buffer.js';
|
||||||
|
import { tmuxName, killSession } from './tmux.js';
|
||||||
|
|
||||||
/** Send a server message to `ws` only if it is OPEN (M5). Never throws on a
|
/** Send a server message to `ws` only if it is OPEN (M5). Never throws on a
|
||||||
* closed socket; forwarding to a dead ws is simply a no-op. */
|
* closed socket; forwarding to a dead ws is simply a no-op. */
|
||||||
@@ -57,14 +58,21 @@ export function createSession(
|
|||||||
dims: Dims,
|
dims: Dims,
|
||||||
now: number,
|
now: number,
|
||||||
onExit: (session: Session) => void,
|
onExit: (session: Session) => void,
|
||||||
|
// H1: pass an existing id to RE-ATTACH to a surviving tmux session (e.g. after
|
||||||
|
// a server restart). Default = a fresh id for a brand-new session.
|
||||||
|
id: string = randomUUID(),
|
||||||
): Session {
|
): Session {
|
||||||
// Generate the id first so it can be injected into the shell env: Claude Code
|
// The id is injected into the shell env so Claude Code hooks know which tab an
|
||||||
// hooks running inside this shell read $WEBTERM_SESSION to tell us which tab
|
// event belongs to ($WEBTERM_SESSION) and where to POST ($WEBTERM_HOOK_URL, H2).
|
||||||
// an event belongs to, and POST to $WEBTERM_HOOK_URL (H2).
|
const tName = cfg.useTmux ? tmuxName(id) : null;
|
||||||
const id = randomUUID();
|
|
||||||
|
|
||||||
// M4: let a spawn failure (e.g. missing shell) propagate synchronously.
|
// H1: under tmux, the node-pty process is a tmux CLIENT; `new-session -A`
|
||||||
const pty: IPty = spawn(cfg.shellPath, [], {
|
// attaches to web_<id> if it exists (restart survival) or creates it.
|
||||||
|
const file = tName !== null ? 'tmux' : cfg.shellPath;
|
||||||
|
const args = tName !== null ? ['new-session', '-A', '-s', tName, cfg.shellPath] : [];
|
||||||
|
|
||||||
|
// M4: let a spawn failure (e.g. missing shell / tmux) propagate synchronously.
|
||||||
|
const pty: IPty = spawn(file, args, {
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
cols: dims.cols,
|
cols: dims.cols,
|
||||||
rows: dims.rows,
|
rows: dims.rows,
|
||||||
@@ -91,6 +99,7 @@ export function createSession(
|
|||||||
exitedAt: null,
|
exitedAt: null,
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
claudeStatus: 'unknown',
|
claudeStatus: 'unknown',
|
||||||
|
tmuxName: tName,
|
||||||
pty,
|
pty,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,7 +158,12 @@ export function resize(session: Session, cols: number, rows: number): void {
|
|||||||
session.pty.resize(cols, rows);
|
session.pty.resize(cols, rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kill the underlying PTY (process exit / idle reclaim). */
|
/**
|
||||||
|
* Kill the session (idle reclaim). Under tmux this ends the actual shell via
|
||||||
|
* `tmux kill-session` (H1); killing only the client pty would leave the tmux
|
||||||
|
* session running. Always also kills the client pty.
|
||||||
|
*/
|
||||||
export function kill(session: Session): void {
|
export function kill(session: Session): void {
|
||||||
|
if (session.tmuxName !== null) killSession(session.tmuxName);
|
||||||
session.pty.kill();
|
session.pty.kill();
|
||||||
}
|
}
|
||||||
|
|||||||
46
src/session/tmux.ts
Normal file
46
src/session/tmux.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* src/session/tmux.ts (H1) — thin synchronous wrappers around the tmux CLI.
|
||||||
|
*
|
||||||
|
* Used only when cfg.useTmux is on. Running the shell inside a tmux session
|
||||||
|
* lets it survive a server/host restart: the node-pty process is just a tmux
|
||||||
|
* *client*; killing it detaches, and the tmux server keeps the shell alive.
|
||||||
|
*
|
||||||
|
* Every call is best-effort and never throws (tmux missing / session gone are
|
||||||
|
* treated as "false"/no-op).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
|
||||||
|
/** Is the tmux binary available on PATH? */
|
||||||
|
export function tmuxAvailable(): boolean {
|
||||||
|
try {
|
||||||
|
execFileSync('tmux', ['-V'], { stdio: 'ignore' })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tmux session name for a web-terminal session id (UUIDs are tmux-safe). */
|
||||||
|
export function tmuxName(sessionId: string): string {
|
||||||
|
return `web_${sessionId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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' })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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' })
|
||||||
|
} catch {
|
||||||
|
// already gone — fine
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ export interface Config {
|
|||||||
readonly scrollbackBytes: number; // ring buffer capacity, default 2MB
|
readonly scrollbackBytes: number; // ring buffer capacity, default 2MB
|
||||||
readonly maxPayloadBytes: number; // max WS frame bytes, default 1MB (L5)
|
readonly maxPayloadBytes: number; // max WS frame bytes, default 1MB (L5)
|
||||||
readonly wsPath: string; // WS upgrade path, default '/term' (L3; invariant 8)
|
readonly wsPath: string; // WS upgrade path, default '/term' (L3; invariant 8)
|
||||||
|
readonly useTmux: boolean; // H1: spawn the shell inside tmux so it survives a server restart
|
||||||
readonly allowedOrigins: readonly string[]; // derived from NIC IPs, NOT bindHost (M1)
|
readonly allowedOrigins: readonly string[]; // derived from NIC IPs, NOT bindHost (M1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +150,8 @@ export interface Session {
|
|||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
/** Claude Code activity from hooks (H2); updated by manager.handleHookEvent. */
|
/** Claude Code activity from hooks (H2); updated by manager.handleHookEvent. */
|
||||||
claudeStatus: ClaudeStatus;
|
claudeStatus: ClaudeStatus;
|
||||||
|
/** tmux session name (H1) when running under tmux, else null. */
|
||||||
|
readonly tmuxName: string | null;
|
||||||
readonly pty: IPty;
|
readonly pty: IPty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import net from 'node:net'
|
import net from 'node:net'
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
import WebSocket from 'ws'
|
import WebSocket from 'ws'
|
||||||
@@ -49,6 +50,19 @@ const PTY_AVAILABLE = (() => {
|
|||||||
})()
|
})()
|
||||||
const itPty = PTY_AVAILABLE ? it : it.skip
|
const itPty = PTY_AVAILABLE ? it : it.skip
|
||||||
|
|
||||||
|
/** H1 tmux tests need real PTY + the tmux binary. */
|
||||||
|
const TMUX_OK =
|
||||||
|
PTY_AVAILABLE &&
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
execFileSync('tmux', ['-V'], { stdio: 'ignore' })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
const itTmux = TMUX_OK ? it : it.skip
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Pick a free port on 127.0.0.1 by briefly binding to port 0. */
|
/** Pick a free port on 127.0.0.1 by briefly binding to port 0. */
|
||||||
@@ -88,6 +102,7 @@ function makeTestConfig(port: number, shellPath: string, maxPayloadBytes = 512 *
|
|||||||
// Keep idle TTL tiny so stray sessions don't linger.
|
// Keep idle TTL tiny so stray sessions don't linger.
|
||||||
IDLE_TTL: '86400',
|
IDLE_TTL: '86400',
|
||||||
MAX_PAYLOAD_BYTES: String(maxPayloadBytes),
|
MAX_PAYLOAD_BYTES: String(maxPayloadBytes),
|
||||||
|
USE_TMUX: '0', // default off so most cases don't spawn/leak tmux sessions
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,4 +653,81 @@ describe('startServer — integration', () => {
|
|||||||
await waitForClose(ws, 3_000).catch(() => undefined)
|
await waitForClose(ws, 3_000).catch(() => undefined)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── H1: tmux keepalive — a shell survives a full server restart ────────────
|
||||||
|
itTmux(
|
||||||
|
'H1 [needs tmux + real PTY] shell state survives a server restart',
|
||||||
|
async () => {
|
||||||
|
const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
||||||
|
const isAttached = (m: unknown): boolean =>
|
||||||
|
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'attached'
|
||||||
|
|
||||||
|
const tport = await getFreePort()
|
||||||
|
const tcfg = loadConfig({
|
||||||
|
PORT: String(tport),
|
||||||
|
BIND_HOST: '127.0.0.1',
|
||||||
|
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
|
||||||
|
ALLOWED_ORIGINS: `http://127.0.0.1:${tport}`,
|
||||||
|
IDLE_TTL: '86400',
|
||||||
|
USE_TMUX: '1',
|
||||||
|
})
|
||||||
|
|
||||||
|
let sid = ''
|
||||||
|
let srv = startServer(tcfg)
|
||||||
|
await delay(150)
|
||||||
|
try {
|
||||||
|
// Attach and set a shell variable inside the tmux shell.
|
||||||
|
const ws1 = new WebSocket(`ws://127.0.0.1:${tport}/term`, {
|
||||||
|
headers: { Origin: `http://127.0.0.1:${tport}` },
|
||||||
|
})
|
||||||
|
await waitForOpen(ws1, 3_000)
|
||||||
|
ws1.send(JSON.stringify({ type: 'attach', sessionId: null }))
|
||||||
|
const att = (await waitForMessage(ws1, isAttached, 5_000)) as Record<string, unknown>
|
||||||
|
sid = att['sessionId'] as string
|
||||||
|
await delay(500)
|
||||||
|
ws1.send(JSON.stringify({ type: 'input', data: 'WEBTERM_MARK=tmuxlives\r' }))
|
||||||
|
await delay(500)
|
||||||
|
ws1.close()
|
||||||
|
await waitForClose(ws1, 3_000).catch(() => undefined)
|
||||||
|
|
||||||
|
// Restart the server — shutdown must keep the tmux session alive (H1).
|
||||||
|
await srv.close()
|
||||||
|
srv = startServer(tcfg)
|
||||||
|
await delay(150)
|
||||||
|
|
||||||
|
// Reconnect with the SAME sessionId → re-attach to the surviving shell.
|
||||||
|
const ws2 = new WebSocket(`ws://127.0.0.1:${tport}/term`, {
|
||||||
|
headers: { Origin: `http://127.0.0.1:${tport}` },
|
||||||
|
})
|
||||||
|
await waitForOpen(ws2, 3_000)
|
||||||
|
const out: string[] = []
|
||||||
|
ws2.on('message', (raw) => {
|
||||||
|
try {
|
||||||
|
const m = JSON.parse(raw.toString('utf8')) as Record<string, unknown>
|
||||||
|
if (m['type'] === 'output' && typeof m['data'] === 'string') out.push(m['data'])
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ws2.send(JSON.stringify({ type: 'attach', sessionId: sid }))
|
||||||
|
await delay(400)
|
||||||
|
ws2.send(JSON.stringify({ type: 'input', data: 'echo MARK-$WEBTERM_MARK\r' }))
|
||||||
|
await delay(900)
|
||||||
|
// The variable set before the restart is still set → same shell survived.
|
||||||
|
expect(out.join('')).toContain('MARK-tmuxlives')
|
||||||
|
ws2.close()
|
||||||
|
await waitForClose(ws2, 3_000).catch(() => undefined)
|
||||||
|
} finally {
|
||||||
|
await srv.close()
|
||||||
|
if (sid !== '') {
|
||||||
|
try {
|
||||||
|
execFileSync('tmux', ['kill-session', '-t', `web_${sid}`], { stdio: 'ignore' })
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
20_000,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const CFG: Config = {
|
|||||||
scrollbackBytes: 2 * 1024 * 1024,
|
scrollbackBytes: 2 * 1024 * 1024,
|
||||||
maxPayloadBytes: 1024 * 1024,
|
maxPayloadBytes: 1024 * 1024,
|
||||||
wsPath: '/term',
|
wsPath: '/term',
|
||||||
|
useTmux: false,
|
||||||
allowedOrigins: [],
|
allowedOrigins: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const CFG: Config = {
|
|||||||
scrollbackBytes: 2 * 1024 * 1024,
|
scrollbackBytes: 2 * 1024 * 1024,
|
||||||
maxPayloadBytes: 1024 * 1024,
|
maxPayloadBytes: 1024 * 1024,
|
||||||
wsPath: '/term',
|
wsPath: '/term',
|
||||||
|
useTmux: false,
|
||||||
allowedOrigins: [],
|
allowedOrigins: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user