From 7fb1934d527368190b21a66d0fff3f7e303e56b9 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 6 Aug 2026 08:47:20 +0800 Subject: [PATCH] cast: don't drop the io loop on a single bad message, and don't let a scopeless link-local IPv6 clobber a working host The receiver-status io loop treated any read error as a dead connection, so one malformed/unexpected message (e.g. a MEDIA_STATUS missing a field the struct requires) tore down the whole control channel for the rest of the session even though the RTP stream was fine. Only end the loop on Io/Tls/Dns errors now; log and keep going on Serialization/Parsing/etc. mDNS resolves a single physical Cast device once per local address it has, so the same device id can show up with a private IPv4 host and again with a link-local IPv6 host. A bare fe80:: address has no zone id attached, so connecting to it fails outright -- don't let it replace an already-usable host in the device map just because it resolved more recently. --- breadcast-core/src/cast_sender.rs | 14 ++++++++++++-- breadcastd/src/daemon.rs | 21 ++++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/breadcast-core/src/cast_sender.rs b/breadcast-core/src/cast_sender.rs index 8f82467..b4cb637 100644 --- a/breadcast-core/src/cast_sender.rs +++ b/breadcast-core/src/cast_sender.rs @@ -326,10 +326,20 @@ fn run_io_loop( // doc comment for why that's fine for this project's usage. } Ok(_) => {} - Err(e) => { - tracing::debug!(error = %e, "cast session io loop ending: receive error"); + // Only end the loop on an error that means the connection + // itself is gone -- a single malformed/unexpected message (e.g. + // a MEDIA_STATUS missing a field this crate's struct treats as + // required) is a `Serialization`/`Parsing` error, not a dead + // socket, and used to take the whole receiver-status/control + // channel down with it for the rest of the session even though + // the RTP stream itself was unaffected. + Err(e @ (rust_cast::errors::Error::Io(_) | rust_cast::errors::Error::Tls(_) | rust_cast::errors::Error::Dns(_))) => { + tracing::debug!(error = %e, "cast session io loop ending: connection error"); return; } + Err(e) => { + tracing::warn!(error = %e, "cast session: ignoring unparseable/unexpected message"); + } } } } diff --git a/breadcastd/src/daemon.rs b/breadcastd/src/daemon.rs index 1a09b64..b13f805 100644 --- a/breadcastd/src/daemon.rs +++ b/breadcastd/src/daemon.rs @@ -73,6 +73,12 @@ impl ActiveSession { } } +/// `host` strings come straight from mDNS resolution, so this just checks +/// the address, not whether a zone id is attached (mDNS never gives us one). +fn is_link_local_v6(host: &str) -> bool { + matches!(host.parse::(), Ok(std::net::IpAddr::V6(v6)) if (v6.segments()[0] & 0xffc0) == 0xfe80) +} + struct Daemon { cast_devices: HashMap, /// Keyed by `DlnaDevice::url`, the closest thing DLNA has to a stable @@ -128,7 +134,20 @@ impl Daemon { } } DaemonCommand::CastDeviceFound(device) => { - self.cast_devices.insert(device.id.clone(), device); + // mDNS resolves one physical device on every local address + // it has -- typically a private IPv4 and a link-local IPv6 + // -- as separate events carrying the same id. A bare + // `fe80::` address has no interface scope attached, so + // `TcpStream::connect`ing to it fails outright; don't let + // one clobber an already-usable host just because it + // happened to resolve more recently. + let should_replace = match self.cast_devices.get(&device.id) { + Some(existing) if is_link_local_v6(&device.host) && !is_link_local_v6(&existing.host) => false, + _ => true, + }; + if should_replace { + self.cast_devices.insert(device.id.clone(), device); + } self.broadcast_devices(); } DaemonCommand::CastDeviceLost(id) => {