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:
Indiana
2026-07-25 18:38:59 +00:00
parent 53239a923d
commit 825f6aa510
2 changed files with 111 additions and 5 deletions

View File

@@ -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)
}