[object Object]

← 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 →