Coverage for vibefpmgui/dap.py: 84%
118 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""A Debug Adapter Protocol client: one debug adapter (gdb's ``--interpreter=dap``,
2``lldb-dap``…) run as a process, spoken to over its stdin and stdout (see
3`vibefpmgui.wire` for the framing; `vibefpmgui.debug_session` drives it).
5Each request gets its answer through a callback, ``callback(success, body,
6message)``, called once: with the adapter's answer, or, when it doesn't
7answer in time or stops, with success False and why. Events come as the
8`event` signal. A request from the adapter (``runInTerminal``…) is answered
9"not supported".
11As for fortls (`vibefpmgui.lsp`): no threads (the process is read in the GUI
12thread), and methods, not lambdas, connected to the process.
13"""
15from __future__ import annotations
17from collections.abc import Callable
19from qtpy.QtCore import QObject, QProcess, QProcessEnvironment, QTimer, Signal
21from .wire import FrameReader, frame
23TIMEOUT = 10_000 # ms a request may take (a hung adapter can't leave a session stuck)
25Callback = Callable[[bool, dict, str], None]
28class DapClient(QObject):
29 event = Signal(str, object) # an event's name and body ({} if none)
30 stopped = Signal(str) # the adapter's process ended (why), not by stop()
32 def __init__(self, parent=None):
33 super().__init__(parent)
34 self.process: QProcess | None = None
35 self._reader = FrameReader()
36 self._seq = 0
37 self._pending: dict[int, tuple[str, Callback, QTimer]] = {}
38 self.capabilities: dict = {}
40 # ---------------------------------------------------------- lifecycle
41 def is_running(self) -> bool:
42 return self.process is not None
44 def start(self, command: list[str], cwd: str, env: dict[str, str] | None = None) -> bool:
45 """Start the adapter (``command``: its program and arguments)."""
46 self.stop()
47 process = QProcess(self)
48 process.setWorkingDirectory(cwd)
49 if env: 49 ↛ 50line 49 didn't jump to line 50 because the condition on line 49 was never true
50 environment = QProcessEnvironment.systemEnvironment()
51 for key, value in env.items():
52 environment.insert(key, value)
53 process.setProcessEnvironment(environment)
54 process.setStandardErrorFile(QProcess.nullDevice())
55 process.readyReadStandardOutput.connect(self._read)
56 process.finished.connect(self._process_finished)
57 process.errorOccurred.connect(self._process_error)
58 self.process = process
59 process.start(command[0], command[1:])
60 return True
62 def stop(self):
63 """End the adapter (and with it the program it debugs), at once."""
64 process, self.process = self.process, None
65 self._fail_pending("the debugger was stopped")
66 self._reader.clear()
67 if process is not None:
68 process.kill()
69 process.waitForFinished(1000)
70 process.deleteLater()
72 def _process_finished(self, *_):
73 if isinstance(self.sender(), QProcess): 73 ↛ exitline 73 didn't return from function '_process_finished' because the condition on line 73 was always true
74 self._ended(self.sender())
76 def _process_error(self, error):
77 if error == QProcess.FailedToStart and isinstance(self.sender(), QProcess):
78 self._ended(self.sender())
80 def _ended(self, process: QProcess):
81 if process is not self.process:
82 return # one we stopped
83 self.process = None
84 why = ("the debugger could not be started" if process.error() == QProcess.FailedToStart
85 else f"the debugger stopped (exit code {process.exitCode()})")
86 self._fail_pending(why)
87 try:
88 process.deleteLater()
89 except RuntimeError:
90 return # the app is shutting down
91 self.stopped.emit(why)
93 # ---------------------------------------------------------- messages
94 def request(self, command: str, arguments: dict | None = None, callback: Callback | None = None,
95 timeout: int = TIMEOUT) -> bool:
96 """Send a request; False if no adapter is running (``callback`` is
97 then called at once, with success False)."""
98 if self.process is None:
99 if callback is not None: 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true
100 callback(False, {}, "no debugger is running")
101 return False
102 self._seq += 1
103 seq = self._seq
104 timer = QTimer(self)
105 timer.setSingleShot(True)
106 timer.setInterval(timeout)
107 timer.timeout.connect(self._timed_out)
108 timer.setProperty("seq", seq)
109 self._pending[seq] = (command, callback or _ignore, timer)
110 if timeout > 0:
111 timer.start()
112 self.process.write(frame({"seq": seq, "type": "request", "command": command,
113 "arguments": arguments or {}}))
114 return True
116 def _timed_out(self):
117 seq = self.sender().property("seq") if self.sender() is not None else None
118 entry = self._pending.pop(seq, None)
119 if entry is not None: 119 ↛ exitline 119 didn't return from function '_timed_out' because the condition on line 119 was always true
120 command, callback, timer = entry
121 timer.deleteLater()
122 _call(callback, False, {}, f"the debugger didn't answer {command} in time")
124 def _fail_pending(self, why: str):
125 pending, self._pending = self._pending, {}
126 for _command, callback, timer in pending.values():
127 timer.stop()
128 timer.deleteLater()
129 _call(callback, False, {}, why)
131 def _read(self):
132 if self.process is None: 132 ↛ 133line 132 didn't jump to line 133 because the condition on line 132 was never true
133 return
134 for message in self._reader.feed(bytes(self.process.readAllStandardOutput())):
135 self._dispatch(message)
137 def _dispatch(self, message: dict):
138 kind = message.get("type")
139 if kind == "response":
140 entry = self._pending.pop(message.get("request_seq"), None)
141 if entry is None: 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true
142 return
143 command, callback, timer = entry
144 timer.stop()
145 timer.deleteLater()
146 if command == "initialize" and message.get("success"):
147 self.capabilities = message.get("body") or {}
148 _call(callback, bool(message.get("success")), message.get("body") or {},
149 str(message.get("message") or ""))
150 elif kind == "event": 150 ↛ 152line 150 didn't jump to line 152 because the condition on line 150 was always true
151 self.event.emit(str(message.get("event") or ""), message.get("body") or {})
152 elif kind == "request" and self.process is not None: # (runInTerminal…: not supported yet)
153 self._seq += 1
154 self.process.write(frame({"seq": self._seq, "type": "response", "request_seq": message.get("seq"),
155 "command": message.get("command"), "success": False,
156 "message": "not supported"}))
159def _ignore(*_):
160 pass
163def _call(callback: Callback, success: bool, body: dict, message: str):
164 try:
165 callback(success, body, message)
166 except RuntimeError:
167 pass # what asked has been deleted (a closed panel)