From 20212dd7f6f0948b47918dc8cb5426d20049447f Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 16 Jun 2026 18:54:15 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20T12=20session/session.ts=20=E2=80=94=20?= =?UTF-8?q?PTY=20lifecycle=20(M4/M5/L1/L4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createSession/attachWs/detachWs/writeInput/resize/kill; node-pty spawn (throws on failure/M4), onData->buffer+forward, onExit->exitedAt+exit+injected onExit; sendIfOpen guards readyState (M5); detach never kills PTY; write/resize no-op after exit (L4); detach-then-exit keeps session (L1). Tests vi.mock node-pty -> mock IPty (no real spawn): 19 tests. Full suite green. --- src/session/session.ts | 145 ++++++++++++++++++ test/session.test.ts | 327 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 472 insertions(+) create mode 100644 src/session/session.ts create mode 100644 test/session.test.ts diff --git a/src/session/session.ts b/src/session/session.ts new file mode 100644 index 0000000..c04479f --- /dev/null +++ b/src/session/session.ts @@ -0,0 +1,145 @@ +/** + * src/session/session.ts (T12) — single PTY session: lifecycle + ws plumbing. + * + * Owns one node-pty process and its scrollback. Wires: + * - pty.onData → ring buffer + (forward to attachedWs as `output`), + * - pty.onExit → stamp exitedAt/exitCode → (notify attachedWs with `exit`) + * → call the injected onExit so the manager can drop it (L2). + * + * Cross-validated fixes embedded here (ARCHITECTURE §3.4 / §4.4): + * - M4: spawn failure THROWS — createSession does not swallow it; server.ts + * catches and serializes a single `exit(-1, reason)` for that connection. + * - M5: every ws.send is guarded by `ws.readyState === WS_OPEN`. + * - L1: detach-then-exit keeps the session; with no attachedWs the `exit` is + * not delivered now — it is re-sent on the next attach (manager's job). + * - L4: writeInput/resize are silently ignored once the PTY has exited + * (calling write/resize on a dead PTY throws). + * + * The session never holds a reference to the manager; the exit event bubbles + * across that layer purely through the injected `onExit` callback. + * + * Imports src/types.ts (frozen, read-only) and createRingBuffer (T6). + * node-pty's `spawn` is imported here and mocked in tests so no real shell runs. + */ + +import { randomUUID } from 'node:crypto'; +import { spawn } from 'node-pty'; + +import type { + Config, + Dims, + IPty, + Session, + SessionMeta, + ServerMessage, + WebSocketLike, +} from '../types.js'; +import { WS_OPEN } from '../types.js'; +import { serialize } from '../protocol.js'; +import { createRingBuffer } from './ring-buffer.js'; + +/** 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. */ +function sendIfOpen(ws: WebSocketLike | null, msg: ServerMessage): void { + if (ws !== null && ws.readyState === WS_OPEN) { + ws.send(serialize(msg)); + } +} + +/** + * Spawn a PTY and wire its data/exit streams. Throws on spawn failure (M4). + * + * @param onExit injected by the manager — invoked after exitedAt is set so the + * session can be removed from the table (L2). + */ +export function createSession( + cfg: Config, + dims: Dims, + now: number, + onExit: (session: Session) => void, +): Session { + // M4: let a spawn failure (e.g. missing shell) propagate synchronously. + const pty: IPty = spawn(cfg.shellPath, [], { + name: 'xterm-256color', + cols: dims.cols, + rows: dims.rows, + cwd: cfg.homeDir, + env: process.env, + }); + + const meta: SessionMeta = { + id: randomUUID(), + createdAt: now, + shellPath: cfg.shellPath, + }; + + const session: Session = { + meta, + buffer: createRingBuffer(cfg.scrollbackBytes), + attachedWs: null, + detachedAt: null, + lastOutputAt: now, + exitedAt: null, + exitCode: null, + pty, + }; + + // onData: persist to scrollback, refresh liveness, forward to the live ws. + pty.onData((chunk) => { + session.buffer.append(chunk); + session.lastOutputAt = Date.now(); + sendIfOpen(session.attachedWs, { type: 'output', data: chunk }); + }); + + // onExit: record exit, notify the attached ws (if any, L1), then bubble up. + pty.onExit(({ exitCode }) => { + session.exitedAt = Date.now(); + session.exitCode = exitCode; + sendIfOpen(session.attachedWs, { type: 'exit', code: exitCode }); + onExit(session); + }); + + return session; +} + +/** + * Bind `ws` to the session: swap the pointer to the new ws FIRST (so live + * forwarding never targets a kicked socket), replay the scrollback, then let + * the real-time stream continue. Returns the kicked old ws (caller closes it). + */ +export function attachWs(session: Session, ws: WebSocketLike): WebSocketLike | null { + const previous = session.attachedWs; + + // Pointer first: later attach wins; forwarding henceforth only reaches `ws`. + session.attachedWs = ws; + session.detachedAt = null; + + // Replay the buffered scrollback so the reconnecting client sees the last screen. + sendIfOpen(ws, { type: 'output', data: session.buffer.snapshot() }); + + return previous; +} + +/** Detach the ws and stamp detachedAt. NEVER kills the PTY (vibe-coding core). */ +export function detachWs(session: Session, now: number): void { + session.attachedWs = null; + session.detachedAt = now; +} + +/** Forward input to the PTY. No-op once the PTY has exited (L4). */ +export function writeInput(session: Session, data: string): void { + if (session.exitedAt !== null) return; + session.pty.write(data); +} + +/** Resize the PTY. No-op after exit (L4); idempotent when dims are unchanged. */ +export function resize(session: Session, cols: number, rows: number): void { + if (session.exitedAt !== null) return; + if (session.pty.cols === cols && session.pty.rows === rows) return; + session.pty.resize(cols, rows); +} + +/** Kill the underlying PTY (process exit / idle reclaim). */ +export function kill(session: Session): void { + session.pty.kill(); +} diff --git a/test/session.test.ts b/test/session.test.ts new file mode 100644 index 0000000..e93ed46 --- /dev/null +++ b/test/session.test.ts @@ -0,0 +1,327 @@ +/** + * T12 tests — session/session.ts (single-session lifecycle). + * + * ARCHITECTURE §3.4 / §4.4 + the cross-validated fixes: + * - M4: spawn failure THROWS, never swallowed. + * - M5: every ws.send is guarded by `ws.readyState === WS_OPEN`. + * - L1: detach-then-exit keeps the session (exit not delivered when attachedWs is null). + * - L4: writeInput/resize are no-ops once the PTY has exited. + * + * node-pty is mocked (`vi.mock`) so `spawn` returns a MockIPty — the sandbox blocks + * real posix_spawn, and the mock lets us drive onData/onExit deterministically and + * assert lifecycle without a real shell. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import type { Config, Dims, Session, ServerMessage, WebSocketLike } from '../src/types.js'; +import { WS_OPEN } from '../src/types.js'; +import { createMockPty, type MockIPty } from './helpers/mock-pty.js'; + +// ── node-pty mock ─────────────────────────────────────────────────────────── +// spawn returns whatever `nextPty` points at, and records its call args so we +// can assert how createSession invoked it (and force it to throw for M4). +let nextPty: MockIPty; +let spawnError: Error | null = null; +const spawnCalls: Array<{ file: string; args: string[] | string; options: unknown }> = []; + +vi.mock('node-pty', () => ({ + spawn: (file: string, args: string[] | string, options: unknown) => { + spawnCalls.push({ file, args, options }); + if (spawnError) throw spawnError; + return nextPty; + }, +})); + +// Imported AFTER vi.mock so the module under test binds to the mocked spawn. +const { createSession, attachWs, detachWs, writeInput, resize, kill } = await import( + '../src/session/session.js' +); + +// ── helpers ───────────────────────────────────────────────────────────────── +const CFG: Config = { + port: 3000, + bindHost: '0.0.0.0', + shellPath: '/bin/zsh', + homeDir: '/home/tester', + idleTtlMs: 86_400_000, + scrollbackBytes: 2 * 1024 * 1024, + maxPayloadBytes: 1024 * 1024, + wsPath: '/term', + allowedOrigins: [], +}; + +const DIMS: Dims = { cols: 80, rows: 24 }; + +interface MockWs extends WebSocketLike { + readyState: number; + sent: string[]; + closed: boolean; +} + +function createMockWs(readyState: number = WS_OPEN): MockWs { + const sent: string[] = []; + return { + readyState, + sent, + closed: false, + send(data: string) { + sent.push(data); + }, + close() { + this.closed = true; + }, + }; +} + +/** Decode the JSON frames a ws received back into ServerMessages. */ +function received(ws: MockWs): ServerMessage[] { + return ws.sent.map((s) => JSON.parse(s) as ServerMessage); +} + +function newSession(onExit: (s: Session) => void = () => {}): Session { + nextPty = createMockPty(); + return createSession(CFG, DIMS, 1_000, onExit); +} + +beforeEach(() => { + spawnError = null; + spawnCalls.length = 0; +}); + +// ── createSession / spawn ───────────────────────────────────────────────────── +describe('createSession', () => { + it('spawns the PTY via node-pty with shellPath, cwd and dims', () => { + const s = newSession(); + + expect(spawnCalls).toHaveLength(1); + expect(spawnCalls[0]!.file).toBe(CFG.shellPath); + const opts = spawnCalls[0]!.options as { cwd?: string; cols?: number; rows?: number }; + expect(opts.cwd).toBe(CFG.homeDir); + expect(opts.cols).toBe(DIMS.cols); + expect(opts.rows).toBe(DIMS.rows); + + expect(s.pty).toBe(nextPty); + expect(s.meta.shellPath).toBe(CFG.shellPath); + expect(s.meta.createdAt).toBe(1_000); + expect(s.attachedWs).toBeNull(); + expect(s.exitedAt).toBeNull(); + expect(s.exitCode).toBeNull(); + }); + + it('THROWS (does not swallow) when spawn fails (M4)', () => { + spawnError = new Error('spawn failed: /no/such/shell ENOENT'); + nextPty = createMockPty(); + + expect(() => createSession(CFG, DIMS, 1_000, () => {})).toThrow(/spawn failed/); + }); +}); + +// ── onData → buffer + forward ────────────────────────────────────────────────── +describe('onData', () => { + it('appends output to the ring buffer and refreshes lastOutputAt', () => { + const s = newSession(); + const pty = s.pty as MockIPty; + + pty.emitData('hello '); + pty.emitData('world'); + + // buffer snapshot prepends the M2 soft-reset, then the verbatim output. + expect(s.buffer.snapshot()).toBe('\x1b[0mhello world'); + expect(s.lastOutputAt).toBeGreaterThanOrEqual(1_000); + }); + + it('forwards output to the attached ws as an `output` message', () => { + const s = newSession(); + const ws = createMockWs(); + attachWs(s, ws); + ws.sent.length = 0; // drop the replay frame from attach + + (s.pty as MockIPty).emitData('abc'); + + expect(received(ws)).toEqual([{ type: 'output', data: 'abc' }]); + }); + + it('still buffers output when no ws is attached (does not throw)', () => { + const s = newSession(); + expect(() => (s.pty as MockIPty).emitData('orphan')).not.toThrow(); + expect(s.buffer.snapshot()).toContain('orphan'); + }); + + it('does NOT send to a ws whose readyState is not OPEN (M5)', () => { + const s = newSession(); + const ws = createMockWs(WS_OPEN); + attachWs(s, ws); + ws.sent.length = 0; + + ws.readyState = 3; // CLOSED + (s.pty as MockIPty).emitData('lost'); + + expect(ws.sent).toHaveLength(0); + }); +}); + +// ── attachWs ────────────────────────────────────────────────────────────────── +describe('attachWs', () => { + it('replays the buffer snapshot to the newly attached ws', () => { + const s = newSession(); + (s.pty as MockIPty).emitData('prior output'); + + const ws = createMockWs(); + const kicked = attachWs(s, ws); + + expect(kicked).toBeNull(); + expect(s.attachedWs).toBe(ws); + expect(received(ws)).toEqual([{ type: 'output', data: '\x1b[0mprior output' }]); + }); + + it('points attachedWs at the new ws BEFORE returning the kicked old ws (later wins)', () => { + const s = newSession(); + const oldWs = createMockWs(); + attachWs(s, oldWs); + + const newWs = createMockWs(); + const kicked = attachWs(s, newWs); + + // pointer is swapped first; caller is responsible for closing the kicked ws. + expect(s.attachedWs).toBe(newWs); + expect(kicked).toBe(oldWs); + expect(oldWs.closed).toBe(false); // attachWs must NOT close it itself + + // forwarding now only ever reaches the new ws. + newWs.sent.length = 0; + (s.pty as MockIPty).emitData('after kick'); + expect(received(newWs)).toEqual([{ type: 'output', data: 'after kick' }]); + expect(oldWs.sent.some((f) => f.includes('after kick'))).toBe(false); + }); +}); + +// ── detachWs ────────────────────────────────────────────────────────────────── +describe('detachWs', () => { + it('clears attachedWs and stamps detachedAt but NEVER kills the PTY', () => { + const s = newSession(); + const ws = createMockWs(); + attachWs(s, ws); + + detachWs(s, 5_000); + + expect(s.attachedWs).toBeNull(); + expect(s.detachedAt).toBe(5_000); + expect((s.pty as MockIPty).killed).toBe(false); + }); + + it('keeps the PTY alive after detach: further onData still fills the buffer', () => { + const s = newSession(); + const ws = createMockWs(); + attachWs(s, ws); + detachWs(s, 5_000); + + (s.pty as MockIPty).emitData('background work'); + + expect((s.pty as MockIPty).killed).toBe(false); + expect(s.buffer.snapshot()).toContain('background work'); + }); +}); + +// ── onExit ──────────────────────────────────────────────────────────────────── +describe('onExit', () => { + it('sets exitedAt/exitCode, sends `exit` to the attached ws, and calls injected onExit', () => { + const onExit = vi.fn(); + nextPty = createMockPty(); + const s = createSession(CFG, DIMS, 1_000, onExit); + + const ws = createMockWs(); + attachWs(s, ws); + ws.sent.length = 0; + + (s.pty as MockIPty).emitExit(0); + + expect(s.exitedAt).not.toBeNull(); + expect(s.exitCode).toBe(0); + expect(received(ws)).toEqual([{ type: 'exit', code: 0 }]); + expect(onExit).toHaveBeenCalledWith(s); + }); + + it('after detach, exit is NOT delivered but the session is preserved (L1)', () => { + const onExit = vi.fn(); + nextPty = createMockPty(); + const s = createSession(CFG, DIMS, 1_000, onExit); + + const ws = createMockWs(); + attachWs(s, ws); + detachWs(s, 5_000); + ws.sent.length = 0; + + (s.pty as MockIPty).emitExit(137); + + // exit happened: state recorded, but no ws to notify → nothing delivered. + expect(s.exitedAt).not.toBeNull(); + expect(s.exitCode).toBe(137); + expect(ws.sent).toHaveLength(0); + // session preserved: buffer intact (caller/manager decides removal via onExit). + expect(onExit).toHaveBeenCalledWith(s); + }); + + it('does not send exit to a ws whose readyState is not OPEN (M5)', () => { + nextPty = createMockPty(); + const s = createSession(CFG, DIMS, 1_000, () => {}); + const ws = createMockWs(WS_OPEN); + attachWs(s, ws); + ws.sent.length = 0; + ws.readyState = 3; // CLOSED + + (s.pty as MockIPty).emitExit(1); + + expect(ws.sent).toHaveLength(0); + }); +}); + +// ── writeInput / resize after exit (L4) + resize idempotence ────────────────── +describe('writeInput / resize', () => { + it('writeInput forwards to pty.write while alive', () => { + const s = newSession(); + writeInput(s, 'ls\r'); + expect((s.pty as MockIPty).writes).toEqual(['ls\r']); + }); + + it('resize forwards to pty.resize while alive', () => { + const s = newSession(); + resize(s, 100, 40); + expect((s.pty as MockIPty).resizes).toEqual([{ cols: 100, rows: 40 }]); + }); + + it('resize is idempotent: same cols/rows are skipped', () => { + const s = newSession(); + resize(s, 80, 24); // equal to the spawn dims → no-op + expect((s.pty as MockIPty).resizes).toHaveLength(0); + + resize(s, 90, 24); // changed → applied + resize(s, 90, 24); // unchanged again → skipped + expect((s.pty as MockIPty).resizes).toEqual([{ cols: 90, rows: 24 }]); + }); + + it('ignores writeInput once the PTY has exited (L4)', () => { + const s = newSession(); + (s.pty as MockIPty).emitExit(0); + + writeInput(s, 'should be dropped'); + expect((s.pty as MockIPty).writes).toHaveLength(0); + }); + + it('ignores resize once the PTY has exited (L4)', () => { + const s = newSession(); + (s.pty as MockIPty).emitExit(0); + + resize(s, 200, 50); + expect((s.pty as MockIPty).resizes).toHaveLength(0); + }); +}); + +// ── kill ────────────────────────────────────────────────────────────────────── +describe('kill', () => { + it('kills the underlying PTY', () => { + const s = newSession(); + kill(s); + expect((s.pty as MockIPty).killed).toBe(true); + }); +});