← back to Dw Photo Capture
Fix 3 Cody-found holes in the getUserMedia timeout fix (TK-12124 follow-up)
9f4d2c229839a847f025c99e949657582cb07320 · 2026-09-24 08:52:33 -0700 · Steve Abrams
Cody red-teamed ca60ce9 (verdict FIX-FIRST). Three holes, all in startTsStream()/
openTwoShotCam():
Hole 1 (most serious — same bug, one await later): `await v.play()` right after
the getUserMedia fix was itself unbounded. A track that CONNECTS but never
produces a frame (camera held by another app/FaceTime call, muted track) can
leave play() pending forever on WebKit, sticking _tsAcquiring true again —
exactly the brick ca60ce9 claimed to have fixed. Bounded play() with a 3s
Promise.race; _tsLive is now keyed off whether play() itself settled, not off
videoWidth (verified empirically that #tsVideo's own `autoplay muted` attribute
populates videoWidth via native autoplay regardless of whether this explicit
play() call ever resolves, so videoWidth can't distinguish "confirmed live"
from "still nothing" and would have been a false signal).
Hole 2: the attempts loop didn't re-check timedOut/generation at the TOP of
each iteration, only after a getUserMedia call returned — so a permission
decision arriving just after the 8s bail-out could still fire a 2nd/3rd
getUserMedia call (fresh prompt + camera light) for a caller who already moved
to the fallback. Added the guard at loop entry.
Hole 3: the timeout-path fallback auto-.click()'d #frontInput ~8-14s after the
original tap. iOS Safari (and usually Chrome past ~5s) will have already
expired the user-activation window by then, so that click would silently no-op
— the toast promised "using snap camera" and then nothing would happen. Changed
the timeout branch to ask for a fresh tap instead of auto-clicking; the fast
flat-denial branch (still well inside the activation window) keeps the
original auto-click behavior unchanged.
Negative-tested: added a 4th test case (v.play() stubbed to never resolve,
getUserMedia resolving fine) — confirmed it hangs forever against the
pre-hole-1-fix commit (killed at a 30s wall-clock timeout), and post-fix
settles within the bounded ~3s window with _tsAcquiring reset. Updated T2 into
2a/2b to separately prove the timeout branch no longer auto-clicks (hole 3) and
the fast-denial branch still does (behavior preserved). All 25 assertions pass
across the original 3 test scenarios + the 2 hole-specific splits + the new
hole-1 case.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY
Files touched
Diff
commit 9f4d2c229839a847f025c99e949657582cb07320
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 24 08:52:33 2026 -0700
Fix 3 Cody-found holes in the getUserMedia timeout fix (TK-12124 follow-up)
Cody red-teamed ca60ce9 (verdict FIX-FIRST). Three holes, all in startTsStream()/
openTwoShotCam():
Hole 1 (most serious — same bug, one await later): `await v.play()` right after
the getUserMedia fix was itself unbounded. A track that CONNECTS but never
produces a frame (camera held by another app/FaceTime call, muted track) can
leave play() pending forever on WebKit, sticking _tsAcquiring true again —
exactly the brick ca60ce9 claimed to have fixed. Bounded play() with a 3s
Promise.race; _tsLive is now keyed off whether play() itself settled, not off
videoWidth (verified empirically that #tsVideo's own `autoplay muted` attribute
populates videoWidth via native autoplay regardless of whether this explicit
play() call ever resolves, so videoWidth can't distinguish "confirmed live"
from "still nothing" and would have been a false signal).
Hole 2: the attempts loop didn't re-check timedOut/generation at the TOP of
each iteration, only after a getUserMedia call returned — so a permission
decision arriving just after the 8s bail-out could still fire a 2nd/3rd
getUserMedia call (fresh prompt + camera light) for a caller who already moved
to the fallback. Added the guard at loop entry.
Hole 3: the timeout-path fallback auto-.click()'d #frontInput ~8-14s after the
original tap. iOS Safari (and usually Chrome past ~5s) will have already
expired the user-activation window by then, so that click would silently no-op
— the toast promised "using snap camera" and then nothing would happen. Changed
the timeout branch to ask for a fresh tap instead of auto-clicking; the fast
flat-denial branch (still well inside the activation window) keeps the
original auto-click behavior unchanged.
Negative-tested: added a 4th test case (v.play() stubbed to never resolve,
getUserMedia resolving fine) — confirmed it hangs forever against the
pre-hole-1-fix commit (killed at a 30s wall-clock timeout), and post-fix
settles within the bounded ~3s window with _tsAcquiring reset. Updated T2 into
2a/2b to separately prove the timeout branch no longer auto-clicks (hole 3) and
the fast-denial branch still does (behavior preserved). All 25 assertions pass
across the original 3 test scenarios + the 2 hole-specific splits + the new
hole-1 case.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY
---
public/index.html | 28 ++++++++++++++++++++++++----
1 file changed, 24 insertions(+), 4 deletions(-)
diff --git a/public/index.html b/public/index.html
index 0e604da..8d02d2b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -2133,6 +2133,7 @@ let _tsStreamGen=0; // bumped every sta
// prompt, MDM camera restriction) — bound the wait so the UI never bricks. 8s ~= comfortably above how long a
// real permission-prompt tap takes, short enough that a genuinely hung prompt still recovers within one breath.
const TS_GETUSERMEDIA_TIMEOUT_MS=8000;
+const TS_PLAY_TIMEOUT_MS=3000; // TK-12124 Cody hole 1: bound v.play() too — same hang class, shorter budget since play() should be near-instant once a stream exists
let _tsWB=null; // {rGain,gGain,bGain} — live white-balance gains (from wbGet)
let _tsTune=CapturePipeline.defaultTune(); // live colour tune (full Photoshop set), persisted per device
let _tsPreset='custom'; // last-picked preset name, persisted (dwTsPreset)
@@ -2291,6 +2292,7 @@ async function startTsStream(){
// permission prompt must not leave _tsAcquiring stuck true forever (that's what bricked the shutter).
const acquire=(async()=>{
for(const cst of attempts){
+ if(timedOut || myGen!==_tsStreamGen) return null; // TK-12124 Cody hole 2: we already bailed on timeout — don't fire a 2nd/3rd getUserMedia (fresh prompt + camera light) for a caller who's already moved to the fallback
try{
const s=await navigator.mediaDevices.getUserMedia(cst);
if(timedOut || myGen!==_tsStreamGen){ s.getTracks().forEach(t=>t.stop()); return null; } // late arrival after we already bailed on timeout — release it, never adopt a stream nobody's tracking
@@ -2305,8 +2307,20 @@ async function startTsStream(){
_tsStream=winner;
if(!_tsStream) throw (err||new Error('no camera'));
const v=$('#tsVideo'); v.srcObject=_tsStream; _tsTrack=_tsStream.getVideoTracks()[0];
- try{ await v.play(); }catch(e){} // explicit play in the tap handler → video shows <1s
- _tsLive=true; if(_sb) _sb.disabled=false; // only NOW is the shutter armed
+ // TK-12124 Cody hole 1: v.play() can hang exactly like getUserMedia did — a track that CONNECTS but never
+ // produces a frame (camera held by another app/FaceTime, muted track) can leave play() pending forever on
+ // WebKit. Key the live-flag off whether OUR play() call actually settled — NOT off videoWidth, which
+ // (verified empirically) populates from the <video autoplay> element's OWN native autoplay the instant
+ // srcObject is set, regardless of whether this explicit play() ever resolves, so it can't tell "real
+ // frame confirmed" from "still nothing" and would falsely arm the shutter on a genuinely dead track.
+ let _playTimedOut=false;
+ await Promise.race([ v.play().catch(()=>{}), new Promise(r=>setTimeout(()=>{ _playTimedOut=true; r(); },TS_PLAY_TIMEOUT_MS)) ]);
+ _tsLive = !_playTimedOut;
+ if(_sb) _sb.disabled=!_tsLive; // only NOW is the shutter armed
+ if(!_tsLive){
+ _tsStream.getTracks().forEach(t=>t.stop()); _tsStream=null; v.srcObject=null; // release the held camera immediately — don't leave the light on through the fallback
+ throw new Error('camera-timeout');
+ }
} finally { _tsAcquiring=false; }
}
async function tsRequestWake(){ try{ if('wakeLock' in navigator){ _tsWake=await navigator.wakeLock.request('screen'); } }catch(e){} }
@@ -2323,8 +2337,14 @@ async function openTwoShotCam(startPhase){
}
try{ await startTsStream(); }
catch(e){
- // TK-12124: a timed-out/hung getUserMedia gets its own message so the operator knows it's not a flat permission denial
- toast(e && e.message==='camera-timeout' ? 'Camera took too long to respond — using snap camera' : 'Camera blocked — using snap');
+ if(e && e.message==='camera-timeout'){
+ // TK-12124 Cody hole 3: this fires ~8-14s after the original tap — iOS Safari (and usually Chrome >~5s)
+ // has already expired the user-activation window by then, so an auto .click() on the native file
+ // input would silently no-op. Ask for a fresh tap instead of pretending the fallback ran.
+ toast('Camera didn’t respond — tap Photo 1 again to use the snap camera');
+ return;
+ }
+ toast('Camera blocked — using snap'); // instant permission denial — still inside the original tap's activation window, auto-click is safe
return $(startPhase==='back'?'#backInput':'#frontInput').click();
}
_tsPhase=startPhase; _tsWB=wbGet(); _tsTune=tsLoadTune(); _tsPreset=tsLoadPreset();
← ca60ce9 Fix: bound getUserMedia() with an 8s timeout so a hung camer
·
back to Dw Photo Capture
·
Add persistent "applies at capture, not preview" hint on the b2bd893 →