1# DExTer : Debugging Experience Tester 2# ~~~~~~ ~ ~~ ~ ~~ 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-exception 7"""Interface for communicating with the Visual Studio debugger via DTE.""" 8 9import abc 10import os 11import sys 12from enum import IntEnum 13from pathlib import PurePath, Path 14from collections import defaultdict, namedtuple 15 16from dex.command.CommandBase import StepExpectInfo 17from dex.debugger.DebuggerBase import DebuggerBase, watch_is_active 18from dex.dextIR import FrameIR, LocIR, StepIR, StopReason, ValueIR 19from dex.dextIR import StackFrame, SourceLocation, ProgramState 20from dex.utils.Exceptions import Error, LoadDebuggerException 21from dex.utils.Imports import load_module 22from dex.utils.ReturnCode import ReturnCode 23 24def _load_com_module(): 25 try: 26 return load_module( 27 "ComInterface", os.path.join(os.path.dirname(__file__), "windows") 28 ) 29 except ImportError as e: 30 raise LoadDebuggerException(e, sys.exc_info()) 31 32 33# VSBreakpoint(path: PurePath, line: int, col: int, cond: str). This is enough 34# info to identify breakpoint equivalence in visual studio based on the 35# properties we set through dexter currently. 36VSBreakpoint = namedtuple("VSBreakpoint", "path, line, col, cond") 37 38 39# Visual Studio events. 40# https://learn.microsoft.com/en-us/dotnet/api/envdte.dbgeventreason?view=visualstudiosdk-2022 41class DbgEvent(IntEnum): 42 dbgEventReasonNone = 1 43 dbgEventReasonGo = 2 44 dbgEventReasonAttachProgram = 3 45 dbgEventReasonDetachProgram = 4 46 dbgEventReasonLaunchProgram = 5 47 dbgEventReasonEndProgram = 6 48 dbgEventReasonStopDebugging = 7 49 dbgEventReasonStep = 8 50 dbgEventReasonBreakpoint = 9 51 dbgEventReasonExceptionThrown = 10 52 dbgEventReasonExceptionNotHandled = 11 53 dbgEventReasonUserBreak = 12 54 dbgEventReasonContextSwitch = 13 55 56 first = dbgEventReasonNone 57 last = dbgEventReasonContextSwitch 58 59class VisualStudio( 60 DebuggerBase, metaclass=abc.ABCMeta 61): # pylint: disable=abstract-method 62 # Constants for results of Debugger.CurrentMode 63 # (https://msdn.microsoft.com/en-us/library/envdte.debugger.currentmode.aspx) 64 dbgDesignMode = 1 65 dbgBreakMode = 2 66 dbgRunMode = 3 67 68 def __init__(self, *args): 69 self.com_module = None 70 self._debugger = None 71 self._solution = None 72 self._fn_step = None 73 self._fn_go = None 74 # The next available unique breakpoint id. Use self._get_next_id(). 75 self._next_bp_id = 0 76 # VisualStudio appears to common identical breakpoints. That is, if you 77 # ask for a breakpoint that already exists the Breakpoints list will 78 # not grow. DebuggerBase requires all breakpoints have a unique id, 79 # even for duplicates, so we'll need to do some bookkeeping. Map 80 # {VSBreakpoint: list(id)} where id is the unique dexter-side id for 81 # the requested breakpoint. 82 self._vs_to_dex_ids = defaultdict(list) 83 # Map {id: VSBreakpoint} where id is unique and VSBreakpoint identifies 84 # a breakpoint in Visual Studio. There may be many ids mapped to a 85 # single VSBreakpoint. Use self._vs_to_dex_ids to find (dexter) 86 # breakpoints mapped to the same visual studio breakpoint. 87 self._dex_id_to_vs = {} 88 89 super(VisualStudio, self).__init__(*args) 90 91 def _create_solution(self): 92 self._solution.Create(self.context.working_directory.path, "DexterSolution") 93 try: 94 self._solution.AddFromFile(self._project_file) 95 except OSError: 96 raise LoadDebuggerException( 97 "could not debug the specified executable", sys.exc_info() 98 ) 99 100 def _load_solution(self): 101 try: 102 self._solution.Open(self.context.options.vs_solution) 103 except: 104 raise LoadDebuggerException( 105 "could not load specified vs solution at {}".format( 106 self.context.options.vs_solution 107 ), 108 sys.exc_info(), 109 ) 110 111 def _custom_init(self): 112 try: 113 self._debugger = self._interface.Debugger 114 self._debugger.HexDisplayMode = False 115 116 self._interface.MainWindow.Visible = self.context.options.show_debugger 117 118 self._solution = self._interface.Solution 119 if self.context.options.vs_solution is None: 120 self._create_solution() 121 else: 122 self._load_solution() 123 124 self._fn_step = self._debugger.StepInto 125 self._fn_go = self._debugger.Go 126 127 except AttributeError as e: 128 raise LoadDebuggerException(str(e), sys.exc_info()) 129 130 def _custom_exit(self): 131 if self._interface: 132 self._interface.Quit() 133 134 @property 135 def _project_file(self): 136 return self.context.options.executable 137 138 @abc.abstractproperty 139 def _dte_version(self): 140 pass 141 142 @property 143 def _location(self): 144 # TODO: Find a better way of determining path, line and column info 145 # that doesn't require reading break points. This method requires 146 # all lines to have a break point on them. 147 bp = self._debugger.BreakpointLastHit 148 return { 149 "path": getattr(bp, "File", None), 150 "lineno": getattr(bp, "FileLine", None), 151 "column": getattr(bp, "FileColumn", None), 152 } 153 154 @property 155 def _mode(self): 156 return self._debugger.CurrentMode 157 158 def _load_interface(self): 159 self.com_module = _load_com_module() 160 return self.com_module.DTE(self._dte_version) 161 162 @property 163 def version(self): 164 try: 165 return self._interface.Version 166 except AttributeError: 167 return None 168 169 def clear_breakpoints(self): 170 for bp in self._debugger.Breakpoints: 171 bp.Delete() 172 self._vs_to_dex_ids.clear() 173 self._dex_id_to_vs.clear() 174 175 def _add_breakpoint(self, file_, line): 176 return self._add_conditional_breakpoint(file_, line, "") 177 178 def _get_next_id(self): 179 # "Generate" a new unique id for the breakpoint. 180 id = self._next_bp_id 181 self._next_bp_id += 1 182 return id 183 184 def _add_conditional_breakpoint(self, file_, line, condition): 185 col = 1 186 vsbp = VSBreakpoint(PurePath(file_), line, col, condition) 187 new_id = self._get_next_id() 188 189 # Do we have an exact matching breakpoint already? 190 if vsbp in self._vs_to_dex_ids: 191 self._vs_to_dex_ids[vsbp].append(new_id) 192 self._dex_id_to_vs[new_id] = vsbp 193 return new_id 194 195 # Breakpoint doesn't exist already. Add it now. 196 count_before = self._debugger.Breakpoints.Count 197 self._debugger.Breakpoints.Add("", file_, line, col, condition) 198 # Our internal representation of VS says that the breakpoint doesn't 199 # already exist so we do not expect this operation to fail here. 200 assert count_before < self._debugger.Breakpoints.Count 201 # We've added a new breakpoint, record its id. 202 self._vs_to_dex_ids[vsbp].append(new_id) 203 self._dex_id_to_vs[new_id] = vsbp 204 return new_id 205 206 def get_triggered_breakpoint_ids(self): 207 """Returns a set of opaque ids for just-triggered breakpoints.""" 208 bps_hit = self._debugger.AllBreakpointsLastHit 209 bp_id_list = [] 210 # Intuitively, AllBreakpointsLastHit breakpoints are the last hit 211 # _bound_ breakpoints. A bound breakpoint's parent holds the info of 212 # the breakpoint the user requested. Our internal state tracks the user 213 # requested breakpoints so we look at the Parent of these triggered 214 # breakpoints to determine which have been hit. 215 for bp in bps_hit: 216 # All bound breakpoints should have the user-defined breakpoint as 217 # a parent. 218 assert bp.Parent 219 vsbp = VSBreakpoint( 220 PurePath(bp.Parent.File), 221 bp.Parent.FileLine, 222 bp.Parent.FileColumn, 223 bp.Parent.Condition, 224 ) 225 try: 226 ids = self._vs_to_dex_ids[vsbp] 227 except KeyError: 228 pass 229 else: 230 bp_id_list += ids 231 return set(bp_id_list) 232 233 def delete_breakpoints(self, ids): 234 """Delete breakpoints by their ids. 235 236 Raises a KeyError if no breakpoint with this id exists. 237 """ 238 vsbp_set = set() 239 for id in ids: 240 vsbp = self._dex_id_to_vs[id] 241 242 # Remove our id from the associated list of dex ids. 243 self._vs_to_dex_ids[vsbp].remove(id) 244 del self._dex_id_to_vs[id] 245 246 # Bail if there are other uses of this vsbp. 247 if len(self._vs_to_dex_ids[vsbp]) > 0: 248 continue 249 # Otherwise find and delete it. 250 vsbp_set.add(vsbp) 251 252 vsbp_to_del_count = len(vsbp_set) 253 254 for bp in self._debugger.Breakpoints: 255 # We're looking at the user-set breakpoints so there should be no 256 # Parent. 257 assert bp.Parent == None 258 this_vsbp = VSBreakpoint( 259 PurePath(bp.File), bp.FileLine, bp.FileColumn, bp.Condition 260 ) 261 if this_vsbp in vsbp_set: 262 bp.Delete() 263 vsbp_to_del_count -= 1 264 if vsbp_to_del_count == 0: 265 break 266 if vsbp_to_del_count: 267 raise KeyError("did not find breakpoint to be deleted") 268 269 def _fetch_property(self, props, name): 270 num_props = props.Count 271 result = None 272 for x in range(1, num_props + 1): 273 item = props.Item(x) 274 if item.Name == name: 275 return item 276 assert False, "Couldn't find property {}".format(name) 277 278 def launch(self, cmdline): 279 exe_path = Path(self.context.options.executable) 280 self.context.logger.note(f"VS: Using executable: '{exe_path}'") 281 cmdline_str = " ".join(cmdline) 282 if self.context.options.target_run_args: 283 cmdline_str += f" {self.context.options.target_run_args}" 284 if cmdline_str: 285 self.context.logger.note(f"VS: Using executable args: '{cmdline_str}'") 286 287 # In a slightly baroque manner, lookup the VS project that runs when 288 # you click "run", and set its command line options to the desired 289 # command line options. 290 startup_proj_name = str( 291 self._fetch_property(self._interface.Solution.Properties, "StartupProject") 292 ) 293 project = self._fetch_property(self._interface.Solution, startup_proj_name) 294 ActiveConfiguration = self._fetch_property( 295 project.Properties, "ActiveConfiguration" 296 ).Object 297 ActiveConfiguration.DebugSettings.CommandArguments = cmdline_str 298 ConfigurationName = ActiveConfiguration.ConfigurationName 299 SolConfig = self._fetch_property( 300 self._interface.Solution.SolutionBuild.SolutionConfigurations, 301 ConfigurationName, 302 ) 303 for Context in SolConfig.SolutionContexts: 304 Context.ShouldBuild = False 305 306 self.context.logger.note("Launching VS debugger...") 307 self._fn_go(False) 308 309 def step(self): 310 self._fn_step(False) 311 312 def go(self) -> ReturnCode: 313 self._fn_go(False) 314 return ReturnCode.OK 315 316 def set_current_stack_frame(self, idx: int = 0): 317 thread = self._debugger.CurrentThread 318 stack_frames = thread.StackFrames 319 try: 320 stack_frame = stack_frames[idx] 321 self._debugger.CurrentStackFrame = stack_frame.raw 322 except IndexError: 323 raise Error( 324 "attempted to access stack frame {} out of {}".format( 325 idx, len(stack_frames) 326 ) 327 ) 328 329 def _translate_stop_reason(self, reason): 330 if reason == DbgEvent.dbgEventReasonNone: 331 return None 332 if reason == DbgEvent.dbgEventReasonBreakpoint: 333 return StopReason.BREAKPOINT 334 if reason == DbgEvent.dbgEventReasonStep: 335 return StopReason.STEP 336 if reason == DbgEvent.dbgEventReasonEndProgram: 337 return StopReason.PROGRAM_EXIT 338 if reason == DbgEvent.dbgEventReasonExceptionNotHandled: 339 return StopReason.ERROR 340 assert reason <= DbgEvent.last and reason >= DbgEvent.first 341 return StopReason.OTHER 342 343 def _get_step_info(self, watches, step_index): 344 thread = self._debugger.CurrentThread 345 stackframes = thread.StackFrames 346 347 frames = [] 348 state_frames = [] 349 350 loc = LocIR(**self._location) 351 valid_loc_for_watch = loc.path and os.path.exists(loc.path) 352 353 for idx, sf in enumerate(stackframes): 354 frame = FrameIR( 355 function=self._sanitize_function_name(sf.FunctionName), 356 is_inlined=sf.FunctionName.startswith("[Inline Frame]"), 357 loc=LocIR(path=None, lineno=None, column=None), 358 ) 359 360 fname = frame.function or "" # pylint: disable=no-member 361 if any(name in fname for name in self.frames_below_main): 362 break 363 364 state_frame = StackFrame( 365 function=frame.function, is_inlined=frame.is_inlined, watches={} 366 ) 367 368 if valid_loc_for_watch and idx == 0: 369 for watch_info in watches: 370 if watch_is_active(watch_info, loc.path, idx, loc.lineno): 371 watch_expr = watch_info.expression 372 state_frame.watches[watch_expr] = self.evaluate_expression( 373 watch_expr, idx 374 ) 375 376 state_frames.append(state_frame) 377 frames.append(frame) 378 379 if frames: 380 frames[0].loc = loc 381 state_frames[0].location = SourceLocation(**self._location) 382 383 stop_reason = self._translate_stop_reason(self._debugger.LastBreakReason) 384 program_state = ProgramState(frames=state_frames) 385 386 return StepIR( 387 step_index=step_index, 388 frames=frames, 389 stop_reason=stop_reason, 390 program_state=program_state, 391 ) 392 393 @property 394 def is_running(self): 395 return self._mode == VisualStudio.dbgRunMode 396 397 @property 398 def is_finished(self): 399 return self._mode == VisualStudio.dbgDesignMode 400 401 @property 402 def frames_below_main(self): 403 return [ 404 "[Inline Frame] invoke_main", 405 "__scrt_common_main_seh", 406 "__tmainCRTStartup", 407 "mainCRTStartup", 408 ] 409 410 def evaluate_expression(self, expression, frame_idx=0) -> ValueIR: 411 if frame_idx != 0: 412 self.set_current_stack_frame(frame_idx) 413 result = self._debugger.GetExpression(expression) 414 if frame_idx != 0: 415 self.set_current_stack_frame(0) 416 value = result.Value 417 418 is_optimized_away = any( 419 s in value 420 for s in [ 421 "Variable is optimized away and not available", 422 "Value is not available, possibly due to optimization", 423 ] 424 ) 425 426 is_irretrievable = any( 427 s in value 428 for s in [ 429 "???", 430 "<Unable to read memory>", 431 ] 432 ) 433 434 # an optimized away value is still counted as being able to be 435 # evaluated. 436 could_evaluate = result.IsValidValue or is_optimized_away or is_irretrievable 437 438 return ValueIR( 439 expression=expression, 440 value=value, 441 type_name=result.Type, 442 error_string=None, 443 is_optimized_away=is_optimized_away, 444 could_evaluate=could_evaluate, 445 is_irretrievable=is_irretrievable, 446 ) 447