feat(v0.4): multi-device session sharing (backend)
Relax the one-WS-per-session invariant (#5) so multiple devices mirror the same live terminal (everyone sees output, anyone can type — tmux-style). - types.ts: Session.attachedWs → clients Set + clientDims Map; add cwd; SessionManager.list() + LiveSessionInfo - session.ts: broadcast() output/exit to all clients; attachWs JOINS (no kick, takes dims); detachWs removes one client, stamps detachedAt only when the last leaves; setClientDims resizes PTY to the MIN cols/rows across clients - manager.ts: join instead of kick; broadcast status; onExit drops only when a client was attached; list() for discovery - server.ts: GET /live-sessions; resize → per-client setClientDims; close → detach one client; permission gate uses clients.size - tests: rewrote session/manager tests for multi-client (join/broadcast/min-dims/ last-detach) + list(); 222 pass, tsc clean
This commit is contained in:
@@ -39,7 +39,7 @@ import { isOriginAllowed } from './http/origin.js'
|
|||||||
import { parseHookEvent } from './http/hook.js'
|
import { parseHookEvent } from './http/hook.js'
|
||||||
import { listSessions } from './http/history.js'
|
import { listSessions } from './http/history.js'
|
||||||
import { createSessionManager } from './session/manager.js'
|
import { createSessionManager } from './session/manager.js'
|
||||||
import { detachWs, writeInput, resize } from './session/session.js'
|
import { detachWs, writeInput, setClientDims } from './session/session.js'
|
||||||
import type { Config } from './types.js'
|
import type { Config } from './types.js'
|
||||||
import { WS_OPEN } from './types.js'
|
import { WS_OPEN } from './types.js'
|
||||||
|
|
||||||
@@ -111,6 +111,12 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
|||||||
res.json(listSessions(50))
|
res.json(listSessions(50))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Live sessions (v0.4) — running server sessions, for multi-device discovery.
|
||||||
|
// Any LAN device opening the app fetches this to show the host's sessions as tabs.
|
||||||
|
app.get('/live-sessions', (_req, res) => {
|
||||||
|
res.json(manager.list())
|
||||||
|
})
|
||||||
|
|
||||||
// ── Claude Code hook side-channel (H2) ────────────────────────────────────
|
// ── Claude Code hook side-channel (H2) ────────────────────────────────────
|
||||||
// Hooks running inside a spawned shell POST status here (loopback only — the
|
// Hooks running inside a spawned shell POST status here (loopback only — the
|
||||||
// shell runs on the host). sessionId arrives in the X-Webterm-Session header.
|
// shell runs on the host). sessionId arrives in the X-Webterm-Session header.
|
||||||
@@ -140,8 +146,8 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
|||||||
const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined
|
const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined
|
||||||
const session = typeof sessionId === 'string' ? manager.get(sessionId) : undefined
|
const session = typeof sessionId === 'string' ? manager.get(sessionId) : undefined
|
||||||
|
|
||||||
// Nobody watching this tab (no session / not attached) → let Claude prompt itself.
|
// Nobody watching this tab (no session / no clients) → let Claude prompt itself.
|
||||||
if (sessionId === undefined || session === undefined || session.attachedWs === null) {
|
if (sessionId === undefined || session === undefined || session.clients.size === 0) {
|
||||||
res.json({})
|
res.json({})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -262,7 +268,8 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
|||||||
if (msg.type === 'input') {
|
if (msg.type === 'input') {
|
||||||
writeInput(session, msg.data)
|
writeInput(session, msg.data)
|
||||||
} else if (msg.type === 'resize') {
|
} else if (msg.type === 'resize') {
|
||||||
resize(session, msg.cols, msg.rows)
|
// Per-client dims; the PTY tracks the min across all sharing devices.
|
||||||
|
setClientDims(session, ws, msg.cols, msg.rows)
|
||||||
} else if (msg.type === 'approve') {
|
} else if (msg.type === 'approve') {
|
||||||
// H3: resolve the held PermissionRequest with allow.
|
// H3: resolve the held PermissionRequest with allow.
|
||||||
resolvePending(boundSessionId, permDecision('allow'))
|
resolvePending(boundSessionId, permDecision('allow'))
|
||||||
@@ -288,8 +295,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
|||||||
const session = manager.get(boundSessionId)
|
const session = manager.get(boundSessionId)
|
||||||
if (session === undefined) return
|
if (session === undefined) return
|
||||||
|
|
||||||
// Invariant #2: never kill the PTY on WS close — detach only.
|
// Invariant #2: never kill the PTY on WS close — detach THIS client only
|
||||||
detachWs(session, Date.now())
|
// (the PTY stays alive for other devices and for reconnect).
|
||||||
|
detachWs(session, ws, Date.now())
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── WS error ─────────────────────────────────────────────────────────────
|
// ── WS error ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -15,8 +15,10 @@
|
|||||||
* at exit time, we keep it in the table so the L1 reconnect path can replay.
|
* at exit time, we keep it in the table so the L1 reconnect path can replay.
|
||||||
*
|
*
|
||||||
* Invariants (ARCHITECTURE §8):
|
* Invariants (ARCHITECTURE §8):
|
||||||
* - #2: WS close → detach, never kill PTY.
|
* - #2: WS close → detach ONE client, never kill PTY (reap only when the last leaves).
|
||||||
* - #5: one ws per session; later attach kicks the earlier one.
|
* - #5 (relaxed v0.4): a session may have MANY concurrent clients (multi-device
|
||||||
|
* mirror sharing); a new attach JOINS instead of kicking. Output/exit/status
|
||||||
|
* broadcast to all; the PTY size is the min across clients (tmux-style).
|
||||||
* - #6: every ws.send is guarded by readyState === WS_OPEN (handled inside session.ts).
|
* - #6: every ws.send is guarded by readyState === WS_OPEN (handled inside session.ts).
|
||||||
*
|
*
|
||||||
* Coding style: immutable-update preference, no console.log, errors explicit.
|
* Coding style: immutable-update preference, no console.log, errors explicit.
|
||||||
@@ -26,13 +28,14 @@ import type {
|
|||||||
ClaudeStatus,
|
ClaudeStatus,
|
||||||
Config,
|
Config,
|
||||||
Dims,
|
Dims,
|
||||||
|
LiveSessionInfo,
|
||||||
Session,
|
Session,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
WebSocketLike,
|
WebSocketLike,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { WS_OPEN } from '../types.js';
|
import { WS_OPEN } from '../types.js';
|
||||||
import { serialize } from '../protocol.js';
|
import { serialize } from '../protocol.js';
|
||||||
import { createSession, attachWs, kill } from './session.js';
|
import { createSession, attachWs, broadcast, kill } from './session.js';
|
||||||
import { hasSession, tmuxName } from './tmux.js';
|
import { hasSession, tmuxName } from './tmux.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,8 +72,8 @@ export function createSessionManager(cfg: Config): SessionManager {
|
|||||||
* re-send the exit message before removing the session.
|
* re-send the exit message before removing the session.
|
||||||
*/
|
*/
|
||||||
function onSessionExit(session: Session): void {
|
function onSessionExit(session: Session): void {
|
||||||
if (session.attachedWs !== null) {
|
if (session.clients.size > 0) {
|
||||||
// ws was attached at exit time — session already notified, safe to drop.
|
// ≥1 client was attached at exit time — they got the `exit` frame, safe to drop.
|
||||||
sessions = new Map(sessions);
|
sessions = new Map(sessions);
|
||||||
sessions.delete(session.meta.id);
|
sessions.delete(session.meta.id);
|
||||||
}
|
}
|
||||||
@@ -89,18 +92,16 @@ export function createSessionManager(cfg: Config): SessionManager {
|
|||||||
// M4: createSession may throw (spawn failure). Do NOT catch here.
|
// M4: createSession may throw (spawn failure). Do NOT catch here.
|
||||||
// M6: cwd (if given) is the spawn directory for "new tab here".
|
// M6: cwd (if given) is the spawn directory for "new tab here".
|
||||||
const session = createSession(cfg, dims, now, onSessionExit, undefined, cwd);
|
const session = createSession(cfg, dims, now, onSessionExit, undefined, cwd);
|
||||||
const kicked = attachWs(session, ws);
|
attachWs(session, ws, dims);
|
||||||
if (kicked !== null) kicked.close();
|
|
||||||
sessions = new Map(sessions).set(session.meta.id, session);
|
sessions = new Map(sessions).set(session.meta.id, session);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = sessions.get(sessionId);
|
const existing = sessions.get(sessionId);
|
||||||
|
|
||||||
// ── Case 2: hit a live session ───────────────────────────────────────────
|
// ── Case 2: hit a live session → JOIN it (multi-device sharing) ───────────
|
||||||
if (existing !== undefined && existing.exitedAt === null) {
|
if (existing !== undefined && existing.exitedAt === null) {
|
||||||
const kicked = attachWs(existing, ws);
|
attachWs(existing, ws, dims);
|
||||||
if (kicked !== null) kicked.close();
|
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,8 +122,7 @@ export function createSessionManager(cfg: Config): SessionManager {
|
|||||||
// to the still-running shell instead of spawning a fresh one.
|
// to the still-running shell instead of spawning a fresh one.
|
||||||
if (cfg.useTmux && hasSession(tmuxName(sessionId))) {
|
if (cfg.useTmux && hasSession(tmuxName(sessionId))) {
|
||||||
const revived = createSession(cfg, dims, now, onSessionExit, sessionId);
|
const revived = createSession(cfg, dims, now, onSessionExit, sessionId);
|
||||||
const kickedRevived = attachWs(revived, ws);
|
attachWs(revived, ws, dims);
|
||||||
if (kickedRevived !== null) kickedRevived.close();
|
|
||||||
sessions = new Map(sessions).set(revived.meta.id, revived);
|
sessions = new Map(sessions).set(revived.meta.id, revived);
|
||||||
return revived;
|
return revived;
|
||||||
}
|
}
|
||||||
@@ -130,8 +130,7 @@ export function createSessionManager(cfg: Config): SessionManager {
|
|||||||
// ── Case 4: session not found → create a new one ─────────────────────────
|
// ── Case 4: session not found → create a new one ─────────────────────────
|
||||||
// M4: createSession may throw. Do NOT catch here.
|
// M4: createSession may throw. Do NOT catch here.
|
||||||
const session = createSession(cfg, dims, now, onSessionExit);
|
const session = createSession(cfg, dims, now, onSessionExit);
|
||||||
const kicked = attachWs(session, ws);
|
attachWs(session, ws, dims);
|
||||||
if (kicked !== null) kicked.close();
|
|
||||||
sessions = new Map(sessions).set(session.meta.id, session);
|
sessions = new Map(sessions).set(session.meta.id, session);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
@@ -140,6 +139,20 @@ export function createSessionManager(cfg: Config): SessionManager {
|
|||||||
return sessions.get(id);
|
return sessions.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Live sessions for multi-device discovery (newest first). */
|
||||||
|
function list(): LiveSessionInfo[] {
|
||||||
|
return [...sessions.values()]
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.meta.id,
|
||||||
|
createdAt: s.meta.createdAt,
|
||||||
|
clientCount: s.clients.size,
|
||||||
|
status: s.claudeStatus,
|
||||||
|
exited: s.exitedAt !== null,
|
||||||
|
cwd: s.cwd,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* H2: a Claude Code hook reported activity for `sessionId`. Record it and push
|
* H2: a Claude Code hook reported activity for `sessionId`. Record it and push
|
||||||
* a `status` frame to the attached ws (no-op if the session is gone/detached).
|
* a `status` frame to the attached ws (no-op if the session is gone/detached).
|
||||||
@@ -159,7 +172,7 @@ export function createSessionManager(cfg: Config): SessionManager {
|
|||||||
};
|
};
|
||||||
if (detail !== undefined) msg.detail = detail;
|
if (detail !== undefined) msg.detail = detail;
|
||||||
if (pending) msg.pending = true;
|
if (pending) msg.pending = true;
|
||||||
sendIfOpen(session.attachedWs, msg);
|
broadcast(session, msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,5 +223,5 @@ export function createSessionManager(cfg: Config): SessionManager {
|
|||||||
sessions = new Map();
|
sessions = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { handleAttach, get, handleHookEvent, reapIdle, shutdown };
|
return { handleAttach, get, list, handleHookEvent, reapIdle, shutdown };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,30 @@ function sendIfOpen(ws: WebSocketLike | null, msg: ServerMessage): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Broadcast a server message to every attached client (multi-device sharing). */
|
||||||
|
export function broadcast(session: Session, msg: ServerMessage): void {
|
||||||
|
for (const ws of session.clients) sendIfOpen(ws, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize the PTY to the MIN cols/rows across all attached clients (tmux-style),
|
||||||
|
* so a small phone and a wide laptop sharing the session both see content
|
||||||
|
* without overflow. No-op after exit (L4), when no client is attached, or when
|
||||||
|
* the computed dims are unchanged (idempotent).
|
||||||
|
*/
|
||||||
|
function applyMinDims(session: Session): void {
|
||||||
|
if (session.exitedAt !== null || session.clientDims.size === 0) return;
|
||||||
|
let cols = Infinity;
|
||||||
|
let rows = Infinity;
|
||||||
|
for (const d of session.clientDims.values()) {
|
||||||
|
cols = Math.min(cols, d.cols);
|
||||||
|
rows = Math.min(rows, d.rows);
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
|
||||||
|
if (session.pty.cols === cols && session.pty.rows === rows) return;
|
||||||
|
session.pty.resize(cols, rows);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spawn a PTY and wire its data/exit streams. Throws on spawn failure (M4).
|
* Spawn a PTY and wire its data/exit streams. Throws on spawn failure (M4).
|
||||||
*
|
*
|
||||||
@@ -95,28 +119,30 @@ export function createSession(
|
|||||||
const session: Session = {
|
const session: Session = {
|
||||||
meta,
|
meta,
|
||||||
buffer: createRingBuffer(cfg.scrollbackBytes),
|
buffer: createRingBuffer(cfg.scrollbackBytes),
|
||||||
attachedWs: null,
|
clients: new Set(),
|
||||||
|
clientDims: new Map(),
|
||||||
detachedAt: null,
|
detachedAt: null,
|
||||||
lastOutputAt: now,
|
lastOutputAt: now,
|
||||||
exitedAt: null,
|
exitedAt: null,
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
claudeStatus: 'unknown',
|
claudeStatus: 'unknown',
|
||||||
|
cwd: cwd ?? null,
|
||||||
tmuxName: tName,
|
tmuxName: tName,
|
||||||
pty,
|
pty,
|
||||||
};
|
};
|
||||||
|
|
||||||
// onData: persist to scrollback, refresh liveness, forward to the live ws.
|
// onData: persist to scrollback, refresh liveness, broadcast to all clients.
|
||||||
pty.onData((chunk) => {
|
pty.onData((chunk) => {
|
||||||
session.buffer.append(chunk);
|
session.buffer.append(chunk);
|
||||||
session.lastOutputAt = Date.now();
|
session.lastOutputAt = Date.now();
|
||||||
sendIfOpen(session.attachedWs, { type: 'output', data: chunk });
|
broadcast(session, { type: 'output', data: chunk });
|
||||||
});
|
});
|
||||||
|
|
||||||
// onExit: record exit, notify the attached ws (if any, L1), then bubble up.
|
// onExit: record exit, notify every client (L1 if none), then bubble up.
|
||||||
pty.onExit(({ exitCode }) => {
|
pty.onExit(({ exitCode }) => {
|
||||||
session.exitedAt = Date.now();
|
session.exitedAt = Date.now();
|
||||||
session.exitCode = exitCode;
|
session.exitCode = exitCode;
|
||||||
sendIfOpen(session.attachedWs, { type: 'exit', code: exitCode });
|
broadcast(session, { type: 'exit', code: exitCode });
|
||||||
onExit(session);
|
onExit(session);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,27 +150,34 @@ export function createSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bind `ws` to the session: swap the pointer to the new ws FIRST (so live
|
* Add `ws` as a client (multi-device sharing): register its dims, clear the
|
||||||
* forwarding never targets a kicked socket), replay the scrollback, then let
|
* detached stamp, re-derive the shared PTY size, then replay the scrollback to
|
||||||
* the real-time stream continue. Returns the kicked old ws (caller closes it).
|
* THIS client only so it sees the current screen. Other clients are untouched.
|
||||||
|
* No kicking — devices share the session (invariant #5 relaxed for v0.4).
|
||||||
*/
|
*/
|
||||||
export function attachWs(session: Session, ws: WebSocketLike): WebSocketLike | null {
|
export function attachWs(session: Session, ws: WebSocketLike, dims: Dims): void {
|
||||||
const previous = session.attachedWs;
|
session.clients.add(ws);
|
||||||
|
session.clientDims.set(ws, dims);
|
||||||
// Pointer first: later attach wins; forwarding henceforth only reaches `ws`.
|
|
||||||
session.attachedWs = ws;
|
|
||||||
session.detachedAt = null;
|
session.detachedAt = null;
|
||||||
|
applyMinDims(session);
|
||||||
|
|
||||||
// Replay the buffered scrollback so the reconnecting client sees the last screen.
|
// Replay the buffered scrollback so the joining client sees the last screen.
|
||||||
sendIfOpen(ws, { type: 'output', data: session.buffer.snapshot() });
|
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 {
|
* Remove one client. NEVER kills the PTY (vibe-coding core). Only when the LAST
|
||||||
session.attachedWs = null;
|
* client leaves do we stamp detachedAt (starts the idle-reclaim clock); while
|
||||||
|
* other clients remain, re-derive the shared size (a leaver may let it grow).
|
||||||
|
*/
|
||||||
|
export function detachWs(session: Session, ws: WebSocketLike, now: number): void {
|
||||||
|
session.clients.delete(ws);
|
||||||
|
session.clientDims.delete(ws);
|
||||||
|
if (session.clients.size === 0) {
|
||||||
session.detachedAt = now;
|
session.detachedAt = now;
|
||||||
|
} else {
|
||||||
|
applyMinDims(session);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Forward input to the PTY. No-op once the PTY has exited (L4). */
|
/** Forward input to the PTY. No-op once the PTY has exited (L4). */
|
||||||
@@ -153,11 +186,19 @@ export function writeInput(session: Session, data: string): void {
|
|||||||
session.pty.write(data);
|
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 {
|
* Record one client's requested dims and resize the PTY to the new min across
|
||||||
|
* all clients (tmux-style). No-op after exit (L4); idempotent when unchanged.
|
||||||
|
*/
|
||||||
|
export function setClientDims(
|
||||||
|
session: Session,
|
||||||
|
ws: WebSocketLike,
|
||||||
|
cols: number,
|
||||||
|
rows: number,
|
||||||
|
): void {
|
||||||
if (session.exitedAt !== null) return;
|
if (session.exitedAt !== null) return;
|
||||||
if (session.pty.cols === cols && session.pty.rows === rows) return;
|
session.clientDims.set(ws, { cols, rows });
|
||||||
session.pty.resize(cols, rows);
|
applyMinDims(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
32
src/types.ts
32
src/types.ts
@@ -140,9 +140,15 @@ export interface SessionMeta {
|
|||||||
export interface Session {
|
export interface Session {
|
||||||
readonly meta: SessionMeta;
|
readonly meta: SessionMeta;
|
||||||
readonly buffer: RingBuffer;
|
readonly buffer: RingBuffer;
|
||||||
/** attached ws, or null = detached but PTY still alive (vibe-coding core). */
|
/** All currently-attached clients (multi-device mirror sharing). Empty set =
|
||||||
attachedWs: WebSocketLike | null;
|
* detached but PTY still alive (vibe-coding core). Output/exit/status are
|
||||||
detachedAt: number | null; // detach time
|
* broadcast to every client; any client can send input (shared control). */
|
||||||
|
readonly clients: Set<WebSocketLike>;
|
||||||
|
/** Per-client requested terminal dims; the PTY uses the MIN cols/rows across
|
||||||
|
* all clients (tmux-style) so every device sees content without overflow. */
|
||||||
|
readonly clientDims: Map<WebSocketLike, Dims>;
|
||||||
|
/** Time the LAST client left (clients became empty); null while ≥1 attached. */
|
||||||
|
detachedAt: number | null;
|
||||||
/** last pty.onData timestamp; reapIdle liveness proxy (M3). */
|
/** last pty.onData timestamp; reapIdle liveness proxy (M3). */
|
||||||
lastOutputAt: number;
|
lastOutputAt: number;
|
||||||
/** PTY exit time; null = alive. Once set: writeInput/resize are ignored (L4),
|
/** PTY exit time; null = alive. Once set: writeInput/resize are ignored (L4),
|
||||||
@@ -151,6 +157,8 @@ export interface Session {
|
|||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
/** Claude Code activity from hooks (H2); updated by manager.handleHookEvent. */
|
/** Claude Code activity from hooks (H2); updated by manager.handleHookEvent. */
|
||||||
claudeStatus: ClaudeStatus;
|
claudeStatus: ClaudeStatus;
|
||||||
|
/** Spawn directory, for the /live-sessions label; null when unknown. */
|
||||||
|
cwd: string | null;
|
||||||
/** tmux session name (H1) when running under tmux, else null. */
|
/** tmux session name (H1) when running under tmux, else null. */
|
||||||
readonly tmuxName: string | null;
|
readonly tmuxName: string | null;
|
||||||
readonly pty: IPty;
|
readonly pty: IPty;
|
||||||
@@ -159,14 +167,24 @@ export interface Session {
|
|||||||
// impl anchors [src/session/session.ts]:
|
// impl anchors [src/session/session.ts]:
|
||||||
// createSession(cfg: Config, dims: Dims, now: number, onExit: (s: Session) => void): Session
|
// createSession(cfg: Config, dims: Dims, now: number, onExit: (s: Session) => void): Session
|
||||||
// // spawn failure THROWS, not swallowed (M4)
|
// // spawn failure THROWS, not swallowed (M4)
|
||||||
// attachWs(session: Session, ws: WebSocketLike): WebSocketLike | null // returns kicked old ws
|
// attachWs(session: Session, ws: WebSocketLike, dims: Dims): void // adds a client, replays buffer (no kick)
|
||||||
// detachWs(session: Session, now: number): void // never kills PTY
|
// detachWs(session: Session, ws: WebSocketLike, now: number): void // removes one client; never kills PTY
|
||||||
// writeInput(session: Session, data: string): void // no-op after exit (L4)
|
// writeInput(session: Session, data: string): void // no-op after exit (L4)
|
||||||
// resize(session: Session, cols: number, rows: number): void // no-op after exit; idempotent
|
// setClientDims(session: Session, ws: WebSocketLike, cols, rows): void // PTY = min over clients (L4 no-op after exit)
|
||||||
// kill(session: Session): void
|
// kill(session: Session): void
|
||||||
|
|
||||||
/* ──────────────────────── manager (§3.5) ─────────────────────── */
|
/* ──────────────────────── manager (§3.5) ─────────────────────── */
|
||||||
|
|
||||||
|
/** A live (or just-exited) server session, for the /live-sessions discovery list. */
|
||||||
|
export interface LiveSessionInfo {
|
||||||
|
id: string;
|
||||||
|
createdAt: number;
|
||||||
|
clientCount: number; // how many devices are currently attached
|
||||||
|
status: ClaudeStatus;
|
||||||
|
exited: boolean;
|
||||||
|
cwd: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionManager {
|
export interface SessionManager {
|
||||||
handleAttach(
|
handleAttach(
|
||||||
ws: WebSocketLike,
|
ws: WebSocketLike,
|
||||||
@@ -176,6 +194,8 @@ export interface SessionManager {
|
|||||||
cwd?: string,
|
cwd?: string,
|
||||||
): Session;
|
): Session;
|
||||||
get(id: string): Session | undefined;
|
get(id: string): Session | undefined;
|
||||||
|
/** Live sessions for multi-device discovery (newest first). */
|
||||||
|
list(): LiveSessionInfo[];
|
||||||
/** Set a session's Claude status (from a hook) and push it to the attached ws (H2/H3). */
|
/** Set a session's Claude status (from a hook) and push it to the attached ws (H2/H3). */
|
||||||
handleHookEvent(
|
handleHookEvent(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ describe('handleAttach — null sessionId', () => {
|
|||||||
|
|
||||||
expect(session).toBeDefined();
|
expect(session).toBeDefined();
|
||||||
expect(session.meta.id).toBeTruthy();
|
expect(session.meta.id).toBeTruthy();
|
||||||
expect(session.attachedWs).toBe(ws);
|
expect(session.clients.has(ws)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stores the session so get() returns it', () => {
|
it('stores the session so get() returns it', () => {
|
||||||
@@ -114,7 +114,7 @@ describe('handleAttach — null sessionId', () => {
|
|||||||
|
|
||||||
// ── handleAttach: hit live session ────────────────────────────────────────────
|
// ── handleAttach: hit live session ────────────────────────────────────────────
|
||||||
describe('handleAttach — hit live session', () => {
|
describe('handleAttach — hit live session', () => {
|
||||||
it('attaches the new ws and returns the same session', () => {
|
it('JOINS the new ws and returns the same session (multi-device share)', () => {
|
||||||
const mgr = createSessionManager(CFG);
|
const mgr = createSessionManager(CFG);
|
||||||
const ws1 = createMockWs();
|
const ws1 = createMockWs();
|
||||||
const session = mgr.handleAttach(ws1, null, DIMS, 1_000);
|
const session = mgr.handleAttach(ws1, null, DIMS, 1_000);
|
||||||
@@ -124,10 +124,12 @@ describe('handleAttach — hit live session', () => {
|
|||||||
const session2 = mgr.handleAttach(ws2, id, DIMS, 2_000);
|
const session2 = mgr.handleAttach(ws2, id, DIMS, 2_000);
|
||||||
|
|
||||||
expect(session2).toBe(session);
|
expect(session2).toBe(session);
|
||||||
expect(session.attachedWs).toBe(ws2);
|
// Both clients share the session now.
|
||||||
|
expect(session.clients.has(ws1)).toBe(true);
|
||||||
|
expect(session.clients.has(ws2)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('kicks (closes) the previously attached ws', () => {
|
it('does NOT kick the previously attached ws (v0.4 sharing)', () => {
|
||||||
const mgr = createSessionManager(CFG);
|
const mgr = createSessionManager(CFG);
|
||||||
const ws1 = createMockWs();
|
const ws1 = createMockWs();
|
||||||
const session = mgr.handleAttach(ws1, null, DIMS, 1_000);
|
const session = mgr.handleAttach(ws1, null, DIMS, 1_000);
|
||||||
@@ -136,8 +138,24 @@ describe('handleAttach — hit live session', () => {
|
|||||||
const ws2 = createMockWs();
|
const ws2 = createMockWs();
|
||||||
mgr.handleAttach(ws2, id, DIMS, 2_000);
|
mgr.handleAttach(ws2, id, DIMS, 2_000);
|
||||||
|
|
||||||
// The old ws should have been closed by the manager.
|
// The old ws must stay open — devices mirror the session.
|
||||||
expect(ws1.closed).toBe(true);
|
expect(ws1.closed).toBe(false);
|
||||||
|
expect(session.clients.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('broadcasts live output to ALL joined clients', () => {
|
||||||
|
const mgr = createSessionManager(CFG);
|
||||||
|
const ws1 = createMockWs();
|
||||||
|
const session = mgr.handleAttach(ws1, null, DIMS, 1_000);
|
||||||
|
const ws2 = createMockWs();
|
||||||
|
mgr.handleAttach(ws2, session.meta.id, DIMS, 2_000);
|
||||||
|
ws1.sent.length = 0;
|
||||||
|
ws2.sent.length = 0;
|
||||||
|
|
||||||
|
(session.pty as MockIPty).emitData('to everyone');
|
||||||
|
|
||||||
|
expect(parseSent(ws1).some((m) => m.type === 'output' && m.data === 'to everyone')).toBe(true);
|
||||||
|
expect(parseSent(ws2).some((m) => m.type === 'output' && m.data === 'to everyone')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replays the buffer to the new ws', () => {
|
it('replays the buffer to the new ws', () => {
|
||||||
@@ -170,7 +188,7 @@ describe('handleAttach — hit already-exited session (L1)', () => {
|
|||||||
(session.pty as MockIPty).emitData('last output before exit');
|
(session.pty as MockIPty).emitData('last output before exit');
|
||||||
mgr.handleAttach(createMockWs(), id, DIMS, 2_000); // re-attach so we can detach
|
mgr.handleAttach(createMockWs(), id, DIMS, 2_000); // re-attach so we can detach
|
||||||
// Force detach by patching directly (simulates WS close event).
|
// Force detach by patching directly (simulates WS close event).
|
||||||
session.attachedWs = null;
|
session.clients.clear();
|
||||||
session.detachedAt = 3_000;
|
session.detachedAt = 3_000;
|
||||||
|
|
||||||
// Now PTY exits while detached — session preserved (L1).
|
// Now PTY exits while detached — session preserved (L1).
|
||||||
@@ -271,7 +289,7 @@ describe('handleAttach — hit already-exited session (L1)', () => {
|
|||||||
const id = session.meta.id;
|
const id = session.meta.id;
|
||||||
|
|
||||||
// Detach by simulating WS close.
|
// Detach by simulating WS close.
|
||||||
session.attachedWs = null;
|
session.clients.clear();
|
||||||
session.detachedAt = 2_000;
|
session.detachedAt = 2_000;
|
||||||
|
|
||||||
// PTY exits while detached.
|
// PTY exits while detached.
|
||||||
@@ -297,7 +315,7 @@ describe('handleAttach — hit already-exited session (L1)', () => {
|
|||||||
const id = session.meta.id;
|
const id = session.meta.id;
|
||||||
|
|
||||||
// Detach and exit.
|
// Detach and exit.
|
||||||
session.attachedWs = null;
|
session.clients.clear();
|
||||||
session.detachedAt = 2_000;
|
session.detachedAt = 2_000;
|
||||||
(session.pty as MockIPty).emitExit(0);
|
(session.pty as MockIPty).emitExit(0);
|
||||||
|
|
||||||
@@ -322,7 +340,7 @@ describe('handleAttach — session not found', () => {
|
|||||||
expect(session).toBeDefined();
|
expect(session).toBeDefined();
|
||||||
// The returned session has a NEW id (not the bogus one).
|
// The returned session has a NEW id (not the bogus one).
|
||||||
expect(session.meta.id).not.toBe(unknownId);
|
expect(session.meta.id).not.toBe(unknownId);
|
||||||
expect(session.attachedWs).toBe(ws);
|
expect(session.clients.has(ws)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('the newly created session is stored in the table', () => {
|
it('the newly created session is stored in the table', () => {
|
||||||
@@ -372,7 +390,7 @@ describe('reapIdle (M3)', () => {
|
|||||||
const id = session.meta.id;
|
const id = session.meta.id;
|
||||||
|
|
||||||
// Detach at t=2000.
|
// Detach at t=2000.
|
||||||
session.attachedWs = null;
|
session.clients.clear();
|
||||||
session.detachedAt = 2_000;
|
session.detachedAt = 2_000;
|
||||||
|
|
||||||
// Output arrives at t=5000 (AFTER detach) — refresh lastOutputAt.
|
// Output arrives at t=5000 (AFTER detach) — refresh lastOutputAt.
|
||||||
@@ -394,7 +412,7 @@ describe('reapIdle (M3)', () => {
|
|||||||
const id = session.meta.id;
|
const id = session.meta.id;
|
||||||
|
|
||||||
// Detach at t=2000; no output after detach.
|
// Detach at t=2000; no output after detach.
|
||||||
session.attachedWs = null;
|
session.clients.clear();
|
||||||
session.detachedAt = 2_000;
|
session.detachedAt = 2_000;
|
||||||
session.lastOutputAt = 1_500; // before detach → max = 2000
|
session.lastOutputAt = 1_500; // before detach → max = 2000
|
||||||
|
|
||||||
@@ -412,7 +430,7 @@ describe('reapIdle (M3)', () => {
|
|||||||
nextPty = createMockPty();
|
nextPty = createMockPty();
|
||||||
const wsA = createMockWs();
|
const wsA = createMockWs();
|
||||||
const sessionA = mgr.handleAttach(wsA, null, DIMS, 1_000);
|
const sessionA = mgr.handleAttach(wsA, null, DIMS, 1_000);
|
||||||
sessionA.attachedWs = null;
|
sessionA.clients.clear();
|
||||||
sessionA.detachedAt = 1_000;
|
sessionA.detachedAt = 1_000;
|
||||||
sessionA.lastOutputAt = 1_000;
|
sessionA.lastOutputAt = 1_000;
|
||||||
|
|
||||||
@@ -420,7 +438,7 @@ describe('reapIdle (M3)', () => {
|
|||||||
nextPty = createMockPty();
|
nextPty = createMockPty();
|
||||||
const wsB = createMockWs();
|
const wsB = createMockWs();
|
||||||
const sessionB = mgr.handleAttach(wsB, null, DIMS, 1_000);
|
const sessionB = mgr.handleAttach(wsB, null, DIMS, 1_000);
|
||||||
sessionB.attachedWs = null;
|
sessionB.clients.clear();
|
||||||
sessionB.detachedAt = 1_000;
|
sessionB.detachedAt = 1_000;
|
||||||
sessionB.lastOutputAt = 1_000;
|
sessionB.lastOutputAt = 1_000;
|
||||||
|
|
||||||
@@ -442,6 +460,31 @@ describe('reapIdle (M3)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── list (v0.4 multi-device discovery) ────────────────────────────────────────
|
||||||
|
describe('list', () => {
|
||||||
|
it('returns live sessions with client counts, newest first', () => {
|
||||||
|
const mgr = createSessionManager(CFG);
|
||||||
|
|
||||||
|
nextPty = createMockPty();
|
||||||
|
const a = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
|
||||||
|
nextPty = createMockPty();
|
||||||
|
const b = mgr.handleAttach(createMockWs(), null, DIMS, 2_000);
|
||||||
|
// a second device joins session b → clientCount 2
|
||||||
|
mgr.handleAttach(createMockWs(), b.meta.id, DIMS, 2_500);
|
||||||
|
|
||||||
|
const list = mgr.list();
|
||||||
|
expect(list.map((s) => s.id)).toEqual([b.meta.id, a.meta.id]); // newest first
|
||||||
|
expect(list.find((s) => s.id === b.meta.id)?.clientCount).toBe(2);
|
||||||
|
expect(list.find((s) => s.id === a.meta.id)?.clientCount).toBe(1);
|
||||||
|
expect(list.every((s) => s.exited === false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when there are no sessions', () => {
|
||||||
|
const mgr = createSessionManager(CFG);
|
||||||
|
expect(mgr.list()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── shutdown ──────────────────────────────────────────────────────────────────
|
// ── shutdown ──────────────────────────────────────────────────────────────────
|
||||||
describe('shutdown', () => {
|
describe('shutdown', () => {
|
||||||
it('kills all sessions and clears the table', () => {
|
it('kills all sessions and clears the table', () => {
|
||||||
@@ -488,7 +531,7 @@ describe('onExit removes session from table when ws attached at exit time (L2)',
|
|||||||
const id = session.meta.id;
|
const id = session.meta.id;
|
||||||
|
|
||||||
// Detach first.
|
// Detach first.
|
||||||
session.attachedWs = null;
|
session.clients.clear();
|
||||||
session.detachedAt = 2_000;
|
session.detachedAt = 2_000;
|
||||||
|
|
||||||
// Then PTY exits.
|
// Then PTY exits.
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* T12 tests — session/session.ts (single-session lifecycle).
|
* T12 tests — session/session.ts (PTY lifecycle + multi-client sharing).
|
||||||
*
|
*
|
||||||
* ARCHITECTURE §3.4 / §4.4 + the cross-validated fixes:
|
* ARCHITECTURE §3.4 / §4.4 + the cross-validated fixes:
|
||||||
* - M4: spawn failure THROWS, never swallowed.
|
* - M4: spawn failure THROWS, never swallowed.
|
||||||
* - M5: every ws.send is guarded by `ws.readyState === WS_OPEN`.
|
* - 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).
|
* - L1: detach-then-exit keeps the session (exit not delivered when no client attached).
|
||||||
* - L4: writeInput/resize are no-ops once the PTY has exited.
|
* - L4: writeInput/setClientDims are no-ops once the PTY has exited.
|
||||||
|
* - v0.4: a session may hold MANY clients (multi-device mirror). Output/exit are
|
||||||
|
* broadcast to all; the PTY size is the MIN cols/rows across clients.
|
||||||
*
|
*
|
||||||
* node-pty is mocked (`vi.mock`) so `spawn` returns a MockIPty — the sandbox blocks
|
* 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
|
* real posix_spawn, and the mock lets us drive onData/onExit deterministically.
|
||||||
* assert lifecycle without a real shell.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
@@ -19,8 +20,6 @@ import { WS_OPEN } from '../src/types.js';
|
|||||||
import { createMockPty, type MockIPty } from './helpers/mock-pty.js';
|
import { createMockPty, type MockIPty } from './helpers/mock-pty.js';
|
||||||
|
|
||||||
// ── node-pty mock ───────────────────────────────────────────────────────────
|
// ── 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 nextPty: MockIPty;
|
||||||
let spawnError: Error | null = null;
|
let spawnError: Error | null = null;
|
||||||
const spawnCalls: Array<{ file: string; args: string[] | string; options: unknown }> = [];
|
const spawnCalls: Array<{ file: string; args: string[] | string; options: unknown }> = [];
|
||||||
@@ -34,7 +33,7 @@ vi.mock('node-pty', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Imported AFTER vi.mock so the module under test binds to the mocked spawn.
|
// Imported AFTER vi.mock so the module under test binds to the mocked spawn.
|
||||||
const { createSession, attachWs, detachWs, writeInput, resize, kill } = await import(
|
const { createSession, attachWs, detachWs, writeInput, setClientDims, kill } = await import(
|
||||||
'../src/session/session.js'
|
'../src/session/session.js'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -105,7 +104,7 @@ describe('createSession', () => {
|
|||||||
expect(s.pty).toBe(nextPty);
|
expect(s.pty).toBe(nextPty);
|
||||||
expect(s.meta.shellPath).toBe(CFG.shellPath);
|
expect(s.meta.shellPath).toBe(CFG.shellPath);
|
||||||
expect(s.meta.createdAt).toBe(1_000);
|
expect(s.meta.createdAt).toBe(1_000);
|
||||||
expect(s.attachedWs).toBeNull();
|
expect(s.clients.size).toBe(0);
|
||||||
expect(s.exitedAt).toBeNull();
|
expect(s.exitedAt).toBeNull();
|
||||||
expect(s.exitCode).toBeNull();
|
expect(s.exitCode).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -118,7 +117,7 @@ describe('createSession', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── onData → buffer + forward ──────────────────────────────────────────────────
|
// ── onData → buffer + broadcast ────────────────────────────────────────────────
|
||||||
describe('onData', () => {
|
describe('onData', () => {
|
||||||
it('appends output to the ring buffer and refreshes lastOutputAt', () => {
|
it('appends output to the ring buffer and refreshes lastOutputAt', () => {
|
||||||
const s = newSession();
|
const s = newSession();
|
||||||
@@ -127,15 +126,14 @@ describe('onData', () => {
|
|||||||
pty.emitData('hello ');
|
pty.emitData('hello ');
|
||||||
pty.emitData('world');
|
pty.emitData('world');
|
||||||
|
|
||||||
// buffer snapshot prepends the M2 soft-reset, then the verbatim output.
|
|
||||||
expect(s.buffer.snapshot()).toBe('\x1b[0mhello world');
|
expect(s.buffer.snapshot()).toBe('\x1b[0mhello world');
|
||||||
expect(s.lastOutputAt).toBeGreaterThanOrEqual(1_000);
|
expect(s.lastOutputAt).toBeGreaterThanOrEqual(1_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forwards output to the attached ws as an `output` message', () => {
|
it('broadcasts output to the attached client as an `output` message', () => {
|
||||||
const s = newSession();
|
const s = newSession();
|
||||||
const ws = createMockWs();
|
const ws = createMockWs();
|
||||||
attachWs(s, ws);
|
attachWs(s, ws, DIMS);
|
||||||
ws.sent.length = 0; // drop the replay frame from attach
|
ws.sent.length = 0; // drop the replay frame from attach
|
||||||
|
|
||||||
(s.pty as MockIPty).emitData('abc');
|
(s.pty as MockIPty).emitData('abc');
|
||||||
@@ -143,16 +141,31 @@ describe('onData', () => {
|
|||||||
expect(received(ws)).toEqual([{ type: 'output', data: 'abc' }]);
|
expect(received(ws)).toEqual([{ type: 'output', data: 'abc' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still buffers output when no ws is attached (does not throw)', () => {
|
it('broadcasts output to EVERY client sharing the session (multi-device)', () => {
|
||||||
|
const s = newSession();
|
||||||
|
const a = createMockWs();
|
||||||
|
const b = createMockWs();
|
||||||
|
attachWs(s, a, DIMS);
|
||||||
|
attachWs(s, b, DIMS);
|
||||||
|
a.sent.length = 0;
|
||||||
|
b.sent.length = 0;
|
||||||
|
|
||||||
|
(s.pty as MockIPty).emitData('shared');
|
||||||
|
|
||||||
|
expect(received(a)).toEqual([{ type: 'output', data: 'shared' }]);
|
||||||
|
expect(received(b)).toEqual([{ type: 'output', data: 'shared' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still buffers output when no client is attached (does not throw)', () => {
|
||||||
const s = newSession();
|
const s = newSession();
|
||||||
expect(() => (s.pty as MockIPty).emitData('orphan')).not.toThrow();
|
expect(() => (s.pty as MockIPty).emitData('orphan')).not.toThrow();
|
||||||
expect(s.buffer.snapshot()).toContain('orphan');
|
expect(s.buffer.snapshot()).toContain('orphan');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does NOT send to a ws whose readyState is not OPEN (M5)', () => {
|
it('does NOT send to a client whose readyState is not OPEN (M5)', () => {
|
||||||
const s = newSession();
|
const s = newSession();
|
||||||
const ws = createMockWs(WS_OPEN);
|
const ws = createMockWs(WS_OPEN);
|
||||||
attachWs(s, ws);
|
attachWs(s, ws, DIMS);
|
||||||
ws.sent.length = 0;
|
ws.sent.length = 0;
|
||||||
|
|
||||||
ws.readyState = 3; // CLOSED
|
ws.readyState = 3; // CLOSED
|
||||||
@@ -162,60 +175,97 @@ describe('onData', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── attachWs ──────────────────────────────────────────────────────────────────
|
// ── attachWs (join, no kick) ──────────────────────────────────────────────────
|
||||||
describe('attachWs', () => {
|
describe('attachWs', () => {
|
||||||
it('replays the buffer snapshot to the newly attached ws', () => {
|
it('replays the buffer snapshot to the newly attached client', () => {
|
||||||
const s = newSession();
|
const s = newSession();
|
||||||
(s.pty as MockIPty).emitData('prior output');
|
(s.pty as MockIPty).emitData('prior output');
|
||||||
|
|
||||||
const ws = createMockWs();
|
const ws = createMockWs();
|
||||||
const kicked = attachWs(s, ws);
|
attachWs(s, ws, DIMS);
|
||||||
|
|
||||||
expect(kicked).toBeNull();
|
expect(s.clients.has(ws)).toBe(true);
|
||||||
expect(s.attachedWs).toBe(ws);
|
|
||||||
expect(received(ws)).toEqual([{ type: 'output', data: '\x1b[0mprior output' }]);
|
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)', () => {
|
it('a second attach JOINS (does not kick) — both clients stay connected', () => {
|
||||||
const s = newSession();
|
const s = newSession();
|
||||||
const oldWs = createMockWs();
|
const a = createMockWs();
|
||||||
attachWs(s, oldWs);
|
attachWs(s, a, DIMS);
|
||||||
|
|
||||||
const newWs = createMockWs();
|
const b = createMockWs();
|
||||||
const kicked = attachWs(s, newWs);
|
attachWs(s, b, DIMS);
|
||||||
|
|
||||||
// pointer is swapped first; caller is responsible for closing the kicked ws.
|
// Both present; the first was NOT closed.
|
||||||
expect(s.attachedWs).toBe(newWs);
|
expect(s.clients.has(a)).toBe(true);
|
||||||
expect(kicked).toBe(oldWs);
|
expect(s.clients.has(b)).toBe(true);
|
||||||
expect(oldWs.closed).toBe(false); // attachWs must NOT close it itself
|
expect(a.closed).toBe(false);
|
||||||
|
|
||||||
// forwarding now only ever reaches the new ws.
|
// Only the joining client got the replay; live output reaches both.
|
||||||
newWs.sent.length = 0;
|
a.sent.length = 0;
|
||||||
(s.pty as MockIPty).emitData('after kick');
|
b.sent.length = 0;
|
||||||
expect(received(newWs)).toEqual([{ type: 'output', data: 'after kick' }]);
|
(s.pty as MockIPty).emitData('live');
|
||||||
expect(oldWs.sent.some((f) => f.includes('after kick'))).toBe(false);
|
expect(received(a)).toEqual([{ type: 'output', data: 'live' }]);
|
||||||
|
expect(received(b)).toEqual([{ type: 'output', data: 'live' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resizes the PTY to the MIN dims across all clients (tmux-style)', () => {
|
||||||
|
const s = newSession(); // spawn dims 80x24
|
||||||
|
const wide = createMockWs();
|
||||||
|
const narrow = createMockWs();
|
||||||
|
|
||||||
|
attachWs(s, wide, { cols: 200, rows: 50 });
|
||||||
|
// one client at 200x50 → PTY grows to 200x50
|
||||||
|
expect(s.pty.cols).toBe(200);
|
||||||
|
expect(s.pty.rows).toBe(50);
|
||||||
|
|
||||||
|
attachWs(s, narrow, { cols: 90, rows: 30 });
|
||||||
|
// min(200,90)=90, min(50,30)=30
|
||||||
|
expect(s.pty.cols).toBe(90);
|
||||||
|
expect(s.pty.rows).toBe(30);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── detachWs ──────────────────────────────────────────────────────────────────
|
// ── detachWs (remove one client) ──────────────────────────────────────────────
|
||||||
describe('detachWs', () => {
|
describe('detachWs', () => {
|
||||||
it('clears attachedWs and stamps detachedAt but NEVER kills the PTY', () => {
|
it('removes the client and stamps detachedAt only when the LAST one leaves', () => {
|
||||||
const s = newSession();
|
const s = newSession();
|
||||||
const ws = createMockWs();
|
const a = createMockWs();
|
||||||
attachWs(s, ws);
|
const b = createMockWs();
|
||||||
|
attachWs(s, a, DIMS);
|
||||||
|
attachWs(s, b, DIMS);
|
||||||
|
|
||||||
detachWs(s, 5_000);
|
detachWs(s, a, 5_000);
|
||||||
|
// one client remains → still "attached", no detach stamp
|
||||||
|
expect(s.clients.has(a)).toBe(false);
|
||||||
|
expect(s.clients.has(b)).toBe(true);
|
||||||
|
expect(s.detachedAt).toBeNull();
|
||||||
|
|
||||||
expect(s.attachedWs).toBeNull();
|
detachWs(s, b, 6_000);
|
||||||
expect(s.detachedAt).toBe(5_000);
|
// last client gone → detached
|
||||||
|
expect(s.clients.size).toBe(0);
|
||||||
|
expect(s.detachedAt).toBe(6_000);
|
||||||
expect((s.pty as MockIPty).killed).toBe(false);
|
expect((s.pty as MockIPty).killed).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the PTY alive after detach: further onData still fills the buffer', () => {
|
it('lets the PTY grow back when a small client leaves', () => {
|
||||||
|
const s = newSession();
|
||||||
|
const wide = createMockWs();
|
||||||
|
const narrow = createMockWs();
|
||||||
|
attachWs(s, wide, { cols: 200, rows: 50 });
|
||||||
|
attachWs(s, narrow, { cols: 90, rows: 30 }); // clamps to 90x30
|
||||||
|
|
||||||
|
detachWs(s, narrow, 5_000);
|
||||||
|
// only the wide client remains → PTY expands back to 200x50
|
||||||
|
expect(s.pty.cols).toBe(200);
|
||||||
|
expect(s.pty.rows).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the PTY alive after the last detach: further onData still fills the buffer', () => {
|
||||||
const s = newSession();
|
const s = newSession();
|
||||||
const ws = createMockWs();
|
const ws = createMockWs();
|
||||||
attachWs(s, ws);
|
attachWs(s, ws, DIMS);
|
||||||
detachWs(s, 5_000);
|
detachWs(s, ws, 5_000);
|
||||||
|
|
||||||
(s.pty as MockIPty).emitData('background work');
|
(s.pty as MockIPty).emitData('background work');
|
||||||
|
|
||||||
@@ -226,48 +276,50 @@ describe('detachWs', () => {
|
|||||||
|
|
||||||
// ── onExit ────────────────────────────────────────────────────────────────────
|
// ── onExit ────────────────────────────────────────────────────────────────────
|
||||||
describe('onExit', () => {
|
describe('onExit', () => {
|
||||||
it('sets exitedAt/exitCode, sends `exit` to the attached ws, and calls injected onExit', () => {
|
it('sets exitedAt/exitCode, broadcasts `exit` to all clients, and calls injected onExit', () => {
|
||||||
const onExit = vi.fn();
|
const onExit = vi.fn();
|
||||||
nextPty = createMockPty();
|
nextPty = createMockPty();
|
||||||
const s = createSession(CFG, DIMS, 1_000, onExit);
|
const s = createSession(CFG, DIMS, 1_000, onExit);
|
||||||
|
|
||||||
const ws = createMockWs();
|
const a = createMockWs();
|
||||||
attachWs(s, ws);
|
const b = createMockWs();
|
||||||
ws.sent.length = 0;
|
attachWs(s, a, DIMS);
|
||||||
|
attachWs(s, b, DIMS);
|
||||||
|
a.sent.length = 0;
|
||||||
|
b.sent.length = 0;
|
||||||
|
|
||||||
(s.pty as MockIPty).emitExit(0);
|
(s.pty as MockIPty).emitExit(0);
|
||||||
|
|
||||||
expect(s.exitedAt).not.toBeNull();
|
expect(s.exitedAt).not.toBeNull();
|
||||||
expect(s.exitCode).toBe(0);
|
expect(s.exitCode).toBe(0);
|
||||||
expect(received(ws)).toEqual([{ type: 'exit', code: 0 }]);
|
expect(received(a)).toEqual([{ type: 'exit', code: 0 }]);
|
||||||
|
expect(received(b)).toEqual([{ type: 'exit', code: 0 }]);
|
||||||
expect(onExit).toHaveBeenCalledWith(s);
|
expect(onExit).toHaveBeenCalledWith(s);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('after detach, exit is NOT delivered but the session is preserved (L1)', () => {
|
it('after the last detach, exit is NOT delivered but the session is preserved (L1)', () => {
|
||||||
const onExit = vi.fn();
|
const onExit = vi.fn();
|
||||||
nextPty = createMockPty();
|
nextPty = createMockPty();
|
||||||
const s = createSession(CFG, DIMS, 1_000, onExit);
|
const s = createSession(CFG, DIMS, 1_000, onExit);
|
||||||
|
|
||||||
const ws = createMockWs();
|
const ws = createMockWs();
|
||||||
attachWs(s, ws);
|
attachWs(s, ws, DIMS);
|
||||||
detachWs(s, 5_000);
|
detachWs(s, ws, 5_000);
|
||||||
ws.sent.length = 0;
|
ws.sent.length = 0;
|
||||||
|
|
||||||
(s.pty as MockIPty).emitExit(137);
|
(s.pty as MockIPty).emitExit(137);
|
||||||
|
|
||||||
// exit happened: state recorded, but no ws to notify → nothing delivered.
|
|
||||||
expect(s.exitedAt).not.toBeNull();
|
expect(s.exitedAt).not.toBeNull();
|
||||||
expect(s.exitCode).toBe(137);
|
expect(s.exitCode).toBe(137);
|
||||||
expect(ws.sent).toHaveLength(0);
|
expect(ws.sent).toHaveLength(0);
|
||||||
// session preserved: buffer intact (caller/manager decides removal via onExit).
|
|
||||||
expect(onExit).toHaveBeenCalledWith(s);
|
expect(onExit).toHaveBeenCalledWith(s);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not send exit to a ws whose readyState is not OPEN (M5)', () => {
|
it('does not send exit to a client whose readyState is not OPEN (M5)', () => {
|
||||||
nextPty = createMockPty();
|
nextPty = createMockPty();
|
||||||
const s = createSession(CFG, DIMS, 1_000, () => {});
|
const s = createSession(CFG, DIMS, 1_000, () => {});
|
||||||
const ws = createMockWs(WS_OPEN);
|
const ws = createMockWs(WS_OPEN);
|
||||||
attachWs(s, ws);
|
attachWs(s, ws, DIMS);
|
||||||
ws.sent.length = 0;
|
ws.sent.length = 0;
|
||||||
ws.readyState = 3; // CLOSED
|
ws.readyState = 3; // CLOSED
|
||||||
|
|
||||||
@@ -277,27 +329,30 @@ describe('onExit', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── writeInput / resize after exit (L4) + resize idempotence ──────────────────
|
// ── writeInput / setClientDims after exit (L4) + dims idempotence ──────────────
|
||||||
describe('writeInput / resize', () => {
|
describe('writeInput / setClientDims', () => {
|
||||||
it('writeInput forwards to pty.write while alive', () => {
|
it('writeInput forwards to pty.write while alive', () => {
|
||||||
const s = newSession();
|
const s = newSession();
|
||||||
writeInput(s, 'ls\r');
|
writeInput(s, 'ls\r');
|
||||||
expect((s.pty as MockIPty).writes).toEqual(['ls\r']);
|
expect((s.pty as MockIPty).writes).toEqual(['ls\r']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resize forwards to pty.resize while alive', () => {
|
it('setClientDims forwards to pty.resize while alive', () => {
|
||||||
const s = newSession();
|
const s = newSession();
|
||||||
resize(s, 100, 40);
|
const ws = createMockWs();
|
||||||
expect((s.pty as MockIPty).resizes).toEqual([{ cols: 100, rows: 40 }]);
|
attachWs(s, ws, DIMS);
|
||||||
|
setClientDims(s, ws, 100, 40);
|
||||||
|
expect((s.pty as MockIPty).resizes).toContainEqual({ cols: 100, rows: 40 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resize is idempotent: same cols/rows are skipped', () => {
|
it('setClientDims is idempotent: same min cols/rows are skipped', () => {
|
||||||
const s = newSession();
|
const s = newSession();
|
||||||
resize(s, 80, 24); // equal to the spawn dims → no-op
|
const ws = createMockWs();
|
||||||
|
attachWs(s, ws, DIMS); // 80x24, equals spawn dims → no resize yet
|
||||||
expect((s.pty as MockIPty).resizes).toHaveLength(0);
|
expect((s.pty as MockIPty).resizes).toHaveLength(0);
|
||||||
|
|
||||||
resize(s, 90, 24); // changed → applied
|
setClientDims(s, ws, 90, 24); // changed → applied
|
||||||
resize(s, 90, 24); // unchanged again → skipped
|
setClientDims(s, ws, 90, 24); // unchanged again → skipped
|
||||||
expect((s.pty as MockIPty).resizes).toEqual([{ cols: 90, rows: 24 }]);
|
expect((s.pty as MockIPty).resizes).toEqual([{ cols: 90, rows: 24 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,11 +364,13 @@ describe('writeInput / resize', () => {
|
|||||||
expect((s.pty as MockIPty).writes).toHaveLength(0);
|
expect((s.pty as MockIPty).writes).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores resize once the PTY has exited (L4)', () => {
|
it('ignores setClientDims once the PTY has exited (L4)', () => {
|
||||||
const s = newSession();
|
const s = newSession();
|
||||||
|
const ws = createMockWs();
|
||||||
|
attachWs(s, ws, DIMS);
|
||||||
(s.pty as MockIPty).emitExit(0);
|
(s.pty as MockIPty).emitExit(0);
|
||||||
|
|
||||||
resize(s, 200, 50);
|
setClientDims(s, ws, 200, 50);
|
||||||
expect((s.pty as MockIPty).resizes).toHaveLength(0);
|
expect((s.pty as MockIPty).resizes).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user