Skip to content

Fix timestamp handling in udp_multicast on macOS #1278

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 29, 2022
95 changes: 72 additions & 23 deletions can/interfaces/udp_multicast/bus.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import errno
import logging
import select
import socket
import struct

try:
from fcntl import ioctl
except ModuleNotFoundError: # Missing on Windows
pass

from typing import List, Optional, Tuple, Union

log = logging.getLogger(__name__)
Expand All @@ -21,6 +27,7 @@

# Additional constants for the interaction with Unix kernels
SO_TIMESTAMPNS = 35
SIOCGSTAMP = 0x8906


class UdpMulticastBus(BusABC):
Expand Down Expand Up @@ -174,6 +181,9 @@ def __init__(
self.hop_limit = hop_limit
self.max_buffer = max_buffer

# `False` will always work, no matter the setup. This might be changed by _create_socket().
self.timestamp_nanosecond = False

# Look up multicast group address in name server and find out IP version of the first suitable target
# and then get the address family of it (socket.AF_INET or socket.AF_INET6)
connection_candidates = socket.getaddrinfo( # type: ignore
Expand All @@ -200,8 +210,15 @@ def __init__(

# used in recv()
self.received_timestamp_struct = "@ll"
ancillary_data_size = struct.calcsize(self.received_timestamp_struct)
self.received_ancillary_buffer_size = socket.CMSG_SPACE(ancillary_data_size)
self.received_timestamp_struct_size = struct.calcsize(
self.received_timestamp_struct
)
if self.timestamp_nanosecond:
self.received_ancillary_buffer_size = socket.CMSG_SPACE(
self.received_timestamp_struct_size
)
else:
self.received_ancillary_buffer_size = 0

# used by send()
self._send_destination = (self.group, self.port)
Expand Down Expand Up @@ -238,7 +255,15 @@ def _create_socket(self, address_family: socket.AddressFamily) -> socket.socket:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

# set how to receive timestamps
sock.setsockopt(socket.SOL_SOCKET, SO_TIMESTAMPNS, 1)
try:
sock.setsockopt(socket.SOL_SOCKET, SO_TIMESTAMPNS, 1)
except OSError as error:
if error.errno == errno.ENOPROTOOPT: # It is unavailable on macOS
self.timestamp_nanosecond = False
else:
raise error
else:
self.timestamp_nanosecond = True

# Bind it to the port (on any interface)
sock.bind(("", self.port))
Expand Down Expand Up @@ -272,18 +297,22 @@ def send(self, data: bytes, timeout: Optional[float] = None) -> None:

:param timeout: the timeout in seconds after which an Exception is raised is sending has failed
:param data: the data to be sent
:raises OSError: if an error occurred while writing to the underlying socket
:raises socket.timeout: if the timeout ran out before sending was completed (this is a subclass of
*OSError*)
:raises can.CanOperationError: if an error occurred while writing to the underlying socket
:raises can.CanTimeoutError: if the timeout ran out before sending was completed
"""
if timeout != self._last_send_timeout:
self._last_send_timeout = timeout
# this applies to all blocking calls on the socket, but sending is the only one that is blocking
self._socket.settimeout(timeout)

bytes_sent = self._socket.sendto(data, self._send_destination)
if bytes_sent < len(data):
raise socket.timeout()
try:
bytes_sent = self._socket.sendto(data, self._send_destination)
if bytes_sent < len(data):
raise TimeoutError()
except TimeoutError:
raise can.CanTimeoutError() from None
except OSError as error:
raise can.CanOperationError("failed to send via socket") from error

def recv(
self, timeout: Optional[float] = None
Expand Down Expand Up @@ -320,21 +349,41 @@ def recv(
self.max_buffer, self.received_ancillary_buffer_size
)

# fetch timestamp; this is configured in in _create_socket()
assert len(ancillary_data) == 1, "only requested a single extra field"
cmsg_level, cmsg_type, cmsg_data = ancillary_data[0]
assert (
cmsg_level == socket.SOL_SOCKET and cmsg_type == SO_TIMESTAMPNS
), "received control message type that was not requested"
# see https://man7.org/linux/man-pages/man3/timespec.3.html -> struct timespec for details
seconds, nanoseconds = struct.unpack(
self.received_timestamp_struct, cmsg_data
)
if nanoseconds >= 1e9:
raise can.CanError(
f"Timestamp nanoseconds field was out of range: {nanoseconds} not less than 1e9"
# fetch timestamp; this is configured in _create_socket()
if self.timestamp_nanosecond:
# Very similar to timestamp handling in can/interfaces/socketcan/socketcan.py -> capture_message()
if len(ancillary_data) != 1:
raise can.CanOperationError(
"Only requested a single extra field but got a different amount"
)
cmsg_level, cmsg_type, cmsg_data = ancillary_data[0]
if cmsg_level != socket.SOL_SOCKET or cmsg_type != SO_TIMESTAMPNS:
raise can.CanOperationError(
"received control message type that was not requested"
)
# see https://man7.org/linux/man-pages/man3/timespec.3.html -> struct timespec for details
seconds, nanoseconds = struct.unpack(
self.received_timestamp_struct, cmsg_data
)
if nanoseconds >= 1e9:
raise can.CanOperationError(
f"Timestamp nanoseconds field was out of range: {nanoseconds} not less than 1e9"
)
timestamp = seconds + nanoseconds * 1.0e-9
else:
result_buffer = ioctl(
self._socket.fileno(),
SIOCGSTAMP,
bytes(self.received_timestamp_struct_size),
)
seconds, microseconds = struct.unpack(
self.received_timestamp_struct, result_buffer
)
timestamp = seconds + nanoseconds * 1.0e-9
if microseconds >= 1e6:
raise can.CanOperationError(
f"Timestamp microseconds field was out of range: {microseconds} not less than 1e6"
)
timestamp = seconds + microseconds * 1e-6

return raw_message_data, sender_address, timestamp

Expand Down
2 changes: 1 addition & 1 deletion doc/interfaces/udp_multicast.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ for specifying multicast IP addresses.
Supported Platforms
-------------------

It should work on most Unix systems (including Linux with kernel 2.6.22+) but currently not on Windows.
It should work on most Unix systems (including Linux with kernel 2.6.22+ and macOS) but currently not on Windows.

Example
-------
Expand Down
2 changes: 1 addition & 1 deletion requirements-lint.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
pylint==2.12.2
black~=22.1.0
black~=22.3.0
mypy==0.931
mypy-extensions==0.4.3
types-setuptools
6 changes: 3 additions & 3 deletions test/back2back_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ class BasicTestSocketCan(Back2BackTestCase):
# this doesn't even work on Travis CI for macOS; for example, see
# https://travis-ci.org/github/hardbyte/python-can/jobs/745389871
@unittest.skipUnless(
IS_UNIX and not IS_OSX,
IS_UNIX and not (IS_CI and IS_OSX),
"only supported on Unix systems (but not on macOS at Travis CI and GitHub Actions)",
)
class BasicTestUdpMulticastBusIPv4(Back2BackTestCase):
Expand All @@ -303,8 +303,8 @@ def test_unique_message_instances(self):
# this doesn't even work for loopback multicast addresses on Travis CI; for example, see
# https://travis-ci.org/github/hardbyte/python-can/builds/745065503
@unittest.skipUnless(
IS_UNIX and not (IS_TRAVIS or IS_OSX),
"only supported on Unix systems (but not on Travis CI; and not an macOS at GitHub Actions)",
IS_UNIX and not (IS_TRAVIS or (IS_CI and IS_OSX)),
"only supported on Unix systems (but not on Travis CI; and not on macOS at GitHub Actions)",
)
class BasicTestUdpMulticastBusIPv6(Back2BackTestCase):

Expand Down