Files
gods-eye-view/src/sdr/workerAdmission.test.mjs
T
Mazeyar Moeini FeizabadiandAditya 240f7ae3da fix(sdr): bound decoder work and stabilize FM playback
Keep USB reads in a bounded ordered pipeline, acknowledge and cap decoder
work, terminate stopped workers, and buffer short playback jitter. Throttle
display telemetry and compute spectra only while seeking.

Preserve PR #1174's wrapped seeking and fresh-spectrum tagging. Add opt-in
bounded timing diagnostics and decoder/playback lifecycle regressions.

Co-authored-by: Aditya <[email protected]>
2026-10-16 18:37:15 +02:00

421 lines
16 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { Worker as ThreadWorker } from 'node:worker_threads';
import { SdrController } from './controller.js';
const ADMISSION = {
maxBlocks: 4,
maxBytes: 4 * 64,
maxAgeMs: 60,
watchdogMs: 10,
reportMs: 20,
readyTimeoutMs: 150,
};
const pause = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
/** Wait (bounded) until `check` holds. */
async function until(check, ms = 1_000) {
const deadline = Date.now() + ms;
while (!check()) {
if (Date.now() > deadline) throw new Error('condition not reached');
await pause(2);
}
}
/** A decoder stand-in that records blocks and acknowledges as told. */
function installWorker(t, { ack = 'auto', readyAfterMs = 0 } = {}) {
const workers = [];
class AckWorker {
constructor() {
this.blocks = [];
this.messages = [];
this.terminated = false;
workers.push(this);
// Like the real worker, report ready once the module has loaded.
if (readyAfterMs !== null)
setTimeout(() => this.emit({ type: 'ready' }), readyAfterMs);
}
postMessage(message) {
this.messages.push(message);
if (message?.type !== 'samples') return;
this.blocks.push(message.id);
if (ack === 'auto') queueMicrotask(() => this.ack(message.id));
}
ack(id) {
this.onmessage?.({ data: { type: 'ack', id } });
}
emit(data) {
this.onmessage?.({ data });
}
terminate() {
this.terminated = true;
}
}
const prior = globalThis.Worker;
globalThis.Worker = AckWorker;
t.after(() => {
globalThis.Worker = prior;
});
return workers;
}
/** A streaming controller with a device that yields one block per read. */
function streaming(t, { reads = Infinity, admission = ADMISSION } = {}) {
const controller = new SdrController({
storage: { getItem: () => null, setItem() {} },
webUsb: {},
workerAdmission: admission,
});
const audio = [];
controller._audioNode = {
port: { postMessage: (message) => audio.push(message) },
};
let served = 0;
const device = {
readSamples: (count) =>
served++ < reads
? new Promise((resolve) =>
setTimeout(() => resolve({ data: new ArrayBuffer(64) }), 1),
)
: new Promise(() => {}),
close: async () => {},
};
controller._device = device;
controller._setState({ mode: 'fm', status: 'streaming', connected: true });
controller._beginAudioSession();
controller._readGeneration += 1;
controller._startWatchdog();
void controller._readLoop(controller._readGeneration);
t.after(() => {
controller._stopWatchdog();
controller._readGeneration += 1;
controller._device = null;
});
return { controller, audio, device };
}
test('a slow decoder startup is not an overload: no block is sent before ready', async (t) => {
// Ready after 200 ms: longer than the 4 blocks the cap admits (and than the
// 128 ms the production cap of 8 blocks covers).
const workers = installWorker(t, { readyAfterMs: 200 });
const admission = { ...ADMISSION, readyTimeoutMs: 1_000 };
const { controller } = streaming(t, { reads: 10, admission });
await pause(150);
assert.equal(workers[0].blocks.length, 0, 'no block before ready');
assert.equal(controller.getState().status, 'streaming');
await until(() => workers[0].blocks.length >= 10);
assert.equal(controller.getState().status, 'streaming');
assert.equal(controller.getState().decoderOverload, undefined);
});
test('a decoder that never becomes ready stops the receiver at the startup bound', async (t) => {
const workers = installWorker(t, { readyAfterMs: null });
const { controller } = streaming(t, { reads: 10 });
await until(() => controller.getState().status === 'error', 1_000);
assert.equal(controller.getState().decoderOverload, 'startup');
assert.equal(workers[0].blocks.length, 0);
assert.equal(workers[0].terminated, true);
});
test('an acknowledging decoder streams with pending work bounded', async (t) => {
const workers = installWorker(t);
const { controller } = streaming(t, { reads: 40 });
await until(() => workers[0]?.blocks.length >= 40);
await pause(5);
assert.equal(controller.getState().status, 'streaming');
assert.equal(controller._pendingBlocks.size, 0);
assert.equal(workers[0].terminated, false);
});
test('a decoder that falls behind stops the receiver at the block bound', async (t) => {
const workers = installWorker(t, { ack: 'manual' });
const { controller } = streaming(t);
await until(() => controller.getState().status === 'error');
const worker = workers[0];
assert.equal(worker.blocks.length, ADMISSION.maxBlocks, 'never past the cap');
assert.equal(worker.terminated, true, 'decoder terminated');
assert.equal(controller.getState().decoderOverload, 'backlog');
assert.equal(controller._pendingBlocks.size, 0);
const posted = worker.blocks.length;
await pause(20);
assert.equal(worker.blocks.length, posted, 'nothing posted after overload');
});
test('a hung decoder is stopped by age even when no further USB block arrives', async (t) => {
const workers = installWorker(t, { ack: 'manual' });
const { controller } = streaming(t, { reads: 2 });
await until(() => workers[0]?.blocks.length === 2);
await until(() => controller.getState().status === 'error', 1_000);
assert.equal(controller.getState().decoderOverload, 'stalled');
assert.equal(workers[0].terminated, true);
});
test('pending diagnostics are published at low frequency, not per ack', async (t) => {
const workers = installWorker(t);
const { controller } = streaming(t, { reads: 30 });
let reports = 0;
controller.subscribe((state) => {
if ('decoderPendingBlocks' in state) reports += 1;
});
await until(() => workers[0]?.blocks.length >= 30);
const state = controller.getState();
assert.ok(Number.isFinite(state.decoderPendingBlocks));
assert.ok(Number.isFinite(state.decoderPendingBytes));
assert.ok(Number.isFinite(state.decoderOldestPendingMs));
assert.ok(reports < workers[0].blocks.length, 'fewer reports than acks');
});
test('duplicate, unknown and stale acknowledgements change nothing', async (t) => {
const workers = installWorker(t, { ack: 'manual' });
const { controller } = streaming(t, { reads: 2 });
await until(() => workers[0]?.blocks.length === 2);
const worker = workers[0];
worker.ack(0);
worker.ack(0); // duplicate
worker.ack(99); // never sent
assert.equal(controller._pendingBlocks.size, 1);
assert.equal(controller._pendingBytes, 64);
assert.equal(controller._ackAnomalies, 2);
// A replaced worker's late acknowledgement cannot touch the new worker.
const oldHandler = worker.onmessage;
controller._discardWorker();
controller._ensureWorker();
const fresh = workers[1];
controller._postSamples(new ArrayBuffer(64));
assert.equal(fresh.blocks[0], 0, 'block numbers restart per worker');
oldHandler?.({ data: { type: 'ack', id: 0 } });
assert.equal(controller._pendingBlocks.size, 1, 'stale ack ignored');
fresh.ack(0);
assert.equal(controller._pendingBlocks.size, 0);
});
test('stop terminates the decoder before cleanup and fences its late output', async (t) => {
const workers = installWorker(t);
const { controller, audio, device } = streaming(t, { reads: 5 });
await until(() => workers[0]?.blocks.length >= 3);
const worker = workers[0];
const handler = worker.onmessage;
let closed = false;
device.close = () =>
new Promise((resolve) =>
setTimeout(() => {
closed = true;
resolve();
}, 30),
);
const stopping = controller.stop();
assert.equal(worker.terminated, true, 'terminated synchronously');
assert.equal(closed, false, 'before the device close settled');
const disabled = audio.filter(
(message) => message.type === 'session' && message.enabled === false,
);
assert.equal(disabled.length, 1, 'player session disabled');
const before = audio.filter((message) => message.type === 'samples').length;
handler?.({
data: {
type: 'fm',
samples: new Float32Array(8),
tune: controller._fmTune,
},
});
await stopping;
assert.equal(
audio.filter((message) => message.type === 'samples').length,
before,
'no late audio after stop',
);
});
test('audio reaches the player only for the live FM session and current tune', async (t) => {
const workers = installWorker(t, { ack: 'manual' });
const { controller, audio } = streaming(t, { reads: 1 });
await until(() => workers[0]?.blocks.length === 1);
const worker = workers[0];
const samples = () => audio.filter((message) => message.type === 'samples');
worker.emit({ type: 'fm', samples: new Float32Array(4), tune: 0 });
assert.equal(samples().length, 1);
assert.equal(samples()[0].sessionId, controller._audioSessionId);
controller._fmTune = 3; // a retune is in flight
worker.emit({ type: 'fm', samples: new Float32Array(4), tune: 0 });
assert.equal(samples().length, 1, 'pre-retune audio dropped');
worker.emit({ type: 'fm', samples: new Float32Array(4), tune: 3 });
assert.equal(samples().length, 2);
controller._setState({ mode: 'adsb' });
worker.emit({ type: 'fm', samples: new Float32Array(4), tune: 3 });
assert.equal(samples().length, 2, 'no FM audio outside FM mode');
controller._setState({ mode: 'fm' });
worker.emit({ type: 'fm', samples: new Float32Array(0), tune: 3 });
assert.equal(samples().length, 2, 'empty audio is not forwarded');
});
test('an FM to ADS-B to FM round trip cannot relabel old output as current', async (t) => {
const workers = installWorker(t, { ack: 'manual' });
const { controller, audio } = streaming(t, { reads: 1 });
await until(() => workers[0]?.blocks.length === 1);
const worker = workers[0];
const samples = () => audio.filter((message) => message.type === 'samples');
controller._configureWorker();
const fmEpoch = controller._fmTune;
// Leave for ADS-B and come back, on the same worker, as _configureDevice does.
controller._setState({ mode: 'adsb' });
controller._configureWorker();
controller._setState({ mode: 'fm' });
controller._configureWorker();
controller._beginAudioSession();
const current = controller._fmTune;
assert.ok(current > fmEpoch);
worker.emit({ type: 'fm', samples: new Float32Array(4), tune: fmEpoch });
assert.equal(
samples().length,
0,
'old FM output dropped after the round trip',
);
worker.emit({ type: 'fm', samples: new Float32Array(4), tune: current });
assert.equal(samples().length, 1);
});
test('old-epoch diagnostics, SNR and ADS-B output are ignored', async (t) => {
const workers = installWorker(t, { ack: 'manual' });
const { controller } = streaming(t, { reads: 1 });
await until(() => workers[0]?.blocks.length === 1);
const worker = workers[0];
controller._configureWorker();
const old = controller._fmTune - 1;
const before = controller.getState();
worker.emit({
type: 'fm',
tune: old,
snr: 42,
samples: new Float32Array(4),
diagnostics: { workerBlocks: 999, iqLevelDbfs: -1, audioLevelDbfs: -1 },
});
worker.emit({ type: 'adsb', tune: old, aircraft: [{ icao: 'abc123' }] });
const after = controller.getState();
assert.equal(after.snr, before.snr);
assert.equal(after.workerBlocks, before.workerBlocks);
assert.deepEqual(after.aircraft, before.aircraft, 'old ADS-B output ignored');
// Control: the same output in the current epoch is applied, so the checks
// above are not vacuous.
worker.emit({
type: 'adsb',
tune: controller._fmTune,
aircraft: [{ icao: 'abc123' }],
});
assert.deepEqual(
controller.getState().aircraft.map((entry) => entry.icao),
['abc123'],
);
});
test('an overload publishes its error first and never overwrites a reconnect', async (t) => {
const workers = installWorker(t, { ack: 'manual' });
const { controller, device } = streaming(t);
device.close = () => new Promise((resolve) => setTimeout(resolve, 40));
await until(() => controller.getState().status === 'error');
assert.equal(controller.getState().connected, false);
assert.equal(workers[0].terminated, true);
// A reconnect starts while the overloaded session is still tearing down.
controller._sessionToken += 1;
controller._setState({ status: 'streaming', connected: true });
await pause(80);
assert.equal(controller.getState().status, 'streaming');
assert.equal(controller.getState().connected, true);
});
test('a reconnect gets a new decoder with fresh numbering and no carried backlog', async (t) => {
const workers = installWorker(t, { ack: 'manual' });
const first = streaming(t, { reads: 3 });
await until(() => workers[0]?.blocks.length === 3);
await first.controller.stop();
assert.equal(first.controller._pendingBlocks.size, 0);
first.controller._device = {
readSamples: () =>
new Promise((resolve) =>
setTimeout(() => resolve({ data: new ArrayBuffer(64) }), 1),
),
close: async () => {},
};
first.controller._readGeneration += 1;
first.controller._startWatchdog();
void first.controller._readLoop(first.controller._readGeneration);
await until(() => workers[1]?.blocks.length >= 1);
assert.equal(workers[1].blocks[0], 0);
assert.equal(
workers[0].blocks.length,
3,
'old decoder receives nothing more',
);
});
/** The real sdr.worker.js in a Node worker thread, with a browser-like `self`. */
function realWorker(t) {
const module = new URL('./sdr.worker.js', import.meta.url).href;
const thread = new ThreadWorker(
`const { parentPort } = require('node:worker_threads');
globalThis.self = globalThis;
self.postMessage = (m, transfer) => parentPort.postMessage(m, transfer);
parentPort.on('message', (data) => self.onmessage && self.onmessage({ data }));
import(${JSON.stringify(module)}).then(() => parentPort.postMessage({ type: 'ready' }));`,
{ eval: true },
);
const inbox = [];
thread.on('message', (message) => inbox.push(message));
t.after(() => thread.terminate());
return { thread, inbox };
}
function iqBlock(samples = 32_768) {
const bytes = new Uint8Array(samples * 2);
for (let n = 0; n < samples; n += 1) {
const phase = (2 * Math.PI * 100_000 * n) / 2_048_000;
bytes[2 * n] = Math.round(127.5 + 60 * Math.cos(phase));
bytes[2 * n + 1] = Math.round(127.5 + 60 * Math.sin(phase));
}
return bytes.buffer;
}
test('the real decoder acknowledges every numbered block after its output, in every path', async (t) => {
const { thread, inbox } = realWorker(t);
await until(() => inbox.some((m) => m.type === 'ready'), 5_000);
thread.postMessage({
type: 'configure',
mode: 'fm',
sampleRate: 2_048_000,
tune: 5,
});
for (const id of [0, 1, 2]) {
const buffer = iqBlock();
thread.postMessage({ type: 'samples', buffer, id }, [buffer]);
}
await until(() => inbox.filter((m) => m.type === 'ack').length === 3, 5_000);
const fm = inbox.filter((m) => m.type === 'fm');
assert.equal(fm.length, 3);
assert.ok(
fm.every((m) => m.tune === 5),
'configure re-synced the tune tag',
);
const order = inbox.filter((m) => m.type === 'fm' || m.type === 'ack');
assert.deepEqual(
order.map((m) => m.type),
['fm', 'ack', 'fm', 'ack', 'fm', 'ack'],
'each ack follows that block output',
);
// An invalid block produces no output and is still acknowledged.
thread.postMessage({ type: 'samples', buffer: null, id: 3 });
// ADS-B: output is rare, acknowledgement is not.
thread.postMessage({
type: 'configure',
mode: 'adsb',
sampleRate: 2_000_000,
});
const adsb = iqBlock(16_384);
thread.postMessage({ type: 'samples', buffer: adsb, id: 4 }, [adsb]);
await until(() => inbox.filter((m) => m.type === 'ack').length === 5, 5_000);
assert.deepEqual(
inbox.filter((m) => m.type === 'ack').map((m) => m.id),
[0, 1, 2, 3, 4],
);
});