fix(core): stop forwarding Twilio audio once the live view closes - #95
Merged
atdr merged 1 commit intoSep 27, 2026
Merged
Conversation
markHomekitSessionStarted had no counterpart, so hasHomekitSession was set once and never cleared. _stopSession kills ffIn and unpipes it, so from the moment the live view closed, Twilio audio was written into a PassThrough with no consumer for the rest of the call. Two consequences, both observed. The buffer holds 4 s (32 KB of 8 kHz mu-law), so a gap longer than that logs media-frames-dropped at warn for audio that was never going anywhere: seen on the Pi at 19:36:36, just after a hangup. And on reopening the view the new ffIn inherits whatever accumulated and drains it faster than real time, which is the buffered startup burst the inbound spawn comment already warns about, so a rejoin begins behind live rather than at it. The grace window did not cause this, but it made it reachable. Before it, the call ended the instant the view closed and barely any audio arrived; holding the call open for 8 s turned a theoretical asymmetry into seconds of pointless buffering on every call. markHomekitSessionEnded restores the invariant the write path already states, namely that only live-view audio is forwarded. It is wired via a setOnHapSessionEnded seam mirroring setOnHapSessionStarted, and fires only when the last session stops, on the same reasoning as the hangup. Verified by mutation, never clearing the flag fails 2, notifying on every stop rather than the last fails 2, never notifying fails 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rq7wnggNTrhwGMADfJ5yvf
atdr
added this pull request to stack #90
September 17, 2026 19:49
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## fix/hangup-only-on-last-session #95 +/- ##
===================================================================
+ Coverage 69.08% 69.47% +0.38%
===================================================================
Files 14 14
Lines 3193 3240 +47
===================================================================
+ Hits 2206 2251 +45
- Misses 987 989 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
4 of 5 tasks
This was referenced Sep 27, 2026
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
markHomekitSessionStarted()had no counterpart, sohasHomekitSessionwas set once and never cleared. From the moment the live view closed, Twilio audio was written into a PassThrough with no consumer for the rest of the call.media-frames-droppedwarnings for audio that was never going anywhere, and a backlog the next ffmpeg bursts through on reopen, so a rejoin begins behind live rather than at it.markHomekitSessionEnded(), wired through asetOnHapSessionEndedseam mirroring the existingsetOnHapSessionStarted.Stacked on #89
Third layer of the stack: #88 (grace window) → #89 (only hang up on the last session) → this.
What was happening
_stopSessionkillsffInand unpipes it from the mu-law PassThrough. Nothing toldMediaStreamto stop writing to it, so it kept forwarding for the remainder of the call.The PassThrough is
new PassThrough({ highWaterMark: 32768 }). At 8 kHz mu-law that is 4.096 seconds of audio, after whichwritableNeedDraintrips and frames are dropped by design.So with the live view closed:
media-frames-droppedat warn, seen on the Pi at19:36:36.870, moments after a hangupffInis piped to the same PassThrough and inherits the backlog, draining it far faster than real timeThat last one is the "buffered startup audio burst" the inbound spawn comment in
homekit.jsalready warns about, and it is the reported symptom: silence on rejoining, clearing after roughly the length of the gap.The grace window did not cause this. Before it, the call ended the instant the view closed, so barely any audio arrived and the asymmetry was unreachable. Holding the call open for 8 s turned it into seconds of pointless buffering on every call that ends this way.
Changes
src/core/media-stream.jsmarkHomekitSessionEnded()clears the flag and resetsdroppingFramesso a later resume cannot log against a run of drops that ended when forwarding stopped. Logshomekit-session-ended, because the previous behaviour left no trace either way.homekit.js,server.jssetOnHapSessionEndedmirrorssetOnHapSessionStarted, fired from_stopSessionand, like the hangup, only when the last session stops. A stop while another session is still streaming must not turn forwarding off, or the surviving live view goes silent. That rule is now enforced in one place for both concerns.Docs
docs/architecture.mdrecords the forwarding stop alongside the deferred hangup. The playbook's event inventory gainshomekit-session-ended, plusmedia-ws-handshake-verifiedandmedia-ws-rejected, which had been missed when they were added.Test Plan
typecheck,lint,format:check,check,test— 150 tests, up from 146).tests/media-stream.test.cjs: forwarding stops when the session ends and nothing accumulates afterwards; reopening resumes from live with no backlog from the gap; ending a session that never started is a no-op. The first also assertsmarkActivitystill fires, so the stale reaper cannot take a call being deliberately held open.tests/homekit-hangup-grace.test.cjs: the media stream is told only when the last session ends.homekit-session-endedon close, nomedia-frames-dropped, and a temporary per-hop audio probe showed nothing written to ffIn during the gap and live frames straight aftermulaw-stream-boundon reopen. The gap was 3.5 s rather than the planned >4.096 s, so the buffer was never at risk of filling; the probe shows the mechanism directly instead.Honest uncertainty (resolved 2026-09-27)
Resolved: it is session startup, and this PR is correct on its own terms but not the fix for what was heard. With no backlog possible, every session start still shows ffIn silent for ~3 s after
mulaw-stream-bound, then ~200 packets (≈4 s of audio) in one second. That is constant, not gap-dependent. Tracked in #102.Original note:
The reported symptom was silence on rejoin, clearing shortly after. The backlog-burst explanation fits and the buffer arithmetic matches the gap lengths, but it has not been isolated: fresh ffmpeg processes and SRTP renegotiation on every rejoin carry their own startup cost, which would also produce brief silence.
The test above separates them. If the silence scales with gap length, it was the backlog and this fixes it. If it is constant regardless, it is session startup and this PR is still correct on its own terms (no dropped-frame warnings, no pointless buffering) but is not the fix for what was heard.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Rq7wnggNTrhwGMADfJ5yvf