[object Object]

← back to Dw Photo Capture

Fix: bound getUserMedia() with an 8s timeout so a hung camera prompt can't brick the shutter (TK-12124)

ca60ce9ba1e7334211b15b1f362b4b98147d89a3 · 2026-09-24 08:39:01 -0700 · Steve Abrams

startTsStream() awaited getUserMedia() with no timeout. On WebKit/Safari (also a
denied/backgrounded permission prompt or an MDM camera restriction on any browser)
that promise can simply never settle, which left _tsOpening/_tsAcquiring stuck true
forever and permanently disabled the shutter + camera-flip buttons until reload.

Race each acquisition attempt against an 8s timer; on timeout, throw a distinct
camera-timeout error so the existing finally blocks reset both flags, surface a
clear toast ("Camera took too long to respond — using snap camera"), and fall
back to the native #frontInput/#backInput capture input. A late-arriving stream
that resolves after the bail-out is stopped immediately (via a generation
counter) instead of being silently adopted as an orphaned hot camera. Success
path is unchanged (verified — no delay, no timeout artifact).

Negative-tested with a real headless Chrome harness stubbing getUserMedia to
never resolve: confirmed the pre-fix code hangs forever (test had to be killed
at a 30s wall-clock timeout), and the fix resolves within the bounded window,
resets both flags, shows the toast, and falls back correctly — 17/17 assertions
pass, including an unaffected fast-path success case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY

Files touched

Diff

commit ca60ce9ba1e7334211b15b1f362b4b98147d89a3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 24 08:39:01 2026 -0700

    Fix: bound getUserMedia() with an 8s timeout so a hung camera prompt can't brick the shutter (TK-12124)
    
    startTsStream() awaited getUserMedia() with no timeout. On WebKit/Safari (also a
    denied/backgrounded permission prompt or an MDM camera restriction on any browser)
    that promise can simply never settle, which left _tsOpening/_tsAcquiring stuck true
    forever and permanently disabled the shutter + camera-flip buttons until reload.
    
    Race each acquisition attempt against an 8s timer; on timeout, throw a distinct
    camera-timeout error so the existing finally blocks reset both flags, surface a
    clear toast ("Camera took too long to respond — using snap camera"), and fall
    back to the native #frontInput/#backInput capture input. A late-arriving stream
    that resolves after the bail-out is stopped immediately (via a generation
    counter) instead of being silently adopted as an orphaned hot camera. Success
    path is unchanged (verified — no delay, no timeout artifact).
    
    Negative-tested with a real headless Chrome harness stubbing getUserMedia to
    never resolve: confirmed the pre-fix code hangs forever (test had to be killed
    at a 30s wall-clock timeout), and the fix resolves within the bounded window,
    resets both flags, shows the toast, and falls back correctly — 17/17 assertions
    pass, including an unaffected fast-path success case.
    
    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY
---
 public/index.html | 31 ++++++++++++++++++++++++++++---
 1 file changed, 28 insertions(+), 3 deletions(-)

diff --git a/public/index.html b/public/index.html
index 07ea50d..0e604da 100644
--- a/public/index.html
+++ b/public/index.html
@@ -2128,6 +2128,11 @@ function wbSampleAt(clientX,clientY){ const v=$('#wbVideo'); if(!v.videoWidth)re
 // The colour is corrected on the LIVE feed and BAKED into the saved JPEG. ONE manual-getImageData bake
 // (tsPipeline) runs on BOTH the ~80ms live preview AND the full-res shutter, so preview == export.
 let _tsStream=null,_tsTrack=null,_tsPhase='front',_tsBusy=false,_tsLive=false,_tsFrontImg=null,_tsPvTimer=null,_tsWake=null,_tsOpening=false,_tsAcquiring=false;
+let _tsStreamGen=0;                                          // bumped every startTsStream() call — lets a late/orphaned getUserMedia resolution (after a timeout bail-out) be detected and released instead of silently adopted
+// TK-12124: getUserMedia() can hang forever on WebKit/Safari (denied-but-not-rejected permission, backgrounded
+// 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;
 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)
@@ -2273,6 +2278,7 @@ async function startTsStream(){
   if(_tsAcquiring) return;                     // race guard: never two getUserMedia at once (double-tap / flip) → orphaned hot stream
   _tsAcquiring=true; _tsLive=false;            // not live until a fresh frame is confirmed → shutter can't bake a frozen frame
   const _sb=$('#tsShutterBtn'); if(_sb) _sb.disabled=true;
+  const myGen=++_tsStreamGen;
   try{
     if(_tsStream){ _tsStream.getTracks().forEach(t=>t.stop()); _tsStream=null; }
     const attempts=[
@@ -2280,8 +2286,23 @@ async function startTsStream(){
       { video:{ facingMode:{ ideal:camFacing } }, audio:false },
       { video:true, audio:false }
     ];
-    let err=null;
-    for(const cst of attempts){ try{ _tsStream=await navigator.mediaDevices.getUserMedia(cst); break; }catch(e){ err=e; } }
+    let err=null, timedOut=false;
+    // TK-12124: getUserMedia() itself has no timeout param, so bound it with a race — a hung/never-settling
+    // permission prompt must not leave _tsAcquiring stuck true forever (that's what bricked the shutter).
+    const acquire=(async()=>{
+      for(const cst of attempts){
+        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
+          return s;
+        }catch(e){ err=e; }
+      }
+      return null;
+    })();
+    const timeout=new Promise(res=>setTimeout(()=>{ timedOut=true; res('timeout'); },TS_GETUSERMEDIA_TIMEOUT_MS));
+    const winner=await Promise.race([acquire,timeout]);
+    if(winner==='timeout') throw new Error('camera-timeout');
+    _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
@@ -2301,7 +2322,11 @@ async function openTwoShotCam(startPhase){
     return $(startPhase==='back'?'#backInput':'#frontInput').click();
   }
   try{ await startTsStream(); }
-  catch(e){ toast('Camera blocked — using snap'); return $(startPhase==='back'?'#backInput':'#frontInput').click(); }
+  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');
+    return $(startPhase==='back'?'#backInput':'#frontInput').click();
+  }
   _tsPhase=startPhase; _tsWB=wbGet(); _tsTune=tsLoadTune(); _tsPreset=tsLoadPreset();
   // vendor bar mirrors the sticky vendor (one place only — no duplicate select)
   const ven=($('#addVendor')&&$('#addVendor').value)|| (typeof LS==='function'?LS('vendor'):'') ||'';

← 1d04e3f auto-data-snapshot: 2026-09-24T04:27:32 (1 data files) — dat  ·  back to Dw Photo Capture  ·  Fix 3 Cody-found holes in the getUserMedia timeout fix (TK-1 9f4d2c2 →