Stale DLNA SessionEnded from an untracked bus-watcher thread can kill (or wrongly tear down) the next cast #4
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What's broken
breadcastd/src/dlna_mirror.rs's DLNA session start spawns a barestd::threadto watch the GStreamer bus (around line 190-200):This
JoinHandleis discarded — it's not stored onDlnaMirrorSession, andDlnaMirrorSession::stop()(lines 216-241) never joins or bounds it.stop()sets the pipeline toNull, stops the renderer, tears down HTTP/portal capture, and returns — with no guarantee the bus-watcher thread has noticed theNulltransition and sent (or finished sending) itsSessionEndedyet.run_until_eos_or_errorpolls the bus roughly every 500ms (breadcast-core/src/pipeline/mod.rs:544-556), so that message can land an unbounded (if short) time afterstop()has already returned to the caller.Compare
CastMirrorSession: its equivalent notification comes from theframe_pumpthread, which is stored on the struct and explicitly joined (bounded 5s,cast_mirror.rs:287) insidestop()beforestop()returns. DLNA has no equivalent guarantee.Compounding this,
daemon.rs'sStopCasthandler (lines 151-165) only bumpsstart_generationwhen a start is still in flight (self.starting):When it stops an already-installed session (the normal "user clicks Stop" case),
start_generationis left unchanged.start_cast(line 249) then reads the currentstart_generationfor the next start without bumping it first either. So a stop→start sequence on an installed DLNA session keeps the exact same generation number throughout, andSessionEnded's staleness check (daemon.rs:171,if generation != self.start_generation { return; }) can't tell the old thread's message apart from a legitimate one for the new session.Why it matters — concrete failure scenario
User stops a DLNA cast, then starts a new one (same or a different device) within a few seconds — an entirely ordinary UI interaction. Negotiation for the new start (portal picker +
wait_for_playlist_segmentswaiting for 2 HLS segments at ~1s each) easily takes several seconds, comfortably longer than the old session's bus-watcher thread needs to noticeNulland fire its staleSessionEnded{generation}. Two live failure modes, both reachable because the generation is unchanged:daemon.rs:200-204treats it as "the in-flight session died," settingself.starting = falseand bumping the generation — so the legitimate upcomingStartFinishedfor the new session gets rejected as "start cancelled" and the working new session is torn down, even though negotiation actually succeeded.daemon.rs:180-187matches the (unchanged/reused) generation,active_session.take()s the brand-new, actively-casting session, and tears it down — the TV/renderer that had just started successfully mirroring drops out seconds later with no user action, right after a working stop→start.Suggested fix
Either (or both):
JoinHandleonDlnaMirrorSessionand join/bound it insidestop()the same wayCastMirrorSession::stop()boundsframe_pumpvia its existing join pattern, so the notification's enqueue-ordering relative tostop()completion becomes deterministic (same fix shape already proven for the Cast path).StopCastunconditionally bumpstart_generationwhenever it takes an installedactive_session, mirroring what theSessionEndedhandler itself already does atdaemon.rs:183when it tears down a session that ended on its own — so a generation number can never be reused across any stop→start sequence, regardless of which protocol (Cast/DLNA) is involved.(b) is the smaller, more robust fix since it closes the gap for DLNA without relying on thread-join timing, and doesn't regress if a future third mirroring backend makes the same untracked-thread choice DLNA did.
Confirmed root cause against real code and fixed. PR: #5 (branch
fix/cast-4-dlna-stale-session-ended)Root cause (both bugs confirmed):
breadcastd/src/daemon.rs'sStopCasthandler only bumpedstart_generationwhen cancelling an in-flight start (self.starting); it left the generation unchanged when tearing down an already-installed session (the ordinary "user clicks Stop" path). A stop → start on an installed session therefore kept the same generation throughout.breadcastd/src/dlna_mirror.rs's bus-watcher thread (start(), then around lines 190-200) spawned via barestd::thread::spawnwith itsJoinHandlediscarded — never stored onDlnaMirrorSession, never joined instop().stop()could return before the thread noticed the pipeline hitNulland sent itsSessionEnded, unlikeCastMirrorSession'sframe_pump, which is stored and joined (bounded 5s) in itsstop().Fix (both parts, as the issue suggested):
daemon.rs:StopCastnow unconditionally bumpsstart_generationwhenever it takes an installedactive_session, mirroring what theSessionEndedhandler itself already does when a session ends on its own (daemon.rs:183). Since the generation is captured by value in the watcher thread at spawn time, this alone makes the staleness check correctly reject any late message from a superseded session, regardless of timing.dlna_mirror.rs: the bus-watcher thread'sJoinHandleis now stored onDlnaMirrorSessionand joined (bounded 5s) instop(), right afterpipeline.set_state(Null)unblocks it — same pattern asCastMirrorSession::stop()'sframe_pumpjoin. Brings DLNA to parity with Cast and closes the thread-leak/non-determinism gap.Test: added a deterministic regression test in
daemon.rs(stop_cast_on_an_installed_session_bumps_generation_so_a_stale_watcher_thread_cannot_kill_the_next_session) that drives stop → install-new-session → stale-SessionEndeddirectly throughDaemon::handlevia a#[cfg(test)]-onlyActiveSession::Stub(a real session needs GStreamer/portal, can't be built in a unit test). No sleeps, no real threads.cargo build --release --workspace,cargo test --release --workspace(17 passed / 0 failed), andcargo clippy --release --workspace --all-targets -- -D warningsall clean.