|
|
|
|
|
|
|
|
|
from __future__ import annotations |
|
|
|
import os |
|
import subprocess |
|
import sys |
|
import threading |
|
import time |
|
|
|
import debugpy |
|
from debugpy import adapter |
|
from debugpy.common import json, log, messaging, sockets |
|
from debugpy.adapter import components, sessions |
|
import traceback |
|
import io |
|
|
|
access_token = None |
|
"""Access token used to authenticate with the servers.""" |
|
|
|
listener = None |
|
"""Listener socket that accepts server connections.""" |
|
|
|
_lock = threading.RLock() |
|
|
|
_connections = [] |
|
"""All servers that are connected to this adapter, in order in which they connected. |
|
""" |
|
|
|
_connections_changed = threading.Event() |
|
|
|
|
|
class Connection(object): |
|
"""A debug server that is connected to the adapter. |
|
|
|
Servers that are not participating in a debug session are managed directly by the |
|
corresponding Connection instance. |
|
|
|
Servers that are participating in a debug session are managed by that sessions's |
|
Server component instance, but Connection object remains, and takes over again |
|
once the session ends. |
|
""" |
|
|
|
disconnected: bool |
|
|
|
process_replaced: bool |
|
"""Whether this is a connection to a process that is being replaced in situ |
|
by another process, e.g. via exec(). |
|
""" |
|
|
|
server: Server | None |
|
"""The Server component, if this debug server belongs to Session. |
|
""" |
|
|
|
pid: int | None |
|
|
|
ppid: int | None |
|
|
|
channel: messaging.JsonMessageChannel |
|
|
|
def __init__(self, sock): |
|
from debugpy.adapter import sessions |
|
|
|
self.disconnected = False |
|
|
|
self.process_replaced = False |
|
|
|
self.server = None |
|
|
|
self.pid = None |
|
|
|
stream = messaging.JsonIOStream.from_socket(sock, str(self)) |
|
self.channel = messaging.JsonMessageChannel(stream, self) |
|
self.channel.start() |
|
|
|
try: |
|
self.authenticate() |
|
info = self.channel.request("pydevdSystemInfo") |
|
process_info = info("process", json.object()) |
|
self.pid = process_info("pid", int) |
|
self.ppid = process_info("ppid", int, optional=True) |
|
if self.ppid == (): |
|
self.ppid = None |
|
self.channel.name = stream.name = str(self) |
|
|
|
with _lock: |
|
|
|
|
|
|
|
|
|
if self.disconnected: |
|
return |
|
|
|
|
|
|
|
|
|
if any( |
|
conn.pid == self.pid and not conn.process_replaced |
|
for conn in _connections |
|
): |
|
raise KeyError(f"{self} is already connected to this adapter") |
|
|
|
is_first_server = len(_connections) == 0 |
|
_connections.append(self) |
|
_connections_changed.set() |
|
|
|
except Exception: |
|
log.swallow_exception("Failed to accept incoming server connection:") |
|
self.channel.close() |
|
|
|
|
|
|
|
dont_wait_for_first_connection() |
|
|
|
|
|
|
|
|
|
return |
|
|
|
parent_session = sessions.get(self.ppid) |
|
if parent_session is None: |
|
parent_session = sessions.get(self.pid) |
|
if parent_session is None: |
|
log.info("No active debug session for parent process of {0}.", self) |
|
else: |
|
if self.pid == parent_session.pid: |
|
parent_server = parent_session.server |
|
if not (parent_server and parent_server.connection.process_replaced): |
|
log.error("{0} is not expecting replacement.", parent_session) |
|
self.channel.close() |
|
return |
|
try: |
|
parent_session.client.notify_of_subprocess(self) |
|
return |
|
except Exception: |
|
|
|
|
|
|
|
|
|
log.swallow_exception( |
|
"Failed to notify parent session about {0}:", self |
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if is_first_server: |
|
return |
|
log.info("No clients to wait for - unblocking {0}.", self) |
|
try: |
|
self.channel.request("initialize", {"adapterID": "debugpy"}) |
|
self.channel.request("attach", {"subProcessId": self.pid}) |
|
self.channel.request("configurationDone") |
|
self.channel.request("disconnect") |
|
except Exception: |
|
log.swallow_exception("Failed to unblock orphaned subprocess:") |
|
self.channel.close() |
|
|
|
def __str__(self): |
|
return "Server" + ("[?]" if self.pid is None else f"[pid={self.pid}]") |
|
|
|
def authenticate(self): |
|
if access_token is None and adapter.access_token is None: |
|
return |
|
auth = self.channel.request( |
|
"pydevdAuthorize", {"debugServerAccessToken": access_token} |
|
) |
|
if auth["clientAccessToken"] != adapter.access_token: |
|
self.channel.close() |
|
raise RuntimeError('Mismatched "clientAccessToken"; server not authorized.') |
|
|
|
def request(self, request): |
|
raise request.isnt_valid( |
|
"Requests from the debug server to the client are not allowed." |
|
) |
|
|
|
def event(self, event): |
|
pass |
|
|
|
def terminated_event(self, event): |
|
self.channel.close() |
|
|
|
def disconnect(self): |
|
with _lock: |
|
self.disconnected = True |
|
if self.server is not None: |
|
|
|
|
|
|
|
self.server.disconnect() |
|
elif self in _connections: |
|
_connections.remove(self) |
|
_connections_changed.set() |
|
|
|
def attach_to_session(self, session): |
|
"""Attaches this server to the specified Session as a Server component. |
|
|
|
Raises ValueError if the server already belongs to some session. |
|
""" |
|
|
|
with _lock: |
|
if self.server is not None: |
|
raise ValueError |
|
log.info("Attaching {0} to {1}", self, session) |
|
self.server = Server(session, self) |
|
|
|
|
|
class Server(components.Component): |
|
"""Handles the debug server side of a debug session.""" |
|
|
|
message_handler = components.Component.message_handler |
|
|
|
connection: Connection |
|
|
|
class Capabilities(components.Capabilities): |
|
PROPERTIES = { |
|
"supportsCompletionsRequest": False, |
|
"supportsConditionalBreakpoints": False, |
|
"supportsConfigurationDoneRequest": False, |
|
"supportsDataBreakpoints": False, |
|
"supportsDelayedStackTraceLoading": False, |
|
"supportsDisassembleRequest": False, |
|
"supportsEvaluateForHovers": False, |
|
"supportsExceptionInfoRequest": False, |
|
"supportsExceptionOptions": False, |
|
"supportsFunctionBreakpoints": False, |
|
"supportsGotoTargetsRequest": False, |
|
"supportsHitConditionalBreakpoints": False, |
|
"supportsLoadedSourcesRequest": False, |
|
"supportsLogPoints": False, |
|
"supportsModulesRequest": False, |
|
"supportsReadMemoryRequest": False, |
|
"supportsRestartFrame": False, |
|
"supportsRestartRequest": False, |
|
"supportsSetExpression": False, |
|
"supportsSetVariable": False, |
|
"supportsStepBack": False, |
|
"supportsStepInTargetsRequest": False, |
|
"supportsTerminateRequest": True, |
|
"supportsTerminateThreadsRequest": False, |
|
"supportsValueFormattingOptions": False, |
|
"exceptionBreakpointFilters": [], |
|
"additionalModuleColumns": [], |
|
"supportedChecksumAlgorithms": [], |
|
} |
|
|
|
def __init__(self, session, connection): |
|
assert connection.server is None |
|
with session: |
|
assert not session.server |
|
super().__init__(session, channel=connection.channel) |
|
|
|
self.connection = connection |
|
|
|
assert self.session.pid is None |
|
if self.session.launcher and self.session.launcher.pid != self.pid: |
|
log.info( |
|
"Launcher reported PID={0}, but server reported PID={1}", |
|
self.session.launcher.pid, |
|
self.pid, |
|
) |
|
self.session.pid = self.pid |
|
|
|
session.server = self |
|
|
|
@property |
|
def pid(self): |
|
"""Process ID of the debuggee process, as reported by the server.""" |
|
return self.connection.pid |
|
|
|
@property |
|
def ppid(self): |
|
"""Parent process ID of the debuggee process, as reported by the server.""" |
|
return self.connection.ppid |
|
|
|
def initialize(self, request): |
|
assert request.is_request("initialize") |
|
self.connection.authenticate() |
|
request = self.channel.propagate(request) |
|
request.wait_for_response() |
|
self.capabilities = self.Capabilities(self, request.response) |
|
|
|
|
|
@message_handler |
|
def request(self, request): |
|
|
|
|
|
|
|
|
|
|
|
raise request.isnt_valid( |
|
"Requests from the debug server to the client are not allowed." |
|
) |
|
|
|
|
|
@message_handler |
|
def event(self, event): |
|
self.client.propagate_after_start(event) |
|
|
|
@message_handler |
|
def initialized_event(self, event): |
|
|
|
pass |
|
|
|
@message_handler |
|
def process_event(self, event): |
|
|
|
if not self.launcher: |
|
self.client.propagate_after_start(event) |
|
|
|
@message_handler |
|
def continued_event(self, event): |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if self.client.client_id not in ("visualstudio", "vsformac"): |
|
self.client.propagate_after_start(event) |
|
|
|
@message_handler |
|
def exited_event(self, event: messaging.Event): |
|
if event("pydevdReason", str, optional=True) == "processReplaced": |
|
|
|
|
|
|
|
|
|
self.connection.process_replaced = True |
|
else: |
|
|
|
if not self.launcher: |
|
self.client.propagate_after_start(event) |
|
|
|
@message_handler |
|
def terminated_event(self, event): |
|
|
|
self.channel.close() |
|
|
|
def detach_from_session(self): |
|
with _lock: |
|
self.is_connected = False |
|
self.channel.handlers = self.connection |
|
self.channel.name = self.channel.stream.name = str(self.connection) |
|
self.connection.server = None |
|
|
|
def disconnect(self): |
|
if self.connection.process_replaced: |
|
|
|
|
|
log.info("{0} is waiting for replacement subprocess.", self) |
|
session = self.session |
|
if not session.client or not session.client.is_connected: |
|
wait_for_connection( |
|
session, lambda conn: conn.pid == self.pid, timeout=30 |
|
) |
|
else: |
|
self.wait_for( |
|
lambda: ( |
|
not session.client |
|
or not session.client.is_connected |
|
or any( |
|
conn.pid == self.pid |
|
for conn in session.client.known_subprocesses |
|
) |
|
), |
|
timeout=30, |
|
) |
|
with _lock: |
|
_connections.remove(self.connection) |
|
_connections_changed.set() |
|
super().disconnect() |
|
|
|
|
|
def serve(host="127.0.0.1", port=0): |
|
global listener |
|
listener = sockets.serve("Server", Connection, host, port) |
|
sessions.report_sockets() |
|
return listener.getsockname() |
|
|
|
|
|
def is_serving(): |
|
return listener is not None |
|
|
|
|
|
def stop_serving(): |
|
global listener |
|
try: |
|
if listener is not None: |
|
listener.close() |
|
listener = None |
|
except Exception: |
|
log.swallow_exception(level="warning") |
|
sessions.report_sockets() |
|
|
|
|
|
def connections(): |
|
with _lock: |
|
return list(_connections) |
|
|
|
|
|
def wait_for_connection(session, predicate, timeout=None): |
|
"""Waits until there is a server matching the specified predicate connected to |
|
this adapter, and returns the corresponding Connection. |
|
|
|
If there is more than one server connection already available, returns the oldest |
|
one. |
|
""" |
|
|
|
def wait_for_timeout(): |
|
time.sleep(timeout) |
|
wait_for_timeout.timed_out = True |
|
with _lock: |
|
_connections_changed.set() |
|
|
|
wait_for_timeout.timed_out = timeout == 0 |
|
if timeout: |
|
thread = threading.Thread( |
|
target=wait_for_timeout, name="servers.wait_for_connection() timeout" |
|
) |
|
thread.daemon = True |
|
thread.start() |
|
|
|
if timeout != 0: |
|
log.info("{0} waiting for connection from debug server...", session) |
|
while True: |
|
with _lock: |
|
_connections_changed.clear() |
|
conns = (conn for conn in _connections if predicate(conn)) |
|
conn = next(conns, None) |
|
if conn is not None or wait_for_timeout.timed_out: |
|
return conn |
|
_connections_changed.wait() |
|
|
|
|
|
def wait_until_disconnected(): |
|
"""Blocks until all debug servers disconnect from the adapter. |
|
|
|
If there are no server connections, waits until at least one is established first, |
|
before waiting for it to disconnect. |
|
""" |
|
while True: |
|
_connections_changed.wait() |
|
with _lock: |
|
_connections_changed.clear() |
|
if not len(_connections): |
|
return |
|
|
|
|
|
def dont_wait_for_first_connection(): |
|
"""Unblocks any pending wait_until_disconnected() call that is waiting on the |
|
first server to connect. |
|
""" |
|
with _lock: |
|
_connections_changed.set() |
|
|
|
|
|
def inject(pid, debugpy_args, on_output): |
|
host, port = listener.getsockname() |
|
|
|
cmdline = [ |
|
sys.executable, |
|
os.path.dirname(debugpy.__file__), |
|
"--connect", |
|
host + ":" + str(port), |
|
] |
|
if adapter.access_token is not None: |
|
cmdline += ["--adapter-access-token", adapter.access_token] |
|
cmdline += debugpy_args |
|
cmdline += ["--pid", str(pid)] |
|
|
|
log.info("Spawning attach-to-PID debugger injector: {0!r}", cmdline) |
|
try: |
|
injector = subprocess.Popen( |
|
cmdline, |
|
bufsize=0, |
|
stdin=subprocess.PIPE, |
|
stdout=subprocess.PIPE, |
|
stderr=subprocess.STDOUT, |
|
) |
|
except Exception as exc: |
|
log.swallow_exception( |
|
"Failed to inject debug server into process with PID={0}", pid |
|
) |
|
raise messaging.MessageHandlingError( |
|
"Failed to inject debug server into process with PID={0}: {1}".format( |
|
pid, exc |
|
) |
|
) |
|
|
|
|
|
|
|
|
|
|
|
output_collected = [] |
|
output_collected.append("--- Starting attach to pid: {0} ---\n".format(pid)) |
|
|
|
def capture(stream): |
|
nonlocal output_collected |
|
try: |
|
while True: |
|
line = stream.readline() |
|
if not line: |
|
break |
|
line = line.decode("utf-8", "replace") |
|
output_collected.append(line) |
|
log.info("Injector[PID={0}] output: {1}", pid, line.rstrip()) |
|
log.info("Injector[PID={0}] exited.", pid) |
|
except Exception: |
|
s = io.StringIO() |
|
traceback.print_exc(file=s) |
|
on_output("stderr", s.getvalue()) |
|
|
|
threading.Thread( |
|
target=capture, |
|
name=f"Injector[PID={pid}] stdout", |
|
args=(injector.stdout,), |
|
daemon=True, |
|
).start() |
|
|
|
def info_on_timeout(): |
|
nonlocal output_collected |
|
taking_longer_than_expected = False |
|
initial_time = time.time() |
|
while True: |
|
time.sleep(1) |
|
returncode = injector.poll() |
|
if returncode is not None: |
|
if returncode != 0: |
|
|
|
on_output( |
|
"stderr", |
|
"Attach to PID failed.\n\n", |
|
) |
|
|
|
old = output_collected |
|
output_collected = [] |
|
contents = "".join(old) |
|
on_output("stderr", "".join(contents)) |
|
break |
|
|
|
elapsed = time.time() - initial_time |
|
on_output( |
|
"stdout", "Attaching to PID: %s (elapsed: %.2fs).\n" % (pid, elapsed) |
|
) |
|
|
|
if not taking_longer_than_expected: |
|
if elapsed > 10: |
|
taking_longer_than_expected = True |
|
if sys.platform in ("linux", "linux2"): |
|
on_output( |
|
"stdout", |
|
"\nThe attach to PID is taking longer than expected.\n", |
|
) |
|
on_output( |
|
"stdout", |
|
"On Linux it's possible to customize the value of\n", |
|
) |
|
on_output( |
|
"stdout", |
|
"`PYDEVD_GDB_SCAN_SHARED_LIBRARIES` so that fewer libraries.\n", |
|
) |
|
on_output( |
|
"stdout", |
|
"are scanned when searching for the needed symbols.\n\n", |
|
) |
|
on_output( |
|
"stdout", |
|
"i.e.: set in your environment variables (and restart your editor/client\n", |
|
) |
|
on_output( |
|
"stdout", |
|
"so that it picks up the updated environment variable value):\n\n", |
|
) |
|
on_output( |
|
"stdout", |
|
"PYDEVD_GDB_SCAN_SHARED_LIBRARIES=libdl, libltdl, libc, libfreebl3\n\n", |
|
) |
|
on_output( |
|
"stdout", |
|
"-- the actual library may be different (the gdb output typically\n", |
|
) |
|
on_output( |
|
"stdout", |
|
"-- writes the libraries that will be used, so, it should be possible\n", |
|
) |
|
on_output( |
|
"stdout", |
|
"-- to test other libraries if the above doesn't work).\n\n", |
|
) |
|
if taking_longer_than_expected: |
|
|
|
old = output_collected |
|
output_collected = [] |
|
contents = "".join(old) |
|
if contents: |
|
on_output("stderr", contents) |
|
|
|
threading.Thread( |
|
target=info_on_timeout, name=f"Injector[PID={pid}] info on timeout", daemon=True |
|
).start() |
|
|