feat: T13 session/manager.ts — session table (M3/L1/L2)

handleAttach 4 paths (new/alive/exited-L1/not-found); reapIdle uses
now-max(detachedAt,lastOutputAt)>idleTtl (M3); shutdown kills all; injected
onExit removes from table when attached, keeps for L1 replay when detached (L2);
createSession errors propagate (M4). 21 tests; full suite 181 green.
This commit is contained in:
Yaojia Wang
2026-06-16 18:58:14 +02:00
parent 20212dd7f6
commit a2897b2e12
2 changed files with 663 additions and 0 deletions

163
src/session/manager.ts Normal file
View File

@@ -0,0 +1,163 @@
/**
* src/session/manager.ts (T13) — session table: create / attach / reap / shutdown.
*
* Cross-validated fixes implemented here (ARCHITECTURE §3.5):
* - M3: reapIdle uses now - max(detachedAt, lastOutputAt) > idleTtlMs (not just
* detachedAt), so a detached session that still has recent output is not reaped.
* - M4: createSession throwing (spawn failure) is NOT swallowed — it propagates to
* server.ts, which serialises a {type:'exit',code:-1,reason} and closes only
* that connection.
* - L1: when a client reconnects with a sessionId and that session's PTY has already
* exited (exitedAt!=null), the manager replays the ring buffer then immediately
* resends the exit message, then removes the session from the table.
* - L2: the onExit callback injected into createSession removes the session from the
* table ONLY when the ws was attached at exit time; if the session was detached
* at exit time, we keep it in the table so the L1 reconnect path can replay.
*
* Invariants (ARCHITECTURE §8):
* - #2: WS close → detach, never kill PTY.
* - #5: one ws per session; later attach kicks the earlier one.
* - #6: every ws.send is guarded by readyState === WS_OPEN (handled inside session.ts).
*
* Coding style: immutable-update preference, no console.log, errors explicit.
*/
import type { Config, Dims, Session, SessionManager, WebSocketLike } from '../types.js';
import { WS_OPEN } from '../types.js';
import { serialize } from '../protocol.js';
import { createSession, attachWs, kill } from './session.js';
/**
* Send a serialised ServerMessage to `ws` only when it is OPEN (M5).
* Mirrors the guard inside session.ts so the manager can also send
* control messages (e.g. the replayed exit on L1).
*/
function sendIfOpen(ws: WebSocketLike | null, msg: Parameters<typeof serialize>[0]): void {
if (ws !== null && ws.readyState === WS_OPEN) {
ws.send(serialize(msg));
}
}
/**
* Create the session table.
*
* The Map is the only mutable state; all updates produce a new Map snapshot
* (add, delete) rather than in-place modification of the table structure.
* Individual Session objects do carry mutable fields (attachedWs, detachedAt,
* lastOutputAt, exitedAt, exitCode) by design — those are runtime handles.
*/
export function createSessionManager(cfg: Config): SessionManager {
let sessions: Map<string, Session> = new Map();
/**
* The onExit callback injected into every createSession call.
*
* L2: when the PTY exits while a ws is attached (the common case), the
* session is removed from the table here — the ws already received the
* `exit` frame from inside session.ts (M5-guarded).
*
* L1: when the PTY exits while detached (no attachedWs), we KEEP the
* session in the table so the next reconnect can replay the buffer and
* re-send the exit message before removing the session.
*/
function onSessionExit(session: Session): void {
if (session.attachedWs !== null) {
// ws was attached at exit time — session already notified, safe to drop.
sessions = new Map(sessions);
sessions.delete(session.meta.id);
}
// Otherwise: detached-then-exit (L1) — leave in table for reconnect replay.
}
function handleAttach(
ws: WebSocketLike,
sessionId: string | null,
dims: Dims,
now: number,
): Session {
// ── Case 1: null → always create a new session ──────────────────────────
if (sessionId === null) {
// M4: createSession may throw (spawn failure). Do NOT catch here.
const session = createSession(cfg, dims, now, onSessionExit);
const kicked = attachWs(session, ws);
if (kicked !== null) kicked.close();
sessions = new Map(sessions).set(session.meta.id, session);
return session;
}
const existing = sessions.get(sessionId);
// ── Case 2: hit a live session ───────────────────────────────────────────
if (existing !== undefined && existing.exitedAt === null) {
const kicked = attachWs(existing, ws);
if (kicked !== null) kicked.close();
return existing;
}
// ── Case 3: hit an already-exited session (L1) ───────────────────────────
if (existing !== undefined && existing.exitedAt !== null) {
// Replay the ring buffer so the reconnecting client sees the last screen.
sendIfOpen(ws, { type: 'output', data: existing.buffer.snapshot() });
// Immediately re-deliver the exit message (it was not delivered while detached).
sendIfOpen(ws, { type: 'exit', code: existing.exitCode ?? -1 });
// Remove from table — session lifecycle is complete.
sessions = new Map(sessions);
sessions.delete(sessionId);
return existing;
}
// ── Case 4: session not found → create a new one ─────────────────────────
// M4: createSession may throw. Do NOT catch here.
const session = createSession(cfg, dims, now, onSessionExit);
const kicked = attachWs(session, ws);
if (kicked !== null) kicked.close();
sessions = new Map(sessions).set(session.meta.id, session);
return session;
}
function get(id: string): Session | undefined {
return sessions.get(id);
}
/**
* Reclaim detached sessions whose liveness window has expired (M3).
*
* Reclaim condition: session is detached (detachedAt != null) AND
* now - max(detachedAt, lastOutputAt) > idleTtlMs
*
* Using max() means: if output arrived after detach (PTY still running a
* background task), the expiry clock restarts from that later timestamp.
* This approximates "is there a live foreground job?" without needing
* cross-platform PTY process-group queries (M3 rationale).
*
* Returns the count of reaped sessions.
*/
function reapIdle(now: number): number {
let count = 0;
const next = new Map(sessions);
for (const [id, session] of next) {
if (session.detachedAt === null) continue; // still attached or never detached
const livenessCursor = Math.max(session.detachedAt, session.lastOutputAt);
if (now - livenessCursor > cfg.idleTtlMs) {
kill(session);
next.delete(id);
count += 1;
}
}
sessions = next;
return count;
}
/** Kill every live session and clear the table (called on SIGINT/SIGTERM). */
function shutdown(): void {
for (const session of sessions.values()) {
kill(session);
}
sessions = new Map();
}
return { handleAttach, get, reapIdle, shutdown };
}