diff --git a/src/debugpy/adapter/__main__.py b/src/debugpy/adapter/__main__.py index 93cd05f6d..a6705c069 100644 --- a/src/debugpy/adapter/__main__.py +++ b/src/debugpy/adapter/__main__.py @@ -65,9 +65,10 @@ def main(): else: endpoints["client"] = {"host": client_host, "port": client_port} + localhost = sockets.get_default_localhost() if args.for_server is not None: try: - server_host, server_port = servers.serve() + server_host, server_port = servers.serve(localhost) except Exception as exc: endpoints = {"error": "Can't listen for server connections: " + str(exc)} else: @@ -80,10 +81,11 @@ def main(): ) try: - sock = sockets.create_client() + ipv6 = localhost.count(":") > 1 + sock = sockets.create_client(ipv6) try: sock.settimeout(None) - sock.connect(("127.0.0.1", args.for_server)) + sock.connect((localhost, args.for_server)) sock_io = sock.makefile("wb", 0) try: sock_io.write(json.dumps(endpoints).encode("utf-8")) @@ -137,6 +139,10 @@ def delete_listener_file(): def _parse_argv(argv): + from debugpy.common import sockets + + host = sockets.get_default_localhost() + parser = argparse.ArgumentParser() parser.add_argument( @@ -154,7 +160,7 @@ def _parse_argv(argv): parser.add_argument( "--host", type=str, - default="127.0.0.1", + default=host, metavar="HOST", help="start the adapter in debugServer mode on the specified host", ) diff --git a/src/debugpy/adapter/clients.py b/src/debugpy/adapter/clients.py index 0b4a870a7..4931b70c7 100644 --- a/src/debugpy/adapter/clients.py +++ b/src/debugpy/adapter/clients.py @@ -404,7 +404,8 @@ def property_or_debug_option(prop_name, flag_name): self._forward_terminate_request = on_terminate == "KeyboardInterrupt" launcher_path = request("debugLauncherPath", os.path.dirname(launcher.__file__)) - adapter_host = request("debugAdapterHost", "127.0.0.1") + localhost = sockets.get_default_localhost() + adapter_host = request("debugAdapterHost", localhost) try: servers.serve(adapter_host) @@ -472,20 +473,21 @@ def attach_request(self, request): '"processId" and "subProcessId" are mutually exclusive' ) + localhost = sockets.get_default_localhost() if listen != (): if servers.is_serving(): raise request.isnt_valid( 'Multiple concurrent "listen" sessions are not supported' ) - host = listen("host", "127.0.0.1") + host = listen("host", localhost) port = listen("port", int) adapter.access_token = None self.restart_requested = request("restart", False) host, port = servers.serve(host, port) else: if not servers.is_serving(): - servers.serve() - host, port = servers.listener.getsockname() + servers.serve(localhost) + host, port = sockets.get_address(servers.listener) # There are four distinct possibilities here. # @@ -710,7 +712,7 @@ def disconnect(self): super().disconnect() def report_sockets(self): - sockets = [ + socks = [ { "host": host, "port": port, @@ -718,12 +720,12 @@ def report_sockets(self): } for listener in [clients.listener, launchers.listener, servers.listener] if listener is not None - for (host, port) in [listener.getsockname()] + for (host, port) in [sockets.get_address(listener)] ] self.channel.send_event( "debugpySockets", { - "sockets": sockets + "sockets": socks }, ) @@ -759,10 +761,11 @@ def notify_of_subprocess(self, conn): if "connect" not in body: body["connect"] = {} if "host" not in body["connect"]: - body["connect"]["host"] = host if host is not None else "127.0.0.1" + localhost = sockets.get_default_localhost() + body["connect"]["host"] = host or localhost if "port" not in body["connect"]: if port is None: - _, port = listener.getsockname() + _, port = sockets.get_address(listener) body["connect"]["port"] = port if self.capabilities["supportsStartDebuggingRequest"]: @@ -779,7 +782,7 @@ def serve(host, port): global listener listener = sockets.serve("Client", Client, host, port) sessions.report_sockets() - return listener.getsockname() + return sockets.get_address(listener) def stop_serving(): diff --git a/src/debugpy/adapter/launchers.py b/src/debugpy/adapter/launchers.py index 38a990d76..62f948817 100644 --- a/src/debugpy/adapter/launchers.py +++ b/src/debugpy/adapter/launchers.py @@ -89,7 +89,7 @@ def spawn_debuggee( arguments = dict(start_request.arguments) if not session.no_debug: - _, arguments["port"] = servers.listener.getsockname() + _, arguments["port"] = sockets.get_address(servers.listener) arguments["adapterAccessToken"] = adapter.access_token def on_launcher_connected(sock): @@ -108,10 +108,11 @@ def on_launcher_connected(sock): sessions.report_sockets() try: - launcher_host, launcher_port = listener.getsockname() + launcher_host, launcher_port = sockets.get_address(listener) + localhost = sockets.get_default_localhost() launcher_addr = ( launcher_port - if launcher_host == "127.0.0.1" + if launcher_host == localhost else f"{launcher_host}:{launcher_port}" ) cmdline += [str(launcher_addr), "--"] diff --git a/src/debugpy/adapter/servers.py b/src/debugpy/adapter/servers.py index 307a2ce62..0aede938f 100644 --- a/src/debugpy/adapter/servers.py +++ b/src/debugpy/adapter/servers.py @@ -395,7 +395,7 @@ def serve(host="127.0.0.1", port=0): global listener listener = sockets.serve("Server", Connection, host, port) sessions.report_sockets() - return listener.getsockname() + return sockets.get_address(listener) def is_serving(): @@ -475,7 +475,7 @@ def dont_wait_for_first_connection(): def inject(pid, debugpy_args, on_output): - host, port = listener.getsockname() + host, port = sockets.get_address(listener) cmdline = [ sys.executable, diff --git a/src/debugpy/common/sockets.py b/src/debugpy/common/sockets.py index ffcef80f6..47db4d89b 100644 --- a/src/debugpy/common/sockets.py +++ b/src/debugpy/common/sockets.py @@ -9,18 +9,68 @@ from debugpy.common import log from debugpy.common.util import hide_thread_from_debugger +def can_bind_ipv4_localhost(): + """Check if we can bind to IPv4 localhost.""" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + # Try to bind to IPv4 localhost on port 0 (any available port) + sock.bind(("127.0.0.1", 0)) + sock.close() + return True + except (socket.error, OSError, AttributeError): + return False + +def can_bind_ipv6_localhost(): + """Check if we can bind to IPv6 localhost.""" + try: + sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + # Try to bind to IPv6 localhost on port 0 (any available port) + sock.bind(("::1", 0)) + sock.close() + return True + except (socket.error, OSError, AttributeError): + return False + +def get_default_localhost(): + """Get the default localhost address. + Defaults to IPv4 '127.0.0.1', but falls back to IPv6 '::1' if IPv4 is unavailable. + """ + # First try IPv4 (preferred default) + if can_bind_ipv4_localhost(): + return "127.0.0.1" + + # Fall back to IPv6 if IPv4 is not available + if can_bind_ipv6_localhost(): + return "::1" + + # If neither works, still return IPv4 as a last resort + # (this is a very unusual situation) + return "127.0.0.1" + +def get_address(sock): + """Gets the socket address host and port.""" + try: + host, port = sock.getsockname()[:2] + except Exception as exc: + log.swallow_exception("Failed to get socket address:") + raise RuntimeError(f"Failed to get socket address: {exc}") from exc + + return host, port def create_server(host, port=0, backlog=socket.SOMAXCONN, timeout=None): """Return a local server socket listening on the given port.""" assert backlog > 0 if host is None: - host = "127.0.0.1" + host = get_default_localhost() if port is None: port = 0 + ipv6 = host.count(":") > 1 try: - server = _new_sock() + server = _new_sock(ipv6) if port != 0: # If binding to a specific port, make sure that the user doesn't have # to wait until the OS times out the socket to be able to use that port @@ -42,13 +92,14 @@ def create_server(host, port=0, backlog=socket.SOMAXCONN, timeout=None): return server -def create_client(): +def create_client(ipv6=False): """Return a client socket that may be connected to a remote address.""" - return _new_sock() + return _new_sock(ipv6) -def _new_sock(): - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP) +def _new_sock(ipv6=False): + address_family = socket.AF_INET6 if ipv6 else socket.AF_INET + sock = socket.socket(address_family, socket.SOCK_STREAM, socket.IPPROTO_TCP) # Set TCP keepalive on an open socket. # It activates after 1 second (TCP_KEEPIDLE,) of idleness, @@ -102,13 +153,14 @@ def serve(name, handler, host, port=0, backlog=socket.SOMAXCONN, timeout=None): log.reraise_exception( "Error listening for incoming {0} connections on {1}:{2}:", name, host, port ) - host, port = listener.getsockname() + host, port = get_address(listener) log.info("Listening for incoming {0} connections on {1}:{2}...", name, host, port) def accept_worker(): while True: try: - sock, (other_host, other_port) = listener.accept() + sock, address = listener.accept() + other_host, other_port = address[:2] except (OSError, socket.error): # Listener socket has been closed. break diff --git a/src/debugpy/launcher/__init__.py b/src/debugpy/launcher/__init__.py index a6e0934f1..677740262 100644 --- a/src/debugpy/launcher/__init__.py +++ b/src/debugpy/launcher/__init__.py @@ -23,7 +23,8 @@ def connect(host, port): log.info("Connecting to adapter at {0}:{1}", host, port) - sock = sockets.create_client() + ipv6 = host.count(":") > 1 + sock = sockets.create_client(ipv6) sock.connect((host, port)) adapter_host = host diff --git a/src/debugpy/launcher/__main__.py b/src/debugpy/launcher/__main__.py index cff18b5f1..7e17d4da8 100644 --- a/src/debugpy/launcher/__main__.py +++ b/src/debugpy/launcher/__main__.py @@ -14,7 +14,7 @@ def main(): from debugpy import launcher - from debugpy.common import log + from debugpy.common import log, sockets from debugpy.launcher import debuggee log.to_file(prefix="debugpy.launcher") @@ -38,9 +38,10 @@ def main(): # The first argument specifies the host/port on which the adapter is waiting # for launcher to connect. It's either host:port, or just port. adapter = launcher_argv[0] - host, sep, port = adapter.partition(":") + host, sep, port = adapter.rpartition(":") + host.strip("[]") if not sep: - host = "127.0.0.1" + host = sockets.get_default_localhost() port = adapter port = int(port) diff --git a/src/debugpy/server/api.py b/src/debugpy/server/api.py index 4b8cf9c54..eb54ffed8 100644 --- a/src/debugpy/server/api.py +++ b/src/debugpy/server/api.py @@ -100,7 +100,8 @@ def debug(address, **kwargs): _, port = address except Exception: port = address - address = ("127.0.0.1", port) + localhost = sockets.get_default_localhost() + address = (localhost, port) try: port.__index__() # ensure it's int-like except Exception: @@ -143,8 +144,8 @@ def listen(address, settrace_kwargs, in_process_debug_adapter=False): # Multiple calls to listen() cause the debuggee to hang raise RuntimeError("debugpy.listen() has already been called on this process") + host, port = address if in_process_debug_adapter: - host, port = address log.info("Listening: pydevd without debugpy adapter: {0}:{1}", host, port) settrace_kwargs["patch_multiprocessing"] = False _settrace( @@ -161,13 +162,14 @@ def listen(address, settrace_kwargs, in_process_debug_adapter=False): server_access_token = codecs.encode(os.urandom(32), "hex").decode("ascii") try: - endpoints_listener = sockets.create_server("127.0.0.1", 0, timeout=30) + localhost = sockets.get_default_localhost() + endpoints_listener = sockets.create_server(localhost, 0, timeout=30) except Exception as exc: log.swallow_exception("Can't listen for adapter endpoints:") raise RuntimeError("can't listen for adapter endpoints: " + str(exc)) try: - endpoints_host, endpoints_port = endpoints_listener.getsockname() + endpoints_host, endpoints_port = sockets.get_address(endpoints_listener) log.info( "Waiting for adapter endpoints on {0}:{1}...", endpoints_host, diff --git a/src/debugpy/server/cli.py b/src/debugpy/server/cli.py index 5e0520da8..a64f515aa 100644 --- a/src/debugpy/server/cli.py +++ b/src/debugpy/server/cli.py @@ -20,7 +20,7 @@ import debugpy import debugpy.server -from debugpy.common import log +from debugpy.common import log, sockets from debugpy.server import api @@ -104,9 +104,10 @@ def do(arg, it): # It's either host:port, or just port. value = next(it) - host, sep, port = value.partition(":") + host, sep, port = value.rpartition(":") + host = host.strip("[]") if not sep: - host = "127.0.0.1" + host = sockets.get_default_localhost() port = value try: port = int(port) diff --git a/tests/debug/comms.py b/tests/debug/comms.py index e690aceb3..a46ad5ca5 100644 --- a/tests/debug/comms.py +++ b/tests/debug/comms.py @@ -25,7 +25,7 @@ def __str__(self): def listen(self): self._server_socket = sockets.create_server("127.0.0.1", 0, self.TIMEOUT) - _, self.port = self._server_socket.getsockname() + _, self.port = sockets.get_address(self._server_socket) self._server_socket.listen(0) def accept_worker(): diff --git a/tests/debug/session.py b/tests/debug/session.py index 3a821f5bc..05abea62a 100644 --- a/tests/debug/session.py +++ b/tests/debug/session.py @@ -464,7 +464,8 @@ def connect_to_adapter(self, address): self.expected_adapter_sockets["client"]["port"] = port - sock = sockets.create_client() + ipv6 = host.count(":") > 1 + sock = sockets.create_client(ipv6) sock.connect(address) stream = messaging.JsonIOStream.from_socket(sock, name=self.adapter_id) diff --git a/tests/debugpy/server/test_cli.py b/tests/debugpy/server/test_cli.py index a098cda8b..9b64ee0e5 100644 --- a/tests/debugpy/server/test_cli.py +++ b/tests/debugpy/server/test_cli.py @@ -15,7 +15,6 @@ from debugpy.common import log from tests.patterns import some - @pytest.fixture def cli(pyfile): @pyfile @@ -89,7 +88,7 @@ def parse(args): # Test a combination of command line switches @pytest.mark.parametrize("target_kind", ["file", "module", "code"]) @pytest.mark.parametrize("mode", ["listen", "connect"]) -@pytest.mark.parametrize("address", ["8888", "localhost:8888"]) +@pytest.mark.parametrize("address", ["8888", "localhost:8888", "[::1]:8888"]) @pytest.mark.parametrize("wait_for_client", ["", "wait_for_client"]) @pytest.mark.parametrize("script_args", ["", "script_args"]) def test_targets(cli, target_kind, mode, address, wait_for_client, script_args): @@ -101,7 +100,8 @@ def test_targets(cli, target_kind, mode, address, wait_for_client, script_args): args = ["--" + mode, address] - host, sep, port = address.partition(":") + host, sep, port = address.rpartition(":") + host = host.strip("[]") if sep: expected_options["address"] = (host, int(port)) else: diff --git a/tests/debugpy/test_attach.py b/tests/debugpy/test_attach.py index c973b09bb..a2cb2fa1e 100644 --- a/tests/debugpy/test_attach.py +++ b/tests/debugpy/test_attach.py @@ -14,8 +14,9 @@ @pytest.mark.parametrize("stop_method", ["breakpoint", "pause"]) @pytest.mark.skipif(IS_PY312_OR_GREATER, reason="Flakey test on 312 and higher") @pytest.mark.parametrize("is_client_connected", ["is_client_connected", ""]) +@pytest.mark.parametrize("host", ["127.0.0.1", "::1"]) @pytest.mark.parametrize("wait_for_client", ["wait_for_client", pytest.param("", marks=pytest.mark.skipif(sys.platform.startswith("darwin"), reason="Flakey test on Mac"))]) -def test_attach_api(pyfile, wait_for_client, is_client_connected, stop_method): +def test_attach_api(pyfile, host, wait_for_client, is_client_connected, stop_method): @pyfile def code_to_debug(): import debuggee @@ -58,7 +59,8 @@ def code_to_debug(): time.sleep(0.1) with debug.Session() as session: - host, port = runners.attach_connect.host, runners.attach_connect.port + host = runners.attach_connect.host if host == "127.0.0.1" else host + port = runners.attach_connect.port session.config.update({"connect": {"host": host, "port": port}}) backchannel = session.open_backchannel() @@ -102,7 +104,8 @@ def code_to_debug(): session.request_continue() -def test_multiple_listen_raises_exception(pyfile): +@pytest.mark.parametrize("host", ["127.0.0.1", "::1"]) +def test_multiple_listen_raises_exception(pyfile, host): @pyfile def code_to_debug(): import debuggee @@ -124,7 +127,8 @@ def code_to_debug(): debugpy.breakpoint() print("break") # @breakpoint - host, port = runners.attach_connect.host, runners.attach_connect.port + host = runners.attach_connect.host if host == "127.0.0.1" else host + port = runners.attach_connect.port with debug.Session() as session: backchannel = session.open_backchannel() session.spawn_debuggee( @@ -147,7 +151,6 @@ def code_to_debug(): assert backchannel.receive() == "listen_exception" session.request_continue() - @pytest.mark.parametrize("run", runners.all_attach_connect) def test_reattach(pyfile, target, run): @pyfile @@ -265,7 +268,8 @@ def before_request(command, arguments): session2.request_continue() -def test_cancel_wait(pyfile): +@pytest.mark.parametrize("host", ["127.0.0.1", "::1"]) +def test_cancel_wait(pyfile, host): @pyfile def code_to_debug(): import debugpy @@ -287,7 +291,8 @@ def cancel(): backchannel.send("exit") with debug.Session() as session: - host, port = runners.attach_connect.host, runners.attach_connect.port + host = runners.attach_connect.host if host == "127.0.0.1" else host + port = runners.attach_connect.port session.config.update({"connect": {"host": host, "port": port}}) session.expected_exit_code = None