From d83a8f5f4b7665c3ec467ba08735606c485a58f8 Mon Sep 17 00:00:00 2001 From: Han Xu Date: Sat, 11 Jul 2026 12:17:57 -0700 Subject: [PATCH 1/7] feat: delay shared PTR query responses per RFC 6762 section 6 --- src/service_daemon.rs | 274 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 273 insertions(+), 1 deletion(-) diff --git a/src/service_daemon.rs b/src/service_daemon.rs index 8dcf1a4..9d559e8 100644 --- a/src/service_daemon.rs +++ b/src/service_daemon.rs @@ -100,6 +100,19 @@ const ANNOUNCE_SECOND_JITTER_MILLIS: u64 = 50; #[allow(clippy::assertions_on_constants)] const _: () = assert!(ANNOUNCE_SECOND_DELAY_MILLIS > MULTICAST_RATE_LIMIT_MILLIS); +/// RFC 6762 §6: +/// In any case where there may be multiple responses, such as queries +/// where the answer is a member of a shared resource record set, each +/// responder SHOULD delay its response by a random amount of time +/// selected with uniform random distribution in the range 20-120 ms. +/// a multicast response whose answer is a member of a *shared* +/// +/// I think the min of 20s is a bit too long. Use 10ms instead. +const SHARED_RESPONSE_DELAY_MIN_MILLIS: u64 = 10; + +/// 120ms suggested in the RFC is too long, use 50ms instead. +const SHARED_RESPONSE_DELAY_MAX_MILLIS: u64 = 50; + /// Response status code for the service `unregister` call. #[derive(Debug)] pub enum UnregisterStatus { @@ -891,6 +904,17 @@ struct ReRun { command: Command, } +/// A query response deferred per RFC 6762 §6 (shared response). +/// Held on the daemon thread rather than in [`Command`] because +/// [`DnsOutgoing`] holds boxed records that are not `Send`. +struct DelayedResponse { + /// UNIX timestamp in millis at which to send `out`. + next_time: u64, + out: DnsOutgoing, + if_index: u32, + is_ipv4: bool, +} + /// Specify kinds of interfaces. It is used to enable or to disable interfaces in the daemon. /// /// Note that for ergonomic reasons, `From<&str>` and `From` are implemented. @@ -1072,6 +1096,9 @@ struct Zeroconf { /// All repeating transmissions. retransmissions: Vec, + /// Query responses deferred per RFC 6762 §6. + delayed_responses: Vec, + counters: Metrics, /// Waits for incoming packets. @@ -1277,6 +1304,7 @@ impl Zeroconf { hostname_resolvers: HashMap::new(), service_queriers: HashMap::new(), retransmissions: Vec::new(), + delayed_responses: Vec::new(), counters: HashMap::new(), poller, monitors, @@ -1333,6 +1361,7 @@ impl Zeroconf { /// 2. Stops all active browse operations /// 3. Stops all active hostname resolution operations /// 4. Clears all retransmissions + /// 5. Drops all pending delayed responses fn cleanup(&mut self) { debug!("Starting cleanup for shutdown"); @@ -1390,6 +1419,9 @@ impl Zeroconf { // 4. Clear all retransmissions self.retransmissions.clear(); + // 5. Drop any pending delayed responses + self.delayed_responses.clear(); + debug!("Cleanup completed"); } @@ -1514,6 +1546,17 @@ impl Zeroconf { } } + // Send delayed shared-record responses whose time is up (RFC 6762 §6). + let mut i = 0; + while i < self.delayed_responses.len() { + if now >= self.delayed_responses[i].next_time { + let resp = self.delayed_responses.remove(i); + self.send_delayed_response(resp); + } else { + i += 1; + } + } + // Refresh cached service records with active queriers self.refresh_active_services(); @@ -3186,7 +3229,17 @@ impl Zeroconf { debug!("handle_query: socket not available for intf {}", if_index); return; }; + + // RFC 6762 §6: a response whose answer is a member of a *shared* record + // set (PTR records for DNS-SD) SHOULD be delayed 20-120 ms, while a + // response containing only *unique* records (A/AAAA/SRV with cache-flush) + // may be sent without delay. If a PTR (shared) answer is added below and + // the query is a normal multicast (not legacy-unicast or probe-defense), + // `delayed` is set and the whole response is deferred onto the timer; + // otherwise it is sent immediately. Delaying the occasional unique record + // that shares a packet with a PTR question is harmless and RFC-compliant. let mut out = DnsOutgoing::new(FLAGS_QR_RESPONSE | FLAGS_AA); + let mut delayed = false; // Special meta-query "_services._dns-sd._udp.". // See https://datatracker.ietf.org/doc/html/rfc6763#section-9 @@ -3207,6 +3260,12 @@ impl Zeroconf { let q_name = question.entry_name(); if qtype == RRType::PTR { + // PTR answers are shared records: defer the response unless this + // is a legacy-unicast (source port != 5353) or probe-defense + // (records in the Authority Section) query. + if querier_addr.port() == MDNS_PORT && msg.num_authorities() == 0 { + delayed = true; + } for service in self.my_services.values() { if service.get_status(if_index) != ServiceStatus::Announced { continue; @@ -3324,6 +3383,28 @@ impl Zeroconf { } } + // Defer shared PTR responses (RFC 6762 §6): if a PTR (shared) answer was + // added and this is a normal multicast query, schedule the whole response + // 20-120 ms out instead of sending it now. The querier is gone by send + // time, so a delayed response is always a plain rate-limited multicast — + // see `send_delayed_response`. + if delayed && out.answers_count() > 0 { + out.set_id(msg.id()); + self.increase_counter(Counter::KnownAnswerSuppression, out.known_answer_count()); + let delay = fastrand::u64( + SHARED_RESPONSE_DELAY_MIN_MILLIS..SHARED_RESPONSE_DELAY_MAX_MILLIS, + ); + let next_time = current_time_millis() + delay; + self.delayed_responses.push(DelayedResponse { + next_time, + out, + if_index, + is_ipv4, + }); + self.add_timer(next_time); + return; + } + if out.answers_count() > 0 { out.set_id(msg.id()); @@ -3386,6 +3467,58 @@ impl Zeroconf { self.increase_counter(Counter::KnownAnswerSuppression, out.known_answer_count()); } + /// Multicasts a shared-record (PTR) query response that was deferred per + /// RFC 6762 §6. + /// + /// Re-resolves the socket and interface from `if_index`, so it is safe to + /// call from the timer loop after the borrows taken while building the + /// response are gone. The original querier is no longer known, so the + /// response is always a plain multicast (no unicast destination, no + /// source-address preference); the §6 once-per-second multicast rate limit + /// still applies. + fn send_delayed_response(&mut self, resp: DelayedResponse) { + let DelayedResponse { + mut out, + if_index, + is_ipv4, + .. + } = resp; + + let sock_opt = if is_ipv4 { + &self.ipv4_sock + } else { + &self.ipv6_sock + }; + let Some(sock) = sock_opt.as_ref() else { + debug!("send_delayed_response: socket not available for intf {if_index}"); + return; + }; + + if let Some(dns_registry) = self.dns_registry_map.get_mut(&if_index) { + dns_registry.apply_multicast_rate_limit(&mut out, current_time_millis(), is_ipv4); + } + if out.answers_count() == 0 { + return; + } + + let Some(intf) = self.my_intfs.get(&if_index) else { + debug!("send_delayed_response: no intf found for index {if_index}"); + return; + }; + + let if_name = intf.name.clone(); + debug!("sending delayed response on intf {}", &if_name); + let send_result = send_dns_outgoing(&out, intf, &sock.pktinfo, self.port, None, None); + + if let Err(InternalError::IntfAddrInvalid(intf_addr)) = send_result { + let invalid_intf_addr = HashSet::from([intf_addr]); + let _ = self.send_cmd_to_self(Command::InvalidIntfAddrs(invalid_intf_addr)); + } + + self.increase_counter(Counter::Respond, 1); + self.notify_monitors(DaemonEvent::Respond(if_name)); + } + /// Increases the value of `counter` by `count`. fn increase_counter(&mut self, counter: Counter, count: i64) { let key = counter.to_string(); @@ -4892,7 +5025,8 @@ mod tests { _new_socket_bind, check_domain_suffix, check_service_name_length, hostname_change, my_ip_interfaces, name_change, send_dns_outgoing_impl, valid_instance_name, valid_ip_on_intf, HostnameResolutionEvent, MyIntf, ServiceDaemon, ServiceEvent, - ServiceInfo, GROUP_ADDR_V4, MDNS_PORT, + ServiceInfo, GROUP_ADDR_V4, MDNS_PORT, SHARED_RESPONSE_DELAY_MAX_MILLIS, + SHARED_RESPONSE_DELAY_MIN_MILLIS, }; use crate::{ dns_parser::{ @@ -5099,6 +5233,144 @@ mod tests { daemon.shutdown().unwrap(); } + #[test] + fn test_shared_response_delay_bounds() { + // RFC 6762 §6: a response for a shared record set is delayed by a + // uniform-random amount in the range 20-120 ms inclusive. + assert_eq!(SHARED_RESPONSE_DELAY_MIN_MILLIS, 20); + assert_eq!(SHARED_RESPONSE_DELAY_MAX_MILLIS, 121); // 120 inclusive + for _ in 0..10_000 { + let d = fastrand::u64( + SHARED_RESPONSE_DELAY_MIN_MILLIS..SHARED_RESPONSE_DELAY_MAX_MILLIS, + ); + assert!( + (20..=120).contains(&d), + "delay {} ms is outside the RFC 6762 §6 range of 20-120 ms", + d + ); + } + } + + #[test] + fn test_shared_ptr_response_delayed() { + // RFC 6762 §6: a PTR (shared record set) response sent by multicast MUST + // be delayed by a uniform-random 20-120 ms. Register a service, then as a + // proper multicast querier (source port 5353) send a PTR query and assert + // the PTR response arrives no sooner than ~20 ms after the query. (A + // legacy unicast querier gets an *immediate* response instead; see + // `test_legacy_unicast_response`.) + use socket2::{Domain, Protocol, Socket, Type}; + + let intf_ip = match my_ip_interfaces(false) + .into_iter() + .find_map(|intf| match intf.ip() { + IpAddr::V4(ip) if !ip.is_loopback() => Some(ip), + _ => None, + }) { + Some(ip) => ip, + None => { + println!("No IPv4 interface available; skipping test."); + return; + } + }; + + let daemon = ServiceDaemon::new().expect("Failed to create daemon"); + let unique = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_micros(); + let service_type = format!("_shared-delay-{unique}._udp.local."); + let hostname = format!("shared-delay-{unique}.local."); + let service_info = ServiceInfo::new( + &service_type, + "test_instance", + &hostname, + &[IpAddr::V4(intf_ip)] as &[IpAddr], + 5353, + None, + ) + .expect("invalid service info"); + daemon.register(service_info).expect("register service"); + + // A proper multicast querier: bound to 5353 and joined to the mDNS group, + // so the daemon replies by (delayed) multicast that we can observe. + let sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)).unwrap(); + sock.set_reuse_address(true).unwrap(); + #[cfg(unix)] + sock.set_reuse_port(true).unwrap(); + sock.bind(&std::net::SocketAddr::from((Ipv4Addr::UNSPECIFIED, MDNS_PORT)).into()) + .unwrap(); + sock.join_multicast_v4(&GROUP_ADDR_V4, &intf_ip).unwrap(); + sock.set_multicast_loop_v4(true).unwrap(); + sock.set_read_timeout(Some(Duration::from_millis(50))).unwrap(); + let sock: UdpSocket = sock.into(); + + let if_id = InterfaceId { + name: "test".to_string(), + index: 0, + }; + + // Build the PTR query for our service type. + let mut query = DnsOutgoing::new(FLAGS_QR_QUERY); + query.add_question(&service_type, RRType::PTR); + let query_packet = query.to_data_on_wire().pop().expect("one packet"); + + // Wait for the initial announcements and the §6 rate-limit window (1s) to + // pass, so our query elicits a fresh (delayed) response instead of being + // suppressed by the rate limiter. + std::thread::sleep(Duration::from_secs(3)); + + let deadline = Instant::now() + Duration::from_secs(8); + let mut measured = None; + let mut buf = [0u8; 1500]; + 'outer: while Instant::now() < deadline { + // Drain queued packets (e.g. periodic announcements) so we only time + // the response to *our* query. + while sock.recv_from(&mut buf).is_ok() {} + + let sent_at = Instant::now(); + sock.send_to(&query_packet, (GROUP_ADDR_V4, MDNS_PORT)) + .expect("send query"); + + // Collect responses for ~1 s, comfortably above the 120 ms max delay. + let collect_until = sent_at + Duration::from_millis(1000); + while Instant::now() < collect_until { + let (len, _from) = match sock.recv_from(&mut buf) { + Ok(v) => v, + Err(_) => continue, // read timeout; keep polling + }; + let elapsed = sent_at.elapsed(); + let Ok(msg) = DnsIncoming::new(buf[..len].to_vec(), if_id.clone()) else { + continue; + }; + if msg.is_response() + && msg.answers().iter().any(|a| { + a.get_type() == RRType::PTR + && a.get_name().eq_ignore_ascii_case(&service_type) + }) + { + measured = Some(elapsed); + break 'outer; + } + } + } + + let elapsed = + measured.expect("expected a multicast PTR response to our query within the deadline"); + assert!( + elapsed >= Duration::from_millis(18), + "PTR response arrived after only {:?}; RFC 6762 §6 requires a 20-120 ms delay", + elapsed + ); + assert!( + elapsed <= Duration::from_millis(600), + "PTR response arrived after {:?}; expected within the 20-120 ms delay window", + elapsed + ); + + daemon.shutdown().unwrap(); + } + #[test] fn test_check_service_name_length() { let result = check_service_name_length("_tcp", 100); From 4ed8024342cc119909b9fb51e89634499def6de3 Mon Sep 17 00:00:00 2001 From: Han Xu Date: Sat, 11 Jul 2026 12:18:11 -0700 Subject: [PATCH 2/7] fix fmt --- src/service_daemon.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/service_daemon.rs b/src/service_daemon.rs index 9d559e8..439ebfe 100644 --- a/src/service_daemon.rs +++ b/src/service_daemon.rs @@ -106,7 +106,7 @@ const _: () = assert!(ANNOUNCE_SECOND_DELAY_MILLIS > MULTICAST_RATE_LIMIT_MILLIS /// responder SHOULD delay its response by a random amount of time /// selected with uniform random distribution in the range 20-120 ms. /// a multicast response whose answer is a member of a *shared* -/// +/// /// I think the min of 20s is a bit too long. Use 10ms instead. const SHARED_RESPONSE_DELAY_MIN_MILLIS: u64 = 10; @@ -3391,9 +3391,8 @@ impl Zeroconf { if delayed && out.answers_count() > 0 { out.set_id(msg.id()); self.increase_counter(Counter::KnownAnswerSuppression, out.known_answer_count()); - let delay = fastrand::u64( - SHARED_RESPONSE_DELAY_MIN_MILLIS..SHARED_RESPONSE_DELAY_MAX_MILLIS, - ); + let delay = + fastrand::u64(SHARED_RESPONSE_DELAY_MIN_MILLIS..SHARED_RESPONSE_DELAY_MAX_MILLIS); let next_time = current_time_millis() + delay; self.delayed_responses.push(DelayedResponse { next_time, @@ -5240,9 +5239,8 @@ mod tests { assert_eq!(SHARED_RESPONSE_DELAY_MIN_MILLIS, 20); assert_eq!(SHARED_RESPONSE_DELAY_MAX_MILLIS, 121); // 120 inclusive for _ in 0..10_000 { - let d = fastrand::u64( - SHARED_RESPONSE_DELAY_MIN_MILLIS..SHARED_RESPONSE_DELAY_MAX_MILLIS, - ); + let d = + fastrand::u64(SHARED_RESPONSE_DELAY_MIN_MILLIS..SHARED_RESPONSE_DELAY_MAX_MILLIS); assert!( (20..=120).contains(&d), "delay {} ms is outside the RFC 6762 §6 range of 20-120 ms", @@ -5302,7 +5300,8 @@ mod tests { .unwrap(); sock.join_multicast_v4(&GROUP_ADDR_V4, &intf_ip).unwrap(); sock.set_multicast_loop_v4(true).unwrap(); - sock.set_read_timeout(Some(Duration::from_millis(50))).unwrap(); + sock.set_read_timeout(Some(Duration::from_millis(50))) + .unwrap(); let sock: UdpSocket = sock.into(); let if_id = InterfaceId { From 182196a52295c3488fa75d01f34df4e8ba8d8562 Mon Sep 17 00:00:00 2001 From: Han Xu Date: Sun, 12 Jul 2026 11:54:09 -0700 Subject: [PATCH 3/7] fix tests --- src/service_daemon.rs | 48 +++++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/src/service_daemon.rs b/src/service_daemon.rs index 439ebfe..99cc256 100644 --- a/src/service_daemon.rs +++ b/src/service_daemon.rs @@ -5234,29 +5234,33 @@ mod tests { #[test] fn test_shared_response_delay_bounds() { - // RFC 6762 §6: a response for a shared record set is delayed by a - // uniform-random amount in the range 20-120 ms inclusive. - assert_eq!(SHARED_RESPONSE_DELAY_MIN_MILLIS, 20); - assert_eq!(SHARED_RESPONSE_DELAY_MAX_MILLIS, 121); // 120 inclusive + // A shared-record (PTR) response is delayed by a uniform-random amount. + // We deviate from the RFC 6762 §6 suggested 20-120 ms window and use a + // shorter 10-50 ms delay (`MAX` is the exclusive upper bound, so the + // actual delay is 10..=49 ms). + assert_eq!(SHARED_RESPONSE_DELAY_MIN_MILLIS, 10); + assert_eq!(SHARED_RESPONSE_DELAY_MAX_MILLIS, 50); for _ in 0..10_000 { let d = fastrand::u64(SHARED_RESPONSE_DELAY_MIN_MILLIS..SHARED_RESPONSE_DELAY_MAX_MILLIS); assert!( - (20..=120).contains(&d), - "delay {} ms is outside the RFC 6762 §6 range of 20-120 ms", - d + (SHARED_RESPONSE_DELAY_MIN_MILLIS..SHARED_RESPONSE_DELAY_MAX_MILLIS).contains(&d), + "delay {} ms is outside the configured {}-{} ms range", + d, + SHARED_RESPONSE_DELAY_MIN_MILLIS, + SHARED_RESPONSE_DELAY_MAX_MILLIS ); } } #[test] fn test_shared_ptr_response_delayed() { - // RFC 6762 §6: a PTR (shared record set) response sent by multicast MUST - // be delayed by a uniform-random 20-120 ms. Register a service, then as a - // proper multicast querier (source port 5353) send a PTR query and assert - // the PTR response arrives no sooner than ~20 ms after the query. (A - // legacy unicast querier gets an *immediate* response instead; see - // `test_legacy_unicast_response`.) + // RFC 6762 §6: a PTR (shared record set) response sent by multicast is + // delayed by a uniform-random amount (we use a 10-50 ms window). Register + // a service, then as a proper multicast querier (source port 5353) send a + // PTR query and assert the PTR response arrives no sooner than ~10 ms + // after the query. (A legacy unicast querier gets an *immediate* response + // instead; see `test_legacy_unicast_response`.) use socket2::{Domain, Protocol, Socket, Type}; let intf_ip = match my_ip_interfaces(false) @@ -5273,12 +5277,15 @@ mod tests { }; let daemon = ServiceDaemon::new().expect("Failed to create daemon"); + // Keep the service name (the `_sd…` label) within the 15-byte limit + // that RFC 6763 §7.2 imposes, while staying unique per run. let unique = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() - .as_micros(); - let service_type = format!("_shared-delay-{unique}._udp.local."); - let hostname = format!("shared-delay-{unique}.local."); + .as_micros() + % 1_000_000_000; + let service_type = format!("_sd{unique}._udp.local."); + let hostname = format!("sd{unique}.local."); let service_info = ServiceInfo::new( &service_type, "test_instance", @@ -5331,7 +5338,7 @@ mod tests { sock.send_to(&query_packet, (GROUP_ADDR_V4, MDNS_PORT)) .expect("send query"); - // Collect responses for ~1 s, comfortably above the 120 ms max delay. + // Collect responses for ~1 s, comfortably above the 50 ms max delay. let collect_until = sent_at + Duration::from_millis(1000); while Instant::now() < collect_until { let (len, _from) = match sock.recv_from(&mut buf) { @@ -5357,13 +5364,14 @@ mod tests { let elapsed = measured.expect("expected a multicast PTR response to our query within the deadline"); assert!( - elapsed >= Duration::from_millis(18), - "PTR response arrived after only {:?}; RFC 6762 §6 requires a 20-120 ms delay", + elapsed >= Duration::from_millis(8), + "PTR response arrived after only {:?}; a shared-record response must be \ + delayed (10-50 ms window)", elapsed ); assert!( elapsed <= Duration::from_millis(600), - "PTR response arrived after {:?}; expected within the 20-120 ms delay window", + "PTR response arrived after {:?}; expected within the 10-50 ms delay window", elapsed ); From 7ae2b7c1e85e014a4b8f7d6dcacd4e239e8ec8ba Mon Sep 17 00:00:00 2001 From: Han Xu Date: Sun, 12 Jul 2026 12:08:47 -0700 Subject: [PATCH 4/7] updated comments --- src/service_daemon.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/service_daemon.rs b/src/service_daemon.rs index 99cc256..093ad40 100644 --- a/src/service_daemon.rs +++ b/src/service_daemon.rs @@ -105,9 +105,8 @@ const _: () = assert!(ANNOUNCE_SECOND_DELAY_MILLIS > MULTICAST_RATE_LIMIT_MILLIS /// where the answer is a member of a shared resource record set, each /// responder SHOULD delay its response by a random amount of time /// selected with uniform random distribution in the range 20-120 ms. -/// a multicast response whose answer is a member of a *shared* /// -/// I think the min of 20s is a bit too long. Use 10ms instead. +/// I think the min of 20ms is a bit too long. Use 10ms instead. const SHARED_RESPONSE_DELAY_MIN_MILLIS: u64 = 10; /// 120ms suggested in the RFC is too long, use 50ms instead. @@ -905,8 +904,6 @@ struct ReRun { } /// A query response deferred per RFC 6762 §6 (shared response). -/// Held on the daemon thread rather than in [`Command`] because -/// [`DnsOutgoing`] holds boxed records that are not `Send`. struct DelayedResponse { /// UNIX timestamp in millis at which to send `out`. next_time: u64, From 61b9ad74628f38e5ecb312b419b0a40ab6545c92 Mon Sep 17 00:00:00 2001 From: Han Xu Date: Sun, 12 Jul 2026 16:51:27 -0700 Subject: [PATCH 5/7] fix tests --- src/service_daemon.rs | 81 +++++++++++++++++++++---------------------- 1 file changed, 40 insertions(+), 41 deletions(-) diff --git a/src/service_daemon.rs b/src/service_daemon.rs index 093ad40..d079660 100644 --- a/src/service_daemon.rs +++ b/src/service_daemon.rs @@ -5020,8 +5020,8 @@ mod tests { use super::{ _new_socket_bind, check_domain_suffix, check_service_name_length, hostname_change, my_ip_interfaces, name_change, send_dns_outgoing_impl, valid_instance_name, - valid_ip_on_intf, HostnameResolutionEvent, MyIntf, ServiceDaemon, ServiceEvent, - ServiceInfo, GROUP_ADDR_V4, MDNS_PORT, SHARED_RESPONSE_DELAY_MAX_MILLIS, + valid_ip_on_intf, DaemonEvent, HostnameResolutionEvent, MyIntf, ServiceDaemon, + ServiceEvent, ServiceInfo, GROUP_ADDR_V4, MDNS_PORT, SHARED_RESPONSE_DELAY_MAX_MILLIS, SHARED_RESPONSE_DELAY_MIN_MILLIS, }; use crate::{ @@ -5255,9 +5255,10 @@ mod tests { // RFC 6762 §6: a PTR (shared record set) response sent by multicast is // delayed by a uniform-random amount (we use a 10-50 ms window). Register // a service, then as a proper multicast querier (source port 5353) send a - // PTR query and assert the PTR response arrives no sooner than ~10 ms + // PTR query and assert the daemon emits its response no sooner than ~10 ms // after the query. (A legacy unicast querier gets an *immediate* response // instead; see `test_legacy_unicast_response`.) + // use socket2::{Domain, Protocol, Socket, Type}; let intf_ip = match my_ip_interfaces(false) @@ -5274,6 +5275,8 @@ mod tests { }; let daemon = ServiceDaemon::new().expect("Failed to create daemon"); + let monitor = daemon.monitor().expect("monitor daemon events"); + // Keep the service name (the `_sd…` label) within the 15-byte limit // that RFC 6763 §7.2 imposes, while staying unique per run. let unique = SystemTime::now() @@ -5294,25 +5297,20 @@ mod tests { .expect("invalid service info"); daemon.register(service_info).expect("register service"); - // A proper multicast querier: bound to 5353 and joined to the mDNS group, - // so the daemon replies by (delayed) multicast that we can observe. + // A proper multicast querier: source port 5353 so the daemon takes the + // shared-record (delayed) path rather than the legacy-unicast one. We only + // *send* on this socket; the response is observed through the monitor. let sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)).unwrap(); sock.set_reuse_address(true).unwrap(); #[cfg(unix)] sock.set_reuse_port(true).unwrap(); sock.bind(&std::net::SocketAddr::from((Ipv4Addr::UNSPECIFIED, MDNS_PORT)).into()) .unwrap(); - sock.join_multicast_v4(&GROUP_ADDR_V4, &intf_ip).unwrap(); + sock.set_multicast_if_v4(&intf_ip).unwrap(); + // Loop the query back to the daemon's socket on this same host. sock.set_multicast_loop_v4(true).unwrap(); - sock.set_read_timeout(Some(Duration::from_millis(50))) - .unwrap(); let sock: UdpSocket = sock.into(); - let if_id = InterfaceId { - name: "test".to_string(), - index: 0, - }; - // Build the PTR query for our service type. let mut query = DnsOutgoing::new(FLAGS_QR_QUERY); query.add_question(&service_type, RRType::PTR); @@ -5323,52 +5321,53 @@ mod tests { // suppressed by the rate limiter. std::thread::sleep(Duration::from_secs(3)); + // Retry until the daemon emits a Respond for our query. A query landing + // inside the 1 s multicast rate-limit window is rate-limited to an empty + // response (no send, no event), so we simply re-query on the next pass. let deadline = Instant::now() + Duration::from_secs(8); let mut measured = None; - let mut buf = [0u8; 1500]; - 'outer: while Instant::now() < deadline { - // Drain queued packets (e.g. periodic announcements) so we only time - // the response to *our* query. - while sock.recv_from(&mut buf).is_ok() {} + while Instant::now() < deadline { + // Drop any Respond events queued earlier so we time only the response + // to the query we are about to send. + while monitor.try_recv().is_ok() {} let sent_at = Instant::now(); sock.send_to(&query_packet, (GROUP_ADDR_V4, MDNS_PORT)) .expect("send query"); - // Collect responses for ~1 s, comfortably above the 50 ms max delay. - let collect_until = sent_at + Duration::from_millis(1000); - while Instant::now() < collect_until { - let (len, _from) = match sock.recv_from(&mut buf) { - Ok(v) => v, - Err(_) => continue, // read timeout; keep polling - }; - let elapsed = sent_at.elapsed(); - let Ok(msg) = DnsIncoming::new(buf[..len].to_vec(), if_id.clone()) else { - continue; - }; - if msg.is_response() - && msg.answers().iter().any(|a| { - a.get_type() == RRType::PTR - && a.get_name().eq_ignore_ascii_case(&service_type) - }) - { - measured = Some(elapsed); - break 'outer; + // The delay window is 10-50 ms; 700 ms comfortably covers it plus any + // scheduling slack. Ignore unrelated events; on timeout, re-query. + let attempt_deadline = sent_at + Duration::from_millis(700); + loop { + let remaining = attempt_deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + match monitor.recv_timeout(remaining) { + Ok(DaemonEvent::Respond(_)) => { + measured = Some(sent_at.elapsed()); + break; + } + Ok(_) => continue, // some other daemon event; keep waiting + Err(_) => break, // timed out; re-query } } + if measured.is_some() { + break; + } } let elapsed = - measured.expect("expected a multicast PTR response to our query within the deadline"); + measured.expect("expected the daemon to respond to our PTR query within the deadline"); assert!( elapsed >= Duration::from_millis(8), - "PTR response arrived after only {:?}; a shared-record response must be \ - delayed (10-50 ms window)", + "PTR response was sent after only {:?}; a shared-record response must be \ + delayed (10-50 ms window), not sent immediately", elapsed ); assert!( elapsed <= Duration::from_millis(600), - "PTR response arrived after {:?}; expected within the 10-50 ms delay window", + "PTR response was sent after {:?}; expected within the 10-50 ms delay window", elapsed ); From b305277c76ebaf5f914a719f9ca3e758020625a7 Mon Sep 17 00:00:00 2001 From: Han Xu Date: Sun, 12 Jul 2026 16:57:24 -0700 Subject: [PATCH 6/7] update comments --- src/service_daemon.rs | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/src/service_daemon.rs b/src/service_daemon.rs index d079660..2f49594 100644 --- a/src/service_daemon.rs +++ b/src/service_daemon.rs @@ -1543,7 +1543,7 @@ impl Zeroconf { } } - // Send delayed shared-record responses whose time is up (RFC 6762 §6). + // Send delayed responses whose time is up (RFC 6762 §6). let mut i = 0; while i < self.delayed_responses.len() { if now >= self.delayed_responses[i].next_time { @@ -3227,14 +3227,6 @@ impl Zeroconf { return; }; - // RFC 6762 §6: a response whose answer is a member of a *shared* record - // set (PTR records for DNS-SD) SHOULD be delayed 20-120 ms, while a - // response containing only *unique* records (A/AAAA/SRV with cache-flush) - // may be sent without delay. If a PTR (shared) answer is added below and - // the query is a normal multicast (not legacy-unicast or probe-defense), - // `delayed` is set and the whole response is deferred onto the timer; - // otherwise it is sent immediately. Delaying the occasional unique record - // that shares a packet with a PTR question is harmless and RFC-compliant. let mut out = DnsOutgoing::new(FLAGS_QR_RESPONSE | FLAGS_AA); let mut delayed = false; @@ -3380,11 +3372,7 @@ impl Zeroconf { } } - // Defer shared PTR responses (RFC 6762 §6): if a PTR (shared) answer was - // added and this is a normal multicast query, schedule the whole response - // 20-120 ms out instead of sending it now. The querier is gone by send - // time, so a delayed response is always a plain rate-limited multicast — - // see `send_delayed_response`. + // Defer PTR responses (RFC 6762 §6). if delayed && out.answers_count() > 0 { out.set_id(msg.id()); self.increase_counter(Counter::KnownAnswerSuppression, out.known_answer_count()); @@ -3463,8 +3451,7 @@ impl Zeroconf { self.increase_counter(Counter::KnownAnswerSuppression, out.known_answer_count()); } - /// Multicasts a shared-record (PTR) query response that was deferred per - /// RFC 6762 §6. + /// Multicasts a PTR query response that was deferred per RFC 6762 §6. /// /// Re-resolves the socket and interface from `if_index`, so it is safe to /// call from the timer loop after the borrows taken while building the From 96fc8272baee1da77a4ccb880ce2cb16e602776e Mon Sep 17 00:00:00 2001 From: Han Xu Date: Sun, 12 Jul 2026 17:08:39 -0700 Subject: [PATCH 7/7] fix comments --- src/service_daemon.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/service_daemon.rs b/src/service_daemon.rs index 2f49594..48c242c 100644 --- a/src/service_daemon.rs +++ b/src/service_daemon.rs @@ -106,10 +106,10 @@ const _: () = assert!(ANNOUNCE_SECOND_DELAY_MILLIS > MULTICAST_RATE_LIMIT_MILLIS /// responder SHOULD delay its response by a random amount of time /// selected with uniform random distribution in the range 20-120 ms. /// -/// I think the min of 20ms is a bit too long. Use 10ms instead. +/// 20ms suggested in the RFC is a bit too long for min. Use 10ms instead. const SHARED_RESPONSE_DELAY_MIN_MILLIS: u64 = 10; -/// 120ms suggested in the RFC is too long, use 50ms instead. +/// 120ms suggested in the RFC is too long for max, use 50ms instead. const SHARED_RESPONSE_DELAY_MAX_MILLIS: u64 = 50; /// Response status code for the service `unregister` call. @@ -5245,7 +5245,6 @@ mod tests { // PTR query and assert the daemon emits its response no sooner than ~10 ms // after the query. (A legacy unicast querier gets an *immediate* response // instead; see `test_legacy_unicast_response`.) - // use socket2::{Domain, Protocol, Socket, Type}; let intf_ip = match my_ip_interfaces(false)