Repository: microsoft/debugpy
Pull request #1896: IPv6 support
Author: login: wh1ter4bb1t-js
Created: 2025-05-22T00:05:19Z | Merged: 2025-05-22T20:04:12Z

Add Support for IPv6 addresses

This PR addresses https://github.com/microsoft/debugpy/issues/1252.

This change tries to better generalize the host address in order to support both IPv4 and IPv6 address families

In this PR the debug workflows have changed in the following manner:

* When passing the full `{host}:{port}` path, the host can now utilize `[::1]` or any other IPv6 address
* The default cases will check if IPv4 or IPv6 is available:
    - if IPv4 is unavailable then the default host will be `[::1]`
    - if IPv4 is available or IPv6 is also unavailable the host will default back to `127.0.0.1`
   
#### Nuances:

* `getsockname` will always return >= 2 (2 in the case of IPv4 and 4 in the case of IPv6) so to ensure that there is no complicated branching logic we always take a [:2] slice.
* since the majority of the cases that are being updated here is in order to replace `127.0.0.1` to either `127.0.0.1` _or_  `::1` we opted to refer to the host when utilizing these values as localhost


Diff:
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
 


Reviews:
user: login: rchiodo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T16:15:35Z
user: login: rchiodo
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T19:04:28Z
user: login: wh1ter4bb1t-js
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T22:11:57Z

Review comments:
diff_hunk: @@ -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 listener.getsockname()[:2]
path: src/debugpy/adapter/servers.py
user: login: rchiodo
body: I suppose the socket.getsockname is well defined, but it feels a little strange to me to embed this [:2] in every spot this is called. Might it be better to have a function that wraps this and the function is called everywhere?


created_at: 2025-05-22T16:15:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 listener.getsockname()[:2]
path: src/debugpy/adapter/servers.py
user: login: wh1ter4bb1t-js
body: sg. Thanks
created_at: 2025-05-22T22:11:57Z
author_association: CONTRIBUTOR

PR comments:
user: login: rchiodo
created_at: 2025-05-22T00:07:53Z
body: /azp run
author_association: CONTRIBUTOR
user: login: azure-pipelines[bot]
created_at: 2025-05-22T00:08:13Z
body: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
author_association: NONE
user: login: wh1ter4bb1t-js
created_at: 2025-05-22T00:15:04Z
body: @microsoft-github-policy-service agree company="Google"

author_association: CONTRIBUTOR
user: login: rchiodo
created_at: 2025-05-22T16:01:44Z
body: /azp run
author_association: CONTRIBUTOR
user: login: azure-pipelines[bot]
created_at: 2025-05-22T16:01:56Z
body: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
author_association: NONE
user: login: rchiodo
created_at: 2025-05-22T19:04:40Z
body: /azp run
author_association: CONTRIBUTOR
user: login: azure-pipelines[bot]
created_at: 2025-05-22T19:04:51Z
body: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
author_association: NONE
user: login: rchiodo
created_at: 2025-05-22T19:05:06Z
body: Looks good. Thanks for the submission. 
author_association: CONTRIBUTOR