Initial implementation of Debugger Adapter Protocol

The Debugger Adapter Protocol is a JSON-RPC protocol that IDEs can use
to communicate with debuggers.  You can find more information here:

    https://microsoft.github.io/debug-adapter-protocol/

Frequently this is implemented as a shim, but it seemed to me that GDB
could implement it directly, via the Python API.  This patch is the
initial implementation.

DAP is implemented as a new "interp".  This is slightly weird, because
it doesn't act like an ordinary interpreter -- for example it doesn't
implement a command syntax, and doesn't use GDB's ordinary event loop.
However, this seemed like the best approach overall.

To run GDB in this mode, use:

    gdb -i=dap

The DAP code will accept JSON-RPC messages on stdin and print
responses to stdout.  GDB redirects the inferior's stdout to a new
pipe so that output can be encapsulated by the protocol.

The Python code uses multiple threads to do its work.  Separate
threads are used for reading JSON from the client and for writing JSON
to the client.  All GDB work is done in the main thread.  (The first
implementation used asyncio, but this had some limitations, and so I
rewrote it to use threads instead.)

This is not a complete implementation of the protocol, but it does
implement enough to demonstrate that the overall approach works.

There is a rudimentary test suite.  It uses a JSON parser written in
pure Tcl.  This parser is under the same license as Tcl itself, so I
felt it was acceptable to simply import it into the tree.

There is also a bit of documentation -- just documenting the new
interpreter name.
This commit is contained in:
Tom Tromey 2022-06-23 11:11:36 -06:00
parent c43d829bca
commit de7d7cb58e
26 changed files with 2297 additions and 2 deletions

View file

@ -0,0 +1,69 @@
# Copyright 2022 Free Software Foundation, Inc.
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
import gdb
# This must come before other DAP imports.
from . import startup
# Load modules that define commands.
from . import breakpoint
from . import bt
from . import disassemble
from . import evaluate
from . import launch
from . import next
from . import pause
from . import scopes
from . import threads
from .server import Server
def run():
"""Main entry point for the DAP server.
This is called by the GDB DAP interpreter."""
startup.exec_and_log("set python print-stack full")
startup.exec_and_log("set pagination off")
# We want to control gdb stdin and stdout entirely, so we dup
# them to new file descriptors.
saved_out = os.dup(1)
saved_in = os.dup(0)
# Make sure these are not inheritable. This is already the case
# for Unix, but not for Windows.
os.set_inheritable(saved_out, False)
os.set_inheritable(saved_in, False)
# The new gdb (and inferior) stdin will just be /dev/null. For
# gdb, the "dap" interpreter also rewires the UI so that gdb
# doesn't try to read this (and thus see EOF and exit).
new_in = os.open(os.devnull, os.O_RDONLY)
os.dup2(new_in, 0, True)
os.close(new_in)
# Make the new stdout be a pipe. This way the DAP code can easily
# read from the inferior and send OutputEvent to the client.
(rfd, wfd) = os.pipe()
os.set_inheritable(rfd, False)
os.dup2(wfd, 1, True)
# Also send stderr this way.
os.dup2(wfd, 2, True)
os.close(wfd)
# Note the inferior output is opened in text mode.
server = Server(open(saved_in, "rb"), open(saved_out, "wb"), open(rfd, "r"))
startup.start_dap(server.main_loop)

View file

@ -0,0 +1,143 @@
# Copyright 2022 Free Software Foundation, Inc.
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import gdb
import os
from .server import request, capability
from .startup import send_gdb_with_response, in_gdb_thread
# Map from the breakpoint "kind" (like "function") to a second map, of
# breakpoints of that type. The second map uses the breakpoint spec
# as a key, and the gdb.Breakpoint itself as a value. This is used to
# implement the clearing behavior specified by the protocol, while
# allowing for reuse when a breakpoint can be kept.
breakpoint_map = {}
@in_gdb_thread
def breakpoint_descriptor(bp):
"Return the Breakpoint object descriptor given a gdb Breakpoint."
if bp.locations:
# Just choose the first location, because DAP doesn't allow
# multiple locations. See
# https://github.com/microsoft/debug-adapter-protocol/issues/13
loc = bp.locations[0]
(basename, line) = loc.source
return {
"id": bp.number,
"verified": True,
"source": {
"name": os.path.basename(basename),
"path": loc.fullname,
# We probably don't need this but it doesn't hurt to
# be explicit.
"sourceReference": 0,
},
"line": line,
"instructionReference": hex(loc.address),
}
else:
return {
"id": bp.number,
"verified": False,
}
# Helper function to set some breakpoints according to a list of
# specifications.
@in_gdb_thread
def _set_breakpoints(kind, specs):
global breakpoint_map
# Try to reuse existing breakpoints if possible.
if kind in breakpoint_map:
saved_map = breakpoint_map[kind]
else:
saved_map = {}
breakpoint_map[kind] = {}
result = []
for spec in specs:
keyspec = frozenset(spec.items())
if keyspec in saved_map:
bp = saved_map.pop(keyspec)
else:
# FIXME handle exceptions here
bp = gdb.Breakpoint(**spec)
breakpoint_map[kind][keyspec] = bp
result.append(breakpoint_descriptor(bp))
# Delete any breakpoints that were not reused.
for entry in saved_map.values():
entry.delete()
return result
@request("setBreakpoints")
def set_breakpoint(source, *, breakpoints=[], **args):
if "path" not in source:
result = []
else:
specs = []
for obj in breakpoints:
specs.append(
{
"source": source["path"],
"line": obj["line"],
}
)
# Be sure to include the path in the key, so that we only
# clear out breakpoints coming from this same source.
key = "source:" + source["path"]
result = send_gdb_with_response(lambda: _set_breakpoints(key, specs))
return {
"breakpoints": result,
}
@request("setFunctionBreakpoints")
@capability("supportsFunctionBreakpoints")
def set_fn_breakpoint(breakpoints, **args):
specs = []
for bp in breakpoints:
specs.append(
{
"function": bp["name"],
}
)
result = send_gdb_with_response(lambda: _set_breakpoints("function", specs))
return {
"breakpoints": result,
}
@request("setInstructionBreakpoints")
@capability("supportsInstructionBreakpoints")
def set_insn_breakpoints(*, breakpoints, offset=None, **args):
specs = []
for bp in breakpoints:
# There's no way to set an explicit address breakpoint
# from Python, so we rely on "spec" instead.
val = "*" + bp["instructionReference"]
if offset is not None:
val = val + " + " + str(offset)
specs.append(
{
"spec": val,
}
)
result = send_gdb_with_response(lambda: _set_breakpoints("instruction", specs))
return {
"breakpoints": result,
}

View file

@ -0,0 +1,93 @@
# Copyright 2022 Free Software Foundation, Inc.
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import gdb
import os
from .frames import frame_id
from .server import request, capability
from .startup import send_gdb_with_response, in_gdb_thread
from .state import set_thread
# Helper function to safely get the name of a frame as a string.
@in_gdb_thread
def _frame_name(frame):
name = frame.name()
if name is None:
name = "???"
return name
# Helper function to get a frame's SAL without an error.
@in_gdb_thread
def _safe_sal(frame):
try:
return frame.find_sal()
except gdb.error:
return None
# Helper function to compute a stack trace.
@in_gdb_thread
def _backtrace(thread_id, levels, startFrame):
set_thread(thread_id)
frames = []
current_number = 0
# FIXME could invoke frame filters here.
try:
current_frame = gdb.newest_frame()
except gdb.error:
current_frame = None
# Note that we always iterate over all frames, which is lame, but
# seemingly necessary to support the totalFrames response.
# FIXME maybe the mildly mysterious note about "monotonically
# increasing totalFrames values" would let us fix this.
while current_frame is not None:
# This condition handles the startFrame==0 case as well.
if current_number >= startFrame and (levels == 0 or len(frames) < levels):
newframe = {
"id": frame_id(current_frame),
"name": _frame_name(current_frame),
# This must always be supplied, but we will set it
# correctly later if that is possible.
"line": 0,
# GDB doesn't support columns.
"column": 0,
"instructionPointerReference": hex(current_frame.pc()),
}
sal = _safe_sal(current_frame)
if sal is not None:
newframe["source"] = {
"name": os.path.basename(sal.symtab.filename),
"path": sal.symtab.filename,
# We probably don't need this but it doesn't hurt
# to be explicit.
"sourceReference": 0,
}
newframe["line"] = sal.line
frames.append(newframe)
current_number = current_number + 1
current_frame = current_frame.older()
return {
"stackFrames": frames,
"totalFrames": current_number,
}
@request("stackTrace")
@capability("supportsDelayedStackTraceLoading")
def stacktrace(*, levels=0, startFrame=0, threadId, **extra):
return send_gdb_with_response(lambda: _backtrace(threadId, levels, startFrame))

View file

@ -0,0 +1,51 @@
# Copyright 2022 Free Software Foundation, Inc.
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import gdb
from .server import request, capability
from .startup import send_gdb_with_response, in_gdb_thread
@in_gdb_thread
def _disassemble(pc, skip_insns, count):
try:
arch = gdb.selected_frame().architecture()
except gdb.error:
# Maybe there was no frame.
arch = gdb.selected_inferior().architecture()
result = []
total_count = skip_insns + count
for elt in arch.disassemble(pc, count=total_count)[skip_insns:]:
result.append(
{
"address": hex(elt["addr"]),
"instruction": elt["asm"],
}
)
return {
"instructions": result,
}
@request("disassemble")
@capability("supportsDisassembleRequest")
def disassemble(
*, memoryReference, offset=0, instructionOffset=0, instructionCount, **extra
):
pc = int(memoryReference, 0) + offset
return send_gdb_with_response(
lambda: _disassemble(pc, instructionOffset, instructionCount)
)

View file

@ -0,0 +1,42 @@
# Copyright 2022 Free Software Foundation, Inc.
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import gdb
from .frames import frame_for_id
from .server import request
from .startup import send_gdb_with_response, in_gdb_thread
# Helper function to evaluate an expression in a certain frame.
@in_gdb_thread
def _evaluate(expr, frame_id):
if frame_id is not None:
frame = frame_for_id(frame_id)
frame.select()
return str(gdb.parse_and_eval(expr))
# FIXME 'format' & hex
# FIXME return a structured response using pretty-printers / varobj
# FIXME supportsVariableType handling
@request("evaluate")
def eval_request(expression, *, frameId=None, **args):
result = send_gdb_with_response(lambda: _evaluate(expression, frameId))
return {
"result": result,
# FIXME
"variablesReference": -1,
}

View file

@ -0,0 +1,166 @@
# Copyright 2022 Free Software Foundation, Inc.
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import enum
import gdb
from .server import send_event
from .startup import in_gdb_thread, Invoker, log
from .breakpoint import breakpoint_descriptor
@in_gdb_thread
def _on_exit(event):
code = 0
if hasattr(event, "exit_code"):
code = event.exit_code
send_event(
"exited",
{
"exitCode": code,
},
)
@in_gdb_thread
def _bp_modified(event):
send_event(
"breakpoint",
{
"reason": "changed",
"breakpoint": breakpoint_descriptor(event),
},
)
@in_gdb_thread
def _bp_created(event):
send_event(
"breakpoint",
{
"reason": "new",
"breakpoint": breakpoint_descriptor(event),
},
)
@in_gdb_thread
def _bp_deleted(event):
send_event(
"breakpoint",
{
"reason": "removed",
"breakpoint": breakpoint_descriptor(event),
},
)
@in_gdb_thread
def _new_thread(event):
send_event(
"thread",
{
"reason": "started",
"threadId": event.inferior_thread.global_num,
},
)
_suppress_cont = False
@in_gdb_thread
def _cont(event):
global _suppress_cont
if _suppress_cont:
log("_suppress_cont case")
_suppress_cont = False
else:
send_event(
"continued",
{
"threadId": gdb.selected_thread().global_num,
"allThreadsContinued": True,
},
)
class StopKinds(enum.Enum):
# The values here are chosen to follow the DAP spec.
STEP = "step"
BREAKPOINT = "breakpoint"
PAUSE = "pause"
EXCEPTION = "exception"
_expected_stop = None
@in_gdb_thread
def expect_stop(reason):
"""Indicate that a stop is expected, for the reason given."""
global _expected_stop
_expected_stop = reason
# A wrapper for Invoker that also sets the expected stop.
class ExecutionInvoker(Invoker):
"""A subclass of Invoker that sets the expected stop.
Note that this assumes that the command will restart the inferior,
so it will also cause ContinuedEvents to be suppressed."""
def __init__(self, cmd, expected):
super().__init__(cmd)
self.expected = expected
@in_gdb_thread
def __call__(self):
expect_stop(self.expected)
global _suppress_cont
_suppress_cont = True
# FIXME if the call fails should we clear _suppress_cont?
super().__call__()
@in_gdb_thread
def _on_stop(event):
log("entering _on_stop: " + repr(event))
global _expected_stop
obj = {
"threadId": gdb.selected_thread().global_num,
# FIXME we don't support non-stop for now.
"allThreadsStopped": True,
}
if isinstance(event, gdb.BreakpointEvent):
# Ignore the expected stop, we hit a breakpoint instead.
# FIXME differentiate between 'breakpoint', 'function breakpoint',
# 'data breakpoint' and 'instruction breakpoint' here.
_expected_stop = StopKinds.BREAKPOINT
obj["hitBreakpointIds"] = [x.number for x in event.breakpoints]
elif _expected_stop is None:
# FIXME what is even correct here
_expected_stop = StopKinds.EXCEPTION
obj["reason"] = _expected_stop.value
_expected_stop = None
send_event("stopped", obj)
gdb.events.stop.connect(_on_stop)
gdb.events.exited.connect(_on_exit)
gdb.events.breakpoint_created.connect(_bp_created)
gdb.events.breakpoint_modified.connect(_bp_modified)
gdb.events.breakpoint_deleted.connect(_bp_deleted)
gdb.events.new_thread.connect(_new_thread)
gdb.events.cont.connect(_cont)

View file

@ -0,0 +1,57 @@
# Copyright 2022 Free Software Foundation, Inc.
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import gdb
from .startup import in_gdb_thread
# Map from frame (thread,level) pair to frame ID numbers. Note we
# can't use the frame itself here as it is not hashable.
_frame_ids = {}
# Map from frame ID number back to frames.
_id_to_frame = {}
# Clear all the frame IDs.
@in_gdb_thread
def _clear_frame_ids(evt):
global _frame_ids, _id_to_frame
_frame_ids = {}
_id_to_frame = {}
# Clear the frame ID map whenever the inferior runs.
gdb.events.cont.connect(_clear_frame_ids)
@in_gdb_thread
def frame_id(frame):
"""Return the frame identifier for FRAME."""
global _frame_ids, _id_to_frame
pair = (gdb.selected_thread().global_num, frame.level)
if pair not in _frame_ids:
id = len(_frame_ids)
_frame_ids[pair] = id
_id_to_frame[id] = frame
return _frame_ids[pair]
@in_gdb_thread
def frame_for_id(id):
"""Given a frame identifier ID, return the corresponding frame."""
global _id_to_frame
return _id_to_frame[id]

View file

@ -0,0 +1,67 @@
# Copyright 2022 Free Software Foundation, Inc.
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
from .startup import start_thread, send_gdb
def read_json(stream):
"""Read a JSON-RPC message from STREAM.
The decoded object is returned."""
# First read and parse the header.
content_length = None
while True:
line = stream.readline()
line = line.strip()
if line == b"":
break
if line.startswith(b"Content-Length:"):
line = line[15:].strip()
content_length = int(line)
data = bytes()
while len(data) < content_length:
new_data = stream.read(content_length - len(data))
data += new_data
result = json.loads(data)
return result
def start_json_writer(stream, queue):
"""Start the JSON writer thread.
It will read objects from QUEUE and write them to STREAM,
following the JSON-RPC protocol."""
def _json_writer():
seq = 1
while True:
obj = queue.get()
if obj is None:
# This is an exit request. The stream is already
# flushed, so all that's left to do is request an
# exit.
send_gdb("quit")
break
obj["seq"] = seq
seq = seq + 1
encoded = json.dumps(obj)
body_bytes = encoded.encode("utf-8")
header = f"Content-Length: {len(body_bytes)}\r\n\r\n"
header_bytes = header.encode("ASCII")
stream.write(header_bytes)
stream.write(body_bytes)
stream.flush()
start_thread("JSON writer", _json_writer)

View file

@ -0,0 +1,39 @@
# Copyright 2022 Free Software Foundation, Inc.
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from .events import ExecutionInvoker
from .server import request, capability
from .startup import send_gdb
_program = None
@request("launch")
def launch(*, program=None, **args):
if program is not None:
global _program
_program = program
send_gdb(f"file {_program}")
@capability("supportsConfigurationDoneRequest")
@request("configurationDone")
def config_done(**args):
global _program
if _program is not None:
# Suppress the continue event, but don't set any particular
# expected stop.
send_gdb(ExecutionInvoker("run", None))

View file

@ -0,0 +1,51 @@
# Copyright 2022 Free Software Foundation, Inc.
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from .events import StopKinds, ExecutionInvoker
from .server import capability, request
from .startup import send_gdb
from .state import set_thread
# Helper function to set the current thread.
def _handle_thread_step(threadId):
# Ensure we're going to step the correct thread.
send_gdb(lambda: set_thread(threadId))
@request("next")
def next(*, threadId, granularity="statement", **args):
_handle_thread_step(threadId)
cmd = "next"
if granularity == "instruction":
cmd += "i"
send_gdb(ExecutionInvoker(cmd, StopKinds.STEP))
@capability("supportsSteppingGranularity")
@request("stepIn")
def stepIn(*, threadId, granularity="statement", **args):
_handle_thread_step(threadId)
cmd = "step"
if granularity == "instruction":
cmd += "i"
send_gdb(ExecutionInvoker(cmd, StopKinds.STEP))
@request("continue")
def continue_request(**args):
send_gdb(ExecutionInvoker("continue", None))
# FIXME Just ignore threadId for the time being, and assume all-stop.
return {"allThreadsContinued": True}

View file

@ -0,0 +1,23 @@
# Copyright 2022 Free Software Foundation, Inc.
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from .events import StopKinds, ExecutionInvoker
from .server import request
from .startup import send_gdb
@request("pause")
def pause(**args):
send_gdb(ExecutionInvoker("interrupt -a", StopKinds.PAUSE))

View file

@ -0,0 +1,65 @@
# Copyright 2022 Free Software Foundation, Inc.
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import gdb
from .frames import frame_for_id
from .startup import send_gdb_with_response, in_gdb_thread
from .server import request
# Helper function to return a frame's block without error.
@in_gdb_thread
def _safe_block(frame):
try:
return frame.block()
except gdb.error:
return None
# Helper function to return a list of variables of block, up to the
# enclosing function.
@in_gdb_thread
def _block_vars(block):
result = []
while True:
result += list(block)
if block.function is not None:
break
block = block.superblock
return result
# Helper function to create a DAP scopes for a given frame ID.
@in_gdb_thread
def _get_scope(id):
frame = frame_for_id(id)
block = _safe_block(frame)
scopes = []
if block is not None:
new_scope = {
# FIXME
"name": "Locals",
"expensive": False,
"namedVariables": len(_block_vars(block)),
}
scopes.append(new_scope)
return scopes
@request("scopes")
def scopes(*, frameId, **extra):
scopes = send_gdb_with_response(lambda: _get_scope(frameId))
return {"scopes": scopes}

View file

@ -0,0 +1,205 @@
# Copyright 2022 Free Software Foundation, Inc.
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
import queue
from .io import start_json_writer, read_json
from .startup import (
in_dap_thread,
start_thread,
log,
log_stack,
send_gdb_with_response,
)
# Map capability names to values.
_capabilities = {}
# Map command names to callables.
_commands = {}
# The global server.
_server = None
class Server:
"""The DAP server class."""
def __init__(self, in_stream, out_stream, child_stream):
self.in_stream = in_stream
self.out_stream = out_stream
self.child_stream = child_stream
self.delayed_events = []
# This queue accepts JSON objects that are then sent to the
# DAP client. Writing is done in a separate thread to avoid
# blocking the read loop.
self.write_queue = queue.SimpleQueue()
self.done = False
global _server
_server = self
# Treat PARAMS as a JSON-RPC request and perform its action.
# PARAMS is just a dictionary from the JSON.
@in_dap_thread
def _handle_command(self, params):
# We don't handle 'cancel' for now.
result = {
"request_seq": params["seq"],
"type": "response",
"command": params["command"],
}
try:
if "arguments" in params:
args = params["arguments"]
else:
args = {}
global _commands
body = _commands[params["command"]](**args)
if body is not None:
result["body"] = body
result["success"] = True
except BaseException as e:
log_stack()
result["success"] = False
result["message"] = str(e)
return result
# Read inferior output and sends OutputEvents to the client. It
# is run in its own thread.
def _read_inferior_output(self):
while True:
line = self.child_stream.readline()
self.send_event(
"output",
{
"category": "stdout",
"output": line,
},
)
# Send OBJ to the client, logging first if needed.
def _send_json(self, obj):
log("WROTE: <<<" + json.dumps(obj) + ">>>")
self.write_queue.put(obj)
# This must be run in the DAP thread, but we can't use
# @in_dap_thread here because the global isn't set until after
# this starts running. FIXME.
def main_loop(self):
"""The main loop of the DAP server."""
# Before looping, start the thread that writes JSON to the
# client, and the thread that reads output from the inferior.
start_thread("output reader", self._read_inferior_output)
start_json_writer(self.out_stream, self.write_queue)
while not self.done:
cmd = read_json(self.in_stream)
log("READ: <<<" + json.dumps(cmd) + ">>>")
result = self._handle_command(cmd)
self._send_json(result)
events = self.delayed_events
self.delayed_events = []
for (event, body) in events:
self.send_event(event, body)
# Got the terminate request. This is handled by the
# JSON-writing thread, so that we can ensure that all
# responses are flushed to the client before exiting.
self.write_queue.put(None)
@in_dap_thread
def send_event_later(self, event, body=None):
"""Send a DAP event back to the client, but only after the
current request has completed."""
self.delayed_events.append((event, body))
# Note that this does not need to be run in any particular thread,
# because it just creates an object and writes it to a thread-safe
# queue.
def send_event(self, event, body=None):
"""Send an event to the DAP client.
EVENT is the name of the event, a string.
BODY is the body of the event, an arbitrary object."""
obj = {
"type": "event",
"event": event,
}
if body is not None:
obj["body"] = body
self._send_json(obj)
def shutdown(self):
"""Request that the server shut down."""
# Just set a flag. This operation is complicated because we
# want to write the result of the request before exiting. See
# main_loop.
self.done = True
def send_event(event, body):
"""Send an event to the DAP client.
EVENT is the name of the event, a string.
BODY is the body of the event, an arbitrary object."""
global _server
_server.send_event(event, body)
def request(name):
"""A decorator that indicates that the wrapper function implements
the DAP request NAME."""
def wrap(func):
global _commands
_commands[name] = func
# All requests must run in the DAP thread.
return in_dap_thread(func)
return wrap
def capability(name):
"""A decorator that indicates that the wrapper function implements
the DAP capability NAME."""
def wrap(func):
global _capabilities
_capabilities[name] = True
return func
return wrap
@request("initialize")
def initialize(**args):
global _server, _capabilities
_server.config = args
_server.send_event_later("initialized")
return _capabilities.copy()
@request("terminate")
@capability("supportsTerminateRequest")
def terminate(**args):
# We can ignore the result here, because we only really need to
# synchronize.
send_gdb_with_response("kill")
@request("disconnect")
@capability("supportTerminateDebuggee")
def disconnect(*, terminateDebuggee=False, **args):
if terminateDebuggee:
terminate()
_server.shutdown()

View file

@ -0,0 +1,189 @@
# Copyright 2022 Free Software Foundation, Inc.
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Do not import other gdbdap modules here -- this module must come
# first.
import functools
import gdb
import queue
import signal
import threading
import traceback
from contextlib import contextmanager
# The GDB thread, aka the main thread.
_gdb_thread = threading.current_thread()
# The DAP thread.
_dap_thread = None
@contextmanager
def blocked_signals():
"""A helper function that blocks and unblocks signals."""
if not hasattr(signal, "pthread_sigmask"):
yield
return
to_block = {signal.SIGCHLD, signal.SIGINT, signal.SIGALRM, signal.SIGWINCH}
signal.pthread_sigmask(signal.SIG_BLOCK, to_block)
try:
yield None
finally:
signal.pthread_sigmask(signal.SIG_UNBLOCK, to_block)
def start_thread(name, target, args=()):
"""Start a new thread, invoking TARGET with *ARGS there.
This is a helper function that ensures that any GDB signals are
correctly blocked."""
# GDB requires that these be delivered to the gdb thread. We
# do this here to avoid any possible race with the creation of
# the new thread. The thread mask is inherited by new
# threads.
with blocked_signals():
result = threading.Thread(target=target, args=args, daemon=True)
result.start()
return result
def start_dap(target):
"""Start the DAP thread and invoke TARGET there."""
global _dap_thread
exec_and_log("set breakpoint pending on")
_dap_thread = start_thread("DAP", target)
def in_gdb_thread(func):
"""A decorator that asserts that FUNC must be run in the GDB thread."""
@functools.wraps(func)
def ensure_gdb_thread(*args, **kwargs):
assert threading.current_thread() is _gdb_thread
return func(*args, **kwargs)
return ensure_gdb_thread
def in_dap_thread(func):
"""A decorator that asserts that FUNC must be run in the DAP thread."""
@functools.wraps(func)
def ensure_dap_thread(*args, **kwargs):
assert threading.current_thread() is _dap_thread
return func(*args, **kwargs)
return ensure_dap_thread
class LoggingParam(gdb.Parameter):
"""Whether DAP logging is enabled."""
set_doc = "Set the DAP logging status."
show_doc = "Show the DAP logging status."
log_file = None
def __init__(self):
super().__init__(
"debug dap-log-file", gdb.COMMAND_MAINTENANCE, gdb.PARAM_OPTIONAL_FILENAME
)
self.value = None
def get_set_string(self):
# Close any existing log file, no matter what.
if self.log_file is not None:
self.log_file.close()
self.log_file = None
if self.value is not None:
self.log_file = open(self.value, "w")
return ""
dap_log = LoggingParam()
def log(something):
"""Log SOMETHING to the log file, if logging is enabled."""
if dap_log.log_file is not None:
print(something, file=dap_log.log_file)
dap_log.log_file.flush()
def log_stack():
"""Log a stack trace to the log file, if logging is enabled."""
if dap_log.log_file is not None:
traceback.print_exc(file=dap_log.log_file)
@in_gdb_thread
def exec_and_log(cmd):
"""Execute the gdb command CMD.
If logging is enabled, log the command and its output."""
log("+++ " + cmd)
try:
output = gdb.execute(cmd, from_tty=True, to_string=True)
if output != "":
log(">>> " + output)
except gdb.error:
log_stack()
class Invoker(object):
"""A simple class that can invoke a gdb command."""
def __init__(self, cmd):
self.cmd = cmd
# This is invoked in the gdb thread to run the command.
@in_gdb_thread
def __call__(self):
exec_and_log(self.cmd)
def send_gdb(cmd):
"""Send CMD to the gdb thread.
CMD can be either a function or a string.
If it is a string, it is passed to gdb.execute."""
if isinstance(cmd, str):
cmd = Invoker(cmd)
gdb.post_event(cmd)
def send_gdb_with_response(fn):
"""Send FN to the gdb thread and return its result.
If FN is a string, it is passed to gdb.execute and None is
returned as the result.
If FN throws an exception, this function will throw the
same exception in the calling thread.
"""
if isinstance(fn, str):
fn = Invoker(fn)
result_q = queue.SimpleQueue()
def message():
try:
val = fn()
result_q.put(val)
except Exception as e:
result_q.put(e)
send_gdb(message)
val = result_q.get()
if isinstance(val, Exception):
raise val
return val

View file

@ -0,0 +1,25 @@
# Copyright 2022 Free Software Foundation, Inc.
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from .startup import in_gdb_thread, exec_and_log, log
@in_gdb_thread
def set_thread(thread_id):
"""Set the current thread to THREAD_ID."""
if thread_id == 0:
log("+++ Thread == 0 +++")
else:
exec_and_log(f"thread {thread_id}")

View file

@ -0,0 +1,42 @@
# Copyright 2022 Free Software Foundation, Inc.
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import gdb
from .server import request
from .startup import send_gdb_with_response, in_gdb_thread
# A helper function to construct the list of threads.
@in_gdb_thread
def _get_threads():
result = []
for thr in gdb.selected_inferior().threads():
one_result = {
"id": thr.global_num,
}
name = thr.name
if name is not None:
one_result["name"] = name
result.append(one_result)
return result
@request("threads")
def threads(**args):
result = send_gdb_with_response(_get_threads)
return {
"threads": result,
}

99
gdb/python/py-dap.c Normal file
View file

@ -0,0 +1,99 @@
/* Python DAP interpreter
Copyright (C) 2022 Free Software Foundation, Inc.
This file is part of GDB.
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. */
#include "defs.h"
#include "python-internal.h"
#include "interps.h"
#include "cli-out.h"
#include "top.h"
class dap_interp final : public interp
{
public:
explicit dap_interp (const char *name)
: interp (name),
m_ui_out (new cli_ui_out (gdb_stdout))
{
}
~dap_interp () override = default;
void init (bool top_level) override;
void suspend () override
{
}
void resume () override
{
}
gdb_exception exec (const char *command) override
{
/* Just ignore it. */
return {};
}
void set_logging (ui_file_up logfile, bool logging_redirect,
bool debug_redirect) override
{
/* Just ignore it. */
}
ui_out *interp_ui_out () override
{
return m_ui_out.get ();
}
private:
std::unique_ptr<ui_out> m_ui_out;
};
void
dap_interp::init (bool top_level)
{
gdbpy_enter enter_py;
gdbpy_ref<> dap_module (PyImport_ImportModule ("gdb.dap"));
if (dap_module == nullptr)
gdbpy_handle_exception ();
gdbpy_ref<> func (PyObject_GetAttrString (dap_module.get (), "run"));
if (func == nullptr)
gdbpy_handle_exception ();
gdbpy_ref<> result_obj (PyObject_CallNoArgs (func.get ()));
if (result_obj == nullptr)
gdbpy_handle_exception ();
current_ui->input_fd = -1;
current_ui->m_input_interactive_p = false;
}
void _initialize_py_interp ();
void
_initialize_py_interp ()
{
interp_factory_register ("dap", [] (const char *name) -> interp *
{
return new dap_interp (name);
});
}