957 lines · python
1# DExTer : Debugging Experience Tester2# ~~~~~~ ~ ~~ ~ ~~3#4# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.5# See https://llvm.org/LICENSE.txt for license information.6# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception7"""Interface for communicating with a debugger via the DAP.8"""9 10import abc11from collections import defaultdict12import copy13import json14import os15import shlex16import subprocess17import sys18import threading19import time20from enum import Enum21 22from dex.debugger.DebuggerBase import DebuggerBase, watch_is_active23from dex.dextIR import FrameIR, LocIR, StepIR, StopReason, ValueIR24from dex.dextIR import StackFrame, SourceLocation, ProgramState25from dex.utils.Exceptions import DebuggerException, LoadDebuggerException26from dex.utils.ReturnCode import ReturnCode27from dex.utils.Logging import Logger28from dex.utils.Timeout import Timeout29 30 31# Helper enum used for colorizing DAP Message Log output.32class Color(Enum):33 CYAN = 3634 GREEN = 3235 YELLOW = 3336 RED = 3137 MAGENTA = 3538 39 def apply(self, text: str) -> str:40 return f"\033[{self.value}m{text}\033[0m"41 42 43class DAPMessageLogger:44 def __init__(self, context):45 self.dexter_logger = context.logger46 self.log_file: str = context.options.dap_message_log47 self.colorized: bool = context.options.colorize_dap_log48 self.indent = 2 if context.options.format_dap_log == "pretty" else None49 self.prefix_send: str = "->"50 self.prefix_recv: str = "<-"51 self.out_handle = None52 self.open = False53 self.lock = threading.Lock()54 55 def _custom_enter(self):56 self.open = True57 if self.log_file is None:58 return59 if self.log_file == "-":60 self.out_handle = sys.stdout61 return62 if self.log_file == "-e":63 self.out_handle = sys.stderr64 return65 self.out_handle = open(self.log_file, "w+", encoding="utf-8")66 67 def _custom_exit(self):68 if (69 self.out_handle is not None70 and self.log_file != "-"71 and self.log_file != "-e"72 ):73 self.out_handle.close()74 self.open = False75 76 def _colorize_dap_message(self, message: dict) -> dict:77 if not self.colorized:78 return message79 colorized_message = copy.deepcopy(message)80 if colorized_message["type"] == "event":81 colorized_message["type"] = Color.YELLOW.apply("event")82 colorized_message["event"] = Color.YELLOW.apply(colorized_message["event"])83 elif colorized_message["type"] == "response":84 colorized_message["type"] = Color.GREEN.apply("response")85 colorized_message["command"] = Color.YELLOW.apply(86 colorized_message["command"]87 )88 elif colorized_message["type"] == "request":89 colorized_message["type"] = Color.CYAN.apply("request")90 colorized_message["command"] = Color.YELLOW.apply(91 colorized_message["command"]92 )93 return colorized_message94 95 def write_message(self, message: dict, incoming: bool):96 prefix = self.prefix_recv if incoming else self.prefix_send97 # ANSI escape codes get butchered by json.dumps(), so we fix them up here.98 message_str = json.dumps(99 self._colorize_dap_message(message), indent=self.indent100 ).replace("\\u001b", "\033")101 if self.out_handle is not None and self.open:102 with self.lock:103 self.out_handle.write(f"{prefix} {message_str}\n")104 elif not self.open:105 self.dexter_logger.warning(106 f'Attempted to write message after program closed: "{prefix} {message_str}"'107 )108 109 110# Debuggers communicate optional feature support.111class DAPDebuggerCapabilities:112 def __init__(self):113 self.supportsConfigurationDoneRequest: bool = False114 self.supportsFunctionBreakpoints: bool = False115 self.supportsConditionalBreakpoints: bool = False116 self.supportsHitConditionalBreakpoints: bool = False117 self.supportsEvaluateForHovers: bool = False118 self.supportsSetVariable: bool = False119 self.supportsStepInTargetsRequest: bool = False120 self.supportsModulesRequest: bool = False121 self.supportsValueFormattingOptions: bool = False122 self.supportsLogPoints: bool = False123 self.supportsSetExpression: bool = False124 self.supportsDataBreakpoints: bool = False125 self.supportsReadMemoryRequest: bool = False126 self.supportsWriteMemoryRequest: bool = False127 self.supportsDisassembleRequest: bool = False128 self.supportsCancelRequest: bool = False129 self.supportsSteppingGranularity: bool = False130 self.supportsInstructionBreakpoints: bool = False131 132 def update(self, logger: Logger, feature_dict: dict):133 for k, v in feature_dict.items():134 if hasattr(self, k):135 setattr(self, k, v)136 else:137 logger.warning(f"DAP: Unknown support flag: {k}")138 139 140# As DAP does not give us a trivially query-able process, we are responsible for maintaining our own state information,141# including what breakpoints are currently set, and whether the debugger is running or stopped.142# This class holds all state that is set based on events sent by the debug adapter; most responses are forwarded through143# to the main DAP class, though in a few cases where it is convenient for bookkeeping the DAPDebuggerState may read some144# information from the responses before forwarding them onwards.145class DAPDebuggerState:146 def __init__(self):147 ## Overall debugger state information.148 #149 # Whether we have received the initialize update yet.150 self.initialized: bool = False151 # Whether the debugger has successfully launched yet.152 self.launched: bool = False153 # The thread that we are debugging.154 # TODO: This is primitively handled right now, assuming that we only ever have one thread; if we want155 # support for debugging any multi-threaded program then we will need to track some more complex state.156 self.thread = None157 # True if the debuggee is currently executing.158 self.is_running: bool = False159 # True if the debuggee has finished executing.160 self.is_finished: bool = False161 162 ## Information for the program at a particular stopped point, which will be invalidated when execution resumes.163 #164 # Either None if the debuggee is currently running, or a string specifying the reason why the165 # debuggee is currently stopped otherwise.166 self.stopped_reason = None167 # If we were stopped for the reason 'breakpoint', this will contain a list of the DAP breakpoint IDs168 # responsible for stopping us.169 self.stopped_bps = []170 # For a currently stopped process, stores the mapping of frame indices (top of stack=0) to frameIds returned171 # from the debug adapter.172 self.frame_map = []173 174 # We use responses[idx] to refer to the response for the request sent with seq=idx, where the value175 # is either the response payload, or None if the response hasn't arrived yet.176 # Since requests are indexed from 1, we insert a 'None' at the front to ensure that the first real177 # entry is indexed correctly.178 self.responses = [None]179 # Map of DAP breakpoint IDs to resolved instruction addresses.180 self.bp_addr_map = {}181 182 # DAP features supported by the debugger.183 self.capabilities = DAPDebuggerCapabilities()184 185 def set_response(self, req_id: int, response: dict):186 if len(self.responses) > req_id:187 self.responses[req_id] = response188 return189 while len(self.responses) < req_id:190 self.responses.append(None)191 self.responses.append(response)192 193 # As the receiver thread does not know when a request has been sent, and only the receiver thread should write to the DebuggerState object,194 # the responses list may not have been populated with a None for a pending request at the time that the main thread expects it. Therefore,195 # we use this getter to account for requests that the receiver thread is unaware of.196 def get_response(self, req_id: int):197 if len(self.responses) <= req_id:198 return None199 return self.responses[req_id]200 201 202# DAP Communication model:203# - Communication is message-based, not stateful - we cannot simply query information from the debugger as we can with204# other debugger implementations, we need to maintain local state.205# - All messages are utf-encoded JSON, which we convert to/from python dicts via methods above; some amount of206# bookkeeping is performed automatically in the DAP class.207# - Commands and queries are sent via 'request' messages, for which a corresponding 'response' will always be sent back208# by the adapter indicating success/failure, containing data related to the request.209# - The adapter will also send 'event' messages, indicating state changes in the debugger - for example, when the210# debugger has stopped at a breakpoint.211# In order to handle this, we run a separate thread that will continuously insert any messages received212# from the adapter into a queue, which the main thread will read; generally, our response to any read message213# is to update our state, which Dexter's DebuggerController will then read.214class DAP(DebuggerBase, metaclass=abc.ABCMeta):215 def __init__(self, context, *args):216 self._debugger_state = DAPDebuggerState()217 self._proc = None218 self._receiver_thread = None219 self._err_thread = None220 self.seq = 0221 self.target_proc_id = -1222 self.max_bp_id = 0223 # Mapping of active breakpoints per-file - intentionally excludes breakpoints that we have deleted.224 # { file -> [dex_breakpoint_id]}225 self.file_to_bp = defaultdict(list)226 # { dex_breakpoint_id -> (file, line, condition) }227 self.bp_info = {}228 # { dex_breakpoint_id -> function_name }229 self.function_bp_info = {}230 # { dex_breakpoint_id -> instruction_reference }231 self.instruction_bp_info = {}232 # We don't rely on IDs returned directly from the debug adapter. Instead, we use dexter breakpoint IDs, and233 # maintain a two-way-mapping of dex_bp_id<->dap_bp_id. This also allows us to defer the setting of breakpoints234 # in the debug adapter itself until necessary.235 # NB: The debug adapter may merge dexter-side breakpoints into a single debugger-side breakpoint; therefore, the236 # DAP->Dex mapping is one-to-many.237 self.dex_id_to_dap_id = {}238 self.dap_id_to_dex_ids = {}239 self.pending_breakpoints: bool = False240 self.pending_function_breakpoints: bool = False241 self.pending_instruction_breakpoints: bool = False242 # List of breakpoints, indexed by BP ID243 # Each entry has the source file (for use in referencing desired_bps), and the DA-assigned244 # ID for that breakpoint if it has one (if it has been removed or not yet created then it will be None).245 # self.bp_source_list: list[(str, int)]246 self.message_logger = None247 super(DAP, self).__init__(context, *args)248 249 @property250 @abc.abstractmethod251 def _debug_adapter_name(self) -> str:252 pass253 254 @property255 @abc.abstractmethod256 def _debug_adapter_executable(self) -> str:257 pass258 259 @property260 def _debug_adapter_launch_args(self) -> list:261 return []262 263 @staticmethod264 def make_request(command: str, arguments=None) -> dict:265 request = {"type": "request", "command": command}266 if arguments is not None:267 request["arguments"] = arguments268 return request269 270 @staticmethod271 def make_initialize_request(adapterID: str) -> dict:272 return DAP.make_request(273 "initialize",274 {275 "clientID": "dexter",276 "adapterID": adapterID,277 "pathFormat": "path",278 "linesStartAt1": True,279 "columnsStartAt1": True,280 "supportsVariableType": True,281 "supportsVariablePaging": True,282 "supportsRunInTerminalRequest": False,283 },284 )285 286 class BreakpointRequest:287 def __init__(self, line: int, condition=None):288 self.line = line289 self.condition = condition290 291 def toDict(self) -> dict:292 result = {"line": self.line}293 if self.condition is not None:294 result["condition"] = self.condition295 return result296 297 @staticmethod298 def make_set_breakpoint_request(source: str, bps) -> dict:299 return DAP.make_request(300 "setBreakpoints",301 {"source": {"path": source}, "breakpoints": [bp.toDict() for bp in bps]},302 )303 304 @staticmethod305 def make_set_function_breakpoint_request(function_names: list) -> dict:306 # Function breakpoints may specify conditions and hit counts, though we307 # don't use those here (though perhaps we should use native hit count,308 # rather than emulating it ConditionalController, now that we have a309 # shared interface (DAP)).310 return DAP.make_request(311 "setFunctionBreakpoints",312 {"breakpoints": [{"name": f} for f in function_names]},313 )314 315 @staticmethod316 def make_set_instruction_breakpoint_request(addrs: list) -> dict:317 # Instruction breakpoints have additional fields we're ignoring for the318 # moment.319 return DAP.make_request(320 "setInstructionBreakpoints",321 {"breakpoints": [{"instructionReference": a} for a in addrs]},322 )323 324 ############################################################################325 ## DAP communication & state-handling functions326 327 # Sends a request to the adapter, returning the seq value of the request.328 def send_message(self, payload: dict) -> int:329 self.seq = self.seq + 1330 payload["seq"] = self.seq331 self.message_logger.write_message(payload, False)332 body = json.dumps(payload)333 message = f"Content-Length: {len(body)}\r\n\r\n{body}".encode("utf-8")334 self._proc.stdin.write(message)335 self._proc.stdin.flush()336 return self.seq337 338 @staticmethod339 def _handle_message(340 message: dict, debugger_state: DAPDebuggerState, logger: Logger341 ):342 # We only support events and responses, we do not implement any reverse-requests.343 # TODO: If we find cases where 'seq' becomes important, we need to read it here and process344 # pending messages in order.345 if message["type"] == "event":346 event_type = message["event"]347 event_details = message.get("body")348 if event_type == "initialized":349 debugger_state.initialized = True350 elif event_type == "process":351 debugger_state.launched = True352 debugger_state.is_running = True353 # The debugger has stopped for some reason.354 elif event_type == "stopped":355 stop_reason = event_details["reason"]356 debugger_state.is_running = False357 debugger_state.stopped_reason = stop_reason358 debugger_state.stopped_bps = event_details.get("hitBreakpointIds", [])359 debugger_state.thread = event_details["threadId"]360 elif event_type == "breakpoint":361 # We handle most BP information in the main DAP thread by reading responses to breakpoint requests;362 # some information is only passed via event, however, which we store here.363 breakpoint_details = event_details["breakpoint"]364 if "instructionReference" in breakpoint_details:365 debugger_state.bp_addr_map[366 breakpoint_details["id"]367 ] = breakpoint_details["instructionReference"]368 elif event_type == "exited" or event_type == "terminated":369 debugger_state.stopped_reason = event_type370 debugger_state.is_running = False371 debugger_state.is_finished = True372 # We may receive this event before or after the response to the corresponding "continue" request.373 elif event_type == "continued":374 debugger_state.is_running = True375 # Reset all state that is invalidated upon program continue.376 debugger_state.stopped_reason = None377 debugger_state.stopped_bps = []378 debugger_state.frame_map = []379 elif event_type == "thread":380 if (381 event_details["reason"] == "started"382 and debugger_state.thread is None383 ):384 debugger_state.thread = event_details["threadId"]385 elif event_type == "capabilities":386 # Unchanged capabilites may not be included.387 debugger_state.capabilities.update(logger, event_details)388 # There are many events we do not care about, just skip processing them.389 else:390 pass391 elif message["type"] == "response":392 # TODO: We also receive a "continued" event, but it seems reasonable to set state based on either the393 # response or the event, since the DAP does not specify an order in which they are sent. May need revisiting394 # if there turns out to be some odd ordering issues, e.g. if we can receive messages in the order395 # ["response: continued", "event: stopped", "event: continued"].396 if (397 message["command"] in ["continue", "stepIn", "next", "stepOut"]398 and message["success"] == True399 ):400 debugger_state.is_running = True401 # Reset all state that is invalidated upon program continue.402 debugger_state.stopped_reason = None403 debugger_state.stopped_bps = []404 debugger_state.frame_map = []405 # It is useful to cache a mapping of frames; since this is invalidated when we continue, and only this406 # message-handling thread should write to debugger_state, we do so while handling the response for407 # convenience.408 if message["command"] == "stackTrace" and message["success"] == True:409 debugger_state.frame_map = [410 stackframe["id"] for stackframe in message["body"]["stackFrames"]411 ]412 # The debugger communicates which optional DAP features are413 # supported in its initalize response.414 if message["command"] == "initialize" and message["success"] == True:415 body = message.get("body")416 if body:417 debugger_state.capabilities.update(logger, body)418 # Now we've done whatever we need to do with the response, tell the419 # receiver thread we've got it.420 request_seq = message["request_seq"]421 debugger_state.set_response(request_seq, message)422 423 @staticmethod424 def _colorize_dap_message(message: dict) -> dict:425 colorized_message = copy.deepcopy(message)426 if colorized_message["type"] == "event":427 colorized_message["type"] = "<y>event</>"428 colorized_message["event"] = f"<y>{colorized_message['event']}</>"429 elif colorized_message["type"] == "response":430 colorized_message["type"] = "<g>response</>"431 colorized_message["command"] = f"<y>{colorized_message['command']}</>"432 elif colorized_message["type"] == "request":433 colorized_message["type"] = "<b>request</>"434 colorized_message["command"] = f"<y>{colorized_message['command']}</>"435 return colorized_message436 437 @staticmethod438 def _read_dap_output(439 proc: subprocess.Popen,440 debugger_state: DAPDebuggerState,441 message_logger: DAPMessageLogger,442 logger: Logger,443 ):444 buffer: bytes = b""445 while True:446 chunk: bytes = proc.stdout.read(1)447 if not chunk:448 break449 buffer += chunk450 if b"\r\n\r\n" in buffer:451 header, rest = buffer.split(b"\r\n\r\n", 1)452 content_length = int(header.decode().split(":")[1].strip())453 while len(rest) < content_length:454 rest += proc.stdout.read(content_length - len(rest))455 message = json.loads(rest[:content_length])456 message_logger.write_message(message, True)457 DAP._handle_message(message, debugger_state, logger)458 buffer = rest[content_length:]459 460 @staticmethod461 def _read_dap_err(proc: subprocess.Popen, logger: Logger):462 while True:463 err: bytes = proc.stderr.readline()464 if len(err) > 0:465 logger.error(f"DAP server: {err.decode().strip()}")466 467 def _custom_init(self):468 self.context.logger.note(469 f"Opening DAP server: {shlex.join([self._debug_adapter_executable] + self._debug_adapter_launch_args)}"470 )471 self.message_logger = DAPMessageLogger(self.context)472 self.message_logger._custom_enter()473 self._proc = subprocess.Popen(474 [self._debug_adapter_executable] + self._debug_adapter_launch_args,475 stdin=subprocess.PIPE,476 stdout=subprocess.PIPE,477 stderr=subprocess.PIPE,478 bufsize=0,479 )480 self._receiver_thread = threading.Thread(481 target=DAP._read_dap_output,482 args=(483 self._proc,484 self._debugger_state,485 self.message_logger,486 self.context.logger,487 ),488 daemon=True,489 )490 self._err_thread = threading.Thread(491 target=DAP._read_dap_err,492 args=(self._proc, self.context.logger),493 daemon=True,494 )495 self._receiver_thread.start()496 self._err_thread.start()497 init_req = self.send_message(498 self.make_initialize_request(self._debug_adapter_name)499 )500 assert self._proc.poll() is None, "Process has closed unexpectedly early?"501 self._await_response(init_req)502 503 def _custom_exit(self):504 if self._proc is not None:505 dc_req = self.send_message(self.make_request("disconnect"))506 dc_req_timeout = 3507 try:508 result = self._await_response(dc_req, dc_req_timeout)509 if not result["success"]:510 self.context.logger.warning(511 "The disconnect request sent to the DAP server failed; forcibly shutting down DAP server."512 )513 else:514 self.context.logger.note(515 "Successfully disconnected from DAP server."516 )517 except:518 # We're going to kill the process regardless, we just want to give the target a chance to shut down519 # gracefully first.520 self.context.logger.warning(521 f"The disconnect request sent to the DAP server timed out after {dc_req_timeout}s; forcibly shutting down DAP server."522 )523 pass524 self._proc.kill()525 self._proc = None526 self.message_logger._custom_exit()527 528 # Waits for a response to the request with the given seq, optionally raising an error529 # if the response takes too long (blocks forever by default/if timeout=0).530 def _await_response(self, seq: int, timeout: float = 0.0) -> dict:531 timeout_check = Timeout(timeout)532 while self._debugger_state.get_response(seq) is None:533 if timeout_check.timed_out():534 if self._proc.poll() is not None:535 self.context.logger.error(536 f"Debug adapter exited while Dexter is awaiting response? Result: {self._proc.poll()}"537 )538 raise TimeoutError(539 f"Timed out while waiting for response to DAP request {seq}"540 )541 time.sleep(0.001)542 return self._debugger_state.get_response(seq)543 544 ## End of DAP communication methods545 ############################################################################546 547 def _translate_stop_reason(self, reason):548 if reason is None:549 return None550 if "breakpoint" in reason:551 return StopReason.BREAKPOINT552 if reason == "step":553 return StopReason.STEP554 if reason == "exited" or reason == "terminated":555 return StopReason.PROGRAM_EXIT556 if reason == "exception":557 return StopReason.ERROR558 return StopReason.OTHER559 560 def _load_interface(self):561 if not os.path.isfile(self._debug_adapter_executable):562 raise LoadDebuggerException(563 f'debug adapter "{self._debug_adapter_executable}" does not exist',564 sys.exc_info(),565 )566 # We don't make use of _interface, so return nothing.567 568 @property569 @abc.abstractmethod570 def version(self):571 """The version of this DAP debugger."""572 573 ############################################################################574 ## Breakpoint Methods575 576 def get_next_bp_id(self):577 new_id = self.max_bp_id578 self.max_bp_id += 1579 return new_id580 581 def get_current_bps(self, source):582 if source in self.file_to_bp:583 return self.file_to_bp[source]584 return []585 586 def _update_requested_bp_list(self, bp_list):587 """Can be overridden for any specific implementations that need further processing before sending breakpoints to588 the debug adapter, e.g. in LLDB we cannot store multiple breakpoints at a single location, and therefore must589 combine conditions for breakpoints at the same location."""590 return bp_list591 592 # For a source file, returns the list of BreakpointRequests for the breakpoints in that file, which can be sent to593 # the debug adapter.594 def _get_desired_bps(self, file: str):595 bp_list = [596 DAP.BreakpointRequest(line, cond)597 for (_, line, cond) in map(598 lambda dex_bp_id: self.bp_info[dex_bp_id], self.get_current_bps(file)599 )600 ]601 return self._update_requested_bp_list(bp_list)602 603 def clear_breakpoints(self):604 # We don't actually need to do anything here - even if breakpoints were preserved between runs, we will605 # automatically clear old breakpoints on the first 'setBreakpoints' message.606 pass607 608 def _add_breakpoint(self, file, line):609 return self._add_conditional_breakpoint(file, line, None)610 611 def add_function_breakpoint(self, name: str):612 if not self._debugger_state.capabilities.supportsFunctionBreakpoints:613 raise DebuggerException("Debugger does not support function breakpoints")614 new_id = self.get_next_bp_id()615 self.function_bp_info[new_id] = name616 self.pending_function_breakpoints = True617 return new_id618 619 def add_instruction_breakpoint(self, addr: str):620 if not self._debugger_state.capabilities.supportsInstructionBreakpoints:621 raise DebuggerException("Debugger does not support instruction breakpoints")622 new_id = self.get_next_bp_id()623 self.instruction_bp_info[new_id] = addr624 self.pending_instruction_breakpoints = True625 return new_id626 627 def _add_conditional_breakpoint(self, file, line, condition):628 new_id = self.get_next_bp_id()629 self.file_to_bp[file].append(new_id)630 self.bp_info[new_id] = (file, line, condition)631 self.pending_breakpoints = True632 return new_id633 634 def _update_breakpoint_ids_after_request(self, dex_bp_ids: list, response: dict):635 dap_bp_ids = [bp["id"] for bp in response["body"]["breakpoints"]]636 if len(dex_bp_ids) != len(dap_bp_ids):637 self.context.logger.error(638 f"Sent request to set {len(dex_bp_ids)} breakpoints, but received {len(dap_bp_ids)} in response."639 )640 visited_dap_ids = set()641 for i, dex_bp_id in enumerate(dex_bp_ids):642 dap_bp_id = dap_bp_ids[i]643 self.dex_id_to_dap_id[dex_bp_id] = dap_bp_id644 # We take the mappings in the response as the canonical mapping, meaning that if the debug server has645 # simply *changed* the DAP ID for a breakpoint we overwrite the existing mapping rather than adding to646 # it, but if we receive the same DAP ID for multiple Dex IDs *then* we store a one-to-many mapping.647 if dap_bp_id in visited_dap_ids:648 self.dap_id_to_dex_ids[dap_bp_id].append(dex_bp_id)649 else:650 self.dap_id_to_dex_ids[dap_bp_id] = [dex_bp_id]651 visited_dap_ids.add(dap_bp_id)652 653 def _flush_breakpoints(self):654 # Normal and conditional breakpoints.655 if self.pending_breakpoints:656 self.pending_breakpoints = False657 for file in self.file_to_bp.keys():658 desired_bps = self._get_desired_bps(file)659 request_id = self.send_message(660 self.make_set_breakpoint_request(file, desired_bps)661 )662 result = self._await_response(request_id, 10)663 if not result["success"]:664 raise DebuggerException(f"could not set breakpoints for '{file}'")665 # The debug adapter may have chosen to merge our breakpoints. From here we need to identify such cases and666 # handle them so that our internal bookkeeping is correct.667 dex_bp_ids = self.get_current_bps(file)668 self._update_breakpoint_ids_after_request(dex_bp_ids, result)669 670 # Function breakpoints.671 if self.pending_function_breakpoints:672 self.pending_function_breakpoints = False673 desired_bps = list(self.function_bp_info.values())674 request_id = self.send_message(675 self.make_set_function_breakpoint_request(desired_bps)676 )677 result = self._await_response(request_id, 10)678 if not result["success"]:679 raise DebuggerException(680 f"could not set function breakpoints: '{desired_bps}'"681 )682 # We expect the breakpoint order to match in request and response.683 dex_bp_ids = list(self.function_bp_info.keys())684 self._update_breakpoint_ids_after_request(dex_bp_ids, result)685 686 # Address / instruction breakpoints.687 if self.pending_instruction_breakpoints:688 self.pending_instruction_breakpoints = False689 desired_bps = list(self.instruction_bp_info.values())690 request_id = self.send_message(691 self.make_set_instruction_breakpoint_request(desired_bps)692 )693 result = self._await_response(request_id, 10)694 if not result["success"]:695 raise DebuggerException(696 f"could not set instruction breakpoints: '{desired_bps}'"697 )698 # We expect the breakpoint order to match in request and response.699 dex_bp_ids = list(self.instruction_bp_info.keys())700 self._update_breakpoint_ids_after_request(dex_bp_ids, result)701 702 def _confirm_triggered_breakpoint_ids(self, dex_bp_ids):703 """Can be overridden for any specific implementations that need further processing from the debug server's704 reported 'hitBreakpointIds', e.g. in LLDB where we the ID for every breakpoint at the current PC, even if some705 are conditional and their condition is not met."""706 return dex_bp_ids707 708 def get_triggered_breakpoint_ids(self):709 # Breakpoints can only have been triggered if we've hit one.710 stop_reason = self._translate_stop_reason(self._debugger_state.stopped_reason)711 if stop_reason != StopReason.BREAKPOINT:712 return set()713 breakpoint_ids = set(714 [715 dex_id716 for dap_id in self._debugger_state.stopped_bps717 if dap_id in self.dap_id_to_dex_ids718 for dex_id in self.dap_id_to_dex_ids[dap_id]719 ]720 )721 return self._confirm_triggered_breakpoint_ids(breakpoint_ids)722 723 def delete_breakpoints(self, ids):724 per_file_deletions = defaultdict(list)725 for dex_bp_id in ids:726 if dex_bp_id in self.bp_info:727 source, _, _ = self.bp_info[dex_bp_id]728 per_file_deletions[source].append(dex_bp_id)729 elif dex_bp_id in self.function_bp_info:730 del self.function_bp_info[dex_bp_id]731 self.pending_function_breakpoints = True732 elif dex_bp_id in self.instruction_bp_info:733 del self.instruction_bp_info[dex_bp_id]734 self.pending_instruction_breakpoints = True735 736 for file, deleted_ids in per_file_deletions.items():737 old_len = len(self.file_to_bp[file])738 self.file_to_bp[file] = [739 bp_id for bp_id in self.file_to_bp[file] if bp_id not in deleted_ids740 ]741 if len(self.file_to_bp[file]) != old_len:742 self.pending_breakpoints = True743 744 ## End of breakpoint methods745 ############################################################################746 747 @classmethod748 @abc.abstractmethod749 def _get_launch_params(self, cmdline):750 """ "Set the debugger-specific params used in a launch request."""751 752 def launch(self, cmdline):753 # FIXME: Should this be a warning or exception, rather than assert?754 assert (755 len(self.file_to_bp)756 + len(self.function_bp_info)757 + len(self.instruction_bp_info)758 > 0759 ), "Expected at least one breakpoint before launching"760 761 if self.context.options.target_run_args:762 cmdline += shlex.split(self.context.options.target_run_args)763 764 launch_request = self._get_launch_params(cmdline)765 766 # Per DAP protocol, the correct sequence is:767 # 1. Send launch request768 # 2. Wait for launch response and "initialized" event769 # 3. Set breakpoints770 # 4. Send configurationDone to start the process771 launch_req_id = self.send_message(self.make_request("launch", launch_request))772 launch_response = self._await_response(launch_req_id)773 if not launch_response["success"]:774 raise DebuggerException(775 f"failure launching debugger: \"{launch_response['body']['error']['format']}\""776 )777 778 # Set breakpoints after receiving launch response but before configurationDone.779 self._flush_breakpoints()780 781 # Send configurationDone to allow the process to start running.782 config_done_req_id = self.send_message(self.make_request("configurationDone"))783 config_done_response = self._await_response(config_done_req_id)784 assert config_done_response["success"]785 786 # Wait for the process to launch and obtain a thread ID.787 while self._debugger_state.thread is None or not self._debugger_state.launched:788 time.sleep(0.001)789 790 # LLDB has unique stepping behaviour w.r.t. breakpoints that needs to be handled after completing a step, so we use791 # an overridable hook to enable debugger-specific behaviour.792 def _post_step_hook(self):793 """Hook to be executed after completing a step request."""794 795 def _step(self, step_request_string):796 self._flush_breakpoints()797 step_req_id = self.send_message(798 self.make_request(799 step_request_string, {"threadId": self._debugger_state.thread}800 )801 )802 response = self._await_response(step_req_id)803 if not response["success"]:804 raise DebuggerException(805 f"failed to perform debugger action: '{step_request_string}'"806 )807 # If we've "stepped" to a breakpoint, then continue to hit the breakpoint properly.808 # NB: This is an issue that only seems relevant to LLDB, but is also harmless outside of LLDB; if it turns out809 # to cause issues for other debuggers, we can move it to a post-step hook.810 while self._debugger_state.is_running:811 time.sleep(0.001)812 self._post_step_hook()813 814 def step_in(self):815 self._step("stepIn")816 817 def step_next(self):818 self._step("next")819 820 def step_out(self):821 self._step("stepOut")822 823 def go(self) -> ReturnCode:824 self._flush_breakpoints()825 continue_req_id = self.send_message(826 self.make_request("continue", {"threadId": self._debugger_state.thread})827 )828 response = self._await_response(continue_req_id)829 if not response["success"]:830 raise DebuggerException("failed to continue")831 # Assuming the request to continue succeeded, we still need to wait to receive an event back from the debugger832 # indicating that we have successfully resumed.833 834 def _get_step_info(self, watches, step_index):835 assert (836 not self._debugger_state.is_running837 ), "Cannot get step info while debugger is running!"838 trace_req_id = self.send_message(839 self.make_request("stackTrace", {"threadId": self._debugger_state.thread})840 )841 trace_response = self._await_response(trace_req_id)842 if not trace_response["success"]:843 raise DebuggerException("failed to get stack frames")844 stackframes = trace_response["body"]["stackFrames"]845 846 frames = []847 state_frames = []848 849 for idx, stackframe in enumerate(stackframes):850 # FIXME: No source, skip the frame! Currently I've only observed this for frames below main, so we break851 # here; if it happens elsewhere, then this will break more stuff and we'll come up with a better solution.852 if (853 stackframe.get("source") is None854 or stackframe["source"].get("path") is None855 ):856 break857 loc_dict = {858 "path": stackframe["source"]["path"],859 "lineno": stackframe["line"],860 "column": stackframe["column"],861 }862 loc = LocIR(**loc_dict)863 valid_loc_for_watch = loc.path and os.path.exists(loc.path)864 frame = FrameIR(865 function=self._sanitize_function_name(stackframe["name"]),866 is_inlined=stackframe["name"].startswith("[Inline Frame]"),867 loc=loc,868 )869 870 # We skip frames that are below "main", since we do not expect those to be user code.871 fname = frame.function or "" # pylint: disable=no-member872 if any(name in fname for name in self.frames_below_main):873 break874 875 frames.append(frame)876 877 state_frame = StackFrame(878 function=frame.function,879 is_inlined=frame.is_inlined,880 location=SourceLocation(**loc_dict),881 watches={},882 )883 if valid_loc_for_watch:884 for expr in map(885 # Filter out watches that are not active in the current frame,886 # and then evaluate all the active watches.887 lambda watch_info, idx=idx: self.evaluate_expression(888 watch_info.expression, idx889 ),890 filter(891 lambda watch_info, idx=idx, line_no=loc.lineno, loc_path=loc.path: watch_is_active(892 watch_info, loc_path, idx, line_no893 ),894 watches,895 ),896 ):897 state_frame.watches[expr.expression] = expr898 state_frames.append(state_frame)899 900 if len(frames) == 1 and frames[0].function is None:901 frames = []902 state_frames = []903 904 reason = self._translate_stop_reason(self._debugger_state.stopped_reason)905 906 return StepIR(907 step_index=step_index,908 frames=frames,909 stop_reason=reason,910 program_state=ProgramState(state_frames),911 )912 913 @property914 def is_running(self):915 return self._debugger_state.is_running916 917 @property918 def is_finished(self):919 return self._debugger_state.is_finished920 921 @property922 def frames_below_main(self):923 pass924 925 @staticmethod926 @abc.abstractmethod927 def _evaluate_result_value(expression: str, result_string: str) -> ValueIR:928 """For the result of an "evaluate" message, return a ValueIR. Implementation must be debugger-specific."""929 930 def evaluate_expression(self, expression, frame_idx=0) -> ValueIR:931 # The frame_idx passed in here needs to be translated to the debug adapter's internal frame ID.932 dap_frame_id = self._debugger_state.frame_map[frame_idx]933 eval_req_id = self.send_message(934 self.make_request(935 "evaluate",936 {937 "expression": expression,938 "frameId": dap_frame_id,939 "context": "watch",940 },941 )942 )943 eval_response = self._await_response(eval_req_id)944 result: str = ""945 if not eval_response["success"]:946 if eval_response["body"].get("error", None):947 result = eval_response["body"]["error"]["format"]948 elif eval_response["message"]:949 result = eval_response["message"]950 else:951 result = "<unable to evaluate expression>"952 else:953 result = eval_response["body"]["result"]954 type_str = eval_response["body"].get("type")955 956 return self._evaluate_result_value(expression, result, type_str)957