autodebug 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
autodebug/session.py ADDED
@@ -0,0 +1,828 @@
1
+ """One debug session, driven through the extension's relay (docs/design.md).
2
+
3
+ The extension shows the Approve dialog, runs what it showed, and relays Debug
4
+ Adapter Protocol messages. Everything else happens here: choosing and checking
5
+ the launch configuration, following the session from the debugger's events,
6
+ breakpoint binding, and reading the stack, threads and variables.
7
+
8
+ `Session.call(op, args)` answers the operations the agent tools use, so
9
+ `tools.Debugger` does not depend on how the extension is built.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import os
17
+ import re
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ from pathlib import Path
22
+
23
+ from autodebug.connection import IDE_NAMES, Connection, DebugError, Window
24
+
25
+ _LOG = logging.getLogger("autodebug")
26
+
27
+ STEP_COMMANDS = {"over": "next", "into": "stepIn", "out": "stepOut"}
28
+ # Extensions to suggest when a launch configuration's debugger is missing.
29
+ DEBUGGER_EXTENSIONS = {
30
+ "debugpy": "Python Debugger", "python": "Python Debugger", "cppdbg": "C/C++", "cppvsdbg": "C/C++",
31
+ "lldb": "CodeLLDB", "lldb-dap": "LLDB DAP", "gdb": "Native Debug", "coreclr": "C#", "java": "Debugger for Java",
32
+ "go": "Go", "php": "PHP Debug",
33
+ }
34
+ PLATFORM_KEYS = {"win32": "windows", "darwin": "osx"}
35
+ FINISHED = ("ended", "denied", "build_failed")
36
+ OUTPUT_NOTE = ('program output went to the IDE terminal; set "console": "internalConsole" in the launch '
37
+ "configuration to capture it")
38
+ _SUBSTITUTION = re.compile(r"\$\{(input|command):([^}]+)\}")
39
+
40
+
41
+ # -- launch configurations and tasks ----------------------------------------------
42
+
43
+
44
+ def apply_platform(item: dict, platform: str) -> dict:
45
+ """The configuration as it runs on `platform`: its windows/osx/linux block applied, then removed."""
46
+ copy = json.loads(json.dumps(item))
47
+ block = copy.get(PLATFORM_KEYS.get(platform, "linux"))
48
+ for key in ("windows", "osx", "linux"):
49
+ copy.pop(key, None)
50
+ if isinstance(block, dict):
51
+ copy.update(block)
52
+ return copy
53
+
54
+
55
+ def _strings(value, leaf: str = ""):
56
+ if isinstance(value, str):
57
+ yield value, leaf
58
+ elif isinstance(value, list):
59
+ for i, item in enumerate(value):
60
+ yield from _strings(item, str(i))
61
+ elif isinstance(value, dict):
62
+ for key, item in value.items():
63
+ yield from _strings(item, str(key))
64
+
65
+
66
+ def substitutions(config: dict, attach_pid_allowed: bool = False) -> list[str]:
67
+ """`${input:…}` and `${command:…}` values: they would ask the developer after the approval."""
68
+ found = []
69
+ for text, leaf in _strings(config):
70
+ for kind, name in _SUBSTITUTION.findall(text):
71
+ if not (attach_pid_allowed and kind == "command" and leaf in ("processId", "pid")):
72
+ found.append(f"${{{kind}:{name}}}")
73
+ return found
74
+
75
+
76
+ def _host(value) -> str:
77
+ host = str(value).strip()
78
+ bracket = re.fullmatch(r"\[([^\]]+)\](?::\d+)?", host)
79
+ if bracket:
80
+ host = bracket.group(1)
81
+ else:
82
+ with_port = re.fullmatch(r"(.*):(\d+)", host)
83
+ if with_port and with_port.group(1).count(":") == 0:
84
+ host = with_port.group(1)
85
+ return host.lower()
86
+
87
+
88
+ def remote_problem(config: dict) -> str | None:
89
+ """The setting that would debug another computer, if any."""
90
+ if config.get("pipeTransport"):
91
+ return "pipeTransport"
92
+ connect = config.get("connect") if isinstance(config.get("connect"), dict) else {}
93
+ for name, value in (("host", config.get("host")), ("address", config.get("address")),
94
+ ("connect.host", connect.get("host")),
95
+ ("miDebuggerServerAddress", config.get("miDebuggerServerAddress"))):
96
+ if value not in (None, "") and _host(value) not in ("localhost", "127.0.0.1", "::1"):
97
+ return name
98
+ return None
99
+
100
+
101
+ def _depends_on(task: dict) -> list[str]:
102
+ deps = task.get("dependsOn") or []
103
+ deps = deps if isinstance(deps, list) else [deps]
104
+ return [d if isinstance(d, str) else d.get("task") for d in deps if isinstance(d, (str, dict))]
105
+
106
+
107
+ def task_chain(label: str | None, tasks: list[dict], seen: set | None = None) -> list[dict]:
108
+ """The task and every task it depends on."""
109
+ seen = set() if seen is None else seen
110
+ if not label or label in seen:
111
+ return []
112
+ seen.add(label)
113
+ task = next((t for t in tasks if t.get("label") == label), None)
114
+ if task is None:
115
+ return []
116
+ return [task] + [t for dep in _depends_on(task) if dep for t in task_chain(dep, tasks, seen)]
117
+
118
+
119
+ def attach_target(launch: dict, name: str = "") -> str:
120
+ pid = launch.get("processId", launch.get("pid"))
121
+ if pid is not None and "${" not in str(pid):
122
+ return f"Attach to: process {pid} ({name or 'name unknown'})"
123
+ connect = launch.get("connect") if isinstance(launch.get("connect"), dict) else {}
124
+ host = connect.get("host") or launch.get("host") or "localhost"
125
+ port = connect.get("port") or launch.get("port")
126
+ return f"Attach to: the debugger listening on {host}:{port}" if port else "Attach to: the running program"
127
+
128
+
129
+ def process_name(pid: int) -> str:
130
+ """The process's name for the Approve dialog, or '' when it cannot be read."""
131
+ try:
132
+ if sys.platform.startswith("linux"):
133
+ return Path(f"/proc/{pid}/comm").read_text(encoding="utf-8").strip()
134
+ if sys.platform == "darwin":
135
+ return subprocess.run(["ps", "-p", str(pid), "-o", "comm="], capture_output=True, text=True,
136
+ timeout=3).stdout.strip()
137
+ out = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"], capture_output=True,
138
+ text=True, timeout=3).stdout.strip()
139
+ match = re.match(r'"([^"]+)"', out)
140
+ return match.group(1) if match else ""
141
+ except FileNotFoundError:
142
+ if sys.platform.startswith("linux"):
143
+ raise DebugError("NOT_FOUND", f"no process {pid}") from None
144
+ return ""
145
+ except (OSError, subprocess.SubprocessError):
146
+ return ""
147
+
148
+
149
+ def missing_program(launch: dict, folder: str, building: bool) -> str | None:
150
+ """The launch configuration's program when it is not on disk and nothing will build it first, else None.
151
+ Only paths that can be resolved here are checked: absolute, or under ${workspaceFolder}."""
152
+ if building or launch.get("request") != "launch" or launch.get("preLaunchTask"):
153
+ return None
154
+ program = launch.get("program")
155
+ if not isinstance(program, str) or not program.strip():
156
+ return None
157
+ for variable in ("${workspaceFolder}", "${workspaceRoot}"):
158
+ program = program.replace(variable, folder)
159
+ if "${" in program or not os.path.isabs(program):
160
+ return None
161
+ path = Path(program)
162
+ if path.exists() or (os.name == "nt" and not path.suffix and path.with_suffix(".exe").exists()):
163
+ return None
164
+ return program
165
+
166
+
167
+ def prepare_launch(raw: dict, process_id, platform: str) -> dict:
168
+ """The launch configuration exactly as it will run, or a DebugError saying why it cannot."""
169
+ launch = apply_platform(raw, platform)
170
+ if launch.get("request") not in ("launch", "attach"):
171
+ raise DebugError("BAD_REQUEST", "request must be launch or attach")
172
+ attach = launch["request"] == "attach"
173
+ if process_id is not None:
174
+ if not attach:
175
+ raise DebugError("BAD_REQUEST", "process_id is only for attach configurations")
176
+ if not isinstance(process_id, int) or isinstance(process_id, bool) or process_id < 1:
177
+ raise DebugError("BAD_REQUEST", "process_id must be a positive integer")
178
+ field = "pid" if launch.get("type") == "lldb" else "processId"
179
+ current = launch.get(field)
180
+ numeric = isinstance(current, int) or (isinstance(current, str) and current.isdigit())
181
+ placeholder = isinstance(current, str) and _SUBSTITUTION.search(current) and current.startswith("${command:")
182
+ if (numeric and int(current) != process_id) or (not numeric and not placeholder and current is not None):
183
+ raise DebugError("BAD_REQUEST", "process_id contradicts the fixed PID")
184
+ launch[field] = process_id
185
+ elif attach and any(leaf in ("processId", "pid") and "${command:" in text for text, leaf in _strings(launch)):
186
+ raise DebugError("BAD_REQUEST", "this configuration picks a process; pass process_id")
187
+ tokens = substitutions(launch, attach)
188
+ if tokens:
189
+ raise DebugError("BAD_REQUEST", f"refuses {', '.join(tokens)}: they would ask the developer after approval")
190
+ remote = remote_problem(launch)
191
+ if remote:
192
+ raise DebugError("BAD_REQUEST", f"AutoDebug debugs only programs on this computer ({remote})")
193
+ return launch
194
+
195
+
196
+ # -- variable paths ----------------------------------------------------------------
197
+
198
+
199
+ def split_path(name: str) -> list[str]:
200
+ """`mesh.verts[2].co` and `scene->objects[2]` as their parts."""
201
+ parts = []
202
+ for piece in str(name).replace("->", ".").split("."):
203
+ parts += [a or b for a, b in re.findall(r"([^\[\].]+)|\[(\d+)\]", piece)]
204
+ return [p for p in parts if p]
205
+
206
+
207
+ def _child_matches(child: dict, part: str) -> bool:
208
+ return child.get("name") in (part, f"[{part}]")
209
+
210
+
211
+ def _key(file: str) -> str:
212
+ return os.path.normcase(os.path.abspath(str(file)))
213
+
214
+
215
+ def _within(root: str, full: str) -> bool:
216
+ try:
217
+ rel = os.path.relpath(full, root)
218
+ except ValueError: # another drive on Windows
219
+ return False
220
+ return rel == "." or not (rel == ".." or rel.startswith(".." + os.sep) or os.path.isabs(rel))
221
+
222
+
223
+ # -- the session -------------------------------------------------------------------
224
+
225
+
226
+ def connect(window: Window) -> "Session":
227
+ return Session(Connection(window))
228
+
229
+
230
+ class Session:
231
+ """The agent's view of one IDE window and its debug session."""
232
+
233
+ def __init__(self, connection: Connection) -> None:
234
+ self.connection = connection
235
+ self.window = connection.window
236
+ self.s: dict | None = None
237
+ self.version = 0
238
+ self.cursor = 0
239
+
240
+ def close(self) -> None:
241
+ self.connection.close()
242
+
243
+ def _relay(self, op: str, args: dict | None = None, timeout: float = 15.0) -> dict:
244
+ return self.connection.call(op, args or {}, timeout=timeout)
245
+
246
+ def call(self, op: str, args: dict | None = None, timeout: float = 30.0) -> dict:
247
+ handler = {
248
+ "targets": self.targets, "start": self.start, "state": self.state, "stack": self.stack,
249
+ "variables": self.variables, "threads": self.threads, "exceptions": self.exceptions, "step": self.step,
250
+ "continue": self.resume, "pause": self.pause, "breakpoints": self.breakpoints, "stop": self.stop,
251
+ }.get(op)
252
+ if handler is None:
253
+ raise DebugError("BAD_REQUEST", f"unknown operation {op}")
254
+ return handler(args or {})
255
+
256
+ # -- targets -----------------------------------------------------------------
257
+
258
+ def targets(self, args: dict) -> dict:
259
+ info = self._relay("info")
260
+ ws = self._relay("workspace", {"folder": args.get("folder")})
261
+ types = info.get("debugger_types")
262
+ launch = []
263
+ for config in ws.get("launch", []):
264
+ prepared = apply_platform(config, info.get("platform", sys.platform))
265
+ launch.append({
266
+ "name": config.get("name"), "type": config.get("type"), "request": config.get("request"),
267
+ "program": prepared.get("program"), "module": prepared.get("module"), "args": prepared.get("args") or [],
268
+ "console": prepared.get("console"), "available": None if types is None else config.get("type") in types,
269
+ "preLaunchTask": config.get("preLaunchTask"), "postDebugTask": config.get("postDebugTask"),
270
+ "attach": attach_target(prepared) if config.get("request") == "attach" else None,
271
+ })
272
+ tasks = [{"label": t.get("label"), "command": " ".join(str(p) for p in [t.get("command"), *(t.get("args") or [])] if p)}
273
+ for t in ws.get("tasks", [])]
274
+ return {"folder": ws.get("folder"), "launch": launch, "tasks": tasks}
275
+
276
+ # -- start: everything is checked here, then the extension asks the developer ---------------
277
+
278
+ def start(self, args: dict) -> dict:
279
+ info = self._relay("info")
280
+ platform = info.get("platform", sys.platform)
281
+ ide = IDE_NAMES.get(info.get("ide"), "the IDE")
282
+ where = " in Visual Studio's startup project" if info.get("ide") == "visualstudio" else " in .vscode/launch.json"
283
+ ws = self._relay("workspace", {"folder": args.get("folder")})
284
+ folder = ws["folder"]
285
+ found = next((c for c in ws.get("launch", []) if c.get("name") == args.get("launch")), None)
286
+ if found is None:
287
+ raise DebugError("NOT_FOUND", f'no launch configuration named "{args.get("launch")}"{where}; see list_targets')
288
+ launch = prepare_launch(found, args.get("process_id"), platform)
289
+ types = info.get("debugger_types")
290
+ if types is not None and launch.get("type") not in types:
291
+ if ide == "Cursor" and launch.get("type") in ("cppdbg", "cppvsdbg"):
292
+ raise DebugError("NOT_FOUND", 'Cursor cannot run the Microsoft C/C++ debugger. Use a CodeLLDB '
293
+ 'configuration ("type": "lldb") instead.')
294
+ extension = DEBUGGER_EXTENSIONS.get(launch.get("type"))
295
+ raise DebugError("NOT_FOUND", f'{ide} has no debugger for type "{launch.get("type")}". Ask the developer to '
296
+ f"install {f'the {extension} extension' if extension else 'the extension for it'}"
297
+ " and reload the window.")
298
+ tasks = [apply_platform(t, platform) for t in ws.get("tasks", [])]
299
+ if args.get("task") and not any(t.get("label") == args["task"] for t in tasks):
300
+ raise DebugError("NOT_FOUND", f'no build task labelled "{args["task"]}"; see list_targets')
301
+ missing = missing_program(launch, folder, bool(args.get("task")))
302
+ if missing:
303
+ labels = [t.get("label") for t in tasks if t.get("label")]
304
+ fix = (f"call start_session again with build_task set to one of: {', '.join(repr(l) for l in labels[:5])}"
305
+ if labels else "ask the developer to build it")
306
+ raise DebugError("NOT_BUILT", f"{missing} does not exist yet, so the debugger cannot start it. "
307
+ f"Nothing was shown to the developer. To build it first, {fix}.")
308
+ used = [t for label in (args.get("task"), launch.get("preLaunchTask"), launch.get("postDebugTask"))
309
+ for t in task_chain(label, tasks)]
310
+ tokens = list(dict.fromkeys(token for t in used for token in substitutions(t)))
311
+ if tokens:
312
+ raise DebugError("BAD_REQUEST", f"refuses {', '.join(tokens)}: they would ask the developer after approval")
313
+ extras = self._source_folders(args.get("source_folders") or [])
314
+ roots = [folder, *extras]
315
+ breakpoints = [self._breakpoint(b, folder, roots) for b in args.get("breakpoints") or []]
316
+ functions = [str(name)[:300] for name in args.get("function_breakpoints") or []]
317
+ max_steps = max(1, min(int(args.get("max_steps") or 100), 1000))
318
+ label = launch["name"]
319
+ notes = [f"Goal: {str(args.get('goal') or '')[:500]}"] if args.get("goal") else []
320
+ if launch["request"] == "attach":
321
+ pid = launch.get("processId", launch.get("pid"))
322
+ name = process_name(int(pid)) if pid is not None and str(pid).isdigit() else ""
323
+ label = name or label
324
+ notes.append(attach_target(launch, name))
325
+ latest = int(self._relay("events", {"since": 2 ** 50, "wait_ms": 0}).get("latest") or 0)
326
+ answer = self._relay("approve", {
327
+ "folder": folder, "launch": launch, "task": args.get("task"), "notes": notes, "label": label,
328
+ "breakpoints": [{"file": b["full"], "line": b["line"], "condition": b["condition"]} for b in breakpoints],
329
+ "functions": functions, "source_folders": extras, "max_steps": max_steps,
330
+ "max_minutes": args.get("max_minutes"), "build_minutes": args.get("build_minutes"),
331
+ })
332
+ for b, ident in zip(breakpoints, answer.get("breakpoint_ids", [])):
333
+ b["id"] = ident
334
+ self.cursor = latest
335
+ self.s = {
336
+ "status": "awaiting_approval", "reason": None, "location": None, "exit_code": None, "message": None,
337
+ "steps_used": 0, "max_steps": max_steps, "build": None, "thread_id": None, "thread_name": None,
338
+ "exception": None, "output": [], "partial": "", "folder": folder, "launch": launch, "roots": roots,
339
+ "breakpoints": breakpoints,
340
+ "functions": [{"name": n, "id": i} for n, i in zip(functions, answer.get("function_ids", []))],
341
+ "verified": {}, "by_id": {}, "pending": {}, "capabilities": {}, "asked": None, "connected": False,
342
+ }
343
+ self._changed()
344
+ return {"state": self.snapshot()}
345
+
346
+ def _source_folders(self, folders: list) -> list[str]:
347
+ if len(folders) > 10:
348
+ raise DebugError("BAD_REQUEST", "source_folders accepts at most 10 folders")
349
+ resolved = []
350
+ for folder in folders:
351
+ if not isinstance(folder, str) or not os.path.isabs(folder):
352
+ raise DebugError("BAD_REQUEST", "source_folders must be absolute paths")
353
+ if not os.path.isdir(folder):
354
+ raise DebugError("NOT_FOUND", f"source folder not found: {folder}")
355
+ resolved.append(os.path.abspath(folder))
356
+ return resolved
357
+
358
+ def _breakpoint(self, b: dict, folder: str, roots: list[str]) -> dict:
359
+ line = b.get("line")
360
+ if not isinstance(line, int) or isinstance(line, bool) or line < 1:
361
+ raise DebugError("BAD_REQUEST", "breakpoint lines are positive integers")
362
+ file = b.get("file")
363
+ if not isinstance(file, str) or not file.strip():
364
+ raise DebugError("BAD_REQUEST", "a breakpoint needs a file")
365
+ if os.path.isabs(file):
366
+ full = os.path.abspath(file)
367
+ if not any(_within(root, full) for root in roots):
368
+ raise DebugError("OUTSIDE_WORKSPACE", f"{file} is outside the workspace; pass source_folders for "
369
+ "folders outside the workspace")
370
+ else:
371
+ full = os.path.abspath(os.path.join(folder, file))
372
+ if not _within(folder, full) or os.path.normcase(full) == os.path.normcase(os.path.abspath(folder)):
373
+ raise DebugError("OUTSIDE_WORKSPACE", f"{file} is outside {folder}; pass source_folders for folders "
374
+ "outside the workspace")
375
+ if not os.path.isfile(full):
376
+ raise DebugError("NOT_FOUND", f"no file {file}")
377
+ condition = str(b["condition"])[:500] if b.get("condition") else None
378
+ return {"file": self._relative(full, folder), "full": full, "line": line, "condition": condition, "id": None}
379
+
380
+ # -- following the session --------------------------------------------------------
381
+
382
+ def _changed(self, **changes) -> None:
383
+ if self.s is not None:
384
+ self.s.update(changes)
385
+ self.version += 1
386
+
387
+ def _relative(self, full: str | None, folder: str | None = None) -> str | None:
388
+ if not full:
389
+ return None
390
+ folder = folder or self.s["folder"]
391
+ return Path(os.path.relpath(full, folder)).as_posix() if _within(folder, full) else full
392
+
393
+ def _pump(self, wait_ms: int = 0) -> None:
394
+ """Read the relay's new events and apply them."""
395
+ while True:
396
+ answer = self._relay("events", {"since": self.cursor, "wait_ms": wait_ms}, timeout=wait_ms / 1000 + 10)
397
+ events = answer.get("events") or []
398
+ if answer.get("dropped"):
399
+ _LOG.warning("some debugger events were dropped before they could be read")
400
+ if events:
401
+ self.cursor = int(answer.get("next") or self.cursor)
402
+ self._apply(events)
403
+ if len(events) < 500:
404
+ return
405
+ wait_ms = 0
406
+
407
+ def _apply(self, events: list[dict]) -> None:
408
+ s = self.s
409
+ if s is None:
410
+ return
411
+ stop = None
412
+ for event in events:
413
+ kind = event.get("kind")
414
+ if kind == "approval":
415
+ if event.get("approved"):
416
+ self._changed(status="starting")
417
+ else:
418
+ self._changed(status="denied", reason=event.get("reason"))
419
+ elif kind == "build":
420
+ self._build(event)
421
+ elif kind == "starting":
422
+ self._changed(status="starting")
423
+ elif kind == "started":
424
+ s["connected"] = True
425
+ if s["status"] == "starting":
426
+ self._changed(status="running")
427
+ elif kind == "not_started":
428
+ self._finish("the debugger did not start", message="check the Debug Console")
429
+ elif kind == "error":
430
+ s["message"] = str(event.get("message") or "")[:500]
431
+ elif kind == "ended":
432
+ self._finish(event.get("reason") or "the debug session ended", event.get("exit_code"))
433
+ elif kind == "dap":
434
+ s["connected"] = True
435
+ stop = self._message(event.get("direction"), event.get("message") or {}, stop)
436
+ if stop is not None and s["status"] not in FINISHED:
437
+ self._locate(stop["threadId"], stop.get("reason"))
438
+
439
+ def _build(self, event: dict) -> None:
440
+ task, status = event.get("task"), event.get("status")
441
+ if status == "running":
442
+ self._changed(status="building", build={"task": task, "status": "running", "exit_code": None})
443
+ elif status == "succeeded":
444
+ self._changed(build={"task": task, "status": "succeeded", "exit_code": 0})
445
+ else:
446
+ minutes = event.get("minutes") or 0
447
+ message = {"timeout": f"the build took longer than {minutes} minute{'' if minutes == 1 else 's'}",
448
+ "unavailable": f'task "{task}" is not available'}.get(status, "see the task output in the terminal")
449
+ self._changed(status="build_failed", exit_code=event.get("exit_code"), message=message,
450
+ build={"task": task, "status": "failed", "exit_code": event.get("exit_code")})
451
+
452
+ def _message(self, direction: str, m: dict, stop: dict | None) -> dict | None:
453
+ """Apply one Debug Adapter Protocol message; returns the stop still to locate, if any."""
454
+ s = self.s
455
+ kind, body = m.get("type"), m.get("body") or {}
456
+ if direction == "to_adapter" and kind == "request":
457
+ arguments = m.get("arguments") or {}
458
+ if m.get("command") == "setBreakpoints" and isinstance(arguments.get("source"), dict):
459
+ s["pending"][m.get("seq")] = ("lines", arguments["source"].get("path"),
460
+ [b.get("line") for b in arguments.get("breakpoints") or []])
461
+ elif m.get("command") == "setFunctionBreakpoints":
462
+ s["pending"][m.get("seq")] = ("functions", None, [b.get("name") for b in arguments.get("breakpoints") or []])
463
+ return stop
464
+ if kind == "response":
465
+ if m.get("command") == "initialize":
466
+ s["capabilities"] = body
467
+ sent = s["pending"].pop(m.get("request_seq"), None)
468
+ if sent:
469
+ what, file, items = sent
470
+ for bp, item in zip(body.get("breakpoints") or [], items):
471
+ key = f"fn:{item}" if what == "functions" else f"{_key(file)}:{item}"
472
+ s["verified"][key] = bp
473
+ if bp.get("id") is not None:
474
+ s["by_id"][bp["id"]] = key
475
+ self._changed()
476
+ return stop
477
+ if kind != "event":
478
+ return stop
479
+ event = m.get("event")
480
+ if event == "breakpoint" and isinstance(body.get("breakpoint"), dict):
481
+ bp = body["breakpoint"]
482
+ key = s["by_id"].get(bp.get("id"))
483
+ if key:
484
+ s["verified"][key] = {**s["verified"].get(key, {}), **bp}
485
+ self._changed()
486
+ elif event == "output":
487
+ self._capture(body)
488
+ elif event == "stopped":
489
+ s["thread_id"] = body.get("threadId")
490
+ return {"threadId": body.get("threadId"), "reason": body.get("reason")}
491
+ elif event == "continued":
492
+ if s["status"] not in FINISHED:
493
+ self._changed(status="running", location=None, reason=None, exception=None)
494
+ return None
495
+ elif event == "exited":
496
+ if s["status"] != "ended":
497
+ s["exit_code"] = body.get("exitCode")
498
+ elif event == "terminated":
499
+ self._finish("the program exited")
500
+ return None
501
+ return stop
502
+
503
+ def _capture(self, body: dict) -> None:
504
+ s = self.s
505
+ if body.get("category") == "telemetry" or not isinstance(body.get("output"), str):
506
+ return
507
+ lines = re.split(r"\r?\n", s["partial"] + body["output"])
508
+ s["partial"] = lines.pop()
509
+ s["output"] += [line[:500] for line in lines if line.strip()]
510
+ del s["output"][:-60]
511
+
512
+ def _locate(self, thread_id, reason) -> None:
513
+ s = self.s
514
+ exception = None
515
+ if reason == "exception" and s["capabilities"].get("supportsExceptionInfoRequest"):
516
+ try:
517
+ body = self._dap("exceptionInfo", {"threadId": thread_id})
518
+ exception = {"id": body.get("exceptionId"), "description": body.get("description") or ""}
519
+ except DebugError:
520
+ pass
521
+ location = None
522
+ try:
523
+ frames = self._dap("stackTrace", {"threadId": thread_id, "startFrame": 0, "levels": 1}).get("stackFrames") or []
524
+ if frames:
525
+ location = {"file": self._relative((frames[0].get("source") or {}).get("path")), "line": frames[0].get("line")}
526
+ except DebugError as exc:
527
+ _LOG.warning("error handling stopped: %s", exc.message)
528
+ self._changed(status="paused", reason=reason, location=location, exception=exception)
529
+
530
+ def _finish(self, reason: str, exit_code=..., message: str | None = None) -> None:
531
+ s = self.s
532
+ if s is None or s["status"] == "ended":
533
+ return
534
+ if s["partial"].strip():
535
+ s["output"].append(s["partial"][:500])
536
+ s["partial"] = ""
537
+ changes = {"status": "ended", "reason": reason, "location": None}
538
+ if exit_code is not ... and exit_code is not None:
539
+ changes["exit_code"] = exit_code
540
+ if message:
541
+ changes["message"] = message
542
+ self._changed(**changes)
543
+
544
+ def _dap(self, command: str, arguments: dict, timeout_ms: int = 10000) -> dict:
545
+ return self._relay("dap", {"command": command, "arguments": arguments, "timeout_ms": timeout_ms},
546
+ timeout=timeout_ms / 1000 + 5).get("body") or {}
547
+
548
+ def current(self, *statuses: str) -> dict:
549
+ if self.s is None:
550
+ raise DebugError("NOT_FOUND", "no debug session; call start_session first")
551
+ self._pump(0)
552
+ status = self.s["status"]
553
+ if statuses and status not in statuses:
554
+ raise DebugError("NOT_PAUSED" if status == "running" else "BAD_REQUEST", f"the session is {status}")
555
+ return self.s
556
+
557
+ # -- the state the tools see -----------------------------------------------------------
558
+
559
+ def _verified(self, key: str) -> bool:
560
+ return bool(self.s["verified"].get(key, {}).get("verified"))
561
+
562
+ def _note(self, key: str, line: int | None) -> str:
563
+ s = self.s
564
+ bp = s["verified"].get(key)
565
+ if bp is None:
566
+ return "not confirmed by the debugger yet" if s["connected"] else "set when the program starts"
567
+ if bp.get("verified"):
568
+ return f"moved to line {bp['line']}" if line and bp.get("line") and bp["line"] != line else ""
569
+ message = str(bp.get("message") or "")
570
+ if s["status"] in ("starting", "running", "paused"):
571
+ return (f"not bound yet ({message}); it may bind when its module loads" if message
572
+ else "not bound yet; it may bind when its module loads")
573
+ return message or "not bound"
574
+
575
+ def _editor_breakpoints(self) -> list[dict]:
576
+ try:
577
+ listed = self._relay("editor_breakpoints").get("breakpoints") or []
578
+ except DebugError:
579
+ return []
580
+ out = []
581
+ for b in listed:
582
+ if b.get("function"):
583
+ key = f"fn:{b['function']}"
584
+ out.append({"function": b["function"], "source": "editor", "verified": self._verified(key), "message": ""})
585
+ elif b.get("file"):
586
+ key = f"{_key(b['file'])}:{b.get('line')}"
587
+ out.append({"file": self._relative(b["file"]), "line": b.get("line"), "source": "editor",
588
+ "verified": self._verified(key), "message": self._note(key, b.get("line"))})
589
+ return out
590
+
591
+ def snapshot(self) -> dict:
592
+ s = self.s
593
+ if s is None:
594
+ return {"version": 0, "status": "none"}
595
+ session = [{"file": b["file"], "line": b["line"], "source": "session",
596
+ "verified": self._verified(f"{_key(b['full'])}:{b['line']}"),
597
+ "message": self._note(f"{_key(b['full'])}:{b['line']}", b["line"])} for b in s["breakpoints"]]
598
+ functions = [{"function": f["name"], "source": "session", "verified": self._verified(f"fn:{f['name']}"),
599
+ "message": ""} for f in s["functions"]]
600
+ ended = s["status"] == "ended"
601
+ return {
602
+ "version": self.version, "status": s["status"], "reason": s["reason"], "location": s["location"],
603
+ "exit_code": s["exit_code"], "message": s["message"], "steps_used": s["steps_used"],
604
+ "max_steps": s["max_steps"], "build": s["build"],
605
+ "thread": None if s["thread_id"] is None else {"id": s["thread_id"], "name": s["thread_name"]},
606
+ "exception": s["exception"], "breakpoints": session + functions + self._editor_breakpoints(),
607
+ "output": s["output"][-20:] if s["status"] in ("ended", "paused") else [],
608
+ "output_note": OUTPUT_NOTE if ended and not s["output"] and str(s["launch"].get("console") or "").endswith("Terminal")
609
+ else None,
610
+ }
611
+
612
+ def state(self, args: dict) -> dict:
613
+ if self.s is None:
614
+ return {"state": self.snapshot()}
615
+ text = args.get("ask_developer")
616
+ if text is not None:
617
+ text = str(text)
618
+ if len(text) > 300:
619
+ raise DebugError("BAD_REQUEST", "ask_developer is at most 300 characters")
620
+ if text and text != self.s["asked"]:
621
+ self._relay("notify", {"text": text})
622
+ self.s["asked"] = text
623
+ since = int(args.get("since") or 0)
624
+ deadline = time.monotonic() + max(0, min(int(args.get("wait_ms") or 0), 60000)) / 1000
625
+ self._pump(0)
626
+ while self.version <= since:
627
+ remaining = deadline - time.monotonic()
628
+ if remaining <= 0:
629
+ break
630
+ self._pump(int(min(remaining, 25) * 1000))
631
+ return {"state": self.snapshot()}
632
+
633
+ # -- inspection ----------------------------------------------------------------------
634
+
635
+ def stack(self, args: dict) -> dict:
636
+ s = self.current("paused")
637
+ levels = max(1, min(int(args.get("levels") or 20), 200))
638
+ start = max(0, int(args.get("start_frame") or 0))
639
+ body = self._dap("stackTrace", {"threadId": s["thread_id"], "startFrame": start, "levels": levels})
640
+ frames = [{"id": f.get("id"), "name": f.get("name"),
641
+ "file": self._relative((f.get("source") or {}).get("path")) or "(no source)", "line": f.get("line")}
642
+ for f in (body.get("stackFrames") or [])[:levels]]
643
+ total = body.get("totalFrames")
644
+ return {"frames": frames, "total": start + len(frames) if total is None else total}
645
+
646
+ def threads(self, args: dict) -> dict:
647
+ s = self.current("paused", "running")
648
+ threads = [{"id": t.get("id"), "name": t.get("name")} for t in (self._dap("threads", {}).get("threads") or [])[:500]]
649
+ select = args.get("select")
650
+ if select is not None:
651
+ if s["status"] != "paused":
652
+ raise DebugError("NOT_PAUSED", "select a thread only while paused")
653
+ chosen = next((t for t in threads if t["id"] == select), None)
654
+ if chosen is None:
655
+ raise DebugError("NOT_FOUND", f"no thread {select}")
656
+ s["thread_id"], s["thread_name"] = chosen["id"], chosen["name"]
657
+ self._locate(chosen["id"], s["reason"])
658
+ elif s["thread_id"] is not None:
659
+ current = next((t for t in threads if t["id"] == s["thread_id"]), None)
660
+ if current:
661
+ s["thread_name"] = current["name"]
662
+ return {"threads": threads, "current": s["thread_id"], "state": self.snapshot()}
663
+
664
+ def exceptions(self, args: dict) -> dict:
665
+ s = self.current()
666
+ if not s["connected"] or s["status"] in FINISHED or s["status"] in ("awaiting_approval", "building"):
667
+ raise DebugError("NOT_FOUND", "the debugger is not connected yet")
668
+ filters = s["capabilities"].get("exceptionBreakpointFilters") or []
669
+ offered = [{"id": f.get("filter"), "label": f.get("label"), "default": bool(f.get("default"))} for f in filters]
670
+ enable = args.get("enable")
671
+ if isinstance(enable, list):
672
+ known = [f["id"] for f in offered]
673
+ unknown = [i for i in enable if i not in known]
674
+ if unknown:
675
+ raise DebugError("BAD_REQUEST", f"unknown exception filters: {', '.join(unknown)}; offered: "
676
+ f"{', '.join(known) or 'none'}")
677
+ self._dap("setExceptionBreakpoints", {"filters": enable})
678
+ return {"filters": offered}
679
+
680
+ def variables(self, args: dict) -> dict:
681
+ s = self.current("paused")
682
+ frame = args.get("frame")
683
+ if not isinstance(frame, int):
684
+ frames = self._dap("stackTrace", {"threadId": s["thread_id"], "startFrame": 0, "levels": 1}).get("stackFrames") or []
685
+ if not frames:
686
+ raise DebugError("NOT_FOUND", "the debugger shows no stack frame")
687
+ frame = frames[0]["id"]
688
+ depth = max(0, min(int(args.get("depth") or 0), 3))
689
+ scopes = self._dap("scopes", {"frameId": frame}).get("scopes") or []
690
+ wanted = [str(n) for n in args["names"]] if isinstance(args.get("names"), list) else None
691
+ searched = [sc for sc in scopes if not sc.get("expensive") and sc.get("variablesReference")]
692
+ found: dict[str, dict] = {}
693
+ for scope in searched if wanted else searched[:1]:
694
+ for variable in (self._children(scope["variablesReference"]))[:200]:
695
+ name = variable.get("name")
696
+ if name not in found and not re.fullmatch(r"(special|function) variables", str(name)):
697
+ found[name] = variable
698
+ budget = {"nodes": 0}
699
+ if wanted is None:
700
+ chosen = list(found.values())
701
+ else:
702
+ chosen = []
703
+ for name in wanted:
704
+ variable = self._lookup(found, name)
705
+ if variable is not None:
706
+ chosen.append({**variable, "name": name})
707
+ return {"frame": frame, "variables": [self._expand(v, depth, wanted is not None, budget) for v in chosen]}
708
+
709
+ def _children(self, reference: int) -> list[dict]:
710
+ return self._dap("variables", {"variablesReference": reference}).get("variables") or []
711
+
712
+ def _lookup(self, roots: dict[str, dict], name: str) -> dict | None:
713
+ parts = split_path(name)
714
+ if not parts:
715
+ return None
716
+ current = roots.get(name) or roots.get(parts[0])
717
+ if current is None:
718
+ return None
719
+ if name in roots:
720
+ return current
721
+ for part in parts[1:]:
722
+ if not current.get("variablesReference"):
723
+ return None
724
+ current = next((c for c in self._children(current["variablesReference"]) if _child_matches(c, part)), None)
725
+ if current is None:
726
+ return None
727
+ return current
728
+
729
+ def _expand(self, variable: dict, depth: int, named: bool, budget: dict) -> dict:
730
+ node = {"name": variable.get("name"), "type": variable.get("type") or "", "value": str(variable.get("value"))}
731
+ if budget["nodes"] >= 1000:
732
+ return {**node, "expandable": True}
733
+ budget["nodes"] += 1
734
+ if not variable.get("variablesReference"):
735
+ return node
736
+ if depth <= 0:
737
+ return {**node, "expandable": True}
738
+ children = self._children(variable["variablesReference"])
739
+ node["children"] = []
740
+ for child in children[:50 if named else 20]:
741
+ if budget["nodes"] >= 1000:
742
+ break
743
+ node["children"].append(self._expand(child, depth - 1, named, budget))
744
+ if len(children) > len(node["children"]):
745
+ node["more"] = len(children) - len(node["children"])
746
+ node["expandable"] = True
747
+ return node
748
+
749
+ # -- control ---------------------------------------------------------------------
750
+
751
+ def step(self, args: dict) -> dict:
752
+ s = self.current("paused")
753
+ command = STEP_COMMANDS.get(args.get("kind"))
754
+ if command is None:
755
+ raise DebugError("BAD_REQUEST", "kind is over, into or out")
756
+ if s["steps_used"] >= s["max_steps"]:
757
+ raise DebugError("LIMIT", f"the approved limit of {s['max_steps']} steps is used up")
758
+ wait_ms = max(0, min(int(args.get("wait_ms") or 15000), 60000))
759
+ paused_at = (s["location"], s["reason"])
760
+ s["steps_used"] += 1
761
+ self._changed(status="running", location=None, reason=None)
762
+ before = self.version
763
+ try:
764
+ self._dap(command, {"threadId": s["thread_id"]})
765
+ except DebugError:
766
+ s["steps_used"] -= 1
767
+ self._changed(status="paused", location=paused_at[0], reason=paused_at[1])
768
+ raise
769
+ deadline = time.monotonic() + wait_ms / 1000
770
+ while (self.version <= before or s["status"] == "running") and s["status"] != "ended":
771
+ remaining = deadline - time.monotonic()
772
+ if remaining <= 0:
773
+ break
774
+ self._pump(int(min(remaining, 25) * 1000))
775
+ return {"state": self.snapshot()}
776
+
777
+ def resume(self, args: dict) -> dict:
778
+ s = self.current("paused")
779
+ self._changed(status="running", location=None, reason=None, exception=None)
780
+ self._dap("continue", {"threadId": s["thread_id"]})
781
+ return {"state": self.snapshot()}
782
+
783
+ def pause(self, args: dict) -> dict:
784
+ s = self.current("running")
785
+ thread_id = s["thread_id"]
786
+ if thread_id is None:
787
+ threads = self._dap("threads", {}).get("threads") or []
788
+ thread_id = threads[0]["id"] if threads else None
789
+ self._dap("pause", {"threadId": thread_id})
790
+ return {"state": self.snapshot()}
791
+
792
+ def breakpoints(self, args: dict) -> dict:
793
+ s = self.current("starting", "running", "paused")
794
+ remove = {(b.get("file"), b.get("line")) for b in args.get("remove") or []}
795
+ remove_keys = set()
796
+ for file, line in remove:
797
+ if isinstance(file, str):
798
+ full = file if os.path.isabs(file) else os.path.join(s["folder"], file)
799
+ remove_keys.add(f"{_key(full)}:{line}")
800
+ gone = [b for b in s["breakpoints"] if f"{_key(b['full'])}:{b['line']}" in remove_keys]
801
+ gone_functions = [f for f in s["functions"] if f["name"] in set(args.get("remove_functions") or [])]
802
+ added = [b for b in (self._breakpoint(b, s["folder"], s["roots"]) for b in args.get("add") or [])
803
+ if not any(x["full"] == b["full"] and x["line"] == b["line"] for x in s["breakpoints"])]
804
+ new_functions = list(dict.fromkeys(str(n) for n in args.get("add_functions") or []
805
+ if n not in {f["name"] for f in s["functions"]}))
806
+ answer = self._relay("breakpoints", {
807
+ "remove": [b["id"] for b in gone + gone_functions if b.get("id")],
808
+ "add": [{"file": b["full"], "line": b["line"], "condition": b["condition"]} for b in added],
809
+ "add_functions": new_functions,
810
+ })
811
+ for b, ident in zip(added, answer.get("breakpoint_ids", [])):
812
+ b["id"] = ident
813
+ s["breakpoints"] = [b for b in s["breakpoints"] if b not in gone] + added
814
+ s["functions"] = [f for f in s["functions"] if f not in gone_functions] + [
815
+ {"name": n, "id": i} for n, i in zip(new_functions, answer.get("function_ids", []))]
816
+ self._changed()
817
+ return {"state": self.snapshot()}
818
+
819
+ def stop(self, args: dict) -> dict:
820
+ s = self.s
821
+ if s is None:
822
+ return {"state": self.snapshot()}
823
+ attach = s["launch"].get("request") == "attach"
824
+ reason = "the debugger detached and the program keeps running" if attach else "stopped by the agent"
825
+ if s["status"] not in FINISHED:
826
+ self._finish(reason)
827
+ self._relay("stop", {"reason": reason}, timeout=20) # the extension ignores it when nothing is running
828
+ return {"state": self.snapshot()}