From 54caf05342a3a96e4d65218eca1d6e37e021b6da Mon Sep 17 00:00:00 2001 From: Andreas Richardson Date: Sat, 12 Sep 2026 15:57:08 +0100 Subject: [PATCH] fix(homekit): only hang up when the last HomeKit session ends The stop handler ended the Twilio call whenever any streaming session stopped. HomeKit runs up to cameraStreamCount sessions at once and starts a second one when you move between the room view's camera tile and the full camera view, so a STOP routinely arrives while another session is still streaming. Observed on the production Pi: session ac5c8c21 stopping ended the call under session d332e46a, three seconds into a live stream. The call now ends only once activeSessions is empty. This predates the grace window, which merely delayed the same hangup by three seconds and made it legible in the journal. Before that it fired instantly and looked like the stream simply dying. tests/homekit-hangup-grace.test.cjs gains four cases driving _stopSession rather than scheduleHangUp. The existing cases could not have caught this: they cover when the timer fires, not whether it should have been set, and the fault was entirely in the latter. Reverting the session check fails the new multi-session case. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Rq7wnggNTrhwGMADfJ5yvf --- homekit.js | 14 +++++- tests/homekit-hangup-grace.test.cjs | 70 +++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/homekit.js b/homekit.js index 99b1216..19eb332 100644 --- a/homekit.js +++ b/homekit.js @@ -558,8 +558,14 @@ function _stopSession(sessionID, hangUp) { if (s.ffOut) killFfmpeg(s.ffOut); if (s.sdpPath) removeReturnAudioSdp(s.sdpPath); + // Only the last session ending means the user is finished. HomeKit runs up + // to cameraStreamCount sessions at once, and moving between the room view's + // camera tile and the full camera view starts a second one, so a STOP here + // routinely arrives while another session is still streaming. Hanging up on + // it kills a live call: seen on 2026-09-12, where session ac5c8c21 stopping + // ended the call under session d332e46a three seconds into the stream. const activeCall = getActiveCall(); - if (hangUp && activeCall) { + if (hangUp && activeCall && activeSessions.size === 0) { scheduleHangUp(activeCall.callSid, sessionID); } } @@ -920,8 +926,12 @@ module.exports = { shutdown, // Exported for tests/homekit-hangup-grace.test.cjs. Reaching the deferred // hangup through the streaming delegate would mean standing up a full SRTP - // session, which tests nothing about the timer. + // session, which tests nothing about the timer. _stopSession and the session + // map go with them: driving only scheduleHangUp tests when the timer fires + // but not whether it should have been set, which is where the live bug was. scheduleHangUp, cancelPendingHangUp, deferPendingHangUp, + _stopSession, + activeSessions, }; diff --git a/tests/homekit-hangup-grace.test.cjs b/tests/homekit-hangup-grace.test.cjs index 2cc98ed..73966e9 100644 --- a/tests/homekit-hangup-grace.test.cjs +++ b/tests/homekit-hangup-grace.test.cjs @@ -187,3 +187,73 @@ test('shutdown drops a pending hangup', async () => { await sleep(GRACE_MS * 2); assert.deepEqual(hangUps, [], 'a hangup must not fire against a process that is going away'); }); + +// --------------------------------------------------------------------------- +// Through _stopSession, the path the live bug was actually on. +// +// 2026-09-12: calls died three seconds into the stream. HomeKit runs up to +// cameraStreamCount sessions at once and starts a second one when you move +// between the room view's camera tile and the full camera view. The STOP for +// that second session scheduled a hangup while the first was still streaming. +// Driving scheduleHangUp directly, as the cases above do, cannot see this: the +// fault was in whether to schedule at all, not in the timer. +// --------------------------------------------------------------------------- + +/** A session bare enough for _stopSession: no ffmpeg, no SDP to clean up. */ +function fakeSession(id) { + homekit.activeSessions.set(id, {}); + return id; +} + +test('a stop with another session still streaming does not end the call', async () => { + reset('CA-two-sessions'); + fakeSession('session-a'); + fakeSession('session-b'); + + homekit._stopSession('session-b', /* hangUp= */ true); + + await sleep(GRACE_MS * 2); + assert.deepEqual(hangUps, [], 'the surviving session is still streaming; the call must live'); + assert.equal(homekit.activeSessions.size, 1, 'only the stopped session should be gone'); + + homekit.activeSessions.clear(); +}); + +test('the last session stopping does end the call', async () => { + reset('CA-last-session'); + fakeSession('session-only'); + + homekit._stopSession('session-only', /* hangUp= */ true); + + assert.deepEqual( + await waitForHangUp(), + ['CA-last-session'], + 'with no sessions left the user is finished and the call must end' + ); +}); + +test('stopping every session in turn ends the call exactly once', async () => { + reset('CA-drain'); + fakeSession('session-a'); + fakeSession('session-b'); + + homekit._stopSession('session-a', /* hangUp= */ true); + homekit._stopSession('session-b', /* hangUp= */ true); + + await waitForHangUp(); + await sleep(GRACE_MS); + assert.deepEqual(hangUps, ['CA-drain'], 'draining the sessions must not hang up twice'); +}); + +test('a caller-initiated teardown never hangs up, whatever is streaming', async () => { + // endHapSession runs on the Twilio stop: the call is already gone. + reset('CA-caller-left'); + fakeSession('session-a'); + fakeSession('session-b'); + + homekit.endHapSession(); + + await sleep(GRACE_MS * 2); + assert.deepEqual(hangUps, [], 'the caller hung up; there is nothing left to hang up'); + assert.equal(homekit.activeSessions.size, 0, 'endHapSession must drain every session'); +});