Files
arboretum/packages/shared/src/protocol.ts
Johan LEROY e9404cb567 P1 complete: web front, test suite, CI — acceptance ALL GREEN
Fan-out integration + fixes found by the test/acceptance pass:
- FIX ring-buffer: chunks >= capacity skipped bytes now count into the
  monotonic offset (invariant: stream byte k lives at k % capacity) —
  window order was corrupted on unaligned big chunks
- FIX auth: non-numeric cookie expiry no longer bypasses expiration
- FIX protocol: safe-integer validation on ack.bytes / hello.protocol
- FIX @fastify/websocket v11: websocket route must be registered in an
  encapsulated context after plugin load (handler got REST signature)
- FIX flow-control deadlock found by e2e acceptance: client only ACKs
  on data receipt, so pausing with an unACKed residue in (LOW,
  ACK_EVERY] stalled both sides at 0.9 MB. ACK_EVERY now 64 KiB (<=
  LOW invariant, tested) + trailing debounced ACK in the web client
- Web: Vue 3 + Vite + Pinia + Tailwind 4 + vue-i18n (EN/FR) + xterm 6
  (fit + webgl fallback), multiplexed ws-client with reconnect/backoff
  and resync epochs
- Tests: 100 vitest (protocol fuzz, ring edges, auth, pty-manager flow
  control with mocked pty, REST e2e) ; CI Node 22/24 + pack-smoke
- scripts/acceptance-p1.mjs: real daemon + real WS client — boot,
  login, attach, stdin, 10 MB flood w/ ACK (13.7 MB/1.9s, RSS bounded),
  brutal disconnect + replay resync, kill broadcast, SIGTERM drain
2026-06-11 22:29:58 +02:00

150 lines
6.0 KiB
TypeScript

// Protocole WebSocket Arboretum — une connexion multiplexée par client.
// Messages de contrôle : frames TEXTE JSON. Sortie terminal : frames BINAIRES
// (un chunk PTY peut couper un caractère UTF-8 en frontière de frame ; le
// décodage incombe à xterm.write(Uint8Array) côté client, jamais au transport).
export const PROTOCOL_VERSION = 1;
// ---- Frames binaires ----
// Layout : [type u8][channel u32le][payload...]
export const BINARY_FRAME = {
HEADER_BYTES: 5,
/** serveur → client : sortie terminal */
OUTPUT: 0x01,
/** serveur → client : resync — le client doit reset son terminal avant d'écrire le payload */
RESYNC: 0x02,
} as const;
export function encodeBinaryFrame(type: number, channel: number, payload: Uint8Array): Uint8Array {
const frame = new Uint8Array(BINARY_FRAME.HEADER_BYTES + payload.byteLength);
const view = new DataView(frame.buffer);
view.setUint8(0, type);
view.setUint32(1, channel, true);
frame.set(payload, BINARY_FRAME.HEADER_BYTES);
return frame;
}
export function decodeBinaryFrame(data: Uint8Array): { type: number; channel: number; payload: Uint8Array } {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
return {
type: view.getUint8(0),
channel: view.getUint32(1, true),
payload: data.subarray(BINARY_FRAME.HEADER_BYTES),
};
}
// ---- Flow control (pattern officiel xterm.js, watermarks du design) ----
// INVARIANT anti-deadlock : ACK_EVERY_BYTES <= LOW_WATERMARK. Le client n'ACK
// qu'à réception de données ; si le serveur pouvait se mettre en pause avec un
// reliquat non-ACKé > LOW mais < pas d'ACK, plus aucun des deux ne progresserait.
// Avec ACK_EVERY <= LOW, une fois tout le flux en vol traité, le reliquat est
// < LOW et le serveur reprend toujours. (Complété côté client par un ACK
// traînant débouncé.)
export const FLOW = {
/** le client ACK tous les N octets réellement traités par xterm.write */
ACK_EVERY_BYTES: 64 * 1024,
/** pause du PTY quand TOUS les clients interactifs dépassent HIGH */
HIGH_WATERMARK: 384 * 1024,
/** reprise quand le min repasse sous LOW */
LOW_WATERMARK: 128 * 1024,
/** au-delà : client marqué lagging, flux coupé, resync au rattrapage */
LAGGING_BYTES: 2 * 1024 * 1024,
} as const;
/** Replay à l'attach : reset terminal + queue du ring (l'écran TUI se repeint en continu) */
export const REPLAY_TAIL_BYTES = 256 * 1024;
// ---- États de session (sous-ensemble P1 ; étendu en P3) ----
export type SessionRuntimeStatus =
| 'starting'
| 'running' // P1 : pas encore de distinction busy/waiting/idle (P3 via claude-adapter)
| 'exited';
export interface SessionSummary {
id: string;
cwd: string;
command: string;
title: string | null;
status: SessionRuntimeStatus;
live: boolean;
createdAt: string;
endedAt: string | null;
exitCode: number | null;
clients: number;
}
// ---- Messages client → serveur ----
export type ClientMessage =
| { type: 'hello'; protocol: number }
| { type: 'attach'; sessionId: string; mode: 'interactive' | 'observer'; cols: number; rows: number }
| { type: 'detach'; channel: number }
| { type: 'stdin'; channel: number; data: string }
| { type: 'resize'; channel: number; cols: number; rows: number }
| { type: 'ack'; channel: number; bytes: number }
| { type: 'sub'; topics: Array<'sessions'> }
| { type: 'ping' };
// ---- Messages serveur → client ----
export type ServerMessage =
| { type: 'hello_ok'; protocol: number; serverVersion: string }
| { type: 'attached'; channel: number; sessionId: string; mode: 'interactive' | 'observer'; controlling: boolean }
| { type: 'detached'; channel: number; reason: 'client' | 'session_exit' | 'replaced' }
| { type: 'control_changed'; channel: number; controlling: boolean }
| { type: 'session_update'; session: SessionSummary }
| { type: 'session_exit'; sessionId: string; exitCode: number | null; signal: number | null }
| { type: 'error'; code: ErrorCode; message: string; channel?: number }
| { type: 'pong' };
export type ErrorCode =
| 'BAD_PROTOCOL'
| 'BAD_MESSAGE'
| 'NOT_FOUND'
| 'NOT_ATTACHED'
| 'NOT_CONTROLLING'
| 'SESSION_EXITED'
| 'INTERNAL';
export function parseClientMessage(raw: string): ClientMessage | null {
let obj: unknown;
try {
obj = JSON.parse(raw);
} catch {
return null;
}
if (typeof obj !== 'object' || obj === null || typeof (obj as { type?: unknown }).type !== 'string') return null;
const m = obj as Record<string, unknown>;
const isU32 = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 && v <= 0xffffffff;
const isCount = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
const isDim = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 2 && v <= 1000;
switch (m.type) {
case 'hello':
return isCount(m.protocol) ? { type: 'hello', protocol: m.protocol } : null;
case 'attach':
return typeof m.sessionId === 'string' && (m.mode === 'interactive' || m.mode === 'observer') && isDim(m.cols) && isDim(m.rows)
? { type: 'attach', sessionId: m.sessionId, mode: m.mode, cols: m.cols, rows: m.rows }
: null;
case 'detach':
return isU32(m.channel) ? { type: 'detach', channel: m.channel } : null;
case 'stdin':
return isU32(m.channel) && typeof m.data === 'string' && m.data.length <= 65536
? { type: 'stdin', channel: m.channel, data: m.data }
: null;
case 'resize':
return isU32(m.channel) && isDim(m.cols) && isDim(m.rows)
? { type: 'resize', channel: m.channel, cols: m.cols, rows: m.rows }
: null;
case 'ack':
return isU32(m.channel) && isCount(m.bytes)
? { type: 'ack', channel: m.channel, bytes: m.bytes }
: null;
case 'sub':
return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions')
? { type: 'sub', topics: m.topics as Array<'sessions'> }
: null;
case 'ping':
return { type: 'ping' };
default:
return null;
}
}