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