xref: /netbsd-src/external/gpl3/gdb/dist/gdb/python/lib/gdb/dap/server.py (revision 3117ece4fc4a4ca4489ba793710b60b0d26bab6c)
1# Copyright 2022-2024 Free Software Foundation, Inc.
2
3# This program is free software; you can redistribute it and/or modify
4# it under the terms of the GNU General Public License as published by
5# the Free Software Foundation; either version 3 of the License, or
6# (at your option) any later version.
7#
8# This program is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11# GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License
14# along with this program.  If not, see <http://www.gnu.org/licenses/>.
15
16import functools
17import heapq
18import inspect
19import json
20import threading
21from contextlib import contextmanager
22
23import gdb
24
25from .io import read_json, start_json_writer
26from .startup import (
27    DAPException,
28    DAPQueue,
29    LogLevel,
30    exec_and_log,
31    in_dap_thread,
32    in_gdb_thread,
33    log,
34    log_stack,
35    start_thread,
36    thread_log,
37)
38from .typecheck import type_check
39
40# Map capability names to values.
41_capabilities = {}
42
43# Map command names to callables.
44_commands = {}
45
46# The global server.
47_server = None
48
49
50# A subclass of Exception that is used solely for reporting that a
51# request needs the inferior to be stopped, but it is not stopped.
52class NotStoppedException(Exception):
53    pass
54
55
56# This is used to handle cancellation requests.  It tracks all the
57# needed state, so that we can cancel both requests that are in flight
58# as well as queued requests.
59class CancellationHandler:
60    def __init__(self):
61        # Methods on this class acquire this lock before proceeding.
62        self.lock = threading.Lock()
63        # The request currently being handled, or None.
64        self.in_flight_dap_thread = None
65        self.in_flight_gdb_thread = None
66        self.reqs = []
67
68    def starting(self, req):
69        """Call at the start of the given request."""
70        with self.lock:
71            self.in_flight_dap_thread = req
72
73    def done(self, req):
74        """Indicate that the request is done."""
75        with self.lock:
76            self.in_flight_dap_thread = None
77
78    def cancel(self, req):
79        """Call to cancel a request.
80
81        If the request has already finished, this is ignored.
82        If the request is in flight, it is interrupted.
83        If the request has not yet been seen, the cancellation is queued."""
84        with self.lock:
85            if req == self.in_flight_gdb_thread:
86                gdb.interrupt()
87            else:
88                # We don't actually ignore the request here, but in
89                # the 'starting' method.  This way we don't have to
90                # track as much state.  Also, this implementation has
91                # the weird property that a request can be cancelled
92                # before it is even sent.  It didn't seem worthwhile
93                # to try to check for this.
94                heapq.heappush(self.reqs, req)
95
96    @contextmanager
97    def interruptable_region(self, req):
98        """Return a new context manager that sets in_flight_gdb_thread to
99        REQ."""
100        if req is None:
101            # No request is handled in the region, just execute the region.
102            yield
103            return
104        try:
105            with self.lock:
106                # If the request is cancelled, don't execute the region.
107                while len(self.reqs) > 0 and self.reqs[0] <= req:
108                    if heapq.heappop(self.reqs) == req:
109                        raise KeyboardInterrupt()
110                # Request is being handled by the gdb thread.
111                self.in_flight_gdb_thread = req
112            # Execute region.  This may be interrupted by gdb.interrupt.
113            yield
114        finally:
115            with self.lock:
116                # Request is no longer handled by the gdb thread.
117                self.in_flight_gdb_thread = None
118
119
120class Server:
121    """The DAP server class."""
122
123    def __init__(self, in_stream, out_stream, child_stream):
124        self.in_stream = in_stream
125        self.out_stream = out_stream
126        self.child_stream = child_stream
127        self.delayed_events = []
128        # This queue accepts JSON objects that are then sent to the
129        # DAP client.  Writing is done in a separate thread to avoid
130        # blocking the read loop.
131        self.write_queue = DAPQueue()
132        # Reading is also done in a separate thread, and a queue of
133        # requests is kept.
134        self.read_queue = DAPQueue()
135        self.done = False
136        self.canceller = CancellationHandler()
137        global _server
138        _server = self
139
140    # Treat PARAMS as a JSON-RPC request and perform its action.
141    # PARAMS is just a dictionary from the JSON.
142    @in_dap_thread
143    def _handle_command(self, params):
144        req = params["seq"]
145        result = {
146            "request_seq": req,
147            "type": "response",
148            "command": params["command"],
149        }
150        try:
151            self.canceller.starting(req)
152            if "arguments" in params:
153                args = params["arguments"]
154            else:
155                args = {}
156            global _commands
157            body = _commands[params["command"]](**args)
158            if body is not None:
159                result["body"] = body
160            result["success"] = True
161        except NotStoppedException:
162            # This is an expected exception, and the result is clearly
163            # visible in the log, so do not log it.
164            result["success"] = False
165            result["message"] = "notStopped"
166        except KeyboardInterrupt:
167            # This can only happen when a request has been canceled.
168            result["success"] = False
169            result["message"] = "cancelled"
170        except DAPException as e:
171            # Don't normally want to see this, as it interferes with
172            # the test suite.
173            log_stack(LogLevel.FULL)
174            result["success"] = False
175            result["message"] = str(e)
176        except BaseException as e:
177            log_stack()
178            result["success"] = False
179            result["message"] = str(e)
180        self.canceller.done(req)
181        return result
182
183    # Read inferior output and sends OutputEvents to the client.  It
184    # is run in its own thread.
185    def _read_inferior_output(self):
186        while True:
187            line = self.child_stream.readline()
188            self.send_event(
189                "output",
190                {
191                    "category": "stdout",
192                    "output": line,
193                },
194            )
195
196    # Send OBJ to the client, logging first if needed.
197    def _send_json(self, obj):
198        log("WROTE: <<<" + json.dumps(obj) + ">>>")
199        self.write_queue.put(obj)
200
201    # This is run in a separate thread and simply reads requests from
202    # the client and puts them into a queue.  A separate thread is
203    # used so that 'cancel' requests can be handled -- the DAP thread
204    # will normally block, waiting for each request to complete.
205    def _reader_thread(self):
206        while True:
207            cmd = read_json(self.in_stream)
208            if cmd is None:
209                break
210            log("READ: <<<" + json.dumps(cmd) + ">>>")
211            # Be extra paranoid about the form here.  If anything is
212            # missing, it will be put in the queue and then an error
213            # issued by ordinary request processing.
214            if (
215                "command" in cmd
216                and cmd["command"] == "cancel"
217                and "arguments" in cmd
218                # gdb does not implement progress, so there's no need
219                # to check for progressId.
220                and "requestId" in cmd["arguments"]
221            ):
222                self.canceller.cancel(cmd["arguments"]["requestId"])
223            self.read_queue.put(cmd)
224        # When we hit EOF, signal it with None.
225        self.read_queue.put(None)
226
227    @in_dap_thread
228    def main_loop(self):
229        """The main loop of the DAP server."""
230        # Before looping, start the thread that writes JSON to the
231        # client, and the thread that reads output from the inferior.
232        start_thread("output reader", self._read_inferior_output)
233        json_writer = start_json_writer(self.out_stream, self.write_queue)
234        start_thread("JSON reader", self._reader_thread)
235        while not self.done:
236            cmd = self.read_queue.get()
237            # A None value here means the reader hit EOF.
238            if cmd is None:
239                break
240            result = self._handle_command(cmd)
241            self._send_json(result)
242            events = self.delayed_events
243            self.delayed_events = []
244            for event, body in events:
245                self.send_event(event, body)
246        # Got the terminate request.  This is handled by the
247        # JSON-writing thread, so that we can ensure that all
248        # responses are flushed to the client before exiting.
249        self.write_queue.put(None)
250        json_writer.join()
251        send_gdb("quit")
252
253    @in_dap_thread
254    def send_event_later(self, event, body=None):
255        """Send a DAP event back to the client, but only after the
256        current request has completed."""
257        self.delayed_events.append((event, body))
258
259    # Note that this does not need to be run in any particular thread,
260    # because it just creates an object and writes it to a thread-safe
261    # queue.
262    def send_event(self, event, body=None):
263        """Send an event to the DAP client.
264        EVENT is the name of the event, a string.
265        BODY is the body of the event, an arbitrary object."""
266        obj = {
267            "type": "event",
268            "event": event,
269        }
270        if body is not None:
271            obj["body"] = body
272        self._send_json(obj)
273
274    def shutdown(self):
275        """Request that the server shut down."""
276        # Just set a flag.  This operation is complicated because we
277        # want to write the result of the request before exiting.  See
278        # main_loop.
279        self.done = True
280
281
282def send_event(event, body=None):
283    """Send an event to the DAP client.
284    EVENT is the name of the event, a string.
285    BODY is the body of the event, an arbitrary object."""
286    global _server
287    _server.send_event(event, body)
288
289
290# A helper decorator that checks whether the inferior is running.
291def _check_not_running(func):
292    @functools.wraps(func)
293    def check(*args, **kwargs):
294        # Import this as late as possible.  This is done to avoid
295        # circular imports.
296        from .events import inferior_running
297
298        if inferior_running:
299            raise NotStoppedException()
300        return func(*args, **kwargs)
301
302    return check
303
304
305def request(
306    name: str,
307    *,
308    response: bool = True,
309    on_dap_thread: bool = False,
310    expect_stopped: bool = True
311):
312    """A decorator for DAP requests.
313
314    This registers the function as the implementation of the DAP
315    request NAME.  By default, the function is invoked in the gdb
316    thread, and its result is returned as the 'body' of the DAP
317    response.
318
319    Some keyword arguments are provided as well:
320
321    If RESPONSE is False, the result of the function will not be
322    waited for and no 'body' will be in the response.
323
324    If ON_DAP_THREAD is True, the function will be invoked in the DAP
325    thread.  When ON_DAP_THREAD is True, RESPONSE may not be False.
326
327    If EXPECT_STOPPED is True (the default), then the request will
328    fail with the 'notStopped' reason if it is processed while the
329    inferior is running.  When EXPECT_STOPPED is False, the request
330    will proceed regardless of the inferior's state.
331    """
332
333    # Validate the parameters.
334    assert not on_dap_thread or response
335
336    def wrap(func):
337        code = func.__code__
338        # We don't permit requests to have positional arguments.
339        try:
340            assert code.co_posonlyargcount == 0
341        except AttributeError:
342            # Attribute co_posonlyargcount is supported starting python 3.8.
343            pass
344        assert code.co_argcount == 0
345        # A request must have a **args parameter.
346        assert code.co_flags & inspect.CO_VARKEYWORDS
347
348        # Type-check the calls.
349        func = type_check(func)
350
351        # Verify that the function is run on the correct thread.
352        if on_dap_thread:
353            cmd = in_dap_thread(func)
354        else:
355            func = in_gdb_thread(func)
356
357            if response:
358
359                def sync_call(**args):
360                    return send_gdb_with_response(lambda: func(**args))
361
362                cmd = sync_call
363            else:
364
365                def non_sync_call(**args):
366                    return send_gdb(lambda: func(**args))
367
368                cmd = non_sync_call
369
370        # If needed, check that the inferior is not running.  This
371        # wrapping is done last, so the check is done first, before
372        # trying to dispatch the request to another thread.
373        if expect_stopped:
374            cmd = _check_not_running(cmd)
375
376        global _commands
377        assert name not in _commands
378        _commands[name] = cmd
379        return cmd
380
381    return wrap
382
383
384def capability(name, value=True):
385    """A decorator that indicates that the wrapper function implements
386    the DAP capability NAME."""
387
388    def wrap(func):
389        global _capabilities
390        assert name not in _capabilities
391        _capabilities[name] = value
392        return func
393
394    return wrap
395
396
397def client_bool_capability(name):
398    """Return the value of a boolean client capability.
399
400    If the capability was not specified, or did not have boolean type,
401    False is returned."""
402    global _server
403    if name in _server.config and isinstance(_server.config[name], bool):
404        return _server.config[name]
405    return False
406
407
408@request("initialize", on_dap_thread=True)
409def initialize(**args):
410    global _server, _capabilities
411    _server.config = args
412    _server.send_event_later("initialized")
413    return _capabilities.copy()
414
415
416@request("terminate", expect_stopped=False)
417@capability("supportsTerminateRequest")
418def terminate(**args):
419    exec_and_log("kill")
420
421
422@request("disconnect", on_dap_thread=True, expect_stopped=False)
423@capability("supportTerminateDebuggee")
424def disconnect(*, terminateDebuggee: bool = False, **args):
425    if terminateDebuggee:
426        send_gdb_with_response("kill")
427    _server.shutdown()
428
429
430@request("cancel", on_dap_thread=True, expect_stopped=False)
431@capability("supportsCancelRequest")
432def cancel(**args):
433    # If a 'cancel' request can actually be satisfied, it will be
434    # handled specially in the reader thread.  However, in order to
435    # construct a proper response, the request is also added to the
436    # command queue and so ends up here.  Additionally, the spec says:
437    #    The cancel request may return an error if it could not cancel
438    #    an operation but a client should refrain from presenting this
439    #    error to end users.
440    # ... which gdb takes to mean that it is fine for all cancel
441    # requests to report success.
442    return None
443
444
445class Invoker(object):
446    """A simple class that can invoke a gdb command."""
447
448    def __init__(self, cmd):
449        self.cmd = cmd
450
451    # This is invoked in the gdb thread to run the command.
452    @in_gdb_thread
453    def __call__(self):
454        exec_and_log(self.cmd)
455
456
457class Cancellable(object):
458
459    def __init__(self, fn, result_q=None):
460        self.fn = fn
461        self.result_q = result_q
462        with _server.canceller.lock:
463            self.req = _server.canceller.in_flight_dap_thread
464
465    # This is invoked in the gdb thread to run self.fn.
466    @in_gdb_thread
467    def __call__(self):
468        try:
469            with _server.canceller.interruptable_region(self.req):
470                val = self.fn()
471                if self.result_q is not None:
472                    self.result_q.put(val)
473        except (Exception, KeyboardInterrupt) as e:
474            if self.result_q is not None:
475                # Pass result or exception to caller.
476                self.result_q.put(e)
477            elif isinstance(e, KeyboardInterrupt):
478                # Fn was cancelled.
479                pass
480            else:
481                # Exception happened.  Ignore and log it.
482                err_string = "%s, %s" % (e, type(e))
483                thread_log("caught exception: " + err_string)
484                log_stack()
485
486
487def send_gdb(cmd):
488    """Send CMD to the gdb thread.
489    CMD can be either a function or a string.
490    If it is a string, it is passed to gdb.execute."""
491    if isinstance(cmd, str):
492        cmd = Invoker(cmd)
493
494    # Post the event and don't wait for the result.
495    gdb.post_event(Cancellable(cmd))
496
497
498def send_gdb_with_response(fn):
499    """Send FN to the gdb thread and return its result.
500    If FN is a string, it is passed to gdb.execute and None is
501    returned as the result.
502    If FN throws an exception, this function will throw the
503    same exception in the calling thread.
504    """
505    if isinstance(fn, str):
506        fn = Invoker(fn)
507
508    # Post the event and wait for the result in result_q.
509    result_q = DAPQueue()
510    gdb.post_event(Cancellable(fn, result_q))
511    val = result_q.get()
512
513    if isinstance(val, (Exception, KeyboardInterrupt)):
514        raise val
515    return val
516