Skip to content

Commit

Permalink
Fix #1337: Get port info from debugpy
Browse files Browse the repository at this point in the history
Send "debugpySockets" event with information about opened sockets when clients connect and whenever ports get opened or closed.
  • Loading branch information
Pavel Minaev committed Oct 3, 2023
1 parent 7d09fb2 commit 94c9999
Show file tree
Hide file tree
Showing 7 changed files with 140 additions and 11 deletions.
34 changes: 29 additions & 5 deletions src/debugpy/adapter/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import debugpy
from debugpy import adapter, common, launcher
from debugpy.common import json, log, messaging, sockets
from debugpy.adapter import components, servers, sessions
from debugpy.adapter import clients, components, launchers, servers, sessions


class Client(components.Component):
Expand Down Expand Up @@ -110,6 +110,7 @@ def __init__(self, sock):
"data": {"packageVersion": debugpy.__version__},
},
)
sessions.report_sockets()

def propagate_after_start(self, event):
# pydevd starts sending events as soon as we connect, but the client doesn't
Expand Down Expand Up @@ -701,6 +702,24 @@ def disconnect_request(self, request):
def disconnect(self):
super().disconnect()

def report_sockets(self):
sockets = [
{
"host": host,
"port": port,
"internal": listener is not clients.listener,
}
for listener in [clients.listener, launchers.listener, servers.listener]
if listener is not None
for (host, port) in [listener.getsockname()]
]
self.channel.send_event(
"debugpySockets",
{
"sockets": sockets
},
)

def notify_of_subprocess(self, conn):
log.info("{1} is a subprocess of {0}.", self, conn)
with self.session:
Expand Down Expand Up @@ -752,11 +771,16 @@ def notify_of_subprocess(self, conn):
def serve(host, port):
global listener
listener = sockets.serve("Client", Client, host, port)
sessions.report_sockets()
return listener.getsockname()


def stop_serving():
try:
listener.close()
except Exception:
log.swallow_exception(level="warning")
global listener
if listener is not None:
try:
listener.close()
except Exception:
log.swallow_exception(level="warning")
listener = None
sessions.report_sockets()
9 changes: 8 additions & 1 deletion src/debugpy/adapter/launchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@

from debugpy import adapter, common
from debugpy.common import log, messaging, sockets
from debugpy.adapter import components, servers
from debugpy.adapter import components, servers, sessions

listener = None


class Launcher(components.Component):
Expand Down Expand Up @@ -76,6 +78,8 @@ def spawn_debuggee(
console_title,
sudo,
):
global listener

# -E tells sudo to propagate environment variables to the target process - this
# is necessary for launcher to get DEBUGPY_LAUNCHER_PORT and DEBUGPY_LOG_DIR.
cmdline = ["sudo", "-E"] if sudo else []
Expand All @@ -101,6 +105,7 @@ def on_launcher_connected(sock):
raise start_request.cant_handle(
"{0} couldn't create listener socket for launcher: {1}", session, exc
)
sessions.report_sockets()

try:
launcher_host, launcher_port = listener.getsockname()
Expand Down Expand Up @@ -189,3 +194,5 @@ def on_launcher_connected(sock):

finally:
listener.close()
listener = None
sessions.report_sockets()
4 changes: 3 additions & 1 deletion src/debugpy/adapter/servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import debugpy
from debugpy import adapter
from debugpy.common import json, log, messaging, sockets
from debugpy.adapter import components
from debugpy.adapter import components, sessions
import traceback
import io

Expand Down Expand Up @@ -394,6 +394,7 @@ def disconnect(self):
def serve(host="127.0.0.1", port=0):
global listener
listener = sockets.serve("Server", Connection, host, port)
sessions.report_sockets()
return listener.getsockname()


Expand All @@ -409,6 +410,7 @@ def stop_serving():
listener = None
except Exception:
log.swallow_exception(level="warning")
sessions.report_sockets()


def connections():
Expand Down
9 changes: 9 additions & 0 deletions src/debugpy/adapter/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,3 +282,12 @@ def wait_until_ended():
return
_sessions_changed.clear()
_sessions_changed.wait()


def report_sockets():
if not _sessions:
return
session = sorted(_sessions, key=lambda session: session.id)[0]
client = session.client
if client is not None:
client.report_sockets()
4 changes: 4 additions & 0 deletions tests/debug/runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@ def attach_connect(session, target, method, cwd=None, wait=True, log_dir=None):
except KeyError:
pass

# If adapter is connecting to the client, the server is already started,
# so it should be reported in the initial event.
session.expect_server_socket()

session.spawn_debuggee(args, cwd=cwd, setup=debuggee_setup)
session.wait_for_adapter_socket()
session.connect_to_adapter((host, port))
Expand Down
87 changes: 84 additions & 3 deletions tests/debug/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ def __init__(self, debug_config=None):
self.adapter = None
"""psutil.Popen instance for the adapter process."""

self.expected_adapter_sockets = {
"client": {"host": some.str, "port": some.int, "internal": False},
}
"""The sockets which the adapter is expected to report."""

self.adapter_endpoints = None
"""Name of the file that contains the adapter endpoints information.
Expand Down Expand Up @@ -183,6 +188,7 @@ def __init__(self, debug_config=None):
timeline.Event("module"),
timeline.Event("continued"),
timeline.Event("debugpyWaitingForServer"),
timeline.Event("debugpySockets"),
timeline.Event("thread", some.dict.containing({"reason": "started"})),
timeline.Event("thread", some.dict.containing({"reason": "exited"})),
timeline.Event("output", some.dict.containing({"category": "stdout"})),
Expand Down Expand Up @@ -296,6 +302,10 @@ def __exit__(self, exc_type, exc_val, exc_tb):
@property
def ignore_unobserved(self):
return self.timeline.ignore_unobserved

@property
def is_subprocess(self):
return "subProcessId" in self.config

def open_backchannel(self):
assert self.backchannel is None
Expand Down Expand Up @@ -352,7 +362,9 @@ def _make_env(self, base_env, codecov=True):
return env

def _make_python_cmdline(self, exe, *args):
return [str(s.strpath if isinstance(s, py.path.local) else s) for s in [exe, *args]]
return [
str(s.strpath if isinstance(s, py.path.local) else s) for s in [exe, *args]
]

def spawn_debuggee(self, args, cwd=None, exe=sys.executable, setup=None):
assert self.debuggee is None
Expand Down Expand Up @@ -406,7 +418,9 @@ def spawn_adapter(self, args=()):
assert self.adapter is None
assert self.channel is None

args = self._make_python_cmdline(sys.executable, os.path.dirname(debugpy.adapter.__file__), *args)
args = self._make_python_cmdline(
sys.executable, os.path.dirname(debugpy.adapter.__file__), *args
)
env = self._make_env(self.spawn_adapter.env)

log.info(
Expand All @@ -430,12 +444,22 @@ def spawn_adapter(self, args=()):
stream = messaging.JsonIOStream.from_process(self.adapter, name=self.adapter_id)
self._start_channel(stream)

def expect_server_socket(self, port=some.int):
self.expected_adapter_sockets["server"] = {
"host": some.str,
"port": port,
"internal": True,
}

def connect_to_adapter(self, address):
assert self.channel is None

self.before_connect(address)
host, port = address
log.info("Connecting to {0} at {1}:{2}", self.adapter_id, host, port)

self.expected_adapter_sockets["client"]["port"] = port

sock = sockets.create_client()
sock.connect(address)

Expand Down Expand Up @@ -483,16 +507,55 @@ def send_request(self, command, arguments=None, proceed=True):

def _process_event(self, event):
occ = self.timeline.record_event(event, block=False)

if event.event == "exited":
self.observe(occ)
self.exit_code = event("exitCode", int)
self.exit_reason = event("reason", str, optional=True)
assert self.exit_code == self.expected_exit_code

elif event.event == "terminated":
# Server socket should be closed next.
self.expected_adapter_sockets.pop("server", None)

elif event.event == "debugpyAttach":
self.observe(occ)
pid = event("subProcessId", int)
watchdog.register_spawn(pid, f"{self.debuggee_id}-subprocess-{pid}")

elif event.event == "debugpySockets":
assert not self.is_subprocess
sockets = list(event("sockets", json.array(json.object())))
for purpose, expected_socket in self.expected_adapter_sockets.items():
if expected_socket is None:
continue
socket = None
for socket in sockets:
if socket == expected_socket:
break
assert (
socket is not None
), f"Expected {purpose} socket {expected_socket} not reported by adapter"
sockets.remove(socket)
assert not sockets, f"Unexpected sockets reported by adapter: {sockets}"

if (
self.start_request is not None
and self.start_request.command == "launch"
):
if "launcher" in self.expected_adapter_sockets:
# If adapter has just reported the launcher socket, it shouldn't be
# reported thereafter.
self.expected_adapter_sockets["launcher"] = None
elif "server" in self.expected_adapter_sockets:
# If adapter just reported the server socket, the next event should
# report the launcher socket.
self.expected_adapter_sockets["launcher"] = {
"host": some.str,
"port": some.int,
"internal": False,
}

def run_in_terminal(self, args, cwd, env):
exe = args.pop(0)
self.spawn_debuggee.env.update(env)
Expand All @@ -514,10 +577,12 @@ def _process_request(self, request):
except Exception as exc:
log.swallow_exception('"runInTerminal" failed:')
raise request.cant_handle(str(exc))

elif request.command == "startDebugging":
pid = request("configuration", dict)("subProcessId", int)
watchdog.register_spawn(pid, f"{self.debuggee_id}-subprocess-{pid}")
return {}

else:
raise request.isnt_valid("not supported")

Expand Down Expand Up @@ -567,6 +632,9 @@ def _start_channel(self, stream):
)
)

if not self.is_subprocess:
self.wait_for_next(timeline.Event("debugpySockets"))

self.request("initialize", self.capabilities)

def all_events(self, event, body=some.object):
Expand Down Expand Up @@ -632,9 +700,20 @@ def request_launch(self):
# If specified, launcher will use it in lieu of PYTHONPATH it inherited
# from the adapter when spawning debuggee, so we need to adjust again.
self.config.env.prepend_to("PYTHONPATH", DEBUGGEE_PYTHONPATH.strpath)

# Adapter is going to start listening for server and spawn the launcher at
# this point. Server socket gets reported first.
self.expect_server_socket()

return self._request_start("launch")

def request_attach(self):
# In attach(listen) scenario, adapter only starts listening for server
# after receiving the "attach" request.
listen = self.config.get("listen", None)
if listen is not None:
assert "server" not in self.expected_adapter_sockets
self.expect_server_socket(listen["port"])
return self._request_start("attach")

def request_continue(self):
Expand Down Expand Up @@ -787,7 +866,9 @@ def wait_for_stop(
return StopInfo(stopped, frames, tid, fid)

def wait_for_next_subprocess(self):
message = self.timeline.wait_for_next(timeline.Event("debugpyAttach") | timeline.Request("startDebugging"))
message = self.timeline.wait_for_next(
timeline.Event("debugpyAttach") | timeline.Request("startDebugging")
)
if isinstance(message, timeline.EventOccurrence):
config = message.body
assert "request" in config
Expand Down
4 changes: 3 additions & 1 deletion tests/debugpy/test_attach.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def code_to_debug():
)
session.wait_for_adapter_socket()

session.expect_server_socket()
session.connect_to_adapter((host, port))
with session.request_attach():
pass
Expand Down Expand Up @@ -124,13 +125,14 @@ def code_to_debug():
session1.expected_exit_code = None # not expected to exit on disconnect

with run(session1, target(code_to_debug)):
pass
expected_adapter_sockets = session1.expected_adapter_sockets.copy()

session1.wait_for_stop(expected_frames=[some.dap.frame(code_to_debug, "first")])
session1.disconnect()

with debug.Session() as session2:
session2.config.update(session1.config)
session2.expected_adapter_sockets = expected_adapter_sockets
if "connect" in session2.config:
session2.connect_to_adapter(
(session2.config["connect"]["host"], session2.config["connect"]["port"])
Expand Down

0 comments on commit 94c9999

Please sign in to comment.