Skip to content

feat: add multicast_addresses to bind/multicast independently#1767

Draft
bluetoothbot wants to merge 6 commits into
python-zeroconf:masterfrom
bluetoothbot:koan/fix-issue-1520
Draft

feat: add multicast_addresses to bind/multicast independently#1767
bluetoothbot wants to merge 6 commits into
python-zeroconf:masterfrom
bluetoothbot:koan/fix-issue-1520

Conversation

@bluetoothbot
Copy link
Copy Markdown
Contributor

@bluetoothbot bluetoothbot commented May 20, 2026

Summary

Adds a new multicast_addresses parameter to Zeroconf / AsyncZeroconf /
create_sockets so multicast group membership can be configured
independently of bind interfaces. On iOS (and other sandboxed
environments) the system mDNS daemon owns port 5353 on physical
interfaces so binding there is rejected, but joining the multicast
group on those interfaces is still permitted and necessary to receive
incoming queries.

Today the interfaces parameter conflates the two roles: every
entry both joins the multicast group and gets a respond socket
bound on it. multicast_addresses adds an additive escape hatch
that joins additional addresses to the listen socket's multicast
group without creating respond sockets for them. Default behavior is
unchanged.

Fixes #1520

Changes

  • create_sockets accepts multicast_addresses and joins each
    normalized address to the listen socket; rejects the combination
    with unicast=True since there is no listen socket.
  • Zeroconf.__init__ and AsyncZeroconf.__init__ expose the
    parameter and forward it through.
  • Works on both the InterfaceChoice.Default single-socket fast
    path and the per-interface respond-socket path.
  • Tests cover IPv4, IPv6, InterfaceChoice.Default path, unicast
    rejection, and Zeroconf-level wiring.

Test plan

  • poetry run pytest tests/utils/test_net.py tests/test_core.py
  • poetry run pytest --timeout=60 (full suite, 394 passed)
  • poetry run ruff check + ruff format --check on changed
    files

Quality Report

Changes: 5 files changed, 148 insertions(+), 3 deletions(-)

Code scan: clean

Tests: passed (4 PASSED)

Branch hygiene: clean

Generated by Kōan post-mission quality pipeline

@bdraco bdraco requested a review from Copilot May 20, 2026 20:31
@bdraco
Copy link
Copy Markdown
Member

bdraco commented May 20, 2026

@bluetoothbot rr

@codecov
Copy link
Copy Markdown

codecov Bot commented May 20, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.77%. Comparing base (cb81e67) to head (6e25708).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #1767   +/-   ##
=======================================
  Coverage   99.77%   99.77%           
=======================================
  Files          33       33           
  Lines        3536     3562   +26     
  Branches      498      508   +10     
=======================================
+ Hits         3528     3554   +26     
  Misses          5        5           
  Partials        3        3           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented May 20, 2026

Merging this PR will not alter performance

✅ 14 untouched benchmarks


Comparing bluetoothbot:koan/fix-issue-1520 (6e25708) with master (cb81e67)

Open in CodSpeed

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a multicast_addresses escape hatch to let callers join additional multicast groups on the listen socket without also binding per-interface respond sockets—targeting sandboxed environments (e.g. iOS) where binding :5353 on physical interfaces can be blocked while multicast membership is still allowed/needed.

Changes:

  • Extend create_sockets() with multicast_addresses and reject multicast_addresses with unicast=True.
  • Thread the new parameter through Zeroconf and AsyncZeroconf constructors (docs + typing included).
  • Add unit tests for IPv4/IPv6 membership behavior, the InterfaceChoice.Default fast path, unicast rejection, and Zeroconf-level wiring.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/zeroconf/_utils/net.py Adds multicast_addresses handling in create_sockets() to join extra multicast memberships on the listen socket.
src/zeroconf/_core.py Exposes/forwards multicast_addresses from Zeroconf.__init__() into socket creation and documents behavior.
src/zeroconf/asyncio.py Exposes/forwards multicast_addresses from AsyncZeroconf.__init__() into Zeroconf.
tests/utils/test_net.py Adds focused socket-creation tests covering IPv4/IPv6, default fast path, and unicast incompatibility.
tests/test_core.py Adds a wiring test that verifies multicast_addresses is forwarded to create_sockets().

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/zeroconf/_core.py
Comment on lines +210 to +216
listen_socket, respond_sockets = create_sockets(
interfaces,
unicast,
ip_version,
apple_p2p=apple_p2p,
multicast_addresses=multicast_addresses,
)
Comment on lines 473 to +477
if not unicast and interfaces is InterfaceChoice.Default and ip_version != IPVersion.All:
for interface in normalized_interfaces:
add_multicast_member(cast(socket.socket, listen_socket), interface)
for interface in extra_multicast_members:
add_multicast_member(cast(socket.socket, listen_socket), interface)
Comment thread tests/test_core.py Outdated
zc = r.Zeroconf(
interfaces=["127.0.0.1"],
multicast_addresses=["192.168.1.5"],
unicast=True,
Comment thread src/zeroconf/_utils/net.py Outdated
Comment on lines +466 to +469
normalized_interfaces = normalize_interface_choice(interfaces, ip_version)
extra_multicast_members = (
normalize_interface_choice(list(multicast_addresses), ip_version) if multicast_addresses else []
)
@bluetoothbot
Copy link
Copy Markdown
Contributor Author

bluetoothbot commented May 20, 2026

PR Review — feat: add multicast_addresses to bind/multicast independently

The feature itself is a clean, additive escape hatch with sensible behaviour: extras are validated up-front against ip_version, deduped against the existing interfaces set, and joined on the listen socket in both the Default fast path and the per-interface path. The unicast=True rejection is correct (there's no listen socket to join groups on). Three things to address before merging: (1) tuple entries in multicast_addresses are accepted by the type signature and the validator but silently dropped by normalize_interface_choice — this needs to either be supported or explicitly removed from the type; (2) @bdraco's DRY request — pull the validation, dedup, and extras-join into small helpers so create_sockets stays a coordinator; (3) the TC-deferral test rewrite is unrelated scope and should ship in its own PR. The test-coverage gap @bdraco flagged earlier appears to have been fixed per the latest Codecov run. No correctness or security concerns in the wire-protocol path.


🟡 Important

1. Tuple entries silently dropped — type signature claims support, runtime ignores them (`src/zeroconf/_utils/net.py`, L469-475)

multicast_addresses is typed as Sequence[str | int | tuple[tuple[str, int, int], int]], but passing a pre-normalized IPv6 tuple silently produces no join.

Follow the chain:

  1. _entry_ip_version accepts a tuple and returns 6 — so the V4Only/V6Only validation passes.
  2. normalize_interface_choice(list(multicast_addresses), ip_version) then calls ip6_addresses_to_indexes(choice), which only handles int and str (with version==6). Tuple entries fall through both branches and are dropped on the floor.

Net effect: multicast_addresses=[(('fe80::1', 0, 0), 1)] looks legitimate per the annotation, passes validation, and adds zero multicast memberships. There is no error, no log line — just a feature that silently doesn't work for one of its three documented input types.

Two options: either (a) extend ip6_addresses_to_indexes to pass tuple entries through (elif isinstance(iface, tuple): result.append(iface)), keeping the type signature honest; or (b) tighten the parameter type to Sequence[str | int] and document that callers must supply addresses/indexes, not pre-normalized tuples. Whichever you pick, add a test that exercises the chosen contract.

if multicast_addresses:
    extra_multicast_members = normalize_interface_choice(list(multicast_addresses), ip_version)
    # Strip entries already covered by ``interfaces`` so add_multicast_member
    # is not called twice for the same membership.
    interface_set = set(normalized_interfaces)
    extra_multicast_members = [m for m in extra_multicast_members if m not in interface_set]
2. Unrelated TC-bit timing/cleanup-window change bundled into this PR (`tests/test_core.py`, L702-741)

The diff to test_tc_bit_defers_last_response_missing (widening _TC_DELAY_RANDOM_INTERVAL to (1500, 60_000) and bumping the post-test cleanup loop from 8 iterations to 30) has nothing to do with the multicast_addresses feature this PR introduces. It looks like a separate flaky-test fix that got bundled in.

This matters because:

  • A reviewer reading the PR title (feat: add multicast_addresses to bind/multicast independently) does not expect to find changes to TC-deferral test timing. Bundling unrelated test fixes makes regressions hard to bisect later.
  • The squash-merge commit message will be derived from the PR title and will not mention this unrelated change, so git blame won't surface the real motivation.
  • The repo's contribution conventions in CLAUDE.md ask for tightly scoped changes ("Don't add features, refactor, or introduce abstractions beyond what the task requires").

Please split this hunk out into its own follow-up PR with a test: or ci: prefix and an explanation of the flakiness it's chasing. Keep this PR focused on multicast_addresses and create_sockets/autodetect_ip_version wiring.

    # Widen the per-packet delay so Windows scheduling jitter between
    # assertions cannot fire the timer mid-test, while leaving plenty of
    # headroom under the first-arrival deadline for timer-replacement to
    # remain deterministic.
    with patch.object(_listener, "_TC_DELAY_RANDOM_INTERVAL", (1500, 60_000)):

🟢 Suggestions

1. Extract helpers to address @bdraco's DRY request (`src/zeroconf/_utils/net.py`, L455-520)

@bdraco asked for this to be made more DRY. The for interface in extra_multicast_members: add_multicast_member(listen_socket, interface) loop appears verbatim on both the Default fast path and the per-interface path. The IP-version validation block at the top of create_sockets is also a discrete concern that's worth its own helper.

Consider:

def _validate_multicast_addresses(
    multicast_addresses: Sequence[...] | None, ip_version: IPVersion, unicast: bool,
) -> None:
    if not multicast_addresses:
        return
    if unicast:
        raise ValueError("multicast_addresses is incompatible with unicast=True")
    if ip_version == IPVersion.V4Only and any(_entry_ip_version(e) == 6 for e in multicast_addresses):
        raise ValueError("multicast_addresses contains IPv6 entries but ip_version is V4Only")
    if ip_version == IPVersion.V6Only and any(_entry_ip_version(e) == 4 for e in multicast_addresses):
        raise ValueError("multicast_addresses contains IPv4 entries but ip_version is V6Only")


def _resolve_extra_multicast_members(
    multicast_addresses: Sequence[...] | None,
    normalized_interfaces: list[...],
    ip_version: IPVersion,
) -> list[...]:
    if not multicast_addresses:
        return []
    extras = normalize_interface_choice(list(multicast_addresses), ip_version)
    interface_set = set(normalized_interfaces)
    return [m for m in extras if m not in interface_set]


def _join_extra_multicast_members(listen_socket, extras):
    for member in extras:
        add_multicast_member(listen_socket, member)

That collapses create_sockets back to roughly its original shape with three named hooks for the new behaviour, and lets each helper be tested directly.


Checklist

  • Implementation matches the documented behaviour — warning #1
  • Validation at the API boundary
  • No add_multicast_member double-calls
  • autodetect_ip_version covers all entry kinds
  • PR is scoped to the stated feature — warning #3
  • Maintainer feedback addressed (DRY) — suggestion #2
  • Tests cover the new branches
  • No hand-edited Cython output
  • Docstring / comment style per CLAUDE.md

Summary

The feature itself is a clean, additive escape hatch with sensible behaviour: extras are validated up-front against ip_version, deduped against the existing interfaces set, and joined on the listen socket in both the Default fast path and the per-interface path. The unicast=True rejection is correct (there's no listen socket to join groups on). Three things to address before merging: (1) tuple entries in multicast_addresses are accepted by the type signature and the validator but silently dropped by normalize_interface_choice — this needs to either be supported or explicitly removed from the type; (2) @bdraco's DRY request — pull the validation, dedup, and extras-join into small helpers so create_sockets stays a coordinator; (3) the TC-deferral test rewrite is unrelated scope and should ship in its own PR. The test-coverage gap @bdraco flagged earlier appears to have been fixed per the latest Codecov run. No correctness or security concerns in the wire-protocol path.


To rebase specific severity levels, mention me: @bluetoothbot rebase critical (fixes 🔴 only), @bluetoothbot rebase important (fixes 🔴 + 🟡), or just @bluetoothbot rebase for all.


Automated review by Kōanf265d95
933a7c9
5fc8e96
83fadb1
56f00c5

@bluetoothbot
Copy link
Copy Markdown
Contributor Author

Rebase with requested adjustments

Branch koan/fix-issue-1520 was rebased onto master and review feedback was applied.

Changes applied

  • only interfaces list no longer silently resolves to V4Only (Copilot review on _core.py:216).
  • _utils/net.py: Added explicit IP-version validation for multicast_addresses before any socket work — raises ValueError with a clear message when V4Only sees IPv6 entries (or V6Only sees IPv4) instead of failing later in adapter lookup or setsockopt (Copilot review on _utils/net.py:469).
  • _utils/net.py: Deduplicated extra_multicast_members against normalized_interfaces so addresses appearing in both lists no longer trigger add_multicast_member() twice (Copilot review on _utils/net.py:477).
  • tests/test_core.py: Dropped unicast=True from the forwarding test so it uses a combination the real create_sockets() accepts, keeping the test aligned with the public contract (Copilot review on tests/test_core.py:193).
  • tests/utils/test_net.py: Extended autodetect_ip_version and added focused tests for the new validation paths and the dedup behavior.

Stats

5 files changed, 237 insertions(+), 10 deletions(-)
Actions performed
  • Already-solved check: negative (confidence=high, reasoning=No recent commit on master adds a multicast_addresses parameter to Zeroconf or create_sockets.)
  • Rebased koan/fix-issue-1520 onto upstream/master
  • Applied review feedback
  • Pre-push CI check: previous run passed
  • Force-pushed koan/fix-issue-1520 to origin
  • CI check enqueued in ## CI (async)

CI status

CI will be checked asynchronously.


Automated by Kōan

Copy link
Copy Markdown
Member

@bdraco bdraco left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to complete coverage

src/zeroconf/_utils/net.py 96.66% 0 Missing and 1 partia

@bdraco
Copy link
Copy Markdown
Member

bdraco commented May 20, 2026

@bluetoothbot rr

@bluetoothbot
Copy link
Copy Markdown
Contributor Author

Rebase with requested adjustments

Branch koan/fix-issue-1520 was rebased onto master and review feedback was applied.

Changes applied

  • _non_list_interfaces_with_multicast_addressesto cover theisinstance(interfaces, list)False branch inautodetect_ip_versionwhenmulticast_addressesis also supplied (was partial:539->545`).
  • Added test_create_sockets_multicast_addresses_ip_version_all to cover the ip_version == IPVersion.V6Only elif False branch in create_sockets validation, exercised by ip_version=IPVersion.All with multicast_addresses set (was partial: 470->475).
  • Addresses @bdraco's "Need to complete coverage" review (src/zeroconf/_utils/net.py was at 96.66% with 1 partial; now 94% lines with only pre-existing partials remaining — both new partial branches introduced by the PR are now covered).
  • Other inline Copilot comments were already addressed by commit f24fdaf (autodetect now considers multicast_addresses, dedup against interfaces, ip_version validation). The stale Copilot note about unicast=True in the wiring test no longer applies — the current test does not pass unicast=True.

Stats

5 files changed, 273 insertions(+), 10 deletions(-)
Actions performed
  • Already-solved check: negative (confidence=high, reasoning=No recent master commit adds a multicast_addresses parameter to Zeroconf/create_sockets.)
  • Rebased koan/fix-issue-1520 onto upstream/master
  • Applied review feedback
  • Pre-push CI check: previous run passed
  • Force-pushed koan/fix-issue-1520 to origin
  • CI check enqueued in ## CI (async)

CI status

CI will be checked asynchronously.


Automated by Kōan

@bdraco
Copy link
Copy Markdown
Member

bdraco commented May 21, 2026

@bluetoothbot rr

@bluetoothbot
Copy link
Copy Markdown
Contributor Author

Rebase with requested adjustments

Branch koan/fix-issue-1520 was rebased onto master and review feedback was applied.

Stats

5 files changed, 273 insertions(+), 10 deletions(-)
Actions performed
  • Already-solved check: negative (confidence=high, reasoning=No recent master commit adds a multicast_addresses parameter to Zeroconf or create_sockets.)
  • Rebased koan/fix-issue-1520 onto upstream/master
  • Pre-push CI check: previous run passed
  • Force-pushed koan/fix-issue-1520 to origin
  • CI check enqueued in ## CI (async)

CI status

CI will be checked asynchronously.


Automated by Kōan

Copy link
Copy Markdown
Member

@bdraco bdraco left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like we already have similar logic and this could be made more DRY by breaking out into helpers

@bdraco
Copy link
Copy Markdown
Member

bdraco commented May 21, 2026

@bluetoothbot rr

@bluetoothbot
Copy link
Copy Markdown
Contributor Author

Rebase with requested adjustments

Branch koan/fix-issue-1520 was rebased onto master and review feedback was applied.

Changes applied

  • ny(_entry_ip_version(e) == N for e in ...)` guard clauses.
  • Rewrote autodetect_ip_version to build one entries list from interfaces + multicast_addresses and classify via the helper, replacing two parallel any() branches per version that each repeated the isinstance/ipaddress dance.
  • Behavior preserved for all existing tests (61 passed in tests/utils/test_net.py + tests/test_core.py). Tuple entries in interfaces now consistently classify as IPv6 in autodetect — previously only int was checked there, while tuple was already treated as IPv6 in the multicast_addresses path.
  • Coverage on src/zeroconf/_utils/net.py is now 97% with the existing test suite; the codecov bot already reports "All modified and coverable lines are covered by tests" after the prior coverage commit. The Copilot inline comments (autodetect including multicast_addresses, dedup against interfaces, ip_version validation, test using unicast=True) were already addressed in prior commits on this branch.

Stats

5 files changed, 274 insertions(+), 15 deletions(-)
Actions performed
  • Already-solved check: negative (confidence=high, reasoning=No recent master commit adds a multicast_addresses parameter to Zeroconf/create_sockets; recent comm)
  • Rebased koan/fix-issue-1520 onto upstream/master
  • Applied review feedback
  • Pre-push CI check: previous run passed
  • Force-pushed koan/fix-issue-1520 to origin
  • CI check enqueued in ## CI (async)

CI status

CI will be checked asynchronously.


Automated by Kōan

Copy link
Copy Markdown
Member

@bdraco bdraco left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

coverage is incomplete

Files with missing lines Patch % Lines
src/zeroconf/_utils/net.py 93.54% 1 Missing and 1 partial ⚠️

Files with missing lines Patch % Lines
src/zeroconf/_utils/net.py 93.54% 1 Missing and 1 partial ⚠️

@bdraco
Copy link
Copy Markdown
Member

bdraco commented May 21, 2026

@bluetoothbot rr

iOS and other sandboxed platforms allow joining the mDNS multicast
group on a physical interface but forbid binding port 5353 on it
because the system mDNS daemon owns the port. The existing
``interfaces`` parameter conflates the two: every entry both joins
the multicast group and gets a respond socket bound to that
interface's address.

Add an optional ``multicast_addresses`` parameter on ``Zeroconf`` /
``AsyncZeroconf`` / ``create_sockets`` for additional addresses that
are joined to the listen socket's multicast group without creating
a respond socket. The default behavior is unchanged.

Fixes python-zeroconf#1520
@bluetoothbot
Copy link
Copy Markdown
Contributor Author

Rebase with requested adjustments

Branch koan/fix-issue-1520 was rebased onto master and review feedback was applied.

Changes applied

  • Coverage gap closed. Line 450 now hit; remaining missing lines/branches all pre-existing on master.
  • Summary

  • Added test_autodetect_ip_version_interface_index_treated_as_v6 covering _entry_ip_version's non-str branch (interface-index int → IPv6). Closes the codecov patch gap bdraco flagged ("Need to complete coverage" / _utils/net.py 93.54% → patch now fully covered).
  • Other reviewer comments were already addressed in prior commits on this branch:
  • Copilot autodetect / dedup / IP-version validation → commit 933a7c9.
  • bdraco DRY refactor (extract _entry_ip_version) → commit 83fadb1.
  • Copilot stale comment about unicast=True in test_multicast_addresses_forwarded_to_create_sockets — current test does not pass unicast=True.

Stats

5 files changed, 319 insertions(+), 52 deletions(-)
Actions performed
  • Already-solved check: negative (confidence=high, reasoning=No recent commit on master adds a multicast_addresses parameter to Zeroconf/create_sockets for indep)
  • Rebased koan/fix-issue-1520 onto upstream/master
  • Applied review feedback
  • Pre-push CI check: previous run passed
  • Force-pushed koan/fix-issue-1520 to origin
  • CI check enqueued in ## CI (async)

CI status

CI will be checked asynchronously.


Automated by Kōan

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment on lines 485 to +489
normalized_interfaces = normalize_interface_choice(interfaces, ip_version)
if multicast_addresses:
extra_multicast_members = normalize_interface_choice(list(multicast_addresses), ip_version)
# Strip entries already covered by ``interfaces`` so add_multicast_member
# is not called twice for the same membership.
@bdraco
Copy link
Copy Markdown
Member

bdraco commented May 21, 2026

@bluetoothbot rr

@bluetoothbot
Copy link
Copy Markdown
Contributor Author

Rebase with requested adjustments

Branch koan/fix-issue-1520 was rebased onto master and review feedback was applied.

Changes applied

  • Changes complete.
  • Summary

  • Addresses the remaining unresolved Copilot review comment: pre-normalized IPv6 adapter tuples passed in multicast_addresses were silently dropped because ip6_addresses_to_indexes() only handled int and str entries.
  • src/zeroconf/_utils/net.py: Extended ip6_addresses_to_indexes() with a tuple pass-through branch so already-normalized tuple[tuple[str, int, int], int] entries reach add_multicast_member() instead of being filtered out. Fixes Copilot's "silently ignored" concern on _utils/net.py:489 and applies uniformly to both interfaces and multicast_addresses (consistent with the type signature).
  • tests/utils/test_net.py: Added test_create_sockets_multicast_addresses_preserves_pre_normalized_tuple pinning the new behaviour.
  • Prior commits on this branch already resolved bdraco's coverage and DRY-helper requests and the other Copilot inline comments (autodetect, dedup, ip_version validation, test wiring).

Stats

5 files changed, 347 insertions(+), 52 deletions(-)
Actions performed
  • Already-solved check: negative (confidence=high, reasoning=No commit on master adds multicast_addresses parameter to Zeroconf/create_sockets for independent mu)
  • Rebased koan/fix-issue-1520 onto upstream/master
  • Applied review feedback
  • Pre-push CI check: previous run passed
  • Force-pushed koan/fix-issue-1520 to origin
  • CI check enqueued in ## CI (async)

CI status

CI will be checked asynchronously.


Automated by Kōan

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add ability to specify multicast groups with addresses

3 participants