feat: T12 session/session.ts — PTY lifecycle (M4/M5/L1/L4)
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.
This commit is contained in:
145
src/session/session.ts
Normal file
145
src/session/session.ts
Normal file
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user