breadcast/breadcast-core/src/discovery.rs
Breadway 17abeed7ae Fix Cast teardown leaks, keyframe latch, and DLNA session lifecycle
Dropped frames never requested a keyframe, SessionEnded skipped ordered
stop (portal/TV/FFI leak, next start could abort), and a late end could
kill the following cast. Failed starts left PlatformClientPosix alive.
DLNA leaked its HTTP server and ignored portal EOS.

Also: start no longer blocks the daemon actor, IPC accept/request loops
stay up, HLS Range is clamped, LAN IP follows the renderer subnet, and
the picker closes before the portal dialog and handles Escape.
2026-08-16 14:16:23 +08:00

200 lines
9 KiB
Rust

use std::collections::HashMap;
use std::net::IpAddr;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use mdns_sd::{ScopedIp, ServiceDaemon, ServiceEvent};
use tokio::sync::mpsc;
use tracing::{debug, warn};
use crate::device::CastDevice;
/// How long a resolved address is trusted without being re-seen in a later
/// `ServiceResolved` event. mDNS re-resolves well inside this window in
/// normal operation, so an address that goes quiet for this long is more
/// likely stale (DHCP lease change, interface removed) than merely unlucky
/// timing.
const ADDRESS_STALE_AFTER: Duration = Duration::from_secs(300);
/// Picks the best address to connect to a resolved device on: routable IPv4
/// first (works unambiguously with rust_cast's TLS connect and with URLs
/// embedded in Cast media-load requests), falling back to a non-link-local
/// IPv6 address. Link-local IPv6 (`fe80::...%wlan0`) is deliberately last
/// resort — its zone-id suffix doesn't round-trip through `IpAddr`'s
/// `FromStr`/`Display`, so it can silently become unconnectable downstream.
///
/// `addresses` is sorted most-recently-seen first (with a stable tie-break)
/// before picking, rather than iterated in whatever order a `HashSet` (or a
/// `Vec` built from one) happens to produce — with a device that resolves
/// to two IPv4 addresses (e.g. wired + wireless, or a DHCP lease change),
/// an unordered choice can silently pick a stale/unreachable one, and pick
/// a *different* one across otherwise-identical runs.
fn pick_address(addresses: &[(ScopedIp, Instant)]) -> Option<String> {
let now = Instant::now();
let mut candidates: Vec<&(ScopedIp, Instant)> = addresses
.iter()
.filter(|(_, seen)| now.duration_since(*seen) < ADDRESS_STALE_AFTER)
.collect();
candidates.sort_by(|a, b| {
b.1.cmp(&a.1) // most-recently-seen first
.then_with(|| a.0.to_ip_addr().to_string().cmp(&b.0.to_ip_addr().to_string()))
});
candidates
.iter()
.find(|(ip, _)| ip.is_ipv4())
.or_else(|| {
candidates.iter().find(|(ip, _)| match ip.to_ip_addr() {
IpAddr::V6(v6) => !v6.is_unicast_link_local(),
IpAddr::V4(_) => false,
})
})
.or_else(|| candidates.first())
// Prefer ScopedIp's Display so a last-resort link-local IPv6
// keeps its `%iface` zone -- `IpAddr` drops it and
// `TcpStream::connect("fe80::…")` then fails with EINVAL.
.map(|(ip, _)| ip.to_string())
}
const SERVICE_TYPE: &str = "_googlecast._tcp.local.";
/// A change in the set of Cast devices visible on the LAN.
///
/// `Found` fires on every mDNS re-resolution of a device (e.g. once per
/// network interface, or periodically as records refresh), not just the
/// first sighting — consumers should treat it as an upsert keyed by
/// `CastDevice::id`, not an append-only log.
#[derive(Debug, Clone)]
pub enum DiscoveryEvent {
Found(CastDevice),
Lost { id: String },
}
/// Browses `_googlecast._tcp.local.` on a background thread and forwards
/// found/lost devices over an unbounded channel. Dropping the returned
/// `Discovery` stops the underlying mDNS daemon (via an explicit `Drop`
/// impl below — `mdns_sd::ServiceDaemon` has none of its own, so without
/// it every dropped `Discovery` would leak its daemon thread and 5353
/// multicast socket for the rest of the process's life).
pub struct Discovery {
daemon: ServiceDaemon,
}
impl Drop for Discovery {
fn drop(&mut self) {
let _ = self.daemon.shutdown();
}
}
impl Discovery {
/// Starts browsing and returns the handle plus a receiver of events.
/// `id=` TXT records are used to dedupe: a `ServiceResolved` for an
/// already-known id is treated as an update, not a duplicate `Found`.
pub fn start() -> Result<(Self, mpsc::UnboundedReceiver<DiscoveryEvent>)> {
let daemon = ServiceDaemon::new().context("failed to start mDNS daemon")?;
let browse_rx = daemon
.browse(SERVICE_TYPE)
.context("failed to browse _googlecast._tcp.local.")?;
let (tx, rx) = mpsc::unbounded_channel();
// mdns-sd's receiver is a blocking `flume` channel, so it needs its
// own OS thread rather than a tokio task; forwarding into an
// unbounded tokio channel is a non-blocking send from here.
std::thread::spawn(move || {
// fullname -> last-known device id, so a ServiceRemoved (which
// only carries the fullname) can still emit the right Lost{id}.
let mut fullname_to_id: HashMap<String, String> = HashMap::new();
// fullname -> every address seen for it, with a last-seen
// timestamp each. mDNS resolves progressively: the first
// ServiceResolved for a device often carries only a link-local
// IPv6 address, with the routable IPv4/global-IPv6 address
// arriving in a later event for the same fullname. Accumulating
// (not replacing) means `pick_address` always chooses from
// everything seen so far, not just whatever happened to be in
// the latest packet — the timestamp lets it also prefer the
// freshest address and ignore ones that have gone stale.
let mut fullname_to_addrs: HashMap<String, Vec<(ScopedIp, std::time::Instant)>> = HashMap::new();
while let Ok(event) = browse_rx.recv() {
match event {
ServiceEvent::ServiceResolved(info) => {
let Some(id) = info.get_property_val_str("id").map(str::to_string) else {
warn!(fullname = %info.get_fullname(), "cast device missing id= TXT record, skipping");
continue;
};
let name = info
.get_property_val_str("fn")
.unwrap_or_else(|| info.get_hostname())
.to_string();
let model = info
.get_property_val_str("md")
.unwrap_or("Chromecast")
.to_string();
let addrs = fullname_to_addrs
.entry(info.get_fullname().to_string())
.or_default();
let now = std::time::Instant::now();
for addr in info.get_addresses().iter().cloned() {
match addrs.iter_mut().find(|(seen, _)| *seen == addr) {
Some(entry) => entry.1 = now,
None => addrs.push((addr, now)),
}
}
let Some(host) = pick_address(addrs) else {
warn!(%id, "cast device resolved with no usable addresses, skipping");
continue;
};
// First ServiceResolved is often IPv6-only (or a
// zoneless fe80::). rust_cast / Cast Streaming
// cannot connect to that. Wait for a later
// resolve that carries IPv4 or a scoped address.
if host.parse::<std::net::Ipv6Addr>().is_ok()
&& host.starts_with("fe80:")
&& !host.contains('%')
{
warn!(%id, %host, "cast device resolved to an unscoped link-local IPv6, waiting for a better address");
continue;
}
fullname_to_id.insert(info.get_fullname().to_string(), id.clone());
let device = CastDevice {
id,
name,
model,
host,
port: info.get_port(),
};
debug!(?device, "cast device found");
if tx.send(DiscoveryEvent::Found(device)).is_err() {
break; // receiver dropped, stop the thread
}
}
ServiceEvent::ServiceRemoved(_ty, fullname) => {
fullname_to_addrs.remove(&fullname);
if let Some(id) = fullname_to_id.remove(&fullname) {
debug!(%id, "cast device lost");
if tx.send(DiscoveryEvent::Lost { id }).is_err() {
break;
}
}
}
_ => {}
}
}
});
Ok((Self { daemon }, rx))
}
/// Stops the mDNS daemon and its browse thread.
pub fn stop(self) -> Result<()> {
self.daemon
.shutdown()
.context("failed to shut down mDNS daemon")?;
Ok(())
}
}