fix: keep lldb dap responsive while running

This commit is contained in:
aimidi
2026-04-27 19:31:32 +08:00
parent 26bbe3e342
commit 77321f997a
8 changed files with 338 additions and 62 deletions
@@ -117,6 +117,22 @@ Variables support expandable child references for structs/classes/arrays, and
`setVariable` can update stopped-frame locals or child values when LLDB allows
the assignment.
For long-running GUI targets, DAP `continue` responds before the blocking LLDB
`SBProcess.Continue()` call completes, then waits on a background thread for the
next stop. DAP `pause` uses `SBProcess.SendAsyncInterrupt()` so the adapter stays
responsive while the debuggee is running. If `setBreakpoints` or
`setFunctionBreakpoints` arrives during an active continue, the adapter first
requests an async interrupt, waits for the continue thread to observe a stopped
state, and only then mutates LLDB breakpoints. If the process does not stop in
time, the request fails clearly instead of hanging the DAP loop.
`launch` and `attach` accept the non-standard boolean argument
`autoContinueInternalBreakpoints`. When enabled, the adapter auto-continues
known internal JIT/startup breakpoint stops such as NVIDIA
`__jit_debug_register_code` / `jit-debug-register` and Windows
`Exception 0x80000003` at ``ntdll.dll`DbgBreakPoint``, while writing a DAP
`output` event explaining the auto-continue.
The persistent session daemon now speaks a localhost JSON socket protocol and
stores its session token in an owner-scoped state file. `memory find` scans in
64 KiB chunks and caps each request at 1 MiB.
@@ -199,6 +199,13 @@ class LLDBSession:
raise RuntimeError(f"Interrupt failed: {error}")
return self._process_info()
def interrupt_async(self) -> Dict[str, Any]:
self._require_process()
error = self.process.SendAsyncInterrupt()
if error is not None and not error.Success():
raise RuntimeError(f"Async interrupt failed: {error}")
return {"status": "interrupt_requested"}
def backtrace(self, limit: int = 50) -> Dict[str, Any]:
thread = self._current_thread()
frames = []
+172 -58
View File
@@ -8,6 +8,7 @@ import json
import os
import shlex
import sys
import threading
from pathlib import Path
from typing import Any, BinaryIO, Callable
@@ -80,6 +81,13 @@ class LLDBDebugAdapter:
self._variable_refs: dict[int, dict[str, Any]] = {}
self._next_ref = 1
self._log_file = Path(log_file).expanduser() if log_file else None
self._protocol_lock = threading.Lock()
self._lldb_api_lock = threading.RLock()
self._continue_state = threading.Condition()
self._continue_active = False
self._auto_continue_internal_breakpoints = False
self._pause_requested = False
self._mutation_stop_timeout = 10.0
def run(self, instream: BinaryIO | None = None, outstream: BinaryIO | None = None) -> int:
instream = instream or sys.stdin.buffer
@@ -157,6 +165,7 @@ class LLDBDebugAdapter:
"stop_at_entry": bool(args.get("stopOnEntry", False)),
"suppress_stdio": True,
}
self._auto_continue_internal_breakpoints = bool(args.get("autoContinueInternalBreakpoints", False))
self._pending_attach = None
return {}, None
@@ -174,6 +183,7 @@ class LLDBDebugAdapter:
"name": str(name) if name else None,
"wait_for": bool(args.get("waitFor", False)),
}
self._auto_continue_internal_breakpoints = bool(args.get("autoContinueInternalBreakpoints", False))
self._pending_launch = None
return {}, None
@@ -220,51 +230,55 @@ class LLDBDebugAdapter:
if not path:
raise RuntimeError("setBreakpoints requires source.path")
self._ensure_stopped_for_target_mutation("setBreakpoints")
session = self._ensure_session()
source_key = str(Path(path))
for bp_id in self._source_breakpoints.get(source_key, []):
try:
session.breakpoint_delete(bp_id)
except Exception:
pass
with self._lldb_api_lock:
for bp_id in self._source_breakpoints.get(source_key, []):
try:
session.breakpoint_delete(bp_id)
except Exception:
pass
dap_breakpoints = []
created_ids = []
for item in args.get("breakpoints") or []:
line = int(item.get("line"))
payload = session.breakpoint_set(
file=source_key,
line=line,
condition=item.get("condition"),
allow_pending=True,
)
created_ids.append(payload["id"])
dap_breakpoints.append(self._to_dap_breakpoint(payload, source_key, requested_line=line))
dap_breakpoints = []
created_ids = []
for item in args.get("breakpoints") or []:
line = int(item.get("line"))
payload = session.breakpoint_set(
file=source_key,
line=line,
condition=item.get("condition"),
allow_pending=True,
)
created_ids.append(payload["id"])
dap_breakpoints.append(self._to_dap_breakpoint(payload, source_key, requested_line=line))
self._source_breakpoints[source_key] = created_ids
return {"breakpoints": dap_breakpoints}, None
def _handle_setFunctionBreakpoints(self, args: dict[str, Any]):
self._ensure_stopped_for_target_mutation("setFunctionBreakpoints")
session = self._ensure_session()
for bp_id in self._function_breakpoints:
try:
session.breakpoint_delete(bp_id)
except Exception:
pass
with self._lldb_api_lock:
for bp_id in self._function_breakpoints:
try:
session.breakpoint_delete(bp_id)
except Exception:
pass
self._function_breakpoints = []
result = []
for item in args.get("breakpoints") or []:
name = item.get("name")
if not name:
continue
payload = session.breakpoint_set(
function=str(name),
condition=item.get("condition"),
allow_pending=True,
)
self._function_breakpoints.append(payload["id"])
result.append(self._to_dap_breakpoint(payload))
self._function_breakpoints = []
result = []
for item in args.get("breakpoints") or []:
name = item.get("name")
if not name:
continue
payload = session.breakpoint_set(
function=str(name),
condition=item.get("condition"),
allow_pending=True,
)
self._function_breakpoints.append(payload["id"])
result.append(self._to_dap_breakpoint(payload))
return {"breakpoints": result}, None
def _handle_threads(self, _args: dict[str, Any]):
@@ -357,16 +371,20 @@ class LLDBDebugAdapter:
def post_send():
self._reset_refs_for_resume()
self._send_continued_event()
self._ensure_session().continue_exec()
self._emit_breakpoint_updates()
self._emit_execution_event(default_reason="breakpoint")
self._start_continue_thread(
name="cli-anything-lldb-dap-continue",
default_reason="breakpoint",
)
return {"allThreadsContinued": True}, post_send
def _handle_pause(self, _args: dict[str, Any]):
def post_send():
self._ensure_session().interrupt()
self._emit_execution_event(default_reason="pause")
self._pause_requested = True
self._request_async_interrupt()
if not self._is_continue_active():
with self._lldb_api_lock:
self._emit_execution_event(default_reason="pause")
return {}, post_send
@@ -448,11 +466,72 @@ class LLDBDebugAdapter:
def post_send():
self._reset_refs_for_resume()
self._send_continued_event()
step_fn()
self._emit_execution_event(default_reason="step")
with self._lldb_api_lock:
step_fn()
self._emit_execution_event(default_reason="step")
return post_send
def _start_continue_thread(self, *, name: str, default_reason: str):
with self._continue_state:
if self._continue_active:
self._log("continue requested while a continue operation is already active")
return
self._continue_active = True
threading.Thread(
target=self._continue_until_stop,
kwargs={"default_reason": default_reason},
name=name,
daemon=True,
).start()
def _continue_until_stop(self, *, default_reason: str):
try:
self._ensure_session().continue_exec()
except Exception as exc:
self._log(f"continue failed: {exc}")
self._send_event("output", {"category": "stderr", "output": f"continue failed: {exc}\n"})
self._send_event("terminated")
return
finally:
self._mark_continue_inactive()
with self._lldb_api_lock:
self._emit_breakpoint_updates()
self._emit_execution_event(default_reason=default_reason)
def _mark_continue_inactive(self):
with self._continue_state:
self._continue_active = False
self._continue_state.notify_all()
def _is_continue_active(self) -> bool:
with self._continue_state:
return self._continue_active
def _ensure_stopped_for_target_mutation(self, operation: str):
if not self._is_continue_active():
return
self._log(f"{operation}: interrupting running debuggee before target mutation")
self._request_async_interrupt()
with self._continue_state:
stopped = self._continue_state.wait_for(
lambda: not self._continue_active,
timeout=self._mutation_stop_timeout,
)
if not stopped:
raise RuntimeError(
f"Timed out waiting for debuggee to stop before {operation}. "
"Send a pause request and retry after the stopped event."
)
def _request_async_interrupt(self):
session = self._ensure_session()
interrupt = getattr(session, "interrupt_async", None)
if interrupt is not None:
return interrupt()
return session.interrupt()
def _emit_execution_event(self, default_reason: str | None = None):
info = self._ensure_session().process_info()
state = info.get("state")
@@ -471,6 +550,9 @@ class LLDBDebugAdapter:
reason = "entry" if default_reason == "entry" else (stop.get("reason") or default_reason or "pause")
if reason in {"signal", "crashed"}:
reason = "exception"
if self._pause_requested:
self._pause_requested = False
reason = "pause"
body = {
"reason": reason,
"threadId": info.get("selected_thread_id"),
@@ -482,8 +564,38 @@ class LLDBDebugAdapter:
if stop.get("description"):
body["description"] = stop["description"]
body["text"] = stop["description"]
if self._should_auto_continue_internal_stop(body):
self._send_event(
"output",
{
"category": "console",
"output": f"auto-continued internal breakpoint: {self._summarize_stop(body)}\n",
},
)
self._send_continued_event(info.get("selected_thread_id"))
self._start_continue_thread(
name="cli-anything-lldb-dap-auto-continue",
default_reason=default_reason or "breakpoint",
)
return
self._send_event("stopped", body)
def _should_auto_continue_internal_stop(self, body: dict[str, Any]) -> bool:
if not self._auto_continue_internal_breakpoints:
return False
if body.get("reason") == "pause":
return False
text = str(body.get("description") or body.get("text") or "")
if "jit-debug-register" in text or "__jit_debug_register_code" in text:
return True
if "Exception 0x80000003" in text and "ntdll.dll`DbgBreakPoint" in text:
return True
return False
def _summarize_stop(self, body: dict[str, Any]) -> str:
text = str(body.get("description") or body.get("text") or body.get("reason") or "unknown")
return text.splitlines()[0] if text else "unknown"
def _send_continued_event(self, thread_id: int | None = None):
body: dict[str, Any] = {"allThreadsContinued": True}
if thread_id is not None:
@@ -616,24 +728,26 @@ class LLDBDebugAdapter:
success: bool = True,
message: str | None = None,
):
payload: dict[str, Any] = {
"seq": self._next_seq(),
"type": "response",
"request_seq": request_seq,
"success": success,
"command": command,
}
if body is not None:
payload["body"] = body
if message:
payload["message"] = message
self._write(payload)
with self._protocol_lock:
payload: dict[str, Any] = {
"seq": self._next_seq(),
"type": "response",
"request_seq": request_seq,
"success": success,
"command": command,
}
if body is not None:
payload["body"] = body
if message:
payload["message"] = message
self._write(payload)
def _send_event(self, event: str, body: dict[str, Any] | None = None):
payload: dict[str, Any] = {"seq": self._next_seq(), "type": "event", "event": event}
if body is not None:
payload["body"] = body
self._write(payload)
with self._protocol_lock:
payload: dict[str, Any] = {"seq": self._next_seq(), "type": "event", "event": event}
if body is not None:
payload["body"] = body
self._write(payload)
def _next_seq(self) -> int:
seq = self._seq
@@ -73,6 +73,19 @@ DAP variables can expose child references for structs/classes/arrays. Use
`setVariable` only while stopped; LLDB may reject writes to optimized-out or
read-only values.
For long-running GUI debuggees, DAP `continue` is non-blocking from the client's
point of view: the adapter sends the response and `continued` event first, then
waits for LLDB on a background thread. DAP `pause` uses LLDB async interrupt.
If an agent needs to change breakpoints while the debuggee is running, the
adapter interrupts first and waits for a stopped state before mutating LLDB
breakpoints; if the target does not stop in time, retry after an explicit
`pause`/`stopped` cycle.
For GUI apps that stop on debugger-internal startup or shader-JIT breakpoints,
`launch` and `attach` accept the non-standard boolean argument
`autoContinueInternalBreakpoints`. Enable it only when those internal stops are
noise for the task; the adapter emits an `output` event before auto-continuing.
## Command Groups
### target
@@ -133,6 +146,8 @@ cli-anything-lldb --json core load --path /path/to/core
- Use REPL when a human-like interactive shell is more convenient, not because persistence requires it.
- Unresolved CLI breakpoints fail by default; pass `--allow-pending` only when a future module/symbol load is expected.
- DAP unresolved breakpoints use protocol semantics: `verified: false` until resolved.
- DAP `continue` is non-blocking for long-running GUI processes, and DAP `pause` uses async interrupt.
- DAP breakpoint changes during an active continue first interrupt and wait for a stopped state before mutating LLDB.
- `memory find` uses a chunked scan capped at 1 MiB per call.
- Call `target create` before process or core commands.
- Expect structured errors: `{"error": "...", "type": "..."}`
@@ -42,7 +42,10 @@
- Validate frame/variable references are cleared on resume
- Validate EOF cleanup destroys the LLDB session
- Validate running-state execution events emit `continued`, not a false `stopped`
- Validate DAP `pause` calls the interrupt path and emits a pause stop event
- Validate DAP `pause` calls the async interrupt path and emits a pause stop event
- Validate DAP breakpoint mutation during an active continue requests async interrupt and waits for stopped state
- Validate DAP breakpoint mutation reports a clear timeout when the target does not stop
- Validate DAP auto-continues known internal JIT/startup breakpoint stops when explicitly enabled
- Validate DAP transcript response/event ordering for initialize, launch, breakpoint setup, and configuration completion
- Validate DAP `modules` and `exceptionInfo` response shapes
- Validate DAP `readMemory` base64 encoding and expandable variable references
@@ -157,9 +160,9 @@ python -m pytest cli_anything/lldb/tests -q
### Result summary
- `test_core.py`: 40 passed
- `test_core.py`: 44 passed
- `test_full_e2e.py`: 7 passed, 2 warnings from LLDB SWIG bindings
- combined default run: 47 passed, 2 warnings from LLDB SWIG bindings
- combined default run: 51 passed, 2 warnings from LLDB SWIG bindings
- skip situation: 0 skipped in the current local run; older runs could skip the optional core-load negative-path scenario when `LLDB_TEST_CORE` was unset, but the fixture now creates a local placeholder core path for that negative-path test
### Notes
@@ -179,3 +182,4 @@ python -m pytest cli_anything/lldb/tests -q
- Added a real DAP source-line breakpoint E2E scenario
- Added DAP `loadedSources` and `readMemory` coverage while keeping the harness at version 1.0.0
- Added DAP variable child expansion, `setVariable`, `modules`, `exceptionInfo`, and transcript ordering coverage while keeping the harness at version 1.0.0
- Added non-blocking DAP continue, async pause, guarded running-state breakpoint mutation, and internal breakpoint auto-continue coverage
@@ -12,6 +12,7 @@ import io
import subprocess
import sys
import stat
import threading
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -199,7 +200,96 @@ class TestDAPProtocol:
assert response["success"] is True
assert event["event"] == "stopped"
assert event["body"]["reason"] == "pause"
fake_session.interrupt.assert_called_once()
fake_session.interrupt_async.assert_called_once()
def test_set_breakpoints_interrupts_active_continue_before_mutation(self):
from cli_anything.lldb.dap import LLDBDebugAdapter
fake_session = MagicMock()
fake_session.breakpoint_set.return_value = {
"id": 7,
"resolved": True,
"locations": 1,
"location_details": [{"file": "C:/tmp/main.c", "line": 12}],
}
adapter = LLDBDebugAdapter(session_factory=lambda: fake_session)
adapter._ensure_session()
adapter._mutation_stop_timeout = 1.0
with adapter._continue_state:
adapter._continue_active = True
release = threading.Timer(0.01, adapter._mark_continue_inactive)
release.start()
try:
body, post_send = adapter._handle_setBreakpoints(
{
"source": {"path": "C:/tmp/main.c"},
"breakpoints": [{"line": 12}],
}
)
finally:
release.join()
assert post_send is None
assert body["breakpoints"][0]["verified"] is True
fake_session.interrupt_async.assert_called_once()
fake_session.breakpoint_set.assert_called_once()
def test_set_breakpoints_reports_timeout_if_running_target_will_not_stop(self):
from cli_anything.lldb.dap import LLDBDebugAdapter
fake_session = MagicMock()
adapter = LLDBDebugAdapter(session_factory=lambda: fake_session)
adapter._ensure_session()
adapter._mutation_stop_timeout = 0.01
with adapter._continue_state:
adapter._continue_active = True
try:
with pytest.raises(RuntimeError, match="Timed out waiting for debuggee to stop"):
adapter._handle_setBreakpoints(
{
"source": {"path": "C:/tmp/main.c"},
"breakpoints": [{"line": 12}],
}
)
finally:
adapter._mark_continue_inactive()
fake_session.interrupt_async.assert_called_once()
fake_session.breakpoint_set.assert_not_called()
def test_auto_continue_internal_breakpoint_emits_output_and_resumes(self):
from cli_anything.lldb.dap import LLDBDebugAdapter, read_message
fake_session = MagicMock()
fake_session.process_info.return_value = {
"state": "stopped",
"selected_thread_id": 99,
"stop": {
"reason": "breakpoint",
"description": "frame #0: nvgpucomp64.dll`__jit_debug_register_code",
"hit_breakpoint_ids": [],
},
"exit_status": 0,
}
out = io.BytesIO()
adapter = LLDBDebugAdapter(session_factory=lambda: fake_session)
adapter._out = out
adapter._auto_continue_internal_breakpoints = True
adapter._start_continue_thread = MagicMock()
adapter._emit_execution_event(default_reason="breakpoint")
out.seek(0)
output_event = read_message(out)
continued_event = read_message(out)
stopped_event = read_message(out)
assert output_event["event"] == "output"
assert "auto-continued internal breakpoint" in output_event["body"]["output"]
assert continued_event["event"] == "continued"
assert stopped_event is None
adapter._start_continue_thread.assert_called_once()
def test_stack_trace_reports_total_frames(self):
from cli_anything.lldb.dap import LLDBDebugAdapter, read_message
@@ -573,6 +663,20 @@ class TestSessionLifecycle:
process.Stop.assert_called_once()
assert payload["pid"] == 123
def test_interrupt_async_requests_async_interrupt(self):
from cli_anything.lldb.core.session import LLDBSession
session = self._make_session()
process = MagicMock()
process.IsValid.return_value = True
process.SendAsyncInterrupt.return_value = MagicMock(Success=lambda: True)
session.process = process
payload = LLDBSession.interrupt_async(session)
process.SendAsyncInterrupt.assert_called_once()
assert payload == {"status": "interrupt_requested"}
def test_session_status_reports_target_and_process(self):
from cli_anything.lldb.core.session import LLDBSession
@@ -38,6 +38,7 @@ _ALLOWED_SESSION_METHODS = {
"step_out",
"continue_exec",
"interrupt",
"interrupt_async",
"backtrace",
"locals",
"local_values",
+15
View File
@@ -73,6 +73,19 @@ DAP variables can expose child references for structs/classes/arrays. Use
`setVariable` only while stopped; LLDB may reject writes to optimized-out or
read-only values.
For long-running GUI debuggees, DAP `continue` is non-blocking from the client's
point of view: the adapter sends the response and `continued` event first, then
waits for LLDB on a background thread. DAP `pause` uses LLDB async interrupt.
If an agent needs to change breakpoints while the debuggee is running, the
adapter interrupts first and waits for a stopped state before mutating LLDB
breakpoints; if the target does not stop in time, retry after an explicit
`pause`/`stopped` cycle.
For GUI apps that stop on debugger-internal startup or shader-JIT breakpoints,
`launch` and `attach` accept the non-standard boolean argument
`autoContinueInternalBreakpoints`. Enable it only when those internal stops are
noise for the task; the adapter emits an `output` event before auto-continuing.
## Command Groups
### target
@@ -133,6 +146,8 @@ cli-anything-lldb --json core load --path /path/to/core
- Use REPL when a human-like interactive shell is more convenient, not because persistence requires it.
- Unresolved CLI breakpoints fail by default; pass `--allow-pending` only when a future module/symbol load is expected.
- DAP unresolved breakpoints use protocol semantics: `verified: false` until resolved.
- DAP `continue` is non-blocking for long-running GUI processes, and DAP `pause` uses async interrupt.
- DAP breakpoint changes during an active continue first interrupt and wait for a stopped state before mutating LLDB.
- `memory find` uses a chunked scan capped at 1 MiB per call.
- Call `target create` before process or core commands.
- Expect structured errors: `{"error": "...", "type": "..."}`