fix: record an interface whose multicast join failed, ending an endless retry - #513
Conversation
keepsimple1
left a comment
There was a problem hiding this comment.
Thank you for the PR! I think the root cause is that my_intfs keys interfaces by index, but the IPv4 join, leave and send-interface calls all identify the interface by address.
Inline comment with more details. I'll also try to see how we could fix the root cause.
| if let Err(e) = join_multicast_group(&sock.pktinfo, intf) { | ||
| debug!("add_interface: socket_config {}: {e}. Skipped.", &intf.name); | ||
| return; | ||
| debug!("add_interface: socket_config {}: {e}", &intf.name); |
There was a problem hiding this comment.
Recording the interface after a failed join fixes the loop, but it seems it could cause problems:
- Teardown drops the interface's membership as well on macOS since the membership is keyed with address.
- Every join error now stops retrying, even if it's a temp failure.
- Duplicate sends on two interfaces with the same address.
| if let Err(e) = join_multicast_group(&sock.pktinfo, intf) { | ||
| debug!("add_interface: socket_config {}: {e}. Skipped.", &intf.name); | ||
| return; | ||
| debug!("add_interface: socket_config {}: {e}", &intf.name); |
There was a problem hiding this comment.
Btw, another option is to simply change debug! to trace! level so that hopefully it won't show up in your logs if you'd like a stop-gap first. (While we work on a longer term fix)
|
Thanks for the fast review, @keepsimple1! Right, I agree the address vs. index mismatch is the real root cause and that this is not the right fix. Personally for me, no need for the temporary I have two ideas for the ways forward:
Let me know your preference and how I can help. |
What you said make sense. I will choose option 1 , i.e. will do a root-cause fix (would need a couple of days), and will send over your way for review and verification once I have a PR. Thank you in advance for your help and working together on this! |
|
Sounds good, looking forward to it! |
|
Looks like, for option 1, we would need to upstream changes to Looking at it again, your Option 2 could be a better option overall. I ran by Claude to have a draft diff attached below. One difference is: "aliases don't send" assumes packets arrive with the owner's interface index": With IPv4 joins keyed by address, the kernel attaches the membership to whichever interface it finds first for that address. That isn't necessarily the one mdns-sd joined first. So in this draft diff, the alias would stay in I don't want to post in another PR because it's based on your idea. Do you mind go ahead with your PR for option 2, and update the fix? diff --git a/src/service_daemon.rs b/src/service_daemon.rs
index 5e50bcf..a2910c9 100644
--- a/src/service_daemon.rs
+++ b/src/service_daemon.rs
@@ -1215,6 +1215,22 @@ struct Zeroconf {
test_down_interfaces: HashSet<String>,
}
+/// Joins the IPv4 mDNS group on the interface that has `ip`.
+///
+/// The kernel picks the interface by address, so interfaces sharing an address
+/// (e.g. the `bridge1NN` and `vmenetN` pairs of VMs on macOS) share one membership.
+/// Joining it again fails with `AddrInUse`, which only means this socket already
+/// holds it, so that is treated as success.
+fn join_multicast_v4(my_sock: &PktInfoUdpSocket, ip: &Ipv4Addr) -> io::Result<()> {
+ match my_sock.join_multicast_v4(&GROUP_ADDR_V4, ip) {
+ Err(e) if e.kind() == io::ErrorKind::AddrInUse => {
+ debug!("multicast group V4 already joined on addr {ip}");
+ Ok(())
+ }
+ result => result,
+ }
+}
+
/// Join the multicast group for the given interface.
fn join_multicast_group(my_sock: &PktInfoUdpSocket, intf: &Interface) -> Result<()> {
let intf_ip = &intf.ip();
@@ -1222,8 +1238,7 @@ fn join_multicast_group(my_sock: &PktInfoUdpSocket, intf: &Interface) -> Result<
IpAddr::V4(ip) => {
// Join mDNS group to receive packets.
debug!("join multicast group V4 on {} addr {ip}", intf.name);
- my_sock
- .join_multicast_v4(&GROUP_ADDR_V4, ip)
+ join_multicast_v4(my_sock, ip)
.map_err(|e| e_fmt!("PKT join multicast group on addr {}: {}", intf_ip, e))?;
}
IpAddr::V6(ip) => {
@@ -1970,18 +1985,17 @@ impl Zeroconf {
);
}
- for ip in deleted_ips {
- self.del_ip(ip);
- }
-
for (if_index, last_ipv4, last_ipv6) in deleted_intfs {
let Some(my_intf) = self.my_intfs.remove(&if_index) else {
continue;
};
if let Some(ipv4) = last_ipv4 {
- debug!("leave multicast for {ipv4}");
- if let Some(sock) = self.ipv4_sock.as_mut() {
+ if self.intfs_with_ip(&IpAddr::V4(ipv4)).next().is_some() {
+ // Leaving by address would drop the membership the other interface uses.
+ debug!("keep multicast for {ipv4}: still used by another interface");
+ } else if let Some(sock) = self.ipv4_sock.as_mut() {
+ debug!("leave multicast for {ipv4}");
if let Err(e) = sock.pktinfo.leave_multicast_v4(&GROUP_ADDR_V4, &ipv4) {
debug!("leave multicast group for addr {ipv4}: {e}");
}
@@ -2010,6 +2024,26 @@ impl Zeroconf {
self.resolve_updated_instances(&result.modified_instances);
}
+ // Handled after the removals above, so that `my_intfs` shows which of
+ // these addresses are still on another interface.
+ deleted_ips.sort();
+ deleted_ips.dedup();
+ for ip in deleted_ips {
+ if self.intfs_with_ip(&ip).next().is_none() {
+ self.del_ip(ip);
+ continue;
+ }
+
+ // The address is still on another interface. The removed interface may
+ // have been the one holding the shared IPv4 membership, which the kernel
+ // drops with it, so join again (a no-op if the membership survived).
+ if let (IpAddr::V4(ipv4), Some(sock)) = (ip, self.ipv4_sock.as_ref()) {
+ if let Err(e) = join_multicast_v4(&sock.pktinfo, &ipv4) {
+ debug!("check_ip_changes: rejoin multicast group on addr {ipv4}: {e}");
+ }
+ }
+ }
+
// Add newly found interfaces only if in our selections.
self.apply_intf_selections(my_ifaddrs);
}
@@ -2024,6 +2058,10 @@ impl Zeroconf {
intf.ip()
);
+ // Another interface with the same address still serves it, and shares its
+ // IPv4 membership, e.g. the `bridge1NN` and `vmenetN` pairs of VMs on macOS.
+ let shared = self.intfs_with_ip(&intf.ip()).any(|i| i != if_index);
+
let Some(my_intf) = self.my_intfs.get_mut(&if_index) else {
debug!("del_interface_addr: interface {} not found", intf.name);
return;
@@ -2036,7 +2074,9 @@ impl Zeroconf {
match intf.addr.ip() {
IpAddr::V4(ipv4) => {
- if my_intf.next_ifaddr_v4().is_none() {
+ if shared {
+ debug!("keep multicast for {ipv4}: still used by another interface");
+ } else if my_intf.next_ifaddr_v4().is_none() {
if let Some(sock) = self.ipv4_sock.as_mut() {
if let Err(e) = sock.pktinfo.leave_multicast_v4(&GROUP_ADDR_V4, &ipv4) {
debug!("leave multicast group for addr {ipv4}: {e}");
@@ -2084,7 +2124,7 @@ impl Zeroconf {
}
}
- if ip_removed {
+ if ip_removed && !shared {
// Notify the monitors.
self.notify_monitors(DaemonEvent::IpDel(intf.ip()));
// Remove the interface from my services that enabled `addr_auto`.
@@ -2092,6 +2132,14 @@ impl Zeroconf {
}
}
+ /// Indexes of the interfaces in `my_intfs` that have `ip`.
+ fn intfs_with_ip<'a>(&'a self, ip: &'a IpAddr) -> impl Iterator<Item = u32> + 'a {
+ self.my_intfs
+ .values()
+ .filter(move |my_intf| my_intf.addrs.iter().any(|addr| addr.ip() == *ip))
+ .map(|my_intf| my_intf.index)
+ }
+
/// Adds the address of `intf` and brings our services up on it.
///
/// Note: this `interface` type only contains one address.
@@ -5438,6 +5486,141 @@ mod tests {
})
}
|
|
Thanks! Sounds good, I'm on it! |
…ast join forever, or drop each other's membership IPv4 joins, leaves, and sends identify an interface by address, while `my_intfs` keys interfaces by index. On macOS, VM and container bridges (`bridge1NN`) can end up with an address the socket already joined, so the join fails with `AddrInUse`, the interface never reaches `my_intfs`, and every IP check retries the join and logs again (every five seconds, for as long as the bridge exists). - Treat `AddrInUse` on an IPv4 join as success: it means the socket already holds that membership. Every other join error still skips the interface and retries, as before. - Don't leave the group for an address another interface still has, since leaving by address would drop the membership that interface uses. - After removing interfaces, join again for any address still in use, in case the removed interface held the shared membership (a no-op when it didn't). - Skip the `IpDel` event and the `addr_auto` cleanup for an address another interface still has. Tests: `test_interface_sharing_an_ipv4_address_is_recorded` and `test_removing_one_of_two_interfaces_sharing_an_ipv4_address_keeps_the_membership` add a second interface on 127.0.0.1, so they need no host setup. Both fail without the fix.
a885e9f to
2daaaad
Compare
|
Thanks for the draft, it helped a lot! I also ran it on my Mac with OrbStack, churning Docker networks to recreate bridges, and experienced this:
One thing I learned, FYI: on my machine, the trigger wasn't two live interfaces sharing an address. It was bridges coming and going. Even a freshly started daemon got Can you re-review this? |
…ted IPv4 join as WSAEINVAL rather than AddrInUse
|
Sorry for the red Windows run! Windows reports a repeated join as |
…red `mdns-sd` fix, so the multicast-join retry storm can't come back unnoticed - New fast-lane check `desktop-rust-vendor-patch-applied` reads the root `[patch.crates-io]` and `Cargo.lock`, and fails unless every path patch resolves to its path: a crates.io copy of the crate, a `[[patch.unused]]` entry (what cargo writes once a bump passes the vendored version, with one build warning as the only sign), or a patch nothing depends on any more - The unused-patch fixture is real cargo output, captured by bumping a probe crate past 0.20.x with this repo's patch in place - Wired into CI next to the other cheap static lints, classified as a Rust meta-check for `workspace-member-coverage`, and listed in the checks DETAILS inventory - `docs/notes/mdns-sd-multicast-join-retry-loop.md` points at the guard and at upstream keepsimple1/mdns-sd#513, still open, so the fork stays; the PR draft's "unsent" header is corrected Refs #319
…fork once keepsimple1/mdns-sd#513 ships) and the guard that now protects it Refs #319
|
Thank you so much! I will review the diff this weekend. |
keepsimple1
left a comment
There was a problem hiding this comment.
Thanks! I think this is a reasonable change that can address the issue at hand.
Interfaces that share an IPv4 address no longer retry the multicast join forever, or drop each other's membership.
Problem: IPv4 join, leave, and send identify an interface by address, but
my_intfskeys interfaces by index. On macOS, OrbStack/Docker/UTM bridges (bridge1NN) end up with an address the socket already joined, so the join fails withAddrInUse, the interface never lands inmy_intfs, and every IP check retries and logs again (every 5 s, for as long as the bridge lives).Fix (based on @keepsimple1's draft):
AddrInUseon an IPv4 join counts as success: the socket already holds that membership. Other join errors still retry like before.IpDel/addr_autocleanup for an address that's still on another interface.Tests: two new ones add a second interface on
127.0.0.1, so no host setup needed. Both fail without the fix.