useUserMedia: concurrent/aborted start leaks a MediaStream and leaves the camera active
Describe the bug
useUserMedia's internal _start() guards against concurrent calls by checking stream.value, but that check happens before the await:
async function _start() {
if (!isSupported.value || stream.value)
return
stream.value = await navigator!.mediaDevices.getUserMedia({ ... })
return stream.value
}
While getUserMedia() is in flight, stream.value is still undefined, so the guard doesn't hold. This leads to two distinct problems.
1. Concurrent starts orphan a MediaStream. Two overlapping _start() calls both pass the guard and both call getUserMedia(). The second assignment overwrites the first, and the first stream is never stopped — its camera/mic tracks stay live with no reference left to stop them.
2. stop() during an in-flight start doesn't stop anything. _stop() reads stream.value, which is still undefined mid-flight, so it stops nothing and sets undefined. The pending getUserMedia() then resolves and assigns stream.value after the stop. The result is an active camera after the user has explicitly disabled it — the hardware indicator stays on.
Both are reachable from the public API:
- The
enabledwatcher calls_start()without awaiting it, so togglingenabledquickly (or mounting and unmounting fast) interleaves calls:watch(enabled, (v) => { if (v) _start() else _stop() }, { immediate: true }) start()is public and awaits_start(), but nothing prevents a secondstart()(or anenabledflip) from overlapping the first.
restart() is the same shape — _stop() immediately followed by start().
Reproduction
Reduced from the actual implementation, with getUserMedia stubbed to take 20ms:
let streamValue
const live = new Set()
let n = 0
const getUserMedia = () => new Promise(r =>
setTimeout(() => { const s = { id: ++n, getTracks: () => [{ stop() { live.delete(s) } }] }; live.add(s); r(s) }, 20))
async function _start() {
if (streamValue) return
streamValue = await getUserMedia()
}
function _stop() {
streamValue?.getTracks().forEach(t => t.stop())
streamValue = undefined
}
// case 2: enabled true -> false immediately
_start()
_stop()
await new Promise(r => setTimeout(r, 60))
console.log(streamValue?.id) // 1 -- assigned after stop()
console.log([...live].map(s => s.id)) // [1] -- never stopped, camera still on
Case 1 (_start(); _start()) leaves two streams created and stream 1 unstoppable.
Expected behavior
- Overlapping starts should not create more than one live
MediaStream. - A
stop()issued while a start is pending should ensure the resulting stream is stopped rather than adopted, so the camera/mic is actually released.
Possible fix
Track the pending promise and/or a generation counter, then discard a resolved stream if it's stale:
let pending: Promise<MediaStream> | undefined
let generation = 0
async function _start() {
if (!isSupported.value || stream.value || pending) return pending
const gen = ++generation
pending = navigator!.mediaDevices.getUserMedia({ ... })
try {
const s = await pending
if (gen !== generation) { // superseded by a stop/restart
s.getTracks().forEach(t => t.stop())
return
}
stream.value = s
return s
} finally {
pending = undefined
}
}
_stop() would bump generation so an in-flight stream is stopped on arrival instead of assigned.
I'd rather confirm the preferred shape before sending a patch — happy to open a PR with tests once you've picked a direction. Note there's currently no test file under packages/core/useUserMedia/, so I'd add one.
I checked #5392 (stopOnDispose) and it only touches dispose behaviour, so it doesn't overlap with this.
Additional context
Found while reviewing the media composables ahead of the WebRTC discussion in #4270. My day job is maintaining a WebRTC-based cloud gaming web SDK, where this exact class of bug — a getUserMedia/getDisplayMedia promise resolving after teardown and leaving hardware live — is a recurring source of "camera light stays on" reports.
useDisplayMedia has the same _start() shape and looks affected in the same way; I can confirm and fold it in if useful.
Source: vueuse/vueuse