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 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,9 +34,15 @@ export function decodeBinaryFrame(data: Uint8Array): { type: number; channel: nu
|
||||
}
|
||||
|
||||
// ---- 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: 256 * 1024,
|
||||
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 */
|
||||
@@ -107,11 +113,12 @@ export function parseClientMessage(raw: string): ClientMessage | 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.isInteger(v) && v >= 0 && v <= 0xffffffff;
|
||||
const isDim = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 2 && v <= 1000;
|
||||
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 typeof m.protocol === 'number' ? { type: 'hello', protocol: m.protocol } : null;
|
||||
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 }
|
||||
@@ -127,7 +134,7 @@ export function parseClientMessage(raw: string): ClientMessage | null {
|
||||
? { type: 'resize', channel: m.channel, cols: m.cols, rows: m.rows }
|
||||
: null;
|
||||
case 'ack':
|
||||
return isU32(m.channel) && typeof m.bytes === 'number' && m.bytes >= 0
|
||||
return isU32(m.channel) && isCount(m.bytes)
|
||||
? { type: 'ack', channel: m.channel, bytes: m.bytes }
|
||||
: null;
|
||||
case 'sub':
|
||||
|
||||
Reference in New Issue
Block a user