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:
2026-06-11 22:29:58 +02:00
parent 3a1396036e
commit 8bc48448c2
42 changed files with 4624 additions and 32 deletions

View File

@@ -0,0 +1,416 @@
import { tmpdir } from 'node:os';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
import { PtyManager, type ClientBinding } from '../src/core/pty-manager.js';
import { openDb, type Db } from '../src/db/index.js';
interface FakePty {
pid: number;
file: string;
args: string[];
opts: { cwd: string; cols: number; rows: number };
write: Mock;
resize: Mock;
pause: Mock;
resume: Mock;
emitData(data: string): void;
emitExit(exitCode: number, signal?: number): void;
}
const ptyMock = vi.hoisted(() => ({ instances: [] as unknown[] }));
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
let nextPid = 100_000;
class FakePtyImpl {
pid = nextPid++;
write = vi.fn();
resize = vi.fn();
pause = vi.fn();
resume = vi.fn();
kill = vi.fn();
private dataCbs: Array<(d: string) => void> = [];
private exitCbs: Array<(e: { exitCode: number; signal?: number }) => void> = [];
constructor(
readonly file: string,
readonly args: string[],
readonly opts: unknown,
) {}
onData(cb: (d: string) => void): { dispose: () => void } {
this.dataCbs.push(cb);
return { dispose: () => {} };
}
onExit(cb: (e: { exitCode: number; signal?: number }) => void): { dispose: () => void } {
this.exitCbs.push(cb);
return { dispose: () => {} };
}
emitData(d: string): void {
for (const cb of this.dataCbs) cb(d);
}
emitExit(exitCode: number, signal?: number): void {
for (const cb of this.exitCbs) cb(signal === undefined ? { exitCode } : { exitCode, signal });
}
}
return {
default: {
spawn: (file: string, args: string[], opts: unknown): FakePtyImpl => {
const p = new FakePtyImpl(file, args, opts);
ptyMock.instances.push(p);
return p;
},
},
};
});
type BindingSpies = ClientBinding & {
sendOutput: Mock<(payload: Buffer) => void>;
sendResync: Mock<(payload: Buffer) => void>;
onDetached: Mock<(reason: 'session_exit' | 'replaced') => void>;
onControlChanged: Mock<(controlling: boolean) => void>;
};
let channelSeq = 1;
function makeBinding(mode: 'interactive' | 'observer'): BindingSpies {
return {
channel: channelSeq++,
mode,
controlling: false,
sentBytes: 0,
ackedBytes: 0,
lagging: false,
sendOutput: vi.fn(),
sendResync: vi.fn(),
onDetached: vi.fn(),
onControlChanged: vi.fn(),
};
}
function lastPty(): FakePty {
return ptyMock.instances.at(-1) as FakePty;
}
describe('PtyManager (pty mocké)', () => {
let db: Db;
let manager: PtyManager;
const cwd = tmpdir();
beforeEach(() => {
ptyMock.instances.length = 0;
db = openDb(':memory:');
manager = new PtyManager(db);
});
function spawnBash(): { summary: SessionSummary; pty: FakePty } {
const summary = manager.spawn({ cwd, command: 'bash' });
return { summary, pty: lastPty() };
}
describe('spawn', () => {
it('émet session_update, insère en base et lance le pty avec le bon spec', () => {
const updates: SessionSummary[] = [];
manager.on('session_update', (s) => updates.push(s));
const { summary, pty } = spawnBash();
expect(updates).toHaveLength(1);
expect(updates[0]).toEqual(summary);
expect(summary.status).toBe('running');
expect(summary.live).toBe(true);
expect(summary.clients).toBe(0);
expect(summary.command).toBe('bash');
expect(pty.file).toBe('bash');
expect(pty.args).toEqual(['--norc']);
expect(pty.opts.cwd).toBe(cwd);
expect(pty.opts.cols).toBe(120);
expect(pty.opts.rows).toBe(32);
const row = db.prepare('SELECT cwd, command FROM sessions WHERE id = ?').get(summary.id) as { cwd: string; command: string };
expect(row).toEqual({ cwd, command: 'bash' });
expect(manager.get(summary.id)?.id).toBe(summary.id);
});
it('cwd inexistant → erreur statusCode 400, rien en base', () => {
expect(() => manager.spawn({ cwd: '/definitely/not/a/dir', command: 'bash' })).toThrowError(
expect.objectContaining({ statusCode: 400 }),
);
const row = db.prepare('SELECT COUNT(*) AS n FROM sessions').get() as { n: number };
expect(row.n).toBe(0);
});
});
describe('attach', () => {
it('session inconnue → NOT_FOUND', () => {
const res = manager.attach('nope', makeBinding('interactive'), 80, 24);
expect(res).toEqual({ ok: false, code: 'NOT_FOUND' });
});
it('resync à lattach = queue du ring (REPLAY_TAIL_BYTES max)', () => {
const { summary, pty } = spawnBash();
const chunks = ['A', 'B', 'C'].map((c) => c.repeat(100 * 1024));
for (const c of chunks) pty.emitData(c);
const b = makeBinding('interactive');
const res = manager.attach(summary.id, b, 80, 24);
expect(res).toEqual({ ok: true, controlling: true });
expect(b.sendResync).toHaveBeenCalledTimes(1);
const payload = b.sendResync.mock.calls[0]![0]!;
const full = Buffer.from(chunks.join(''), 'ascii');
expect(payload.length).toBe(REPLAY_TAIL_BYTES);
expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
// compteurs de flow control remis à zéro après le resync initial
expect(b.sentBytes).toBe(0);
expect(b.ackedBytes).toBe(0);
});
it('ring vide → resync avec payload vide', () => {
const { summary } = spawnBash();
const b = makeBinding('interactive');
manager.attach(summary.id, b, 80, 24);
expect(b.sendResync).toHaveBeenCalledTimes(1);
expect(b.sendResync.mock.calls[0]![0]!.length).toBe(0);
});
it('contrôle au premier interactif seulement, les observers ne comptent pas', () => {
const { summary, pty } = spawnBash();
const obs = makeBinding('observer');
const a = makeBinding('interactive');
const b = makeBinding('interactive');
expect(manager.attach(summary.id, obs, 80, 24)).toEqual({ ok: true, controlling: false });
expect(pty.resize).not.toHaveBeenCalled(); // un observer ne redimensionne pas
expect(manager.attach(summary.id, a, 100, 30)).toEqual({ ok: true, controlling: true });
expect(pty.resize).toHaveBeenCalledWith(100, 30); // le contrôleur impose sa taille
expect(manager.attach(summary.id, b, 200, 50)).toEqual({ ok: true, controlling: false });
expect(pty.resize).toHaveBeenCalledTimes(1); // pas de resize pour le non-contrôleur
expect(manager.get(summary.id)?.clients).toBe(3);
});
});
describe('write / resize', () => {
it('observer read-only : write refusé', () => {
const { summary, pty } = spawnBash();
const obs = makeBinding('observer');
manager.attach(summary.id, obs, 80, 24);
expect(manager.write(summary.id, obs, 'ls\r')).toBe('not_controlling');
expect(pty.write).not.toHaveBeenCalled();
});
it('tout interactif peut écrire (même non contrôleur), session inconnue → gone', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
const b = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, b, 80, 24);
expect(manager.write(summary.id, a, 'echo a\r')).toBe('ok');
expect(manager.write(summary.id, b, 'echo b\r')).toBe('ok');
expect(pty.write).toHaveBeenCalledTimes(2);
expect(manager.write('nope', a, 'x')).toBe('gone');
});
it('resize ignoré pour les non-contrôleurs', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
const b = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, b, 80, 24);
pty.resize.mockClear();
manager.resize(summary.id, b, 50, 20);
expect(pty.resize).not.toHaveBeenCalled();
manager.resize(summary.id, a, 90, 28);
expect(pty.resize).toHaveBeenCalledWith(90, 28);
});
});
describe('flow control', () => {
it('pause() UNIQUEMENT quand tous les interactifs dépassent HIGH ; resume() quand le min repasse sous LOW', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
const b = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, b, 80, 24);
const chunk = 'x'.repeat(200 * 1024); // 200 Kio ASCII
pty.emitData(chunk); // a=200K, b=200K — sous HIGH (384K)
expect(pty.pause).not.toHaveBeenCalled();
manager.ack(summary.id, b, 200 * 1024); // b rattrape tout
pty.emitData(chunk); // a=400K > HIGH, b=200K < HIGH
expect(pty.pause).not.toHaveBeenCalled(); // un seul interactif au-dessus ne suffit pas
pty.emitData(chunk); // a=600K, b=400K → TOUS au-dessus de HIGH
expect(a.sentBytes - a.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK);
expect(b.sentBytes - b.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK);
expect(pty.pause).toHaveBeenCalledTimes(1);
expect(pty.resume).not.toHaveBeenCalled();
manager.ack(summary.id, b, 600 * 1024); // outstanding b = 0 < LOW
expect(pty.resume).toHaveBeenCalledTimes(1);
expect(pty.pause).toHaveBeenCalledTimes(1); // pas de re-pause intempestive
});
it('les observers ne freinent jamais le PTY', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
const obs = makeBinding('observer');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, obs, 80, 24);
const chunk = 'y'.repeat(200 * 1024);
for (let i = 0; i < 3; i++) {
pty.emitData(chunk);
manager.ack(summary.id, a, a.sentBytes); // l'interactif suit le rythme
}
expect(obs.sentBytes - obs.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK);
expect(pty.pause).not.toHaveBeenCalled();
});
it('session sans interactif (observers seuls) : jamais de pause', () => {
const { summary, pty } = spawnBash();
const obs = makeBinding('observer');
manager.attach(summary.id, obs, 80, 24);
pty.emitData('z'.repeat(500 * 1024));
expect(pty.pause).not.toHaveBeenCalled();
});
});
describe('client lagging', () => {
it('> LAGGING_BYTES sans ACK → flux coupé, puis resync au rattrapage', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
const mib = 1024 * 1024;
const chunks = ['A', 'B', 'C', 'D'].map((c) => c.repeat(mib));
pty.emitData(chunks[0]!); // outstanding 1 Mio
pty.emitData(chunks[1]!); // outstanding 2 Mio — pas strictement > LAGGING_BYTES
expect(a.lagging).toBe(false);
expect(a.sendOutput).toHaveBeenCalledTimes(2);
pty.emitData(chunks[2]!); // outstanding 3 Mio > 2 Mio → lagging
expect(a.lagging).toBe(true);
expect(a.sendOutput).toHaveBeenCalledTimes(3); // ce chunk est parti avant le marquage
// le seul interactif est lagging : le PTY ne doit pas rester en pause
expect(pty.resume).toHaveBeenCalled();
pty.emitData(chunks[3]!); // flux coupé pour le client lagging
expect(a.sendOutput).toHaveBeenCalledTimes(3);
manager.ack(summary.id, a, 3 * mib); // rattrapage : outstanding 0 < LOW
expect(a.lagging).toBe(false);
expect(a.sendResync).toHaveBeenCalledTimes(2); // attach + rattrapage
const payload = a.sendResync.mock.calls[1]![0]!;
const full = Buffer.from(chunks.join(''), 'ascii');
expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
expect(a.sentBytes).toBe(0);
expect(a.ackedBytes).toBe(0);
// le flux reprend normalement après le resync
pty.emitData('tail');
expect(a.sendOutput).toHaveBeenCalledTimes(4);
expect(a.sendOutput.mock.calls[3]![0]!.toString('ascii')).toBe('tail');
});
});
describe('detach et transfert de contrôle', () => {
it('detach du contrôleur → transfert au prochain interactif (onControlChanged)', () => {
const { summary } = spawnBash();
const a = makeBinding('interactive');
const b = makeBinding('interactive');
const obs = makeBinding('observer');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, obs, 80, 24);
manager.attach(summary.id, b, 80, 24);
expect(a.controlling).toBe(true);
expect(b.controlling).toBe(false);
manager.detach(summary.id, a);
expect(b.controlling).toBe(true);
expect(b.onControlChanged).toHaveBeenCalledExactlyOnceWith(true);
expect(obs.onControlChanged).not.toHaveBeenCalled();
expect(manager.get(summary.id)?.clients).toBe(2);
});
it('detach dun non-contrôleur : aucun transfert', () => {
const { summary } = spawnBash();
const a = makeBinding('interactive');
const b = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, b, 80, 24);
manager.detach(summary.id, b);
expect(a.controlling).toBe(true);
expect(a.onControlChanged).not.toHaveBeenCalled();
});
it('detach du dernier interactif : un observer ne récupère jamais le contrôle', () => {
const { summary } = spawnBash();
const a = makeBinding('interactive');
const obs = makeBinding('observer');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, obs, 80, 24);
manager.detach(summary.id, a);
expect(obs.controlling).toBe(false);
expect(obs.onControlChanged).not.toHaveBeenCalled();
});
it('un nouvel interactif après detach du contrôleur reprend le contrôle', () => {
const { summary } = spawnBash();
const a = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
manager.detach(summary.id, a);
const b = makeBinding('interactive');
expect(manager.attach(summary.id, b, 80, 24)).toEqual({ ok: true, controlling: true });
});
});
describe('exit', () => {
it('onDetached(session_exit) pour tous, DB mise à jour, événements émis', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
const obs = makeBinding('observer');
manager.attach(summary.id, a, 80, 24);
manager.attach(summary.id, obs, 80, 24);
const exits: Array<{ sessionId: string; exitCode: number | null; signal: number | null }> = [];
const updates: SessionSummary[] = [];
manager.on('session_exit', (e) => exits.push(e));
manager.on('session_update', (s) => updates.push(s));
pty.emitExit(0);
expect(a.onDetached).toHaveBeenCalledExactlyOnceWith('session_exit');
expect(obs.onDetached).toHaveBeenCalledExactlyOnceWith('session_exit');
expect(exits).toEqual([{ sessionId: summary.id, exitCode: 0, signal: null }]);
expect(updates).toHaveLength(1);
expect(updates[0]).toMatchObject({ id: summary.id, status: 'exited', live: false, exitCode: 0 });
const row = db.prepare('SELECT ended_at, exit_code FROM sessions WHERE id = ?').get(summary.id) as { ended_at: string | null; exit_code: number | null };
expect(row.ended_at).not.toBeNull();
expect(row.exit_code).toBe(0);
expect(manager.get(summary.id)).toBeNull();
const listed = manager.list().find((s) => s.id === summary.id);
expect(listed).toMatchObject({ status: 'exited', live: false, exitCode: 0 });
});
it('exit avec signal : propagé dans session_exit', () => {
const { summary, pty } = spawnBash();
const exits: Array<{ sessionId: string; exitCode: number | null; signal: number | null }> = [];
manager.on('session_exit', (e) => exits.push(e));
pty.emitExit(1, 15);
expect(exits).toEqual([{ sessionId: summary.id, exitCode: 1, signal: 15 }]);
});
it('après exit : write → gone, attach → NOT_FOUND', () => {
const { summary, pty } = spawnBash();
const a = makeBinding('interactive');
manager.attach(summary.id, a, 80, 24);
pty.emitExit(0);
expect(manager.write(summary.id, a, 'x')).toBe('gone');
expect(manager.attach(summary.id, makeBinding('interactive'), 80, 24)).toEqual({ ok: false, code: 'NOT_FOUND' });
});
});
});