Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[sys.monitoring] Breakpoints #1512

Merged
merged 3 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/debugpy/common/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,5 +160,6 @@ def hide_thread_from_debugger(thread):
DEBUGPY_TRACE_DEBUGPY is used to debug debugpy with debugpy
"""
if hide_debugpy_internals():
thread.is_debugpy_thread = True
thread.pydev_do_not_trace = True
thread.is_pydev_daemon_thread = True
17 changes: 17 additions & 0 deletions src/debugpy/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,25 @@
# Licensed under the MIT License. See LICENSE in the project root
# for license information.

import itertools

# Unique IDs for DAP objects such as threads, variables, breakpoints etc. These are
# negative to allow for pre-existing OS-assigned IDs (which are positive) to be used
# where available, e.g. for threads.
_dap_ids = itertools.count(-1, -1)


def new_dap_id():
"""Returns the next unique ID."""
return next(_dap_ids)


def adapter():
"""
Returns the instance of Adapter corresponding to the debug adapter that is currently
connected to this process, or None if there is no adapter connected. Use in lieu of
Adapter.instance to avoid import cycles.
"""
from debugpy.server.adapters import Adapter

return Adapter.instance
183 changes: 129 additions & 54 deletions src/debugpy/server/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,18 @@

from debugpy.adapter import components
from debugpy.common import json, log, messaging, sockets
from debugpy.common.messaging import Request
from debugpy.server import tracing, eval
from debugpy.server.tracing import Breakpoint, StackFrame
from debugpy.common.messaging import MessageDict, Request
from debugpy.server import eval, new_dap_id
from debugpy.server.tracing import (
Breakpoint,
Condition,
HitCondition,
LogMessage,
Source,
StackFrame,
Thread,
Tracer,
)


class Adapter:
Expand Down Expand Up @@ -50,13 +59,13 @@ class Expectations(components.Capabilities):
server_access_token = None
"""Access token that the adapter must use to authenticate with this server."""


_is_initialized: bool = False
_has_started: bool = False
_client_id: str = None
_capabilities: Capabilities = None
_expectations: Expectations = None
_start_request: messaging.Request = None
_tracer: Tracer = None

def __init__(self, stream: messaging.JsonIOStream):
self._is_initialized = False
Expand All @@ -65,6 +74,7 @@ def __init__(self, stream: messaging.JsonIOStream):
self._capabilities = None
self._expectations = None
self._start_request = None
self._tracer = Tracer.instance

self.channel = messaging.JsonMessageChannel(stream, self)
self.channel.start()
Expand Down Expand Up @@ -124,21 +134,25 @@ def initialize_request(self, request: Request):
"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.",
},
# TODO: https://github.com/microsoft/debugpy/issues/1453
# {
# "filter": "uncaught",
# "label": "Uncaught Exceptions",
# "default": True,
# "description": "Break when the process is exiting due to unhandled exception.",
# },
# TODO: https://github.com/microsoft/debugpy/issues/1454
# {
# "filter": "userUnhandled",
# "label": "User Uncaught Exceptions",
# "default": False,
# "description": "Break when exception escapes into library code.",
# },
]

return {
"exceptionBreakpointFilters": exception_breakpoint_filters,
"supportsClipboardContext": True,
"supportsCompletionsRequest": True,
"supportsConditionalBreakpoints": True,
"supportsConfigurationDoneRequest": True,
Expand All @@ -148,17 +162,15 @@ def initialize_request(self, request: Request):
"supportsExceptionInfoRequest": True,
"supportsExceptionOptions": True,
"supportsFunctionBreakpoints": True,
"supportsGotoTargetsRequest": 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,
"supportsTerminateRequest": True,
"supportsValueFormattingOptions": True,
}

def _handle_start_request(self, request: Request):
Expand Down Expand Up @@ -189,7 +201,7 @@ def configurationDone_request(self, request: Request):
'or an "attach" request'
)

tracing.start()
self._tracer.start()
self._has_started = True

request.respond({})
Expand Down Expand Up @@ -218,8 +230,7 @@ def setExceptionBreakpoints_request(self, request: Request):
def setBreakpoints_request(self, request: Request):
# TODO: implement source.reference for setting breakpoints in sources for
# which source code was decompiled or retrieved via inspect.getsource.
source = request("source", json.object())
path = source("path", str)
source = Source(request("source", json.object())("path", str))

# TODO: implement column support.
# Use dis.get_instruction() to iterate over instructions and corresponding
Expand All @@ -233,20 +244,79 @@ def setBreakpoints_request(self, request: Request):
bps = list(request("breakpoints", json.array(json.object())))
else:
lines = request("lines", json.array(int))
bps = [{"line": line} for line in lines]

Breakpoint.clear([path])
bps_set = [Breakpoint.set(path, bp["line"]) for bp in bps]
bps = [MessageDict(request, {"line": line}) for line in lines]

Breakpoint.clear([source])

# Do the first pass validating conditions and log messages for syntax errors; if
# any breakpoint fails validation, we want to respond with an error right away
# so that user gets immediate feedback, but this also means that we shouldn't
# actually set any breakpoints until we've validated all of them.
bps_info = []
for bp in bps:
id = new_dap_id()
line = bp("line", int)

# A missing condition or log message can be represented as the corresponding
# property missing, or as the property being present but set to empty string.

condition = bp("condition", str, optional=True)
if condition:
try:
condition = Condition(id, condition)
except SyntaxError as exc:
raise request.isnt_valid(
f"Syntax error in condition ({condition}): {exc}"
)
else:
condition = None

hit_condition = bp("hitCondition", str, optional=True)
if hit_condition:
try:
hit_condition = HitCondition(id, hit_condition)
except SyntaxError as exc:
raise request.isnt_valid(
f"Syntax error in hit condition ({hit_condition}): {exc}"
)
else:
hit_condition = None

log_message = bp("logMessage", str, optional=True)
if log_message:
try:
log_message = LogMessage(id, log_message)
except SyntaxError as exc:
raise request.isnt_valid(
f"Syntax error in log message f{log_message!r}: {exc}"
)
else:
log_message = None

bps_info.append((id, source, line, condition, hit_condition, log_message))

# Now that we know all breakpoints are syntactically valid, we can set them.
bps_set = [
Breakpoint(
id,
source,
line,
condition=condition,
hit_condition=hit_condition,
log_message=log_message,
)
for id, source, line, condition, hit_condition, log_message in bps_info
]
return {"breakpoints": bps_set}

def threads_request(self, request: Request):
return {"threads": tracing.Thread.enumerate()}
return {"threads": Thread.enumerate()}

def stackTrace_request(self, request: Request):
thread_id = request("threadId", int)
start_frame = request("startFrame", 0)

thread = tracing.Thread.get(thread_id)
thread = Thread.get(thread_id)
if thread is None:
raise request.isnt_valid(f'Invalid "threadId": {thread_id}')

Expand All @@ -260,39 +330,44 @@ def stackTrace_request(self, request: Request):
finally:
del frames

# For "pause" and "continue" requests, DAP requires a thread ID to be specified,
# but does not require the adapter to only pause/unpause the specified thread.
# Visual Studio debug adapter host does not support the ability to pause/unpause
# only the specified thread, and requires the adapter to always pause/unpause all
# threads. For "continue" requests, there is a capability flag that the client can
# use to indicate support for per-thread continuation, but there's no such flag
# for per-thread pausing. Furethermore, the semantics of unpausing a specific
# thread after all threads have been paused is unclear in the event the unpaused
# thread then spawns additional threads. Therefore, we always ignore the "threadId"
# property and just pause/unpause everything.

def pause_request(self, request: Request):
if request.arguments.get("threadId", None) == "*":
thread_ids = None
else:
thread_ids = [request("threadId", int)]
tracing.pause(thread_ids)
try:
self._tracer.pause()
except ValueError:
raise request.cant_handle("No threads to pause")
return {}

def continue_request(self, request: Request):
if request.arguments.get("threadId", None) == "*":
thread_ids = None
else:
thread_ids = [request("threadId", int)]
single_thread = request("singleThread", False)
tracing.resume(thread_ids if single_thread else None)
self._tracer.resume()
return {}

def stepIn_request(self, request: Request):
# TODO: support "singleThread" and "granularity"
thread_id = request("threadId", int)
tracing.step_in(thread_id)
thread = Thread.get(request("threadId", int))
self._tracer.step_in(thread)
return {}

def stepOut_request(self, request: Request):
# TODO: support "singleThread" and "granularity"
thread_id = request("threadId", int)
tracing.step_out(thread_id)
thread = Thread.get(request("threadId", int))
self._tracer.step_out(thread)
return {}

def next_request(self, request: Request):
# TODO: support "singleThread" and "granularity"
thread_id = request("threadId", int)
tracing.step_over(thread_id)
thread = Thread.get(request("threadId", int))
self._tracer.step_over(thread)
return {}

def scopes_request(self, request: Request):
Expand All @@ -316,18 +391,18 @@ def evaluate_request(self, request: Request):
return {"result": var.repr, "variablesReference": var.id}

def disconnect_request(self, request: Request):
tracing.Breakpoint.clear()
tracing.abandon_step()
tracing.resume()
Breakpoint.clear()
self._tracer.abandon_step()
self._tracer.resume()
return {}

def terminate_request(self, request: Request):
tracing.Breakpoint.clear()
tracing.abandon_step()
tracing.resume()
Breakpoint.clear()
self._tracer.abandon_step()
self._tracer.resume()
return {}

def disconnect(self):
tracing.resume()
self._tracer.resume()
self.connected_event.clear()
return {}
19 changes: 9 additions & 10 deletions src/debugpy/server/eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,28 @@
# Licensed under the MIT License. See LICENSE in the project root
# for license information.

import debugpy
import threading

from collections.abc import Iterable
from debugpy.server.inspect import inspect
from types import FrameType
from typing import ClassVar, Dict, Literal, Self

from debugpy.server import tracing
from debugpy.server.inspect import inspect

ScopeKind = Literal["global", "nonlocal", "local"]
type ScopeKind = Literal["global", "nonlocal", "local"]
type StackFrame = "debugpy.server.tracing.StackFrame"


_lock = threading.RLock()


class VariableContainer:
frame: "tracing.StackFrame"
frame: StackFrame
id: int

_last_id: ClassVar[int] = 0
_all: ClassVar[Dict[int, "VariableContainer"]] = {}

def __init__(self, frame: "tracing.StackFrame"):
def __init__(self, frame: StackFrame):
self.frame = frame
with _lock:
VariableContainer._last_id += 1
Expand All @@ -46,7 +45,7 @@ def variables(self) -> Iterable["Variable"]:
raise NotImplementedError

@classmethod
def invalidate(self, *frames: Iterable["tracing.StackFrame"]) -> None:
def invalidate(self, *frames: Iterable[StackFrame]) -> None:
with _lock:
ids = [
id
Expand All @@ -61,7 +60,7 @@ class Scope(VariableContainer):
frame: FrameType
kind: ScopeKind

def __init__(self, frame: "tracing.StackFrame", kind: ScopeKind):
def __init__(self, frame: StackFrame, kind: ScopeKind):
super().__init__(frame)
self.kind = kind

Expand Down Expand Up @@ -92,7 +91,7 @@ class Variable(VariableContainer):
value: object
# TODO: evaluateName, memoryReference, presentationHint

def __init__(self, frame: "tracing.StackFrame", name: str, value: object):
def __init__(self, frame: StackFrame, name: str, value: object):
super().__init__(frame)
self.name = name
self.value = value
Expand Down
Loading
Loading