From b077f76b1d70d3150ffc5e9a5d5077c7618642e1 Mon Sep 17 00:00:00 2001 From: chrysn Date: Fri, 17 Apr 2026 17:19:41 +0200 Subject: [PATCH 01/18] site: Pull Uri-Path-Abbrev handling into dedicated function --- aiocoap/resource.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/aiocoap/resource.py b/aiocoap/resource.py index f9643860..5ad39d63 100644 --- a/aiocoap/resource.py +++ b/aiocoap/resource.py @@ -467,18 +467,12 @@ async def render_to_pipe(self, request: Pipe): # need to agree on how the path is processed, even before we go into # the site dispatch -- so we better resolve this now (exercising our # liberties as a library proxy) and provide a consistent view. - if request.request.opt.uri_path_abbrev is not None: - if request.request.opt.uri_path: - # Conflicting options - raise error.BadOption() - try: - request.request.opt.uri_path = uri_path_abbrev._map[ - request.request.opt.uri_path_abbrev - ] - except KeyError: - # Unknown option - raise error.BadOption() from None - request.request.opt.uri_path_abbrev = None + # + # Factored out because other components that don't yet go through + # render_to_pipe + # + # Workaround-For: https://github.com/chrysn/aiocoap/issues/414 + _expand_upa(request.request) try: child, subrequest = self._find_child_and_pathstripped_message( @@ -491,3 +485,17 @@ async def render_to_pipe(self, request: Pipe): # It probably is. request.request = subrequest return await child.render_to_pipe(request) + + +def _expand_upa(request): + """Expands the Uri-Path-Abbrev option in-place, or raises BadOption""" + if request.opt.uri_path_abbrev is not None: + if request.opt.uri_path: + # Conflicting options + raise error.BadOption() + try: + request.opt.uri_path = uri_path_abbrev._map[request.opt.uri_path_abbrev] + except KeyError: + # Unknown option + raise error.BadOption() from None + request.opt.uri_path_abbrev = None From 47fb9ba5f684708c57e9eaa448629930b441f9ac Mon Sep 17 00:00:00 2001 From: chrysn Date: Fri, 17 Apr 2026 17:20:05 +0200 Subject: [PATCH 02/18] proxy: Handle Uri-Path-Abbrev This is a bit excessive in terms of the cases it covers, but currently necessary because the regular Site handling of UPA is otherwise not visible to aiocoap-rd. --- aiocoap/proxy/server.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/aiocoap/proxy/server.py b/aiocoap/proxy/server.py index 0b9bdcbc..6f8098fa 100644 --- a/aiocoap/proxy/server.py +++ b/aiocoap/proxy/server.py @@ -153,6 +153,15 @@ async def render(self, request): # resolution tree (it can't deal with None requests, which are used among # proxy implementations) async def render_to_pipe(self, pipe: Pipe) -> None: + # I'd rather not expand unconditionally, but if we don't do this here, + # we're too deep into render() etc to get out again. This is working on + # the assumption that all proxies are really at root. + # + # Workaround-For: https://github.com/chrysn/aiocoap/issues/414 + try: + resource._expand_upa(pipe.request) + except error.BadOption: + pass await resource.Resource.render_to_pipe(self, pipe) # type: ignore From e016cba567f87ef685e2b3e899419ce83e4b39c7 Mon Sep 17 00:00:00 2001 From: chrysn Date: Fri, 17 Apr 2026 21:38:34 +0200 Subject: [PATCH 03/18] rd: Fix AttributeError on message.request.opt The fallback check was erroneous as every Message has a request (but it may be None). This triggered when data POSTed to the registration resource had no content format. --- aiocoap/cli/rd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiocoap/cli/rd.py b/aiocoap/cli/rd.py index e9e8167f..a4dcd6f5 100644 --- a/aiocoap/cli/rd.py +++ b/aiocoap/cli/rd.py @@ -407,7 +407,7 @@ def link_format_from_message(message): This expects an explicit media type set on the response (or was explicitly requested) """ certain_format = message.opt.content_format - if certain_format is None and hasattr(message, "request"): + if certain_format is None and message.request is not None: certain_format = message.request.opt.accept try: if certain_format == ContentFormat.LINKFORMAT: From 79e0c2a146de7ee1f65cd58ff643fc10a7151a54 Mon Sep 17 00:00:00 2001 From: chrysn Date: Fri, 17 Apr 2026 22:19:02 +0200 Subject: [PATCH 04/18] udp6/MessageInterface: provide __repr__ that tells the IP/port --- aiocoap/transports/udp6.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/aiocoap/transports/udp6.py b/aiocoap/transports/udp6.py index 312b5225..9e83bd6c 100644 --- a/aiocoap/transports/udp6.py +++ b/aiocoap/transports/udp6.py @@ -305,6 +305,10 @@ def __init__(self, bind, log, loop): "_remote_being_sent_to", default=None ) + def __repr__(self): + socket = self.transport.get_extra_info("socket") + return f"<{type(self).__name__} at {id(self):#x} bound to {socket.getsockname() if socket else '(nothing)'}>" + def _local_port(self): # FIXME: either raise an error if this is 0, or send a message to self # to force the OS to decide on a port. Right now, this reports wrong From 615b45be54abe82fb6c8597f85e32c0901cf5035 Mon Sep 17 00:00:00 2001 From: chrysn Date: Fri, 17 Apr 2026 23:26:51 +0200 Subject: [PATCH 05/18] rd: Render errors from role reversal --- aiocoap/cli/rd.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/aiocoap/cli/rd.py b/aiocoap/cli/rd.py index a4dcd6f5..7be00eee 100644 --- a/aiocoap/cli/rd.py +++ b/aiocoap/cli/rd.py @@ -692,8 +692,12 @@ async def process_request(self, network_remote, registration_parameters): get.code = aiocoap.GET get.opt.accept = ContentFormat.LINKFORMAT - # not trying to catch anything here -- the errors are most likely well renderable into the final response - response = await self.context.request(get).response_raising + try: + response = await self.context.request(get).response_raising + except error.ResponseWrappingError as e: + # Note that ResponseWrappingError is *not* itself renderable + raise error.BadRequest(f"got error {e.coapmessage.code.dotted}") + # No handling needed here: This raises renderable error links = link_format_from_message(response) if self.registration_warning: From cb828a23db2c188f564d0e649c37cdb06dac0dc5 Mon Sep 17 00:00:00 2001 From: chrysn Date: Fri, 17 Apr 2026 23:44:31 +0200 Subject: [PATCH 06/18] slipmux: Fix recognize_remote The failure to recognize the own remote is covered up by determine_remote creating a sufficiently similar object after it was not recognized in the fast path. This change prepares for remotes being reused more. --- aiocoap/transports/slipmux.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aiocoap/transports/slipmux.py b/aiocoap/transports/slipmux.py index cd39a761..406072fa 100644 --- a/aiocoap/transports/slipmux.py +++ b/aiocoap/transports/slipmux.py @@ -359,6 +359,7 @@ async def recognize_remote(self, remote): # See determine_remote: from the current API, this is the only # asynchronous point before a send. await self._get(remote) + return True return False async def determine_remote(self, message): From b0c2bb74d415bdc77de962c2b3b67b861f2daf35 Mon Sep 17 00:00:00 2001 From: chrysn Date: Fri, 17 Apr 2026 23:56:45 +0200 Subject: [PATCH 07/18] rd/proxy: Support draft-amsuess-core-resource-directory-extensions-11's proxy=yes --- aiocoap/cli/rd.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/aiocoap/cli/rd.py b/aiocoap/cli/rd.py index 7be00eee..36eeae47 100644 --- a/aiocoap/cli/rd.py +++ b/aiocoap/cli/rd.py @@ -324,9 +324,15 @@ def initialize_endpoint(self, network_remote, registration_parameters): proxy = pop_single_arg(registration_parameters, "proxy") - if proxy is not None and proxy != "on": + if proxy is not None and proxy not in ("on", "yes", "ondemand"): raise error.BadRequest("Unsupported proxy value") + if proxy == "on": + # FIXME: Deprecate more visibly -- but this has been in the impl + # for quite some time, and is presumably used, given that the + # missing "yes" went uncontested for years. + self.log.warning("Client uses old proprietary value proxy=on") + key = (ep, d) if static_registration_parameters.pop("proxy", None): From fb41a521fb61e16b01f497980b1e9633647d9acd Mon Sep 17 00:00:00 2001 From: chrysn Date: Fri, 24 Apr 2026 00:11:22 +0200 Subject: [PATCH 08/18] tests: Relax timing for connection-refused error --- tests/test_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_client.py b/tests/test_client.py index bbbec076..2a46eefc 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -72,7 +72,7 @@ async def test_uri_parser2(self): resp = self.client.request(request).response try: # give the request some time to finish getaddrinfo - result = await asyncio.as_completed([resp], timeout=0.1).__next__() + result = await asyncio.as_completed([resp], timeout=0.5).__next__() except aiocoap.error.NetworkError as e: # This is a bit stricter than what the API indicates, but hey, we # can still relax the tests. From 1b39d2aa63d55a4ce855999011193f98c723bf60 Mon Sep 17 00:00:00 2001 From: chrysn Date: Fri, 24 Apr 2026 22:54:23 +0200 Subject: [PATCH 09/18] tests/blockwise: Set test_client_hints to slow_empty_ack The test counts messages, and on loaded systems, it can easily trigger due to the high number of repetitions. --- tests/test_blockwise.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_blockwise.py b/tests/test_blockwise.py index 065c91bf..c12f0d77 100644 --- a/tests/test_blockwise.py +++ b/tests/test_blockwise.py @@ -15,6 +15,7 @@ WithTestServer, WithClient, no_warnings, + slow_empty_ack, BigResource, BasicTestingSite, ) @@ -74,6 +75,10 @@ async def test_sequential(self): ) @no_warnings + # because we're counting responses, and on slow loaded systems a response + # might all of a sudden take 0.2s -- it's unlikely, but we're counting 80 + # of them. + @slow_empty_ack() async def test_client_hints(self): """Test whether a handle_blockwise=True request takes a block2 option set in it as a hint to start requesting with a low size right away From ce544c46c0086941d80f422aa570adc7354cb090 Mon Sep 17 00:00:00 2001 From: chrysn Date: Thu, 23 Apr 2026 14:44:51 +0200 Subject: [PATCH 10/18] interfaces: Clarifications around RequestInterface --- aiocoap/interfaces.py | 2 ++ aiocoap/protocol.py | 2 +- aiocoap/transports/oscore.py | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/aiocoap/interfaces.py b/aiocoap/interfaces.py index c142af89..15378220 100644 --- a/aiocoap/interfaces.py +++ b/aiocoap/interfaces.py @@ -272,6 +272,8 @@ class TokenManager(metaclass=abc.ABCMeta): class RequestInterface(metaclass=abc.ABCMeta): + """A transport of CoAP.""" + @abc.abstractmethod async def fill_or_recognize_remote(self, message): pass diff --git a/aiocoap/protocol.py b/aiocoap/protocol.py index 1a0e36b5..52d12ebe 100644 --- a/aiocoap/protocol.py +++ b/aiocoap/protocol.py @@ -131,7 +131,7 @@ def __init__( self.serversite = serversite - self.request_interfaces = [] + self.request_interfaces: list[interfaces.RequestInterface] = [] self.client_credentials = client_credentials or CredentialsMap() self.server_credentials = server_credentials or CredentialsMap() diff --git a/aiocoap/transports/oscore.py b/aiocoap/transports/oscore.py index fe7cc2af..fef25c4e 100644 --- a/aiocoap/transports/oscore.py +++ b/aiocoap/transports/oscore.py @@ -7,7 +7,7 @@ # reasons why RequestInterface, TokenInterface and MessageInterface were split # in the first place. -"""This module implements a RequestProvider for OSCORE. As such, it takes +"""This module implements a RequestInterface for OSCORE. As such, it takes routing ownership of requests that it has a security context available for, and sends off the protected messages via another transport. @@ -114,7 +114,7 @@ def blockwise_key(self): return (self.underlying_address.blockwise_key, detail) -class TransportOSCORE(interfaces.RequestProvider): +class TransportOSCORE(interfaces.RequestInterface): def __init__(self, context, forward_context): self._context = context self._wire = forward_context From 313b3a3fd72933a0bf91cf56bf21f9b55588a0f2 Mon Sep 17 00:00:00 2001 From: chrysn Date: Thu, 23 Apr 2026 15:23:31 +0200 Subject: [PATCH 11/18] interfaces: Split fill_and_recognize_remote The functions have been split that way already in the lower parts of MID handling, and this allows for a future two-pass interface. This has a regression in that fill_or_recognize_remote now fails when called by a user; a determination has yet to be made whether that is even considered public. --- aiocoap/interfaces.py | 24 +++++++++++++++++++++--- aiocoap/messagemanager.py | 14 +++++--------- aiocoap/protocol.py | 7 ++++--- aiocoap/tokenmanager.py | 7 +++++-- aiocoap/transports/oscore.py | 19 ++++++++++--------- aiocoap/transports/tcp.py | 30 ++++++++++-------------------- aiocoap/transports/ws.py | 16 ++++++---------- 7 files changed, 61 insertions(+), 56 deletions(-) diff --git a/aiocoap/interfaces.py b/aiocoap/interfaces.py index 15378220..0e3f43b8 100644 --- a/aiocoap/interfaces.py +++ b/aiocoap/interfaces.py @@ -258,13 +258,22 @@ def send_message( Currently, it is up to the TokenInterface to unset the no_response option in response messages, and to possibly not send them.""" - @abc.abstractmethod async def fill_or_recognize_remote(self, message): """Return True if the message is recognized to already have a .remote managedy by this TokenInterface, or return True and set a .remote on message if it should (by its unresolved remote or Uri-* options) be routed through this TokenInterface, or return False otherwise.""" + raise RuntimeError("FIXME: Warn / deprecate and implemnent fallback") + + @abc.abstractmethod + async def recognize_remote(self, message): + """Like :meth:`RequestInterface.recognize_remote`""" + + @abc.abstractmethod + async def determine_remote(self, message): + """Like :meth:`RequestInterface.determine_remote`""" + class TokenManager(metaclass=abc.ABCMeta): # to be described in full; at least there is a dispatch_error in analogy to MessageManager's @@ -274,14 +283,23 @@ class TokenManager(metaclass=abc.ABCMeta): class RequestInterface(metaclass=abc.ABCMeta): """A transport of CoAP.""" - @abc.abstractmethod async def fill_or_recognize_remote(self, message): - pass + raise RuntimeError("FIXME: Warn / deprecate and implemnent fallback") @abc.abstractmethod def request(self, request: Pipe): pass + @abc.abstractmethod + async def recognize_remote(self, message): + """Return True if the remote of this message is currently expected to + be usable with this transport.""" + + @abc.abstractmethod + async def determine_remote(self, message): + """Return a remote if the transport expects to be able to deliver the + request based on its URI components.""" + class RequestProvider(metaclass=abc.ABCMeta): """ diff --git a/aiocoap/messagemanager.py b/aiocoap/messagemanager.py index 6cf4bbb1..6489908a 100644 --- a/aiocoap/messagemanager.py +++ b/aiocoap/messagemanager.py @@ -402,15 +402,11 @@ def _process_response(self, response): # outgoing messages # - async def fill_or_recognize_remote(self, message): - if message.remote is not None: - if await self.message_interface.recognize_remote(message.remote): - return True - remote = await self.message_interface.determine_remote(message) - if remote is not None: - message.remote = remote - return True - return False + async def recognize_remote(self, message): + return await self.message_interface.recognize_remote(message.remote) + + async def determine_remote(self, message): + return await self.message_interface.determine_remote(message) def send_message(self, message, messageerror_monitor): """Encode and send message. This takes care of retransmissions (if diff --git a/aiocoap/protocol.py b/aiocoap/protocol.py index 52d12ebe..43fcec09 100644 --- a/aiocoap/protocol.py +++ b/aiocoap/protocol.py @@ -541,9 +541,10 @@ async def shutdown(self): # populated). async def find_remote_and_interface(self, message): if message.remote is None: - raise error.MissingRemoteError() - for ri in self.request_interfaces: - if await ri.fill_or_recognize_remote(message): + if await ri.recognize_remote(message): + return ri + if remote := await ri.determine_remote(message): + message.remote = remote return ri raise error.NoRequestInterface() diff --git a/aiocoap/tokenmanager.py b/aiocoap/tokenmanager.py index 1370c700..ab3ee6ee 100644 --- a/aiocoap/tokenmanager.py +++ b/aiocoap/tokenmanager.py @@ -211,8 +211,11 @@ def process_response(self, response): # implement RequestInterface # - async def fill_or_recognize_remote(self, message): - return await self.token_interface.fill_or_recognize_remote(message) + async def recognize_remote(self, message): + return await self.token_interface.recognize_remote(message) + + async def determine_remote(self, message): + return await self.token_interface.determine_remote(message) def request(self, request): if self.outgoing_requests is None: diff --git a/aiocoap/transports/oscore.py b/aiocoap/transports/oscore.py index fef25c4e..3d1674ef 100644 --- a/aiocoap/transports/oscore.py +++ b/aiocoap/transports/oscore.py @@ -137,35 +137,36 @@ def __init__(self, context, forward_context): # implement RequestInterface # - async def fill_or_recognize_remote(self, message): - if isinstance(message.remote, OSCOREAddress): - return True + async def recognize_remote(self, message): + # There is just one of those + return isinstance(message.remote, OSCOREAddress) + + async def determine_remote(self, message): if message.opt.oscore is not None: # double oscore is not specified; using this fact to make `._wire # is ._context` an option - return False + return None if message.opt.uri_path == (".well-known", "edhoc"): # FIXME better criteria based on next-hop? - return False + return None try: secctx = self._context.client_credentials.credentials_from_request(message) except credentials.CredentialsMissingError: - return False + return None # FIXME: it'd be better to have a "get me credentials *of this type* if they exist" if isinstance(secctx, oscore.CanProtect) or isinstance( secctx, edhoc.EdhocCredentials ): - message.remote = OSCOREAddress(secctx, message.remote) self.log.debug( "Selecting OSCORE transport based on context %r for new request %r", secctx, message, ) - return True + return OSCOREAddress(secctx, message.remote) else: - return False + return None def request(self, request): t = self.loop.create_task( diff --git a/aiocoap/transports/tcp.py b/aiocoap/transports/tcp.py index 521a063d..d9c5fc94 100644 --- a/aiocoap/transports/tcp.py +++ b/aiocoap/transports/tcp.py @@ -171,7 +171,7 @@ def connection_lost(self, exc): # * send event through pool so it can propagate the error to all # requests on the same remote # * mark the address as erroneous so it won't be recognized by - # fill_or_recognize_remote + # determine_remote self._ctx._dispatch_error(self, exc) @@ -342,15 +342,12 @@ def _evict_from_pool(self, connection): # implementing TokenInterface - async def fill_or_recognize_remote(self, message): - if ( - message.remote is not None - and isinstance(message.remote, TcpConnection) - and message.remote._ctx is self - ): - return True + async def recognize_remote(self, message): + return isinstance(message.remote, TcpConnection) and message.remote._ctx is self - return False + async def determine_remote(self, message): + # We're a server at the transport level + return None async def shutdown(self): self.log.debug("Shutting down server %r", self) @@ -450,24 +447,17 @@ async def create_client_transport( # implementing TokenInterface - async def fill_or_recognize_remote(self, message): - if ( - message.remote is not None - and isinstance(message.remote, TcpConnection) - and message.remote._ctx is self - ): - return True + async def recognize_remote(self, message): + return isinstance(message.remote, TcpConnection) and message.remote._ctx is self + async def determine_remote(self, message): if message.requested_scheme == self._scheme: # FIXME: This could pool outgoing connections. # (Checking if an incoming connection is a pool candidate is # probably overkill because even if a URI can be constructed from a # ephemeral client port, nobody but us can use it, and we can just # set .remote). - message.remote = await self._spawn_protocol(message) - return True - - return False + return await self._spawn_protocol(message) async def shutdown(self): self.log.debug("Shutting down any outgoing connections on on %r", self) diff --git a/aiocoap/transports/ws.py b/aiocoap/transports/ws.py index 599bc243..f250a747 100644 --- a/aiocoap/transports/ws.py +++ b/aiocoap/transports/ws.py @@ -402,29 +402,25 @@ async def _connect_task(self, key: PoolKey): # Implementation of TokenInterface - async def fill_or_recognize_remote(self, message): - if isinstance(message.remote, WSRemote) and message.remote._pool() is self: - return True + async def recognize_remote(self, message): + return isinstance(message.remote, WSRemote) and message.remote._pool() is self + async def determine_remote(self, message): if message.requested_scheme in ("coap+ws", "coaps+ws"): key = PoolKey(message.requested_scheme, message.remote.hostinfo) if key in self._pool: - message.remote = self._pool[key] # It would have handled the ConnectionClosed on recv and # removed itself if it was not live. - return True + return self._pool[key] if key not in self._outgoing_starting: self._connect(key) # It's a bit unorthodox to wait for an (at least partially) - # established connection in fill_or_recognize_remote, but it's + # established connection in determine_remote, but it's # not completely off off either, and it makes it way easier to # not have partially initialized remotes around - message.remote = await self._outgoing_starting[key] - return True - - return False + return await self._outgoing_starting[key] def send_message(self, message, messageerror_monitor): # Ignoring messageerror_monitor: CoAP over reliable transports has no From 43dac7ae578e56f004efd6cdbf91935424bb5dfa Mon Sep 17 00:00:00 2001 From: chrysn Date: Thu, 23 Apr 2026 15:25:00 +0200 Subject: [PATCH 12/18] transport selection: Use two-pass strategy This allows using role reversal when multiple ports are in use: Only if no transport "feels responsible" for a message, other transports (starting from the first) are asked whether they can route it. --- aiocoap/protocol.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/aiocoap/protocol.py b/aiocoap/protocol.py index 43fcec09..fb79cfd1 100644 --- a/aiocoap/protocol.py +++ b/aiocoap/protocol.py @@ -541,8 +541,11 @@ async def shutdown(self): # populated). async def find_remote_and_interface(self, message): if message.remote is None: + raise error.MissingRemoteError() + for ri in self.request_interfaces: if await ri.recognize_remote(message): return ri + for ri in self.request_interfaces: if remote := await ri.determine_remote(message): message.remote = remote return ri From 9718b81e9fd05d2ba0999e1706802478c100440d Mon Sep 17 00:00:00 2001 From: chrysn Date: Thu, 23 Apr 2026 21:03:00 +0200 Subject: [PATCH 13/18] interfaces: Provide fill_or_recognize_remote On the off chance an application is using this even though it was not described as stable, things should (!) keep working with a warning. --- aiocoap/interfaces.py | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/aiocoap/interfaces.py b/aiocoap/interfaces.py index 0e3f43b8..1de31ef0 100644 --- a/aiocoap/interfaces.py +++ b/aiocoap/interfaces.py @@ -259,12 +259,19 @@ def send_message( option in response messages, and to possibly not send them.""" async def fill_or_recognize_remote(self, message): - """Return True if the message is recognized to already have a .remote - managedy by this TokenInterface, or return True and set a .remote on - message if it should (by its unresolved remote or Uri-* options) be - routed through this TokenInterface, or return False otherwise.""" + """Deprecated; like :meth:`RequestInterface.fill_or_recognize_remote`""" - raise RuntimeError("FIXME: Warn / deprecate and implemnent fallback") + warnings.warn( + "fill_or_recognize_remote has been split into recognize_remote and determine_remote", + DeprecationWarning, + ) + if self.recognize_remote(message): + return True + remote = self.determine_remote(message) + if remote is not None: + message.remote = remote + return True + return False @abc.abstractmethod async def recognize_remote(self, message): @@ -284,7 +291,29 @@ class RequestInterface(metaclass=abc.ABCMeta): """A transport of CoAP.""" async def fill_or_recognize_remote(self, message): - raise RuntimeError("FIXME: Warn / deprecate and implemnent fallback") + """Deprecated method to perform + :meth:`RequestInterface.recognize_remote` and + :meth:`RequestInterface.determine_remote` in one go. + + The implementation is only provided for compatibility, and will be + removed after the next release. + + Return True if the message is recognized to already have a .remote + managedy by this TokenInterface, or return True and set a .remote on + message if it should (by its unresolved remote or Uri-* options) be + routed through this TokenInterface, or return False otherwise.""" + + warnings.warn( + "fill_or_recognize_remote has been split into recognize_remote and determine_remote", + DeprecationWarning, + ) + if self.recognize_remote(message): + return True + remote = self.determine_remote(message) + if remote is not None: + message.remote = remote + return True + return False @abc.abstractmethod def request(self, request: Pipe): From 299a7fd9ba50bf8c96fd82a83a40a38771f2bb67 Mon Sep 17 00:00:00 2001 From: chrysn Date: Thu, 23 Apr 2026 21:58:00 +0200 Subject: [PATCH 14/18] tests: Cover goal of recent fixes --- tests/test_multitransport_rolereversal.py | 61 +++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/test_multitransport_rolereversal.py diff --git a/tests/test_multitransport_rolereversal.py b/tests/test_multitransport_rolereversal.py new file mode 100644 index 00000000..77b9fc6e --- /dev/null +++ b/tests/test_multitransport_rolereversal.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: Christian Amsüss and the aiocoap contributors +# +# SPDX-License-Identifier: MIT + +import unittest + +import aiocoap +from aiocoap import resource + +from .test_server import WhoAmI + + +class RoleReverseAndQueryWhoAmI(resource.Resource): + context: aiocoap.Context + + async def render_post(self, request): + counter_request = aiocoap.Message(code=aiocoap.GET, uri_path=["whoami"]) + counter_request.remote = request.remote + response = await self.context.request(counter_request).response_raising + # Maybe we should raise a warning but ignore this... + response.direction = aiocoap.message.Direction.OUTGOING + # ... like we do with those (just clearing them for the warnings) + response.mid = None + response.token = None + return response + + +class TestMultitransportRolereversal(unittest.IsolatedAsyncioTestCase): + """A test family for whether even when multiple transports are active, role + reversal works as it should.""" + + async def test_udp6_multiport(self): + """Let a client connect to a udp6 multiply bound server, and verify + that role reversal comes from the right place. + + This is a bit of a funny test because it enforces UDP6 on one side + while using the defaults on the other.""" + + checkme = RoleReverseAndQueryWhoAmI() + serversite = resource.Site() + serversite.add_resource(["checkme"], checkme) + + serverctx = await aiocoap.Context.create_server_context( + serversite, + transports={ + "udp6": { + "bind": ["[::]:5001", "[::]:5002"], + } + }, + ) + checkme.context = serverctx + + clientsite = resource.Site() + clientsite.add_resource(["whoami"], WhoAmI()) + clientctx = await aiocoap.Context.create_client_context() + clientctx.serversite = clientsite + + response = await clientctx.request( + aiocoap.Message(code=aiocoap.POST, uri="coap://localhost:5002/checkme") + ).response_raising + self.assertRegex(response.payload.decode("utf8"), ":5002") From ba7166a60e801c6af3dff21f424ca273b42fd0d8 Mon Sep 17 00:00:00 2001 From: chrysn Date: Wed, 29 Apr 2026 10:32:56 +0200 Subject: [PATCH 15/18] tests: Run on GitHub Windows runners --- .github/workflows/test-on-windows.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/test-on-windows.yml diff --git a/.github/workflows/test-on-windows.yml b/.github/workflows/test-on-windows.yml new file mode 100644 index 00000000..e38a86f0 --- /dev/null +++ b/.github/workflows/test-on-windows.yml @@ -0,0 +1,24 @@ +name: Test Python package + +on: [push] + +jobs: + build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v5 + with: + # not bothering with old versions on Windows; this is tested more for + # curiosity anyway. + python-version: 3.14 + - name: Install dependencies + run: | + pip install tox + python3 -m tox --notest -e py314-noextras + - name: Run tests + # -allextras would be nice, but DTLSSocket doesn't built + # out-of-the-box, and I don't want to cut it up too much + run: | + python3 -m tox -e py314-noextras From 45e01046e8d3e565dc79594690c7fd3b1a512372 Mon Sep 17 00:00:00 2001 From: chrysn Date: Wed, 29 Apr 2026 10:49:56 +0200 Subject: [PATCH 16/18] tests/commandline: Be robust against line endings --- tests/test_commandline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_commandline.py b/tests/test_commandline.py index 067d2f5b..259f57a1 100644 --- a/tests/test_commandline.py +++ b/tests/test_commandline.py @@ -260,8 +260,8 @@ async def test_location(self): ) # Or similar; what matters is that the URI is properly recomposed self.assertEqual( - b"Location options indicate new resource: /create/here/?this=this&that=that\n", - diagnostic_post, + b"Location options indicate new resource: /create/here/?this=this&that=that", + diagnostic_post.strip(), ) @no_warnings From a7ddca70a65724f02eb172f4e6f164f8666ae2d7 Mon Sep 17 00:00:00 2001 From: chrysn Date: Wed, 29 Apr 2026 11:42:43 +0200 Subject: [PATCH 17/18] udp6: Make repr more robust --- aiocoap/transports/udp6.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/aiocoap/transports/udp6.py b/aiocoap/transports/udp6.py index 9e83bd6c..591eee1c 100644 --- a/aiocoap/transports/udp6.py +++ b/aiocoap/transports/udp6.py @@ -307,7 +307,15 @@ def __init__(self, bind, log, loop): def __repr__(self): socket = self.transport.get_extra_info("socket") - return f"<{type(self).__name__} at {id(self):#x} bound to {socket.getsockname() if socket else '(nothing)'}>" + if socket is not None: + try: + sockname = socket.getsockname() + except OSError as e: + # This has only ever failed on Windows + sockname = f"(getsockname failed: {e})" + else: + sockname = "(no socket in extra info)" + return f"<{type(self).__name__} at {id(self):#x} bound to {sockname}>" def _local_port(self): # FIXME: either raise an error if this is 0, or send a message to self From d00143d19e0bdb6c1c967c85ad70917e9839070c Mon Sep 17 00:00:00 2001 From: chrysn Date: Wed, 29 Apr 2026 11:31:33 +0200 Subject: [PATCH 18/18] DNM: Hack in recvpktinfo for Windows --- aiocoap/defaults.py | 10 +++++----- aiocoap/transports/udp6.py | 13 ++++++++++++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/aiocoap/defaults.py b/aiocoap/defaults.py index 6cf0762d..67eef92c 100644 --- a/aiocoap/defaults.py +++ b/aiocoap/defaults.py @@ -70,11 +70,11 @@ def get_default_clienttransports(*, loop=None, use_env=True): # There, the remaining ones would just raise NotImplementedError all over the place return - if sys.platform != "linux": - # udp6 was never reported to work on anything but linux; would happily - # add more platforms. - yield "simple6" - return + # if sys.platform != "linux": + # # udp6 was never reported to work on anything but linux; would happily + # # add more platforms. + # yield "simple6" + # return # on android it seems that it's only the AI_V4MAPPED that causes trouble, # that should be manageable in udp6 too. diff --git a/aiocoap/transports/udp6.py b/aiocoap/transports/udp6.py index 591eee1c..19f74d99 100644 --- a/aiocoap/transports/udp6.py +++ b/aiocoap/transports/udp6.py @@ -401,11 +401,22 @@ async def prepare_transport_endpoints( sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) try: - sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_RECVPKTINFO, 1) + # On Windows they call it different, but + # + # describes what it does, and that's the same as + # RECVPKTINFO everywhere else. + pktinfo_option = ( + socket.IPV6_RECVPKTINFO + if hasattr(socket, "IPV6_RECVPKTINFO") + else socket.IPV6_PKTINFO + ) except NameError: raise RuntimeError( "RFC3542 PKTINFO flags are unavailable, unable to create a udp6 transport." ) + + sock.setsockopt(socket.IPPROTO_IPV6, pktinfo_option, 1) + if socknumbers.HAS_RECVERR: sock.setsockopt( socket.IPPROTO_IPV6, socknumbers.IPV6_RECVERR, 1