|
|
|
|
|
|
|
|
|
from __future__ import annotations |
|
|
|
import atexit |
|
import os |
|
import sys |
|
|
|
import debugpy |
|
from debugpy import adapter, common, launcher |
|
from debugpy.common import json, log, messaging, sockets |
|
from debugpy.adapter import clients, components, launchers, servers, sessions |
|
|
|
|
|
class Client(components.Component): |
|
"""Handles the client side of a debug session.""" |
|
|
|
message_handler = components.Component.message_handler |
|
|
|
known_subprocesses: set[servers.Connection] |
|
"""Server connections to subprocesses that this client has been made aware of. |
|
""" |
|
|
|
class Capabilities(components.Capabilities): |
|
PROPERTIES = { |
|
"supportsVariableType": False, |
|
"supportsVariablePaging": False, |
|
"supportsRunInTerminalRequest": False, |
|
"supportsMemoryReferences": False, |
|
"supportsArgsCanBeInterpretedByShell": False, |
|
"supportsStartDebuggingRequest": False, |
|
} |
|
|
|
class Expectations(components.Capabilities): |
|
PROPERTIES = { |
|
"locale": "en-US", |
|
"linesStartAt1": True, |
|
"columnsStartAt1": True, |
|
"pathFormat": json.enum("path", optional=True), |
|
} |
|
|
|
def __init__(self, sock): |
|
if sock == "stdio": |
|
log.info("Connecting to client over stdio...", self) |
|
self.using_stdio = True |
|
stream = messaging.JsonIOStream.from_stdio() |
|
|
|
|
|
sys.stdin = stdin = open(os.devnull, "r") |
|
atexit.register(stdin.close) |
|
sys.stdout = stdout = open(os.devnull, "w") |
|
atexit.register(stdout.close) |
|
else: |
|
self.using_stdio = False |
|
stream = messaging.JsonIOStream.from_socket(sock) |
|
|
|
with sessions.Session() as session: |
|
super().__init__(session, stream) |
|
|
|
self.client_id = None |
|
"""ID of the connecting client. This can be 'test' while running tests.""" |
|
|
|
self.has_started = False |
|
"""Whether the "launch" or "attach" request was received from the client, and |
|
fully handled. |
|
""" |
|
|
|
self.start_request = None |
|
"""The "launch" or "attach" request as received from the client. |
|
""" |
|
|
|
self.restart_requested = False |
|
"""Whether the client requested the debug adapter to be automatically |
|
restarted via "restart":true in the start request. |
|
""" |
|
|
|
self._initialize_request = None |
|
"""The "initialize" request as received from the client, to propagate to the |
|
server later.""" |
|
|
|
self._deferred_events = [] |
|
"""Deferred events from the launcher and the server that must be propagated |
|
only if and when the "launch" or "attach" response is sent. |
|
""" |
|
|
|
self._forward_terminate_request = False |
|
|
|
self.known_subprocesses = set() |
|
|
|
session.client = self |
|
session.register() |
|
|
|
|
|
|
|
self.channel.send_event( |
|
"output", |
|
{ |
|
"category": "telemetry", |
|
"output": "ptvsd", |
|
"data": {"packageVersion": debugpy.__version__}, |
|
}, |
|
) |
|
self.channel.send_event( |
|
"output", |
|
{ |
|
"category": "telemetry", |
|
"output": "debugpy", |
|
"data": {"packageVersion": debugpy.__version__}, |
|
}, |
|
) |
|
sessions.report_sockets() |
|
|
|
def propagate_after_start(self, event): |
|
|
|
|
|
|
|
|
|
if self._deferred_events is not None: |
|
self._deferred_events.append(event) |
|
log.debug("Propagation deferred.") |
|
else: |
|
self.client.channel.propagate(event) |
|
|
|
def _propagate_deferred_events(self): |
|
log.debug("Propagating deferred events to {0}...", self.client) |
|
for event in self._deferred_events: |
|
log.debug("Propagating deferred {0}", event.describe()) |
|
self.client.channel.propagate(event) |
|
log.info("All deferred events propagated to {0}.", self.client) |
|
self._deferred_events = None |
|
|
|
|
|
|
|
|
|
@message_handler |
|
def event(self, event): |
|
if self.server: |
|
self.server.channel.propagate(event) |
|
|
|
|
|
@message_handler |
|
def request(self, request): |
|
return self.server.channel.delegate(request) |
|
|
|
@message_handler |
|
def initialize_request(self, request): |
|
if self._initialize_request is not None: |
|
raise request.isnt_valid("Session is already initialized") |
|
|
|
self.client_id = request("clientID", "") |
|
self.capabilities = self.Capabilities(self, request) |
|
self.expectations = self.Expectations(self, request) |
|
self._initialize_request = request |
|
|
|
exception_breakpoint_filters = [ |
|
{ |
|
"filter": "raised", |
|
"label": "Raised Exceptions", |
|
"default": False, |
|
"description": "Break whenever any exception is raised.", |
|
}, |
|
{ |
|
"filter": "uncaught", |
|
"label": "Uncaught Exceptions", |
|
"default": True, |
|
"description": "Break when the process is exiting due to unhandled exception.", |
|
}, |
|
{ |
|
"filter": "userUnhandled", |
|
"label": "User Uncaught Exceptions", |
|
"default": False, |
|
"description": "Break when exception escapes into library code.", |
|
}, |
|
] |
|
|
|
return { |
|
"supportsCompletionsRequest": True, |
|
"supportsConditionalBreakpoints": True, |
|
"supportsConfigurationDoneRequest": True, |
|
"supportsDebuggerProperties": True, |
|
"supportsDelayedStackTraceLoading": True, |
|
"supportsEvaluateForHovers": True, |
|
"supportsExceptionInfoRequest": True, |
|
"supportsExceptionOptions": True, |
|
"supportsFunctionBreakpoints": True, |
|
"supportsHitConditionalBreakpoints": True, |
|
"supportsLogPoints": True, |
|
"supportsModulesRequest": True, |
|
"supportsSetExpression": True, |
|
"supportsSetVariable": True, |
|
"supportsValueFormattingOptions": True, |
|
"supportsTerminateRequest": True, |
|
"supportsGotoTargetsRequest": True, |
|
"supportsClipboardContext": True, |
|
"exceptionBreakpointFilters": exception_breakpoint_filters, |
|
"supportsStepInTargetsRequest": True, |
|
} |
|
|
|
|
|
|
|
|
|
|
|
def _start_message_handler(f): |
|
@components.Component.message_handler |
|
def handle(self, request): |
|
assert request.is_request("launch", "attach") |
|
if self._initialize_request is None: |
|
raise request.isnt_valid("Session is not initialized yet") |
|
if self.launcher or self.server: |
|
raise request.isnt_valid("Session is already started") |
|
|
|
self.session.no_debug = request("noDebug", json.default(False)) |
|
if self.session.no_debug: |
|
servers.dont_wait_for_first_connection() |
|
|
|
self.session.debug_options = debug_options = set( |
|
request("debugOptions", json.array(str)) |
|
) |
|
|
|
f(self, request) |
|
if request.response is not None: |
|
return |
|
|
|
if self.server: |
|
self.server.initialize(self._initialize_request) |
|
self._initialize_request = None |
|
|
|
arguments = request.arguments |
|
if self.launcher: |
|
redirecting = arguments.get("console") == "internalConsole" |
|
if "RedirectOutput" in debug_options: |
|
|
|
|
|
arguments = dict(arguments) |
|
arguments["debugOptions"] = list( |
|
debug_options - {"RedirectOutput"} |
|
) |
|
redirecting = True |
|
|
|
if arguments.get("redirectOutput"): |
|
arguments = dict(arguments) |
|
del arguments["redirectOutput"] |
|
redirecting = True |
|
|
|
arguments["isOutputRedirected"] = redirecting |
|
|
|
|
|
|
|
|
|
try: |
|
self.server.channel.request(request.command, arguments) |
|
except messaging.NoMoreMessages: |
|
|
|
|
|
|
|
|
|
request.respond({}) |
|
self.session.finalize( |
|
"{0} disconnected before responding to {1}".format( |
|
self.server, |
|
json.repr(request.command), |
|
) |
|
) |
|
return |
|
except messaging.MessageHandlingError as exc: |
|
exc.propagate(request) |
|
|
|
if self.session.no_debug: |
|
self.start_request = request |
|
self.has_started = True |
|
request.respond({}) |
|
self._propagate_deferred_events() |
|
return |
|
|
|
|
|
self.channel.send_event("initialized") |
|
|
|
self.start_request = request |
|
return messaging.NO_RESPONSE |
|
|
|
return handle |
|
|
|
@_start_message_handler |
|
def launch_request(self, request): |
|
from debugpy.adapter import launchers |
|
|
|
if self.session.id != 1 or len(servers.connections()): |
|
raise request.cant_handle('"attach" expected') |
|
|
|
debug_options = set(request("debugOptions", json.array(str))) |
|
|
|
|
|
|
|
|
|
def property_or_debug_option(prop_name, flag_name): |
|
assert prop_name[0].islower() and flag_name[0].isupper() |
|
|
|
value = request(prop_name, bool, optional=True) |
|
if value == (): |
|
value = None |
|
|
|
if flag_name in debug_options: |
|
if value is False: |
|
raise request.isnt_valid( |
|
'{0}:false and "debugOptions":[{1}] are mutually exclusive', |
|
json.repr(prop_name), |
|
json.repr(flag_name), |
|
) |
|
value = True |
|
|
|
return value |
|
|
|
|
|
|
|
python_key = "python" |
|
if python_key in request: |
|
if "pythonPath" in request: |
|
raise request.isnt_valid( |
|
'"pythonPath" is not valid if "python" is specified' |
|
) |
|
elif "pythonPath" in request: |
|
python_key = "pythonPath" |
|
python = request(python_key, json.array(str, vectorize=True, size=(0,))) |
|
if not len(python): |
|
python = [sys.executable] |
|
|
|
python += request("pythonArgs", json.array(str, size=(0,))) |
|
request.arguments["pythonArgs"] = python[1:] |
|
request.arguments["python"] = python |
|
|
|
launcher_python = request("debugLauncherPython", str, optional=True) |
|
if launcher_python == (): |
|
launcher_python = python[0] |
|
|
|
program = module = code = () |
|
if "program" in request: |
|
program = request("program", str) |
|
args = [program] |
|
request.arguments["processName"] = program |
|
if "module" in request: |
|
module = request("module", str) |
|
args = ["-m", module] |
|
request.arguments["processName"] = module |
|
if "code" in request: |
|
code = request("code", json.array(str, vectorize=True, size=(1,))) |
|
args = ["-c", "\n".join(code)] |
|
request.arguments["processName"] = "-c" |
|
|
|
num_targets = len([x for x in (program, module, code) if x != ()]) |
|
if num_targets == 0: |
|
raise request.isnt_valid( |
|
'either "program", "module", or "code" must be specified' |
|
) |
|
elif num_targets != 1: |
|
raise request.isnt_valid( |
|
'"program", "module", and "code" are mutually exclusive' |
|
) |
|
|
|
console = request( |
|
"console", |
|
json.enum( |
|
"internalConsole", |
|
"integratedTerminal", |
|
"externalTerminal", |
|
optional=True, |
|
), |
|
) |
|
console_title = request("consoleTitle", json.default("Python Debug Console")) |
|
|
|
|
|
target_args = request("args", json.array(str, vectorize=True)) |
|
args += target_args |
|
|
|
|
|
shell_expand_args = len(target_args) > 0 and isinstance( |
|
request.arguments["args"], str |
|
) |
|
if shell_expand_args: |
|
if not self.capabilities["supportsArgsCanBeInterpretedByShell"]: |
|
raise request.isnt_valid( |
|
'Shell expansion in "args" is not supported by the client' |
|
) |
|
if console == "internalConsole": |
|
raise request.isnt_valid( |
|
'Shell expansion in "args" is not available for "console":"internalConsole"' |
|
) |
|
|
|
cwd = request("cwd", str, optional=True) |
|
if cwd == (): |
|
|
|
|
|
cwd = None if program == () else (os.path.dirname(program) or None) |
|
|
|
sudo = bool(property_or_debug_option("sudo", "Sudo")) |
|
if sudo and sys.platform == "win32": |
|
raise request.cant_handle('"sudo":true is not supported on Windows.') |
|
|
|
on_terminate = request("onTerminate", str, optional=True) |
|
|
|
if on_terminate: |
|
self._forward_terminate_request = on_terminate == "KeyboardInterrupt" |
|
|
|
launcher_path = request("debugLauncherPath", os.path.dirname(launcher.__file__)) |
|
adapter_host = request("debugAdapterHost", "127.0.0.1") |
|
|
|
try: |
|
servers.serve(adapter_host) |
|
except Exception as exc: |
|
raise request.cant_handle( |
|
"{0} couldn't create listener socket for servers: {1}", |
|
self.session, |
|
exc, |
|
) |
|
|
|
launchers.spawn_debuggee( |
|
self.session, |
|
request, |
|
[launcher_python], |
|
launcher_path, |
|
adapter_host, |
|
args, |
|
shell_expand_args, |
|
cwd, |
|
console, |
|
console_title, |
|
sudo, |
|
) |
|
|
|
@_start_message_handler |
|
def attach_request(self, request): |
|
if self.session.no_debug: |
|
raise request.isnt_valid('"noDebug" is not supported for "attach"') |
|
|
|
host = request("host", str, optional=True) |
|
port = request("port", int, optional=True) |
|
listen = request("listen", dict, optional=True) |
|
connect = request("connect", dict, optional=True) |
|
pid = request("processId", (int, str), optional=True) |
|
sub_pid = request("subProcessId", int, optional=True) |
|
on_terminate = request("onTerminate", bool, optional=True) |
|
|
|
if on_terminate: |
|
self._forward_terminate_request = on_terminate == "KeyboardInterrupt" |
|
|
|
if host != () or port != (): |
|
if listen != (): |
|
raise request.isnt_valid( |
|
'"listen" and "host"/"port" are mutually exclusive' |
|
) |
|
if connect != (): |
|
raise request.isnt_valid( |
|
'"connect" and "host"/"port" are mutually exclusive' |
|
) |
|
if listen != (): |
|
if connect != (): |
|
raise request.isnt_valid( |
|
'"listen" and "connect" are mutually exclusive' |
|
) |
|
if pid != (): |
|
raise request.isnt_valid( |
|
'"listen" and "processId" are mutually exclusive' |
|
) |
|
if sub_pid != (): |
|
raise request.isnt_valid( |
|
'"listen" and "subProcessId" are mutually exclusive' |
|
) |
|
if pid != () and sub_pid != (): |
|
raise request.isnt_valid( |
|
'"processId" and "subProcessId" are mutually exclusive' |
|
) |
|
|
|
if listen != (): |
|
if servers.is_serving(): |
|
raise request.isnt_valid( |
|
'Multiple concurrent "listen" sessions are not supported' |
|
) |
|
host = listen("host", "127.0.0.1") |
|
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() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if pid != (): |
|
if not isinstance(pid, int): |
|
try: |
|
pid = int(pid) |
|
except Exception: |
|
raise request.isnt_valid('"processId" must be parseable as int') |
|
debugpy_args = request("debugpyArgs", json.array(str)) |
|
|
|
def on_output(category, output): |
|
self.channel.send_event( |
|
"output", |
|
{ |
|
"category": category, |
|
"output": output, |
|
}, |
|
) |
|
|
|
try: |
|
servers.inject(pid, debugpy_args, on_output) |
|
except Exception as e: |
|
log.swallow_exception() |
|
self.session.finalize( |
|
"Error when trying to attach to PID:\n%s" % (str(e),) |
|
) |
|
return |
|
|
|
timeout = common.PROCESS_SPAWN_TIMEOUT |
|
pred = lambda conn: conn.pid == pid |
|
else: |
|
if sub_pid == (): |
|
pred = lambda conn: True |
|
timeout = common.PROCESS_SPAWN_TIMEOUT if listen == () else None |
|
else: |
|
pred = lambda conn: conn.pid == sub_pid |
|
timeout = 0 |
|
|
|
self.channel.send_event("debugpyWaitingForServer", {"host": host, "port": port}) |
|
conn = servers.wait_for_connection(self.session, pred, timeout) |
|
if conn is None: |
|
if sub_pid != (): |
|
|
|
|
|
|
|
|
|
request.respond({}) |
|
self.session.finalize( |
|
'No known subprocess with "subProcessId":{0}'.format(sub_pid) |
|
) |
|
return |
|
|
|
raise request.cant_handle( |
|
( |
|
"Timed out waiting for debug server to connect." |
|
if timeout |
|
else "There is no debug server connected to this adapter." |
|
), |
|
sub_pid, |
|
) |
|
|
|
try: |
|
conn.attach_to_session(self.session) |
|
except ValueError: |
|
request.cant_handle("{0} is already being debugged.", conn) |
|
|
|
@message_handler |
|
def configurationDone_request(self, request): |
|
if self.start_request is None or self.has_started: |
|
request.cant_handle( |
|
'"configurationDone" is only allowed during handling of a "launch" ' |
|
'or an "attach" request' |
|
) |
|
|
|
try: |
|
self.has_started = True |
|
try: |
|
result = self.server.channel.delegate(request) |
|
except messaging.NoMoreMessages: |
|
|
|
|
|
|
|
|
|
request.respond({}) |
|
self.start_request.respond({}) |
|
self.session.finalize( |
|
"{0} disconnected before responding to {1}".format( |
|
self.server, |
|
json.repr(request.command), |
|
) |
|
) |
|
return |
|
else: |
|
request.respond(result) |
|
except messaging.MessageHandlingError as exc: |
|
self.start_request.cant_handle(str(exc)) |
|
finally: |
|
if self.start_request.response is None: |
|
self.start_request.respond({}) |
|
self._propagate_deferred_events() |
|
|
|
|
|
|
|
for conn in servers.connections(): |
|
if conn.server is None and conn.ppid == self.session.pid: |
|
self.notify_of_subprocess(conn) |
|
|
|
@message_handler |
|
def evaluate_request(self, request): |
|
propagated_request = self.server.channel.propagate(request) |
|
|
|
def handle_response(response): |
|
request.respond(response.body) |
|
|
|
propagated_request.on_response(handle_response) |
|
|
|
return messaging.NO_RESPONSE |
|
|
|
@message_handler |
|
def pause_request(self, request): |
|
request.arguments["threadId"] = "*" |
|
return self.server.channel.delegate(request) |
|
|
|
@message_handler |
|
def continue_request(self, request): |
|
request.arguments["threadId"] = "*" |
|
|
|
try: |
|
return self.server.channel.delegate(request) |
|
except messaging.NoMoreMessages: |
|
|
|
|
|
|
|
return {"allThreadsContinued": True} |
|
|
|
@message_handler |
|
def debugpySystemInfo_request(self, request): |
|
result = {"debugpy": {"version": debugpy.__version__}} |
|
if self.server: |
|
try: |
|
pydevd_info = self.server.channel.request("pydevdSystemInfo") |
|
except Exception: |
|
|
|
|
|
pass |
|
else: |
|
result.update(pydevd_info) |
|
return result |
|
|
|
@message_handler |
|
def terminate_request(self, request): |
|
|
|
|
|
self.restart_requested = False |
|
|
|
if self._forward_terminate_request: |
|
|
|
|
|
|
|
|
|
|
|
|
|
return self.server.channel.delegate(request) |
|
|
|
self.session.finalize('client requested "terminate"', terminate_debuggee=True) |
|
return {} |
|
|
|
@message_handler |
|
def disconnect_request(self, request): |
|
|
|
|
|
self.restart_requested = False |
|
|
|
terminate_debuggee = request("terminateDebuggee", bool, optional=True) |
|
if terminate_debuggee == (): |
|
terminate_debuggee = None |
|
self.session.finalize('client requested "disconnect"', terminate_debuggee) |
|
request.respond({}) |
|
|
|
if self.using_stdio: |
|
|
|
|
|
servers.stop_serving() |
|
log.info("{0} disconnected from stdio; closing remaining server connections.", self) |
|
for conn in servers.connections(): |
|
try: |
|
conn.channel.close() |
|
except Exception: |
|
log.swallow_exception() |
|
|
|
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: |
|
if self.start_request is None or conn in self.known_subprocesses: |
|
return |
|
if "processId" in self.start_request.arguments: |
|
log.warning( |
|
"Not reporting subprocess for {0}, because the parent process " |
|
'was attached to using "processId" rather than "port".', |
|
self.session, |
|
) |
|
return |
|
|
|
log.info("Notifying {0} about {1}.", self, conn) |
|
body = dict(self.start_request.arguments) |
|
self.known_subprocesses.add(conn) |
|
self.session.notify_changed() |
|
|
|
for key in "processId", "listen", "preLaunchTask", "postDebugTask", "request", "restart": |
|
body.pop(key, None) |
|
|
|
body["name"] = "Subprocess {0}".format(conn.pid) |
|
body["subProcessId"] = conn.pid |
|
|
|
for key in "args", "processName", "pythonArgs": |
|
body.pop(key, None) |
|
|
|
host = body.pop("host", None) |
|
port = body.pop("port", None) |
|
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" |
|
if "port" not in body["connect"]: |
|
if port is None: |
|
_, port = listener.getsockname() |
|
body["connect"]["port"] = port |
|
|
|
if self.capabilities["supportsStartDebuggingRequest"]: |
|
self.channel.request("startDebugging", { |
|
"request": "attach", |
|
"configuration": body, |
|
}) |
|
else: |
|
body["request"] = "attach" |
|
self.channel.send_event("debugpyAttach", body) |
|
|
|
|
|
def serve(host, port): |
|
global listener |
|
listener = sockets.serve("Client", Client, host, port) |
|
sessions.report_sockets() |
|
return listener.getsockname() |
|
|
|
|
|
def stop_serving(): |
|
global listener |
|
if listener is not None: |
|
try: |
|
listener.close() |
|
except Exception: |
|
log.swallow_exception(level="warning") |
|
listener = None |
|
sessions.report_sockets() |
|
|