cpp-debug-mcp 0.1.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.
- cpp_debug_mcp/__init__.py +1 -0
- cpp_debug_mcp/__main__.py +6 -0
- cpp_debug_mcp/analysis/__init__.py +0 -0
- cpp_debug_mcp/analysis/correlator.py +260 -0
- cpp_debug_mcp/gdb/__init__.py +0 -0
- cpp_debug_mcp/gdb/controller.py +136 -0
- cpp_debug_mcp/gdb/session.py +146 -0
- cpp_debug_mcp/lsp/__init__.py +0 -0
- cpp_debug_mcp/lsp/client.py +210 -0
- cpp_debug_mcp/lsp/protocol.py +213 -0
- cpp_debug_mcp/lsp/session.py +91 -0
- cpp_debug_mcp/server.py +52 -0
- cpp_debug_mcp/tools/__init__.py +0 -0
- cpp_debug_mcp/tools/combined_tools.py +110 -0
- cpp_debug_mcp/tools/fmt.py +301 -0
- cpp_debug_mcp/tools/gdb_tools.py +395 -0
- cpp_debug_mcp/tools/lsp_tools.py +265 -0
- cpp_debug_mcp-0.1.1.dist-info/METADATA +187 -0
- cpp_debug_mcp-0.1.1.dist-info/RECORD +22 -0
- cpp_debug_mcp-0.1.1.dist-info/WHEEL +5 -0
- cpp_debug_mcp-0.1.1.dist-info/licenses/LICENSE +21 -0
- cpp_debug_mcp-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Claude Code MCP server for C++ debugging with GDB and clangd."""
|
|
File without changes
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Cross-reference GDB runtime state with LSP static analysis."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..gdb.controller import GdbMiController
|
|
7
|
+
from ..lsp.client import ClangdClient
|
|
8
|
+
from ..lsp.protocol import (
|
|
9
|
+
file_uri,
|
|
10
|
+
make_did_open,
|
|
11
|
+
make_text_document_position,
|
|
12
|
+
make_reference_params,
|
|
13
|
+
parse_diagnostic,
|
|
14
|
+
parse_hover,
|
|
15
|
+
parse_location,
|
|
16
|
+
)
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def ensure_file_open(
|
|
21
|
+
client: ClangdClient,
|
|
22
|
+
file_path: str,
|
|
23
|
+
opened_files: set[str],
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Open a file in clangd if not already opened."""
|
|
26
|
+
uri = file_uri(file_path)
|
|
27
|
+
if uri not in opened_files:
|
|
28
|
+
try:
|
|
29
|
+
content = Path(file_path).resolve().read_text()
|
|
30
|
+
except (FileNotFoundError, OSError):
|
|
31
|
+
return
|
|
32
|
+
lang = "c" if file_path.endswith(".c") else "cpp"
|
|
33
|
+
await client.send_notification(
|
|
34
|
+
"textDocument/didOpen",
|
|
35
|
+
make_did_open(file_path, content, lang),
|
|
36
|
+
)
|
|
37
|
+
opened_files.add(uri)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def get_crash_report(
|
|
41
|
+
gdb_ctrl: GdbMiController,
|
|
42
|
+
lsp_client: ClangdClient,
|
|
43
|
+
opened_files: set[str],
|
|
44
|
+
max_frames: int = 5,
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
"""Build a crash diagnosis report combining GDB backtrace with LSP info.
|
|
47
|
+
|
|
48
|
+
Returns a structured report with:
|
|
49
|
+
- stop_reason and signal info
|
|
50
|
+
- backtrace with per-frame type info from LSP
|
|
51
|
+
- local variables at crash site
|
|
52
|
+
- static diagnostics for relevant files
|
|
53
|
+
"""
|
|
54
|
+
report: dict[str, Any] = {}
|
|
55
|
+
|
|
56
|
+
# Get stop reason from GDB
|
|
57
|
+
stop_responses = await gdb_ctrl.send_command("-thread-info")
|
|
58
|
+
for r in stop_responses:
|
|
59
|
+
if r.get("type") == "result" and isinstance(r.get("payload"), dict):
|
|
60
|
+
threads = r["payload"].get("threads", [])
|
|
61
|
+
if threads:
|
|
62
|
+
current = threads[0]
|
|
63
|
+
report["current_thread"] = {
|
|
64
|
+
"id": current.get("id"),
|
|
65
|
+
"state": current.get("state"),
|
|
66
|
+
"name": current.get("name", ""),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Get backtrace
|
|
70
|
+
bt_responses = await gdb_ctrl.send_command(f"-stack-list-frames 0 {max_frames - 1}")
|
|
71
|
+
frames = []
|
|
72
|
+
for r in bt_responses:
|
|
73
|
+
if r.get("type") == "result" and isinstance(r.get("payload"), dict):
|
|
74
|
+
stack = r["payload"].get("stack", [])
|
|
75
|
+
for frame_entry in stack:
|
|
76
|
+
frame = frame_entry.get("frame", frame_entry) if isinstance(frame_entry, dict) else frame_entry
|
|
77
|
+
frames.append({
|
|
78
|
+
"level": frame.get("level"),
|
|
79
|
+
"function": frame.get("func", "??"),
|
|
80
|
+
"file": frame.get("file", ""),
|
|
81
|
+
"fullname": frame.get("fullname", ""),
|
|
82
|
+
"line": frame.get("line", ""),
|
|
83
|
+
"address": frame.get("addr", ""),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
report["backtrace"] = frames
|
|
87
|
+
|
|
88
|
+
# Get local variables at crash frame
|
|
89
|
+
try:
|
|
90
|
+
var_responses = await gdb_ctrl.send_command("-stack-list-variables --simple-values")
|
|
91
|
+
for r in var_responses:
|
|
92
|
+
if r.get("type") == "result" and isinstance(r.get("payload"), dict):
|
|
93
|
+
report["local_variables"] = r["payload"].get("variables", [])
|
|
94
|
+
except Exception:
|
|
95
|
+
report["local_variables"] = []
|
|
96
|
+
|
|
97
|
+
# Enrich with LSP info for each frame
|
|
98
|
+
diagnostics_by_file: dict[str, list] = {}
|
|
99
|
+
for frame in frames:
|
|
100
|
+
file_path = frame.get("fullname") or frame.get("file", "")
|
|
101
|
+
if not file_path or not Path(file_path).exists():
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
await ensure_file_open(lsp_client, file_path, opened_files)
|
|
105
|
+
|
|
106
|
+
# Get hover info at crash line
|
|
107
|
+
line = frame.get("line", "")
|
|
108
|
+
if line:
|
|
109
|
+
try:
|
|
110
|
+
hover_result = await lsp_client.send_request(
|
|
111
|
+
"textDocument/hover",
|
|
112
|
+
make_text_document_position(file_path, int(line) - 1, 0),
|
|
113
|
+
timeout=5.0,
|
|
114
|
+
)
|
|
115
|
+
hover = parse_hover(hover_result)
|
|
116
|
+
if hover:
|
|
117
|
+
frame["type_info"] = hover.to_dict()
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
# Collect diagnostics per file (once per file)
|
|
122
|
+
if file_path not in diagnostics_by_file:
|
|
123
|
+
notif = await lsp_client.wait_for_notification(
|
|
124
|
+
"textDocument/publishDiagnostics", timeout=5.0
|
|
125
|
+
)
|
|
126
|
+
if notif and "diagnostics" in notif:
|
|
127
|
+
diagnostics_by_file[file_path] = [
|
|
128
|
+
parse_diagnostic(d, file_path).to_dict()
|
|
129
|
+
for d in notif["diagnostics"]
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
report["static_diagnostics"] = diagnostics_by_file
|
|
133
|
+
return report
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def get_variable_info(
|
|
137
|
+
gdb_ctrl: GdbMiController,
|
|
138
|
+
lsp_client: ClangdClient,
|
|
139
|
+
opened_files: set[str],
|
|
140
|
+
variable_name: str,
|
|
141
|
+
file_path: str,
|
|
142
|
+
line: int,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
"""Get combined runtime value (GDB) and type info (LSP) for a variable."""
|
|
145
|
+
result: dict[str, Any] = {"variable": variable_name}
|
|
146
|
+
|
|
147
|
+
# GDB: evaluate the variable
|
|
148
|
+
try:
|
|
149
|
+
eval_responses = await gdb_ctrl.send_command(
|
|
150
|
+
f'-data-evaluate-expression "{variable_name}"'
|
|
151
|
+
)
|
|
152
|
+
for r in eval_responses:
|
|
153
|
+
if r.get("type") == "result" and isinstance(r.get("payload"), dict):
|
|
154
|
+
result["runtime_value"] = r["payload"].get("value", "")
|
|
155
|
+
except Exception as e:
|
|
156
|
+
result["runtime_value"] = f"(error: {e})"
|
|
157
|
+
|
|
158
|
+
# LSP: hover for type info
|
|
159
|
+
if file_path and Path(file_path).exists():
|
|
160
|
+
await ensure_file_open(lsp_client, file_path, opened_files)
|
|
161
|
+
try:
|
|
162
|
+
hover_result = await lsp_client.send_request(
|
|
163
|
+
"textDocument/hover",
|
|
164
|
+
make_text_document_position(file_path, line - 1, 0),
|
|
165
|
+
timeout=5.0,
|
|
166
|
+
)
|
|
167
|
+
hover = parse_hover(hover_result)
|
|
168
|
+
if hover:
|
|
169
|
+
result["type_info"] = hover.to_dict()
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
# LSP: go to definition
|
|
174
|
+
try:
|
|
175
|
+
def_result = await lsp_client.send_request(
|
|
176
|
+
"textDocument/definition",
|
|
177
|
+
make_text_document_position(file_path, line - 1, 0),
|
|
178
|
+
timeout=5.0,
|
|
179
|
+
)
|
|
180
|
+
if isinstance(def_result, list) and def_result:
|
|
181
|
+
loc = parse_location(def_result[0])
|
|
182
|
+
result["definition"] = loc.to_dict()
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def analyze_function_info(
|
|
190
|
+
gdb_ctrl: GdbMiController,
|
|
191
|
+
lsp_client: ClangdClient,
|
|
192
|
+
opened_files: set[str],
|
|
193
|
+
function_name: str,
|
|
194
|
+
) -> dict[str, Any]:
|
|
195
|
+
"""Analyze a function using both GDB and LSP.
|
|
196
|
+
|
|
197
|
+
Sets a temporary breakpoint, gets LSP definition/references, and if stopped
|
|
198
|
+
at the function, retrieves local variables.
|
|
199
|
+
"""
|
|
200
|
+
result: dict[str, Any] = {"function": function_name}
|
|
201
|
+
|
|
202
|
+
# Set a temporary breakpoint at the function
|
|
203
|
+
try:
|
|
204
|
+
bp_responses = await gdb_ctrl.send_command(f"-break-insert -t {function_name}")
|
|
205
|
+
for r in bp_responses:
|
|
206
|
+
if r.get("type") == "result" and isinstance(r.get("payload"), dict):
|
|
207
|
+
bkpt = r["payload"].get("bkpt", {})
|
|
208
|
+
result["breakpoint"] = {
|
|
209
|
+
"id": bkpt.get("number"),
|
|
210
|
+
"file": bkpt.get("file", ""),
|
|
211
|
+
"fullname": bkpt.get("fullname", ""),
|
|
212
|
+
"line": bkpt.get("line", ""),
|
|
213
|
+
}
|
|
214
|
+
file_path = bkpt.get("fullname", bkpt.get("file", ""))
|
|
215
|
+
line = bkpt.get("line", "")
|
|
216
|
+
except Exception as e:
|
|
217
|
+
result["breakpoint_error"] = str(e)
|
|
218
|
+
file_path = ""
|
|
219
|
+
line = ""
|
|
220
|
+
|
|
221
|
+
# LSP: get definition and references
|
|
222
|
+
if file_path and Path(file_path).exists():
|
|
223
|
+
await ensure_file_open(lsp_client, file_path, opened_files)
|
|
224
|
+
|
|
225
|
+
if line:
|
|
226
|
+
try:
|
|
227
|
+
hover_result = await lsp_client.send_request(
|
|
228
|
+
"textDocument/hover",
|
|
229
|
+
make_text_document_position(file_path, int(line) - 1, 0),
|
|
230
|
+
timeout=5.0,
|
|
231
|
+
)
|
|
232
|
+
hover = parse_hover(hover_result)
|
|
233
|
+
if hover:
|
|
234
|
+
result["signature"] = hover.to_dict()
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
refs = await lsp_client.send_request(
|
|
240
|
+
"textDocument/references",
|
|
241
|
+
make_reference_params(file_path, int(line) - 1, 0),
|
|
242
|
+
timeout=5.0,
|
|
243
|
+
)
|
|
244
|
+
if isinstance(refs, list):
|
|
245
|
+
result["references"] = [
|
|
246
|
+
parse_location(r).to_dict() for r in refs[:20]
|
|
247
|
+
]
|
|
248
|
+
except Exception:
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
# Try to get local variables if we're stopped at the function
|
|
252
|
+
try:
|
|
253
|
+
var_responses = await gdb_ctrl.send_command("-stack-list-variables --simple-values")
|
|
254
|
+
for r in var_responses:
|
|
255
|
+
if r.get("type") == "result" and isinstance(r.get("payload"), dict):
|
|
256
|
+
result["local_variables"] = r["payload"].get("variables", [])
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""GDB/MI subprocess controller wrapping pygdbmi with async execution."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pygdbmi.gdbcontroller import GdbController
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# Commands blocked in raw mode to prevent injection
|
|
15
|
+
BLOCKED_COMMANDS = re.compile(
|
|
16
|
+
r"^\s*(shell\b|!|python\b|python-interactive\b|pi\b|pipe\b|source\b)",
|
|
17
|
+
re.IGNORECASE,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GdbError(Exception):
|
|
22
|
+
"""Error from GDB."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GdbTimeoutError(GdbError):
|
|
26
|
+
"""GDB command timed out."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GdbMiController:
|
|
30
|
+
"""Async wrapper around pygdbmi's GdbController for GDB/MI communication."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, gdb_path: str | None = None):
|
|
33
|
+
self._gdb_path = gdb_path or os.environ.get("GDB_PATH", "gdb")
|
|
34
|
+
self._controller: GdbController | None = None
|
|
35
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_running(self) -> bool:
|
|
39
|
+
return self._controller is not None
|
|
40
|
+
|
|
41
|
+
async def start(
|
|
42
|
+
self,
|
|
43
|
+
executable: str,
|
|
44
|
+
args: list[str] | None = None,
|
|
45
|
+
working_dir: str = ".",
|
|
46
|
+
) -> str:
|
|
47
|
+
"""Launch GDB and load the executable. Returns initialization output."""
|
|
48
|
+
self._loop = asyncio.get_event_loop()
|
|
49
|
+
gdb_args = [self._gdb_path, "--interpreter=mi3", "-q"]
|
|
50
|
+
if working_dir and working_dir != ".":
|
|
51
|
+
gdb_args.extend(["--cd", working_dir])
|
|
52
|
+
|
|
53
|
+
def _start():
|
|
54
|
+
ctrl = GdbController(command=gdb_args)
|
|
55
|
+
return ctrl
|
|
56
|
+
|
|
57
|
+
self._controller = await self._loop.run_in_executor(None, _start)
|
|
58
|
+
|
|
59
|
+
# Load the executable
|
|
60
|
+
result = await self.send_command(f"-file-exec-and-symbols {executable}")
|
|
61
|
+
return self._format_responses(result)
|
|
62
|
+
|
|
63
|
+
async def send_command(
|
|
64
|
+
self,
|
|
65
|
+
command: str,
|
|
66
|
+
timeout: float = 30.0,
|
|
67
|
+
) -> list[dict[str, Any]]:
|
|
68
|
+
"""Send an MI command and return parsed responses."""
|
|
69
|
+
if not self._controller:
|
|
70
|
+
raise GdbError("GDB session not started")
|
|
71
|
+
|
|
72
|
+
def _write():
|
|
73
|
+
return self._controller.write(
|
|
74
|
+
command,
|
|
75
|
+
timeout_sec=int(timeout),
|
|
76
|
+
raise_error_on_timeout=True,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
return await self._loop.run_in_executor(None, _write)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
if "timed out" in str(e).lower():
|
|
83
|
+
raise GdbTimeoutError(f"Command timed out: {command}") from e
|
|
84
|
+
raise GdbError(str(e)) from e
|
|
85
|
+
|
|
86
|
+
async def send_raw_command(self, command: str, timeout: float = 30.0) -> list[dict[str, Any]]:
|
|
87
|
+
"""Send a raw GDB command with safety checks."""
|
|
88
|
+
if BLOCKED_COMMANDS.match(command.strip()):
|
|
89
|
+
raise GdbError(
|
|
90
|
+
f"Command blocked for safety: {command.split()[0]}. "
|
|
91
|
+
"Shell, python, pipe, and source commands are not allowed."
|
|
92
|
+
)
|
|
93
|
+
return await self.send_command(command, timeout)
|
|
94
|
+
|
|
95
|
+
async def stop(self) -> None:
|
|
96
|
+
"""Terminate GDB process."""
|
|
97
|
+
if self._controller:
|
|
98
|
+
try:
|
|
99
|
+
def _exit():
|
|
100
|
+
try:
|
|
101
|
+
self._controller.write("-gdb-exit", timeout_sec=5)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
self._controller.exit()
|
|
105
|
+
|
|
106
|
+
await self._loop.run_in_executor(None, _exit)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.warning("Error stopping GDB: %s", e, file=sys.stderr)
|
|
109
|
+
finally:
|
|
110
|
+
self._controller = None
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _format_responses(responses: list[dict[str, Any]]) -> str:
|
|
114
|
+
"""Format MI responses into a readable string."""
|
|
115
|
+
parts = []
|
|
116
|
+
for resp in responses:
|
|
117
|
+
msg_type = resp.get("type", "")
|
|
118
|
+
message = resp.get("message", "")
|
|
119
|
+
payload = resp.get("payload", "")
|
|
120
|
+
|
|
121
|
+
if msg_type == "result":
|
|
122
|
+
if message == "error":
|
|
123
|
+
parts.append(f"ERROR: {payload}")
|
|
124
|
+
elif payload:
|
|
125
|
+
parts.append(str(payload))
|
|
126
|
+
elif msg_type == "console":
|
|
127
|
+
if payload:
|
|
128
|
+
parts.append(payload.strip())
|
|
129
|
+
elif msg_type == "notify" and payload:
|
|
130
|
+
parts.append(f"[{message}] {payload}")
|
|
131
|
+
|
|
132
|
+
return "\n".join(parts) if parts else "OK"
|
|
133
|
+
|
|
134
|
+
def format(self, responses: list[dict[str, Any]]) -> str:
|
|
135
|
+
"""Public format method."""
|
|
136
|
+
return self._format_responses(responses)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""GDB session lifecycle manager."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
from .controller import GdbMiController, GdbError
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
MAX_SESSIONS = 4
|
|
15
|
+
INACTIVITY_TIMEOUT = 1800 # 30 minutes
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GdbSessionManager:
|
|
19
|
+
"""Manages multiple GDB debugging sessions."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, max_sessions: int = MAX_SESSIONS):
|
|
22
|
+
self._sessions: dict[str, GdbMiController] = {}
|
|
23
|
+
self._last_activity: dict[str, float] = {}
|
|
24
|
+
self._consoles: dict[str, str] = {} # session_id -> tmux session name
|
|
25
|
+
self._max_sessions = max_sessions
|
|
26
|
+
|
|
27
|
+
async def create_session(
|
|
28
|
+
self,
|
|
29
|
+
executable: str,
|
|
30
|
+
args: list[str] | None = None,
|
|
31
|
+
working_dir: str = ".",
|
|
32
|
+
) -> tuple[str, str]:
|
|
33
|
+
"""Create a new GDB session. Returns (session_id, init_output)."""
|
|
34
|
+
await self._cleanup_stale()
|
|
35
|
+
|
|
36
|
+
if len(self._sessions) >= self._max_sessions:
|
|
37
|
+
raise GdbError(
|
|
38
|
+
f"Maximum sessions ({self._max_sessions}) reached. "
|
|
39
|
+
"End an existing session first."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
session_id = str(uuid.uuid4())[:8]
|
|
43
|
+
controller = GdbMiController()
|
|
44
|
+
|
|
45
|
+
output = await controller.start(executable, args, working_dir)
|
|
46
|
+
self._sessions[session_id] = controller
|
|
47
|
+
self._last_activity[session_id] = time.time()
|
|
48
|
+
|
|
49
|
+
return session_id, output
|
|
50
|
+
|
|
51
|
+
def get_session(self, session_id: str) -> GdbMiController:
|
|
52
|
+
"""Get a session by ID, updating activity timestamp."""
|
|
53
|
+
if session_id not in self._sessions:
|
|
54
|
+
raise GdbError(f"Session not found: {session_id}")
|
|
55
|
+
self._last_activity[session_id] = time.time()
|
|
56
|
+
return self._sessions[session_id]
|
|
57
|
+
|
|
58
|
+
async def open_console(self, session_id: str) -> str:
|
|
59
|
+
"""Open an interactive GDB console for a session via tmux.
|
|
60
|
+
|
|
61
|
+
Creates a tmux session and attaches GDB's `new-ui console` to its PTY,
|
|
62
|
+
allowing a programmer to interact with the same GDB session from their
|
|
63
|
+
terminal.
|
|
64
|
+
|
|
65
|
+
Returns instructions for connecting.
|
|
66
|
+
"""
|
|
67
|
+
if not shutil.which("tmux"):
|
|
68
|
+
raise GdbError("tmux is required for interactive console. Install it with: apt install tmux")
|
|
69
|
+
|
|
70
|
+
if session_id in self._consoles:
|
|
71
|
+
tmux_name = self._consoles[session_id]
|
|
72
|
+
return (
|
|
73
|
+
f"Console already open for session {session_id}.\n"
|
|
74
|
+
f"Connect with:\n tmux attach -t {tmux_name}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
ctrl = self.get_session(session_id)
|
|
78
|
+
|
|
79
|
+
tmux_name = f"gdb-{session_id}"
|
|
80
|
+
|
|
81
|
+
# Create a tmux session (detached, with a shell that stays open)
|
|
82
|
+
subprocess.run(
|
|
83
|
+
["tmux", "new-session", "-d", "-s", tmux_name],
|
|
84
|
+
check=True,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Get the PTY device of the tmux pane
|
|
88
|
+
result = subprocess.run(
|
|
89
|
+
["tmux", "display-message", "-t", tmux_name, "-p", "#{pane_tty}"],
|
|
90
|
+
capture_output=True, text=True, check=True,
|
|
91
|
+
)
|
|
92
|
+
pane_tty = result.stdout.strip()
|
|
93
|
+
|
|
94
|
+
if not pane_tty:
|
|
95
|
+
subprocess.run(["tmux", "kill-session", "-t", tmux_name], check=False)
|
|
96
|
+
raise GdbError("Failed to get tmux pane TTY")
|
|
97
|
+
|
|
98
|
+
# Tell GDB to create a new console UI on that PTY
|
|
99
|
+
await ctrl.send_command(f"new-ui console {pane_tty}")
|
|
100
|
+
|
|
101
|
+
self._consoles[session_id] = tmux_name
|
|
102
|
+
|
|
103
|
+
return tmux_name
|
|
104
|
+
|
|
105
|
+
def get_console(self, session_id: str) -> str | None:
|
|
106
|
+
"""Get the tmux session name for a console, if open."""
|
|
107
|
+
return self._consoles.get(session_id)
|
|
108
|
+
|
|
109
|
+
async def close_console(self, session_id: str) -> None:
|
|
110
|
+
"""Close the interactive console for a session."""
|
|
111
|
+
tmux_name = self._consoles.pop(session_id, None)
|
|
112
|
+
if tmux_name:
|
|
113
|
+
subprocess.run(
|
|
114
|
+
["tmux", "kill-session", "-t", tmux_name],
|
|
115
|
+
check=False,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
async def destroy_session(self, session_id: str) -> None:
|
|
119
|
+
"""End and clean up a specific session."""
|
|
120
|
+
await self.close_console(session_id)
|
|
121
|
+
controller = self._sessions.pop(session_id, None)
|
|
122
|
+
self._last_activity.pop(session_id, None)
|
|
123
|
+
if controller:
|
|
124
|
+
await controller.stop()
|
|
125
|
+
|
|
126
|
+
async def destroy_all(self) -> None:
|
|
127
|
+
"""End all sessions. Called during server shutdown."""
|
|
128
|
+
session_ids = list(self._sessions.keys())
|
|
129
|
+
for sid in session_ids:
|
|
130
|
+
await self.destroy_session(sid)
|
|
131
|
+
|
|
132
|
+
def list_sessions(self) -> list[str]:
|
|
133
|
+
"""Return list of active session IDs."""
|
|
134
|
+
return list(self._sessions.keys())
|
|
135
|
+
|
|
136
|
+
async def _cleanup_stale(self) -> None:
|
|
137
|
+
"""Remove sessions that have been inactive too long."""
|
|
138
|
+
now = time.time()
|
|
139
|
+
stale = [
|
|
140
|
+
sid
|
|
141
|
+
for sid, last in self._last_activity.items()
|
|
142
|
+
if now - last > INACTIVITY_TIMEOUT
|
|
143
|
+
]
|
|
144
|
+
for sid in stale:
|
|
145
|
+
logger.info("Cleaning up stale session: %s", sid)
|
|
146
|
+
await self.destroy_session(sid)
|
|
File without changes
|