IPv6 support (#1896)

* Add initial support for IPv6

* address comments (part 1)

* quick clean up of missed fix suggestion

* Fix misassigned default serving server address in adapter client

* add wrapper method to get host and port from`getsockname`
This commit is contained in:
rabbit 2025-05-22 13:04:11 -07:00 committed by GitHub
parent 4bc7343c05
commit 34d5de99a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 123 additions and 50 deletions

View file

@ -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 main():
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",
)

View file

@ -404,7 +404,8 @@ class Client(components.Component):
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 @@ class Client(components.Component):
'"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 @@ class Client(components.Component):
super().disconnect()
def report_sockets(self):
sockets = [
socks = [
{
"host": host,
"port": port,
@ -718,12 +720,12 @@ class Client(components.Component):
}
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 @@ class Client(components.Component):
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():

View file

@ -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 spawn_debuggee(
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), "--"]

View file

@ -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,

View file

@ -9,18 +9,68 @@ import threading
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

View file

@ -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

View file

@ -14,7 +14,7 @@ import sys
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)

View file

@ -100,7 +100,8 @@ def _starts_debugging(func):
_, 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,

View file

@ -20,7 +20,7 @@ from _pydevd_bundle import pydevd_runpy as runpy
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 set_address(mode):
# 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)

View file

@ -25,7 +25,7 @@ class BackChannel(object):
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():

View file

@ -464,7 +464,8 @@ class Session(object):
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)

View file

@ -15,7 +15,6 @@ from unittest import mock
from debugpy.common import log
from tests.patterns import some
@pytest.fixture
def cli(pyfile):
@pyfile
@ -89,7 +88,7 @@ def cli(pyfile):
# 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:

View file

@ -14,8 +14,9 @@ from tests.patterns import some
@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 test_attach_api(pyfile, wait_for_client, is_client_connected, stop_method):
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 test_attach_api(pyfile, wait_for_client, is_client_connected, stop_method):
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 test_multiple_listen_raises_exception(pyfile):
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 test_multiple_listen_raises_exception(pyfile):
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 test_attach_pid_client(pyfile, target, pid_type):
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 test_cancel_wait(pyfile):
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