From fcc292e7360fff0266334640b0de403434b2d6ab Mon Sep 17 00:00:00 2001 From: Rich Chiodo false Date: Tue, 30 Apr 2024 15:55:54 -0700 Subject: [PATCH] Working goto handler --- src/debugpy/adapter/__init__.py | 1 + src/debugpy/common/util.py | 29 +++++++++ src/debugpy/server/adapters.py | 37 +++++++++++ src/debugpy/server/tracing/__init__.py | 89 +++++++++++++++++--------- src/debugpy/server/tracing/tracer.py | 37 +++++++++-- 5 files changed, 157 insertions(+), 36 deletions(-) diff --git a/src/debugpy/adapter/__init__.py b/src/debugpy/adapter/__init__.py index 348d33ce..fb303477 100644 --- a/src/debugpy/adapter/__init__.py +++ b/src/debugpy/adapter/__init__.py @@ -67,6 +67,7 @@ CAPABILITIES_V2 = { "supportsSetExpression": True, "supportsTerminateRequest": True, "supportsClipboardContext": True, + "supportsGotoTargetsRequest": True, } access_token = None diff --git a/src/debugpy/common/util.py b/src/debugpy/common/util.py index 57dfea80..e9555972 100644 --- a/src/debugpy/common/util.py +++ b/src/debugpy/common/util.py @@ -2,9 +2,12 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. +from functools import partial import inspect +import itertools import os import sys +from types import CodeType def evaluate(code, path=__file__, mode="eval"): @@ -163,3 +166,29 @@ def hide_thread_from_debugger(thread): thread.is_debugpy_thread = True thread.pydev_do_not_trace = True thread.is_pydev_daemon_thread = True + +class IDMap(object): + + def __init__(self): + self._value_to_key = {} + self._key_to_value = {} + self._next_id = partial(next, itertools.count(0)) + + def obtain_value(self, key): + return self._key_to_value[key] + + def obtain_key(self, value): + try: + key = self._value_to_key[value] + except KeyError: + key = self._next_id() + self._key_to_value[key] = value + self._value_to_key[value] = key + return key + +def contains_line(code: CodeType, line: int) -> bool: + for co_line in code.co_lines(): + if len(co_line) == 3: + if co_line[2] == line: + return True + return False \ No newline at end of file diff --git a/src/debugpy/server/adapters.py b/src/debugpy/server/adapters.py index d34d1c40..47823a5f 100644 --- a/src/debugpy/server/adapters.py +++ b/src/debugpy/server/adapters.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. See LICENSE in the project root # for license information. +import inspect import os import sys import threading @@ -11,6 +12,7 @@ from debugpy import adapter from debugpy.adapter import components from debugpy.common import json, log, messaging, sockets from debugpy.common.messaging import MessageDict, Request +from debugpy.common.util import IDMap, contains_line from debugpy.server import eval, new_dap_id from debugpy.server.tracing import ( Breakpoint, @@ -60,6 +62,7 @@ class Adapter: _capabilities: Capabilities = None _expectations: Expectations = None _start_request: messaging.Request = None + _goto_targets_map: IDMap = IDMap() def __init__(self, stream: messaging.JsonIOStream): self._is_initialized = False @@ -346,6 +349,40 @@ class Adapter: self._tracer.step_over(thread) return {} + def gotoTargets_request(self, request: Request) -> dict: + path = request("source", dict)["path"] + line = request("line", int) + target_id = self._goto_targets_map.obtain_key((path, line)) + target = {"id": target_id, "label": "%s:%s" % (path, line), "line": line} + + return {"targets": [target]} + + + def goto_request(self, request: Request) -> dict: + thread_id = request("threadId", int) + thread = Thread.get(thread_id) + if thread is None: + return request.isnt_valid(f'Unknown thread with "threadId":{thread_id}') + target_id = request("targetId", int) + if target_id is None: + return request.isnt_valid('Unknown targetId for goto') + try: + path, line = self._goto_targets_map.obtain_value(target_id) + except KeyError: + return request.isnt_valid('Invalid targetId for goto') + + # Make sure the thread is in the same source file + current_source = inspect.getsourcefile(thread.current_frame.f_code) + if current_source.casefold() != path.casefold(): + return request.cant_handle(f'{path} is not in the same code block as the current frame', silent=True) + + # Make sure line number is in the same code black + if not contains_line(thread.current_frame.f_code, line): + return request.cant_handle(f'Line {line} is not in the same code block as the current frame', silent=True) + + self._tracer.goto(thread, path, line) + return {} + def exceptionInfo_request(self, request: Request): thread_id = request("threadId", int) thread = Thread.get(thread_id) diff --git a/src/debugpy/server/tracing/__init__.py b/src/debugpy/server/tracing/__init__.py index 53a32730..05c4ab2a 100644 --- a/src/debugpy/server/tracing/__init__.py +++ b/src/debugpy/server/tracing/__init__.py @@ -7,7 +7,6 @@ import threading import traceback from collections import defaultdict from collections.abc import Callable, Iterable -from dataclasses import dataclass from debugpy import server from debugpy.common import log from debugpy.server import new_dap_id @@ -16,7 +15,7 @@ from enum import Enum from pathlib import Path from sys import monitoring from types import CodeType, FrameType -from typing import ClassVar, Literal, Union +from typing import ClassVar, Literal, Union, override # Shared for all global state pertaining to breakpoints and stepping. _cvar = threading.Condition() @@ -146,6 +145,12 @@ class Thread: can exclude a specific thread from tracing. """ + pending_ip: int | None + """ + As a result of a https://microsoft.github.io/debug-adapter-protocol/specification#Requests_Goto + this is the line number for the current thread to switch to while it is stopped. + """ + _all: ClassVar[dict[int, "Thread"]] = {} def __init__(self, python_thread: threading.Thread): @@ -158,6 +163,7 @@ class Thread: self.current_frame = None self.is_known_to_adapter = False self.is_traced = True + self.pending_ip = None # Thread IDs are serialized as JSON numbers in DAP, which are handled as 64-bit # floats by most DAP clients. However, OS thread IDs can be large 64-bit integers @@ -375,42 +381,61 @@ class StackFrame: frame.python_frame = None -@dataclass class Step: - step: Literal["in", "out", "over"] - origin: FrameType = None - origin_line: int = None + def __init__( + self, + step: Literal["in", "out", "over", "goto"], + origin: FrameType = None, + origin_line: int = None, + ): + self.step = step + self.origin = origin + self.origin_line = origin_line def __repr__(self): return f"Step({self.step})" def is_complete(self, python_frame: FrameType) -> bool: - # TODO: avoid using traceback.walk_stack every time by counting stack - # depth via PY_CALL / PY_RETURN events. - is_complete = False - if self.step == "in": - is_complete = ( - python_frame is not self.origin - or python_frame.f_lineno != self.origin_line - ) - elif self.step == "over": - is_complete = True - for python_frame, _ in traceback.walk_stack(python_frame): - if ( - python_frame is self.origin - and python_frame.f_lineno == self.origin_line - ): - is_complete = False - break - return is_complete - elif self.step == "out": - is_complete = True - for python_frame, _ in traceback.walk_stack(python_frame): - if python_frame is self.origin: - is_complete = False - break - else: - raise ValueError(f"Unknown step type: {self.step}") + raise ValueError(f"Unknown step type: {self.step}") + + +class StepIn(Step): + def __init__(self): + super().__init__("in") + + @override + def is_complete(self, python_frame: FrameType) -> bool: + return ( + python_frame is not self.origin or python_frame.f_lineno != self.origin_line + ) + + +class StepOver(Step): + def __init__(self): + super().__init__("over") + + def is_complete(self, python_frame: FrameType) -> bool: + is_complete = True + for python_frame, _ in traceback.walk_stack(python_frame): + if ( + python_frame is self.origin + and python_frame.f_lineno == self.origin_line + ): + is_complete = False + break + return is_complete + + +class StepOut(Step): + def __init__(self): + super().__init__("out") + + def is_complete(self, python_frame: FrameType) -> bool: + is_complete = True + for python_frame, _ in traceback.walk_stack(python_frame): + if python_frame is self.origin: + is_complete = False + break return is_complete diff --git a/src/debugpy/server/tracing/tracer.py b/src/debugpy/server/tracing/tracer.py index 9244eada..af6d3203 100644 --- a/src/debugpy/server/tracing/tracer.py +++ b/src/debugpy/server/tracing/tracer.py @@ -8,6 +8,7 @@ import sys import threading import traceback from collections.abc import Iterable +import warnings from debugpy import server from debugpy.server.tracing import ( Breakpoint, @@ -16,6 +17,9 @@ from debugpy.server.tracing import ( Source, StackFrame, Step, + StepIn, + StepOut, + StepOver, Thread, _cvar, is_internal_python_frame, @@ -208,7 +212,7 @@ class Tracer: """ log.info(f"Step in on {thread}.") with _cvar: - self._steps[thread] = Step("in") + self._steps[thread] = StepIn() self._end_stop() monitoring.restart_events() @@ -218,7 +222,7 @@ class Tracer: """ log.info(f"Step out on {thread}.") with _cvar: - self._steps[thread] = Step("out") + self._steps[thread] = StepOut() self._end_stop() monitoring.restart_events() @@ -228,10 +232,24 @@ class Tracer: Step over the next statement executed by the specified thread. """ with _cvar: - self._steps[thread] = Step("over") + self._steps[thread] = StepOver() self._end_stop() monitoring.restart_events() + def goto(self, thread: Thread, path: str, line: int): + log.info(f"Goto {path}:{line} on {thread}") + """ + Change the instruction pointer of the current thread to point to + the new line/source file. + """ + with _cvar: + thread.pending_ip = line + # Notify just this thread + _cvar.notify(thread.id) + + # Act like a new stop happened + self._begin_stop(thread, "goto") + def _begin_stop( self, thread: Thread, @@ -280,7 +298,7 @@ class Tracer: Suspends execution of this thread until the current stop ends. """ - thread = self._this_thread() + thread: Thread | None = self._this_thread() with _cvar: if self._stopped_by is None: return @@ -289,6 +307,17 @@ class Tracer: thread.current_frame = python_frame while self._stopped_by is not None: _cvar.wait() + + # This thread may have had its IP changed. We + # want to change the IP before we resume but we + # need to change the IP during a trace callback. + if thread.pending_ip is not None: + # Filter out runtime warnings that come from doing a goto + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + thread.current_frame.f_lineno = thread.pending_ip + thread.pending_ip = None + thread.current_frame = None log.info(f"{thread} resumed.") StackFrame.invalidate(thread)