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:
Yaojia Wang
2026-06-17 19:48:39 +02:00
parent af2a0879e3
commit 9099f73534
8 changed files with 198 additions and 9 deletions

View File

@@ -25,6 +25,7 @@
*/
import net from 'node:net'
import { execFileSync } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import WebSocket from 'ws'
@@ -49,6 +50,19 @@ const PTY_AVAILABLE = (() => {
})()
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 ──────────────────────────────────────────────────────────────────
/** 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.
IDLE_TTL: '86400',
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)
},
)
// ── 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,
)
})