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
This commit is contained in:
247
packages/server/test/ring-buffer.test.ts
Normal file
247
packages/server/test/ring-buffer.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { RingBuffer } from '../src/core/ring-buffer.js';
|
||||
|
||||
// Modèle de référence naïf : garde tout le flux et recalcule la fenêtre à la demande
|
||||
class RefBuffer {
|
||||
private data = Buffer.alloc(0);
|
||||
constructor(readonly capacity: number) {}
|
||||
write(chunk: Buffer): void {
|
||||
this.data = Buffer.concat([this.data, chunk]);
|
||||
}
|
||||
get total(): number {
|
||||
return this.data.length;
|
||||
}
|
||||
get windowStart(): number {
|
||||
return Math.max(0, this.data.length - this.capacity);
|
||||
}
|
||||
tail(count: number): Buffer {
|
||||
const window = this.data.subarray(this.windowStart);
|
||||
return Buffer.from(window.subarray(Math.max(0, window.length - count)));
|
||||
}
|
||||
readFrom(offset: number): Buffer | null {
|
||||
if (offset > this.total) return null;
|
||||
if (offset === this.total) return Buffer.alloc(0);
|
||||
if (offset < this.windowStart) return null;
|
||||
return Buffer.from(this.data.subarray(offset));
|
||||
}
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const buf = (s: string): Buffer => Buffer.from(s, 'ascii');
|
||||
|
||||
describe('RingBuffer — écriture simple', () => {
|
||||
it('écrit puis relit sans wrap', () => {
|
||||
const ring = new RingBuffer(16);
|
||||
ring.write(buf('hello'));
|
||||
expect(ring.totalOffset).toBe(5);
|
||||
expect(ring.windowStart).toBe(0);
|
||||
expect(ring.tail(5).toString()).toBe('hello');
|
||||
expect(ring.tail(3).toString()).toBe('llo');
|
||||
expect(ring.tail(100).toString()).toBe('hello'); // borné à la fenêtre
|
||||
expect(ring.tail(0).length).toBe(0);
|
||||
});
|
||||
|
||||
it('écritures successives sans wrap', () => {
|
||||
const ring = new RingBuffer(16);
|
||||
ring.write(buf('abc'));
|
||||
ring.write(buf('def'));
|
||||
expect(ring.totalOffset).toBe(6);
|
||||
expect(ring.tail(6).toString()).toBe('abcdef');
|
||||
});
|
||||
|
||||
it('chunk vide : no-op', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('ab'));
|
||||
ring.write(Buffer.alloc(0));
|
||||
expect(ring.totalOffset).toBe(2);
|
||||
expect(ring.tail(8).toString()).toBe('ab');
|
||||
});
|
||||
|
||||
it('buffer vierge : tail vide, readFrom(0) vide', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
expect(ring.totalOffset).toBe(0);
|
||||
expect(ring.tail(8).length).toBe(0);
|
||||
expect(ring.readFrom(0)?.length).toBe(0);
|
||||
expect(ring.readFrom(1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RingBuffer — wrap-around', () => {
|
||||
it('wrap simple : le chunk chevauche la frontière du buffer', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('01234')); // pos 0..5
|
||||
ring.write(buf('56789')); // 3 octets en queue, 2 au début
|
||||
expect(ring.totalOffset).toBe(10);
|
||||
expect(ring.windowStart).toBe(2);
|
||||
expect(ring.tail(8).toString()).toBe('23456789');
|
||||
expect(ring.tail(4).toString()).toBe('6789');
|
||||
});
|
||||
|
||||
it('wrap-around multiple : la fenêtre reste les N derniers octets', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
let stream = '';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const chunk = String.fromCharCode(97 + i).repeat(3); // 'aaa', 'bbb'…
|
||||
ring.write(buf(chunk));
|
||||
stream += chunk;
|
||||
expect(ring.totalOffset).toBe(stream.length);
|
||||
expect(ring.tail(8).toString()).toBe(stream.slice(Math.max(0, stream.length - 8)));
|
||||
}
|
||||
expect(ring.windowStart).toBe(30 - 8);
|
||||
});
|
||||
|
||||
it('écriture qui finit exactement sur la frontière (tailSpace == chunk.length)', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('abcd'));
|
||||
ring.write(buf('efgh')); // remplit exactement jusqu'à la frontière
|
||||
expect(ring.totalOffset).toBe(8);
|
||||
expect(ring.tail(8).toString()).toBe('abcdefgh');
|
||||
ring.write(buf('ij')); // repart de la position 0
|
||||
expect(ring.tail(8).toString()).toBe('cdefghij');
|
||||
});
|
||||
|
||||
it('total multiple exact de la capacité (end === 0)', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('abcdefgh'));
|
||||
ring.write(buf('ijklmnop'));
|
||||
expect(ring.totalOffset).toBe(16);
|
||||
expect(ring.tail(8).toString()).toBe('ijklmnop');
|
||||
expect(ring.tail(3).toString()).toBe('nop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RingBuffer — chunk >= capacité', () => {
|
||||
it('chunk == capacité sur un ring vierge', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('abcdefgh'));
|
||||
expect(ring.totalOffset).toBe(8);
|
||||
expect(ring.windowStart).toBe(0);
|
||||
expect(ring.tail(8).toString()).toBe('abcdefgh');
|
||||
});
|
||||
|
||||
it('chunk == 2x capacité sur un ring vierge (total reste aligné)', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('0123456789abcdef'));
|
||||
expect(ring.totalOffset).toBe(16);
|
||||
expect(ring.windowStart).toBe(8);
|
||||
expect(ring.tail(8).toString()).toBe('89abcdef');
|
||||
expect(ring.readFrom(8)?.toString()).toBe('89abcdef');
|
||||
expect(ring.readFrom(7)).toBeNull();
|
||||
});
|
||||
|
||||
// Invariant : l'octet k du flux vit à la position k % capacity, même quand un
|
||||
// chunk dépasse la capacité (les octets sautés comptent dans l'offset).
|
||||
it('chunk > capacité non aligné : la fenêtre reste ordonnée', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('0123456789')); // 10 octets, total final 10 % 8 != 0
|
||||
expect(ring.totalOffset).toBe(10);
|
||||
expect(ring.tail(8).toString()).toBe('23456789');
|
||||
});
|
||||
|
||||
it('chunk == capacité après un préfixe non aligné : fenêtre ordonnée', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('abc'));
|
||||
ring.write(buf('ABCDEFGH'));
|
||||
expect(ring.tail(8).toString()).toBe('ABCDEFGH');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RingBuffer — readFrom', () => {
|
||||
function filled(): RingBuffer {
|
||||
// total = 10, capacité 8 → fenêtre = offsets [2, 10)
|
||||
const ring = new RingBuffer(8);
|
||||
ring.write(buf('01234'));
|
||||
ring.write(buf('56789'));
|
||||
return ring;
|
||||
}
|
||||
|
||||
it('offset dans la fenêtre', () => {
|
||||
const ring = filled();
|
||||
expect(ring.readFrom(5)?.toString()).toBe('56789');
|
||||
expect(ring.readFrom(9)?.toString()).toBe('9');
|
||||
});
|
||||
|
||||
it('offset == windowStart : toute la fenêtre', () => {
|
||||
const ring = filled();
|
||||
expect(ring.readFrom(ring.windowStart)?.toString()).toBe('23456789');
|
||||
});
|
||||
|
||||
it('offset == total : buffer vide (rien de nouveau)', () => {
|
||||
const ring = filled();
|
||||
const out = ring.readFrom(10);
|
||||
expect(out).not.toBeNull();
|
||||
expect(out?.length).toBe(0);
|
||||
});
|
||||
|
||||
it('offset sorti de fenêtre ou au-delà du total → null', () => {
|
||||
const ring = filled();
|
||||
expect(ring.readFrom(1)).toBeNull(); // windowStart - 1
|
||||
expect(ring.readFrom(0)).toBeNull();
|
||||
expect(ring.readFrom(-1)).toBeNull();
|
||||
expect(ring.readFrom(11)).toBeNull(); // total + 1
|
||||
});
|
||||
});
|
||||
|
||||
describe('RingBuffer — totalOffset monotone', () => {
|
||||
it('croît exactement de la taille de chaque chunk, jamais ne décroît', () => {
|
||||
const ring = new RingBuffer(8);
|
||||
const rand = mulberry32(0x5eed);
|
||||
let expected = 0;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const size = Math.floor(rand() * 7); // 0..6 (< capacité)
|
||||
ring.write(Buffer.alloc(size, 65 + (i % 26)));
|
||||
expected += size;
|
||||
expect(ring.totalOffset).toBe(expected);
|
||||
expect(ring.windowStart).toBe(Math.max(0, expected - 8));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('RingBuffer — équivalence avec le modèle de référence (property test)', () => {
|
||||
it('tail et readFrom identiques au modèle pour 500 écritures aléatoires (chunks < capacité)', () => {
|
||||
const capacity = 64;
|
||||
const ring = new RingBuffer(capacity);
|
||||
const ref = new RefBuffer(capacity);
|
||||
const rand = mulberry32(0xc0ffee);
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const size = Math.floor(rand() * capacity); // 0..63
|
||||
const chunk = Buffer.alloc(size);
|
||||
for (let j = 0; j < size; j++) chunk[j] = Math.floor(rand() * 256);
|
||||
ring.write(chunk);
|
||||
ref.write(chunk);
|
||||
|
||||
expect(ring.totalOffset).toBe(ref.total);
|
||||
expect(ring.windowStart).toBe(ref.windowStart);
|
||||
for (const n of [0, 1, 7, capacity - 1, capacity, capacity + 1, 500]) {
|
||||
expect(ring.tail(n).equals(ref.tail(n)), `tail(${n}) après ${i + 1} écritures`).toBe(true);
|
||||
}
|
||||
const offsets = [
|
||||
ref.windowStart - 1,
|
||||
ref.windowStart,
|
||||
ref.windowStart + Math.floor((ref.total - ref.windowStart) / 2),
|
||||
ref.total - 1,
|
||||
ref.total,
|
||||
ref.total + 1,
|
||||
];
|
||||
for (const off of offsets) {
|
||||
const a = ring.readFrom(off);
|
||||
const b = ref.readFrom(off);
|
||||
if (b === null) {
|
||||
expect(a, `readFrom(${off}) après ${i + 1} écritures`).toBeNull();
|
||||
} else {
|
||||
expect(a?.equals(b), `readFrom(${off}) après ${i + 1} écritures`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user