| # DExTer : Debugging Experience Tester |
| # ~~~~~~ ~ ~~ ~ ~~ |
| # |
| # Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions. |
| # See https://llvm.org/LICENSE.txt for license information. |
| # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception |
| """Interface for communicating with the Visual Studio debugger via DTE.""" |
| |
| import abc |
| import os |
| import sys |
| from enum import IntEnum |
| from pathlib import PurePath, Path |
| from collections import defaultdict, namedtuple |
| |
| from dex.command.CommandBase import StepExpectInfo |
| from dex.debugger.DebuggerBase import DebuggerBase, watch_is_active |
| from dex.dextIR import FrameIR, LocIR, StepIR, StopReason, ValueIR |
| from dex.dextIR import StackFrame, SourceLocation, ProgramState |
| from dex.utils.Exceptions import Error, LoadDebuggerException |
| from dex.utils.Imports import load_module |
| from dex.utils.ReturnCode import ReturnCode |
| |
| def _load_com_module(): |
| try: |
| return load_module( |
| "ComInterface", |
| os.path.join(os.path.dirname(__file__), "windows"), |
| "ComInterface.py", |
| ) |
| except ImportError as e: |
| raise LoadDebuggerException(e, sys.exc_info()) |
| |
| |
| # VSBreakpoint(path: PurePath, line: int, col: int, cond: str). This is enough |
| # info to identify breakpoint equivalence in visual studio based on the |
| # properties we set through dexter currently. |
| VSBreakpoint = namedtuple("VSBreakpoint", "path, line, col, cond") |
| |
| |
| # Visual Studio events. |
| # https://learn.microsoft.com/en-us/dotnet/api/envdte.dbgeventreason?view=visualstudiosdk-2022 |
| class DbgEvent(IntEnum): |
| dbgEventReasonNone = 1 |
| dbgEventReasonGo = 2 |
| dbgEventReasonAttachProgram = 3 |
| dbgEventReasonDetachProgram = 4 |
| dbgEventReasonLaunchProgram = 5 |
| dbgEventReasonEndProgram = 6 |
| dbgEventReasonStopDebugging = 7 |
| dbgEventReasonStep = 8 |
| dbgEventReasonBreakpoint = 9 |
| dbgEventReasonExceptionThrown = 10 |
| dbgEventReasonExceptionNotHandled = 11 |
| dbgEventReasonUserBreak = 12 |
| dbgEventReasonContextSwitch = 13 |
| |
| first = dbgEventReasonNone |
| last = dbgEventReasonContextSwitch |
| |
| class VisualStudio( |
| DebuggerBase, metaclass=abc.ABCMeta |
| ): # pylint: disable=abstract-method |
| # Constants for results of Debugger.CurrentMode |
| # (https://msdn.microsoft.com/en-us/library/envdte.debugger.currentmode.aspx) |
| dbgDesignMode = 1 |
| dbgBreakMode = 2 |
| dbgRunMode = 3 |
| |
| def __init__(self, *args): |
| self.com_module = None |
| self._debugger = None |
| self._solution = None |
| self._fn_step = None |
| self._fn_go = None |
| # The next available unique breakpoint id. Use self._get_next_id(). |
| self._next_bp_id = 0 |
| # VisualStudio appears to common identical breakpoints. That is, if you |
| # ask for a breakpoint that already exists the Breakpoints list will |
| # not grow. DebuggerBase requires all breakpoints have a unique id, |
| # even for duplicates, so we'll need to do some bookkeeping. Map |
| # {VSBreakpoint: list(id)} where id is the unique dexter-side id for |
| # the requested breakpoint. |
| self._vs_to_dex_ids = defaultdict(list) |
| # Map {id: VSBreakpoint} where id is unique and VSBreakpoint identifies |
| # a breakpoint in Visual Studio. There may be many ids mapped to a |
| # single VSBreakpoint. Use self._vs_to_dex_ids to find (dexter) |
| # breakpoints mapped to the same visual studio breakpoint. |
| self._dex_id_to_vs = {} |
| |
| super(VisualStudio, self).__init__(*args) |
| |
| def _create_solution(self): |
| self._solution.Create(self.context.working_directory.path, "DexterSolution") |
| try: |
| self._solution.AddFromFile(self._project_file) |
| except OSError: |
| raise LoadDebuggerException( |
| "could not debug the specified executable", sys.exc_info() |
| ) |
| |
| def _load_solution(self): |
| try: |
| self._solution.Open(self.context.options.vs_solution) |
| except: |
| raise LoadDebuggerException( |
| "could not load specified vs solution at {}".format( |
| self.context.options.vs_solution |
| ), |
| sys.exc_info(), |
| ) |
| |
| def _custom_init(self): |
| try: |
| self._debugger = self._interface.Debugger |
| self._debugger.HexDisplayMode = False |
| |
| self._interface.MainWindow.Visible = self.context.options.show_debugger |
| |
| self._solution = self._interface.Solution |
| if self.context.options.vs_solution is None: |
| self._create_solution() |
| else: |
| self._load_solution() |
| |
| self._fn_step = self._debugger.StepInto |
| self._fn_go = self._debugger.Go |
| |
| except AttributeError as e: |
| raise LoadDebuggerException(str(e), sys.exc_info()) |
| |
| def _custom_exit(self): |
| if self._interface: |
| self._interface.Quit() |
| |
| @property |
| def _project_file(self): |
| return self.context.options.executable |
| |
| @abc.abstractproperty |
| def _dte_version(self): |
| pass |
| |
| @property |
| def _location(self): |
| # TODO: Find a better way of determining path, line and column info |
| # that doesn't require reading break points. This method requires |
| # all lines to have a break point on them. |
| bp = self._debugger.BreakpointLastHit |
| return { |
| "path": getattr(bp, "File", None), |
| "lineno": getattr(bp, "FileLine", None), |
| "column": getattr(bp, "FileColumn", None), |
| } |
| |
| @property |
| def _mode(self): |
| return self._debugger.CurrentMode |
| |
| def _load_interface(self): |
| self.com_module = _load_com_module() |
| return self.com_module.DTE(self._dte_version) |
| |
| @property |
| def version(self): |
| try: |
| return self._interface.Version |
| except AttributeError: |
| return None |
| |
| def clear_breakpoints(self): |
| for bp in self._debugger.Breakpoints: |
| bp.Delete() |
| self._vs_to_dex_ids.clear() |
| self._dex_id_to_vs.clear() |
| |
| def _add_breakpoint(self, file_, line): |
| return self._add_conditional_breakpoint(file_, line, "") |
| |
| def _get_next_id(self): |
| # "Generate" a new unique id for the breakpoint. |
| id = self._next_bp_id |
| self._next_bp_id += 1 |
| return id |
| |
| def _add_conditional_breakpoint(self, file_, line, condition): |
| col = 1 |
| vsbp = VSBreakpoint(PurePath(file_), line, col, condition) |
| new_id = self._get_next_id() |
| |
| # Do we have an exact matching breakpoint already? |
| if vsbp in self._vs_to_dex_ids: |
| self._vs_to_dex_ids[vsbp].append(new_id) |
| self._dex_id_to_vs[new_id] = vsbp |
| return new_id |
| |
| # Breakpoint doesn't exist already. Add it now. |
| count_before = self._debugger.Breakpoints.Count |
| self._debugger.Breakpoints.Add("", file_, line, col, condition) |
| # Our internal representation of VS says that the breakpoint doesn't |
| # already exist so we do not expect this operation to fail here. |
| assert count_before < self._debugger.Breakpoints.Count |
| # We've added a new breakpoint, record its id. |
| self._vs_to_dex_ids[vsbp].append(new_id) |
| self._dex_id_to_vs[new_id] = vsbp |
| return new_id |
| |
| def get_triggered_breakpoint_ids(self): |
| """Returns a set of opaque ids for just-triggered breakpoints.""" |
| bps_hit = self._debugger.AllBreakpointsLastHit |
| bp_id_list = [] |
| # Intuitively, AllBreakpointsLastHit breakpoints are the last hit |
| # _bound_ breakpoints. A bound breakpoint's parent holds the info of |
| # the breakpoint the user requested. Our internal state tracks the user |
| # requested breakpoints so we look at the Parent of these triggered |
| # breakpoints to determine which have been hit. |
| for bp in bps_hit: |
| # All bound breakpoints should have the user-defined breakpoint as |
| # a parent. |
| assert bp.Parent |
| vsbp = VSBreakpoint( |
| PurePath(bp.Parent.File), |
| bp.Parent.FileLine, |
| bp.Parent.FileColumn, |
| bp.Parent.Condition, |
| ) |
| try: |
| ids = self._vs_to_dex_ids[vsbp] |
| except KeyError: |
| pass |
| else: |
| bp_id_list += ids |
| return set(bp_id_list) |
| |
| def delete_breakpoints(self, ids): |
| """Delete breakpoints by their ids. |
| |
| Raises a KeyError if no breakpoint with this id exists. |
| """ |
| vsbp_set = set() |
| for id in ids: |
| vsbp = self._dex_id_to_vs[id] |
| |
| # Remove our id from the associated list of dex ids. |
| self._vs_to_dex_ids[vsbp].remove(id) |
| del self._dex_id_to_vs[id] |
| |
| # Bail if there are other uses of this vsbp. |
| if len(self._vs_to_dex_ids[vsbp]) > 0: |
| continue |
| # Otherwise find and delete it. |
| vsbp_set.add(vsbp) |
| |
| vsbp_to_del_count = len(vsbp_set) |
| |
| for bp in self._debugger.Breakpoints: |
| # We're looking at the user-set breakpoints so there should be no |
| # Parent. |
| assert bp.Parent is None |
| this_vsbp = VSBreakpoint( |
| PurePath(bp.File), bp.FileLine, bp.FileColumn, bp.Condition |
| ) |
| if this_vsbp in vsbp_set: |
| bp.Delete() |
| vsbp_to_del_count -= 1 |
| if vsbp_to_del_count == 0: |
| break |
| if vsbp_to_del_count: |
| raise KeyError("did not find breakpoint to be deleted") |
| |
| def _fetch_property(self, props, name): |
| num_props = props.Count |
| result = None |
| for x in range(1, num_props + 1): |
| item = props.Item(x) |
| if item.Name == name: |
| return item |
| assert False, "Couldn't find property {}".format(name) |
| |
| def launch(self, cmdline): |
| exe_path = Path(self.context.options.executable) |
| self.context.logger.note(f"VS: Using executable: '{exe_path}'") |
| cmdline_str = " ".join(cmdline) |
| if self.context.options.target_run_args: |
| cmdline_str += f" {self.context.options.target_run_args}" |
| if cmdline_str: |
| self.context.logger.note(f"VS: Using executable args: '{cmdline_str}'") |
| |
| # In a slightly baroque manner, lookup the VS project that runs when |
| # you click "run", and set its command line options to the desired |
| # command line options. |
| startup_proj_name = str( |
| self._fetch_property(self._interface.Solution.Properties, "StartupProject") |
| ) |
| project = self._fetch_property(self._interface.Solution, startup_proj_name) |
| ActiveConfiguration = self._fetch_property( |
| project.Properties, "ActiveConfiguration" |
| ).Object |
| ActiveConfiguration.DebugSettings.CommandArguments = cmdline_str |
| ConfigurationName = ActiveConfiguration.ConfigurationName |
| SolConfig = self._fetch_property( |
| self._interface.Solution.SolutionBuild.SolutionConfigurations, |
| ConfigurationName, |
| ) |
| for Context in SolConfig.SolutionContexts: |
| Context.ShouldBuild = False |
| |
| self.context.logger.note("Launching VS debugger...") |
| self._fn_go(False) |
| |
| def step_in(self): |
| self._fn_step(False) |
| |
| def go(self) -> ReturnCode: |
| self._fn_go(False) |
| return ReturnCode.OK |
| |
| def set_current_stack_frame(self, idx: int = 0): |
| thread = self._debugger.CurrentThread |
| stack_frames = thread.StackFrames |
| try: |
| stack_frame = stack_frames[idx] |
| self._debugger.CurrentStackFrame = stack_frame.raw |
| except IndexError: |
| raise Error( |
| "attempted to access stack frame {} out of {}".format( |
| idx, len(stack_frames) |
| ) |
| ) |
| |
| def _translate_stop_reason(self, reason): |
| if reason == DbgEvent.dbgEventReasonNone: |
| return None |
| if reason == DbgEvent.dbgEventReasonBreakpoint: |
| return StopReason.BREAKPOINT |
| if reason == DbgEvent.dbgEventReasonStep: |
| return StopReason.STEP |
| if reason == DbgEvent.dbgEventReasonEndProgram: |
| return StopReason.PROGRAM_EXIT |
| if reason == DbgEvent.dbgEventReasonExceptionNotHandled: |
| return StopReason.ERROR |
| assert reason <= DbgEvent.last and reason >= DbgEvent.first |
| return StopReason.OTHER |
| |
| def _get_step_info(self, watches, step_index): |
| thread = self._debugger.CurrentThread |
| stackframes = thread.StackFrames |
| |
| frames = [] |
| state_frames = [] |
| |
| loc = LocIR(**self._location) |
| valid_loc_for_watch = loc.path and os.path.exists(loc.path) |
| |
| for idx, sf in enumerate(stackframes): |
| frame = FrameIR( |
| function=self._sanitize_function_name(sf.FunctionName), |
| is_inlined=sf.FunctionName.startswith("[Inline Frame]"), |
| loc=LocIR(path=None, lineno=None, column=None), |
| ) |
| |
| fname = frame.function or "" # pylint: disable=no-member |
| if any(name in fname for name in self.frames_below_main): |
| break |
| |
| state_frame = StackFrame( |
| function=frame.function, is_inlined=frame.is_inlined, watches={} |
| ) |
| |
| if valid_loc_for_watch and idx == 0: |
| for watch_info in watches: |
| if watch_is_active(watch_info, loc.path, idx, loc.lineno): |
| watch_expr = watch_info.expression |
| state_frame.watches[watch_expr] = self.evaluate_expression( |
| watch_expr, idx |
| ) |
| |
| state_frames.append(state_frame) |
| frames.append(frame) |
| |
| if frames: |
| frames[0].loc = loc |
| state_frames[0].location = SourceLocation(**self._location) |
| |
| stop_reason = self._translate_stop_reason(self._debugger.LastBreakReason) |
| program_state = ProgramState(frames=state_frames) |
| |
| return StepIR( |
| step_index=step_index, |
| frames=frames, |
| stop_reason=stop_reason, |
| program_state=program_state, |
| ) |
| |
| @property |
| def is_running(self): |
| return self._mode == VisualStudio.dbgRunMode |
| |
| @property |
| def is_finished(self): |
| return self._mode == VisualStudio.dbgDesignMode |
| |
| @property |
| def frames_below_main(self): |
| return [ |
| "[Inline Frame] invoke_main", |
| "__scrt_common_main_seh", |
| "__tmainCRTStartup", |
| "mainCRTStartup", |
| ] |
| |
| def evaluate_expression(self, expression, frame_idx=0) -> ValueIR: |
| if frame_idx != 0: |
| self.set_current_stack_frame(frame_idx) |
| result = self._debugger.GetExpression(expression) |
| if frame_idx != 0: |
| self.set_current_stack_frame(0) |
| value = result.Value |
| |
| is_optimized_away = any( |
| s in value |
| for s in [ |
| "Variable is optimized away and not available", |
| "Value is not available, possibly due to optimization", |
| ] |
| ) |
| |
| is_irretrievable = any( |
| s in value |
| for s in [ |
| "???", |
| "<Unable to read memory>", |
| ] |
| ) |
| |
| # an optimized away value is still counted as being able to be |
| # evaluated. |
| could_evaluate = result.IsValidValue or is_optimized_away or is_irretrievable |
| |
| return ValueIR( |
| expression=expression, |
| value=value, |
| type_name=result.Type, |
| error_string=None, |
| is_optimized_away=is_optimized_away, |
| could_evaluate=could_evaluate, |
| is_irretrievable=is_irretrievable, |
| ) |