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:
@@ -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,
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user