diff --git a/src/session/manager.ts b/src/session/manager.ts new file mode 100644 index 0000000..480b8b2 --- /dev/null +++ b/src/session/manager.ts @@ -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[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 = 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 }; +} diff --git a/test/manager.test.ts b/test/manager.test.ts new file mode 100644 index 0000000..f0c1383 --- /dev/null +++ b/test/manager.test.ts @@ -0,0 +1,500 @@ +/** + * T13 tests — session/manager.ts (session table lifecycle). + * + * ARCHITECTURE §3.5 + the cross-validated fixes: + * - M3: reapIdle uses now - max(detachedAt, lastOutputAt) > idleTtlMs + * (not just detachedAt, so active sessions with recent output are not reaped). + * - M4: createSession throwing propagates out of handleAttach (not swallowed). + * - L1: attach hitting an already-exited session → replay buffer + resend exit + remove. + * - L2: the onExit callback injected into createSession removes the session from the table. + * + * node-pty is mocked so spawn returns a MockIPty — no real PTY/shell is started. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import type { Config, Dims, Session, WebSocketLike } from '../src/types.js'; +import { WS_OPEN } from '../src/types.js'; +import { createMockPty, type MockIPty } from './helpers/mock-pty.js'; + +// ── node-pty mock ─────────────────────────────────────────────────────────── +// All createSession calls ultimately call spawn; we intercept at the node-pty +// boundary so the session module works exactly as in production, but no real +// shell is ever spawned. + +let nextPty: MockIPty = createMockPty(); +let spawnShouldThrow: Error | null = null; + +vi.mock('node-pty', () => ({ + spawn: (_file: string, _args: string[], _options: unknown) => { + if (spawnShouldThrow) throw spawnShouldThrow; + return nextPty; + }, +})); + +// Import AFTER vi.mock so the module under test binds to the mocked spawn. +const { createSessionManager } = await import('../src/session/manager.js'); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const CFG: Config = { + port: 3000, + bindHost: '0.0.0.0', + shellPath: '/bin/zsh', + homeDir: '/home/tester', + idleTtlMs: 10_000, // 10 s for easy arithmetic in tests + 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; + this.readyState = 3; // CLOSED + }, + }; +} + +function parseSent(ws: MockWs) { + return ws.sent.map((s) => JSON.parse(s)); +} + +beforeEach(() => { + nextPty = createMockPty(); + spawnShouldThrow = null; +}); + +// ── handleAttach: null sessionId → new session ──────────────────────────────── +describe('handleAttach — null sessionId', () => { + it('creates a new session and returns it', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const session = mgr.handleAttach(ws, null, DIMS, 1_000); + + expect(session).toBeDefined(); + expect(session.meta.id).toBeTruthy(); + expect(session.attachedWs).toBe(ws); + }); + + it('stores the session so get() returns it', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const session = mgr.handleAttach(ws, null, DIMS, 1_000); + + expect(mgr.get(session.meta.id)).toBe(session); + }); + + it('propagates spawn failure without swallowing it (M4)', () => { + spawnShouldThrow = new Error('spawn failed: /no/such/shell ENOENT'); + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + + expect(() => mgr.handleAttach(ws, null, DIMS, 1_000)).toThrow(/spawn failed/); + }); +}); + +// ── handleAttach: hit live session ──────────────────────────────────────────── +describe('handleAttach — hit live session', () => { + it('attaches the new ws and returns the same session', () => { + const mgr = createSessionManager(CFG); + const ws1 = createMockWs(); + const session = mgr.handleAttach(ws1, null, DIMS, 1_000); + const id = session.meta.id; + + const ws2 = createMockWs(); + const session2 = mgr.handleAttach(ws2, id, DIMS, 2_000); + + expect(session2).toBe(session); + expect(session.attachedWs).toBe(ws2); + }); + + it('kicks (closes) the previously attached ws', () => { + const mgr = createSessionManager(CFG); + const ws1 = createMockWs(); + const session = mgr.handleAttach(ws1, null, DIMS, 1_000); + const id = session.meta.id; + + const ws2 = createMockWs(); + mgr.handleAttach(ws2, id, DIMS, 2_000); + + // The old ws should have been closed by the manager. + expect(ws1.closed).toBe(true); + }); + + it('replays the buffer to the new ws', () => { + const mgr = createSessionManager(CFG); + const ws1 = createMockWs(); + const session = mgr.handleAttach(ws1, null, DIMS, 1_000); + const id = session.meta.id; + + // Produce some output while ws1 is attached. + (session.pty as MockIPty).emitData('hello world'); + + const ws2 = createMockWs(); + mgr.handleAttach(ws2, id, DIMS, 2_000); + + const msgs = parseSent(ws2); + const outputMsgs = msgs.filter((m) => m.type === 'output'); + expect(outputMsgs.some((m: { data: string }) => m.data.includes('hello world'))).toBe(true); + }); +}); + +// ── handleAttach: hit already-exited session (L1) ───────────────────────────── +describe('handleAttach — hit already-exited session (L1)', () => { + it('sends the buffer snapshot to the new ws', () => { + const mgr = createSessionManager(CFG); + const ws1 = createMockWs(); + const session = mgr.handleAttach(ws1, null, DIMS, 1_000); + const id = session.meta.id; + + // Build some output, then detach ws1, then simulate PTY exit. + (session.pty as MockIPty).emitData('last output before exit'); + mgr.handleAttach(createMockWs(), id, DIMS, 2_000); // re-attach so we can detach + // Force detach by patching directly (simulates WS close event). + session.attachedWs = null; + session.detachedAt = 3_000; + + // Now PTY exits while detached — session preserved (L1). + (session.pty as MockIPty).emitExit(0); + + // session.exitedAt is set, session still in table because onExit in detach + // scenario keeps session (manager's onExit callback removes from table only if + // invoked — but per L2, after exit while attached ws exists the callback removes, + // while L1 says detach-then-exit: we must keep for replay). + // Actually per ARCHITECTURE: the injected onExit IS called by session.ts after exit. + // But the L1 scenario means: exitedAt!=null, session is in table until re-attach. + // We re-insert it manually to simulate the correct state from ARCHITECTURE §4.4: + // "session is preserved (buffer + exitedAt)" waiting for re-attach. + // The key thing to test is that when a NEW ws comes with this session's id, + // the manager replays the buffer + sends exit + removes from table. + + // For this test: we need the session to still be in the map with exitedAt set. + // The manager's injected onExit should NOT remove from table in this case — + // But wait: per ARCHITECTURE §3.5 L2: "PTY 退出: injected onExit → 从表删除" + // And L1: "detach后exit → 保留buffer+exitedAt, 等用户回来attach" + // These seem contradictory. Let me re-read: + // + // ARCHITECTURE §3.5 constraint: "PTY 退出: createSession 内部 onExit → 置 exitedAt → + // 有 ws 则发 exit → 调 manager 注入的回调从表移除 (L2)" + // ARCHITECTURE §3.5 L1: "detach 后才退出(无 ws 可通知, L1): 不立即丢弃会话, 保留 + // buffer + exitedAt, 等用户带旧 id 回来 attach 时回放 + 补发 exit" + // + // Resolution: L2 says "从表移除" — but L1 says "不立即丢弃". So L2 applies when + // attachedWs is NOT null (ws is attached), and L1 applies when detached. + // The manager's injected onExit callback should: + // - Only remove from table if attachedWs != null at exit time (ws was attached) + // - Keep in table if detached at exit time (for L1 replay on reconnect) + // + // But PLAN.md T13 Steps says: "注入给 createSession 的 onExit: 从会话表删除该会话 (L2)" + // And the task prompt says: "L2: onExit 从会话表删除" + // And also: "命中已退出会话(exitedAt!=null) → 回放+补发exit后从表移除(L1)" + // + // So the behavior is: + // - When PTY exits with ws attached: onExit removes from table immediately (L2). + // - When PTY exits while detached (L1): onExit does NOT remove; session stays. + // The session is removed when the next attach hits it and sees exitedAt!=null. + // + // This means the manager's onExit callback must check attachedWs: if null, keep. + // If ws attached, remove. + // + // Actually re-reading ARCHITECTURE §3.5 more carefully: + // The constraint says: "PTY 退出: session 内部 onExit → 置 exitedAt → 有 ws 则发 exit + // → 调 manager 注入的回调从表移除 (L2: session 不持有 manager, 退出事件靠注入回调跨层冒泡)" + // + // This says "调 manager 注入的回调从表移除" — it calls the callback and removes. + // But L1 says "不立即丢弃". These are the same callback situation. + // + // Looking at PLAN T13 instruction: "注入给 createSession 的 onExit: 从会话表删除该会话 (L2)" + // And the task prompt: "命中已退出 → 回放+补发exit+移除 → 从表移除(L1)" + // + // The reconciliation: L2's callback ALWAYS removes the session from the table when + // called. L1 means: when the session exits while detached, the session stays in the + // table BECAUSE the onExit in the detach case should NOT remove it immediately. + // + // Wait, but the task prompt says "注入给 createSession 的 onExit: 从会话表删除该会话 (L2)" + // unconditionally. And L1 says "保留 buffer + exitedAt". + // + // The only way to reconcile: the onExit callback should NOT remove when detached. + // The callback should check session.attachedWs (which is null when detached): + // if (session.attachedWs !== null) → remove from table (session was live) + // if (session.attachedWs === null) → keep in table (for L1 replay) + // + // This matches ARCHITECTURE §4.4: "[在 detach 状态下退出] pty.onExit → 置 exitedAt/exitCode + // → attachedWs 为 null, exit 暂不投递 → 会话保留(buffer + exitedAt)" + // And the session.ts onExit flow: sets exitedAt, sends exit if ws, calls manager onExit. + // + // So the manager's onExit MUST only remove when ws was attached. When detached, + // it should keep the session for L1 replay. + // + // CONCLUSION: The injected onExit callback in the manager checks session.attachedWs: + // - non-null → ws existed when exit fired → remove from table (L2) + // - null → detached-then-exit → keep in table for L1 replay + + // At this point, session.exitedAt should be set, and session should still be in table. + expect(session.exitedAt).not.toBeNull(); + + // Now a new ws comes back with the same session id: + const ws2 = createMockWs(); + // Need to re-add session to map manually IF the onExit removed it. + // Let's just test the flow by checking what actually happens: + const result = mgr.handleAttach(ws2, id, DIMS, 5_000); + + // The manager should have replayed the buffer to ws2. + const msgs = parseSent(ws2); + const outputMsgs = msgs.filter((m: { type: string }) => m.type === 'output'); + expect(outputMsgs.some((m: { data: string }) => m.data.includes('last output before exit'))).toBe(true); + }); + + it('sends exit message to the new ws after replay (L1)', () => { + const mgr = createSessionManager(CFG); + const ws1 = createMockWs(); + const session = mgr.handleAttach(ws1, null, DIMS, 1_000); + const id = session.meta.id; + + // Detach by simulating WS close. + session.attachedWs = null; + session.detachedAt = 2_000; + + // PTY exits while detached. + (session.pty as MockIPty).emitExit(42); + + expect(session.exitedAt).not.toBeNull(); + expect(session.exitCode).toBe(42); + + // New ws comes back. + const ws2 = createMockWs(); + mgr.handleAttach(ws2, id, DIMS, 5_000); + + const msgs = parseSent(ws2); + const exitMsgs = msgs.filter((m: { type: string }) => m.type === 'exit'); + expect(exitMsgs).toHaveLength(1); + expect(exitMsgs[0]).toMatchObject({ type: 'exit', code: 42 }); + }); + + it('removes the exited session from the table after replay (L1)', () => { + const mgr = createSessionManager(CFG); + const ws1 = createMockWs(); + const session = mgr.handleAttach(ws1, null, DIMS, 1_000); + const id = session.meta.id; + + // Detach and exit. + session.attachedWs = null; + session.detachedAt = 2_000; + (session.pty as MockIPty).emitExit(0); + + // Reconnect triggers L1 replay + removal. + const ws2 = createMockWs(); + mgr.handleAttach(ws2, id, DIMS, 5_000); + + // Session must be gone from the table now. + expect(mgr.get(id)).toBeUndefined(); + }); +}); + +// ── handleAttach: session not found → new session ───────────────────────────── +describe('handleAttach — session not found', () => { + it('creates a new session when the given id does not exist', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + + const unknownId = '00000000-0000-4000-8000-000000000000'; + const session = mgr.handleAttach(ws, unknownId, DIMS, 1_000); + + expect(session).toBeDefined(); + // The returned session has a NEW id (not the bogus one). + expect(session.meta.id).not.toBe(unknownId); + expect(session.attachedWs).toBe(ws); + }); + + it('the newly created session is stored in the table', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const unknownId = '00000000-0000-4000-8000-000000000001'; + const session = mgr.handleAttach(ws, unknownId, DIMS, 1_000); + + expect(mgr.get(session.meta.id)).toBe(session); + // Old unknown id is NOT stored. + expect(mgr.get(unknownId)).toBeUndefined(); + }); +}); + +// ── get ─────────────────────────────────────────────────────────────────────── +describe('get', () => { + it('returns undefined for unknown ids', () => { + const mgr = createSessionManager(CFG); + expect(mgr.get('00000000-0000-4000-8000-000000000002')).toBeUndefined(); + }); + + it('returns the session for a known id', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const session = mgr.handleAttach(ws, null, DIMS, 1_000); + expect(mgr.get(session.meta.id)).toBe(session); + }); +}); + +// ── reapIdle (M3) ───────────────────────────────────────────────────────────── +describe('reapIdle (M3)', () => { + it('does not reap a session that is still attached', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const session = mgr.handleAttach(ws, null, DIMS, 1_000); + // Not detached, so detachedAt = null → not eligible. + + const reaped = mgr.reapIdle(1_000 + CFG.idleTtlMs + 1); + expect(reaped).toBe(0); + expect(mgr.get(session.meta.id)).toBeDefined(); + }); + + it('does not reap a detached session that received output recently (M3)', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const session = mgr.handleAttach(ws, null, DIMS, 1_000); + const id = session.meta.id; + + // Detach at t=2000. + session.attachedWs = null; + session.detachedAt = 2_000; + + // Output arrives at t=5000 (AFTER detach) — refresh lastOutputAt. + (session.pty as MockIPty).emitData('fresh output'); + // Force the lastOutputAt to a specific value we can assert against. + session.lastOutputAt = 5_000; + + // Now reap at t = 5000 + idleTtl - 1 → not yet expired relative to lastOutputAt. + const reaped = mgr.reapIdle(5_000 + CFG.idleTtlMs - 1); + expect(reaped).toBe(0); + expect(mgr.get(id)).toBeDefined(); + expect((session.pty as MockIPty).killed).toBe(false); + }); + + it('reaps a detached session whose max(detachedAt, lastOutputAt) + idleTtl has passed (M3)', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const session = mgr.handleAttach(ws, null, DIMS, 1_000); + const id = session.meta.id; + + // Detach at t=2000; no output after detach. + session.attachedWs = null; + session.detachedAt = 2_000; + session.lastOutputAt = 1_500; // before detach → max = 2000 + + // Reap at t = 2000 + idleTtl + 1 → expired. + const reaped = mgr.reapIdle(2_000 + CFG.idleTtlMs + 1); + expect(reaped).toBe(1); + expect(mgr.get(id)).toBeUndefined(); + expect((session.pty as MockIPty).killed).toBe(true); + }); + + it('reaps multiple idle sessions and returns the count', () => { + const mgr = createSessionManager(CFG); + + // Session A — will be reaped. + nextPty = createMockPty(); + const wsA = createMockWs(); + const sessionA = mgr.handleAttach(wsA, null, DIMS, 1_000); + sessionA.attachedWs = null; + sessionA.detachedAt = 1_000; + sessionA.lastOutputAt = 1_000; + + // Session B — will be reaped. + nextPty = createMockPty(); + const wsB = createMockWs(); + const sessionB = mgr.handleAttach(wsB, null, DIMS, 1_000); + sessionB.attachedWs = null; + sessionB.detachedAt = 1_000; + sessionB.lastOutputAt = 1_000; + + const reaped = mgr.reapIdle(1_000 + CFG.idleTtlMs + 1); + expect(reaped).toBe(2); + expect(mgr.get(sessionA.meta.id)).toBeUndefined(); + expect(mgr.get(sessionB.meta.id)).toBeUndefined(); + }); + + it('does not reap if detachedAt is null (still attached / never detached)', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const session = mgr.handleAttach(ws, null, DIMS, 1_000); + // detachedAt is null — session is still live. + + const reaped = mgr.reapIdle(99_999_999); + expect(reaped).toBe(0); + expect(mgr.get(session.meta.id)).toBeDefined(); + }); +}); + +// ── shutdown ────────────────────────────────────────────────────────────────── +describe('shutdown', () => { + it('kills all sessions and clears the table', () => { + const mgr = createSessionManager(CFG); + + nextPty = createMockPty(); + const ws1 = createMockWs(); + const s1 = mgr.handleAttach(ws1, null, DIMS, 1_000); + + nextPty = createMockPty(); + const ws2 = createMockWs(); + const s2 = mgr.handleAttach(ws2, null, DIMS, 1_000); + + mgr.shutdown(); + + expect((s1.pty as MockIPty).killed).toBe(true); + expect((s2.pty as MockIPty).killed).toBe(true); + + // Table is cleared. + expect(mgr.get(s1.meta.id)).toBeUndefined(); + expect(mgr.get(s2.meta.id)).toBeUndefined(); + }); +}); + +// ── L2: injected onExit removes session when ws is attached at exit time ─────── +describe('onExit removes session from table when ws attached at exit time (L2)', () => { + it('session is removed from the table when PTY exits while ws is attached', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const session = mgr.handleAttach(ws, null, DIMS, 1_000); + const id = session.meta.id; + + // PTY exits while ws is still attached. + (session.pty as MockIPty).emitExit(0); + + // The manager's onExit should have removed the session. + expect(mgr.get(id)).toBeUndefined(); + }); + + it('session is NOT removed from table when PTY exits while detached (kept for L1 replay)', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const session = mgr.handleAttach(ws, null, DIMS, 1_000); + const id = session.meta.id; + + // Detach first. + session.attachedWs = null; + session.detachedAt = 2_000; + + // Then PTY exits. + (session.pty as MockIPty).emitExit(0); + + // Session must stay in table for L1 replay. + expect(mgr.get(id)).toBeDefined(); + expect(session.exitedAt).not.toBeNull(); + }); +});