Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions crates/openplay-discovery/src/address.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//! Ordering for the addresses mDNS reports for a discovered receiver.
//!
//! `mdns_sd::ServiceInfo::get_addresses` returns a `&HashSet<IpAddr>`, so the
//! order a receiver's addresses arrive in is not merely unsorted — it is
//! non-deterministic, and changes between advertisements of the same service.
//! Since callers connect to the first address, that made the dialled address a
//! lottery, re-rolled every time a record refreshed.
//!
//! Sorting at collection makes the first address the most connectable one, so
//! `addresses.first()` is correct by construction rather than by luck.

use std::net::IpAddr;

/// Connectability rank, lowest first.
///
/// A link-local address is ranked below loopback because we advertise no zone
/// index and cannot reconstruct one, so `fe80::1` is not dialable at all, while
/// loopback at least resolves — to the wrong host for a remote receiver, which
/// is why it still ranks below anything routable.
fn rank(ip: &IpAddr) -> u8 {
let (loopback, link_local, unspecified) = match ip {
IpAddr::V4(v4) => (v4.is_loopback(), v4.is_link_local(), v4.is_unspecified()),
// `Ipv6Addr::is_unicast_link_local` is still unstable, so test fe80::/10
// directly.
IpAddr::V6(v6) => (
v6.is_loopback(),
(v6.segments()[0] & 0xffc0) == 0xfe80,
v6.is_unspecified(),
),
};

if unspecified {
return 4;
}
if link_local {
return 3;
}
if loopback {
return 2;
}
// Routable. Prefer IPv4: every receiver we target reachable over IPv6 is
// also reachable over IPv4, and IPv4 needs no scope handling.
match ip {
IpAddr::V4(_) => 0,
IpAddr::V6(_) => 1,
}
}

/// Sorts discovered addresses most-connectable first.
///
/// Ties break on the address value so the result is stable across runs, which
/// the `HashSet` iteration order on its own is not.
pub fn sort_by_connectability(addrs: &mut [IpAddr]) {
addrs.sort_by_key(|ip| (rank(ip), *ip));
}

#[cfg(test)]
mod tests {
use super::*;

fn ips(raw: &[&str]) -> Vec<IpAddr> {
raw.iter().map(|s| s.parse().unwrap()).collect()
}

#[test]
fn routable_ipv4_wins() {
// The exact set this machine advertised for itself, in one of the
// orders observed in the wild.
let mut a = ips(&[
"fe80::1",
"::1",
"fe80::1427:c2c8:a235:a115",
"127.0.0.1",
"192.168.0.97",
]);
sort_by_connectability(&mut a);
assert_eq!(a[0], "192.168.0.97".parse::<IpAddr>().unwrap());
}

#[test]
fn link_local_ranks_below_loopback() {
let mut a = ips(&["fe80::1", "127.0.0.1"]);
sort_by_connectability(&mut a);
assert_eq!(a, ips(&["127.0.0.1", "fe80::1"]));
}

#[test]
fn routable_ipv6_beats_loopback_and_link_local() {
let mut a = ips(&["fe80::1", "::1", "2001:db8::1"]);
sort_by_connectability(&mut a);
assert_eq!(a[0], "2001:db8::1".parse::<IpAddr>().unwrap());
}

#[test]
fn ipv4_preferred_over_routable_ipv6() {
let mut a = ips(&["2001:db8::1", "192.168.0.97"]);
sort_by_connectability(&mut a);
assert_eq!(a[0], "192.168.0.97".parse::<IpAddr>().unwrap());
}

#[test]
fn ipv4_link_local_is_demoted_too() {
let mut a = ips(&["169.254.1.1", "192.168.0.97"]);
sort_by_connectability(&mut a);
assert_eq!(a, ips(&["192.168.0.97", "169.254.1.1"]));
}

/// Every permutation of the observed set must produce the same first
/// address — that is the whole point.
#[test]
fn ordering_is_stable_whatever_the_hashset_yielded() {
let base = ips(&["fe80::1", "::1", "127.0.0.1", "192.168.0.97"]);
let mut sorted: Option<Vec<IpAddr>> = None;
for rotate in 0..base.len() {
let mut a = base.clone();
a.rotate_left(rotate);
sort_by_connectability(&mut a);
match &sorted {
None => sorted = Some(a),
Some(first) => assert_eq!(first, &a),
}
}
}

#[test]
fn empty_and_single_are_fine() {
let mut none: Vec<IpAddr> = vec![];
sort_by_connectability(&mut none);
assert!(none.is_empty());

let mut one = ips(&["fe80::1"]);
sort_by_connectability(&mut one);
assert_eq!(one, ips(&["fe80::1"]));
}
}
7 changes: 6 additions & 1 deletion crates/openplay-discovery/src/airplay_browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,12 @@ impl AirPlayBrowser {
let receiver_info = AirPlayReceiverInfo {
name: info.get_fullname().to_string(),
display_name,
addresses: info.get_addresses().iter().copied().collect(),
addresses: {
let mut addrs: Vec<_> =
info.get_addresses().iter().copied().collect();
crate::address::sort_by_connectability(&mut addrs);
addrs
},
port: info.get_port(),
device_id: txt
.as_ref()
Expand Down
7 changes: 6 additions & 1 deletion crates/openplay-discovery/src/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ impl ReceiverBrowser {
let receiver_info = ReceiverInfo {
name: info.get_fullname().to_string(),
display_name,
addresses: info.get_addresses().iter().copied().collect(),
addresses: {
let mut addrs: Vec<_> =
info.get_addresses().iter().copied().collect();
crate::address::sort_by_connectability(&mut addrs);
addrs
},
port: info.get_port(),
fingerprint: txt
.as_ref()
Expand Down
2 changes: 2 additions & 0 deletions crates/openplay-discovery/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
//! On Linux, Miracast peers can additionally be found over Wi-Fi Direct; that
//! lives in `openplay-miracast`, not here.

pub mod address;
mod advertiser;
pub mod airplay_browser;
pub mod airplay_record;
mod browser;
pub mod miracast_browser;
mod record;

pub use address::sort_by_connectability;
pub use advertiser::ReceiverAdvertiser;
pub use airplay_browser::{AirPlayBrowser, AirPlayReceiverInfo};
pub use airplay_record::AirPlayTxtRecord;
Expand Down
6 changes: 5 additions & 1 deletion crates/openplay-discovery/src/miracast_browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,11 @@ fn run_browser_thread(
let receiver_info = MiracastReceiverInfo {
name: info.get_fullname().to_string(),
display_name: display_name.clone(),
addresses: info.get_addresses().iter().copied().collect(),
addresses: {
let mut addrs: Vec<_> = info.get_addresses().iter().copied().collect();
crate::address::sort_by_connectability(&mut addrs);
addrs
},
port,
device_info: device_info.clone(),
};
Expand Down
Loading