diff --git a/lldb/agent-harness/cli_anything/lldb/README.md b/lldb/agent-harness/cli_anything/lldb/README.md index 5a1a82cee..2ec0c393a 100644 --- a/lldb/agent-harness/cli_anything/lldb/README.md +++ b/lldb/agent-harness/cli_anything/lldb/README.md @@ -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. diff --git a/lldb/agent-harness/cli_anything/lldb/core/session.py b/lldb/agent-harness/cli_anything/lldb/core/session.py index 70acc2f2b..3ea6eccb0 100644 --- a/lldb/agent-harness/cli_anything/lldb/core/session.py +++ b/lldb/agent-harness/cli_anything/lldb/core/session.py @@ -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 = [] diff --git a/lldb/agent-harness/cli_anything/lldb/dap.py b/lldb/agent-harness/cli_anything/lldb/dap.py index 79daceb29..94468ac16 100644 --- a/lldb/agent-harness/cli_anything/lldb/dap.py +++ b/lldb/agent-harness/cli_anything/lldb/dap.py @@ -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 diff --git a/lldb/agent-harness/cli_anything/lldb/skills/SKILL.md b/lldb/agent-harness/cli_anything/lldb/skills/SKILL.md index c1902c6c3..65e80a565 100644 --- a/lldb/agent-harness/cli_anything/lldb/skills/SKILL.md +++ b/lldb/agent-harness/cli_anything/lldb/skills/SKILL.md @@ -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": "..."}` diff --git a/lldb/agent-harness/cli_anything/lldb/tests/TEST.md b/lldb/agent-harness/cli_anything/lldb/tests/TEST.md index cec211ec5..e2ec7c697 100644 --- a/lldb/agent-harness/cli_anything/lldb/tests/TEST.md +++ b/lldb/agent-harness/cli_anything/lldb/tests/TEST.md @@ -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 diff --git a/lldb/agent-harness/cli_anything/lldb/tests/test_core.py b/lldb/agent-harness/cli_anything/lldb/tests/test_core.py index 2bd7d6e92..01558952f 100644 --- a/lldb/agent-harness/cli_anything/lldb/tests/test_core.py +++ b/lldb/agent-harness/cli_anything/lldb/tests/test_core.py @@ -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 diff --git a/lldb/agent-harness/cli_anything/lldb/utils/session_server.py b/lldb/agent-harness/cli_anything/lldb/utils/session_server.py index 3745a0ed8..6233e6f85 100644 --- a/lldb/agent-harness/cli_anything/lldb/utils/session_server.py +++ b/lldb/agent-harness/cli_anything/lldb/utils/session_server.py @@ -38,6 +38,7 @@ _ALLOWED_SESSION_METHODS = { "step_out", "continue_exec", "interrupt", + "interrupt_async", "backtrace", "locals", "local_values", diff --git a/skills/cli-anything-lldb/SKILL.md b/skills/cli-anything-lldb/SKILL.md index e0604688d..8c6dc32cb 100644 --- a/skills/cli-anything-lldb/SKILL.md +++ b/skills/cli-anything-lldb/SKILL.md @@ -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": "..."}`