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