feat(frontend): add vitest + ByteStreamBuffer FIFO for orphan-read fix

Test runner setup (vitest) plus deadline-based FIFO buffer that
becomes the single sink for serial bytes. readExact keeps
partial bytes buffered on timeout — the exact case that dropped
optiboot INSYNC/OK replies under the orphaned-reader race.
7 unit tests pass.
This commit is contained in:
a2nr 2026-07-19 19:03:40 +07:00
parent 8bb52682a3
commit bf4b1a4932
5 changed files with 1554 additions and 3 deletions

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,9 @@
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 3000"
"preview": "vite preview --host 0.0.0.0 --port 3000",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.0.0",
@ -16,7 +18,8 @@
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^6.0.0"
"vite": "^6.0.0",
"vitest": "^2.1.9"
},
"dependencies": {
"@codemirror/lang-cpp": "^6.0.2",

View File

@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { ByteStreamBuffer } from './byte-stream-buffer';
describe('ByteStreamBuffer', () => {
it('readExact resolves when enough bytes already buffered', async () => {
const buf = new ByteStreamBuffer();
buf.push(new Uint8Array([0x14, 0x10]));
const out = await buf.readExact(2, 100);
expect(Array.from(out)).toEqual([0x14, 0x10]);
});
it('readExact resolves when bytes arrive after the call', async () => {
const buf = new ByteStreamBuffer();
const p = buf.readExact(2, 200);
setTimeout(() => buf.push(new Uint8Array([0x14])), 20);
setTimeout(() => buf.push(new Uint8Array([0x10])), 40);
const out = await p;
expect(Array.from(out)).toEqual([0x14, 0x10]);
});
it('readExact rejects on timeout without consuming future bytes', async () => {
const buf = new ByteStreamBuffer();
await expect(buf.readExact(1, 30)).rejects.toThrow(/timeout/);
// Byte yang datang setelah timeout tetap tersimpan, tidak hilang.
buf.push(new Uint8Array([0x14]));
const out = await buf.readExact(1, 50);
expect(Array.from(out)).toEqual([0x14]);
});
it('partial bytes before timeout remain buffered for next read', async () => {
const buf = new ByteStreamBuffer();
buf.push(new Uint8Array([0x14]));
await expect(buf.readExact(2, 30)).rejects.toThrow(/timeout/);
buf.push(new Uint8Array([0x10]));
// 0x14 yang belum terpakai harus masih ada → total [0x14,0x10].
const out = await buf.readExact(2, 50);
expect(Array.from(out)).toEqual([0x14, 0x10]);
});
it('readAvailable drains everything currently buffered', () => {
const buf = new ByteStreamBuffer();
buf.push(new Uint8Array([1, 2, 3]));
const out = buf.readAvailable();
expect(Array.from(out)).toEqual([1, 2, 3]);
expect(buf.length).toBe(0);
});
it('clear empties the buffer', () => {
const buf = new ByteStreamBuffer();
buf.push(new Uint8Array([1, 2, 3]));
buf.clear();
expect(buf.length).toBe(0);
});
it('onData fires for each pushed chunk', () => {
const buf = new ByteStreamBuffer();
const seen: number[] = [];
buf.onData = (chunk) => seen.push(...chunk);
buf.push(new Uint8Array([5, 6]));
buf.push(new Uint8Array([7]));
expect(seen).toEqual([5, 6, 7]);
});
});

View File

@ -0,0 +1,104 @@
/**
* FIFO byte buffer for Web Serial reads.
*
* A single external pump loop is the ONLY caller of reader.read() and feeds
* bytes here via push(). Consumers (readExact, drain, serial monitor) read
* from this buffer and never touch reader.read() directly eliminating the
* orphaned-read race that dropped optiboot's INSYNC/OK replies.
*/
export class ByteStreamBuffer {
/** Optional hook: called synchronously with every pushed chunk (serial monitor). */
onData: ((chunk: Uint8Array) => void) | null = null;
private chunks: Uint8Array[] = [];
private totalLen = 0;
/** Waiters parked in readExact, notified on push. */
private waiters: Array<() => void> = [];
get length(): number {
return this.totalLen;
}
/** Feed bytes from the pump loop. */
push(chunk: Uint8Array): void {
if (chunk.length > 0) {
this.chunks.push(chunk);
this.totalLen += chunk.length;
const waiters = this.waiters;
this.waiters = [];
for (const w of waiters) w();
}
/* Fire onData even for empty chunk? No — only real data. */
if (chunk.length > 0 && this.onData) this.onData(chunk);
}
/** Remove and return all currently buffered bytes (non-blocking). */
readAvailable(): Uint8Array {
return this.take(this.totalLen);
}
/** Discard everything buffered. */
clear(): void {
this.chunks = [];
this.totalLen = 0;
}
/**
* Resolve with exactly `len` bytes, or reject after `timeoutMs`.
* Bytes already buffered are consumed immediately; otherwise we park a
* waiter until enough arrive or the deadline passes. On timeout, any
* bytes shorter than `len` REMAIN buffered for the next call.
*/
readExact(len: number, timeoutMs: number): Promise<Uint8Array> {
return new Promise<Uint8Array>((resolve, reject) => {
const deadline = Date.now() + timeoutMs;
const attempt = () => {
if (this.totalLen >= len) {
resolve(this.take(len));
return;
}
const remaining = deadline - Date.now();
if (remaining <= 0) {
reject(new Error(`read timeout: got ${this.totalLen}/${len}`));
return;
}
let timer: ReturnType<typeof setTimeout>;
const onPush = () => {
clearTimeout(timer);
attempt();
};
timer = setTimeout(() => {
/* Remove our waiter so a late push doesn't call a dead cb. */
this.waiters = this.waiters.filter((w) => w !== onPush);
attempt();
}, remaining);
this.waiters.push(onPush);
};
attempt();
});
}
/** Pull up to `n` bytes off the front of the queue. */
private take(n: number): Uint8Array {
const want = Math.min(n, this.totalLen);
const out = new Uint8Array(want);
let filled = 0;
while (filled < want && this.chunks.length > 0) {
const head = this.chunks[0];
const need = want - filled;
if (head.length <= need) {
out.set(head, filled);
filled += head.length;
this.chunks.shift();
} else {
out.set(head.subarray(0, need), filled);
this.chunks[0] = head.subarray(need);
filled += need;
}
}
this.totalLen -= want;
return out;
}
}

View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts']
}
});