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:
parent
8bb52682a3
commit
bf4b1a4932
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts']
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue