Add support for the goto request (#1575)

* Working goto handler

* Fix stack_trace_len

* Get test working

* Review feedback

* Use new_dap_id in the IDMap

* Use format string instead of old way

* Review feedback

* Feedback for step class rewrite

* Use trace function to detect line problems

* Remove unnecessary logging

* Optimize the stack_trace property

* Move error checking for stack frame to adapter
This commit is contained in:
Rich Chiodo 2024-05-02 11:24:07 -07:00 committed by GitHub
parent 2f4ed23203
commit 278ed2fe2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 220 additions and 63 deletions

View file

@ -67,6 +67,7 @@ CAPABILITIES_V2 = {
"supportsSetExpression": True,
"supportsTerminateRequest": True,
"supportsClipboardContext": True,
"supportsGotoTargetsRequest": True,
}
access_token = None

View file

@ -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, id_generator=partial(next, itertools.count(0))):
self._value_to_key = {}
self._key_to_value = {}
self._next_id = id_generator
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:
return any(
(len(co_line) == 3 and co_line[2] == line for co_line in code.co_lines())
)

View file

@ -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
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(new_dap_id)
def __init__(self, stream: messaging.JsonIOStream):
self._is_initialized = False
@ -287,13 +290,21 @@ class Adapter:
if thread is None:
raise request.isnt_valid(f'Unknown thread with "threadId":{thread_id}')
stop_frame = None if levels is None else start_frame + levels
try:
stack_trace = thread.get_stack_trace()
except ValueError:
raise request.isnt_valid(f"Thread {thread_id} is not suspended")
stop_frame = (
len(stack_trace) if levels == () or levels == 0 else start_frame + levels
)
log.info(f"stackTrace info {start_frame} {stop_frame}")
frames = None
try:
frames = islice(thread.stack_trace(), start_frame, stop_frame)
frames = islice(stack_trace, start_frame, stop_frame)
return {
"stackFrames": list(frames),
"totalFrames": thread.stack_trace_len(),
"totalFrames": len(stack_trace),
}
finally:
del frames
@ -346,6 +357,52 @@ class Adapter:
self._tracer.step_over(thread)
return {}
def gotoTargets_request(self, request: Request) -> dict:
source = request("source", json.object())
path = source("path", str)
source = Source(path)
line = request("line", int)
target_id = self._goto_targets_map.obtain_key((source, line))
target = {"id": target_id, "label": f"({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)
try:
source, 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_path = inspect.getsourcefile(thread.current_frame.f_code)
current_source = Source(current_path) if current_path is not None else None
if current_source != source:
return request.cant_handle(
f"{source} is not in the same code block as the current frame",
silent=True,
)
# Create a callback for when the goto actually finishes. We don't
# want to send our response until then.
def goto_finished(e: Exception | None):
if e is not None:
request.cant_handle(
f"Line {line} is not in the same code block as the current frame",
silent=True,
)
else:
request.respond({})
self._tracer.goto(thread, source, line, goto_finished)
# Response will happen when the line_change_callback happens
return messaging.NO_RESPONSE
def exceptionInfo_request(self, request: Request):
thread_id = request("threadId", int)
thread = Thread.get(thread_id)

View file

@ -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
@ -15,8 +14,8 @@ from debugpy.server.eval import Scope, VariableContainer
from enum import Enum
from pathlib import Path
from sys import monitoring
from types import CodeType, FrameType
from typing import ClassVar, Literal, Union
from types import CodeType, FrameType, FunctionType
from typing import Any, ClassVar, Generator, Literal, Union, override
# Shared for all global state pertaining to breakpoints and stepping.
_cvar = threading.Condition()
@ -123,12 +122,6 @@ class Thread:
python_thread: threading.Thread
"""The Python thread object this DAP Thread represents."""
current_frame: FrameType | None
"""
The Python frame object corresponding to the topmost stack frame on this thread
if it is suspended, or None if it is running.
"""
current_exception: ExceptionInfo | None
"""
The exception currently being propagated on this thread, if any.
@ -146,7 +139,15 @@ class Thread:
can exclude a specific thread from tracing.
"""
pending_callback: FunctionType | None
"""
As a result of a https://microsoft.github.io/debug-adapter-protocol/specification#Requests_Goto
this is a callback to run when the thread is notified after a goto
"""
_all: ClassVar[dict[int, "Thread"]] = {}
_current_frame: FrameType | None
_cached_stack: list["StackFrame"] | None
def __init__(self, python_thread: threading.Thread):
"""
@ -158,6 +159,7 @@ class Thread:
self.current_frame = None
self.is_known_to_adapter = False
self.is_traced = True
self.pending_callback = 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
@ -187,6 +189,20 @@ class Thread:
@property
def name(self) -> str:
return self.python_thread.name
@property
def current_frame(self) -> FrameType | None:
"""
The Python frame object corresponding to the topmost stack frame on this thread
if it is suspended, or None if it is running.
"""
return self._current_frame
@current_frame.setter
def current_frame(self, val: FrameType | None):
# Clear our stack frame list whenever the current frame changes
self._cached_stack = None
self._current_frame = val
@classmethod
def from_python_thread(self, python_thread: threading.Thread) -> "Thread":
@ -251,25 +267,20 @@ class Thread:
self.is_known_to_adapter = True
return True
def stack_trace_len(self) -> int:
"""
Returns the total count of frames in this thread's stack.
"""
try:
with _cvar:
python_frame = self.current_frame
except ValueError:
raise ValueError(f"Can't get frames for inactive Thread({self.id})")
try:
return len(tuple(traceback.walk_stack(python_frame)))
finally:
del python_frame
def stack_trace(self) -> Iterable["StackFrame"]:
def get_stack_trace(self) -> list["StackFrame"]:
"""
Returns an iterable of StackFrame objects for the current stack of this thread,
Returns a list of StackFrame objects for the current stack of this thread,
starting with the topmost frame.
"""
# If our current frame is none, this is invalid. Throw an error.
if self._current_frame is None:
raise ValueError(reason="Thread is not suspended")
if self._cached_stack is None:
self._cached_stack = list(self._generate_stack_trace())
return self._cached_stack
def _generate_stack_trace(self) -> Generator["StackFrame", Any, None]:
try:
with _cvar:
python_frame = self.current_frame
@ -375,42 +386,60 @@ class StackFrame:
frame.python_frame = None
@dataclass
class Step:
step: Literal["in", "out", "over"]
origin: FrameType = None
origin_line: int = None
def __init__(
self,
origin: FrameType = None,
origin_line: int = None,
):
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):
step = "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):
step = "over"
@override
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):
step = "out"
@override
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

View file

@ -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,12 +17,15 @@ from debugpy.server.tracing import (
Source,
StackFrame,
Step,
StepIn,
StepOut,
StepOver,
Thread,
_cvar,
is_internal_python_frame,
)
from sys import monitoring
from types import CodeType, FrameType, TracebackType
from types import CodeType, FrameType, FunctionType, TracebackType
from typing import Literal
@ -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,41 @@ 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, source: Source, line: int, finish_callback: FunctionType
):
log.info(f"Goto {source}:{line} on {thread}")
"""
Change the instruction pointer of the current thread to point to
the new line/source file.
"""
def goto_handler():
# Filter out runtime warnings that come from doing a goto
with warnings.catch_warnings():
warnings.simplefilter("ignore")
try:
thread.current_frame.f_lineno = line
except ValueError as e:
finish_callback(e)
else:
finish_callback(None)
# Send a stop once we finish changing the line number
self._begin_stop(thread, "goto")
with _cvar:
# We do this with a callback because only the trace function
# can change the lineno
thread.pending_callback = goto_handler
# Notify just this thread
_cvar.notify(thread.id)
def _begin_stop(
self,
thread: Thread,
@ -280,7 +315,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 +324,13 @@ class Tracer:
thread.current_frame = python_frame
while self._stopped_by is not None:
_cvar.wait()
# This thread may need to run some code while it's still
# stopped.
if thread.pending_callback is not None:
thread.pending_callback()
thread.pending_callback = None
thread.current_frame = None
log.info(f"{thread} resumed.")
StackFrame.invalidate(thread)

View file

@ -56,7 +56,6 @@ def test_set_next_statement(pyfile, run, target):
inner2_target = targets[0]["id"]
session.request("goto", {"threadId": stop.thread_id, "targetId": inner2_target})
session.wait_for_next_event("continued")
stop = session.wait_for_stop(
"goto", expected_frames=[some.dap.frame(code_to_debug, "inner2")]

View file

@ -11,7 +11,7 @@ from tests import code
from tests.patterns import some, _impl
id = some.int.in_range(0, 10000)
id = some.int.in_range(-10000, 10000)
"""Matches a DAP "id", assuming some reasonable range for an implementation that
generates those ids sequentially.
"""