diff --git a/frontend/src/lib/sdr.test.ts b/frontend/src/lib/sdr.test.ts index 728c2fa..0ce8a30 100644 --- a/frontend/src/lib/sdr.test.ts +++ b/frontend/src/lib/sdr.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest' -import { SpectrumAnomalyDetector } from './sdr' +import { describe, expect, it, vi } from 'vitest' +import { SpectrumAnomalyDetector, withTimeout } from './sdr' const SAMPLE_RATE = 2_000_000 const BINS = 8 @@ -166,3 +166,44 @@ describe('SpectrumAnomalyDetector', () => { } }) }) + +describe('withTimeout', () => { + it('resolves with the wrapped promise\'s value when it settles before the deadline', async () => { + const p = withTimeout(Promise.resolve('ok'), 1000, 'test op') + await expect(p).resolves.toBe('ok') + }) + + it('rejects with the wrapped promise\'s error when it rejects before the deadline', async () => { + const p = withTimeout(Promise.reject(new Error('boom')), 1000, 'test op') + await expect(p).rejects.toThrow('boom') + }) + + it('rejects with a clear, labeled error if the promise never settles before the deadline', async () => { + vi.useFakeTimers() + try { + const never = new Promise(() => { + /* deliberately never resolves — simulates a stalled transferIn */ + }) + const p = withTimeout(never, 4000, 'bulk IQ read') + const assertion = expect(p).rejects.toThrow(/bulk IQ read timed out after 4000ms/) + await vi.advanceTimersByTimeAsync(4000) + await assertion + } finally { + vi.useRealTimers() + } + }) + + it('does not fire the timeout after the promise already settled (no dangling timer rejection)', async () => { + vi.useFakeTimers() + try { + const p = withTimeout(Promise.resolve('done'), 100, 'test op') + await expect(p).resolves.toBe('done') + // If the internal timer weren't cleared, this would still be a no-op + // for the already-settled `p`, but advancing confirms no unhandled + // rejection gets thrown from a stray timer callback. + await vi.advanceTimersByTimeAsync(200) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/frontend/src/lib/sdr.ts b/frontend/src/lib/sdr.ts index 66e6e54..0168078 100644 --- a/frontend/src/lib/sdr.ts +++ b/frontend/src/lib/sdr.ts @@ -41,6 +41,34 @@ const USB_EPA = 0x02 const SYS = 0x09 const PAGE_USB = 0x01 +/** + * WebUSB's transferIn/controlTransfer calls have no built-in timeout — if a + * dongle doesn't respond as expected (wrong endpoint, a mis-poked register + * during the unverified init sequence, anything), the returned promise just + * never settles. Without this, that hang is silent and indistinguishable + * from "nothing happened" — no error, no UI change, forever. Every + * USB call below that could plausibly stall on real (mis-)behaving + * hardware is wrapped in this. + */ +export function withTimeout(promise: Promise, ms: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`${label} timed out after ${ms}ms — the dongle went quiet`)), + ms, + ) + promise.then( + (v) => { + clearTimeout(timer) + resolve(v) + }, + (err) => { + clearTimeout(timer) + reject(err) + }, + ) + }) +} + /** WebUSB only exists in secure contexts (https, or localhost). */ export function isSecureContext(): boolean { return typeof window !== 'undefined' && window.isSecureContext === true @@ -77,10 +105,22 @@ export class RtlSdr { this.device = await navigator.usb.requestDevice({ filters }) } - /** Open, claim, and run the RTL2832U + R820T init sequence. */ + /** + * Open, claim, and run the RTL2832U + R820T init sequence. + * + * The whole sequence is time-boxed: it's ~20 sequential raw USB control + * transfers against an unverified register-poke sequence on real + * hardware, and any one of them stalling (device confused, wrong + * endpoint, anything) would otherwise hang this promise forever with no + * way for a caller to ever know — see withTimeout's comment. + */ async open(sampleRateHz = 2_048_000): Promise { const dev = this.device if (!dev) throw new Error('no device selected') + await withTimeout(this._openSequence(dev, sampleRateHz), 15_000, 'RTL-SDR open/init sequence') + } + + private async _openSequence(dev: USBDevice, sampleRateHz: number): Promise { this.sampleRate = sampleRateHz await dev.open() @@ -102,6 +142,18 @@ export class RtlSdr { if (iface && alt && ep) { this.interfaceNumber = iface.interfaceNumber this.endpointIn = ep.endpointNumber + } else { + // Silently falling back to the standard interface-0/endpoint-0x81 + // defaults used to mean a real endpoint mismatch could go completely + // unnoticed until the bulk read hangs during sweeping — surface it + // immediately instead, even though the defaults are the correct + // values for a standard RTL2832U and may well still work. + console.warn( + '[sdr] could not read this device’s USB descriptor for its bulk-IN endpoint; ' + + `falling back to the standard interface ${this.interfaceNumber} / ` + + `endpoint 0x${this.endpointIn.toString(16)}. If sweeping never produces data, ` + + 'this device likely uses a non-standard endpoint layout.', + ) } // Detach kernel driver (Linux) — ignore failure: may not be supported. @@ -179,11 +231,24 @@ export class RtlSdr { this.sampleRate = hz } - /** Read one block of I/Q samples. Length must be multiple of 512. */ + /** + * Read one block of I/Q samples. Length must be multiple of 512. + * + * This is the single most important place in this file to time-box: a + * bulk `transferIn` has no built-in timeout at all, and if the dongle + * isn't actually streaming (wrong endpoint, tuner not really locked + * despite the init sequence "succeeding", anything), this call is where + * the whole sweep silently hangs forever — no error, no data, no visible + * change on the page. That exact symptom is why this wrapper exists. + */ async readSamples(bytes: number): Promise { const dev = this.device if (!dev?.opened) throw new Error('device not open') - const res = await dev.transferIn(this.endpointIn, bytes) + const res = await withTimeout( + dev.transferIn(this.endpointIn, bytes), + 4_000, + 'bulk IQ read', + ) if (!res.data) throw new Error('bulk read failed') return new Uint8Array(res.data.buffer, res.data.byteOffset, res.data.byteLength) }