From 064486857b2a6cbeef3e950162ee4bc474c58d34 Mon Sep 17 00:00:00 2001 From: Tim Verhoogt <115410250+Timverhoogt@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:26:18 +0000 Subject: [PATCH] fix: guard camera playback lifecycle and stale stream callbacks --- homebody/static/camera.js | 45 ++++++++++++++++++------- homebody/static/index.html | 16 ++++----- homebody/static/service-worker.js | 18 +++++----- tests/camera_ui.browser.cjs | 51 +++++++++++++++++++++++++++++ tests/test_announcements.py | 2 +- tests/test_home_assistant_bridge.py | 4 +-- tests/test_kids_mode.py | 4 +-- tests/test_motor_ui.py | 6 ++-- 8 files changed, 110 insertions(+), 36 deletions(-) diff --git a/homebody/static/camera.js b/homebody/static/camera.js index b81766a..4b949d1 100644 --- a/homebody/static/camera.js +++ b/homebody/static/camera.js @@ -11,6 +11,7 @@ producersListener: null, connectionListener: null, producerId: null, + playbackGeneration: 0, enabled: false, controlsEnabled: false, controlsHandedness: "right", @@ -184,6 +185,7 @@ } function detachStream() { + state.playbackGeneration += 1; void endControlGesture("Controls stopped with the camera feed"); const video = byId("reachy-camera-video"); stopVisionOverlay(); @@ -200,8 +202,9 @@ function cleanupConnection() { detachStream(); if (state.session) { - try { state.session.close(); } catch { /* The signaling session may already be closed. */ } - state.session = null; + const session = state.session; + state.session = null; // Invalidate callbacks before close can dispatch events. + try { session.close(); } catch { /* The signaling session may already be closed. */ } } if (state.api) { try { @@ -255,37 +258,53 @@ } function attachSession(api, producer) { - if (!state.requested || state.session || !isReachyCameraProducer(producer)) return; + if (!state.requested || state.api !== api || state.session || !isReachyCameraProducer(producer)) return; const session = api.createConsumerSession(producer.id); if (!session) return; state.session = session; state.producerId = producer.id; session.addEventListener("error", (event) => { + if (state.session !== session) return; const message = event?.message || "WebRTC camera stream failed"; state.requested = false; cleanupConnection(); setUi("Error", message, "error"); }); session.addEventListener("closed", () => { + if (state.session !== session) return; const requested = state.requested; - state.session = null; - detachStream(); - updateButtons(); + state.requested = false; + cleanupConnection(); if (requested) setUi("Offline", "Camera connection closed. Press Start to reconnect.", "error"); }); session.addEventListener("streamsChanged", () => { - if (!state.requested || !session.streams?.length) return; + if (!state.requested || state.session !== session || !session.streams?.length) return; const stream = session.streams[0]; + // GStreamer may announce the same stream more than once. Reassignment + // interrupts the existing play request in Chromium. + if (state.stream === stream) return; + if (state.stream) detachStream(); + const generation = ++state.playbackGeneration; + const isCurrent = () => state.requested && state.session === session + && state.stream === stream && state.playbackGeneration === generation; stream.getAudioTracks().forEach((track) => { track.enabled = false; }); state.stream = stream; const video = byId("reachy-camera-video"); byId("camera-viewer").classList.add("live"); startVisionOverlay(); video.srcObject = stream; + updateButtons(); // Stop must remain available while play is pending. video.play().then(() => { + if (!isCurrent()) return; setUi("Live", "Local camera connected. No frames are sent to Hermes or OpenAI.", "ok"); updateButtons(); - }).catch((error) => setUi("Paused", `Camera connected, but playback was blocked: ${String(error)}`, "error")); + }).catch((error) => { + // Stop/privacy/replacement can intentionally abort a pending play. + // Never let its late result overwrite the current connection or policy. + if (!isCurrent()) return; + const reason = error?.name === "AbortError" ? "interrupted" : "blocked"; + setUi("Paused", `Camera connected, but playback was ${reason}: ${String(error)}`, "error"); + }); }); session.connect(); } @@ -314,9 +333,13 @@ }); state.api = api; state.connectionListener = { - connected: () => setUi("Connecting", "Camera signaling connected; waiting for video…"), + connected: () => { + if (state.api === api && state.requested && !state.stream) { + setUi("Connecting", "Camera signaling connected; waiting for video…"); + } + }, disconnected: () => { - if (!state.requested) return; + if (!state.requested || state.api !== api) return; state.requested = false; cleanupConnection(); setUi("Offline", "Camera signaling disconnected. Press Start to reconnect.", "error"); @@ -325,7 +348,7 @@ state.producersListener = { producerAdded: (producer) => { if (isReachyCameraProducer(producer)) attachSession(api, producer); }, producerRemoved: (producer) => { - if (!state.requested || producer?.id !== state.producerId) return; + if (!state.requested || state.api !== api || producer?.id !== state.producerId) return; state.requested = false; cleanupConnection(); setUi("Offline", "Reachy's camera producer stopped.", "error"); diff --git a/homebody/static/index.html b/homebody/static/index.html index dede363..d14f2fd 100644 --- a/homebody/static/index.html +++ b/homebody/static/index.html @@ -13,7 +13,7 @@ - +
@@ -976,13 +976,13 @@

App and host lifecycle

- - + + - - - - - + + + + + diff --git a/homebody/static/service-worker.js b/homebody/static/service-worker.js index 7ea35bc..c3ce470 100644 --- a/homebody/static/service-worker.js +++ b/homebody/static/service-worker.js @@ -1,16 +1,16 @@ -const CACHE_NAME = "homebody-shell-v71"; +const CACHE_NAME = "homebody-shell-v72"; const APP_SHELL = [ "/", "/manifest.webmanifest", - "/static/style.css?v=71", + "/static/style.css?v=72", "/static/gstwebrtc-api.js", - "/static/camera.js?v=71", - "/static/main.js?v=71", - "/static/workspace.js?v=71", - "/static/native-workspace.js?v=71", - "/static/agent-connection.js?v=71", - "/static/owner.js?v=71", - "/static/notifications.js?v=71", + "/static/camera.js?v=72", + "/static/main.js?v=72", + "/static/workspace.js?v=72", + "/static/native-workspace.js?v=72", + "/static/agent-connection.js?v=72", + "/static/owner.js?v=72", + "/static/notifications.js?v=72", "/static/icon-192.png", "/static/icon-512.png", "/static/icon-maskable-512.png", diff --git a/tests/camera_ui.browser.cjs b/tests/camera_ui.browser.cjs index b1c770e..2fc4544 100644 --- a/tests/camera_ui.browser.cjs +++ b/tests/camera_ui.browser.cjs @@ -42,6 +42,7 @@ async function setup(t, { width = 900, height = 700, fallback = false } = {}) { } createConsumerSession() { const session = new EventTarget(); + (window.cameraSessions ||= []).push(session); session.streams = [stream]; session.connect = () => session.dispatchEvent(new Event('streamsChanged')); session.close = () => {}; @@ -62,6 +63,56 @@ async function setup(t, { width = 900, height = 700, fallback = false } = {}) { return page; } +for (const outcome of ['reject', 'resolve']) { + test(`late play ${outcome} cannot overwrite Stop or a new camera connection`, async t => { + const page = await setup(t); + await page.evaluate(() => { + window.ReachyCamera.stop(); + document.querySelector('#reachy-camera-video').play = () => new Promise((resolve, reject) => { window.pendingPlay = { resolve, reject }; }); + }); + await page.locator('#camera-live-start').click(); + await page.evaluate(() => { + window.oldSession = window.cameraSessions.at(-1); + window.ReachyCamera.stop('Stopped for privacy.'); + }); + await page.evaluate(outcome => { + if (outcome === 'reject') window.pendingPlay.reject(new DOMException('Interrupted by pause', 'AbortError')); + else window.pendingPlay.resolve(); + }, outcome); + assert.equal(await page.locator('#camera-live-status').textContent(), 'Off'); + assert.equal(await page.locator('#camera-message').textContent(), 'Stopped for privacy.'); + await page.evaluate(() => { document.querySelector('#reachy-camera-video').play = () => Promise.resolve(); }); + await page.locator('#camera-live-start').click(); + await page.waitForFunction(() => document.querySelector('#camera-live-status').textContent === 'Live'); + await page.evaluate(() => { + window.oldSession.dispatchEvent(new Event('closed')); + window.oldSession.dispatchEvent(new Event('error')); + window.oldSession.dispatchEvent(new Event('streamsChanged')); + }); + assert.equal(await page.locator('#camera-live-status').textContent(), 'Live'); + assert.equal(await page.locator('#camera-live-fullscreen').isEnabled(), true); + }); +} + +test('duplicate stream events do not restart pending playback; genuine autoplay denial remains visible', async t => { + const page = await setup(t); + await page.evaluate(() => { + window.ReachyCamera.stop(); + window.playCalls = 0; + document.querySelector('#reachy-camera-video').play = () => { + window.playCalls++; + return new Promise((resolve, reject) => { window.pendingPlay = { resolve, reject }; }); + }; + }); + await page.locator('#camera-live-start').click(); + await page.evaluate(() => window.cameraSessions.at(-1).dispatchEvent(new Event('streamsChanged'))); + assert.equal(await page.evaluate(() => window.playCalls), 1); + assert.equal(await page.locator('#camera-live-stop').isEnabled(), true); + await page.evaluate(() => window.pendingPlay.reject(new DOMException('Autoplay denied', 'NotAllowedError'))); + assert.equal(await page.locator('#camera-live-status').textContent(), 'Paused'); + assert.match(await page.locator('#camera-message').textContent(), /NotAllowedError/); +}); + for (const fallback of [false, true]) { for (const viewport of [{ width: 900, height: 700 }, { width: 390, height: 844 }, { width: 844, height: 390 }]) { test(`${fallback ? 'fallback' : 'native'} fullscreen ${viewport.width}x${viewport.height}: disabled guidance, opt-in, release and exit`, async t => { diff --git a/tests/test_announcements.py b/tests/test_announcements.py index 62351c8..904ce90 100644 --- a/tests/test_announcements.py +++ b/tests/test_announcements.py @@ -437,4 +437,4 @@ def test_announcement_ui_exposes_full_tts_controls_and_private_routes() -> None: assert 'role="alert"' in html assert "Voice only · do not change power state" in html assert "homebody-shell-v" in worker - assert "/static/main.js?v=71" in html + assert "/static/main.js?v=72" in html diff --git a/tests/test_home_assistant_bridge.py b/tests/test_home_assistant_bridge.py index ad10609..a319de8 100644 --- a/tests/test_home_assistant_bridge.py +++ b/tests/test_home_assistant_bridge.py @@ -145,8 +145,8 @@ def test_home_assistant_ui_exposes_nested_opt_ins_and_v44_assets() -> None: assert "toggleHomeAssistantOptions" in script assert "homebody-shell-v" in worker for asset in ("style.css", "camera.js", "main.js"): - assert f"/static/{asset}?v=71" in html - assert f'"/static/{asset}?v=71"' in worker + assert f"/static/{asset}?v=72" in html + assert f'"/static/{asset}?v=72"' in worker def test_runtime_provider_maps_native_daemon_telemetry_without_enabling_controls() -> None: diff --git a/tests/test_kids_mode.py b/tests/test_kids_mode.py index 30d91a6..e0661f3 100644 --- a/tests/test_kids_mode.py +++ b/tests/test_kids_mode.py @@ -616,8 +616,8 @@ def test_kids_static_assets_advance_pwa_cache_together() -> None: worker = (STATIC / "service-worker.js").read_text(encoding="utf-8") assert "homebody-shell-v" in worker for asset in ("style.css", "camera.js", "main.js"): - assert f"/static/{asset}?v=71" in html - assert f'"/static/{asset}?v=71"' in worker + assert f"/static/{asset}?v=72" in html + assert f'"/static/{asset}?v=72"' in worker def test_runtime_reports_kids_session_start_replacement_and_end_to_bridge() -> None: diff --git a/tests/test_motor_ui.py b/tests/test_motor_ui.py index 86473e4..20426b8 100644 --- a/tests/test_motor_ui.py +++ b/tests/test_motor_ui.py @@ -99,6 +99,6 @@ def test_motor_ui_serializes_power_transitions_and_reports_confirmed_state() -> assert "position: fixed" in style assert 'data-action-busy="false"' in html assert 'homebody-shell-v' in worker - assert '/static/style.css?v=71' in html - assert '/static/camera.js?v=71' in html - assert '/static/main.js?v=71' in html + assert '/static/style.css?v=72' in html + assert '/static/camera.js?v=72' in html + assert '/static/main.js?v=72' in html