use core::cell::Cell;
use std::rc::Rc;
use super::*;
#[compio::test]
async fn local_notify_wakes_a_listener() {
let n = LocalNotify::new();
let woken = Rc::new(Cell::new(false));
let woken_in = woken.clone();
let n2 = n.clone();
compio_runtime::spawn(async move {
n2.listen().await;
woken_in.set(true);
})
.detach();
compio::time::sleep(std::time::Duration::from_millis(10)).await;
n.notify();
compio::time::sleep(std::time::Duration::from_millis(10)).await;
assert!(woken.get(), "listener woken by notify()");
}
#[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 is_mdns_multicast_dst_classifies_groups_and_ports() {
use core::net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6};
assert!(is_mdns_multicast_dst(SocketAddr::V4(SocketAddrV4::new(
Ipv4Addr::new(224, 0, 0, 251),
5353
))));
assert!(is_mdns_multicast_dst(SocketAddr::V6(SocketAddrV6::new(
Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 0x00fb),
5353,
0,
0
))));
assert!(!is_mdns_multicast_dst(SocketAddr::V4(SocketAddrV4::new(
Ipv4Addr::new(192, 168, 1, 5),
5353
))));
assert!(!is_mdns_multicast_dst(SocketAddr::V4(SocketAddrV4::new(
Ipv4Addr::new(224, 0, 0, 251),
53
))));
}
#[test]
fn state_construction_is_empty() {
let s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
assert_eq!(s.services.len(), 0);
assert_eq!(s.queries.len(), 0);
assert!(s.completed_withdrawals.is_empty());
}
#[test]
fn fire_timeouts_runs_without_panic_on_empty_state() {
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
s.fire_timeouts(std::time::Instant::now());
}
#[compio::test]
async fn endpoint_inner_can_be_constructed_and_dropped() {
let cfg = mdns_proto::EndpointConfig::default();
let inner = EndpointInner::new(cfg, 1500, 9000);
let n = inner.notify.clone();
let h = compio_runtime::spawn(async move {
n.listen().await;
});
compio::time::sleep(std::time::Duration::from_millis(5)).await;
inner.notify.notify();
h.await.ok();
drop(inner);
}
#[test]
fn mark_dirty_sets_a_durable_level_flag_consumed_by_replace() {
let inner = EndpointInner::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
assert!(!inner.dirty.get(), "dirty must start clear");
inner.mark_dirty();
assert!(inner.dirty.get(), "mark_dirty must set the level flag");
let force_now = inner.dirty.replace(false);
assert!(
force_now,
"the pre-park decision must observe the pending work"
);
assert!(
!inner.dirty.get(),
"consuming the flag clears it so a clean iteration can park"
);
assert!(
!inner.dirty.replace(false),
"no work created since last consume → not dirty → driver may park"
);
inner.mark_dirty();
assert!(
inner.dirty.replace(false),
"work created after the previous consume must be observed at the next park boundary"
);
}
#[cfg(feature = "stats")]
#[test]
fn pre_drop_short_qr1_counts_rx_and_dropped_exactly_once() {
use core::net::{IpAddr, Ipv4Addr, SocketAddr};
use crate::socket::RecvMeta;
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
s.local_subnets = vec![(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 0)), 8)];
s.bound_interface = 1;
let data: Vec<u8> = vec![0x00, 0x00, 0x80];
let len = data.len() as u64;
let meta = RecvMeta::new(
SocketAddr::from(([127, 0, 0, 1], 40000)), IpAddr::V4(Ipv4Addr::UNSPECIFIED),
1,
Some(255), None,
len as usize,
);
s.handle_datagram(&meta, &data);
let snap = s.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 = "stats")]
#[test]
fn pre_drop_untrusted_qr1_response_counts_rx_and_dropped_exactly_once() {
use core::net::{IpAddr, Ipv4Addr, SocketAddr};
use crate::socket::RecvMeta;
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
s.local_subnets = vec![(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 0)), 8)];
s.bound_interface = 1;
let data: Vec<u8> = vec![
0x00, 0x00, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
let len = data.len() as u64;
assert!(s.recent_sends.is_empty(), "no prior self-send credits");
let meta = RecvMeta::new(
SocketAddr::from(([127, 0, 0, 1], 54321)), IpAddr::V4(Ipv4Addr::UNSPECIFIED),
1,
Some(255), None,
len as usize,
);
s.handle_datagram(&meta, &data);
assert!(
s.recent_sends.is_empty(),
"self-send credit ring must be untouched"
);
let snap = s.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 = "stats")]
#[test]
fn pre_drop_off_link_datagram_counts_rx_and_dropped_exactly_once() {
use core::net::{IpAddr, Ipv4Addr, SocketAddr};
use crate::socket::RecvMeta;
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
s.local_subnets = vec![(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 0)), 8)];
s.bound_interface = 1;
let data: Vec<u8> = vec![
0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
let len = data.len() as u64;
let meta = RecvMeta::new(
SocketAddr::from(([203, 0, 113, 5], 5353)),
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
1,
Some(64), None,
len as usize,
);
s.handle_datagram(&meta, &data);
let snap = s.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");
}
#[test]
fn handle_datagram_drops_off_link_packet() {
use core::net::{IpAddr, Ipv4Addr, SocketAddr};
use crate::socket::RecvMeta;
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
s.local_subnets = vec![(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 0)), 8)];
s.bound_interface = 1;
let meta = RecvMeta::new(
SocketAddr::from(([8, 8, 8, 8], 5353)),
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
1,
Some(64), None,
12,
);
let data = vec![0u8; 12];
s.handle_datagram(&meta, &data);
}
#[cfg(test)]
fn establish_service(
s: &mut State,
handle: ServiceHandle,
t0: std::time::Instant,
) -> std::time::Instant {
let mut t = t0;
let mut buf = vec![0u8; 4096];
for _ in 0..40 {
t += Duration::from_millis(300);
let ctx = s.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);
}
}
assert!(
s.services
.get(&handle)
.map(|c| c.proto.advertises_host())
.unwrap_or(false),
"service must advertise at least one record before withdrawal"
);
t
}
#[test]
fn begin_service_withdrawal_holds_name_then_frees_on_completion() {
use mdns_proto::{Name, ServiceRecords, ServiceSpec, error::RegisterServiceError};
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
let t0 = std::time::Instant::now();
let stype = Name::try_from_str("_gb._tcp.local.").unwrap();
let inst = Name::try_from_str("G._gb._tcp.local.").unwrap();
let host = Name::try_from_str("g.local.").unwrap();
let mut recs = ServiceRecords::new(stype.clone(), inst.clone(), host.clone(), 1234, 120);
recs.add_a([127, 0, 0, 1].into());
let handle = s.test_register_service(ServiceSpec::new(recs), t0).unwrap();
let mut t = establish_service(&mut s, handle, t0);
s.begin_service_withdrawal(handle, t);
assert!(
s.services.get(&handle).map(|c| c.errored).unwrap_or(false),
"begin_service_withdrawal must keep the ctx and mark it errored"
);
let mut dup = ServiceRecords::new(stype.clone(), inst.clone(), host.clone(), 1234, 120);
dup.add_a([127, 0, 0, 1].into());
assert!(
matches!(
s.test_register_service(ServiceSpec::new(dup), t),
Err(RegisterServiceError::NameAlreadyRegistered(_))
),
"a same-name registration must be rejected while the withdrawal holds the name"
);
let mut scratch = vec![0u8; 4096];
let mut completed = false;
for _ in 0..64 {
t += Duration::from_millis(250);
while let Some((_, _, tok)) = s.poll_one_withdrawal(t, &mut scratch) {
s.note_withdrawal_result(tok, t, WithdrawalSend::Retry, WithdrawalSend::Retry);
}
s.drain_completed_withdrawals(t);
if !s.services.contains_key(&handle) {
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"
);
let mut recs2 = ServiceRecords::new(stype, inst, host, 1234, 120);
recs2.add_a([127, 0, 0, 1].into());
assert!(
s.test_register_service(ServiceSpec::new(recs2), t).is_ok(),
"the proto-layer route slot must be freed once the withdrawal completes"
);
}
#[compio::test]
async fn drop_defers_withdrawal_to_driver_sweep() {
use mdns_proto::{Name, ServiceRecords, ServiceSpec};
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
let t0 = std::time::Instant::now();
let stype = Name::try_from_str("_sw._tcp.local.").unwrap();
let inst = Name::try_from_str("s._sw._tcp.local.").unwrap();
let host = Name::try_from_str("s.local.").unwrap();
let mut recs = ServiceRecords::new(stype, inst, host, 1234, 120);
recs.add_a([127, 0, 0, 1].into());
let handle = s.test_register_service(ServiceSpec::new(recs), t0).unwrap();
let t = establish_service(&mut s, handle, t0);
s.flag_service_unregistered(handle);
assert!(
s.services.contains_key(&handle),
"drop must NOT remove the service synchronously"
);
assert!(
!s.services.get(&handle).map(|c| c.errored).unwrap_or(true),
"drop must NOT begin the withdrawal synchronously — the driver sweep does"
);
assert!(
s.services
.get(&handle)
.map(|c| c.cancelled)
.unwrap_or(false),
"the service must be flagged cancelled"
);
assert!(
s.has_pending_withdrawal(),
"a cancelled-but-unswept service must report a pending withdrawal"
);
let swept = s.sweep_cancelled_services(t);
assert!(swept, "sweep must report it retired a cancelled service");
assert!(
s.services.get(&handle).map(|c| c.errored).unwrap_or(false),
"sweep must begin the withdrawal (ctx kept, marked errored)"
);
assert!(
!s.has_pending_withdrawal(),
"after the sweep the cancelled service is already withdrawing (errored), so \
it is no longer reported as an unswept pending withdrawal"
);
}
#[test]
fn shutdown_loop_sweeps_a_drop_that_raced_the_prior_sweep() {
use mdns_proto::{Name, ServiceRecords, ServiceSpec};
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
let t = std::time::Instant::now();
let recs = ServiceRecords::new(
Name::try_from_str("_ipp._tcp.local.").unwrap(),
Name::try_from_str("Svc._ipp._tcp.local.").unwrap(),
Name::try_from_str("h.local.").unwrap(),
631,
120,
);
let a = s.test_register_service(ServiceSpec::new(recs), t).unwrap();
assert!(
!s.sweep_cancelled_services(t),
"nothing is cancelled before the drop"
);
s.flag_service_unregistered(a);
assert!(
s.has_pending_withdrawal(),
"the post-sweep drop is an unswept pending withdrawal"
);
assert!(
s.sweep_cancelled_services(t),
"the shutdown-loop sweep retires the raced cancellation"
);
assert!(
s.next_withdrawal_deadline().is_some(),
"a withdrawal now exists for the raced drop — not GC'd goodbye-less"
);
}
#[test]
fn dropped_ctx_with_undrained_update_is_gc_d_not_leaked() {
use mdns_proto::{Name, ServiceRecords, ServiceSpec};
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
let t = std::time::Instant::now();
let recs = ServiceRecords::new(
Name::try_from_str("_ipp._tcp.local.").unwrap(),
Name::try_from_str("Svc._ipp._tcp.local.").unwrap(),
Name::try_from_str("h.local.").unwrap(),
631,
120,
);
let a = s.test_register_service(ServiceSpec::new(recs), t).unwrap();
s.services
.get(&a)
.unwrap()
.mailbox
.borrow_mut()
.push_update(ServiceUpdate::Established);
s.flag_service_unregistered(a);
s.sweep_cancelled_services(t);
s.drain_completed_withdrawals(t);
assert!(
!s.services.contains_key(&a),
"a cancelled ctx with an undrained update must be GC'd UNCONDITIONALLY on \
withdrawal completion, never deferred and leaked"
);
}
#[test]
fn completed_ctx_gc_keeps_terminal_observable_by_live_reader() {
use mdns_proto::{Name, ServiceRecords, ServiceSpec};
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
let t = std::time::Instant::now();
let recs = ServiceRecords::new(
Name::try_from_str("_ipp._tcp.local.").unwrap(),
Name::try_from_str("Svc._ipp._tcp.local.").unwrap(),
Name::try_from_str("h.local.").unwrap(),
631,
120,
);
let a = s.test_register_service(ServiceSpec::new(recs), t).unwrap();
let reader_mailbox = Rc::clone(&s.services.get(&a).unwrap().mailbox);
reader_mailbox
.borrow_mut()
.set_terminal(ServiceUpdate::Conflict);
s.begin_service_withdrawal(a, t);
s.drain_completed_withdrawals(t);
assert!(
!s.services.contains_key(&a),
"the completed ctx is GC'd unconditionally — no pending-terminal defer"
);
assert!(
matches!(
reader_mailbox.borrow_mut().drain_for_test(),
Some(ServiceUpdate::Conflict)
),
"the terminal Conflict must survive the immediate ctx GC and be drainable \
by a live reader (mailbox outlives the ctx)"
);
}
#[test]
fn terminal_survives_full_mailbox_and_immediate_ctx_gc() {
use mdns_proto::{Name, ServiceRecords, ServiceSpec};
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
let t = std::time::Instant::now();
let recs = ServiceRecords::new(
Name::try_from_str("_ipp._tcp.local.").unwrap(),
Name::try_from_str("Svc._ipp._tcp.local.").unwrap(),
Name::try_from_str("h.local.").unwrap(),
631,
120,
);
let a = s.test_register_service(ServiceSpec::new(recs), t).unwrap();
let reader_mailbox = Rc::clone(&s.services.get(&a).unwrap().mailbox);
{
let mut mb = reader_mailbox.borrow_mut();
mb.fill_non_terminal_to_cap_for_test();
mb.set_terminal(ServiceUpdate::Conflict);
assert_eq!(
mb.non_terminal_len(),
crate::service::SERVICE_UPDATE_CAPACITY,
"the non-terminal ring is full"
);
assert!(mb.has_terminal(), "the terminal slot is reserved");
}
s.begin_service_withdrawal(a, t);
s.drain_completed_withdrawals(t);
assert!(
!s.services.contains_key(&a),
"the ctx must be gone from `services` after the withdrawal completes"
);
let mut non_terminal = 0usize;
let mut got_terminal = false;
while let Some(upd) = reader_mailbox.borrow_mut().drain_for_test() {
match upd {
ServiceUpdate::Conflict => got_terminal = true,
_ => non_terminal += 1,
}
}
assert_eq!(
non_terminal,
crate::service::SERVICE_UPDATE_CAPACITY,
"every buffered non-terminal update survives the ctx GC"
);
assert!(
got_terminal,
"the reserved Conflict IS observed by the live reader after the immediate \
ctx GC (mailbox is handle-owned and outlives the ctx)"
);
}
#[test]
fn same_name_replacement_is_rejected_until_withdrawal_completes() {
use mdns_proto::{Name, ServiceRecords, ServiceSpec, error::RegisterServiceError};
let cfg = mdns_proto::EndpointConfig::new().with_probe_unique_names(false);
let mut s = State::new(cfg, 1500, 9000);
let t0 = std::time::Instant::now();
let mk = || {
let mut r = ServiceRecords::new(
Name::try_from_str("_ipp._tcp.local.").unwrap(),
Name::try_from_str("repl._ipp._tcp.local.").unwrap(),
Name::try_from_str("repl.local.").unwrap(),
631,
120,
);
r.add_a([192, 168, 1, 10].into());
ServiceSpec::new(r)
};
let a = s.test_register_service(mk(), t0).unwrap();
let mut t = establish_service(&mut s, a, t0);
s.flag_service_unregistered(a);
s.sweep_cancelled_services(t);
assert!(
s.services.get(&a).map(|c| c.errored).unwrap_or(false),
"the sweep must begin the withdrawal and keep the ctx (errored)"
);
assert!(
matches!(
s.test_register_service(mk(), t),
Err(RegisterServiceError::NameAlreadyRegistered(_))
),
"a same-name registration must be rejected while the withdrawal holds the name"
);
let mut scratch = vec![0u8; 4096];
let mut completed = false;
for _ in 0..64 {
t += Duration::from_millis(250);
while let Some((_, _, tok)) = s.poll_one_withdrawal(t, &mut scratch) {
s.note_withdrawal_result(tok, t, WithdrawalSend::Retry, WithdrawalSend::Retry);
}
s.drain_completed_withdrawals(t);
if !s.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"
);
s.test_register_service(mk(), t)
.expect("the same name must be re-registerable once the withdrawal completes");
}
#[test]
fn oversized_service_escalates_to_conflict_not_silent_stall() {
use mdns_proto::{Name, ServiceRecords, ServiceSpec};
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1, 9000);
let now = std::time::Instant::now();
let stype = Name::try_from_str("_ovf._tcp.local.").unwrap();
let inst = Name::try_from_str("Oversized._ovf._tcp.local.").unwrap();
let host = Name::try_from_str("oversized.local.").unwrap();
let mut recs = ServiceRecords::new(stype, inst, host, 8080, 120);
recs.add_a([192, 168, 1, 42].into());
recs.add_aaaa([0xfe80, 0, 0, 0, 0, 0, 0, 0x1234].into());
recs.add_txt_segment(b"path=/health".to_vec());
let handle = s
.test_register_service(ServiceSpec::new(recs), now)
.unwrap();
let mut scratch = [0u8; 1];
let mut t = now;
let mut armed = false;
for _ in 0..40 {
t += Duration::from_millis(300);
s.fire_timeouts(t);
let pumped = s.poll_one_transmit(t, &mut scratch);
assert!(
pumped.is_none(),
"an un-encodable transmit must never be returned as a phantom send"
);
if s.services.get(&handle).unwrap().encode_failures == 1 {
armed = true;
break;
}
}
assert!(
armed,
"the proto must queue a probe that fails to encode into the 1-byte scratch"
);
for expected in 2..=MAX_CONSECUTIVE_ENCODE_ERRORS {
let pumped = s.poll_one_transmit(t, &mut scratch);
assert!(
pumped.is_none(),
"an un-encodable transmit must never be returned as a phantom send \
(failure #{expected})"
);
assert_eq!(
s.services.get(&handle).unwrap().encode_failures,
expected,
"each failing poll must increment encode_failures by one"
);
}
{
let ctx = s.services.get(&handle).unwrap();
assert!(
ctx.errored,
"reaching MAX_CONSECUTIVE_ENCODE_ERRORS must mark the service errored"
);
assert!(
ctx.mailbox.borrow().has_terminal(),
"the escalation must record a reserved-slot Conflict for Service::next"
);
}
assert!(
s.poll_one_transmit(now, &mut scratch).is_none(),
"an errored service must be skipped by poll_one_transmit"
);
assert_eq!(
s.services.get(&handle).unwrap().encode_failures,
MAX_CONSECUTIVE_ENCODE_ERRORS,
"a skipped errored service must not have its failure counter advanced further"
);
let mailbox = Rc::clone(&s.services.get(&handle).unwrap().mailbox);
assert!(
matches!(
mailbox.borrow_mut().drain_for_test(),
Some(ServiceUpdate::Conflict)
),
"the reserved Conflict must remain readable by Service::next"
);
assert!(
mailbox.borrow_mut().drain_for_test().is_none(),
"after the terminal Conflict the mailbox reports end-of-stream"
);
}
#[cfg(feature = "stats")]
#[test]
fn encode_failure_escalation_frees_proto_route_and_decrements_services_active() {
use std::time::Duration;
use mdns_proto::{Name, ServiceRecords, ServiceSpec};
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1, 9000);
let now = std::time::Instant::now();
let stype = Name::try_from_str("_http._tcp.local.").unwrap();
let inst = Name::try_from_str("F2Test._http._tcp.local.").unwrap();
let host = Name::try_from_str("f2test.local.").unwrap();
let mut recs = ServiceRecords::new(stype.clone(), inst.clone(), host.clone(), 80, 120);
recs.add_a([10, 0, 0, 1].into());
let handle = s
.test_register_service(ServiceSpec::new(recs), now)
.unwrap();
assert_eq!(
s.stats.snapshot().services_active,
1,
"services_active must be 1 after registration"
);
let mut scratch = [0u8; 1];
let mut t = now;
let mut armed = false;
for _ in 0..40 {
t += Duration::from_millis(300);
s.fire_timeouts(t);
s.poll_one_transmit(t, &mut scratch);
if s.services.get(&handle).unwrap().encode_failures == 1 {
armed = true;
break;
}
}
assert!(armed, "must reach the first encode failure");
for _ in 2..=MAX_CONSECUTIVE_ENCODE_ERRORS {
s.poll_one_transmit(t, &mut scratch);
}
assert!(
s.services.get(&handle).unwrap().errored,
"service must be errored after escalation"
);
let mailbox = Rc::clone(&s.services.get(&handle).unwrap().mailbox);
assert!(
mailbox.borrow().has_terminal(),
"the escalation must record a reserved-slot Conflict for Service::next"
);
s.drain_completed_withdrawals(t);
assert_eq!(
s.stats.snapshot().services_active,
0,
"services_active must be 0 after the encode-failure withdrawal completes (route freed)"
);
assert!(
!s.services.contains_key(&handle),
"the ctx must be GC'd unconditionally once its withdrawal completes"
);
assert!(
matches!(
mailbox.borrow_mut().drain_for_test(),
Some(ServiceUpdate::Conflict)
),
"the reserved Conflict survives the ctx GC and is drainable by Service::next"
);
let mut recs2 = ServiceRecords::new(stype, inst, host, 80, 120);
recs2.add_a([10, 0, 0, 2].into());
s.test_register_service(ServiceSpec::new(recs2), t)
.expect("same service name must be re-registerable after encode-failure withdrawal");
assert_eq!(
s.stats.snapshot().services_active,
1,
"services_active must be 1 after re-registration"
);
}
#[cfg(feature = "stats")]
#[test]
fn multi_service_encode_failure_frees_route_even_with_sibling_transmit() {
use std::time::Duration;
use mdns_proto::{Name, ServiceRecords, ServiceSpec};
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
let now = std::time::Instant::now();
let stype_a = Name::try_from_str("_http._tcp.local.").unwrap();
let inst_a = Name::try_from_str("Retire._http._tcp.local.").unwrap();
let host_a = Name::try_from_str("retire.local.").unwrap();
let mut recs_a = ServiceRecords::new(stype_a.clone(), inst_a.clone(), host_a.clone(), 80, 120);
recs_a.add_a([10, 0, 0, 1].into());
recs_a.add_txt_segment(vec![b'x'; 255]);
recs_a.add_txt_segment(vec![b'y'; 255]);
recs_a.add_txt_segment(vec![b'z'; 255]);
recs_a.add_txt_segment(vec![b'w'; 255]);
recs_a.add_txt_segment(vec![b'v'; 255]);
recs_a.add_txt_segment(vec![b'u'; 255]);
let handle_a = s
.test_register_service(ServiceSpec::new(recs_a), now)
.unwrap();
let stype_b = Name::try_from_str("_grpc._tcp.local.").unwrap();
let inst_b = Name::try_from_str("Active._grpc._tcp.local.").unwrap();
let host_b = Name::try_from_str("active.local.").unwrap();
let mut recs_b = ServiceRecords::new(stype_b, inst_b.clone(), host_b.clone(), 443, 120);
recs_b.add_a([10, 0, 0, 2].into());
let handle_b = s
.test_register_service(ServiceSpec::new(recs_b), now)
.unwrap();
assert_eq!(
s.stats.snapshot().services_active,
2,
"both services registered: services_active must be 2"
);
let mut scratch = [0u8; 1500];
let mut t = now;
let mut a_retired = false;
for _ in 0..40 {
t += Duration::from_millis(300);
s.fire_timeouts(t);
let result = s.poll_one_transmit(t, &mut scratch);
if let Some((_, _, TransmitOrigin::Service(h))) = result {
assert_eq!(
h, handle_b,
"any returned transmit must be from B, never from A (A's records won't encode)"
);
s.note_service_transmit_result(h, t, true);
}
if s
.services
.get(&handle_a)
.map(|c| c.errored)
.unwrap_or(false)
{
assert_eq!(
s.stats.snapshot().services_active,
2,
"services_active must be 2 when A escalates (A's route held by its \
in-iteration-begun withdrawal + B live), even if B returned Ok(Some) in \
the same poll_one_transmit call (regression: deferred-drain bypass)"
);
a_retired = true;
break;
}
}
assert!(
a_retired,
"A must be retired by encode-failure escalation within 40 pumps"
);
let a_mailbox = Rc::clone(&s.services.get(&handle_a).unwrap().mailbox);
assert!(
a_mailbox.borrow().has_terminal(),
"A's reserved-slot Conflict must be set for Service::next"
);
s.drain_completed_withdrawals(t);
assert!(
matches!(
a_mailbox.borrow_mut().drain_for_test(),
Some(ServiceUpdate::Conflict)
),
"A's reserved Conflict survives its ctx GC and is drainable by Service::next"
);
assert_eq!(
s.stats.snapshot().services_active,
1,
"services_active must be 1 once A's (empty) withdrawal completes (B still live)"
);
let mut recs_a2 = ServiceRecords::new(stype_a, inst_a, host_a, 80, 120);
recs_a2.add_a([10, 0, 0, 3].into());
s.test_register_service(ServiceSpec::new(recs_a2), t)
.expect("A's name must be re-registerable after its in-iteration-begun withdrawal completes");
assert_eq!(
s.stats.snapshot().services_active,
2,
"services_active must be 2 after re-registering A (B still live)"
);
assert!(
!s.services.get(&handle_b).map(|c| c.errored).unwrap_or(true),
"B must not be errored — its small records encode successfully"
);
}
#[cfg(feature = "stats")]
#[test]
fn rename_collision_with_local_service_frees_proto_route() {
use std::time::Duration;
use core::net::{IpAddr, Ipv4Addr, SocketAddr};
use crate::socket::RecvMeta;
use mdns_proto::{
Name, ServiceRecords, ServiceSpec,
wire::{Header, MessageBuilder},
};
fn conflict_for(instance: &str) -> Vec<u8> {
let mut buf = [0u8; 512];
let name = Name::try_from_str(instance).unwrap();
let target = Name::try_from_str("rival.local.").unwrap();
let mut b = MessageBuilder::<'_, 32>::try_new(&mut buf, Header::new()).unwrap();
b.push_srv_authority(&name, 120, 0, 0, 9999, &target)
.unwrap();
let n = b.finish().unwrap();
buf[..n].to_vec()
}
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
s.local_subnets = vec![(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 0)), 24)];
s.bound_interface = 1;
let now = std::time::Instant::now();
let stype = Name::try_from_str("_ipp._tcp.local.").unwrap();
let inst_a = Name::try_from_str("First._ipp._tcp.local.").unwrap();
let host_a = Name::try_from_str("first.local.").unwrap();
let mut recs_a = ServiceRecords::new(stype.clone(), inst_a.clone(), host_a.clone(), 80, 120);
recs_a.add_a([192, 168, 1, 1].into());
let handle_a = s
.test_register_service(ServiceSpec::new(recs_a), now)
.unwrap();
let inst_b = Name::try_from_str("First-1._ipp._tcp.local.").unwrap();
let host_b = Name::try_from_str("second.local.").unwrap();
let mut recs_b = ServiceRecords::new(stype, inst_b, host_b, 80, 120);
recs_b.add_a([192, 168, 1, 2].into());
s.test_register_service(ServiceSpec::new(recs_b), now)
.unwrap();
assert_eq!(
s.stats.snapshot().services_active,
2,
"both services registered: services_active must be 2"
);
fn pump_transmits(s: &mut State, t: StdInstant, buf: &mut [u8]) {
loop {
match s.poll_one_transmit(t, buf) {
Some((_, _, TransmitOrigin::Service(h))) => {
s.note_service_transmit_result(h, t, true);
}
Some(_) => {}
None => break,
}
}
}
let a_mailbox = Rc::clone(&s.services.get(&handle_a).unwrap().mailbox);
let mut buf = [0u8; 1500];
let mut t = now;
let mut a_established = false;
for _ in 0..60 {
t += Duration::from_millis(300);
s.fire_timeouts(t);
pump_transmits(&mut s, t, &mut buf);
s.push_service_updates(t);
while let Some(u) = a_mailbox.borrow_mut().drain_for_test() {
if matches!(u, ServiceUpdate::Established) {
a_established = true;
}
}
if a_established {
break;
}
}
let _ = a_established;
let conflict = conflict_for("First._ipp._tcp.local.");
let peer = RecvMeta::new(
SocketAddr::from(([192, 168, 1, 200], 5353)),
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 200)),
1,
Some(255),
None,
conflict.len(),
);
let mut conflicted = false;
for _ in 0..80 {
t += Duration::from_millis(300);
s.fire_timeouts(t);
s.handle_datagram(&peer, &conflict);
pump_transmits(&mut s, t, &mut buf);
s.push_service_updates(t);
if s
.services
.get(&handle_a)
.map(|c| c.errored)
.unwrap_or(false)
{
conflicted = true;
break;
}
}
assert!(
conflicted,
"A must be driven to rename-collision-Conflict within 60 iterations"
);
assert_eq!(
s.stats.snapshot().services_active,
2,
"services_active must still be 2 while A's rename-collision withdrawal holds \
the route (B live + A withdrawing)"
);
assert!(
a_mailbox.borrow().has_terminal(),
"A's reserved-slot Conflict must be set for Service::next"
);
let mut scratch = vec![0u8; 4096];
let mut completed = false;
for _ in 0..64 {
t += Duration::from_millis(250);
while let Some((_, _, tok)) = s.poll_one_withdrawal(t, &mut scratch) {
s.note_withdrawal_result(tok, t, WithdrawalSend::Retry, WithdrawalSend::Retry);
}
s.drain_completed_withdrawals(t);
if !s.services.contains_key(&handle_a) {
completed = true;
break;
}
}
assert!(completed, "A's rename-collision withdrawal must complete");
assert_eq!(
s.stats.snapshot().services_active,
1,
"services_active must be 1 once A's withdrawal completes (B still live)"
);
assert!(
matches!(
a_mailbox.borrow_mut().drain_for_test(),
Some(ServiceUpdate::Conflict)
),
"A's reserved Conflict survives the ctx GC and is drainable by Service::next"
);
let mut recs_a2 = ServiceRecords::new(
Name::try_from_str("_ipp._tcp.local.").unwrap(),
inst_a,
host_a,
80,
120,
);
recs_a2.add_a([192, 168, 1, 10].into());
s.test_register_service(ServiceSpec::new(recs_a2), t)
.expect("A's old name must be re-registerable once the rename-collision withdrawal completes");
}
#[cfg(feature = "stats")]
#[test]
fn rename_collision_drains_old_name_goodbye_before_name_reuse() {
use std::time::Duration;
use core::net::{IpAddr, Ipv4Addr, SocketAddr};
use crate::socket::RecvMeta;
use mdns_proto::{
Name, ServiceRecords, ServiceSpec,
wire::{Header, MessageBuilder},
};
fn conflict_for(instance: &str) -> Vec<u8> {
let mut buf = [0u8; 512];
let name = Name::try_from_str(instance).unwrap();
let target = Name::try_from_str("rival.local.").unwrap();
let mut b = MessageBuilder::<'_, 32>::try_new(&mut buf, Header::new()).unwrap();
b.push_srv_authority(&name, 120, 0, 0, 9999, &target)
.unwrap();
let n = b.finish().unwrap();
buf[..n].to_vec()
}
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
s.local_subnets = vec![(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 0)), 24)];
s.bound_interface = 1;
let now = std::time::Instant::now();
let stype = Name::try_from_str("_ipp._tcp.local.").unwrap();
let inst_a = Name::try_from_str("First._ipp._tcp.local.").unwrap();
let host_a = Name::try_from_str("first.local.").unwrap();
let mut recs_a = ServiceRecords::new(stype.clone(), inst_a.clone(), host_a.clone(), 80, 120);
recs_a.add_a([192, 168, 1, 1].into());
let handle_a = s
.test_register_service(ServiceSpec::new(recs_a), now)
.unwrap();
let inst_b = Name::try_from_str("First-1._ipp._tcp.local.").unwrap();
let host_b = Name::try_from_str("second.local.").unwrap();
let mut recs_b = ServiceRecords::new(stype.clone(), inst_b, host_b, 80, 120);
recs_b.add_a([192, 168, 1, 2].into());
s.test_register_service(ServiceSpec::new(recs_b), now)
.unwrap();
fn pump_transmits(s: &mut State, t: StdInstant, buf: &mut [u8]) {
loop {
match s.poll_one_transmit(t, buf) {
Some((_, _, TransmitOrigin::Service(h))) => {
s.note_service_transmit_result(h, t, true);
}
Some(_) => {}
None => break,
}
}
}
let a_mailbox = Rc::clone(&s.services.get(&handle_a).unwrap().mailbox);
let mut buf = [0u8; 1500];
let mut t = now;
let mut a_established = false;
for _ in 0..60 {
t += Duration::from_millis(300);
s.fire_timeouts(t);
pump_transmits(&mut s, t, &mut buf);
s.push_service_updates(t);
while let Some(u) = a_mailbox.borrow_mut().drain_for_test() {
if matches!(u, ServiceUpdate::Established) {
a_established = true;
}
}
if a_established {
break;
}
}
assert!(
a_established,
"A must reach Established before the rename-collision test can verify the goodbye"
);
let conflict = conflict_for("First._ipp._tcp.local.");
let peer = RecvMeta::new(
SocketAddr::from(([192, 168, 1, 200], 5353)),
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 200)),
1,
Some(255),
None,
conflict.len(),
);
let mut conflicted = false;
for _ in 0..80 {
t += Duration::from_millis(300);
s.fire_timeouts(t);
s.handle_datagram(&peer, &conflict);
pump_transmits(&mut s, t, &mut buf);
s.push_service_updates(t);
if s
.services
.get(&handle_a)
.map(|c| c.errored)
.unwrap_or(false)
{
conflicted = true;
break;
}
}
assert!(
conflicted,
"A must be driven to rename-collision-Conflict within 80 iterations"
);
{
let mut dup = ServiceRecords::new(stype.clone(), inst_a.clone(), host_a.clone(), 80, 120);
dup.add_a([192, 168, 1, 1].into());
assert!(
matches!(
s.test_register_service(ServiceSpec::new(dup), t),
Err(mdns_proto::error::RegisterServiceError::NameAlreadyRegistered(_))
),
"A's OLD name must be held by the in-flight withdrawal (NameAlreadyRegistered)"
);
}
let mut scratch = vec![0u8; 4096];
let mut completed = false;
for _ in 0..64 {
t += Duration::from_millis(250);
while let Some((_, _, tok)) = s.poll_one_withdrawal(t, &mut scratch) {
s.note_withdrawal_result(tok, t, WithdrawalSend::Retry, WithdrawalSend::Retry);
}
s.drain_completed_withdrawals(t);
if !s.services.contains_key(&handle_a) {
completed = true;
break;
}
}
assert!(completed, "A's rename-collision withdrawal must complete");
let host_r = Name::try_from_str("replacement.local.").unwrap();
let mut recs_r = ServiceRecords::new(stype, inst_a, host_r, 80, 120);
recs_r.add_a([192, 168, 1, 10].into());
s.test_register_service(ServiceSpec::new(recs_r), t)
.expect("replacement R must register under A's old name once the withdrawal completes");
}
#[test]
fn proto_emitted_host_conflict_retires_and_gcs_the_service() {
use core::net::{IpAddr, Ipv4Addr, SocketAddr};
use mdns_proto::{
Name, ServiceRecords, ServiceSpec, WithdrawalSend,
wire::{Header, MessageBuilder},
};
use crate::socket::RecvMeta;
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
s.local_subnets = vec![(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 0)), 24)];
s.bound_interface = 1;
let now = std::time::Instant::now();
let stype = Name::try_from_str("_ipp._tcp.local.").unwrap();
let inst = Name::try_from_str("Printer._ipp._tcp.local.").unwrap();
let host = Name::try_from_str("printer.local.").unwrap();
let mut recs = ServiceRecords::new(stype, inst, host.clone(), 631, 120);
recs.add_a([192, 168, 1, 10].into());
let handle = s
.test_register_service(ServiceSpec::new(recs), now)
.unwrap();
let mailbox = Rc::clone(&s.services.get(&handle).unwrap().mailbox);
fn pump_transmits(s: &mut State, t: StdInstant, buf: &mut [u8]) {
loop {
match s.poll_one_transmit(t, buf) {
Some((_, _, TransmitOrigin::Service(h))) => s.note_service_transmit_result(h, t, true),
Some(_) => {}
None => break,
}
}
}
let mut buf = [0u8; 1500];
let mut t = now;
let mut established = false;
for _ in 0..60 {
t += Duration::from_millis(300);
s.fire_timeouts(t);
pump_transmits(&mut s, t, &mut buf);
s.push_service_updates(t);
while let Some(u) = mailbox.borrow_mut().drain_for_test() {
if matches!(u, ServiceUpdate::Established) {
established = true;
}
}
if established {
break;
}
}
assert!(
established,
"service must reach Established before the host conflict"
);
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 peer = RecvMeta::new(
SocketAddr::from(([192, 168, 1, 200], 5353)),
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 200)),
1,
Some(255), None,
conflict.len(),
);
let mut retired = false;
for _ in 0..40 {
t += Duration::from_millis(300);
s.fire_timeouts(t);
s.handle_datagram(&peer, &conflict);
pump_transmits(&mut s, t, &mut buf);
s.push_service_updates(t);
if s.services.get(&handle).map(|c| c.errored).unwrap_or(false) {
retired = true;
break;
}
}
assert!(
retired,
"a proto-emitted HostConflict must begin the endpoint-owned withdrawal (errored)"
);
let mut saw_host_conflict = false;
while let Some(u) = mailbox.borrow_mut().drain_for_test() {
if u.is_host_conflict() {
saw_host_conflict = true;
}
}
assert!(
saw_host_conflict,
"the HostConflict terminal must reach the handle-owned mailbox"
);
let mut scratch = vec![0u8; 4096];
let mut gced = false;
for _ in 0..64 {
t += Duration::from_millis(250);
while let Some((_, _, tok)) = s.poll_one_withdrawal(t, &mut scratch) {
s.note_withdrawal_result(tok, t, WithdrawalSend::Retry, WithdrawalSend::Retry);
}
s.drain_completed_withdrawals(t);
if !s.services.contains_key(&handle) {
gced = true;
break;
}
}
assert!(
gced,
"the withdrawn service ctx must be GC'd after the §10.1 goodbye completes"
);
}
#[test]
fn unencodable_query_is_errored_not_spun_or_hung() {
use mdns_proto::{QuerySpec, wire::ResourceType};
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
let now = std::time::Instant::now();
let qname = mdns_proto::Name::try_from_str("printer.local.").unwrap();
let h = s
.start_query(QuerySpec::new(qname, ResourceType::A), now)
.unwrap();
let mut scratch = [0u8; 1];
let pumped = s.poll_one_transmit(now, &mut scratch);
assert!(
pumped.is_none(),
"an un-encodable query must not yield a transmit"
);
assert!(
s.queries.get(&h).map(|c| c.errored).unwrap_or(false),
"the query must be flagged errored after the encode failure"
);
assert!(
s.poll_deadline().is_none(),
"an errored query must contribute no deadline"
);
assert!(
s.take_query_terminal_wakes(),
"the terminal wake must be armed once on the errored transition"
);
assert!(
!s.take_query_terminal_wakes(),
"the terminal wake is one-shot — a second drain must report nothing"
);
assert!(
s.poll_one_transmit(now, &mut scratch).is_none(),
"an errored query must be skipped by later pumps, not re-polled"
);
assert!(
!s.take_query_terminal_wakes(),
"no further wake is armed once the query is already errored"
);
}
#[test]
fn duplicate_registration_is_rejected_as_name_already_registered() {
use mdns_proto::{Name, ServiceRecords, ServiceSpec, error::RegisterServiceError};
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
let t = std::time::Instant::now();
let mk = || {
let mut r = ServiceRecords::new(
Name::try_from_str("_http._tcp.local.").unwrap(),
Name::try_from_str("dup._http._tcp.local.").unwrap(),
Name::try_from_str("dup.local.").unwrap(),
80,
120,
);
r.add_a([127, 0, 0, 1].into());
ServiceSpec::new(r)
};
s.test_register_service(mk(), t).unwrap();
let err = s.test_register_service(mk(), t).unwrap_err();
assert!(
matches!(err, RegisterServiceError::NameAlreadyRegistered(_)),
"second registration of the same instance name must be rejected as NameAlreadyRegistered, got {err:?}"
);
}
#[cfg(feature = "stats")]
#[test]
fn unencodable_query_retire_records_terminal_stats() {
use mdns_proto::{QuerySpec, wire::ResourceType};
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
let now = std::time::Instant::now();
let qname = mdns_proto::Name::try_from_str("printer.local.").unwrap();
let h = s
.start_query(QuerySpec::new(qname, ResourceType::A), now)
.unwrap();
let before = s.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 = [0u8; 1];
let pumped = s.poll_one_transmit(now, &mut scratch);
assert!(
pumped.is_none(),
"an un-encodable query must not yield a transmit"
);
let after = s.stats.snapshot();
assert_eq!(
after.queries_active, 0,
"queries_active must be 0 after retire_query (was leaking)"
);
let terminal_count = after.queries_done;
assert_eq!(
terminal_count, 1,
"exactly one terminal (done/timeout) must be recorded; got queries_done={}, queries_timeout={}",
after.queries_done, after.queries_timeout,
);
assert!(
s.queries.get(&h).map(|c| c.errored).unwrap_or(false),
"the query must be flagged errored after the encode failure"
);
assert!(
s.take_query_terminal_wakes(),
"the terminal wake must be armed once on the errored transition"
);
}
#[cfg(feature = "stats")]
#[compio::test]
async fn encode_failed_query_slot_is_gc_after_terminal_observed() {
use core::cell::Cell;
use crate::query::{Query, QueryEvent};
let inner = EndpointInner::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
let qname = mdns_proto::Name::try_from_str("printer.local.").unwrap();
let spec = mdns_proto::QuerySpec::new(qname, mdns_proto::wire::ResourceType::A);
let h = inner
.state
.borrow_mut()
.start_query(spec, std::time::Instant::now())
.unwrap();
assert_eq!(
inner.state.borrow().stats.snapshot().queries_active,
1,
"one active query before encode failure"
);
let mut scratch = [0u8; 1];
{
let mut st = inner.state.borrow_mut();
let _ = st.poll_one_transmit(std::time::Instant::now(), &mut scratch);
}
let snap = inner.state.borrow().stats.snapshot();
assert_eq!(
snap.queries_active, 0,
"queries_active must be 0 after retire"
);
assert_eq!(
snap.queries_done, 1,
"exactly one terminal counter must be recorded"
);
let _ = inner.state.borrow_mut().take_query_terminal_wakes();
let query = Query {
inner: Rc::clone(&inner),
handle: h,
terminal_delivered: Cell::new(false),
};
let event = query.next().await;
assert!(
matches!(event, Some(QueryEvent::Terminal(_))),
"Query::next must return Terminal after encode failure, got {event:?}"
);
assert!(
!inner.state.borrow().queries.contains_key(&h),
"driver query map must not contain the handle after terminal is observed"
);
let qname2 = mdns_proto::Name::try_from_str("scanner.local.").unwrap();
let spec2 = mdns_proto::QuerySpec::new(qname2, mdns_proto::wire::ResourceType::A);
let h2 = inner
.state
.borrow_mut()
.start_query(spec2, std::time::Instant::now())
.expect("new query must succeed after slot was freed");
assert_ne!(h, h2, "new handle should differ from the retired one");
assert_eq!(
inner.state.borrow().stats.snapshot().queries_active,
1,
"new query must count as active"
);
let second = query.next().await;
assert!(
second.is_none(),
"subsequent Query::next after terminal must return None, got {second:?}"
);
}
#[cfg(feature = "stats")]
#[test]
fn generic_recv_error_does_not_increment_packets_dropped() {
let inner = EndpointInner::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
let before = inner.state.borrow().stats.snapshot();
assert_eq!(before.packets_dropped, 0, "no drops before recv error");
let err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "injected recv error");
handle_recv(&inner, Err(err));
let after = inner.state.borrow().stats.snapshot();
assert_eq!(
after.packets_dropped, 0,
"a generic recv error must NOT increment packets_dropped"
);
assert_eq!(after.packets_rx, 0, "packets_rx must stay 0");
assert_eq!(after.bytes_rx, 0, "bytes_rx must stay 0");
}
#[cfg(feature = "stats")]
#[test]
fn truncated_datagram_counts_rx_and_dropped_not_delivered_to_proto() {
use core::net::{IpAddr, Ipv4Addr, SocketAddr};
use crate::socket::RecvMeta;
let inner = EndpointInner::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
let data: Vec<u8> = vec![0u8; 9000]; let len = data.len();
let meta = RecvMeta::new(
SocketAddr::from(([224, 0, 0, 251], 5353)),
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
0,
Some(255),
None,
len,
)
.with_truncated();
let before = inner.state.borrow().stats.snapshot();
assert_eq!(before.packets_rx, 0);
assert_eq!(before.bytes_rx, 0);
assert_eq!(before.packets_dropped, 0);
handle_recv(&inner, Ok((data, meta)));
let after = inner.state.borrow().stats.snapshot();
assert_eq!(
after.packets_rx, 1,
"truncated datagram was received — packets_rx must be +1"
);
assert_eq!(
after.bytes_rx, len as u64,
"bytes_rx must reflect the truncated bytes that landed"
);
assert_eq!(
after.packets_dropped, 1,
"truncated datagram must bump packets_dropped"
);
assert_eq!(
after.questions_rx, 0,
"truncated datagram must NOT be routed to proto (no question side effect)"
);
}
#[cfg(feature = "stats")]
#[test]
fn normal_non_truncated_datagram_routes_to_proto() {
use core::net::{IpAddr, Ipv4Addr, SocketAddr};
use crate::socket::RecvMeta;
let inner = EndpointInner::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
inner.state.borrow_mut().local_subnets = vec![(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 0)), 8)];
inner.state.borrow_mut().bound_interface = 1;
let data: Vec<u8> = vec![
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
let len = data.len();
let meta = RecvMeta::new(
SocketAddr::from(([127, 0, 0, 1], 5353)),
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
1,
Some(255),
None,
len,
);
assert!(
!meta.truncated(),
"sanity: RecvMeta::new must not set truncated"
);
handle_recv(&inner, Ok((data, meta)));
let after = inner.state.borrow().stats.snapshot();
assert_eq!(
after.packets_dropped, 0,
"a normal non-truncated datagram must NOT bump packets_dropped"
);
assert_eq!(
after.packets_rx, 1,
"normal datagram must be counted by proto (packets_rx == 1)"
);
}
#[cfg(feature = "stats")]
#[test]
fn withdrawal_pump_runs_after_push_service_updates_loop_order() {
use std::time::Duration;
use core::net::{IpAddr, Ipv4Addr, SocketAddr};
use crate::socket::RecvMeta;
use mdns_proto::{
Name, ServiceRecords, ServiceSpec,
wire::{Header, MessageBuilder},
};
fn conflict_for(instance: &str) -> Vec<u8> {
let mut buf = [0u8; 512];
let name = Name::try_from_str(instance).unwrap();
let target = Name::try_from_str("rival.local.").unwrap();
let mut b = MessageBuilder::<'_, 32>::try_new(&mut buf, Header::new()).unwrap();
b.push_srv_authority(&name, 120, 0, 0, 9999, &target)
.unwrap();
let n = b.finish().unwrap();
buf[..n].to_vec()
}
let mut s = State::new(mdns_proto::EndpointConfig::default(), 1500, 9000);
s.local_subnets = vec![(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 0)), 24)];
s.bound_interface = 1;
let now = std::time::Instant::now();
let stype = Name::try_from_str("_ipp._tcp.local.").unwrap();
let inst_a = Name::try_from_str("Alpha._ipp._tcp.local.").unwrap();
let host_a = Name::try_from_str("alpha.local.").unwrap();
let mut recs_a = ServiceRecords::new(stype.clone(), inst_a.clone(), host_a, 80, 120);
recs_a.add_a([192, 168, 1, 1].into());
let handle_a = s
.test_register_service(ServiceSpec::new(recs_a), now)
.unwrap();
let inst_b = Name::try_from_str("Alpha-1._ipp._tcp.local.").unwrap();
let host_b = Name::try_from_str("beta.local.").unwrap();
let mut recs_b = ServiceRecords::new(stype, inst_b, host_b, 80, 120);
recs_b.add_a([192, 168, 1, 2].into());
s.test_register_service(ServiceSpec::new(recs_b), now)
.unwrap();
fn pump_transmits(s: &mut State, t: StdInstant, buf: &mut [u8]) {
loop {
match s.poll_one_transmit(t, buf) {
Some((_, _, TransmitOrigin::Service(h))) => {
s.note_service_transmit_result(h, t, true);
}
Some(_) => {}
None => break,
}
}
}
fn withdrawal_due(s: &mut State, t: StdInstant, scratch: &mut [u8]) -> bool {
s.poll_one_withdrawal(t, scratch).is_some()
}
let a_mailbox = Rc::clone(&s.services.get(&handle_a).unwrap().mailbox);
let mut buf = [0u8; 1500];
let mut t = now;
let mut a_established = false;
for _ in 0..60 {
t += Duration::from_millis(300);
s.fire_timeouts(t);
pump_transmits(&mut s, t, &mut buf);
s.push_service_updates(t);
while let Some(u) = a_mailbox.borrow_mut().drain_for_test() {
if matches!(u, ServiceUpdate::Established) {
a_established = true;
}
}
if a_established {
break;
}
}
assert!(
a_established,
"A must reach Established before the ordering test can verify the goodbye timing"
);
let conflict = conflict_for("Alpha._ipp._tcp.local.");
let peer = RecvMeta::new(
SocketAddr::from(([192, 168, 1, 200], 5353)),
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 200)),
1,
Some(255),
None,
conflict.len(),
);
let mut scratch = [0u8; 1500];
let mut decisive_before: Option<bool> = None;
let mut decisive_after: Option<bool> = None;
for _ in 0..80 {
t += Duration::from_millis(300);
s.fire_timeouts(t);
s.handle_datagram(&peer, &conflict);
pump_transmits(&mut s, t, &mut buf);
let before = withdrawal_due(&mut s, t, &mut scratch);
s.push_service_updates(t);
let after = withdrawal_due(&mut s, t, &mut scratch);
if s
.services
.get(&handle_a)
.map(|c| c.errored)
.unwrap_or(false)
{
decisive_before = Some(before);
decisive_after = Some(after);
break;
}
}
let before = decisive_before
.expect("A must be driven to rename-collision-Conflict within the iteration limit");
let after = decisive_after.unwrap();
assert!(
after,
"a withdrawal datagram must be DUE after push_service_updates begins the \
rename-collision withdrawal (so the post-push withdrawal pump drains it this \
iteration)"
);
assert!(
!before,
"no withdrawal must be due BEFORE push_service_updates on the decisive \
iteration (the collision withdrawal is begun by push, not by a prior sweep)"
);
}