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