fix: three real sensor-lifecycle and honesty bugs (verified, not assumed)
From the audit whose verifier agents all died on session limits — so I
checked each claim myself rather than trusting it. One was WRONG and is
left alone; three were real.
REFUTED, deliberately unchanged: "the EMF support check is a false
positive, dead on iOS". The iOS gesture flow is correctly implemented
(EmfSensorListener.needsPermission/requestPermission) and the panel calls
it before start(). Nothing to fix; "fixing" it would have broken working
code.
1. Microphone never released when the panel unmounts mid-getUserMedia.
`this.stream` is only assigned after the await, so stop() during the
permission prompt found null and released nothing — then the promise
resolved, set running = true, and the mic went live *after* teardown,
staying on for the page's life with the recording indicator lit and an
orphaned rAF loop burning battery. Fixed with a generation counter that
makes the await cancellable. Proven: the new test fails without the
guard and passes with it (verified by reverting it).
2. Same bug class in the RTL-SDR panel: sdrRef.current is assigned after
requestDevice()+open(), so unmounting during the device picker left the
dongle claimed AND started a sweep against a dead component — only a
tab close would free it. Added a mountedRef check, mirroring the guard
EmfPanel already had.
3. EVP blamed the seeker for refusals that never happened. A bare
`catch {}` set "you refused the microphone" for every failure, so an
insecure http:// origin, a machine with no mic, and a mic held by
another app all told the user to go fix a permission that was never
denied. Now classified from the DOMException name into four honest
causes (denied / insecure / absent / busy), each with its own copy and
a working alternative, in both languages.
375 frontend tests pass; i18n parity gate passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -543,7 +543,11 @@ function EvpPanel() {
|
||||
const hasListeningTool = user?.unlocks?.includes('listening_tool') ?? false
|
||||
const [listening, setListening] = useState(false)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [denied, setDenied] = useState(false)
|
||||
// Why the mic isn't open, so the notice can be truthful about the cause
|
||||
// instead of always claiming the seeker refused it.
|
||||
const [micFailure, setMicFailure] = useState<
|
||||
'denied' | 'insecure' | 'absent' | 'busy' | null
|
||||
>(null)
|
||||
const [bandDb, setBandDb] = useState<number | null>(null)
|
||||
const [nyquist, setNyquist] = useState(24000)
|
||||
const [markers, setMarkers] = useState<ScopeAnomaly[]>([])
|
||||
@@ -569,7 +573,7 @@ function EvpPanel() {
|
||||
|
||||
const start = async () => {
|
||||
if (pending || listening) return
|
||||
setDenied(false)
|
||||
setMicFailure(null)
|
||||
setPending(true)
|
||||
const listener = listenerRef.current ?? new EvpListener()
|
||||
listenerRef.current = listener
|
||||
@@ -628,9 +632,26 @@ function EvpPanel() {
|
||||
}
|
||||
setListening(true)
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (listenerRef.current === listener) listenerRef.current = null
|
||||
setDenied(true)
|
||||
// Blaming the seeker for a refusal that never happened sends them to
|
||||
// fix the wrong thing. getUserMedia's DOMException names distinguish
|
||||
// the real causes, so each one gets truthful, actionable copy:
|
||||
// NotAllowedError is a genuine denial; NotFoundError means there's no
|
||||
// microphone at all; NotReadableError means another app holds it; and
|
||||
// on a bare http:// origin mediaDevices is simply absent.
|
||||
const name = err instanceof DOMException ? err.name : ''
|
||||
const noMediaApi =
|
||||
typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia
|
||||
setMicFailure(
|
||||
noMediaApi || name === 'SecurityError'
|
||||
? 'insecure'
|
||||
: name === 'NotFoundError' || name === 'OverconstrainedError'
|
||||
? 'absent'
|
||||
: name === 'NotReadableError' || name === 'AbortError'
|
||||
? 'busy'
|
||||
: 'denied',
|
||||
)
|
||||
setBandDb(null)
|
||||
} finally {
|
||||
setPending(false)
|
||||
@@ -677,10 +698,10 @@ function EvpPanel() {
|
||||
{t('seance.evp.listening')}
|
||||
</p>
|
||||
)}
|
||||
{denied && (
|
||||
{micFailure && (
|
||||
<div className="panel-notice error">
|
||||
<h4>{t('seance.evp.deniedTitle')}</h4>
|
||||
<p>{t('seance.evp.denied')}</p>
|
||||
<h4>{t(`seance.evp.fail.${micFailure}Title`)}</h4>
|
||||
<p>{t(`seance.evp.fail.${micFailure}`)}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
@@ -716,9 +737,15 @@ function RadioPanel() {
|
||||
const lastTuneUiRef = useRef(0)
|
||||
const sonifierRef = useRef(new SpectrumSonifier())
|
||||
|
||||
// False once this panel unmounts, so an open sequence still awaiting the
|
||||
// device picker can tell it was abandoned — sdrRef alone can't cover that
|
||||
// window, because it isn't assigned until after the awaits complete.
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
// Power down the dongle on unmount (mode switch / leaving the page).
|
||||
useEffect(
|
||||
() => () => {
|
||||
mountedRef.current = false
|
||||
const sdr = sdrRef.current
|
||||
sdrRef.current = null
|
||||
detectorRef.current = null
|
||||
@@ -769,6 +796,15 @@ function RadioPanel() {
|
||||
void sdr.close()
|
||||
return
|
||||
}
|
||||
// requestDevice() waits on the browser's device picker and open() runs
|
||||
// ~20 sequential control transfers, so the seeker has ample time to
|
||||
// switch modes or leave. Without this the dongle would be left claimed
|
||||
// and sweeping forever against an unmounted panel, and could only be
|
||||
// freed by closing the tab.
|
||||
if (!mountedRef.current) {
|
||||
void sdr.close()
|
||||
return
|
||||
}
|
||||
sdrRef.current = sdr
|
||||
detectorRef.current = new SpectrumAnomalyDetector()
|
||||
setPhase('sweeping')
|
||||
|
||||
Reference in New Issue
Block a user