mirror of
https://github.com/microsoft/debugpy.git
synced 2025-12-23 08:48:12 +00:00
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:
parent
2f4ed23203
commit
278ed2fe2a
7 changed files with 220 additions and 63 deletions
|
|
@ -67,6 +67,7 @@ CAPABILITIES_V2 = {
|
|||
"supportsSetExpression": True,
|
||||
"supportsTerminateRequest": True,
|
||||
"supportsClipboardContext": True,
|
||||
"supportsGotoTargetsRequest": True,
|
||||
}
|
||||
|
||||
access_token = None
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue