Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions pyatv/support/net.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@

_LOGGER = logging.getLogger(__name__)

# Private RFC1918 target used only to select a default route.
_DEFAULT_ROUTE_PROBE = ("10.255.255.1", 1)


def unused_port() -> int:
"""Return a port that is unused on the current host."""
Expand Down Expand Up @@ -47,6 +50,26 @@ def mcast_socket(address: Optional[str], port: int = 0) -> socket.socket:
with suppress(OSError):
membership = socket.inet_aton("224.0.0.251") + socket.inet_aton(address)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, membership)
else:
# Fallback for sandboxed platforms where ifaddr finds no adapters:
# detect default IPv4 via a dummy UDP connect and use it for multicast join.
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
probe.connect(_DEFAULT_ROUTE_PROBE)
Comment on lines +54 to +58
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

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

The UDP “default route probe” logic is duplicated in both mcast_socket and get_private_addresses. To avoid the two call sites drifting (e.g., different exception handling or filtering), consider extracting a small helper like _get_default_ipv4() and reusing it in both places.

Copilot uses AI. Check for mistakes.
detected = probe.getsockname()[0]
sock.setsockopt(
socket.SOL_IP, socket.IP_MULTICAST_IF, socket.inet_aton(detected)
)
membership = socket.inet_aton("224.0.0.251") + socket.inet_aton(
detected
)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, membership)
except OSError:
with suppress(OSError):
membership = socket.inet_aton("224.0.0.251") + socket.inet_aton(
"0.0.0.0"
)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, membership)

_LOGGER.debug("Binding on %s:%d", address or "*", port)
sock.bind((address or "", port))
Expand Down Expand Up @@ -74,6 +97,17 @@ def get_private_addresses(include_loopback=True) -> List[IPv4Address]:
if ipaddr.is_private:
addresses.append(ipaddr)

# Fallback when no adapters are reported (e.g. sandboxed platforms).
if not addresses:
try:
Comment on lines +100 to +102
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

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

get_private_addresses fallback runs whenever addresses is empty, which also covers cases where adapters exist but only have public IPv4s. That expands behavior beyond the PR description (“when no adapters are reported”) and changes previous semantics for public-only hosts. Consider storing adapters = get_adapters() and only running the UDP-probe fallback when not adapters (or when no IPv4 addresses were enumerated).

Copilot uses AI. Check for mistakes.
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
probe.connect(_DEFAULT_ROUTE_PROBE)
Comment on lines +100 to +104
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

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

This new fallback changes observable behavior when get_adapters() returns an empty list (existing unit test tests/support/test_net.py::test_no_address currently expects []). Add/update tests to mock the UDP probe socket (connect/getsockname) so the fallback is deterministic and validated (both success and OSError paths).

Copilot uses AI. Check for mistakes.
detected = IPv4Address(probe.getsockname()[0])
if include_loopback or not detected.is_loopback:
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

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

In the get_private_addresses fallback, the detected address is appended without checking detected.is_private. This can cause the function to return a globally-routable IP, contradicting the docstring/previous behavior (only ipaddr.is_private values were returned). Apply the same is_private filter in the fallback path (while still honoring include_loopback).

Suggested change
if include_loopback or not detected.is_loopback:
if detected.is_private or (detected.is_loopback and include_loopback):

Copilot uses AI. Check for mistakes.
addresses.append(detected)
except OSError:
pass
Comment on lines +108 to +109
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

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

'except' clause does nothing but pass and there is no explanatory comment.

Suggested change
except OSError:
pass
except OSError as ex:
_LOGGER.debug(
"Unable to determine fallback local address via UDP probe: %s", ex
)

Copilot uses AI. Check for mistakes.

return addresses


Expand Down
Loading