@@ -82,6 +82,8 @@ show remote thread-options-packet
** GDB now emits the "process" event.
+ ** GDB now supports the "cancel" request.
+
* New remote packets
New stop reason: clone
@@ -39595,6 +39595,22 @@ to return the bytes of each instruction in an implementation-defined
format. @value{GDBN} implements this by sending a string with the
bytes encoded in hex, like @code{"55a2b900"}.
+When the @code{repl} context is used for the @code{evaluate} request,
+@value{GDBN} evaluates the provided expression as a CLI command.
+
+Evaluation in general can cause the inferior to continue execution.
+For example, evaluating the @code{continue} command could do this, as
+could evaluating an expression that involves an inferior function
+call.
+
+@code{repl} evaluation can also cause @value{GDBN} to appear to stop
+responding to requests, for example if a CLI script does a lengthy
+computation.
+
+Evaluations like this can be interrupted using the DAP @code{cancel}
+request. (In fact, @code{cancel} should work for any request, but it
+is unlikely to be useful for most of them.)
+
@node JIT Interface
@chapter JIT Compilation Interface
@cindex just-in-time compilation
@@ -14,8 +14,11 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import functools
+import gdb
+import heapq
import inspect
import json
+import threading
from .io import start_json_writer, read_json
from .startup import (
@@ -48,6 +51,52 @@ class NotStoppedException(Exception):
pass
+# This is used to handle cancellation requests. It tracks all the
+# needed state, so that we can cancel both requests that are in flight
+# as well as queued requests.
+class CancellationHandler:
+ def __init__(self):
+ # Methods on this class acquire this lock before proceeding.
+ self.lock = threading.Lock()
+ # The request currently being handled, or None.
+ self.in_flight = None
+ self.reqs = []
+
+ def starting(self, req):
+ """Call at the start of the given request.
+
+ Throws the appropriate exception if the request should be
+ immediately cancelled."""
+ with self.lock:
+ self.in_flight = req
+ while len(self.reqs) > 0 and self.reqs[0] <= req:
+ if heapq.heappop(self.reqs) == req:
+ raise KeyboardInterrupt()
+
+ def done(self, req):
+ """Indicate that the request is done."""
+ with self.lock:
+ self.in_flight = None
+
+ def cancel(self, req):
+ """Call to cancel a request.
+
+ If the request has already finished, this is ignored.
+ If the request is in flight, it is interrupted.
+ If the request has not yet been seen, the cancellation is queued."""
+ with self.lock:
+ if req == self.in_flight:
+ gdb.interrupt()
+ else:
+ # We don't actually ignore the request here, but in
+ # the 'starting' method. This way we don't have to
+ # track as much state. Also, this implementation has
+ # the weird property that a request can be cancelled
+ # before it is even sent. It didn't seem worthwhile
+ # to try to check for this.
+ heapq.heappush(self.reqs, req)
+
+
class Server:
"""The DAP server class."""
@@ -64,6 +113,7 @@ class Server:
# requests is kept.
self.read_queue = DAPQueue()
self.done = False
+ self.canceller = CancellationHandler()
global _server
_server = self
@@ -71,13 +121,14 @@ class Server:
# PARAMS is just a dictionary from the JSON.
@in_dap_thread
def _handle_command(self, params):
- # We don't handle 'cancel' for now.
+ req = params["seq"]
result = {
- "request_seq": params["seq"],
+ "request_seq": req,
"type": "response",
"command": params["command"],
}
try:
+ self.canceller.starting(req)
if "arguments" in params:
args = params["arguments"]
else:
@@ -90,10 +141,15 @@ class Server:
except NotStoppedException:
result["success"] = False
result["message"] = "notStopped"
+ except KeyboardInterrupt:
+ # This can only happen when a request has been canceled.
+ result["success"] = False
+ result["message"] = "cancelled"
except BaseException as e:
log_stack()
result["success"] = False
result["message"] = str(e)
+ self.canceller.done(req)
return result
# Read inferior output and sends OutputEvents to the client. It
@@ -115,11 +171,25 @@ class Server:
self.write_queue.put(obj)
# This is run in a separate thread and simply reads requests from
- # the client and puts them into a queue.
+ # the client and puts them into a queue. A separate thread is
+ # used so that 'cancel' requests can be handled -- the DAP thread
+ # will normally block, waiting for each request to complete.
def _reader_thread(self):
while True:
cmd = read_json(self.in_stream)
log("READ: <<<" + json.dumps(cmd) + ">>>")
+ # Be extra paranoid about the form here. If anything is
+ # missing, it will be put in the queue and then an error
+ # issued by ordinary request processing.
+ if (
+ "command" in cmd
+ and cmd["command"] == "cancel"
+ and "arguments" in cmd
+ # gdb does not implement progress, so there's no need
+ # to check for progressId.
+ and "requestId" in cmd["arguments"]
+ ):
+ self.canceller.cancel(cmd["arguments"]["requestId"])
self.read_queue.put(cmd)
@in_dap_thread
@@ -316,3 +386,18 @@ def disconnect(*, terminateDebuggee: bool = False, **args):
if terminateDebuggee:
send_gdb_with_response("kill")
_server.shutdown()
+
+
+@request("cancel", on_dap_thread=True, expect_stopped=False)
+@capability("supportsCancelRequest")
+def cancel(**args):
+ # If a 'cancel' request can actually be satisfied, it will be
+ # handled specially in the reader thread. However, in order to
+ # construct a proper response, the request is also added to the
+ # command queue and so ends up here. Additionally, the spec says:
+ # The cancel request may return an error if it could not cancel
+ # an operation but a client should refrain from presenting this
+ # error to end users.
+ # ... which gdb takes to mean that it is fine for all cancel
+ # requests to report success.
+ return None
@@ -75,4 +75,75 @@ foreach event [lindex $result 1] {
}
gdb_assert {$seen == "pass"} "continue event from inferior call"
+#
+# Test that a repl evaluation that causes a continue can be canceled.
+#
+
+set cont_id [dap_send_request evaluate \
+ {o expression [s continue] context [s repl]}]
+dap_wait_for_event_and_check "continued" continued
+
+set cancel_id [dap_send_request cancel \
+ [format {o requestId [i %d]} $cont_id]]
+
+# The stop event will come before any responses to the requests.
+dap_wait_for_event_and_check "stopped by cancel" stopped
+
+# Now we can wait for the 'continue' request to complete, and then the
+# 'cancel' request.
+dap_read_response evaluate $cont_id
+dap_read_response cancel $cancel_id
+
+#
+# Test that a repl evaluation of a long-running gdb command (that does
+# not continue the inferior) can be canceled.
+#
+
+proc write_file {suffix contents} {
+ global testfile
+
+ set gdbfile [standard_output_file ${testfile}.$suffix]
+ set ofd [open $gdbfile w]
+ puts $ofd $contents
+ close $ofd
+ return $gdbfile
+}
+
+set gdbfile [write_file gdb "set \$x = 0\nwhile 1\nset \$x = \$x + 1\nend"]
+set cont_id [dap_send_request evaluate \
+ [format {o expression [s "source %s"] context [s repl]} \
+ $gdbfile]]
+
+# Wait a little to try to ensure the command is running.
+sleep 0.2
+set cancel_id [dap_send_request cancel \
+ [format {o requestId [i %d]} $cont_id]]
+
+set info [lindex [dap_read_response evaluate $cont_id] 0]
+gdb_assert {[dict get $info success] == "false"} "gdb command failed"
+gdb_assert {[dict get $info message] == "cancelled"} "gdb command canceled"
+
+dap_read_response cancel $cancel_id
+
+#
+# Test that a repl evaluation of a long-running Python command (that
+# does not continue the inferior) can be canceled.
+#
+
+write_file py "while True:\n pass"
+set cont_id [dap_send_request evaluate \
+ [format {o expression [s "source %s"] context [s repl]} \
+ $gdbfile]]
+
+# Wait a little to try to ensure the command is running.
+sleep 0.2
+set cancel_id [dap_send_request cancel \
+ [format {o requestId [i %d]} $cont_id]]
+
+set info [lindex [dap_read_response evaluate $cont_id] 0]
+gdb_assert {[dict get $info success] == "false"} "python command failed"
+gdb_assert {[dict get $info message] == "cancelled"} "python command canceled"
+
+dap_read_response cancel $cancel_id
+
dap_shutdown