use super::*;
use crate::service::{SERVICE_UPDATE_CAPACITY, ServiceMailbox};
fn lock_mailbox_for_test(
mailbox: &std::sync::Arc<std::sync::Mutex<ServiceMailbox>>,
) -> Option<ServiceUpdate> {
mailbox
.lock()
.unwrap_or_else(|e| e.into_inner())
.drain_for_test()
}
#[test]
fn on_link_check_rejects_non_255_ttl() {
assert!(is_on_link(Some(255)));
assert!(is_on_link(None)); assert!(!is_on_link(Some(254)));
assert!(!is_on_link(Some(1)));
assert!(!is_on_link(Some(0)));
}
#[test]
fn present_socket_send_error_is_retry_not_writeoff() {
assert_eq!(
present_socket_send_outcome::<usize>(&Ok(42)),
WithdrawalSend::Sent,
);
for kind in [
std::io::ErrorKind::WouldBlock,
std::io::ErrorKind::Interrupted,
std::io::ErrorKind::OutOfMemory, std::io::ErrorKind::AddrNotAvailable, std::io::ErrorKind::PermissionDenied,
std::io::ErrorKind::Other,
] {
let res: std::io::Result<usize> = Err(std::io::Error::from(kind));
assert_eq!(
present_socket_send_outcome(&res),
WithdrawalSend::Retry,
"a present (bound) socket error ({kind:?}) must be Retry, not WriteOff"
);
}
}
#[test]
fn packet_is_response_reads_qr_bit() {
assert!(packet_is_response(&[0, 0, 0x84, 0, 0, 0, 0, 0, 0, 0, 0, 0]));
assert!(!packet_is_response(&[
0, 0, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0
])); assert!(!packet_is_response(&[0, 0])); assert!(!packet_is_response(&[]));
}
#[cfg(feature = "tokio")]
#[tokio::test]
async fn untrusted_response_does_not_burn_self_send_credit() {
use std::{
net::{IpAddr, Ipv4Addr},
time::SystemTime,
};
let opts = crate::options::ServerOptions::default();
let sockets = BoundSockets::<agnostic_net::tokio::Net> {
v4: None,
v6: None,
interface_index: 0,
};
let mut state = DriverState::new(&opts, sockets);
let body = vec![0u8, 0, 0x84, 0, 0, 0, 0, 0, 0, 0, 0, 0];
record_self_send(&mut state.recent_sends, &body, SystemTime::now());
assert_eq!(state.recent_sends.len(), 1);
let untrusted = Packet {
src: "192.0.2.9:40000".parse().unwrap(),
data: body.clone(),
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
interface_index: 0,
kernel_rx_time: Some(SystemTime::now()),
read_time: SystemTime::now(),
hop_limit: Some(255),
};
state.handle_packet(untrusted);
assert_eq!(
state.recent_sends.len(),
1,
"untrusted response must not consume the self-send credit"
);
let loopback = Packet {
src: "192.0.2.9:5353".parse().unwrap(),
data: body,
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
interface_index: 0,
kernel_rx_time: Some(SystemTime::now()),
read_time: SystemTime::now(),
hop_limit: Some(255),
};
state.handle_packet(loopback);
assert_eq!(
state.recent_sends.len(),
0,
"the trusted port-5353 loopback consumes the credit"
);
}
#[cfg(all(feature = "stats", feature = "tokio"))]
#[test]
fn pre_drop_short_qr1_counts_rx_and_dropped_exactly_once() {
use std::{
net::{IpAddr, Ipv4Addr},
time::SystemTime,
};
let opts = crate::options::ServerOptions::default();
let sockets = BoundSockets::<agnostic_net::tokio::Net> {
v4: None,
v6: None,
interface_index: 0,
};
let mut state = DriverState::new(&opts, sockets);
let body: Vec<u8> = vec![0x00, 0x00, 0x80];
let len = body.len() as u64;
let pkt = Packet {
src: "192.0.2.7:40000".parse().unwrap(),
data: body,
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
interface_index: 0,
kernel_rx_time: Some(SystemTime::now()),
read_time: SystemTime::now(),
hop_limit: Some(255),
};
state.handle_packet(pkt);
let snap = state.stats.snapshot();
assert_eq!(
snap.packets_rx, 1,
"packets_rx must be 1 (datagram was received)"
);
assert_eq!(
snap.bytes_rx, len,
"bytes_rx must equal the datagram length"
);
assert_eq!(snap.packets_dropped, 1, "exactly one reject counter");
assert_eq!(
snap.packets_rx, 1,
"no double-count: proto handle() was not reached"
);
}
#[cfg(all(feature = "stats", feature = "tokio"))]
#[test]
fn pre_drop_untrusted_qr1_response_counts_rx_and_dropped_exactly_once() {
use std::{
net::{IpAddr, Ipv4Addr},
time::SystemTime,
};
let opts = crate::options::ServerOptions::default();
let sockets = BoundSockets::<agnostic_net::tokio::Net> {
v4: None,
v6: None,
interface_index: 0,
};
let mut state = DriverState::new(&opts, sockets);
let body: Vec<u8> = vec![
0x00, 0x00, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
let len = body.len() as u64;
assert_eq!(state.recent_sends.len(), 0);
let pkt = Packet {
src: "192.0.2.8:54321".parse().unwrap(), data: body,
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
interface_index: 0,
kernel_rx_time: Some(SystemTime::now()),
read_time: SystemTime::now(),
hop_limit: Some(255), };
state.handle_packet(pkt);
assert_eq!(
state.recent_sends.len(),
0,
"self-send credit ring must be untouched"
);
let snap = state.stats.snapshot();
assert_eq!(snap.packets_rx, 1, "packets_rx +1 (datagram was received)");
assert_eq!(snap.bytes_rx, len, "bytes_rx == datagram length");
assert_eq!(snap.packets_dropped, 1, "exactly one reject counter");
}
#[cfg(all(feature = "stats", feature = "tokio"))]
#[test]
fn pre_drop_off_link_datagram_counts_rx_and_dropped_exactly_once() {
use std::{
net::{IpAddr, Ipv4Addr},
time::SystemTime,
};
let opts = crate::options::ServerOptions::default();
let sockets = BoundSockets::<agnostic_net::tokio::Net> {
v4: None,
v6: None,
interface_index: 0,
};
let mut state = DriverState::new(&opts, sockets);
let body: Vec<u8> = vec![
0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
let len = body.len() as u64;
let pkt = Packet {
src: "203.0.113.5:5353".parse().unwrap(),
data: body,
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
interface_index: 0,
kernel_rx_time: Some(SystemTime::now()),
read_time: SystemTime::now(),
hop_limit: Some(64), };
state.handle_packet(pkt);
let snap = state.stats.snapshot();
assert_eq!(snap.packets_rx, 1, "packets_rx +1 (datagram was received)");
assert_eq!(snap.bytes_rx, len, "bytes_rx == datagram length");
assert_eq!(snap.packets_dropped, 1, "exactly one reject counter");
}
#[cfg(feature = "tokio")]
#[tokio::test]
async fn service_update_delivery_is_bounded_for_non_draining_caller() {
use mdns_proto::{ServiceUpdate, event::ServiceRenamed};
let opts = crate::options::ServerOptions::default();
let sockets = BoundSockets::<agnostic_net::tokio::Net> {
v4: None,
v6: None,
interface_index: 0,
};
let mut state = DriverState::new(&opts, sockets);
let now = StdInstant::now();
let mut r = mdns_proto::ServiceRecords::new(
mdns_proto::Name::try_from_str("_ipp._tcp.local.").unwrap(),
mdns_proto::Name::try_from_str("svc._ipp._tcp.local.").unwrap(),
mdns_proto::Name::try_from_str("host.local.").unwrap(),
631,
120,
);
r.add_a(std::net::Ipv4Addr::new(192, 168, 1, 10));
let reg = state
.register_service(mdns_proto::ServiceSpec::new(r), now)
.unwrap();
let handle = reg.handle;
{
let ctx = state.services.get_mut(&handle).unwrap();
for i in 0..1000u32 {
deliver_service_update(ctx, ServiceUpdate::Established);
deliver_service_update(
ctx,
ServiceUpdate::Renamed(ServiceRenamed::new(
mdns_proto::Name::try_from_str(&format!("svc-{i}._ipp._tcp.local.")).unwrap(),
)),
);
}
let mb = ctx.mailbox.lock().unwrap_or_else(|e| e.into_inner());
assert!(
mb.non_terminal_len() <= SERVICE_UPDATE_CAPACITY,
"the mailbox must stay within capacity under churn; got {}",
mb.non_terminal_len()
);
assert_eq!(
mb.non_terminal_len(),
2,
"Established and the latest Renamed coalesce to two pending updates"
);
}
drop(reg);
}
#[test]
fn addr_in_subnet_masks_correctly() {
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
let net = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10));
assert!(addr_in_subnet(
net,
24,
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 200))
));
assert!(!addr_in_subnet(
net,
24,
IpAddr::V4(Ipv4Addr::new(192, 168, 2, 1))
));
assert!(addr_in_subnet(
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
0,
IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))
));
assert!(!addr_in_subnet(net, 24, IpAddr::V6(Ipv6Addr::LOCALHOST)));
let n6 = IpAddr::V6("2001:db8:0:1::".parse().unwrap());
assert!(addr_in_subnet(
n6,
64,
IpAddr::V6("2001:db8:0:1::ff".parse().unwrap())
));
assert!(!addr_in_subnet(
n6,
64,
IpAddr::V6("2001:db8:0:2::ff".parse().unwrap())
));
}
#[test]
fn src_on_local_link_fallback() {
use std::net::{IpAddr, Ipv4Addr};
let subnets = vec![(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)), 24u8)];
const BOUND: u32 = 3;
assert!(src_on_local_link(
&subnets,
BOUND,
BOUND,
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 55))
));
assert!(!src_on_local_link(
&subnets,
BOUND,
BOUND,
IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))
));
assert!(src_on_local_link(
&subnets,
BOUND,
BOUND,
IpAddr::V4(Ipv4Addr::LOCALHOST)
));
assert!(!src_on_local_link(
&[],
BOUND,
BOUND,
IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))
));
assert!(!src_on_local_link(
&[],
BOUND,
BOUND,
IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1))
));
let wide = vec![(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 0)), 8u8)];
assert!(src_on_local_link(
&wide,
BOUND,
BOUND,
IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3))
));
assert!(!src_on_local_link(
&wide,
BOUND,
BOUND,
IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1))
));
}
#[test]
fn src_on_local_link_scopes_link_local_to_bound_interface() {
use std::net::{IpAddr, Ipv4Addr};
let subnets = vec![(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)), 24u8)];
const BOUND: u32 = 3;
const OTHER: u32 = 7;
let v4_ll = IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1));
let v6_ll = IpAddr::V6("fe80::1".parse().unwrap());
assert!(src_on_local_link(&subnets, BOUND, BOUND, v4_ll));
assert!(src_on_local_link(&subnets, BOUND, BOUND, v6_ll));
assert!(!src_on_local_link(&subnets, BOUND, OTHER, v4_ll));
assert!(!src_on_local_link(&subnets, BOUND, OTHER, v6_ll));
assert!(src_on_local_link(&subnets, BOUND, 0, v4_ll));
assert!(src_on_local_link(&subnets, BOUND, 0, v6_ll));
}
#[test]
fn collect_local_subnets_rejects_zero_index() {
assert!(collect_local_subnets(0).is_empty());
}
#[test]
fn self_send_consume_once() {
let t = SystemTime::now();
let mut tracker = Vec::new();
record_self_send(&mut tracker, b"hello", t);
assert!(take_self_send(
&mut tracker,
b"hello",
t,
MatchMode::Ordered
));
assert!(!take_self_send(
&mut tracker,
b"hello",
t,
MatchMode::Ordered
));
assert!(tracker.is_empty());
}
#[test]
fn self_send_distinct_payloads_do_not_match() {
let t = SystemTime::now();
let mut tracker = Vec::new();
record_self_send(&mut tracker, b"alpha", t);
assert!(!take_self_send(
&mut tracker,
b"beta",
t,
MatchMode::Ordered
));
assert!(take_self_send(
&mut tracker,
b"alpha",
t,
MatchMode::Ordered
));
}
#[test]
fn self_send_expires_after_ttl() {
let t = SystemTime::now();
let mut tracker = Vec::new();
record_self_send(&mut tracker, b"hello", t);
let too_late = t + SELF_SEND_TTL + Duration::from_millis(1);
assert!(!take_self_send(
&mut tracker,
b"hello",
too_late,
MatchMode::Ordered
));
record_self_send(&mut tracker, b"other", too_late);
assert_eq!(tracker.len(), 1);
assert!(take_self_send(
&mut tracker,
b"other",
too_late,
MatchMode::Ordered
));
}
#[test]
fn self_send_peer_before_our_send_cannot_steal_credit() {
let sent = SystemTime::now();
let mut tracker = Vec::new();
record_self_send(&mut tracker, b"probe", sent);
let peer_rx = sent - Duration::from_millis(500);
assert!(!take_self_send(
&mut tracker,
b"probe",
peer_rx,
MatchMode::Ordered
));
let loop_rx = sent + Duration::from_millis(1);
assert!(take_self_send(
&mut tracker,
b"probe",
loop_rx,
MatchMode::Ordered
));
}
#[cfg(not(any(target_os = "linux", target_os = "android")))]
#[test]
fn self_send_ordered_tolerates_microsecond_truncation() {
assert_eq!(hick_udp::RX_TIMESTAMP_GRAIN, Duration::from_micros(1));
let sent = SystemTime::now();
let mut tracker = Vec::new();
record_self_send(&mut tracker, b"trunc", sent);
let truncated_rx = sent - (hick_udp::RX_TIMESTAMP_GRAIN - Duration::from_nanos(1));
assert!(take_self_send(
&mut tracker,
b"trunc",
truncated_rx,
MatchMode::Ordered
));
record_self_send(&mut tracker, b"trunc", sent);
let too_early = sent - (hick_udp::RX_TIMESTAMP_GRAIN + Duration::from_micros(4));
assert!(!take_self_send(
&mut tracker,
b"trunc",
too_early,
MatchMode::Ordered
));
}
#[cfg(any(target_os = "linux", target_os = "android"))]
#[test]
fn self_send_ordered_nanosecond_rejects_pre_send() {
assert_eq!(hick_udp::RX_TIMESTAMP_GRAIN, Duration::ZERO);
let sent = SystemTime::now();
let mut tracker = Vec::new();
record_self_send(&mut tracker, b"probe", sent);
let pre_send = sent - Duration::from_nanos(500);
assert!(!take_self_send(
&mut tracker,
b"probe",
pre_send,
MatchMode::Ordered
));
assert!(take_self_send(
&mut tracker,
b"probe",
sent,
MatchMode::Ordered
));
}
#[test]
fn self_send_degraded_matches_take_once_within_ttl() {
let sent = SystemTime::now();
let mut tracker = Vec::new();
record_self_send(&mut tracker, b"win", sent);
let read = sent + Duration::from_millis(10);
assert!(take_self_send(
&mut tracker,
b"win",
read,
MatchMode::Degraded
));
assert!(!take_self_send(
&mut tracker,
b"win",
read,
MatchMode::Degraded
));
}
#[test]
fn self_send_degraded_expires_after_ttl() {
let sent = SystemTime::now();
let mut tracker = Vec::new();
record_self_send(&mut tracker, b"win", sent);
let too_late = sent + SELF_SEND_TTL + Duration::from_millis(1);
assert!(!take_self_send(
&mut tracker,
b"win",
too_late,
MatchMode::Degraded
));
}
#[test]
fn self_send_dual_stack_records_two_entries() {
let t = SystemTime::now();
let mut tracker = Vec::new();
record_self_send(&mut tracker, b"resp", t);
record_self_send(&mut tracker, b"resp", t);
assert!(take_self_send(&mut tracker, b"resp", t, MatchMode::Ordered));
assert!(take_self_send(&mut tracker, b"resp", t, MatchMode::Ordered));
assert!(!take_self_send(
&mut tracker,
b"resp",
t,
MatchMode::Ordered
));
}
#[test]
fn self_send_cap_declines_without_evicting_live_entries() {
let t = SystemTime::now();
let mut tracker = vec![(fnv1a(b"live"), t); MAX_SELF_SEND_ENTRIES];
record_self_send(&mut tracker, b"overflow", t);
assert_eq!(tracker.len(), MAX_SELF_SEND_ENTRIES);
assert!(!take_self_send(
&mut tracker,
b"overflow",
t,
MatchMode::Ordered
));
assert!(take_self_send(&mut tracker, b"live", t, MatchMode::Ordered));
}
#[cfg(feature = "tokio")]
#[tokio::test]
async fn same_name_replacement_is_rejected_until_withdrawal_completes() {
use std::{net::Ipv4Addr, time::Duration};
let opts = crate::options::ServerOptions::default()
.with_endpoint_config(mdns_proto::EndpointConfig::new().with_probe_unique_names(false));
let sockets = BoundSockets::<agnostic_net::tokio::Net> {
v4: None,
v6: None,
interface_index: 0,
};
let mut state = DriverState::new(&opts, sockets);
let now = StdInstant::now();
let mk = || {
let mut r = mdns_proto::ServiceRecords::new(
mdns_proto::Name::try_from_str("_ipp._tcp.local.").unwrap(),
mdns_proto::Name::try_from_str("repl._ipp._tcp.local.").unwrap(),
mdns_proto::Name::try_from_str("repl.local.").unwrap(),
631,
120,
);
r.add_a(Ipv4Addr::new(192, 168, 1, 10));
mdns_proto::ServiceSpec::new(r)
};
let a = state.register_service(mk(), now).unwrap().handle;
{
let ctx = state.services.get_mut(&a).unwrap();
let mut buf = vec![0u8; 4096];
let mut t = now;
for _ in 0..40 {
t += Duration::from_millis(300);
let _ = ctx.proto.handle_timeout(t);
while let Ok(Some(_)) = ctx.proto.poll_transmit(t, &mut buf) {
ctx.proto.note_transmit_delivered(t);
}
}
}
state.remove_service(a, now);
assert!(
state
.services
.get(&a)
.map(|c| c.withdrawing)
.unwrap_or(false),
"unregister must begin the withdrawal and keep the ctx (withdrawing)"
);
match state.register_service(mk(), now) {
Err(crate::error::RegisterError::NameAlreadyRegistered(_)) => {}
Err(e) => panic!("a same-name registration must be rejected while withdrawing; got {e:?}"),
Ok(_) => {
panic!("a same-name registration must be rejected while the withdrawal holds the name")
}
}
let mut scratch = vec![0u8; 4096];
let mut t = now;
let mut completed = false;
for _ in 0..64 {
t += Duration::from_millis(250);
state.drain_withdrawals(t, &mut scratch).await;
if !state.services.contains_key(&a) {
completed = true;
break;
}
}
assert!(
completed,
"the withdrawal must complete (route freed + driver ctx GC'd) — by its 2 s \
anti-pin ceiling when no family can deliver"
);
state
.register_service(mk(), t)
.expect("the same name must be re-registerable once the withdrawal completes");
}
#[cfg(feature = "tokio")]
#[tokio::test]
async fn queued_conflict_survives_withdrawal_gc() {
use std::{net::Ipv4Addr, time::Duration};
use mdns_proto::ServiceUpdate;
let opts = crate::options::ServerOptions::default();
let sockets = BoundSockets::<agnostic_net::tokio::Net> {
v4: None,
v6: None,
interface_index: 0,
};
let mut state = DriverState::new(&opts, sockets);
let now = StdInstant::now();
let mut r = mdns_proto::ServiceRecords::new(
mdns_proto::Name::try_from_str("_ipp._tcp.local.").unwrap(),
mdns_proto::Name::try_from_str("cflt._ipp._tcp.local.").unwrap(),
mdns_proto::Name::try_from_str("cflt.local.").unwrap(),
631,
120,
);
r.add_a(Ipv4Addr::new(192, 168, 1, 10));
let reg = state
.register_service(mdns_proto::ServiceSpec::new(r), now)
.unwrap();
let handle = reg.handle;
let mailbox = Arc::clone(®.mailbox);
{
let ctx = state.services.get_mut(&handle).unwrap();
let mut buf = vec![0u8; 4096];
let mut t = now;
for _ in 0..40 {
t += Duration::from_millis(300);
let _ = ctx.proto.handle_timeout(t);
while let Ok(Some(_)) = ctx.proto.poll_transmit(t, &mut buf) {
ctx.proto.note_transmit_delivered(t);
}
}
}
{
let ctx = state.services.get_mut(&handle).unwrap();
deliver_service_update(ctx, ServiceUpdate::Conflict);
}
{
let ctx = state.services.get_mut(&handle).unwrap();
ctx.withdrawing = true;
let snap = ctx.proto.withdrawal_snapshot();
state.endpoint.begin_withdrawal(handle, snap, now);
}
let mut scratch = vec![0u8; 4096];
let mut t = now;
let mut completed = false;
for _ in 0..64 {
t += Duration::from_millis(250);
state.drain_withdrawals(t, &mut scratch).await;
if !state.services.contains_key(&handle) {
completed = true;
break;
}
}
assert!(
completed,
"the withdrawal must complete (route freed + driver ctx GC'd unconditionally)"
);
let drained = lock_mailbox_for_test(&mailbox);
assert!(
matches!(drained, Some(ServiceUpdate::Conflict)),
"the Conflict queued at retirement must survive the unconditional ctx GC and \
stay readable from the handle-owned mailbox; got {drained:?}"
);
drop(reg);
}
#[cfg(feature = "tokio")]
#[tokio::test]
async fn dropped_reply_reclaiming_register_keeps_old_name_goodbye() {
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::Duration,
};
use mdns_proto::{
Name, ServiceRecords, ServiceSpec,
event::RouteEvent,
wire::{Header, MessageBuilder},
};
use crate::command::Command;
let opts = crate::options::ServerOptions::default();
let sockets = BoundSockets::<agnostic_net::tokio::Net> {
v4: None,
v6: None,
interface_index: 0,
};
let mut state = DriverState::new(&opts, sockets);
let now = StdInstant::now();
let old_inst = Name::try_from_str("Old._ipp._tcp.local.").unwrap();
let mut r = ServiceRecords::new(
Name::try_from_str("_ipp._tcp.local.").unwrap(),
old_inst.clone(),
Name::try_from_str("old-host.local.").unwrap(),
631,
120,
);
r.add_a(Ipv4Addr::new(192, 168, 1, 10));
let reg = state.register_service(ServiceSpec::new(r), now).unwrap();
let handle = reg.handle;
let mut buf = std::vec![0u8; 4096];
{
let ctx = state.services.get_mut(&handle).unwrap();
let mut t = now;
for _ in 0..40 {
t += Duration::from_millis(300);
let _ = ctx.proto.handle_timeout(t);
while let Ok(Some(_)) = ctx.proto.poll_transmit(t, &mut buf) {
ctx.proto.note_transmit_delivered(t);
}
}
assert!(
ctx.proto.advertises_host(),
"Old must announce before the rename (so the goodbye is non-empty)"
);
}
let conflict = {
let target = Name::try_from_str("rival-host.local.").unwrap();
let mut cbuf = [0u8; 512];
let mut b = MessageBuilder::<'_, 32>::try_new(&mut cbuf, Header::new()).unwrap();
b.push_srv_authority(&old_inst, 120, 0, 0, 9999, &target)
.unwrap();
let n = b.finish().unwrap();
cbuf[..n].to_vec()
};
let src = SocketAddr::from(([192, 168, 1, 200], 5353));
let local_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10));
let mut t = now;
let mut renamed = false;
for _ in 0..80 {
t += Duration::from_millis(250);
{
let ctx = state.services.get_mut(&handle).unwrap();
let _ = ctx.proto.handle_timeout(t);
while let Ok(Some(_)) = ctx.proto.poll_transmit(t, &mut buf) {
ctx.proto.note_transmit_delivered(t);
}
}
{
let DriverState {
endpoint, services, ..
} = &mut state;
if let Ok(evs) = endpoint.handle(t, src, local_ip, 0, &conflict, false) {
for ev in evs {
if let Ok(RouteEvent::ToService(ts)) = ev
&& let Some(ctx) = services.get_mut(&ts.handle())
{
ctx.proto.handle_event(ts.into_event(), t);
}
}
}
}
state.push_updates(t).await;
if state
.services
.get(&handle)
.map(|c| c.proto.name().as_str() != old_inst.as_str())
.unwrap_or(true)
{
renamed = true;
break;
}
}
assert!(
renamed,
"Old must rename away under sustained conflict (seeding the detached goodbye)"
);
let (reply_tx, reply_rx) = futures::channel::oneshot::channel();
drop(reply_rx);
let mut r2 = ServiceRecords::new(
Name::try_from_str("_ipp._tcp.local.").unwrap(),
old_inst.clone(),
Name::try_from_str("new-host.local.").unwrap(),
631,
120,
);
r2.add_a(Ipv4Addr::new(192, 168, 1, 11));
state.handle_command(
Command::RegisterService {
spec: ServiceSpec::new(r2),
reply: reply_tx,
},
t,
);
assert!(
state
.endpoint
.poll_withdrawal_transmit(t, &mut buf)
.is_some(),
"the reclaimed old-name goodbye must survive the dropped-reply rollback and still emit"
);
drop(reg);
}
#[cfg(feature = "tokio")]
#[tokio::test]
async fn proto_emitted_host_conflict_retires_and_gcs_the_service() {
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::Duration,
};
use mdns_proto::{
event::RouteEvent,
wire::{Header, MessageBuilder},
};
let opts = crate::options::ServerOptions::default();
let sockets = BoundSockets::<agnostic_net::tokio::Net> {
v4: None,
v6: None,
interface_index: 0,
};
let mut state = DriverState::new(&opts, sockets);
let now = StdInstant::now();
let host = mdns_proto::Name::try_from_str("printer.local.").unwrap();
let mut r = mdns_proto::ServiceRecords::new(
mdns_proto::Name::try_from_str("_ipp._tcp.local.").unwrap(),
mdns_proto::Name::try_from_str("Printer._ipp._tcp.local.").unwrap(),
host.clone(),
631,
120,
);
r.add_a(Ipv4Addr::new(192, 168, 1, 10));
let reg = state
.register_service(mdns_proto::ServiceSpec::new(r), now)
.unwrap();
let handle = reg.handle;
let mailbox = Arc::clone(®.mailbox);
{
let ctx = state.services.get_mut(&handle).unwrap();
let mut buf = vec![0u8; 4096];
let mut t = now;
for _ in 0..40 {
t += Duration::from_millis(300);
let _ = ctx.proto.handle_timeout(t);
while let Ok(Some(_)) = ctx.proto.poll_transmit(t, &mut buf) {
ctx.proto.note_transmit_delivered(t);
}
}
}
let conflict = {
let mut cbuf = [0u8; 512];
let mut b = MessageBuilder::<'_, 32>::try_new(&mut cbuf, Header::new()).unwrap();
b.push_a_authority(&host, 120, Ipv4Addr::new(10, 0, 0, 99))
.unwrap();
let n = b.finish().unwrap();
cbuf[..n].to_vec()
};
{
let DriverState {
endpoint, services, ..
} = &mut state;
let route_events = endpoint
.handle(
now,
SocketAddr::from(([192, 168, 1, 200], 5353)),
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
0,
&conflict,
false,
)
.expect("endpoint.handle must accept the host-conflict packet");
for ev in route_events {
if let Ok(RouteEvent::ToService(ts)) = ev
&& let Some(ctx) = services.get_mut(&ts.handle())
{
ctx.proto.handle_event(ts.into_event(), now);
}
}
}
state.push_updates(now).await;
assert!(
state
.services
.get(&handle)
.map(|c| c.withdrawing)
.unwrap_or(false),
"a proto-emitted HostConflict must begin the withdrawal (withdrawing)"
);
let mut scratch = vec![0u8; 4096];
let mut t = now;
let mut gced = false;
for _ in 0..64 {
t += Duration::from_millis(250);
state.drain_withdrawals(t, &mut scratch).await;
if !state.services.contains_key(&handle) {
gced = true;
break;
}
}
assert!(
gced,
"the withdrawn ctx must be GC'd after the §10.1 goodbye completes"
);
let mut saw_host_conflict = false;
while let Some(u) = lock_mailbox_for_test(&mailbox) {
if u.is_host_conflict() {
saw_host_conflict = true;
}
}
assert!(
saw_host_conflict,
"the HostConflict terminal must survive the ctx GC and stay readable from the \
handle-owned mailbox"
);
drop(reg);
}
#[cfg(feature = "tokio")]
#[tokio::test]
async fn terminal_survives_full_mailbox_and_immediate_ctx_gc() {
use std::{net::Ipv4Addr, time::Duration};
use mdns_proto::ServiceUpdate;
let opts = crate::options::ServerOptions::default();
let sockets = BoundSockets::<agnostic_net::tokio::Net> {
v4: None,
v6: None,
interface_index: 0,
};
let mut state = DriverState::new(&opts, sockets);
let now = StdInstant::now();
let mut r = mdns_proto::ServiceRecords::new(
mdns_proto::Name::try_from_str("_ipp._tcp.local.").unwrap(),
mdns_proto::Name::try_from_str("stuck._ipp._tcp.local.").unwrap(),
mdns_proto::Name::try_from_str("stuck.local.").unwrap(),
631,
120,
);
r.add_a(Ipv4Addr::new(192, 168, 1, 10));
let reg = state
.register_service(mdns_proto::ServiceSpec::new(r), now)
.unwrap();
let handle = reg.handle;
let mailbox = Arc::clone(®.mailbox);
{
let ctx = state.services.get_mut(&handle).unwrap();
let mut buf = vec![0u8; 4096];
let mut t = now;
for _ in 0..40 {
t += Duration::from_millis(300);
let _ = ctx.proto.handle_timeout(t);
while let Ok(Some(_)) = ctx.proto.poll_transmit(t, &mut buf) {
ctx.proto.note_transmit_delivered(t);
}
}
}
{
let mut mb = mailbox.lock().unwrap_or_else(|e| e.into_inner());
mb.fill_non_terminal_to_cap_for_test();
assert_eq!(
mb.non_terminal_len(),
SERVICE_UPDATE_CAPACITY,
"the non-terminal ring must be saturated at the cap"
);
mb.set_terminal(ServiceUpdate::Conflict);
}
{
let ctx = state.services.get_mut(&handle).unwrap();
ctx.withdrawing = true;
let snap = ctx.proto.withdrawal_snapshot();
state.endpoint.begin_withdrawal(handle, snap, now);
}
let mut scratch = vec![0u8; 4096];
let mut t = now;
let mut completed = false;
for _ in 0..64 {
t += Duration::from_millis(250);
state.drain_withdrawals(t, &mut scratch).await;
if !state.services.contains_key(&handle) {
completed = true;
break;
}
}
assert!(
completed,
"the ctx must be GC'd unconditionally on withdrawal completion"
);
assert!(
!state.services.contains_key(&handle),
"no leak: the ctx must be gone from `services` after the withdrawal"
);
let mut non_terminal = 0usize;
let mut saw_conflict = false;
loop {
let drained = lock_mailbox_for_test(&mailbox);
match drained {
Some(ServiceUpdate::Conflict) => {
saw_conflict = true;
break;
}
Some(_) => non_terminal += 1,
None => break,
}
}
assert_eq!(
non_terminal, SERVICE_UPDATE_CAPACITY,
"the saturated non-terminal ring must drain in full before the terminal"
);
assert!(
saw_conflict,
"the reserved terminal Conflict must survive a full mailbox + an immediate, \
unconditional ctx GC and reach the live reader"
);
drop(reg);
}
#[cfg(feature = "tokio")]
#[test]
fn duplicate_registration_maps_to_name_already_registered() {
use std::net::Ipv4Addr;
let opts = crate::options::ServerOptions::default();
let sockets = BoundSockets::<agnostic_net::tokio::Net> {
v4: None,
v6: None,
interface_index: 0,
};
let mut state = DriverState::new(&opts, sockets);
let now = StdInstant::now();
let mk = || {
let mut r = mdns_proto::ServiceRecords::new(
mdns_proto::Name::try_from_str("_http._tcp.local.").unwrap(),
mdns_proto::Name::try_from_str("dup._http._tcp.local.").unwrap(),
mdns_proto::Name::try_from_str("dup.local.").unwrap(),
80,
120,
);
r.add_a(Ipv4Addr::new(192, 168, 1, 10));
mdns_proto::ServiceSpec::new(r)
};
state.register_service(mk(), now).unwrap();
match state.register_service(mk(), now) {
Err(crate::error::RegisterError::NameAlreadyRegistered(_)) => {}
Err(e) => panic!("expected NameAlreadyRegistered, got error {e:?}"),
Ok(_) => panic!("expected NameAlreadyRegistered, but the second registration succeeded"),
}
}
#[cfg(all(feature = "stats", feature = "tokio"))]
#[tokio::test]
async fn unencodable_query_retire_records_terminal_stats() {
let opts = crate::options::ServerOptions::default();
let sockets = BoundSockets::<agnostic_net::tokio::Net> {
v4: None,
v6: None,
interface_index: 0,
};
let mut state = DriverState::new(&opts, sockets);
let now = StdInstant::now();
let qname = mdns_proto::Name::try_from_str("printer.local.").unwrap();
let started = state
.start_query(
mdns_proto::QuerySpec::new(qname, mdns_proto::wire::ResourceType::A),
now,
)
.unwrap();
let h = started.handle;
let before = state.stats.snapshot();
assert_eq!(
before.queries_active, 1,
"one active query before encode failure"
);
assert_eq!(before.queries_done, 0, "no terminal yet");
let mut scratch = vec![0u8; 1];
state.drain_transmits(now, &mut scratch).await;
let after = state.stats.snapshot();
assert_eq!(
after.queries_active, 0,
"queries_active must be 0 after retire_query on encode failure (was leaking)"
);
assert_eq!(
after.queries_done, 1,
"exactly one terminal (queries_done) must be recorded after encode failure; \
got queries_done={}, queries_timeout={}",
after.queries_done, after.queries_timeout,
);
assert!(
!state.queries.contains_key(&h),
"the retired query slot must be removed from the driver map"
);
let mb = started.mailbox;
let (cmd_tx, _cmd_rx) = async_channel::unbounded::<crate::command::Command>();
let mut q = crate::query::Query::new(h, mb, started.doorbell, cmd_tx);
let event = tokio::time::timeout(std::time::Duration::from_millis(200), q.next())
.await
.expect("Query::next must complete (terminal is already in mailbox)")
.expect("Query::next must return Some(Terminal), not None");
assert!(
matches!(event, crate::query::QueryEvent::Terminal(_)),
"the first event from Query::next must be the terminal; got {event:?}"
);
}
#[cfg(all(feature = "stats", feature = "tokio"))]
#[tokio::test]
async fn encode_retired_gc_runs_with_subsequent_queries_pending() {
let opts = crate::options::ServerOptions::default();
let sockets = BoundSockets::<agnostic_net::tokio::Net> {
v4: None,
v6: None,
interface_index: 0,
};
let mut state = DriverState::new(&opts, sockets);
let now = StdInstant::now();
let bad_qname = mdns_proto::Name::try_from_str("encode-fail.local.").unwrap();
let bad_started = state
.start_query(
mdns_proto::QuerySpec::new(bad_qname, mdns_proto::wire::ResourceType::A),
now,
)
.unwrap();
let bad_h = bad_started.handle;
let mut normal_started = Vec::new();
for i in 0u8..4 {
let name = mdns_proto::Name::try_from_str(&format!("normal-{i}.local.")).unwrap();
let started = state
.start_query(
mdns_proto::QuerySpec::new(name, mdns_proto::wire::ResourceType::A),
now,
)
.unwrap();
normal_started.push(started);
}
let normal_handles: Vec<_> = normal_started.iter().map(|s| s.handle).collect();
let before = state.stats.snapshot();
assert_eq!(before.queries_active, 5, "five active queries before drain");
let mut scratch = vec![0u8; 1];
let more_pending = state.drain_transmits(now, &mut scratch).await;
assert!(
!more_pending,
"null sockets never exhaust credits; more_pending must be false"
);
assert!(
!state.queries.contains_key(&bad_h),
"the encode-retired query handle must be removed from the driver map after drain_transmits"
);
let after = state.stats.snapshot();
assert_eq!(
after.queries_active, 0,
"all five queries must be retired; queries_active must be 0"
);
assert_eq!(
after.queries_done, 5,
"five terminals (queries_done) must be recorded; \
got queries_done={}, queries_timeout={}",
after.queries_done, after.queries_timeout,
);
for &h in &normal_handles {
assert!(
!state.queries.contains_key(&h),
"normal query handle {h:?} must also be removed (all encode-failed with 1-byte scratch)"
);
}
}
#[cfg(feature = "stats")]
#[test]
fn consumed_oversized_datagram_counts_rx_and_dropped() {
let stats = std::sync::Arc::new(hick_trace::stats::Stats::default());
let buf_len: usize = 9000;
count_consumed_oversized(&stats, buf_len);
let snap = stats.snapshot();
assert_eq!(
snap.packets_rx, 1,
"packets_rx must be 1 (datagram was consumed)"
);
assert_eq!(
snap.bytes_rx, buf_len as u64,
"bytes_rx must equal buf_len (best-effort truncated payload)"
);
assert_eq!(
snap.packets_dropped, 1,
"packets_dropped must be 1 (unusable datagram)"
);
}
#[cfg(feature = "stats")]
#[test]
fn generic_recv_error_does_not_increment_any_stats() {
let stats = std::sync::Arc::new(hick_trace::stats::Stats::default());
let _e = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "simulated");
hick_trace::debug!(error = %_e, "recv_with_meta failed (test simulation — no stats bumped)");
let snap = stats.snapshot();
assert_eq!(
snap.packets_rx, 0,
"packets_rx must stay 0 on a generic recv error"
);
assert_eq!(
snap.bytes_rx, 0,
"bytes_rx must stay 0 on a generic recv error"
);
assert_eq!(
snap.packets_dropped, 0,
"packets_dropped must stay 0 on a generic recv error"
);
}