From 5bda0f77015f95760cba36e3cc3b0471177f26ec Mon Sep 17 00:00:00 2001 From: Scott K Logan Date: Thu, 4 Sep 2025 14:10:44 -0500 Subject: [PATCH 1/3] Use actual host and port when printing daemon endpoint This is particularly important if '0' is used as a port because the system will dynamically assign an unused port to the socket. There should be no behavioral changes as a result of this modification. Signed-off-by: Scott K Logan --- ros2cli/ros2cli/daemon/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ros2cli/ros2cli/daemon/__init__.py b/ros2cli/ros2cli/daemon/__init__.py index 1c00795bf..7ff2825b7 100644 --- a/ros2cli/ros2cli/daemon/__init__.py +++ b/ros2cli/ros2cli/daemon/__init__.py @@ -57,6 +57,9 @@ def make_xmlrpc_server() -> LocalXMLRPCServer: """Make local XMLRPC server listening over ros2cli daemon's default port.""" address = get_address() + assert urlparse(get_xmlrpc_server_url()).scheme == 'http', \ + 'Only http XMLRPC servers are supported at this time.' + return LocalXMLRPCServer( address, logRequests=False, requestHandler=RequestHandler, @@ -143,7 +146,11 @@ def shutdown_handler(): shutdown = True server.register_function(shutdown_handler, 'system.shutdown') - print('Serving XML-RPC on ' + get_xmlrpc_server_url(server.server_address)) + server_path = server.RequestHandlerClass.rpc_paths[0] + server_hostname, server_port = server.server_address + server_url = f'http://{server_hostname}:{server_port}{server_path}' + + print('Serving XML-RPC on ' + server_url) try: while rclpy.ok() and not shutdown: server.handle_request() From 82e97d26508d0fd5f4443cf865af1f7072c54fae Mon Sep 17 00:00:00 2001 From: Scott K Logan Date: Thu, 7 Aug 2025 13:01:08 -0500 Subject: [PATCH 2/3] Invert daemon URL function chain To better support a mechanism to override the ROS 2 daemon URL, this change inverts the chain of functions used to determine what hostname, port, and URL should be used. There should be no behavioral changes as a result of this modification. Signed-off-by: Scott K Logan --- ros2cli/ros2cli/daemon/__init__.py | 42 ++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/ros2cli/ros2cli/daemon/__init__.py b/ros2cli/ros2cli/daemon/__init__.py index 7ff2825b7..9676e8da3 100644 --- a/ros2cli/ros2cli/daemon/__init__.py +++ b/ros2cli/ros2cli/daemon/__init__.py @@ -15,6 +15,7 @@ import argparse import os import time +from urllib.parse import urlparse import uuid import rclpy @@ -31,26 +32,45 @@ from ros2cli.xmlrpc.local_server import SimpleXMLRPCRequestHandler +def get_xmlrpc_server_url(address=None): + if address: + host, port = address + else: + host = '127.0.0.1' + port = 11511 + int(os.environ.get('ROS_DOMAIN_ID', 0)) + return f'http://{host}:{port}/ros2cli/' + + def get_port(): - base_port = 11511 - base_port += int(os.environ.get('ROS_DOMAIN_ID', 0)) - return base_port + url = get_xmlrpc_server_url() + return urlparse(url).port def get_address(): - return '127.0.0.1', get_port() + url = get_xmlrpc_server_url() + parsed_url = urlparse(url) + return parsed_url.hostname, parsed_url.port + + +def get_path(): + url = get_xmlrpc_server_url() + return urlparse(url).path class RequestHandler(SimpleXMLRPCRequestHandler): - rpc_paths = ('/ros2cli/',) + class _GetRpcPaths(property): + """ + Getter for the RPC paths value to use on the request handler. -def get_xmlrpc_server_url(address=None): - if not address: - address = get_address() - host, port = address - path = RequestHandler.rpc_paths[0] - return f'http://{host}:{port}{path}' + We need this property to work when accessed from the class reference, + so we can't just use ``@property`` here. + """ + + def __get__(self, instance, owner): + return (get_path(),) + + rpc_paths = _GetRpcPaths() def make_xmlrpc_server() -> LocalXMLRPCServer: From 63c17eb6fe31b0617668b9c79c34494d0ef06b63 Mon Sep 17 00:00:00 2001 From: Scott K Logan Date: Thu, 4 Sep 2025 14:15:10 -0500 Subject: [PATCH 3/3] Add environment variable to override daemon endpoint This change adds a new environment variable 'ROS2_DAEMON_SERVER_URL' which can be used to change the expected endpoint for communicating with the ROS 2 daemon process. Unspecified parts of the URL will be replaced with default values, so it's possible to omit the scheme, host, and port to retain default behavior while still overriding other parts of the URL. Signed-off-by: Scott K Logan --- ros2cli/ros2cli/daemon/__init__.py | 25 ++++++++++++++++------ ros2cli/test/test_ros2cli_daemon.py | 32 +++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/ros2cli/ros2cli/daemon/__init__.py b/ros2cli/ros2cli/daemon/__init__.py index 9676e8da3..f021662c4 100644 --- a/ros2cli/ros2cli/daemon/__init__.py +++ b/ros2cli/ros2cli/daemon/__init__.py @@ -16,6 +16,7 @@ import os import time from urllib.parse import urlparse +from urllib.parse import urlunparse import uuid import rclpy @@ -32,13 +33,25 @@ from ros2cli.xmlrpc.local_server import SimpleXMLRPCRequestHandler +SERVER_URL_VARIABLE_NAME = 'ROS2_DAEMON_SERVER_URL' + + def get_xmlrpc_server_url(address=None): - if address: - host, port = address - else: - host = '127.0.0.1' - port = 11511 + int(os.environ.get('ROS_DOMAIN_ID', 0)) - return f'http://{host}:{port}/ros2cli/' + url = urlparse( + os.environ.get(SERVER_URL_VARIABLE_NAME) or '/ros2cli/', + scheme='http') + + if address is None: + address = ( + url.hostname or '127.0.0.1', + str( + url.port + if url.port not in (None, '') + else (11511 + int(os.environ.get('ROS_DOMAIN_ID', 0))) + ), + ) + + return urlunparse(url._replace(netloc=':'.join(address))) def get_port(): diff --git a/ros2cli/test/test_ros2cli_daemon.py b/ros2cli/test/test_ros2cli_daemon.py index 751d84681..60ab9cad8 100644 --- a/ros2cli/test/test_ros2cli_daemon.py +++ b/ros2cli/test/test_ros2cli_daemon.py @@ -13,12 +13,14 @@ # limitations under the License. import time +from unittest.mock import patch import pytest import rclpy import rclpy.action +from ros2cli.daemon import SERVER_URL_VARIABLE_NAME from ros2cli.node.daemon import DaemonNode from ros2cli.node.daemon import is_daemon_running from ros2cli.node.daemon import shutdown_daemon @@ -103,8 +105,7 @@ def noop_execute_callback(goal_handle): yield node -@pytest.fixture(scope='module') -def daemon_node(): +def _daemon_node(): if is_daemon_running(args=[]): assert shutdown_daemon(args=[], timeout=5.0) assert spawn_daemon(args=[], timeout=5.0) @@ -128,6 +129,11 @@ def daemon_node(): node.system.shutdown() +@pytest.fixture(scope='module') +def daemon_node(): + yield from _daemon_node() + + def test_get_name(daemon_node): assert 'daemon' in daemon_node.get_name() @@ -249,3 +255,25 @@ def test_count_clients(daemon_node): def test_count_services(daemon_node): assert 1 == daemon_node.count_services(TEST_SERVICE_NAME) + + +def test_url_override(daemon_node): + # Started by daemon_node + assert is_daemon_running(args=[]) + + with patch.dict( + 'ros2cli.daemon.os.environ', + {SERVER_URL_VARIABLE_NAME: 'http://127.0.0.1:11744/test/'}, + ): + # No daemon running on that port + assert not is_daemon_running(args=[]) + + for _ in _daemon_node(): + # New daemon running on our custom port + assert is_daemon_running(args=[]) + + # Custom port daemon is shut down + assert not is_daemon_running(args=[]) + + # Back to the one started by daemon_node + assert is_daemon_running(args=[])