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": {
|
"scripts": {
|
||||||
"dev": "vite dev --host 0.0.0.0 --port 3000",
|
"dev": "vite dev --host 0.0.0.0 --port 3000",
|
||||||
"build": "vite build",
|
"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": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.0.0",
|
"@sveltejs/adapter-node": "^5.0.0",
|
||||||
|
|
@ -16,7 +18,8 @@
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-cpp": "^6.0.2",
|
"@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