[object Object]

← back to Dw Photo Capture

Wire ended + mute listeners on _tsTrack, scoped by generation (TK-12128)

0e42bafa15e7d7dcd63722aea4e4a117d97ad2be · 2026-09-24 09:04:00 -0700 · Steve Abrams

Cody-found gap from the TK-12124 red-team pass: the two-shot flow had no
mute/ended listener on _tsTrack. On iOS, a LATER camera request elsewhere
(another app, FaceTime) can mute or end an earlier stream's track without any
getUserMedia call of ours — the preview freezes while _tsLive stays true and
the shutter would save a frozen frame.

batch.html's reference handler (line ~310) only covers 'ended'. The failure
Cody actually described is specifically a MUTE (the track never ends, it just
stops delivering frames) — wiring 'ended' alone per the literal instruction
would have left the named bug unfixed, so both events are wired here.

On either event: _tsLive resets to false, the shutter disables, a toast fires
("Camera stopped — tap Front/Back to restart"), and the stream is stopped +
cleared so a retap goes through the existing bounded acquire path cleanly.

Scoped to the CURRENT generation via the _tsStreamGen counter (from the
TK-12124 pass) plus a direct track-identity check, so an old, already-replaced
track's late event — e.g. from the .stop() call the next acquire makes on it —
can never kill a newer, genuinely-live stream.

Negative-tested: confirmed both 'ended' and 'mute' correctly reset _tsLive/
disable the shutter/toast (mute dispatched synthetically since it's exclusively
OS-triggered with no script-callable trigger; 'ended' likewise synthetic since
MediaStreamTrack.stop() does NOT fire 'ended' per spec — only an external end
does), confirmed a retap re-acquires cleanly afterward, and confirmed a STALE
old-generation track firing either event does NOT affect a newer live stream.
12/12 new assertions pass; the existing 25 TK-12124 assertions are unaffected.

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

Files touched

Diff

commit 0e42bafa15e7d7dcd63722aea4e4a117d97ad2be
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 24 09:04:00 2026 -0700

    Wire ended + mute listeners on _tsTrack, scoped by generation (TK-12128)
    
    Cody-found gap from the TK-12124 red-team pass: the two-shot flow had no
    mute/ended listener on _tsTrack. On iOS, a LATER camera request elsewhere
    (another app, FaceTime) can mute or end an earlier stream's track without any
    getUserMedia call of ours — the preview freezes while _tsLive stays true and
    the shutter would save a frozen frame.
    
    batch.html's reference handler (line ~310) only covers 'ended'. The failure
    Cody actually described is specifically a MUTE (the track never ends, it just
    stops delivering frames) — wiring 'ended' alone per the literal instruction
    would have left the named bug unfixed, so both events are wired here.
    
    On either event: _tsLive resets to false, the shutter disables, a toast fires
    ("Camera stopped — tap Front/Back to restart"), and the stream is stopped +
    cleared so a retap goes through the existing bounded acquire path cleanly.
    
    Scoped to the CURRENT generation via the _tsStreamGen counter (from the
    TK-12124 pass) plus a direct track-identity check, so an old, already-replaced
    track's late event — e.g. from the .stop() call the next acquire makes on it —
    can never kill a newer, genuinely-live stream.
    
    Negative-tested: confirmed both 'ended' and 'mute' correctly reset _tsLive/
    disable the shutter/toast (mute dispatched synthetically since it's exclusively
    OS-triggered with no script-callable trigger; 'ended' likewise synthetic since
    MediaStreamTrack.stop() does NOT fire 'ended' per spec — only an external end
    does), confirmed a retap re-acquires cleanly afterward, and confirmed a STALE
    old-generation track firing either event does NOT affect a newer live stream.
    12/12 new assertions pass; the existing 25 TK-12124 assertions are unaffected.
    
    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY
---
 public/index.html | 17 +++++++++++++++++
 1 file changed, 17 insertions(+)

diff --git a/public/index.html b/public/index.html
index 968a11d..6a186bc 100644
--- a/public/index.html
+++ b/public/index.html
@@ -2324,6 +2324,23 @@ async function startTsStream(){
       _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');
     }
+    // TK-12128 (Cody-found gap): nothing watched _tsTrack for 'ended' OR 'mute' — on iOS a LATER camera
+    // request elsewhere can mute (or end) THIS track without any getUserMedia call of ours, so the preview
+    // freezes while _tsLive stays true and the shutter would save a frozen frame. batch.html's reference
+    // handler only covers 'ended'; the failure Cody actually named is a MUTE (track never ends, just stops
+    // delivering frames), so both are wired here — 'ended'-only would leave the named bug unfixed.
+    // Scoped to THIS generation (myGen + a direct track-identity check) so an old, now-replaced track's
+    // late event (e.g. from the .stop() call above on the NEXT acquire) can never kill a newer live stream.
+    const _tsTrackGen=myGen, _tsTrackRef=_tsTrack;
+    const onTrackDead=()=>{
+      if(_tsTrackGen!==_tsStreamGen || _tsTrack!==_tsTrackRef) return; // stale — a newer generation already took over
+      _tsLive=false; if(_sb) _sb.disabled=true;
+      toast('Camera stopped — tap Front/Back to restart');
+      if(_tsStream){ _tsStream.getTracks().forEach(t=>t.stop()); _tsStream=null; }
+      v.srcObject=null;
+    };
+    _tsTrack.addEventListener('ended',onTrackDead);
+    _tsTrack.addEventListener('mute',onTrackDead);
   } finally { _tsAcquiring=false; }
 }
 async function tsRequestWake(){ try{ if('wakeLock' in navigator){ _tsWake=await navigator.wakeLock.request('screen'); } }catch(e){} }

← b2bd893 Add persistent "applies at capture, not preview" hint on the  ·  back to Dw Photo Capture  ·  Extract shared acquireCamera() helper, adopt across 5 unboun a1b8ee3 →