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:
163
src/session/manager.ts
Normal file
163
src/session/manager.ts
Normal 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 };
|
||||||
|
}
|
||||||
500
test/manager.test.ts
Normal file
500
test/manager.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user