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
417 lines
16 KiB
TypeScript
417 lines
16 KiB
TypeScript
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 à l’attach = 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 d’un 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' });
|
||
});
|
||
});
|
||
});
|