xref: /llvm-project/cross-project-tests/debuginfo-tests/dexter/dex/debugger/visualstudio/VisualStudio.py (revision ca92bdfa3ef8f9a1cc97167fc96601f8bd7b436b)
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