fix: RTL-SDR bulk reads could hang forever with zero user feedback
Real bug report: user selects their RTL-SDR dongle in the WebUSB picker, "nothing happens" — no error, no sweep, no visible change at all. Root cause: WebUSB's transferIn/controlTransfer calls have no built-in timeout. readSamples()'s bulk transferIn (called every sweep step, twice — once to discard PLL-settle samples, once for real) had nothing bounding it, so if the dongle doesn't actually stream data for any reason (this init sequence has never been verified against real hardware), that promise just never settles — indistinguishable from the page being frozen, forever, with no way for the UI to ever surface an error. Added withTimeout(), applied to: the bulk IQ read (4s — the one most likely to actually hang during sweeping) and the whole open()/init sequence as one unit (15s, since it's ~20 sequential unverified register pokes). Also stopped silently falling back to default interface/endpoint values when the device's USB descriptor doesn't expose a bulk-IN endpoint as expected — now logs a warning so a real endpoint mismatch is at least visible in DevTools instead of only surfacing as a downstream hang. 4 new unit tests for withTimeout(). 306/306 frontend tests pass.
This commit is contained in:
@@ -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<string>(() => {
|
||||
/* 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Uint8Array> {
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user