Skip to content

Commit

Permalink
Rewrite network interface searching (#19)
Browse files Browse the repository at this point in the history
This PR rewrites how network interfaces are scanned. Goals:
- Improve performance by not scanning the interfaces each time a socket
is opened
- Make better use of IP address types instead of strings
- Be looser about what can be used to specify a network interface.
IfaceInfo objects, IPAddress objects, IP address strings, and network
interface names all work now.
- Handle interfaces with multiple IPs without crashing
- Handle duplicate IPs between interfaces
  • Loading branch information
relativityspace-jsmith authored Feb 7, 2025
1 parent 12fe76e commit 37cf5d6
Show file tree
Hide file tree
Showing 24 changed files with 1,005 additions and 422 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
# Note: Currently using pytest with retries due to intermittent (~10% of the time) failure when
# running unit tests on macos runners due to "exec format error" when setting socket options (???).
- name: Run Pytest
run: poetry run pytest --retries 3
run: poetry run pytest --retries 3 --capture=no

# This job exists to run mypy on Windows and Mac, as sometimes it can turn up different errors in these cases.
my-py:
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ __pycache__/
*$py.class

# builds
dist/*
dist/*

# PyCharm project
.idea/*
3 changes: 0 additions & 3 deletions .idea/.gitignore

This file was deleted.

6 changes: 0 additions & 6 deletions .idea/inspectionProfiles/profiles_settings.xml

This file was deleted.

7 changes: 0 additions & 7 deletions .idea/misc.xml

This file was deleted.

8 changes: 0 additions & 8 deletions .idea/modules.xml

This file was deleted.

11 changes: 0 additions & 11 deletions .idea/multicast_expert.iml

This file was deleted.

6 changes: 0 additions & 6 deletions .idea/vcs.xml

This file was deleted.

19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,25 @@ Lorem Ipsum dolor sit amet.

_______________________________________________________________________________

## [1.5.0] - 2025-02-XX

This release is focused on addressing the performance issues caused by multicast_expert rescanning the network interfaces on the machine each time a socket is opened. You can now avoid this overhead by using the new scan functions and then passing their result into the socket constructors' `iface` argument.

### Added
- `multicast_expert.scan_interfaces()` provides a more complete wrapper around `netifaces` It will scan all the interface details from the machine into a list of `IfaceInfo` dataclass objects.
- `multicast_expert.find_interfaces()` provides an easy way to locate interfaces matching a given specifier. The specifier may be an IPv4 or IPv6 address of the interface (as a string or an object), or an interface name (e.g. eth0).

### Changed
- `McastTxSocket` and `McastRxSocket` now accept an `IfaceSpecifier` for their interface IP arguments instead of just an IP address string. This is compatible with old usage but allows a wider range of values to be used, including an interface obtained from `scan_interfaces()` or `find_interfaces()`.
- `McastTxSocket` and `McastRxSocket` now accept IPv4Address and IPv6Address objects in addition to strings for the `mcast_ips` and `source_ips` constructor arguments.
- Note that the sendto() and recvfrom() functions still accept and return only string addresses, as this matches the behavior of the `socket` module (surprisingly).

### Fixed
- It is now possible to unambiguously open a socket on an interface that has the same IP as another interface (in most cases). Previously, it was undefined which interface you'd get if this happened.
- It is now possible to open a `McastRxSocket` socket on a machine with interfaces with multiple IPs. This previously crashed unless you explicitly specified one interface to use.

_______________________________________________________________________________

## [1.4.2] - 2025-01-31

### Fixed
Expand Down
17 changes: 10 additions & 7 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,16 @@ Using Multicast Expert
Now let's get into some actual code examples. Now first, before we can create any sockets, we need to find the interface address we want to use (see above). Luckily, Multicast Expert comes with a convenient function to list all available network interfaces:

>>> import multicast_expert
>>> multicast_expert.get_interface_ips(include_ipv4=True, include_ipv6=False)
['192.168.0.248', '192.168.153.1', '127.0.0.1']
>>> multicast_expert.scan_interfaces()
IfaceInfo(machine_name='{E61AD7AD-0125-4162-9967-98BE8A9CB330}', index=20, ip4_addrs=[], ip4_networks=[], ip6_addrs=[IPv6Address('fe80::1234:5678:%20')], ip6_networks=[IPv6Network('fe80::/64')])
IfaceInfo(machine_name='{195D3CB7-6D21-4C5A-8514-C4F01494FDC0}', index=37, ip4_addrs=[IPv4Address('192.168.1.5')], ip4_networks=[IPv4Network('192.168.1.0/24')], ip6_addrs=[IPv6Address('fe80::1111:2222%37')], ip6_networks=[IPv6Network('fe80::/64')])

(note that this function is a wrapper around the netifaces library, which provides quite a bit more functionality if you need it)

But which of those is the interface we actually want to use? Well, that depends on your specific network setup, but to make an educated guess, we also have a function to get the interface your machine uses to contact the internet. This is not always correct but will work for many network setups.

>>> multicast_expert.get_default_gateway_iface_ip_v4()
'192.168.0.248'
>>> multicast_expert.get_default_gateway_iface(socket.AF_INET).ip4_addrs
[IPv4Address('192.168.1.5')]

Transmitting Multicasts
=======================
Expand All @@ -96,23 +97,25 @@ To send some data to a multicast, use the McastTxSocket class. This wraps a soc
The following block shows how to create a Tx socket and send some data:

>>> import socket
>>> with multicast_expert.McastTxSocket(socket.AF_INET, mcast_ips=['239.1.2.3'], iface_ip='192.168.0.248') as mcast_tx_sock:
>>> with multicast_expert.McastTxSocket(socket.AF_INET, mcast_ips=['239.1.2.3'], iface='192.168.0.248') as mcast_tx_sock:
... mcast_tx_sock.sendto(b'Hello World', ('239.1.2.3', 12345))

Note: when you construct the socket, you have to pass in all of the multicast IPs that you will want to use the socket to send to. These must be known in advance in order to configure socket options correctly.

Note 2: If you omitted the iface_ip= argument, the get_default_gateway_iface_ip_v4() function would have been called to guess the iface ip. So, we could have omitted this argument for the same result.
Note 2: If you omitted the iface= argument, the get_default_gateway_iface() function would have been called to guess the interface to use. So, we could have omitted this argument for the same result. However, you should pass this argument in most real usage, or at least make it configurable by the user. In addition to the interface IP address, you can pass the interface name or an IfaceInfo dataclass received from scan_interfaces().

Receiving Multicasts
====================

To receive from one or more multicast addresses, use the McastRxSocket class. For example:

>>> with multicast_expert.McastRxSocket(socket.AF_INET, mcast_ips=['239.1.2.3'], port=12345, iface_ip='192.168.0.248') as mcast_rx_sock:
>>> with multicast_expert.McastRxSocket(socket.AF_INET, mcast_ips=['239.1.2.3'], port=12345) as mcast_rx_sock:
... bytes, src_address = mcast_rx_sock.recvfrom()

The above code will listen on the 239.1.2.3 multicast address, and will block until a packet is received. To change the blocking behavior, use the settimeout() function.

For receiving multicasts, you often don't need to pass an interface name, as the default is to listen on all possible interfaces of the machine. However, you can pass one or more specific interfaces to listen on if you like, for performance or functionality reasons.

Full Example
============
For a complete example of how to use this library, see the system test script `here <https://github.com/multiplemonomials/multicast_expert/blob/main/examples/mcast_communicator.py>`_.
Expand Down
12 changes: 6 additions & 6 deletions examples/mcast_communicator.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,25 +70,25 @@ def listener_thread(machine_index: int, addr_family: int, iface_ip: str) -> None
sys.exit(1)

if len(sys.argv) > 3:
iface_ip = sys.argv[3]
iface = sys.argv[3]
else:
iface_ip = multicast_expert.get_default_gateway_iface_ip(addr_family)
if iface_ip is None:
iface = multicast_expert.get_default_gateway_iface_ip(addr_family)
if iface is None:
print("Unable to determine default gateway. Please specify interface in arguments.")
sys.exit(1)

# Start listener thread
listener_thread_obj = threading.Thread(
target=listener_thread, name="Multicast Listener Thread", args=(machine_number, addr_family, iface_ip), daemon=True
target=listener_thread, name="Multicast Listener Thread", args=(machine_number, addr_family, iface), daemon=True
)
listener_thread_obj.start()

# Start transmitting
print(f"Communicator starting on interface {iface_ip}. Press Ctrl-C to exit")
print(f"Communicator starting on interface {iface}. Press Ctrl-C to exit")
with multicast_expert.McastTxSocket(
addr_family=addr_family,
mcast_ips=[MULTICAST_ADDRESSES[addr_family][0], MULTICAST_ADDRESSES[addr_family][machine_number]],
iface_ip=iface_ip,
iface=iface,
) as tx_socket:
while True:
time.sleep(1.0)
Expand Down
14 changes: 10 additions & 4 deletions multicast_expert/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,18 @@
LOCALHOST_IPV6 = "::1"


from multicast_expert.interfaces import IfaceInfo as IfaceInfo
from multicast_expert.interfaces import IfaceSpecifier as IfaceSpecifier
from multicast_expert.interfaces import find_interfaces as find_interfaces
from multicast_expert.interfaces import get_default_gateway_iface as get_default_gateway_iface
from multicast_expert.interfaces import get_default_gateway_iface_ip as get_default_gateway_iface_ip
from multicast_expert.interfaces import get_default_gateway_iface_ip_v4 as get_default_gateway_iface_ip_v4
from multicast_expert.interfaces import get_default_gateway_iface_ip_v6 as get_default_gateway_iface_ip_v6
from multicast_expert.interfaces import get_interface_ips as get_interface_ips
from multicast_expert.interfaces import scan_interfaces as scan_interfaces
from multicast_expert.rx_socket import McastRxSocket as McastRxSocket
from multicast_expert.tx_socket import McastTxSocket as McastTxSocket
from multicast_expert.utils import IPv4Or6Address as IPv4Or6Address
from multicast_expert.utils import MulticastAddress as MulticastAddress
from multicast_expert.utils import MulticastExpertError as MulticastExpertError
from multicast_expert.utils import get_default_gateway_iface_ip as get_default_gateway_iface_ip
from multicast_expert.utils import get_default_gateway_iface_ip_v4 as get_default_gateway_iface_ip_v4
from multicast_expert.utils import get_default_gateway_iface_ip_v6 as get_default_gateway_iface_ip_v6
from multicast_expert.utils import get_interface_ips as get_interface_ips
from multicast_expert.utils import validate_mcast_ip as validate_mcast_ip
Loading

0 comments on commit 37cf5d6

Please sign in to comment.