diff --git a/.codex/config.toml.example b/.codex/config.toml.example new file mode 100644 index 0000000000..6ae31ef09b --- /dev/null +++ b/.codex/config.toml.example @@ -0,0 +1,31 @@ +# Project-local Codex MCP setup. +# Copy this file to .codex/config.toml from the repository root: +# cp .codex/config.toml.example .codex/config.toml +# The MCP commands and working directory are relative to this checkout. + +[mcp_servers.daslang] +command = "python3" +args = ["utils/mcp/mcp_supervisor.py", "--repo-root", "."] +cwd = "." +enabled = true +required = true +startup_timeout_sec = 20 +tool_timeout_sec = 120 + +[mcp_servers.daslang-lsp] +command = "python3" +args = ["utils/lsp/mcp_bridge.py", "--repo-root", "."] +cwd = "." +enabled = true +required = true +startup_timeout_sec = 20 +tool_timeout_sec = 120 + +[mcp_servers.daslang-dap] +command = "python3" +args = ["utils/dap/mcp_bridge.py", "--repo-root", "."] +cwd = "." +enabled = true +required = true +startup_timeout_sec = 20 +tool_timeout_sec = 120 diff --git a/.github/workflows/extended_checks.yml b/.github/workflows/extended_checks.yml index 0c79b171ee..cdc2347e23 100644 --- a/.github/workflows/extended_checks.yml +++ b/.github/workflows/extended_checks.yml @@ -473,8 +473,8 @@ jobs: - name: "Test ser/deser" if: matrix.role != 'core' run: | - $BIN/daslang _dasroot_/dastest/dastest.das -- --color --failures-only --test ./tests --ser serialized.bin - $BIN/daslang _dasroot_/dastest/dastest.das -- --color --failures-only --test ./tests --deser serialized.bin + $BIN/daslang _dasroot_/dastest/dastest.das -- --color --failures-only --test ./tests --ser serialized.bin --timeout 1200 + $BIN/daslang _dasroot_/dastest/dastest.das -- --color --failures-only --test ./tests --deser serialized.bin --timeout 1200 - name: "Compile tests/ with --ast-verify-batch" # NIGHTLY ONLY (cron or manual dispatch), because of what it costs: the cost is @@ -530,6 +530,13 @@ jobs: $BIN/daslang _dasroot_/dastest/dastest.das -- --color --failures-only --test ./utils/mcp/test_tools.das $BIN/daslang _dasroot_/dastest/dastest.das -- --color --failures-only --test ./utils/mcp/test_crosstree_guard.das + - name: "Test DAP MCP bridge (python)" + if: matrix.target == 'linux' + run: | + set -eux + PYTHONDONTWRITEBYTECODE=1 python3 utils/dap/test_mcp_bridge.py + DAS_TEST_STEPPING=1 PYTHONDONTWRITEBYTECODE=1 python3 utils/dap/test_mcp_bridge.py + - name: "Test LSP cross-tree guard (python)" if: matrix.role != 'modules' run: | @@ -760,4 +767,3 @@ jobs: run: | $BIN/daslang dastest/dastest.das -- --cov-path coverage.lcov --color --test ./tests/language --timeout 1800 $BIN/dascov.exe -- coverage.lcov --exclude tests/language - diff --git a/.gitignore b/.gitignore index 45a03e3e42..6527cc50d6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,8 @@ benchdata.db # build artifacts web/build_pt/ -.codex/ +.codex/* +!.codex/config.toml.example .agents/ build/ build-ninja/ diff --git a/CMakeLists.txt b/CMakeLists.txt index a96a52a7c9..3b7d7218c9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2031,6 +2031,12 @@ install(DIRECTORY ${PROJECT_SOURCE_DIR}/skills/daslang/ ) install(FILES ${PROJECT_SOURCE_DIR}/.claude/agents/dragon.md DESTINATION .claude/agents) +install(FILES + ${PROJECT_SOURCE_DIR}/utils/dap/mcp_bridge.py + ${PROJECT_SOURCE_DIR}/utils/dap/README.md + DESTINATION utils/dap +) + # Install dascov (code coverage) install(FILES ${PROJECT_SOURCE_DIR}/utils/dascov/main.das DESTINATION utils/dascov) install(FILES ${PROJECT_SOURCE_DIR}/utils/dascov/README.md DESTINATION utils/dascov) diff --git a/daslib/ARCHITECTURE.md b/daslib/ARCHITECTURE.md index d83fff30cc..46eef05630 100644 --- a/daslib/ARCHITECTURE.md +++ b/daslib/ARCHITECTURE.md @@ -127,6 +127,32 @@ Three companions carry a concern each; a section number is unique across all fou - **`g_installed_agents` is the GC root for every installed agent** - the C++ adapter holds a raw classPtr the das GC cannot see. +### 24.1 Debugger readiness query {#debugger-readiness-query} + +- **Non-allocation debug-agent callbacks and cross-context invocations share the debug-agent + context mutex** - callbacks can run on debuggee or debugger worker threads, while pinvokes can + arrive on the main thread. Allocation instrumentation callbacks remain direct because they run + inside allocator operations, where re-entering the same allocation path cannot be serialized. +- **The debugger-ready query uses an explicit pinvoke** - generated `apply_in_context` + verification would acquire the agent registry while holding the context mutex, opposite to the + debugger tick's registry-to-context order. + +### 24.2 Debugger worker startup {#debugger-worker-startup} + +- **The statement-debugger worker executes its lambda only after the source context reaches + `onSimulateContext`** - its cloned context shares the finalized SimNode graph, so executing the + lambda while `Program::simulate` is still hashing that graph is a data race. With + `--das-wait-debugger`, the main thread pumps DAP requests until client configuration completes; + without that flag, source-context simulation proceeds while the worker waits for it to finish. +- **Only one statement-debugger worker may be active** - a second launch fails immediately rather + than blocking the source thread that must deliver the first worker's readiness notification. + Destroying that source context cancels the pending worker so its raw identity cannot outlive it. + +### 24.3 Statement breakpoint lookup {#statement-breakpoint-lookup} + +- **Statement stepping releases a breakpoint table borrow before entering the stopped command + pump** - requests handled while stopped may replace that table entry. + ## 25. typemacro_boost {#typemacro-boost} - **The parser does not run annotation `apply` for macro-added functions** - the add/erase diff --git a/daslib/debug.das b/daslib/debug.das index 2dbc969257..60c8132aa9 100644 --- a/daslib/debug.das +++ b/daslib/debug.das @@ -832,6 +832,13 @@ struct private DABreakpoint { typedef DABreakpoints = table> +def private delete_breakpoint_arrays(var breakpoints : DABreakpoints) { + for (it in values(breakpoints)) { + delete it + } +} + + struct private DAContext { //! Internal context state for the DAP debugger. id : uint64 @@ -1151,6 +1158,19 @@ class private DAgent : DapiDebugAgent { }) } + [arch(at="ARCHITECTURE.md#statement-breakpoint-lookup")] + def findBreakpointId(file : string; line : uint) : uint64 { + for (fileBr, brs in keys(breakpoints), values(breakpoints)) { + if (!compare_path(fileBr, file)) continue + for (br in brs) { + if (br.line == line) return br.id + } + break + } + return -1ul + } + + [arch(at="ARCHITECTURE.md#statement-breakpoint-lookup")] def override onSingleStep(var ctx : Context; at : LineInfo) : void { //! Handles single-step events, processing pause, step-in, step-over requests, and breakpoint checks. if (at.fileInfo == null || server == null) return @@ -1203,30 +1223,12 @@ class private DAgent : DapiDebugAgent { ctx |> set_single_step(ctxData.stepInRequested || ctxData.stepRequestedDepth > 0) return } - var i = 0 - while (i < length(breakpoints)) { - var fileBr : string - for (j, it in range(i + 1), keys(breakpoints)) { - if (j == i) { - fileBr = it - break - } - } - i++ - if (!compare_path(fileBr, file)) continue - breakpoints |> get(file) $(brs) { - var brIdx = 0 - while (brIdx < length(brs)) { - let br & = unsafe((brs)[brIdx++]) - if (br.line != at.line) continue - sendStopped(ctx, ctxData, file, at, "breakpoint", "", br.id) - - ctxData.continueRequested = false - wait_for_resume(ctxData) - afterPause() - break - } - } + let breakpointHit = findBreakpointId(file, at.line) + if (breakpointHit != -1ul) { + sendStopped(ctx, ctxData, file, at, "breakpoint", "", breakpointHit) + ctxData.continueRequested = false + wait_for_resume(ctxData) + afterPause() } }) } @@ -1343,10 +1345,10 @@ class private DAgent : DapiDebugAgent { } } - def invalidateBreakpoints(var ctx : Context) : void { - //! Re-instruments breakpoints for a context after code changes, updating their verification state. + def invalidateBreakpoints(var ctx : Context; configuring : bool = false) : void { + //! Instruments configured breakpoints for a context and updates their verification state. if (!withInstruments - || server == null || !server.configurationDone + || server == null || (!server.configurationDone && !configuring) || ctx.category.debug_context || ctx.category.debugger_tick) return for (brs in values(breakpoints)) { @@ -1418,6 +1420,11 @@ class private DAgent : DapiDebugAgent { invalidateBreakpoints(ctx) } + [arch(at="ARCHITECTURE.md#debugger-worker-startup")] + def override onSimulateContext(var ctx : Context) : void { + debugger_thread_context_ready(ctx) + } + def override onDestroyContext(var ctx : Context) : void { //! Handles context destruction by unregistering it and updating breakpoint instrumentation. removeContext(ctx) @@ -1662,6 +1669,11 @@ class private DAServer : Server { def reqConfigurationDone(seq : double; command : string; data : JsonValue?) { //! Handles the DAP configurationDone request, marking client configuration as complete. + for (ctx in agent.contexts) { + if (ctx.ctx != null) { + agent->invalidateBreakpoints(*ctx.ctx, true) + } + } configurationDone = true sendSuccessResponse(seq, command, null) } @@ -1732,9 +1744,7 @@ class private DAServer : Server { ctx |> reset_debug_flags() ctx.continueRequested = true } - for (it in values(agent.breakpoints)) { - delete it - } + delete_breakpoint_arrays(agent.breakpoints) agent.breakpoints |> clear() disconnected = true } @@ -2217,12 +2227,33 @@ def private tick_debugger() { var private g```dAgent : DAgent? -[apply_in_context(name="~debug")] +[arch(at="ARCHITECTURE.md#debugger-readiness-query"), export, pinvoke] +def private query_debugger_ready(var ready : bool?) { + unsafe { + if (g```dAgent == null || g```dAgent.server == null) { + *ready = false + } else { + *ready = !g```dAgent.waitConnection || (g```dAgent.server.configurationDone && g```dAgent.server.threadsDone) + } + } +} + + +[arch(at="ARCHITECTURE.md#debugger-readiness-query")] +def private debugger_is_ready() : bool { + var ready = false + unsafe { + invoke_in_context(get_debug_agent_context("~debug"), @@query_debugger_ready, addr(ready)) + } + return ready +} + + +[arch(at="ARCHITECTURE.md#debugger-worker-startup")] def wait_for_debugger() : bool { - //! Blocks until the debug agent is connected and the client configuration is complete. - while (g```dAgent == null || g```dAgent.server == null - || (g```dAgent.waitConnection && (!g```dAgent.server.configurationDone || !g```dAgent.server.threadsDone))) { - sleep(10u) + //! Blocks until the debug agent reports readiness for the selected connection mode. + while (!debugger_is_ready()) { + tick_debugger() } return true } diff --git a/dastest/dastest.das b/dastest/dastest.das index a8cff7c2a0..35dde4674a 100644 --- a/dastest/dastest.das +++ b/dastest/dastest.das @@ -53,6 +53,7 @@ struct FailedFile { var private fileTimings : array var private failedFiles : array +var private currentTestFile : string var private timingOutliers : int = 0 var private maxFileTime : float = 0.0 var private jsonFilePath : string @@ -444,6 +445,10 @@ def deserialize_path(var ctx : SuiteCtx, _files : array, in_file : strin return } return if (program._options |> find_arg("rtti") ?as tBool ?? false) + let program_module = program |> get_this_module() + let program_file = program_module != null ? string(program_module.fileName) : "" + log::info("deser {i + 1:d}/{count:d}: {program_file}") + currentTestFile = program_file simulate(program) $(sok, context, serrors) { if (!sok) { log::error("Failed to simulate program {i}\n{serrors}") @@ -460,6 +465,7 @@ def deserialize_path(var ctx : SuiteCtx, _files : array, in_file : strin } } delete_ast_serializer(ser) + log::info("Deserialized run finished: {count:d} programs, {res.total:d} tests, {res.errors:d} error(s)") } return res } @@ -590,6 +596,7 @@ def main() : int { // nolint:STYLE037,STYLE038 - CLI dispatch, one arm per mode for (file in files) { let uri = ctx.uriPaths ? file_name_to_uri(file) : file let fileTime = ref_time_ticks() + currentTestFile = file let status = suite::test_file(file, ctx) let fileDt = get_time_nsec(fileTime) / 1000 if (status.errors + status.failed == 0) { @@ -769,7 +776,7 @@ def main() : int { // nolint:STYLE037,STYLE038 - CLI dispatch, one arm per mode def timeout_tests(var res : SuiteResult; start_time : int64; timeout_sec : float) { res.errors++ res.total++ - log::error("Test timed out after {timeout_sec}s") + log::error("Test timed out after {timeout_sec}s while running {currentTestFile}") // timeout fires from a worker thread; fio::exit is the cross-thread // cancellation mechanism (bypasses shutdown dump — acceptable for this path). unsafe { diff --git a/doc/reflections/das2rst.das b/doc/reflections/das2rst.das index bb078c361f..ff31279809 100644 --- a/doc/reflections/das2rst.das +++ b/doc/reflections/das2rst.das @@ -416,7 +416,7 @@ def document_module_debugapi(_root : string) { group_by_regex("Agent lifecycle", mod, %regex~(fork_debug_agent_context|install_debug_agent|install_debug_agent_thread_local|install_new_debug_agent|install_new_thread_local_debug_agent|has_debug_agent_context|get_debug_agent_context|delete_debug_agent_context|is_in_debug_agent_creation|lock_debug_agent)$%%), group_by_regex("Cross-context invocation", mod, %regex~(invoke_in_context|invoke_debug_agent_method|invoke_debug_agent_function)$%%), group_by_regex("Agent construction", mod, %regex~(make_debug_agent|make_data_walker|make_stack_walker)$%%), - group_by_regex("Agent tick and state collection", mod, %regex~(tick_debug_agent|collect_debug_agent_state|on_breakpoints_reset|report_context_state|debug_agent_command|debugger_stop_requested)$%%), + group_by_regex("Agent tick and state collection", mod, %regex~(tick_debug_agent|collect_debug_agent_state|on_breakpoints_reset|report_context_state|debug_agent_command|debugger_stop_requested|debugger_thread_context_ready)$%%), group_by_regex("Instrumentation", mod, %regex~(instrument_node|instrument_function|instrument_all_functions|instrument_all_functions_thread_local|instrument_context_allocations|clear_instruments|set_single_step)$%%), group_by_regex("Data and stack walking", mod, %regex~(walk_data|walk_stack|stackwalk|get_stackwalk|stack_depth)$%%), group_by_regex("Context inspection", mod, %regex~(get_context_global_variable|has_function|get_heap_stats)$%%), diff --git a/doc/source/reference/tutorials/45_debug_agents.rst b/doc/source/reference/tutorials/45_debug_agents.rst index 0ad0899444..4522d1585c 100644 --- a/doc/source/reference/tutorials/45_debug_agents.rst +++ b/doc/source/reference/tutorials/45_debug_agents.rst @@ -247,6 +247,43 @@ debuggers show custom watch variables and application diagnostics: // output: // Diagnostics: collection_count = 1 +When the built-in DAP debugger stops, it calls +``collect_debug_agent_state`` for the paused context automatically. Each +``category`` reported by an agent becomes an additional DAP scope in the +top stack frame. Each ``name`` becomes a variable in that scope, and +structures, arrays, and other compound values can be expanded with the +normal DAP ``variables`` request. A DAP client should therefore enumerate +*all* scopes returned for a frame instead of assuming that only ``Locals``, +``Arguments``, and ``Globals`` exist. + +These state-reporting modules are sometimes called **debugger macros**. A +program opts in by requiring the module; its init code installs a +``DapiDebugAgent``, and categories reported by the module appear as extra +scopes when execution pauses. +The DAP transport needs no module-specific support. + +Two modules provide ready-made examples: + +================================================ ============================================================ +Module Scopes +================================================ ============================================================ +``require opengl/opengl_state`` ``OPENGL``, ``OPENGL program``, ``OPENGL arrays``, and others +``require daslib/decs_state`` ``DECS archetype`` and ``DECS requests`` +================================================ ============================================================ + +``opengl/opengl_boost`` already requires ``opengl_state``. The OpenGL +agent reports state only for a paused context marked as an OpenGL context, +where querying the current GL state is valid. ``daslib/decs_boost`` already +requires ``decs_state`` and therefore enables DECS inspection without an +additional ``require``. Code that uses only the lower-level ``opengl`` or +``decs`` module can require the corresponding state module explicitly. + +Keep ``onCollect`` bounded and minimize changes to application state. It runs +synchronously while the debuggee is paused, so expensive collection directly +increases debugger latency. A collector may consume diagnostic state when +the underlying API requires it; for example, reading OpenGL errors clears the +error flag, while reading debug messages removes the returned messages. + Agent existence checks ======================= diff --git a/doc/source/reference/utils.rst b/doc/source/reference/utils.rst index c887332020..11d4d40045 100644 --- a/doc/source/reference/utils.rst +++ b/doc/source/reference/utils.rst @@ -24,6 +24,7 @@ built-in leak-detection mechanism. utils/benchctl.rst utils/aot.rst utils/mcp.rst + utils/dap.rst utils/lsp.rst utils/detect_dupe.rst utils/find_dupe.rst diff --git a/doc/source/reference/utils/dap.rst b/doc/source/reference/utils/dap.rst new file mode 100644 index 0000000000..96c0eb9304 --- /dev/null +++ b/doc/source/reference/utils/dap.rst @@ -0,0 +1,192 @@ +.. _utils_dap: + +.. index:: + single: Utils; DAP MCP Bridge + single: Utils; Debug Adapter Protocol + single: Utils; Debugging with MCP + +================================ + DAP MCP Bridge --- AI Debugging +================================ + +``utils/dap/mcp_bridge.py`` exposes the daslang TCP +`Debug Adapter Protocol `_ +server as a stateful Model Context Protocol server. An AI coding agent can +launch or attach to a program, set breakpoints, inspect paused state, evaluate +expressions, step, and terminate the session through MCP tool calls. + +The bridge requires Python 3.10 or newer and a daslang executable. It contains no language +semantics: requests and responses are translated between MCP JSON-RPC and DAP, +while the native daslang debugger remains responsible for execution and state +inspection. + +.. contents:: + :local: + :depth: 2 + + +Configuration +============= + +Configure one bridge process per agent session. Pin both the target workspace +and compiler so source paths and dynamic modules resolve in the intended tree: + +.. code-block:: toml + + [mcp_servers.daslang-dap] + command = "python3" + args = [ + "/abs/path/to/sdk/utils/dap/mcp_bridge.py", + "--repo-root", + "/abs/path/to/project", + "--executable", + "/abs/path/to/sdk/bin/daslang", + ] + cwd = "/abs/path/to/project" + enabled = true + required = true + +The configured executable is the default for ``debug_launch``; a launch call +can override it. Paths passed to tools may be absolute or relative to +``--repo-root``. + + +Launch workflow +=============== + +The startup order is significant: + +#. Call ``debug_launch`` with the ``.das`` entry point. The bridge starts + daslang with ``--das-wait-debugger``, chooses an available loopback port when + no port is supplied, connects, initializes DAP, and sends ``launch``. +#. Install source breakpoints with ``debug_set_breakpoints``. +#. Call ``debug_threads``. This satisfies the native debugger's startup gate. +#. Call ``debug_configuration_done`` to finish the DAP configuration phase. +#. Wait for a ``stopped`` event with ``debug_wait_event``. +#. Inspect the selected thread with ``debug_stack_trace``, ``debug_scopes``, + ``debug_variables``, and ``debug_evaluate``. +#. Resume with ``debug_continue`` or one of the stepping tools. +#. Finish with ``debug_terminate`` or ``debug_disconnect``. + +Instrumentation is the default launch mode. Set +``stepping_debugger=true`` to opt into native statement stepping. Source +breakpoints sent before ``configurationDone`` are retained and instrumented in +contexts that already exist as well as contexts created later. + + +Attach workflow +=============== + +For a runtime that already owns a DAP listener, call ``debug_connect``, +``debug_initialize``, and ``debug_attach``. Complete the same +``debug_threads`` and ``debug_configuration_done`` startup sequence before +waiting for stops. + + +Tools +===== + +Session lifecycle +----------------- + +``debug_connect`` + Connect to an existing DAP TCP endpoint. + +``debug_initialize`` + Initialize DAP and return the debugger capabilities. + +``debug_launch`` + Start a daslang process owned by the bridge and initialize its DAP session. + +``debug_attach`` + Attach to a runtime started outside the bridge. + +``debug_configuration_done`` + Complete startup after threads and breakpoints have been configured. + +``debug_terminate`` + Request debuggee termination through DAP. + +``debug_disconnect`` + Close the session. Cleanup is idempotent: a repeated call succeeds with + ``already_disconnected=true``. + +Breakpoints and execution +------------------------- + +``debug_set_breakpoints`` + Replace all source breakpoints for one file. An empty line list clears + them. + +``debug_data_breakpoint_info`` and ``debug_set_data_breakpoints`` + Resolve a visible variable to a hardware data-breakpoint identifier and + replace the active data breakpoints. + +``debug_continue``, ``debug_pause``, ``debug_step_in``, ``debug_step_over``, and ``debug_step_out`` + Control execution of the selected DAP thread. + +Inspection and events +--------------------- + +``debug_threads`` and ``debug_stack_trace`` + Enumerate debuggee contexts and the call stack of a selected context. + +``debug_scopes`` and ``debug_variables`` + Enumerate frame scopes and expand their values. + +``debug_evaluate`` + Evaluate an expression in a paused stack frame. + +``debug_wait_event`` + Wait for the next DAP event, optionally filtering by event name. + + +Lifecycle diagnostics +===================== + +The bridge owns only processes started by ``debug_launch``. A ``terminated`` +event returned by ``debug_wait_event`` and an idempotent disconnect response +include a session snapshot with the endpoint, owned process identifier and +return code, close reason, last DAP termination body, and a bounded +stdout/stderr tail. This preserves the cause when the DAP socket closes before +cleanup. + + +Application-specific scopes +=========================== + +Debug-agent modules can add application state to a paused frame from +``DapiDebugAgent.onCollect`` by calling ``report_context_state``. Each +reported category appears as another ``debug_scopes`` result and expands via +``debug_variables`` without bridge-specific adapters. + +Inspect every returned scope rather than assuming only ``Locals``, +``Arguments``, and ``Globals`` exist. For example, +``opengl/opengl_state`` supplies OpenGL scopes and ``daslib/decs_state`` +supplies DECS archetype and request scopes. Their corresponding boost modules +require these state modules automatically. + + +Tests +===== + +Run the end-to-end suite in both debugger modes:: + + PYTHONDONTWRITEBYTECODE=1 python3 utils/dap/test_mcp_bridge.py + DAS_TEST_STEPPING=1 PYTHONDONTWRITEBYTECODE=1 python3 utils/dap/test_mcp_bridge.py + +The suite invokes all 21 MCP tools against real debuggee processes. It covers +launch, attach, automatic port selection, breakpoint mutation while stopped, +stepping, termination, process failure diagnostics, and repeated cleanup. The +runtime probes also cover cancellation before source-context readiness and a +repeated debugger-worker lifecycle in one process. The Linux +``extended_checks`` job executes both commands. + + +.. seealso:: + + ``utils/dap/README.md`` -- compact setup and workflow reference + + :ref:`utils_mcp` -- compiler, source-navigation, and live-runtime MCP tools + + :ref:`utils_lsp` -- push diagnostics and source navigation diff --git a/doc/source/stdlib/handmade/function-debugapi-debugger_thread_context_ready-0x9b8cead37d9a1b94.rst b/doc/source/stdlib/handmade/function-debugapi-debugger_thread_context_ready-0x9b8cead37d9a1b94.rst new file mode 100644 index 0000000000..02a007cae6 --- /dev/null +++ b/doc/source/stdlib/handmade/function-debugapi-debugger_thread_context_ready-0x9b8cead37d9a1b94.rst @@ -0,0 +1 @@ +Internal debugger hook that releases a waiting worker. Call it only after the supplied source context has finished simulation. diff --git a/install/CLAUDE.md b/install/CLAUDE.md index 8bfb6f0d51..12efafe746 100644 --- a/install/CLAUDE.md +++ b/install/CLAUDE.md @@ -197,7 +197,7 @@ For path/filename ops use `fio` helpers (`base_name`/`dir_name`/`path_join`/...) - `modules/` - optional plugin modules (dasHV, dasGlfw, dasPUGIXML, dasSQLITE, dasAudio, dasLLVM, dasLLAMA, ...) - `examples/`, `tutorials/` - example scripts; language, integration, and module tutorials - `dastest/` - test framework (usable for testing your own code) -- `utils/mcp/`, `utils/lsp/` - MCP and LSP servers for AI coding assistants +- `utils/mcp/`, `utils/dap/`, `utils/lsp/` - MCP, DAP-to-MCP, and LSP servers for AI coding assistants - `utils/lint/` - lint runner: `bin/daslang utils/lint/main.das -- --quiet` - `utils/das-fmt/` - the formatter script (`dasfmt.das`, wraps `daslib/das_source_formatter`) - `utils/gen1-to-gen2/` - v1 (indentation) -> gen2 (braces) syntax converter, run as `bin/daslang utils/gen1-to-gen2/main.das -- ` (also the `convert_to_gen2` MCP tool) diff --git a/src/builtin/module_builtin_debugger.cpp b/src/builtin/module_builtin_debugger.cpp index 9d9e4432a6..fd4ca0a27a 100644 --- a/src/builtin/module_builtin_debugger.cpp +++ b/src/builtin/module_builtin_debugger.cpp @@ -21,6 +21,8 @@ MAKE_TYPE_FACTORY(Prologue,Prologue) namespace das { + void debuggerThreadContextDestroyed ( Context & context ); + struct PrologueAnnotation : ManagedStructureAnnotation { PrologueAnnotation(ModuleLibrary & ml) : ManagedStructureAnnotation ("Prologue", ml) { addField("info"); @@ -49,26 +51,28 @@ namespace debugger { struct DebugAgentAdapter : DebugAgent, DapiDebugAgent_Adapter { DebugAgentAdapter ( char * pClass, const StructInfo * info, Context * ctx ) : DapiDebugAgent_Adapter(info), classPtr(pClass), classInfo(info), context(ctx) { + if ( !context->contextMutex ) context->contextMutex = new recursive_mutex; } virtual void onBeforeGC ( Context * ctx ) override { if ( auto fnOnBeforeGC = get_onBeforeGC(classPtr) ) { - context->lock(); - invoke_onBeforeGC(context,fnOnBeforeGC,classPtr,*ctx); - context->unlock(); + context->threadlock_context([&](){ + invoke_onBeforeGC(context,fnOnBeforeGC,classPtr,*ctx); + }); } } virtual void onAfterGC ( Context * ctx ) override { if ( auto fnOnAfterGC = get_onAfterGC(classPtr) ) { - context->lock(); - invoke_onAfterGC(context,fnOnAfterGC,classPtr,*ctx); - context->unlock(); + context->threadlock_context([&](){ + invoke_onAfterGC(context,fnOnAfterGC,classPtr,*ctx); + }); } } virtual bool onUserCommand ( const char * cmd ) override { if ( auto fnOnUserCommand = get_onUserCommand(classPtr) ) { - context->lock(); - auto res = invoke_onUserCommand(context,fnOnUserCommand,classPtr,(char *)cmd); - context->unlock(); + bool res = false; + context->threadlock_context([&](){ + res = invoke_onUserCommand(context,fnOnUserCommand,classPtr,(char *)cmd); + }); return res; } else { return false; @@ -76,95 +80,97 @@ namespace debugger { } virtual void onInstall ( DebugAgent * agent ) override { if ( auto fnOnInstall = get_onInstall(classPtr) ) { - context->lock(); - invoke_onInstall(context,fnOnInstall,classPtr,agent); - context->unlock(); + context->threadlock_context([&](){ + invoke_onInstall(context,fnOnInstall,classPtr,agent); + }); } } virtual void onUninstall ( DebugAgent * agent ) override { if ( auto fnOnUninstall = get_onUninstall(classPtr) ) { - context->lock(); - invoke_onUninstall(context,fnOnUninstall,classPtr,agent); - context->unlock(); + context->threadlock_context([&](){ + invoke_onUninstall(context,fnOnUninstall,classPtr,agent); + }); } } virtual void onCreateContext ( Context * ctx ) override { if ( auto fnOnCreateContext = get_onCreateContext(classPtr)) { - context->lock(); - invoke_onCreateContext(context,fnOnCreateContext,classPtr,*ctx); - context->unlock(); + context->threadlock_context([&](){ + invoke_onCreateContext(context,fnOnCreateContext,classPtr,*ctx); + }); } } virtual void onDestroyContext ( Context * ctx ) override { + debuggerThreadContextDestroyed(*ctx); if ( auto fnOnDestroyContext = get_onDestroyContext(classPtr) ) { - context->lock(); - invoke_onDestroyContext(context,fnOnDestroyContext,classPtr,*ctx); - context->unlock(); + context->threadlock_context([&](){ + invoke_onDestroyContext(context,fnOnDestroyContext,classPtr,*ctx); + }); } } virtual void onSimulateContext ( Context * ctx ) override { if ( auto fnOnSimulateContext = get_onSimulateContext(classPtr)) { - context->lock(); - invoke_onSimulateContext(context,fnOnSimulateContext,classPtr,*ctx); - context->unlock(); + context->threadlock_context([&](){ + invoke_onSimulateContext(context,fnOnSimulateContext,classPtr,*ctx); + }); } } virtual void onSingleStep ( Context * ctx, const LineInfo & at ) override { if ( auto fnOnSingleStep = get_onSingleStep(classPtr) ) { - context->lock(); - invoke_onSingleStep(context,fnOnSingleStep,classPtr,*ctx,at); - context->unlock(); + context->threadlock_context([&](){ + invoke_onSingleStep(context,fnOnSingleStep,classPtr,*ctx,at); + }); } } virtual void onInstrument ( Context * ctx, const LineInfo & at ) override { if ( ctx==context ) return; // do not step into the same context if ( auto fnOnInstrument = get_onInstrument(classPtr) ) { - context->lock(); - invoke_onInstrument(context,fnOnInstrument,classPtr,*ctx,at); - context->unlock(); + context->threadlock_context([&](){ + invoke_onInstrument(context,fnOnInstrument,classPtr,*ctx,at); + }); } } virtual void onInstrumentFunction ( Context * ctx, SimFunction * sim, bool entering, uint64_t userData ) override { if ( ctx==context ) return; // do not step into the same context if ( auto fnOnInstrumentFunction = get_onInstrumentFunction(classPtr) ) { - context->lock(); - invoke_onInstrumentFunction(context,fnOnInstrumentFunction,classPtr,*ctx,sim,entering,userData); - context->unlock(); + context->threadlock_context([&](){ + invoke_onInstrumentFunction(context,fnOnInstrumentFunction,classPtr,*ctx,sim,entering,userData); + }); } } virtual void onBreakpoint ( Context * ctx, const LineInfo & at, const char * reason, const char * text ) override { if ( auto fnOnBreakpoint = get_onBreakpoint(classPtr) ) { - context->lock(); - invoke_onBreakpoint(context,fnOnBreakpoint,classPtr,*ctx,at,(char *)reason,(char *)text); - context->unlock(); + context->threadlock_context([&](){ + invoke_onBreakpoint(context,fnOnBreakpoint,classPtr,*ctx,at,(char *)reason,(char *)text); + }); } } virtual void onVariable ( Context * ctx, const char * category, const char * name, TypeInfo * info, void * data ) override { if ( auto fnOnVariable = get_onVariable(classPtr) ) { - context->lock(); - invoke_onVariable(context,fnOnVariable,classPtr,*ctx,(char *)category,(char *)name,*info,data); - context->unlock(); + context->threadlock_context([&](){ + invoke_onVariable(context,fnOnVariable,classPtr,*ctx,(char *)category,(char *)name,*info,data); + }); } } virtual void onTick () override { if ( auto fnOnTick = get_onTick(classPtr) ) { - context->lock(); - invoke_onTick(context,fnOnTick,classPtr); - context->unlock(); + context->threadlock_context([&](){ + invoke_onTick(context,fnOnTick,classPtr); + }); } } virtual void onCollect ( Context * ctx, const LineInfo & at ) override { if ( auto fnOnCollect = get_onCollect(classPtr) ) { - context->lock(); - invoke_onCollect(context,fnOnCollect,classPtr,*ctx,at); - context->unlock(); + context->threadlock_context([&](){ + invoke_onCollect(context,fnOnCollect,classPtr,*ctx,at); + }); } } virtual bool onLog ( Context * ctx, const LineInfo * at, int level, const char * text ) override { if ( auto fnOnLog = get_onLog(classPtr) ) { - context->lock(); - auto res = invoke_onLog(context,fnOnLog,classPtr,ctx,at,level,(char *)text); - context->unlock(); + bool res = false; + context->threadlock_context([&](){ + res = invoke_onLog(context,fnOnLog,classPtr,ctx,at,level,(char *)text); + }); return res; } else { return false; @@ -172,9 +178,9 @@ namespace debugger { } virtual void onBreakpointsReset ( const char * file, int breakpointsNum ) override { if ( auto fnOnBreakpointsReset = get_onBreakpointsReset(classPtr) ) { - context->lock(); - invoke_onBreakpointsReset(context,fnOnBreakpointsReset,classPtr,(char *)file, breakpointsNum); - context->unlock(); + context->threadlock_context([&](){ + invoke_onBreakpointsReset(context,fnOnBreakpointsReset,classPtr,(char *)file, breakpointsNum); + }); } } virtual void onAllocate ( Context * ctx, void * data, uint64_t size, const LineInfo & at ) override { @@ -908,22 +914,74 @@ namespace debugger { return make_smart((char *)pClass,info,context); } - atomic stopped; atomic stop_requested; - atomic debugger_started; mutex debugger_mutex; + condition_variable debugger_ready; condition_variable debugger_stopped; + bool debugger_started = false; + bool debugger_context_ready = false; + Context * debugger_wait_context = nullptr; + uint64_t debugger_generation = 0; bool debuggerStopRequested ( ) { return stop_requested.load(); } void shutdownDebuggers ( ) { - if (debugger_started.load()) { - das::unique_lock lock(das::debugger_mutex); - stop_requested.store(1); - debugger_stopped.wait(lock, []() { return stopped.load(); }); + das::unique_lock lock(das::debugger_mutex); + if ( !debugger_started ) return; + stop_requested.store(true); + debugger_ready.notify_all(); + debugger_stopped.wait(lock, []() { return !debugger_started; }); + } + + void debuggerThreadContextReady ( Context & context ) { + { + lock_guard guard{debugger_mutex}; + if ( !debugger_started || debugger_wait_context != &context ) return; + debugger_context_ready = true; } + debugger_ready.notify_all(); + } + + uint64_t debuggerThreadStarted ( Context & context ) { + lock_guard guard{debugger_mutex}; + if ( debugger_started ) return 0; + stop_requested.store(false); + debugger_wait_context = &context; + debugger_context_ready = false; + debugger_started = true; + return ++debugger_generation; + } + + void debuggerThreadContextDestroyed ( Context & context ) { + { + lock_guard guard{debugger_mutex}; + if ( !debugger_started || debugger_wait_context != &context ) return; + debugger_wait_context = nullptr; + stop_requested.store(true); + } + debugger_ready.notify_all(); + } + + bool debuggerThreadWait ( uint64_t generation ) { + das::unique_lock lock(das::debugger_mutex); + debugger_ready.wait(lock, [generation]() { + return generation != debugger_generation || debugger_context_ready || stop_requested.load(); + }); + return generation == debugger_generation && debugger_context_ready && !stop_requested.load(); + } + + void debuggerThreadFinished ( uint64_t generation ) { + { + lock_guard guard{debugger_mutex}; + if ( generation != debugger_generation ) return; + debugger_wait_context = nullptr; + debugger_context_ready = false; + stop_requested.store(false); + debugger_started = false; + } + debugger_stopped.notify_all(); } void debuggerSetContextSingleStep ( Context & context, bool step ) { @@ -1483,6 +1541,9 @@ namespace debugger { addExtern(*this, lib, "set_single_step", SideEffects::modifyExternal, "debuggerSetContextSingleStep") ->args({"context","enabled"}); + addExtern(*this, lib, "debugger_thread_context_ready", + SideEffects::modifyExternal, "debuggerThreadContextReady") + ->arg("context"); addExtern(*this, lib, "stackwalk", SideEffects::modifyExternal, "debuggerStackWalk") ->args({"context","line"}); @@ -1714,4 +1775,3 @@ namespace debugger { } REGISTER_MODULE_IN_NAMESPACE(Module_Debugger,das); - diff --git a/src/builtin/module_builtin_jobque.cpp b/src/builtin/module_builtin_jobque.cpp index bc08a9cefe..4005a52dac 100644 --- a/src/builtin/module_builtin_jobque.cpp +++ b/src/builtin/module_builtin_jobque.cpp @@ -1214,35 +1214,31 @@ namespace das { }).detach(); } - extern condition_variable debugger_stopped; - extern atomic debugger_started; - extern atomic stopped; - extern mutex debugger_mutex; - extern atomic stop_requested; - - static void stop_debugger() { - g_jobQueTotalThreads --; - { - lock_guard guard{debugger_mutex}; - stopped.store(true); - } - debugger_stopped.notify_all(); - } + uint64_t debuggerThreadStarted ( Context & context ); + bool debuggerThreadWait ( uint64_t generation ); + void debuggerThreadFinished ( uint64_t generation ); + void shutdownDebuggers ( ); void new_debugger_thread ( const Block & lambda, Context * context, LineInfoArg * lineinfo ) { - g_jobQueTotalThreads ++; - debugger_started.store(true); shared_ptr forkContext; forkContext.reset(get_clone_context(context, uint32_t(ContextCategory::thread_clone))); forkContext->sharedPtrContext = true; auto bound = daScriptEnvironment::getBound(); + auto generation = debuggerThreadStarted(*context); + if ( !generation ) { + forkContext.reset(); + context->throw_error_at(lineinfo, "debugger thread is already active"); + } + g_jobQueTotalThreads ++; thread([=]() mutable { daScriptEnvironment::setBound(bound); - das_invoke::invoke(forkContext.get(), lineinfo, lambda); + if ( debuggerThreadWait(generation) ) { + das_invoke::invoke(forkContext.get(), lineinfo, lambda); + } forkContext.reset(); - stop_debugger(); - stop_requested = false; shutdownThreadLocalDebugAgent(); + g_jobQueTotalThreads --; + debuggerThreadFinished(generation); }).detach(); } @@ -1848,6 +1844,7 @@ namespace das { virtual ~Module_JobQue() { g_jobQueAvailable--; if ( g_jobQueAvailable == 0 ) { + shutdownDebuggers(); while ( g_jobQueTotalThreads ) { builtin_sleep(0); } @@ -1863,4 +1860,3 @@ namespace das { } REGISTER_MODULE_IN_NAMESPACE(Module_JobQue,das); - diff --git a/src/misc/ARCHITECTURE.md b/src/misc/ARCHITECTURE.md index fa349d6431..1fd73bf1e3 100644 --- a/src/misc/ARCHITECTURE.md +++ b/src/misc/ARCHITECTURE.md @@ -4,6 +4,8 @@ - `job_que.cpp` - how many compute lanes a `JobQue` starts with, and where the OS puts them. - `sysos.cpp` - the per-platform core-count probes `job_que.cpp` calls. +- `network.cpp` - the single-client TCP `Server` the DAP debugger and `daslib/network` sit on, + and the two helpers every socket error passes through. The knobs are bound to daslang in `src/builtin/module_builtin_jobque.cpp`; each knob's caller contract is stated on its declaration in `include/daScript/misc/job_que.h`. @@ -47,3 +49,17 @@ by the golden-stride walk instead, so most of its live workers then sit on demot spread A/B trades the tier placement away by design - and the next class down goes to the rest, so the scheduler seats the surplus lanes on the slower tier. One class across every lane instead lets the scheduler dice the threads over the few fast cores, which measures as a per-run token-generation placement lottery. + +## 5. A socket error has one source per platform, and one reader + +Winsock reports a failed socket call through `WSAGetLastError()` and leaves `errno` untouched; +POSIX reports it in `errno`. `network.cpp` reads the error only through `last_socket_error()`, +which returns whichever the platform set, and asks "retry later?" only through +`socket_would_block()`, which knows that the would-block code is `WSAEWOULDBLOCK` on Windows and +`EAGAIN`, `EWOULDBLOCK`, or `EINTR` elsewhere. `send_msg` loops on would-block and closes the +client on any other error; `tick` treats any other `recv` error as a disconnect. A site that +read `errno` after a Winsock call would see 0 and treat a dead peer as "no error", so a send to +a disconnected client would retry forever while holding the debug-agent context lock, and the +tick that notices the closed socket could never run. `REVIEW.das` beside this file fails a +`network.cpp` that reads `errno` outside `last_socket_error()`, or names a would-block code +outside `socket_would_block()`. diff --git a/src/misc/REVIEW.das b/src/misc/REVIEW.das new file mode 100644 index 0000000000..92da48de65 --- /dev/null +++ b/src/misc/REVIEW.das @@ -0,0 +1,125 @@ +options gen2 + +require strings +require daslib/strings_boost +require daslib/fio +require dastest/review_gate + +// The mechanical half of src/misc/REVIEW.md (contract: REVIEW_COMMON.md at the repo root). +// Run from the repo root: bin/daslang src/misc/REVIEW.das - exit 0 clean, 1 with findings. + +let private NETWORK_CPP = "src/misc/network.cpp" +let private ERROR_SOURCE = "last_socket_error" +let private WOULD_BLOCK_SOURCE = "socket_would_block" + +def private is_ident_byte(b : int) : bool { + return is_alnum(b) || b == '_' +} + +// Byte offset of the first whole-word occurrence of `tok` in `line`; -1 when absent. +def private token_index(line : string; tok : string) : int { + var at = -1 + let tlen = length(tok) + peek_data(line) $(d) { + let n = length(d) + var from = 0 + while (at < 0) { + let idx = find(d, tok, from) + break if (idx < 0) + let before_ok = idx == 0 || !is_ident_byte(int(d[idx - 1])) + let after_ok = idx + tlen >= n || !is_ident_byte(int(d[idx + tlen])) + if (before_ok && after_ok) { + at = idx + } + from = idx + 1 + } + } + return at +} + +def private has_token(line : string; tok : string) : bool { + return token_index(line, tok) >= 0 +} + +def private is_write_of(line : string; tok : string) : bool { + let idx = find(line, tok) + return false if (idx < 0) + let rest = strip(slice(line, idx + length(tok))) + return starts_with(rest, "=") && !starts_with(rest, "==") +} + +def private brace_delta(line : string) : int { + var delta = 0 + peek_data(line) $(d) { + for (b in d) { + if (b == '{') { + delta++ + } elif (b == '}') { + delta-- + } + } + } + return delta +} + +// 1-based line range of the function whose definition line names `fname` and opens a brace; +// int2(0, 0) when the file defines no such function. A call site has a `(` before the name, +// a definition has only its return type there. +def private body_range(lines : array; fname : string) : int2 { + for (i, ln in range(length(lines)), lines) { + let at = token_index(ln, fname) + continue if (at < 0 || find(ln, "\{") < 0 || find(ln, ";") >= 0 + || find(slice(ln, 0, at), "(") >= 0 || find(slice(ln, at), "(") < 0) + var depth = 0 + for (j in range(i, length(lines))) { + depth += brace_delta(lines[j]) + if (depth <= 0) { + return int2(i + 1, j + 1) + } + } + } + return int2(0, 0) +} + +def private inside(span : int2; line : int) : bool { + return span.x > 0 && line >= span.x && line <= span.y +} + +def private check_network_error_sources { + if (!fexist(NETWORK_CPP)) { + return + } + let text = strip_line_comments(fread(NETWORK_CPP)) + var inscope lines <- split(text, "\n") + let error_body = body_range(lines, ERROR_SOURCE) + let would_block_body = body_range(lines, WOULD_BLOCK_SOURCE) + if (error_body.x == 0) { + gate_finding(NETWORK_CPP, "no {ERROR_SOURCE}() - Winsock reports through WSAGetLastError(), and this helper is the only place that reads it") + } + if (would_block_body.x == 0) { + gate_finding(NETWORK_CPP, "no {WOULD_BLOCK_SOURCE}() - Winsock returns WSAEWOULDBLOCK, and this helper is the only place that names the would-block codes") + } + for (i, ln in range(length(lines)), lines) { + let line_no = i + 1 + let is_preprocessor = starts_with(strip(ln), "#") + if (!is_preprocessor && has_token(ln, "errno") && !is_write_of(ln, "errno") && !inside(error_body, line_no)) { + gate_finding(NETWORK_CPP, line_no, "reads errno outside {ERROR_SOURCE}() - Winsock leaves errno at 0 after a failed socket call; call {ERROR_SOURCE}()") + } + for (code in ["EAGAIN", "EWOULDBLOCK", "EINTR", "WSAEWOULDBLOCK"]) { + if (has_token(ln, code) && !inside(would_block_body, line_no)) { + gate_finding(NETWORK_CPP, line_no, "names {code} outside {WOULD_BLOCK_SOURCE}() - Winsock and POSIX disagree on the would-block code; call {WOULD_BLOCK_SOURCE}()") + break + } + } + } +} + +[export] +def main() : int { + if (!fexist("src/misc/REVIEW.das") || !fexist("CMakeLists.txt")) { + to_log(LOG_ERROR, "src/misc/REVIEW.das: run from the repo root\n") + return 2 + } + check_network_error_sources() + return gate_verdict("src/misc") +} diff --git a/src/misc/REVIEW.md b/src/misc/REVIEW.md index d04a19faf4..b330607506 100644 --- a/src/misc/REVIEW.md +++ b/src/misc/REVIEW.md @@ -21,3 +21,6 @@ buffer.** **Never call `isfinite`, `isnan`, or `signbit` in `luau_float2string.cpp` - classify special values from the IEEE bits instead.** A build with `-ffinite-math-only` folds those calls to constants. + +**Weakening `REVIEW.das` (beside this file) is a defect: dropping a check, narrowing the files or +lines a check scans, or rewriting a finding text so it no longer names what failed.** diff --git a/src/misc/network.cpp b/src/misc/network.cpp index 2c7a5c09bd..6e38d5ee76 100644 --- a/src/misc/network.cpp +++ b/src/misc/network.cpp @@ -56,6 +56,22 @@ namespace das { #endif } + static int last_socket_error () { +#ifdef _WIN32 + return WSAGetLastError(); +#else + return errno; +#endif + } + + static bool socket_would_block ( int err ) { +#ifdef _WIN32 + return err==WSAEWOULDBLOCK; +#else + return err==EAGAIN || err==EWOULDBLOCK || err==EINTR; +#endif + } + bool set_socket_blocking ( socket_t fd, bool blocking ) { #ifdef _WIN32 unsigned long mode = blocking ? 0 : 1; @@ -75,7 +91,7 @@ namespace das { errno = 0; server_fd = socket(AF_INET, SOCK_STREAM, 0); if ( !server_fd ) { - onError("can't socket", errno); + onError("can't socket", last_socket_error()); return false; } struct sockaddr_in address; @@ -87,17 +103,17 @@ namespace das { setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &val, sizeof(val)); #endif if ( ::bind(server_fd, (struct sockaddr *)&address,sizeof(address))<0 ) { - onError("can't bind", errno); + onError("can't bind", last_socket_error()); closesocket(server_fd); return false; } if ( listen(server_fd, 3) < 0) { - onError("can't listen", errno); + onError("can't listen", last_socket_error()); closesocket(server_fd); return false; } if ( !set_socket_blocking(server_fd,false) ) { - onError("can't set nbio", errno); + onError("can't set nbio", last_socket_error()); closesocket(server_fd); return false; } @@ -140,8 +156,8 @@ namespace das { return true; } } else { - res = errno; - if ( res!=0 && res!=EAGAIN && res!=EWOULDBLOCK ) { + res = last_socket_error(); + if ( !socket_would_block(res) ) { onError ( "can't send", res); closesocket(client_fd); client_fd = 0; @@ -159,7 +175,7 @@ namespace das { client_fd = accept(server_fd, (struct sockaddr *)&address,(socklen_t*)&addrlen); if ( !invalid_socket(client_fd) ) { if ( !set_socket_blocking(client_fd,false) ) { - onError("can't set client nbio", errno); + onError("can't set client nbio", last_socket_error()); closesocket(client_fd); client_fd = 0; } @@ -181,8 +197,8 @@ namespace das { closesocket(client_fd); client_fd = 0; } else { // res<0 - res = errno; - if ( res!=0 && res!=EAGAIN && res!=EWOULDBLOCK ) { + res = last_socket_error(); + if ( !socket_would_block(res) ) { onError("connection closed on error", res); onDisconnect(); closesocket(client_fd); diff --git a/tests/debug_agent/test_callback_threadlock.das b/tests/debug_agent/test_callback_threadlock.das new file mode 100644 index 0000000000..02ca8f3694 --- /dev/null +++ b/tests/debug_agent/test_callback_threadlock.das @@ -0,0 +1,100 @@ +options gen2 +options no_aot +options no_unused_function_arguments = false +options no_unused_block_arguments = false + +require dastest/testing_boost public +require daslib/debugger +require daslib/jobque_boost +require daslib/fio + +var callback_entered : Atomic32? +var callback_release : Atomic32? +var callback_active : Atomic32? +var callback_overlap : Atomic32? + +class CallbackThreadlockAgent : DapiDebugAgent { + def override onTick() { + callback_active |> set(1) + callback_entered |> set(1) + while ((callback_release |> get) == 0) { + sleep(10u) + } + callback_active |> set(0) + } +} + +[export, pinvoke] +def configure_callback(var entered, release, active, overlap : Atomic32?) { + callback_entered = entered + callback_release = release + callback_active = active + callback_overlap = overlap +} + +[export, pinvoke] +def probe_callback_overlap() { + if ((callback_active |> get) != 0) { + callback_overlap |> set(1) + } +} + +def setup_callback_agent(_ctx : Context) { + install_new_debug_agent(new CallbackThreadlockAgent(), "callback_threadlock_test") +} + +[test] +def test_callback_and_pinvoke_are_serialized(t : T?) { + t |> run("debug-agent callback and pinvoke share the context lock") @(t : T?) { + fork_debug_agent_context(@@setup_callback_agent) + var entered = atomic32_create() + var release = atomic32_create() + var active = atomic32_create() + var overlap = atomic32_create() + var tick_done = atomic32_create() + var probe_started = atomic32_create() + let agent_context = unsafe(addr(get_debug_agent_context("callback_threadlock_test"))) + unsafe { + invoke_in_context( + *agent_context, + "configure_callback", + entered, + release, + active, + overlap) + } + with_job_que() { + new_thread() @() { + tick_debug_agent("callback_threadlock_test") + tick_done |> set(1) + } + new_thread() @() { + probe_started |> set(1) + unsafe { + invoke_in_context(*agent_context, "probe_callback_overlap") + } + } + while ((entered |> get) == 0) { + sleep(10u) + } + while ((probe_started |> get) == 0) { + sleep(10u) + } + sleep(200u) + release |> set(1) + while ((tick_done |> get) == 0) { + sleep(10u) + } + } + t |> equal(overlap |> get, 0) + delete_debug_agent_context("callback_threadlock_test") + unsafe { + atomic32_remove(entered) + atomic32_remove(release) + atomic32_remove(active) + atomic32_remove(overlap) + atomic32_remove(tick_done) + atomic32_remove(probe_started) + } + } +} diff --git a/utils/dap/README.md b/utils/dap/README.md new file mode 100644 index 0000000000..ca6856fa89 --- /dev/null +++ b/utils/dap/README.md @@ -0,0 +1,100 @@ +# daScript DAP MCP bridge + +`mcp_bridge.py` exposes the repository's TCP Debug Adapter Protocol server as a +stateful MCP server for Codex. One MCP process owns one DAP connection and, for +`debug_launch`, the launched `daslang` process. + +The bridge requires Python 3.10 or newer. + +For an external project, start the bridge with that project's workspace root +and pinned compiler. The configured executable becomes the default for every +`debug_launch`; an individual call can still override it: + +```toml +[mcp_servers.daslang-dap] +command = "python3" +args = [ + "/abs/path/to/daScript/utils/dap/mcp_bridge.py", + "--repo-root", + "/abs/path/to/project", + "--executable", + "/abs/path/to/pinned-sdk/bin/daslang", +] +cwd = "/abs/path/to/project" +enabled = true +required = true +``` + +## Local launch workflow + +1. Call `debug_launch` with a `.das` file. The bridge starts `daslang` with + `--das-wait-debugger`, connects, initializes DAP, and sends `launch`. When + `port` is omitted, the bridge chooses an available local port; pass an + explicit port only when another process needs to know it in advance. +2. Call `debug_set_breakpoints` as needed. +3. Call `debug_threads`. The daScript startup gate requires this request. +4. Call `debug_configuration_done`. +5. Wait for `stopped` with `debug_wait_event`, then use `debug_stack_trace`, + `debug_scopes`, `debug_variables`, and `debug_evaluate`. +6. Resume with `debug_continue`, `debug_step_in`, `debug_step_over`, or + `debug_step_out`. +7. Finish with `debug_terminate` or `debug_disconnect`. + +`debug_disconnect` is safe to repeat. If the DAP peer has already gone away, +it returns success with `already_disconnected=true` and a `session` snapshot +containing the last endpoint, owned-process return code, termination reason, +recent DAP events, and captured stdout/stderr tail. A `terminated` result from +`debug_wait_event` carries the same snapshot. + +Local launch uses instrumentation mode by default. Source breakpoints are sent +to DAP immediately; the native debug agent keeps unverified breakpoints and +instruments contexts already present when `configurationDone` arrives, then +instruments later contexts when they are created, before their code runs. +Statement stepping remains available with `stepping_debugger=true`; it stays +opt-in so callers choose between statement stepping and instrumentation. + +## Custom debugger state + +daScript debug-agent modules can add application-specific state to a paused +stack frame. Their `DapiDebugAgent.onCollect` implementation calls +`report_context_state`; each reported category then appears as an extra result +from `debug_scopes`, and `debug_variables` expands the values normally. The +bridge does not need per-module adapters. + +Always inspect every scope returned by `debug_scopes`, not only `Locals`, +`Arguments`, and `Globals`. For example: + +- `require opengl/opengl_state` adds scopes such as `OPENGL`, `OPENGL program`, + and `OPENGL texture` when the paused context is an OpenGL context. +- `require daslib/decs_state` adds `DECS archetype` and `DECS requests`. + +`opengl/opengl_boost` and `daslib/decs_boost` already require their respective +state modules, so code using either boost module gets these scopes +automatically. + +## Attach workflow + +For a runtime already started with the debug server, use `debug_connect`, +`debug_initialize`, and `debug_attach`, followed by `debug_threads` and +`debug_configuration_done`. + +## Test + +```sh +PYTHONDONTWRITEBYTECODE=1 python3 utils/dap/test_mcp_bridge.py +``` + +To exercise the native stepping mode and its locked-table regression: + +```sh +DAS_TEST_STEPPING=1 PYTHONDONTWRITEBYTECODE=1 python3 utils/dap/test_mcp_bridge.py +``` + +The end-to-end test invokes all 21 MCP tools against real daScript debuggee +processes, including automatic port allocation, launch, attach, stepping, +termination, repeated cleanup, disconnect while stopped at a breakpoint, and +debug-agent callbacks without a preconfigured context mutex. Runtime probes +also cover shutdown and source-context destruction while a debugger worker is +still waiting, repeated worker lifecycle in one process, and immediate +rejection of a duplicate singleton worker. It is also run by the Linux +`extended_checks` job. diff --git a/utils/dap/_fixture.das b/utils/dap/_fixture.das new file mode 100644 index 0000000000..6f42ea62cc --- /dev/null +++ b/utils/dap/_fixture.das @@ -0,0 +1,25 @@ +options gen2 +options debugger + +require daslib/fio + + +def dap_add_one(value : int) : int { + var result = value + 1 + result += 1 + return result +} + + +[export] +def main() { + var seed = 40 + let answer = dap_add_one(seed) + var guard = 0 + let started = ref_time_ticks() + while (get_time_usec(started) < 300000) { + guard += 1 + sleep(1u) + } + print("DAP_RESULT={answer}\n") +} diff --git a/utils/dap/_fixture_callback_no_threadlock.das b/utils/dap/_fixture_callback_no_threadlock.das new file mode 100644 index 0000000000..4d8f62cb5c --- /dev/null +++ b/utils/dap/_fixture_callback_no_threadlock.das @@ -0,0 +1,21 @@ +options gen2 +options no_aot + +require daslib/debugger + +class CallbackWithoutThreadlockAgent : DapiDebugAgent { + def override onTick() { + } +} + +def setup_callback_agent(_ctx : Context) { + install_new_debug_agent(new CallbackWithoutThreadlockAgent(), "callback_without_threadlock") +} + +[export] +def main() { + fork_debug_agent_context(@@setup_callback_agent) + tick_debug_agent("callback_without_threadlock") + delete_debug_agent_context("callback_without_threadlock") + print("DAP_CALLBACK_NO_THREADLOCK=1\n") +} diff --git a/utils/dap/_fixture_cancel.das b/utils/dap/_fixture_cancel.das new file mode 100644 index 0000000000..c3e0bcbcb8 --- /dev/null +++ b/utils/dap/_fixture_cancel.das @@ -0,0 +1,11 @@ +options gen2 + +require jobque + +[export] +def main() { + new_debugger_thread() { + panic("cancelled debugger worker ran") + } + print("DAP_CANCEL_READY\n") +} diff --git a/utils/dap/_fixture_context_destroyed.das b/utils/dap/_fixture_context_destroyed.das new file mode 100644 index 0000000000..532779a535 --- /dev/null +++ b/utils/dap/_fixture_context_destroyed.das @@ -0,0 +1,58 @@ +options gen2 + +require daslib/debugger +require daslib/fio +require jobque + +class ObserverAgent : DapiDebugAgent { +} + +class WorkerAgent : DapiDebugAgent { +} + +def setup_observer(_ctx : Context) { + install_new_debug_agent(new ObserverAgent(), "context_destroy_observer") +} + +def setup_worker(_ctx : Context) { + install_new_debug_agent(new WorkerAgent(), "context_destroy_worker") +} + +[export, pinvoke] +def start_waiting_worker() { + new_debugger_thread() { + panic("destroyed context worker ran") + } +} + +[export] +def main() { + let args <- get_command_line_arguments() + let marker = args[length(args) - 1] + fork_debug_agent_context(@@setup_observer) + fork_debug_agent_context(@@setup_worker) + unsafe { + invoke_in_context( + get_debug_agent_context("context_destroy_worker"), + "start_waiting_worker") + } + delete_debug_agent_context("context_destroy_worker") + var started = false + while (!started) { + try { + new_debugger_thread() { + let worker_args <- get_command_line_arguments() + fwrite(worker_args[length(worker_args) - 1], "done") + print("DAP_CONTEXT_DESTROY_RECOVERED=1\n") + } + started = true + } recover { + sleep(1u) + } + } + debugger_thread_context_ready(this_context()) + while (!fexist(marker)) { + sleep(1u) + } + delete_debug_agent_context("context_destroy_observer") +} diff --git a/utils/dap/_fixture_duplicate.das b/utils/dap/_fixture_duplicate.das new file mode 100644 index 0000000000..7c352fa6ff --- /dev/null +++ b/utils/dap/_fixture_duplicate.das @@ -0,0 +1,11 @@ +options gen2 + +require jobque + +[export] +def main() { + new_debugger_thread() { + } + new_debugger_thread() { + } +} diff --git a/utils/dap/_fixture_lifecycle.das b/utils/dap/_fixture_lifecycle.das new file mode 100644 index 0000000000..e145c4c61a --- /dev/null +++ b/utils/dap/_fixture_lifecycle.das @@ -0,0 +1,39 @@ +options gen2 + +require debugapi +require daslib/fio +require jobque + +[export] +def main() { + let args <- get_command_line_arguments() + let first_marker = args[length(args) - 2] + let second_marker = args[length(args) - 1] + new_debugger_thread() { + let worker_args <- get_command_line_arguments() + fwrite(worker_args[length(worker_args) - 2], "done") + print("DAP_LIFECYCLE_WORKER=1\n") + } + debugger_thread_context_ready(this_context()) + while (!fexist(first_marker)) { + sleep(1u) + } + var started = false + while (!started) { + try { + new_debugger_thread() { + let worker_args <- get_command_line_arguments() + fwrite(worker_args[length(worker_args) - 1], "done") + print("DAP_LIFECYCLE_WORKER=2\n") + } + started = true + } recover { + sleep(1u) + } + } + debugger_thread_context_ready(this_context()) + while (!fexist(second_marker)) { + sleep(1u) + } + print("DAP_LIFECYCLE=2\n") +} diff --git a/utils/dap/mcp_bridge.py b/utils/dap/mcp_bridge.py new file mode 100644 index 0000000000..df75aafd3c --- /dev/null +++ b/utils/dap/mcp_bridge.py @@ -0,0 +1,1268 @@ +#!/usr/bin/env python3 +"""Expose the daScript Debug Adapter Protocol server as MCP tools for Codex.""" + +from __future__ import annotations + +import argparse +import copy +import json +import os +import signal +import socket +import subprocess +import sys +import threading +import time +from collections import deque +from pathlib import Path +from typing import Any, BinaryIO + + +MCP_PROTOCOL_VERSION = "2025-11-25" +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 10000 +DEFAULT_TIMEOUT = 90.0 +EVENT_LIMIT = 2000 +OUTPUT_LIMIT = 200 +DAP_HISTORY_LIMIT = 200 +DAP_HEADER_LINE_LIMIT = 8192 +DAP_HEADER_LIMIT = 65536 +DAP_PAYLOAD_LIMIT = 64 * 1024 * 1024 + + +class BridgeError(RuntimeError): + """An error suitable for returning from an MCP tool call.""" + + +def _read_dap_frame(stream: BinaryIO) -> dict[str, Any] | None: + headers: dict[str, str] = {} + header_size = 0 + while True: + line = stream.readline(DAP_HEADER_LINE_LIMIT + 1) + if not line: + if header_size == 0: + return None + raise BridgeError("truncated DAP headers") + if len(line) > DAP_HEADER_LINE_LIMIT: + raise BridgeError("DAP header line is too long") + if not line.endswith(b"\n"): + raise BridgeError("truncated DAP header line") + header_size += len(line) + if header_size > DAP_HEADER_LIMIT: + raise BridgeError("DAP headers are too large") + if line in (b"\r\n", b"\n"): + break + name, separator, value = line.decode("ascii").partition(":") + if not separator: + raise BridgeError("invalid DAP header") + headers[name.lower()] = value.strip() + try: + length = int(headers["content-length"]) + except (KeyError, ValueError) as error: + raise BridgeError("missing or invalid DAP Content-Length") from error + if length < 0 or length > DAP_PAYLOAD_LIMIT: + raise BridgeError(f"DAP Content-Length is outside 0..{DAP_PAYLOAD_LIMIT}") + payload = stream.read(length) + if len(payload) != length: + raise BridgeError("truncated DAP payload") + message = json.loads(payload.decode("utf-8")) + if not isinstance(message, dict): + raise BridgeError("DAP payload must be a JSON object") + return message + + +def _write_dap_frame(sock: socket.socket, message: dict[str, Any]) -> None: + payload = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode( + "utf-8" + ) + sock.sendall(f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii") + payload) + + +class DapClient: + def __init__(self, timeout: float) -> None: + self.timeout = timeout + self.sock: socket.socket | None = None + self.stream: BinaryIO | None = None + self.reader: threading.Thread | None = None + self.condition = threading.Condition() + self.write_lock = threading.Lock() + self.responses: dict[int, dict[str, Any]] = {} + self.events: deque[dict[str, Any]] = deque() + self.event_history: deque[dict[str, Any]] = deque(maxlen=DAP_HISTORY_LIMIT) + self.next_seq = 1 + self.reader_error: BaseException | None = None + self.stopped = True + self.closing = False + self.endpoint: tuple[str, int] | None = None + self.last_endpoint: tuple[str, int] | None = None + self.capabilities: dict[str, Any] | None = None + self.terminated_enqueued = False + self.last_termination_body: dict[str, Any] = {} + self.close_reason: str | None = None + + @property + def connected(self) -> bool: + return self.sock is not None and not self.stopped + + def connect(self, host: str, port: int, timeout: float) -> dict[str, Any]: + if self.connected: + if self.endpoint == (host, port): + return {"host": host, "port": port, "already_connected": True} + raise BridgeError(f"already connected to {self.endpoint[0]}:{self.endpoint[1]}") + if self.sock is not None or self.stream is not None: + self.close() + + deadline = time.monotonic() + timeout + last_error: OSError | None = None + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + detail = f": {last_error}" if last_error is not None else "" + raise BridgeError(f"timed out connecting to DAP at {host}:{port}{detail}") + try: + sock = socket.create_connection((host, port), timeout=min(0.5, remaining)) + break + except OSError as error: + last_error = error + time.sleep(min(0.05, max(remaining, 0.0))) + + sock.settimeout(None) + stream = sock.makefile("rb") + with self.condition: + self.sock = sock + self.stream = stream + self.responses.clear() + self.events.clear() + self.event_history.clear() + self.next_seq = 1 + self.reader_error = None + self.stopped = False + self.closing = False + self.endpoint = (host, port) + self.last_endpoint = (host, port) + self.capabilities = None + self.terminated_enqueued = False + self.last_termination_body = {} + self.close_reason = None + self.reader = threading.Thread( + target=self._read_loop, name="daslang-dap-reader", daemon=True + ) + self.reader.start() + return {"host": host, "port": port, "already_connected": False} + + def _read_loop(self) -> None: + try: + assert self.stream is not None + while True: + message = _read_dap_frame(self.stream) + if message is None: + break + with self.condition: + if message.get("type") == "response": + request_seq = message.get("request_seq") + if isinstance(request_seq, (int, float)): + self.responses[int(request_seq)] = message + elif message.get("type") == "event": + self._enqueue_event_locked(message) + self.condition.notify_all() + except BaseException as error: + with self.condition: + if not self.closing: + self.reader_error = error + self.close_reason = f"reader_error: {error}" + self.condition.notify_all() + finally: + with self.condition: + was_closing = self.closing + self.stopped = True + if not was_closing: + if self.close_reason is None: + self.close_reason = "connection_closed" + self._enqueue_event_locked( + { + "type": "event", + "event": "terminated", + "body": {"reason": "connection_closed", "bridgeSynthetic": True}, + } + ) + self.condition.notify_all() + + def _enqueue_event_locked(self, event: dict[str, Any]) -> None: + if event.get("event") == "terminated": + body = event.get("body") + if not isinstance(body, dict): + body = {} + merged_body = {**self.last_termination_body, **body} + previous_reason = self.last_termination_body.get("reason") + current_reason = body.get("reason") + if previous_reason and current_reason and previous_reason != current_reason: + merged_body["reasons"] = list( + dict.fromkeys( + [ + *self.last_termination_body.get("reasons", []), + previous_reason, + current_reason, + ] + ) + ) + self.last_termination_body = merged_body + event["body"] = merged_body + self.event_history.append(copy.deepcopy(event)) + if self.terminated_enqueued: + for queued in self.events: + if queued.get("event") == "terminated": + queued["body"] = dict(merged_body) + break + return + self.terminated_enqueued = True + else: + self.event_history.append(copy.deepcopy(event)) + if len(self.events) >= EVENT_LIMIT: + output_index = next( + (index for index, item in enumerate(self.events) if item.get("event") == "output"), + None, + ) + if output_index is not None: + del self.events[output_index] + else: + self.events.popleft() + self.events.append(event) + + def enqueue_event(self, event: str, body: dict[str, Any]) -> None: + with self.condition: + self._enqueue_event_locked({"type": "event", "event": event, "body": body}) + self.condition.notify_all() + + def request( + self, command: str, arguments: dict[str, Any], timeout: float | None = None + ) -> dict[str, Any]: + if not self.connected or self.sock is None: + raise BridgeError("not connected to a DAP server; call debug_connect or debug_launch") + with self.condition: + seq = self.next_seq + self.next_seq += 1 + message = { + "seq": seq, + "type": "request", + "command": command, + "arguments": arguments, + } + try: + with self.write_lock: + assert self.sock is not None + _write_dap_frame(self.sock, message) + except OSError as error: + raise BridgeError(f"failed to send DAP {command}: {error}") from error + + deadline = time.monotonic() + (self.timeout if timeout is None else timeout) + with self.condition: + while seq not in self.responses: + if self.reader_error is not None: + raise BridgeError(f"DAP reader failed: {self.reader_error}") + if self.stopped: + raise BridgeError(f"DAP connection closed before replying to {command}") + remaining = deadline - time.monotonic() + if remaining <= 0: + raise BridgeError(f"DAP request timed out: {command}") + self.condition.wait(remaining) + response = self.responses.pop(seq) + if not response.get("success", False): + raise BridgeError(f"DAP {command} failed: {response.get('message', response)}") + return { + "command": command, + "success": True, + "body": response.get("body"), + } + + def initialize(self, timeout: float | None = None) -> dict[str, Any]: + if self.capabilities is not None: + return { + "command": "initialize", + "success": True, + "body": self.capabilities, + "cached": True, + } + result = self.request( + "initialize", + { + "clientID": "codex", + "clientName": "Codex daslang DAP MCP bridge", + "adapterID": "daslang", + "pathFormat": "path", + "linesStartAt1": True, + "columnsStartAt1": True, + "supportsVariableType": True, + "supportsVariablePaging": True, + "supportsRunInTerminalRequest": False, + }, + timeout=timeout, + ) + body = result.get("body") + self.capabilities = body if isinstance(body, dict) else {} + return result + + def wait_event( + self, event_name: str | None, timeout: float + ) -> dict[str, Any]: + deadline = time.monotonic() + timeout + with self.condition: + while True: + event_index = next( + ( + index + for index, item in enumerate(self.events) + if event_name is None or item.get("event") == event_name + ), + None, + ) + if event_index is not None: + event = self.events[event_index] + del self.events[event_index] + return event + if self.reader_error is not None: + raise BridgeError(f"DAP reader failed: {self.reader_error}") + remaining = deadline - time.monotonic() + if remaining <= 0 or self.stopped: + return { + "type": "event", + "event": None, + "body": { + "timedOut": remaining <= 0, + "connectionClosed": self.stopped, + "requestedEvent": event_name, + }, + } + self.condition.wait(remaining) + + def close(self, reason: str = "client_close") -> None: + sock = self.sock + stream = self.stream + with self.condition: + was_connected = self.connected + self.closing = True + if was_connected: + self.close_reason = reason + if sock is not None: + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + try: + sock.close() + except OSError: + pass + if stream is not None: + try: + stream.close() + except OSError: + pass + reader = self.reader + if reader is not None and reader is not threading.current_thread(): + reader.join(timeout=1.0) + with self.condition: + self.sock = None + self.stream = None + self.reader = None + self.stopped = True + self.endpoint = None + self.capabilities = None + self.condition.notify_all() + + def termination_body(self) -> dict[str, Any]: + with self.condition: + return dict(self.last_termination_body) + + def history(self) -> list[dict[str, Any]]: + with self.condition: + return copy.deepcopy(list(self.event_history)) + + +def _schema( + properties: dict[str, Any] | None = None, required: list[str] | None = None +) -> dict[str, Any]: + return { + "type": "object", + "properties": properties or {}, + "required": required or [], + "additionalProperties": False, + } + + +HOST_PORT = { + "host": {"type": "string", "description": "DAP host; defaults to 127.0.0.1."}, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "DAP TCP port; defaults to 10000.", + }, + "timeout_sec": {"type": "number", "exclusiveMinimum": 0}, +} + +LAUNCH_HOST_PORT = { + **HOST_PORT, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": ( + "DAP TCP port. When omitted, debug_launch selects an available local port." + ), + }, +} + +SESSION_SETTINGS = { + "cwd": {"type": "string"}, + "paths": {"type": "array", "items": {"type": "string"}}, + "path_aliases": {"type": "object", "additionalProperties": {"type": "string"}}, + "inline_preview_limit": {"type": "integer", "minimum": 0}, + "max_children_count": {"type": "integer", "minimum": 0}, + "collect_all_globals": {"type": "boolean"}, +} + +THREAD_ID = { + "thread_id": {"type": "integer", "minimum": 1, "description": "DAP thread id."} +} + +TOOLS = [ + { + "name": "debug_connect", + "description": "Connect to an already running daScript DAP TCP server.", + "inputSchema": _schema(HOST_PORT), + }, + { + "name": "debug_initialize", + "description": "Initialize the connected DAP session and return debugger capabilities.", + "inputSchema": _schema(), + }, + { + "name": "debug_launch", + "description": ( + "Launch a .das program with --das-wait-debugger, connect, initialize DAP, " + "and send launch. Set breakpoints, call debug_threads, then " + "debug_configuration_done to release startup." + ), + "inputSchema": _schema( + { + "file": {"type": "string", "description": "Script path."}, + "executable": {"type": "string", "description": "Optional daslang binary."}, + "program_args": {"type": "array", "items": {"type": "string"}}, + "stepping_debugger": { + "type": "boolean", + "description": ( + "Use native statement stepping instead of the default instrumentation " + "debugger; defaults to false so callers choose the debugging mode explicitly." + ), + }, + "project": {"type": "string"}, + "project_root": {"type": "string"}, + "load_modules": {"type": "array", "items": {"type": "string"}}, + **LAUNCH_HOST_PORT, + **SESSION_SETTINGS, + }, + ["file"], + ), + }, + { + "name": "debug_attach", + "description": "Connect if needed, initialize DAP, and attach to a running daScript runtime.", + "inputSchema": _schema({**HOST_PORT, **SESSION_SETTINGS}), + }, + { + "name": "debug_set_breakpoints", + "description": "Replace source breakpoints for one file; pass an empty lines array to clear them.", + "inputSchema": _schema( + { + "file": {"type": "string"}, + "lines": { + "type": "array", + "items": {"type": "integer", "minimum": 1}, + }, + "source_modified": {"type": "boolean"}, + }, + ["file", "lines"], + ), + }, + { + "name": "debug_data_breakpoint_info", + "description": "Resolve a visible variable to a daScript hardware data-breakpoint id.", + "inputSchema": _schema( + { + "variables_reference": {"type": "integer", "minimum": 1}, + "name": {"type": "string"}, + }, + ["variables_reference", "name"], + ), + }, + { + "name": "debug_set_data_breakpoints", + "description": "Replace data breakpoints; pass an empty array to clear them.", + "inputSchema": _schema( + { + "breakpoints": { + "type": "array", + "items": { + "type": "object", + "properties": { + "data_id": {"type": "string"}, + "access_type": {"type": "string"}, + "condition": {"type": "string"}, + "hit_condition": {"type": "string"}, + "description": {"type": "string"}, + "enabled": {"type": "boolean"}, + }, + "required": ["data_id"], + "additionalProperties": False, + }, + } + }, + ["breakpoints"], + ), + }, + { + "name": "debug_configuration_done", + "description": "Tell DAP that breakpoints and startup configuration are complete.", + "inputSchema": _schema(), + }, + {"name": "debug_threads", "description": "Return debuggee threads/contexts.", "inputSchema": _schema()}, + { + "name": "debug_stack_trace", + "description": "Return the call stack of a DAP thread.", + "inputSchema": _schema( + { + **THREAD_ID, + "start_frame": {"type": "integer", "minimum": 0}, + "levels": {"type": "integer", "minimum": 1}, + }, + ["thread_id"], + ), + }, + { + "name": "debug_scopes", + "description": "Return locals, arguments, state, and globals scopes for a stack frame.", + "inputSchema": _schema( + {"frame_id": {"type": "integer", "minimum": 1}}, ["frame_id"] + ), + }, + { + "name": "debug_variables", + "description": "Expand a DAP variables reference.", + "inputSchema": _schema( + { + "variables_reference": {"type": "integer", "minimum": 1}, + "start": {"type": "integer", "minimum": 0}, + "count": {"type": "integer", "minimum": 1}, + }, + ["variables_reference"], + ), + }, + { + "name": "debug_evaluate", + "description": "Evaluate an expression in a paused stack frame.", + "inputSchema": _schema( + { + "expression": {"type": "string"}, + "frame_id": {"type": "integer", "minimum": 1}, + "context": {"type": "string"}, + }, + ["expression", "frame_id"], + ), + }, + { + "name": "debug_continue", + "description": "Continue a paused thread.", + "inputSchema": _schema(THREAD_ID, ["thread_id"]), + }, + { + "name": "debug_pause", + "description": "Request a running thread to pause.", + "inputSchema": _schema(THREAD_ID, ["thread_id"]), + }, + { + "name": "debug_step_in", + "description": "Step into the next call or statement.", + "inputSchema": _schema(THREAD_ID, ["thread_id"]), + }, + { + "name": "debug_step_over", + "description": "Step over the next statement (DAP next).", + "inputSchema": _schema(THREAD_ID, ["thread_id"]), + }, + { + "name": "debug_step_out", + "description": "Continue until the current function returns.", + "inputSchema": _schema(THREAD_ID, ["thread_id"]), + }, + { + "name": "debug_terminate", + "description": "Terminate the debuggee through DAP.", + "inputSchema": _schema(), + }, + { + "name": "debug_disconnect", + "description": "Disconnect DAP; daScript resumes threads and clears source breakpoints.", + "inputSchema": _schema(), + }, + { + "name": "debug_wait_event", + "description": "Wait for the next DAP event, optionally filtering by event name.", + "inputSchema": _schema( + { + "event": {"type": "string"}, + "timeout_sec": {"type": "number", "minimum": 0, "maximum": 3600}, + } + ), + }, +] + + +class DapBridge: + def __init__( + self, + repo_root: Path, + timeout: float, + executable: Path | None = None, + ) -> None: + self.repo_root = repo_root.resolve() + self.timeout = timeout + self.executable = executable.resolve() if executable is not None else None + self.dap = DapClient(timeout) + self.process: subprocess.Popen[bytes] | None = None + self.process_lock = threading.Lock() + self.process_output: deque[str] = deque(maxlen=OUTPUT_LIMIT) + self.process_threads: list[threading.Thread] = [] + self.process_generation = 0 + + def _available_local_port(self, host: str) -> int: + bind_host = "127.0.0.1" if host == "localhost" else host + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind((bind_host, 0)) + return int(sock.getsockname()[1]) + + def _ensure_local_port_available(self, host: str, port: int) -> None: + bind_host = "127.0.0.1" if host == "localhost" else host + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind((bind_host, port)) + except OSError as error: + raise BridgeError( + f"DAP launch port {host}:{port} is unavailable: {error}" + ) from error + + def _wait_owned_process(self, timeout: float) -> int | None: + with self.process_lock: + process = self.process + generation = self.process_generation + process_threads = list(self.process_threads) + if process is None: + return None + try: + return_code = process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + return_code = process.poll() + if return_code is not None: + for thread in process_threads: + if thread is not threading.current_thread(): + thread.join(timeout=1.0) + with self.process_lock: + if process is not self.process or generation != self.process_generation: + return return_code + return return_code + + def _session_snapshot(self) -> dict[str, Any]: + self._wait_owned_process(0) + with self.process_lock: + process = self.process + output_tail = list(self.process_output) + endpoint = self.dap.endpoint or self.dap.last_endpoint + return_code = process.poll() if process is not None else None + return { + "connected": self.dap.connected, + "host": endpoint[0] if endpoint is not None else None, + "port": endpoint[1] if endpoint is not None else None, + "pid": process.pid if process is not None else None, + "return_code": return_code, + "close_reason": self.dap.close_reason, + "last_dap_termination": self.dap.termination_body(), + "recent_dap_events": self.dap.history(), + "process_output_tail": output_tail, + } + + def error_text(self, error: BaseException) -> str: + snapshot = self._session_snapshot() + if snapshot["host"] is None and snapshot["pid"] is None: + return str(error) + return f"{error}; session: {json.dumps(snapshot, ensure_ascii=False)}" + + def _path(self, value: Any, *, must_exist: bool = True) -> Path: + if not isinstance(value, str) or not value: + raise BridgeError("path must be a non-empty string") + path = Path(value).expanduser() + if not path.is_absolute(): + path = self.repo_root / path + path = path.resolve() + if must_exist and not path.exists(): + raise BridgeError(f"path does not exist: {path}") + return path + + def _host_port_timeout(self, arguments: dict[str, Any]) -> tuple[str, int, float]: + host = arguments.get("host", DEFAULT_HOST) + port = arguments.get("port", DEFAULT_PORT) + timeout = arguments.get("timeout_sec", min(self.timeout, 30.0)) + if not isinstance(host, str) or not host: + raise BridgeError("host must be a non-empty string") + if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535: + raise BridgeError("port must be an integer from 1 to 65535") + if not isinstance(timeout, (int, float)) or isinstance(timeout, bool) or timeout <= 0: + raise BridgeError("timeout_sec must be positive") + return host, port, float(timeout) + + def _integer(self, arguments: dict[str, Any], name: str, minimum: int = 0) -> int: + value = arguments.get(name) + if not isinstance(value, int) or isinstance(value, bool) or value < minimum: + raise BridgeError(f"{name} must be an integer >= {minimum}") + return value + + def _session_settings(self, arguments: dict[str, Any]) -> dict[str, Any]: + result: dict[str, Any] = {} + if "cwd" in arguments: + result["cwd"] = str(self._path(arguments["cwd"])) + if "paths" in arguments: + paths = arguments["paths"] + if not isinstance(paths, list) or not all(isinstance(item, str) for item in paths): + raise BridgeError("paths must be an array of strings") + result["paths"] = [str(self._path(item)) for item in paths] + if "path_aliases" in arguments: + aliases = arguments["path_aliases"] + if not isinstance(aliases, dict) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in aliases.items() + ): + raise BridgeError("path_aliases must map strings to strings") + result["pathAliases"] = aliases + for source, target in ( + ("inline_preview_limit", "inlinePreviewLimit"), + ("max_children_count", "maxChildrenCount"), + ): + if source in arguments: + value = arguments[source] + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise BridgeError(f"{source} must be a non-negative integer") + result[target] = value + if "collect_all_globals" in arguments: + value = arguments["collect_all_globals"] + if not isinstance(value, bool): + raise BridgeError("collect_all_globals must be a boolean") + result["collectAllGlobals"] = value + return result + + def _pick_executable(self, requested: Any) -> Path: + if requested is None and self.executable is not None: + requested = str(self.executable) + if requested is not None: + executable = self._path(requested) + if not executable.is_file() or not os.access(executable, os.X_OK): + raise BridgeError(f"not an executable file: {executable}") + return executable + candidates = [ + self.repo_root / "bin" / "daslang", + self.repo_root / "bin" / "daslang.exe", + self.repo_root / "build" / "daslang", + self.repo_root / "build" / "daslang.exe", + self.repo_root / "bin" / "Release" / "daslang", + self.repo_root / "bin" / "Release" / "daslang.exe", + ] + usable = [path for path in candidates if path.is_file() and os.access(path, os.X_OK)] + if not usable: + raise BridgeError("no daslang executable found; pass executable explicitly") + return max(usable, key=lambda path: path.stat().st_mtime) + + def _pump_output(self, stream: BinaryIO, category: str, generation: int) -> None: + try: + for raw_line in iter(stream.readline, b""): + line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n") + with self.process_lock: + if generation == self.process_generation: + self.process_output.append(f"{category}: {line}") + finally: + stream.close() + + def _monitor_process( + self, + process: subprocess.Popen[bytes], + generation: int, + output_threads: list[threading.Thread], + ) -> None: + exit_code = process.wait() + for thread in output_threads: + thread.join(timeout=1.0) + with self.process_lock: + current = process is self.process and generation == self.process_generation + if not current: + return + self.dap.enqueue_event( + "terminated", + {"exitCode": exit_code, "reason": "process_exit", "bridgeSynthetic": True}, + ) + + def _spawn(self, command: list[str], cwd: Path) -> subprocess.Popen[bytes]: + with self.process_lock: + if self.process is not None and self.process.poll() is None: + raise BridgeError(f"bridge already owns running debuggee pid {self.process.pid}") + old_threads = list(self.process_threads[:2]) + for thread in old_threads: + thread.join(timeout=1.0) + with self.process_lock: + self.process_generation += 1 + generation = self.process_generation + self.process_output.clear() + kwargs: dict[str, Any] = {} + if os.name == "posix": + kwargs["start_new_session"] = True + elif os.name == "nt": + kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + process = subprocess.Popen( + command, + cwd=cwd, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + **kwargs, + ) + with self.process_lock: + self.process = process + self.process_threads = [] + output_threads: list[threading.Thread] = [] + for stream, category in ((process.stdout, "stdout"), (process.stderr, "stderr")): + assert stream is not None + thread = threading.Thread( + target=self._pump_output, + args=(stream, category, generation), + name=f"daslang-debuggee-{category}", + daemon=True, + ) + thread.start() + output_threads.append(thread) + monitor = threading.Thread( + target=self._monitor_process, + args=(process, generation, output_threads), + name="daslang-debuggee-monitor", + daemon=True, + ) + with self.process_lock: + self.process_threads = [*output_threads, monitor] + monitor.start() + return process + + def _output_tail(self) -> list[str]: + with self.process_lock: + return list(self.process_output) + + def _terminate_owned(self) -> None: + with self.process_lock: + process = self.process + if process is None: + return + if process.poll() is not None: + self._wait_owned_process(0) + return + try: + if os.name == "posix": + os.killpg(process.pid, signal.SIGTERM) + else: + process.terminate() + process.wait(timeout=3.0) + except subprocess.TimeoutExpired: + if os.name == "posix": + os.killpg(process.pid, signal.SIGKILL) + else: + process.kill() + process.wait(timeout=3.0) + except ProcessLookupError: + pass + self._wait_owned_process(0) + + def _launch(self, arguments: dict[str, Any]) -> dict[str, Any]: + file_path = self._path(arguments.get("file")) + executable = self._pick_executable(arguments.get("executable")) + host, port, connect_timeout = self._host_port_timeout(arguments) + if host not in ("127.0.0.1", "localhost"): + raise BridgeError("debug_launch supports only the IPv4 loopback host") + if "port" not in arguments: + port = self._available_local_port(host) + else: + self._ensure_local_port_available(host, port) + cwd = self._path(arguments.get("cwd", str(self.repo_root))) + command = [str(executable), "--das-wait-debugger"] + if "project" in arguments: + command.extend(["-project", str(self._path(arguments["project"]))]) + if "project_root" in arguments: + command.extend(["-project_root", str(self._path(arguments["project_root"]))]) + load_modules = arguments.get("load_modules", []) + if not isinstance(load_modules, list) or not all( + isinstance(item, str) for item in load_modules + ): + raise BridgeError("load_modules must be an array of strings") + for module in load_modules: + command.extend(["-load_module", str(self._path(module))]) + program_args = arguments.get("program_args", []) + if not isinstance(program_args, list) or not all( + isinstance(item, str) for item in program_args + ): + raise BridgeError("program_args must be an array of strings") + stepping_debugger = arguments.get("stepping_debugger", False) + if not isinstance(stepping_debugger, bool): + raise BridgeError("stepping_debugger must be a boolean") + debugger_args = ["--das-debug-port", str(port)] + if stepping_debugger: + debugger_args.append("--das-stepping-debugger") + command.extend([str(file_path), "--", *debugger_args, *program_args]) + + if self.dap.connected: + raise BridgeError("a DAP session is already connected; disconnect it first") + process = self._spawn(command, cwd) + try: + connection = self.dap.connect(host, port, connect_timeout) + if process.poll() is not None: + raise BridgeError(f"debuggee exited before DAP initialization with {process.returncode}") + initialized = self.dap.initialize(connect_timeout) + session_settings = self._session_settings(arguments) + session_settings.setdefault("cwd", str(cwd)) + launched = self.dap.request("launch", session_settings) + initialized_event = self.dap.wait_event("initialized", min(connect_timeout, 10.0)) + if process.poll() is not None: + raise BridgeError(f"debuggee exited during DAP launch with {process.returncode}") + except BaseException as error: + self.dap.close() + self._terminate_owned() + tail = self._output_tail() + detail = f"; debuggee output: {tail}" if tail else "" + raise BridgeError(f"failed to launch debug session: {error}{detail}") from error + return { + "pid": process.pid, + "command": command, + "connection": connection, + "initialize": initialized, + "launch": launched, + "initialized_event": initialized_event, + } + + def _attach(self, arguments: dict[str, Any]) -> dict[str, Any]: + with self.process_lock: + if self.process is not None and self.process.poll() is None: + raise BridgeError( + f"bridge already owns running debuggee pid {self.process.pid}" + ) + self.process_generation += 1 + self.process = None + self.process_threads = [] + self.process_output.clear() + host, port, timeout = self._host_port_timeout(arguments) + connection = self.dap.connect(host, port, timeout) + initialized = self.dap.initialize(timeout) + attached = self.dap.request("attach", self._session_settings(arguments)) + initialized_event = self.dap.wait_event("initialized", min(timeout, 10.0)) + return { + "connection": connection, + "initialize": initialized, + "attach": attached, + "initialized_event": initialized_event, + } + + def _resume(self, command: str, arguments: dict[str, Any]) -> dict[str, Any]: + thread_id = self._integer(arguments, "thread_id", 1) + result = self.dap.request(command, {"threadId": thread_id}) + self.dap.enqueue_event( + "continued", + { + "threadId": thread_id, + "allThreadsContinued": False, + "bridgeSynthetic": True, + "command": command, + }, + ) + return result + + def call_tool(self, name: str, arguments: dict[str, Any]) -> Any: + if not isinstance(arguments, dict): + raise BridgeError("tool arguments must be an object") + if name == "debug_connect": + host, port, timeout = self._host_port_timeout(arguments) + return self.dap.connect(host, port, timeout) + if name == "debug_initialize": + return self.dap.initialize() + if name == "debug_launch": + return self._launch(arguments) + if name == "debug_attach": + return self._attach(arguments) + if name == "debug_set_breakpoints": + file_path = self._path(arguments.get("file")) + lines = arguments.get("lines") + if not isinstance(lines, list) or not all( + isinstance(line, int) and not isinstance(line, bool) and line > 0 + for line in lines + ): + raise BridgeError("lines must be an array of positive integers") + source_modified = arguments.get("source_modified", False) + if not isinstance(source_modified, bool): + raise BridgeError("source_modified must be a boolean") + params = { + "source": {"name": file_path.name, "path": str(file_path)}, + "breakpoints": [{"line": line} for line in lines], + "sourceModified": source_modified, + } + return self.dap.request("setBreakpoints", params) + if name == "debug_data_breakpoint_info": + reference = self._integer(arguments, "variables_reference", 1) + variable_name = arguments.get("name") + if not isinstance(variable_name, str) or not variable_name: + raise BridgeError("name must be a non-empty string") + return self.dap.request( + "dataBreakpointInfo", + {"variablesReference": reference, "name": variable_name}, + ) + if name == "debug_set_data_breakpoints": + breakpoints = arguments.get("breakpoints") + if not isinstance(breakpoints, list): + raise BridgeError("breakpoints must be an array") + converted = [] + for item in breakpoints: + if not isinstance(item, dict) or not isinstance(item.get("data_id"), str): + raise BridgeError("each data breakpoint needs a string data_id") + converted.append( + { + "dataId": item["data_id"], + "accessType": item.get("access_type", "write"), + "condition": item.get("condition", ""), + "hitCondition": item.get("hit_condition", ""), + "description": item.get("description", ""), + "enabled": item.get("enabled", True), + } + ) + return self.dap.request("setDataBreakpoints", {"breakpoints": converted}) + if name == "debug_configuration_done": + return self.dap.request("configurationDone", {}) + if name == "debug_threads": + return self.dap.request("threads", {}) + if name == "debug_stack_trace": + thread_id = self._integer(arguments, "thread_id", 1) + start_frame = arguments.get("start_frame", 0) + levels = arguments.get("levels", 1000) + if not isinstance(start_frame, int) or isinstance(start_frame, bool) or start_frame < 0: + raise BridgeError("start_frame must be a non-negative integer") + if not isinstance(levels, int) or isinstance(levels, bool) or levels < 1: + raise BridgeError("levels must be a positive integer") + return self.dap.request( + "stackTrace", + {"threadId": thread_id, "startFrame": start_frame, "levels": levels}, + ) + if name == "debug_scopes": + return self.dap.request( + "scopes", {"frameId": self._integer(arguments, "frame_id", 1)} + ) + if name == "debug_variables": + params = { + "variablesReference": self._integer(arguments, "variables_reference", 1) + } + if "start" in arguments: + params["start"] = self._integer(arguments, "start", 0) + if "count" in arguments: + params["count"] = self._integer(arguments, "count", 1) + return self.dap.request("variables", params) + if name == "debug_evaluate": + expression = arguments.get("expression") + if not isinstance(expression, str) or not expression: + raise BridgeError("expression must be a non-empty string") + context = arguments.get("context", "repl") + if not isinstance(context, str): + raise BridgeError("context must be a string") + return self.dap.request( + "evaluate", + { + "expression": expression, + "frameId": self._integer(arguments, "frame_id", 1), + "context": context, + }, + ) + if name == "debug_continue": + return self._resume("continue", arguments) + if name == "debug_pause": + return self.dap.request( + "pause", {"threadId": self._integer(arguments, "thread_id", 1)} + ) + if name == "debug_step_in": + return self._resume("stepIn", arguments) + if name == "debug_step_over": + return self._resume("next", arguments) + if name == "debug_step_out": + return self._resume("stepOut", arguments) + if name == "debug_terminate": + result = self.dap.request("terminate", {}, timeout=min(self.timeout, 10.0)) + return {**result, "process_output_tail": self._output_tail()} + if name == "debug_disconnect": + if not self.dap.connected: + self._wait_owned_process(min(self.timeout, 1.0)) + self.dap.close() + return { + "command": "disconnect", + "success": True, + "already_disconnected": True, + "session": self._session_snapshot(), + } + try: + result = self.dap.request("disconnect", {}) + except BridgeError as error: + if self.dap.connected: + raise + self.dap.close() + self._wait_owned_process(min(self.timeout, 1.0)) + return { + "command": "disconnect", + "success": True, + "already_disconnected": True, + "disconnect_error": str(error), + "session": self._session_snapshot(), + } + self.dap.close("disconnect_request") + self._wait_owned_process(min(self.timeout, 3.0)) + return {**result, "session": self._session_snapshot()} + if name == "debug_wait_event": + event_name = arguments.get("event") + if event_name is not None and not isinstance(event_name, str): + raise BridgeError("event must be a string") + timeout = arguments.get("timeout_sec", min(self.timeout, 30.0)) + if not isinstance(timeout, (int, float)) or isinstance(timeout, bool) or timeout < 0: + raise BridgeError("timeout_sec must be non-negative") + result = self.dap.wait_event(event_name, float(timeout)) + if result.get("event") == "terminated": + self._wait_owned_process(min(self.timeout, 1.0)) + body = result.setdefault("body", {}) + if isinstance(body, dict): + body.update(self.dap.termination_body()) + body["session"] = self._session_snapshot() + if result.get("event") is None: + body = result.setdefault("body", {}) + if isinstance(body, dict): + body["processOutputTail"] = self._output_tail() + return result + raise BridgeError(f"unknown tool: {name}") + + def close(self) -> None: + self.dap.close() + self._terminate_owned() + + +def _tool_result(value: Any, is_error: bool = False) -> dict[str, Any]: + text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, indent=2) + result: dict[str, Any] = {"content": [{"type": "text", "text": text}]} + if is_error: + result["isError"] = True + return result + + +def _write_mcp_message(message: dict[str, Any]) -> None: + sys.stdout.write(json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n") + sys.stdout.flush() + + +def serve(bridge: DapBridge) -> int: + for line in sys.stdin: + try: + request = json.loads(line) + if not isinstance(request, dict): + raise ValueError("request must be an object") + except (json.JSONDecodeError, ValueError) as error: + _write_mcp_message( + {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": str(error)}} + ) + continue + request_id = request.get("id") + method = request.get("method") + params = request.get("params") or {} + if request_id is None: + if method == "exit": + break + continue + try: + if method == "initialize": + result = { + "protocolVersion": MCP_PROTOCOL_VERSION, + "capabilities": {"tools": {}}, + "serverInfo": {"name": "daslang-dap", "version": "0.1.0"}, + "instructions": ( + "Stateful daScript DAP bridge. For debug_launch: set breakpoints, call " + "debug_threads, then debug_configuration_done. DAP ids returned by one " + "stop are inputs to stack/scopes/variables/evaluate tools." + ), + } + elif method == "ping": + result = {} + elif method == "tools/list": + result = {"tools": TOOLS} + elif method == "tools/call": + name = params.get("name") + arguments = params.get("arguments") or {} + if not isinstance(name, str): + raise BridgeError("tool name must be a string") + try: + result = _tool_result(bridge.call_tool(name, arguments)) + except Exception as error: + result = _tool_result(bridge.error_text(error), is_error=True) + elif method == "shutdown": + result = None + else: + raise KeyError(method) + _write_mcp_message({"jsonrpc": "2.0", "id": request_id, "result": result}) + except KeyError: + _write_mcp_message( + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"method not found: {method}"}, + } + ) + except BaseException as error: + _write_mcp_message( + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32603, "message": str(error)}, + } + ) + return 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repo-root", + type=Path, + default=Path(__file__).resolve().parents[2], + help="workspace root whose .das programs are debugged", + ) + parser.add_argument( + "--executable", + type=Path, + help="default daslang executable for debug_launch", + ) + parser.add_argument( + "--timeout", type=float, default=DEFAULT_TIMEOUT, help="DAP request timeout" + ) + return parser.parse_args() + + +def main() -> int: + arguments = parse_args() + bridge = DapBridge( + arguments.repo_root, + arguments.timeout, + executable=arguments.executable, + ) + try: + return serve(bridge) + finally: + bridge.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/utils/dap/test_mcp_bridge.py b/utils/dap/test_mcp_bridge.py new file mode 100644 index 0000000000..8d4d3b08d9 --- /dev/null +++ b/utils/dap/test_mcp_bridge.py @@ -0,0 +1,675 @@ +#!/usr/bin/env python3 +"""End-to-end smoke test for the daScript DAP-to-MCP bridge.""" + +from __future__ import annotations + +import errno +import io +import json +import os +import signal +import socket +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + +from mcp_bridge import BridgeError, DapBridge, _read_dap_frame + + +ROOT = Path(__file__).resolve().parents[2] +BRIDGE = ROOT / "utils" / "dap" / "mcp_bridge.py" +FIXTURE = ROOT / "utils" / "dap" / "_fixture.das" + + +def _default_daslang() -> Path: + candidates = [ + ROOT / "bin" / "daslang", + ROOT / "bin" / "daslang.exe", + ROOT / "build" / "daslang", + ROOT / "build" / "daslang.exe", + ROOT / "bin" / "Release" / "daslang", + ROOT / "bin" / "Release" / "daslang.exe", + ] + usable = [path for path in candidates if path.is_file()] + if not usable: + return ROOT / "bin" / "daslang" + return max(usable, key=lambda path: path.stat().st_mtime) + + +DASLANG = Path(os.environ.get("DASLANG_DAP_BIN", _default_daslang())).resolve() +STEPPING_OVERRIDE = os.environ.get("DAS_TEST_STEPPING") +STEPPING_DEBUGGER = ( + STEPPING_OVERRIDE == "1" if STEPPING_OVERRIDE is not None else False +) + + +def stepping_arguments() -> dict[str, bool]: + if STEPPING_OVERRIDE is None: + return {} + return {"stepping_debugger": STEPPING_DEBUGGER} + + +class McpClient: + def __init__(self) -> None: + environment = os.environ.copy() + environment["PYTHONDONTWRITEBYTECODE"] = "1" + self.process = subprocess.Popen( + [ + sys.executable, + str(BRIDGE), + "--repo-root", + str(ROOT), + "--executable", + str(DASLANG), + ], + cwd=ROOT, + env=environment, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + self.next_id = 1 + + def request(self, method: str, params: dict[str, Any] | None = None) -> Any: + assert self.process.stdin is not None and self.process.stdout is not None + request_id = self.next_id + self.next_id += 1 + message: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + message["params"] = params + self.process.stdin.write(json.dumps(message, separators=(",", ":")) + "\n") + self.process.stdin.flush() + line = self.process.stdout.readline() + if not line: + stderr = self.process.stderr.read() if self.process.stderr is not None else "" + raise AssertionError( + f"MCP bridge exited with {self.process.poll()} while handling {method}: {stderr}" + ) + response = json.loads(line) + assert response.get("id") == request_id, response + assert "error" not in response, response + return response.get("result") + + def tool(self, name: str, arguments: dict[str, Any]) -> Any: + result = self.request("tools/call", {"name": name, "arguments": arguments}) + assert not result.get("isError"), (name, result) + content = result.get("content") + assert isinstance(content, list) and content, result + return json.loads(content[0]["text"]) + + def tool_error(self, name: str, arguments: dict[str, Any]) -> str: + result = self.request("tools/call", {"name": name, "arguments": arguments}) + assert result.get("isError"), (name, result) + content = result.get("content") + assert isinstance(content, list) and content, result + return content[0]["text"] + + def close(self) -> None: + if self.process.poll() is None: + try: + self.request("shutdown") + except (AssertionError, BrokenPipeError): + pass + if self.process.stdin is not None: + self.process.stdin.close() + try: + self.process.wait(timeout=5.0) + except subprocess.TimeoutExpired: + self.process.terminate() + self.process.wait(timeout=5.0) + + +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def source_line(needle: str) -> int: + lines = FIXTURE.read_text(encoding="utf-8").splitlines() + return next(index for index, line in enumerate(lines, start=1) if needle in line) + + +def response_body(value: dict[str, Any]) -> dict[str, Any]: + body = value.get("body") + assert isinstance(body, dict), value + return body + + +def test_dap_frame_limits() -> None: + invalid_frames = ( + (b"Content-Length: 10", "truncated DAP header line"), + (b"Broken\r\n\r\n", "invalid DAP header"), + (b"X-Test: value\r\n\r\n", "missing or invalid DAP Content-Length"), + (b"Content-Length: nope\r\n\r\n", "missing or invalid DAP Content-Length"), + (b"Content-Length: -1\r\n\r\n", "DAP Content-Length is outside"), + (b"X-Long: " + b"x" * 8192 + b"\r\n\r\n", "DAP header line is too long"), + ((b"X-Many: " + b"x" * 8170 + b"\r\n") * 9 + b"\r\n", "DAP headers are too large"), + (b"Content-Length: 67108865\r\n\r\n", "DAP Content-Length is outside"), + (b"Content-Length: 4\r\n\r\n{}", "truncated DAP payload"), + (b"Content-Length: 1\r\n\r\n1", "DAP payload must be a JSON object"), + ) + for frame, expected_error in invalid_frames: + try: + _read_dap_frame(io.BytesIO(frame)) + except BridgeError as error: + assert expected_error in str(error), (expected_error, str(error)) + continue + raise AssertionError(f"invalid DAP frame was accepted: {frame[:80]!r}") + + +def launch_external(port: int) -> subprocess.Popen[bytes]: + command = [ + str(DASLANG), + "--das-wait-debugger", + str(FIXTURE), + "--", + "--das-debug-port", + str(port), + ] + if STEPPING_DEBUGGER: + command.append("--das-stepping-debugger") + return subprocess.Popen( + command, + cwd=ROOT, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def test_waiting_worker_shutdown() -> None: + fixture = ROOT / "utils" / "dap" / "_fixture_cancel.das" + completed = subprocess.run( + [str(DASLANG), str(fixture)], + cwd=ROOT, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=10, + check=False, + ) + assert completed.returncode == 0, completed.stdout + assert "DAP_CANCEL_READY" in completed.stdout, completed.stdout + assert "cancelled debugger worker ran" not in completed.stdout, completed.stdout + + +def test_callback_without_threadlock() -> None: + fixture = ROOT / "utils" / "dap" / "_fixture_callback_no_threadlock.das" + completed = subprocess.run( + [str(DASLANG), str(fixture)], + cwd=ROOT, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=10, + check=False, + ) + assert completed.returncode == 0, completed.stdout + assert "DAP_CALLBACK_NO_THREADLOCK=1" in completed.stdout, completed.stdout + + +def test_waiting_worker_context_destroyed() -> None: + fixture = ROOT / "utils" / "dap" / "_fixture_context_destroyed.das" + with tempfile.TemporaryDirectory(prefix="das-dap-destroy-") as temp_dir: + completed = subprocess.run( + [str(DASLANG), str(fixture), "--", f"{temp_dir}/done"], + cwd=ROOT, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=10, + check=False, + ) + assert completed.returncode == 0, completed.stdout + assert "destroyed context worker ran" not in completed.stdout, completed.stdout + assert "DAP_CONTEXT_DESTROY_RECOVERED=1" in completed.stdout, completed.stdout + + +def test_repeated_worker_lifecycle() -> None: + fixture = ROOT / "utils" / "dap" / "_fixture_lifecycle.das" + with tempfile.TemporaryDirectory(prefix="das-dap-lifecycle-") as temp_dir: + completed = subprocess.run( + [ + str(DASLANG), + str(fixture), + "--", + f"{temp_dir}/first", + f"{temp_dir}/second", + ], + cwd=ROOT, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=10, + check=False, + ) + assert completed.returncode == 0, completed.stdout + assert "DAP_LIFECYCLE_WORKER=1" in completed.stdout, completed.stdout + assert "DAP_LIFECYCLE_WORKER=2" in completed.stdout, completed.stdout + assert "DAP_LIFECYCLE=2" in completed.stdout, completed.stdout + + +def test_duplicate_worker_rejected() -> None: + fixture = ROOT / "utils" / "dap" / "_fixture_duplicate.das" + completed = subprocess.run( + [str(DASLANG), str(fixture)], + cwd=ROOT, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=10, + check=False, + ) + assert completed.returncode != 0, completed.stdout + assert "debugger thread is already active" in completed.stdout, completed.stdout + + +class RecordingDap: + def __init__(self) -> None: + self.commands: list[str] = [] + + def request(self, command: str, arguments: dict[str, Any]) -> dict[str, Any]: + self.commands.append(command) + return {"command": command, "success": True, "body": arguments} + + def enqueue_event(self, _event: str, _body: dict[str, Any]) -> None: + pass + + +def test_execution_command_mapping() -> None: + bridge = DapBridge(ROOT, 1.0, DASLANG) + recorder = RecordingDap() + bridge.dap = recorder + expected = { + "debug_pause": "pause", + "debug_step_in": "stepIn", + "debug_step_over": "next", + "debug_step_out": "stepOut", + } + for tool, command in expected.items(): + bridge.call_tool(tool, {"thread_id": 1}) + assert recorder.commands[-1] == command, (tool, recorder.commands) + + +def main() -> int: + test_dap_frame_limits() + test_waiting_worker_shutdown() + test_callback_without_threadlock() + test_waiting_worker_context_destroyed() + test_repeated_worker_lifecycle() + test_duplicate_worker_rejected() + test_execution_command_mapping() + client = McpClient() + external_processes: list[subprocess.Popen[bytes]] = [] + try: + initialized = client.request( + "initialize", + { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "test-dap-mcp-bridge", "version": "0"}, + }, + ) + assert initialized["serverInfo"]["name"] == "daslang-dap", initialized + + listed = client.request("tools/list") + names = [tool["name"] for tool in listed["tools"]] + assert names == [ + "debug_connect", + "debug_initialize", + "debug_launch", + "debug_attach", + "debug_set_breakpoints", + "debug_data_breakpoint_info", + "debug_set_data_breakpoints", + "debug_configuration_done", + "debug_threads", + "debug_stack_trace", + "debug_scopes", + "debug_variables", + "debug_evaluate", + "debug_continue", + "debug_pause", + "debug_step_in", + "debug_step_over", + "debug_step_out", + "debug_terminate", + "debug_disconnect", + "debug_wait_event", + ], names + + occupied_port_guard = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + occupied_port_guard.bind(("127.0.0.1", 0)) + occupied_port = int(occupied_port_guard.getsockname()[1]) + occupied_port_guard.listen(1) + launch_error = client.tool_error( + "debug_launch", + {"file": str(FIXTURE), "port": occupied_port, "timeout_sec": 2}, + ) + assert "is unavailable" in launch_error, launch_error + finally: + occupied_port_guard.close() + + ipv6_error = client.tool_error( + "debug_launch", + {"file": str(FIXTURE), "host": "::1", "timeout_sec": 2}, + ) + assert "IPv4 loopback" in ipv6_error, ipv6_error + + port = free_port() + launch = client.tool( + "debug_launch", + { + "file": str(FIXTURE), + "port": port, + "timeout_sec": 20, + **stepping_arguments(), + }, + ) + assert launch["initialize"]["body"]["supportsDataBreakpoints"], launch + assert launch["initialized_event"]["event"] == "initialized", launch + + early_breakpoint_line = source_line("var result = value + 1") + breakpoint_line = source_line("guard += 1") + breakpoint_lines = [early_breakpoint_line, breakpoint_line] + breakpoints = response_body( + client.tool( + "debug_set_breakpoints", + {"file": str(FIXTURE), "lines": breakpoint_lines}, + ) + )["breakpoints"] + assert len(breakpoints) == len(breakpoint_lines), breakpoints + assert all( + item["verified"] == STEPPING_DEBUGGER for item in breakpoints + ), breakpoints + + initial_threads = response_body(client.tool("debug_threads", {}))["threads"] + assert initial_threads, initial_threads + configured = client.tool("debug_configuration_done", {}) + assert configured["success"], configured + stopped = client.tool( + "debug_wait_event", {"event": "stopped", "timeout_sec": 20} + ) + assert stopped["event"] == "stopped", stopped + thread_id = int(stopped["body"]["threadId"]) + early_stack = response_body( + client.tool("debug_stack_trace", {"thread_id": thread_id, "levels": 1}) + )["stackFrames"] + expected_first_line = breakpoint_line if STEPPING_DEBUGGER else early_breakpoint_line + assert early_stack and early_stack[0]["line"] == expected_first_line, early_stack + if not STEPPING_DEBUGGER: + client.tool("debug_continue", {"thread_id": thread_id}) + stopped = client.tool( + "debug_wait_event", {"event": "stopped", "timeout_sec": 20} + ) + assert stopped["event"] == "stopped", stopped + thread_id = int(stopped["body"]["threadId"]) + + changed_breakpoints = response_body( + client.tool( + "debug_set_breakpoints", + { + "file": str(FIXTURE), + "lines": [breakpoint_line, source_line("sleep(1u)")], + }, + ) + )["breakpoints"] + assert len(changed_breakpoints) == 2, changed_breakpoints + if STEPPING_DEBUGGER: + client.tool("debug_terminate", {}) + terminated = client.tool( + "debug_wait_event", {"event": "terminated", "timeout_sec": 20} + ) + assert terminated["event"] == "terminated", terminated + assert terminated["body"]["session"]["return_code"] is not None, terminated + assert "exitCode" in terminated["body"], terminated + assert not any( + "locked table" in line + for line in terminated["body"]["session"]["process_output_tail"] + ), terminated + print("daslang DAP MCP stepping breakpoint regression passed") + return 0 + + stack = response_body( + client.tool("debug_stack_trace", {"thread_id": thread_id, "levels": 100}) + )["stackFrames"] + assert stack, stack + frame_id = int(stack[0]["id"]) + scopes = response_body(client.tool("debug_scopes", {"frame_id": frame_id}))[ + "scopes" + ] + local_groups = [] + for scope in scopes: + if scope["name"].startswith("Locals"): + variables = response_body( + client.tool( + "debug_variables", + {"variables_reference": int(scope["variablesReference"])}, + ) + )["variables"] + local_groups.append((scope, variables)) + guard_scope, guard_variables = next( + group + for group in local_groups + if any(item["name"] == "guard" for item in group[1]) + ) + guard_variable = next(item for item in guard_variables if item["name"] == "guard") + guard_value = int(guard_variable["value"]) + assert 0 <= guard_value < 1000, local_groups + evaluated = response_body( + client.tool( + "debug_evaluate", + {"expression": "guard", "frame_id": frame_id}, + ) + ) + assert str(guard_value) in evaluated["result"], evaluated + + data_info = response_body( + client.tool( + "debug_data_breakpoint_info", + { + "variables_reference": int(guard_scope["variablesReference"]), + "name": "guard", + }, + ) + ) + assert data_info["dataId"], data_info + cleared = response_body( + client.tool( + "debug_set_data_breakpoints", + {"breakpoints": [{"data_id": data_info["dataId"]}]}, + ) + ) + assert len(cleared["breakpoints"]) == 1, cleared + assert int(cleared["breakpoints"][0]["id"]) != 0, cleared + cleared = response_body( + client.tool("debug_set_data_breakpoints", {"breakpoints": []}) + ) + assert cleared["breakpoints"] == [], cleared + + client.tool("debug_set_breakpoints", {"file": str(FIXTURE), "lines": []}) + client.tool("debug_continue", {"thread_id": thread_id}) + client.tool( + "debug_wait_event", {"event": "continued", "timeout_sec": 1} + ) + client.tool("debug_pause", {"thread_id": thread_id}) + paused = client.tool( + "debug_wait_event", {"event": "stopped", "timeout_sec": 20} + ) + assert paused["event"] == "stopped", paused + assert paused["body"]["reason"] == "pause", paused + client.tool("debug_step_in", {"thread_id": thread_id}) + continued = client.tool( + "debug_wait_event", {"event": "continued", "timeout_sec": 1} + ) + assert continued["event"] == "continued", continued + stepped = client.tool( + "debug_wait_event", {"event": "stopped", "timeout_sec": 20} + ) + assert stepped["event"] == "stopped", stepped + assert stepped["body"]["reason"] == "step", stepped + client.tool("debug_step_over", {"thread_id": thread_id}) + stepped = client.tool( + "debug_wait_event", {"event": "stopped", "timeout_sec": 20} + ) + assert stepped["event"] == "stopped", stepped + assert stepped["body"]["reason"] == "step", stepped + client.tool("debug_step_out", {"thread_id": thread_id}) + stepped = client.tool( + "debug_wait_event", {"event": "stopped", "timeout_sec": 20} + ) + assert stepped["event"] == "stopped", stepped + assert stepped["body"]["reason"] == "step", stepped + client.tool("debug_continue", {"thread_id": thread_id}) + terminated = client.tool( + "debug_wait_event", {"event": "terminated", "timeout_sec": 20} + ) + assert terminated["event"] == "terminated", terminated + assert any( + "DAP_RESULT=42" in line + for line in terminated["body"]["session"]["process_output_tail"] + ), terminated + assert any( + event.get("event") == "stopped" + for event in terminated["body"]["session"]["recent_dap_events"] + ), terminated + + attach_port = free_port() + attached_process = launch_external(attach_port) + external_processes.append(attached_process) + connected = client.tool( + "debug_connect", {"port": attach_port, "timeout_sec": 20} + ) + assert connected["port"] == attach_port, connected + dap_initialized = client.tool("debug_initialize", {}) + assert dap_initialized["body"]["supportsConfigurationDoneRequest"], dap_initialized + attached = client.tool( + "debug_attach", {"port": attach_port, "cwd": str(ROOT), "timeout_sec": 20} + ) + assert attached["attach"]["success"], attached + client.tool("debug_threads", {}) + client.tool("debug_configuration_done", {}) + client.tool("debug_terminate", {}) + terminated = client.tool( + "debug_wait_event", {"event": "terminated", "timeout_sec": 20} + ) + assert terminated["event"] == "terminated", terminated + attached_process.wait(timeout=10) + + disconnect_port = free_port() + disconnected_process = launch_external(disconnect_port) + external_processes.append(disconnected_process) + client.tool("debug_connect", {"port": disconnect_port, "timeout_sec": 20}) + client.tool("debug_initialize", {}) + client.tool( + "debug_attach", + {"port": disconnect_port, "cwd": str(ROOT), "timeout_sec": 20}, + ) + client.tool("debug_threads", {}) + client.tool("debug_configuration_done", {}) + disconnected = client.tool("debug_disconnect", {}) + assert disconnected["success"], disconnected + disconnected_process.wait(timeout=10) + + disconnected_again = client.tool("debug_disconnect", {}) + assert disconnected_again["already_disconnected"], disconnected_again + assert disconnected_again["session"]["return_code"] is None, disconnected_again + + default_port_guard = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + try: + default_port_guard.bind(("127.0.0.1", 10000)) + except OSError as error: + if error.errno != errno.EADDRINUSE: + raise + auto_launch = client.tool( + "debug_launch", + { + "file": str(FIXTURE), + "timeout_sec": 20, + **stepping_arguments(), + }, + ) + finally: + default_port_guard.close() + auto_port = int(auto_launch["connection"]["port"]) + assert auto_port != 10000, auto_launch + + breakpoint_line = source_line("guard += 1") + client.tool( + "debug_set_breakpoints", + {"file": str(FIXTURE), "lines": [breakpoint_line]}, + ) + client.tool("debug_threads", {}) + client.tool("debug_configuration_done", {}) + stopped = client.tool( + "debug_wait_event", {"event": "stopped", "timeout_sec": 20} + ) + assert stopped["event"] == "stopped", stopped + disconnected = client.tool("debug_disconnect", {}) + assert disconnected["success"], disconnected + disconnected_again = client.tool("debug_disconnect", {}) + assert disconnected_again["already_disconnected"], disconnected_again + disconnect_session = disconnected_again["session"] + assert disconnect_session["return_code"] == 0, disconnect_session + assert not any( + "locked table" in line + for line in disconnect_session["process_output_tail"] + ), disconnect_session + + killed_launch = client.tool( + "debug_launch", + { + "file": str(FIXTURE), + "timeout_sec": 20, + **stepping_arguments(), + }, + ) + auto_pid = int(killed_launch["pid"]) + if os.name == "posix": + os.killpg(auto_pid, signal.SIGKILL) + else: + os.kill(auto_pid, signal.SIGTERM) + terminated = client.tool( + "debug_wait_event", {"event": "terminated", "timeout_sec": 20} + ) + assert terminated["event"] == "terminated", terminated + assert terminated["body"]["exitCode"] != 0, terminated + assert terminated["body"]["session"]["return_code"] != 0, terminated + assert isinstance( + terminated["body"]["session"]["process_output_tail"], list + ), terminated + + cleanup = client.tool("debug_disconnect", {}) + assert cleanup["already_disconnected"], cleanup + assert cleanup["session"]["return_code"] != 0, cleanup + initialize_error = client.tool_error("debug_initialize", {}) + assert "not connected" in initialize_error, initialize_error + finally: + for process in external_processes: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=3) + client.close() + + print("daslang DAP MCP bridge test passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())