From 5ec9b0d58a59a5b3283baaa52de2ca30ca556ba4 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Wed, 9 Sep 2026 22:09:35 -0700 Subject: [PATCH 1/2] list_modules lists what a require can reach: the C++ modules the process registered - a module with a file name was compiled from das and is not one - the daslib files under the das root instead of the cwd, and the require paths descriptors registered (module/path, from for_each_registered_native_path), which the tool never showed; with project_root or load_modules the listing comes from an eager child (-ignore-manifest, the new eager argv flag), since a deferred C++ module is not a registered one until something requires it; the pretend root gains a second module folder whose descriptor registers the tree's UnitTest C++ module and a require path of its own, and test_tools proves list_modules, find_symbol and compile_check see both under project_root, with a plain and an eager child run of the consumer; tests/lsp gains the same fixture through validate.das with -project_root; the mcp roadmap's fixture item closes --- tests/lsp/test_lsp_project_root.das | 29 ++++++++ utils/mcp/README.md | 2 +- utils/mcp/ROADMAP.md | 9 +-- utils/mcp/registry_das.das | 8 +- utils/mcp/subtools/list_modules.das | 17 +++++ utils/mcp/test_tools.das | 74 +++++++++++++++++++ .../modules/dasUnitTest/.das_module | 12 +++ .../modules/dasUnitTest/daslib/hello.das | 7 ++ .../tests/_pretend_root/probe_consumer.das | 11 +++ utils/mcp/tools/common.das | 11 ++- utils/mcp/tools/list_modules.das | 29 +++++++- 11 files changed, 192 insertions(+), 17 deletions(-) create mode 100644 tests/lsp/test_lsp_project_root.das create mode 100644 utils/mcp/subtools/list_modules.das create mode 100644 utils/mcp/tests/_pretend_root/modules/dasUnitTest/.das_module create mode 100644 utils/mcp/tests/_pretend_root/modules/dasUnitTest/daslib/hello.das create mode 100644 utils/mcp/tests/_pretend_root/probe_consumer.das diff --git a/tests/lsp/test_lsp_project_root.das b/tests/lsp/test_lsp_project_root.das new file mode 100644 index 0000000000..1e98e6bb9c --- /dev/null +++ b/tests/lsp/test_lsp_project_root.das @@ -0,0 +1,29 @@ +options gen2 +options no_unused_block_arguments = false + +require dastest/testing_boost +require daslib/fio +require daslib/command_line +require strings + +//! the validate subtool spawned with -project_root sees a project's descriptors: the pretend +//! root's module registers a C++ module and a require path, and the consumer validates clean +//! only through them +[test] +def test_validate_project_root(t : T?) { + let exe = get_full_file_name(get_das_exe()) + let root = path_join(get_das_root(), "utils/mcp/tests/_pretend_root") + let script = path_join(root, "probe_consumer.das") + let validate = path_join(get_das_root(), "utils/lsp/subtools/validate.das") + t |> run("with -project_root the consumer validates clean") @(t : T?) { + var out : string + let rc = run_and_capture([exe, "-project_root", root, validate, "--", script], out, 120.0) + t |> equal(rc, 0, "validate exits 0: {out}") + t |> success(find(out, "\"ok\":true") >= 0 && find(out, "\"diagnostics\":[]") >= 0, "no diagnostics: {out}") + } + t |> run("without it the same file reports the require it cannot resolve") @(t : T?) { + var out : string + run_and_capture([exe, validate, "--", script], out, 120.0) + t |> success(find(out, "unit_probe/hello") >= 0, "the project's require path is what goes missing: {out}") + } +} diff --git a/utils/mcp/README.md b/utils/mcp/README.md index 20c8db4299..bfd1b7046f 100644 --- a/utils/mcp/README.md +++ b/utils/mcp/README.md @@ -16,7 +16,7 @@ A [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that e | `run_script` | Run a `.das` file or inline code snippet and return stdout/stderr. Optional `project` for `.das_project`-bound module resolution. | | `ast_dump` | Dump AST of an expression or compiled function. `mode=ast` returns S-expression (node types/fields), `mode=source` returns post-macro daslang code. Optional `lineinfo` to include file and line:col spans on each node | | `program_log` | Produce full post-compilation program text (like `options log`). Shows all types, globals, and functions after macro expansion, template instantiation, and inference. Optional `function` filter | -| `list_modules` | List all available daslang modules (builtin C++ modules and daslib). Optional `json` for structured output | +| `list_modules` | List all available daslang modules in three sections: the C++ modules the process registered, the daslib files, and the require paths descriptors register (`module/path`). With `project_root` or `load_modules` the listing comes from a child that scanned those descriptors too, eager. Optional `json` for structured output | | `find_symbol` | Cross-module symbol search (functions, generics, structs, handled types, enums, globals, typedefs/aliases, fields). Case-insensitive substring by default; `=query` for exact match | | `list_requires` | Compile a `.das` file and list all `require` dependencies (direct and transitive), with source file paths and builtin annotations. Optional `json` for structured output | | `list_module_api` | List all functions, types, enums, and globals exported by a builtin or daslib module (e.g. `math`, `strings`, `fio`, `daslib/json`). Optional `compact` mode for large modules | diff --git a/utils/mcp/ROADMAP.md b/utils/mcp/ROADMAP.md index dd2fbab3ef..7536c9052f 100644 --- a/utils/mcp/ROADMAP.md +++ b/utils/mcp/ROADMAP.md @@ -337,12 +337,9 @@ Building the foundational tools well creates a platform for everything else. ## Follow-ups -- **A custom `modules/` fixture.** A test that starts the server with `-project_root` on a - fixture holding its own `modules//.das_module` - a descriptor that registers a require - path and a C++ module - and checks `list_modules`, `find_symbol` and `compile_check` see - them. The server runs eager (`-ignore-manifest`) while a plain run loads a `.shared_module` - at its first `require`; the fixture is what proves a user's tree behaves under both. -- **Back to an exe form.** Once that fixture passes, the server can build and ship as an exe +- **Back to an exe form.** `tests/_pretend_root` now carries a descriptor that registers a + C++ module and a require path, and `test_tools.das` proves `list_modules`, `find_symbol` and + `compile_check` see both under `project_root`, eager and plain. With that, the server can build and ship as an exe again: the reason it runs interpreted - development through the python keep-alive supervisor, so an exe would ship unrun - is met by the watchdog, which now covers what the supervisor did. `utils/REVIEW.das` bans the exe today; lifting the ban is part of this item. diff --git a/utils/mcp/registry_das.das b/utils/mcp/registry_das.das index 3294c02cef..65ec7fc104 100644 --- a/utils/mcp/registry_das.das +++ b/utils/mcp/registry_das.das @@ -155,13 +155,15 @@ def build_das_tools(var reg : array) { // nolint:STYLE038 — flat tool reg |> emplace(ToolDef( tool <- make_tool( "list_modules", - "List all available daslang modules (builtin and daslib)", + "List all available daslang modules: the C++ modules, the daslib files, and the require paths descriptors register (module/path). With project_root or load_modules the listing comes from a child that scanned those descriptors too.", { - "json" => PropertySchema(_type = "string", description = "If 'true', return structured JSON (ModuleList with builtin and daslib arrays)") + "json" => PropertySchema(_type = "string", description = "If 'true', return structured JSON (ModuleList with builtin, daslib and registered arrays)"), + "project_root" => PROJECT_ROOT_PROP, + "load_modules" => LOAD_MODULES_PROP }, []), arg_names <- ["json"], - handler = @@(arg1, arg2, arg3, arg4, arg5, arg6, project, project_root : string; load_modules : array) => do_list_modules(arg1 == "true"))) + handler = @@(arg1, arg2, arg3, arg4, arg5, arg6, project, project_root : string; load_modules : array) => do_list_modules(arg1 == "true", project_root, load_modules))) reg |> emplace(ToolDef( tool <- make_tool( "find_symbol", diff --git a/utils/mcp/subtools/list_modules.das b/utils/mcp/subtools/list_modules.das new file mode 100644 index 0000000000..c93b8801f8 --- /dev/null +++ b/utils/mcp/subtools/list_modules.das @@ -0,0 +1,17 @@ +options gen2 +options rtti +options indenting = 4 + +require ../tools/list_modules.das public + +//! Subprocess form of list_modules, spawned with the caller's -project_root / -load_module flags +//! and -ignore-manifest. Argv (after daslang exe + script path): , the literal "true" or +//! "false". Prints the make_tool_result envelope to stdout. + +[export] +def main { + let raw <- get_command_line_arguments() + let args <- subtool_user_args(raw) + let json = !empty(args) && args[0] == "true" + print(list_modules_here(json)) +} diff --git a/utils/mcp/test_tools.das b/utils/mcp/test_tools.das index 493255d34a..67b31ca8f4 100644 --- a/utils/mcp/test_tools.das +++ b/utils/mcp/test_tools.das @@ -369,6 +369,80 @@ def test_list_modules(t : T?) { parse_result(do_list_modules(), text, is_error) t |> success(find(text, "json") >= 0, "should list json module") } + t |> run("a module this program compiled from das is not a builtin, and the registered paths are a section") <| @(t : T?) { + var text : string + var is_error = false + parse_result(do_list_modules(true), text, is_error) + var err : string + var parsed = read_json(text, err) + t |> success(parsed != null, "json parses: {err}") + return if (parsed == null) + t |> success(json_array_has(parsed?["builtin"], "math"), "math is a builtin") + t |> success(!json_array_has(parsed?["builtin"], "json_boost"), "json_boost, which this program compiled, is not listed as a builtin") + t |> success(json_array_has(parsed?["daslib"], "json_boost"), "json_boost is a daslib module") + t |> success(parsed?["registered"] != null, "the registered require paths are listed") + unsafe { delete parsed; } + } +} + +def private json_array_has(arr : JsonValue?; wanted : string) : bool { + if (arr == null || !(arr.value is _array)) return false + for (m in arr.value as _array) { + if (m != null && m.value is _string && (m.value as _string) == wanted) return true + } + return false +} + +def private project_root_fixture() : string { + return path_join(get_das_root(), "utils/mcp/tests/_pretend_root") +} + +//! the pretend root's second module folder: its descriptor registers the tree's UnitTest C++ module +//! and a require path of its own, and every tool asked with project_root sees both - as does a plain run +[test] +def test_project_root_fixture(t : T?) { + let root = project_root_fixture() + let script = path_join(root, "probe_consumer.das") + t |> run("list_modules with project_root lists the descriptor's C++ module and require path") <| @(t : T?) { + var text : string + var is_error = false + parse_result(do_list_modules(true, root), text, is_error) + t |> success(!is_error, "should not be error: {text}") + var err : string + var parsed = read_json(text, err) + t |> success(parsed != null, "json parses: {err}") + return if (parsed == null) + t |> success(json_array_has(parsed?["builtin"], "UnitTest"), "the C++ module the descriptor registered is a builtin") + t |> success(json_array_has(parsed?["registered"], "unit_probe/hello"), "the require path the descriptor registered is listed") + unsafe { delete parsed; } + } + t |> run("find_symbol with project_root finds a symbol from the project's own require path") <| @(t : T?) { + var text : string + var is_error = false + parse_result(do_find_symbol("probe_hello_value", "", script, "", root), text, is_error) + t |> success(!is_error, "should not be error: {text}") + t |> success(find(text, "probe_hello_value") >= 0, "should find the project's function") + } + t |> run("compile_check with project_root compiles a file requiring both") <| @(t : T?) { + var text : string + var is_error = false + parse_result(do_compile_check(script, "", root), text, is_error) + t |> success(!is_error, "should not be error: {text}") + t |> success(find(text, "Compilation OK") >= 0, "should compile: {text}") + } + t |> run("a plain run and an eager run of the file both reach the C++ module and the require path") <| @(t : T?) { + let exe = get_daslang_exe() + t |> success(!empty(exe), "daslang executable is known") + return if (empty(exe)) + var plain_out : string + let plain_rc = run_and_capture([exe, "-project_root", root, script], plain_out, 120.0) + t |> equal(plain_rc, 0, "plain run exits 0: {plain_out}") + t |> success(find(plain_out, "probe 4242 1234") >= 0, "plain run prints the probe: {plain_out}") + var eager_out : string + let eager_rc = run_and_capture([exe, "-ignore-manifest", "-project_root", root, script], eager_out, 120.0) + t |> equal(eager_rc, 0, "eager run exits 0: {eager_out}") + t |> success(find(eager_out, "probe 4242 1234") >= 0, "eager run prints the probe: {eager_out}") + } } diff --git a/utils/mcp/tests/_pretend_root/modules/dasUnitTest/.das_module b/utils/mcp/tests/_pretend_root/modules/dasUnitTest/.das_module new file mode 100644 index 0000000000..279f5790f3 --- /dev/null +++ b/utils/mcp/tests/_pretend_root/modules/dasUnitTest/.das_module @@ -0,0 +1,12 @@ +options gen2 +require daslib/fio + +//! a project's own module folder: shadows the tree's dasUnitTest by basename, registers the tree's +//! UnitTest C++ module, and adds a require path of its own (unit_probe/hello) +[export] +def initialize(project_path : string) { + if (das_is_dll_build()) { + register_dynamic_module("{get_das_root()}/modules/dasUnitTest/dasModuleUnitTest.shared_module", "Module_UnitTest") + } + register_native_path("unit_probe", "hello", "{project_path}/daslib/hello.das") +} diff --git a/utils/mcp/tests/_pretend_root/modules/dasUnitTest/daslib/hello.das b/utils/mcp/tests/_pretend_root/modules/dasUnitTest/daslib/hello.das new file mode 100644 index 0000000000..e1eff1e501 --- /dev/null +++ b/utils/mcp/tests/_pretend_root/modules/dasUnitTest/daslib/hello.das @@ -0,0 +1,7 @@ +options gen2 + +module hello shared public + +def probe_hello_value() : int { + return 4242 +} diff --git a/utils/mcp/tests/_pretend_root/probe_consumer.das b/utils/mcp/tests/_pretend_root/probe_consumer.das new file mode 100644 index 0000000000..b1df2395d8 --- /dev/null +++ b/utils/mcp/tests/_pretend_root/probe_consumer.das @@ -0,0 +1,11 @@ +options gen2 + +require UnitTest +require unit_probe/hello + +[export] +def main() { + var foo = makeDummy() + testFoo(foo) + print("probe {probe_hello_value()} {foo.fooData}\n") +} diff --git a/utils/mcp/tools/common.das b/utils/mcp/tools/common.das index c6a9072ec4..983aa0f4b3 100644 --- a/utils/mcp/tools/common.das +++ b/utils/mcp/tools/common.das @@ -460,8 +460,12 @@ def subtool_user_args(args : array) : array { def public build_subtool_argv(exe, subtool_path : string; args : array; project_root : string; - load_modules : array = array()) : array { + load_modules : array = array(); + eager : bool = false) : array { var argv <- [exe] + if (eager) { + argv |> push("-ignore-manifest") + } if (!empty(project_root)) { argv |> push("-project_root") argv |> push(project_root) @@ -480,11 +484,12 @@ def public build_subtool_argv(exe, subtool_path : string; def run_mcp_subtool(subtool_name : string; args : array; project_root : string = ""; load_modules : array = array(); - timeout_sec : float = 120.0) : string { + timeout_sec : float = 120.0; + eager : bool = false) : string { let exe = get_daslang_exe() if (empty(exe)) return make_tool_result("Cannot determine daslang executable path", true) let subtool_path = path_join(get_das_root(), "utils/mcp/subtools/{subtool_name}.das") - let argv <- build_subtool_argv(exe, subtool_path, args, project_root, load_modules) + let argv <- build_subtool_argv(exe, subtool_path, args, project_root, load_modules, eager) var output : string let exit_code = run_and_capture(argv, output, timeout_sec) if (exit_code == popen_timed_out) return make_tool_result("MCP subtool '{subtool_name}' timed out after {timeout_sec}s:\n{output}", true) diff --git a/utils/mcp/tools/list_modules.das b/utils/mcp/tools/list_modules.das index f46bad983a..43e074d6ec 100644 --- a/utils/mcp/tools/list_modules.das +++ b/utils/mcp/tools/list_modules.das @@ -4,27 +4,37 @@ options rtti require common public require daslib/json_boost +//! the C++ modules the process registered, the daslib files under the das root, and the require +//! paths descriptors registered (`module/path`, one per `register_native_path` row) struct ModuleList { builtin : array daslib : array + registered : array } -def do_list_modules(json : bool = false) : string { +//! a module with a file name was compiled from das - the server's own program, a descriptor's +//! helper - and is not a C++ module the process offers +def list_modules_here(json : bool = false) : string { var builtin_mods : array program_for_each_registered_module() $(mod) { let mname = string(mod.name) - if (mname == "$" || mname == "__main__" || empty(mname)) return + if (mname == "$" || mname == "__main__" || empty(mname) || !empty(mod.fileName)) return builtin_mods |> push(mname) } sort(builtin_mods) var daslib_mods : array - dir("daslib") $(fname) { + dir(path_join(get_das_root(), "daslib")) $(fname) { if (!ends_with(fname, ".das")) return daslib_mods |> push(slice(fname, 0, length(fname) - 4)) } sort(daslib_mods) + var registered : array + for_each_registered_native_path() $(mod_name, src_path, _dst_path) { + registered |> push("{mod_name}/{src_path}") + } + sort(registered) if (json) { - let result = ModuleList(builtin <- builtin_mods, daslib <- daslib_mods) + let result = ModuleList(builtin <- builtin_mods, daslib <- daslib_mods, registered <- registered) return make_tool_result(sprint_json(result, false)) } return make_tool_result(build_string() $(var writer) { @@ -36,5 +46,16 @@ def do_list_modules(json : bool = false) : string { for (m in daslib_mods) { write(writer, " daslib/{m}\n") } + write(writer, "Registered require paths ({length(registered)}):\n") + for (m in registered) { + write(writer, " {m}\n") + } }) } + +//! with a project root or extra modules the listing comes from a child that scanned them, eager, +//! since a deferred C++ module is not a registered one until something requires it +def do_list_modules(json : bool = false; project_root : string = ""; load_modules : array = array()) : string { + if (empty(project_root) && empty(load_modules)) return list_modules_here(json) + return run_mcp_subtool("list_modules", [json ? "true" : "false"], project_root, load_modules, eager = true) +} From eb8b8161e49f04c405f9e1de964607134bc23ea6 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Wed, 9 Sep 2026 22:40:03 -0700 Subject: [PATCH 2/2] the watchdog gains --stdio, the front the MCP client spawns: this process is the client's newline-delimited JSON-RPC pipe and the child is the server - initialize and ping answered locally so the client connects before any child exists, the first tools request spawning the child, in --cwd, with the client's initialize replayed, one request forwarded at a time, a child that died before delivery respawned with the request re-sent once and one that died while answering reported instead, since a re-sent tool call could run twice; the child's non-JSON stdout is logged as child_noise and carried in that error; the log goes to its file only, since stdout is the protocol, and its child_started and child_exited spell their keys as the supervisor's do; one child lifetime per tick, so the exe host's loop and the interpreter host's loop both serve it; mcp_supervisor.py goes, and setup.das writes .mcp.json itself - the watchdog exe beside the binary when built, the interpreter host otherwise, the vcvars launcher as the child on Windows - preserving every other server, the entry's defer_loading and the das-herd shim, and moving the old file aside until the new one is in place; the utils/REVIEW.das ban on an exe form of mcp and lsp stays, since both still run interpreted under a supervisor, and says so; utils/watchdog/REVIEW.das is a new gate holding the README's event list to what the folder emits; stdio_front.das joins the watchdog's install list; tests/watchdog/test_stdio_front.das drives a scripted session through both hosts from a directory that is not the tree, the server's own initialize result as the front's, a tool catalog longer than one fgets chunk, the server's shutdown tool as the death and the next call as the respawn; utils/mcp/test_setup.das drives the .mcp.json rewrite into scratch roots; the LSP endpoint's port to the same shape is ledgered in utils/lsp/ROADMAP.md with the protocol test as its acceptance test --- .codex/config.toml.example | 8 +- CMakeLists.txt | 7 +- ci/smoke_test_bundle.sh | 6 +- skills/mcp_tools.md | 4 +- tests/README.md | 16 ++ tests/watchdog/test_stdio_front.das | 210 ++++++++++++++ utils/CMakeLists.txt | 4 +- utils/REVIEW.das | 10 +- utils/lsp/ROADMAP.md | 13 +- utils/mcp/README.md | 15 +- utils/mcp/REVIEW.md | 10 +- utils/mcp/ROADMAP.md | 13 +- utils/mcp/mcp_supervisor.py | 432 ---------------------------- utils/mcp/setup.das | 133 +++++++-- utils/mcp/test_setup.das | 130 +++++++++ utils/watchdog/README.md | 34 ++- utils/watchdog/REVIEW.das | 78 +++++ utils/watchdog/REVIEW.md | 25 +- utils/watchdog/main.das | 40 ++- utils/watchdog/stdio_front.das | 290 +++++++++++++++++++ utils/watchdog/watchdog.das | 2 + 21 files changed, 970 insertions(+), 510 deletions(-) create mode 100644 tests/watchdog/test_stdio_front.das delete mode 100644 utils/mcp/mcp_supervisor.py create mode 100644 utils/mcp/test_setup.das create mode 100644 utils/watchdog/REVIEW.das create mode 100644 utils/watchdog/stdio_front.das diff --git a/.codex/config.toml.example b/.codex/config.toml.example index 6ae31ef09b..3e7d87030f 100644 --- a/.codex/config.toml.example +++ b/.codex/config.toml.example @@ -1,11 +1,13 @@ # 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. +# The MCP commands and working directory are relative to this checkout. bin/watchdog is built +# by the default target; the same flags run through `bin/daslang utils/watchdog/main.das --` +# where it is not. [mcp_servers.daslang] -command = "python3" -args = ["utils/mcp/mcp_supervisor.py", "--repo-root", "."] +command = "bin/watchdog" +args = ["--stdio", "--name", "daslang-mcp", "--cwd", ".", "--program", "bin/daslang", "--", "-ignore-manifest", "utils/mcp/main.das"] cwd = "." enabled = true required = true diff --git a/CMakeLists.txt b/CMakeLists.txt index 4aed71ff86..c7cd1b0677 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1979,7 +1979,6 @@ install(DIRECTORY ${PROJECT_SOURCE_DIR}/utils/gen1-to-gen2/ # setup.das — bootstrap for a fresh tree; the installed README and the # shipped skills/mcp_tools.md both document running it, so it # must ship (it was missing through 0.6.4) -# mcp_supervisor.py — the stdio supervisor setup.das writes into .mcp.json install(FILES ${PROJECT_SOURCE_DIR}/utils/mcp/main.das ${PROJECT_SOURCE_DIR}/utils/mcp/cpp_main.das @@ -1989,7 +1988,6 @@ install(FILES ${PROJECT_SOURCE_DIR}/utils/mcp/registry_cpp.das ${PROJECT_SOURCE_DIR}/utils/mcp/cpp_search_config.das ${PROJECT_SOURCE_DIR}/utils/mcp/setup.das - ${PROJECT_SOURCE_DIR}/utils/mcp/mcp_supervisor.py ${PROJECT_SOURCE_DIR}/utils/mcp/daslang-mcp-msvc.cmd ${PROJECT_SOURCE_DIR}/utils/mcp/README.md DESTINATION utils/mcp @@ -2002,8 +2000,8 @@ install(FILES ${PROJECT_SOURCE_DIR}/utils/mcp/test_tools.das DESTINATION utils/mcp ) -# The installed test_tools.das drives two non-.das fixtures — _fixture_cpp_outline.h -# (cpp_outline) and _pretend_root/modules/pretend_mod/.das_module (project_root) — so a +# The installed test_tools.das drives non-.das fixtures — _fixture_cpp_outline.h +# (cpp_outline) and the _pretend_root/modules/*/.das_module descriptors (project_root) — so a # bare "*.das" filter ships the suite in a state where those cases cannot run. install(DIRECTORY ${PROJECT_SOURCE_DIR}/utils/mcp/tests/ DESTINATION utils/mcp/tests @@ -2154,6 +2152,7 @@ install(FILES ${PROJECT_SOURCE_DIR}/utils/dasllama-convert/main.das # beside daslang from utils/CMakeLists.txt, these are the library and the interpreter entry) install(FILES ${PROJECT_SOURCE_DIR}/utils/watchdog/watchdog.das + ${PROJECT_SOURCE_DIR}/utils/watchdog/stdio_front.das ${PROJECT_SOURCE_DIR}/utils/watchdog/main.das ${PROJECT_SOURCE_DIR}/utils/watchdog/README.md DESTINATION utils/watchdog diff --git a/ci/smoke_test_bundle.sh b/ci/smoke_test_bundle.sh index 88811fde1a..4be8841193 100644 --- a/ci/smoke_test_bundle.sh +++ b/ci/smoke_test_bundle.sh @@ -296,12 +296,12 @@ fi # tutorial/scaffold invoking a tool the bundle does not carry (found live: the # AOT integration scaffolds). skills/ is excluded here: its own gate above owns # skills content, with repo-only marker semantics this raw grep cannot honor. -# mcp_supervisor.py is excluded: it PROBES for the in-repo das-herd behind an -# exists-check, so the literal is functional and inert in a bundle. +# setup.das is excluded: it PROBES for the in-repo das-herd behind an exists-check, so +# the literal is functional and inert in a bundle. # CHANGELIST.md is excluded: release history legitimately NAMES the utils/internal # split; prose there is documentation, not a reference that can dangle. printf ' %-30s ' "no utils/internal references" -INTERNAL_REFS="$(grep -rIl 'utils/internal' "$BUNDLE" --exclude-dir=skills --exclude=mcp_supervisor.py --exclude=CHANGELIST.md 2>/dev/null || true)" +INTERNAL_REFS="$(grep -rIl 'utils/internal' "$BUNDLE" --exclude-dir=skills --exclude=setup.das --exclude=CHANGELIST.md 2>/dev/null || true)" if [[ -z "$INTERNAL_REFS" ]]; then echo "OK" PASS=$((PASS + 1)) diff --git a/skills/mcp_tools.md b/skills/mcp_tools.md index 66bf1f52e3..388b9f2ca2 100644 --- a/skills/mcp_tools.md +++ b/skills/mcp_tools.md @@ -70,9 +70,9 @@ The daslang MCP server (`utils/mcp/main.das`) exposes compiler diagnostics, prog **Live tools.** `live_*` interact with a running `daslang-live` instance via its REST API. `live_launch` starts one if not already running (sets working directory to the script's folder). All live tools accept an optional `port` parameter (default 9090). When a compilation error is active, `live_command` and `live_pause` return HTTP 503 with the error - use `live_reload` to fix. Hitting any unknown endpoint returns JSON help with all endpoints + curl examples. -**`shutdown` tool.** Shuts down the MCP server process. Claude Code auto-restarts it, picking up code changes to `.das` tool files. Tool registration changes (adding/removing tools) still require a manual MCP restart. +**`shutdown` tool.** Shuts down the MCP server process. Under the stdio front the next `tools/*` call respawns it; in the bare form Claude Code auto-restarts it. Either way the new process picks up code changes to `.das` tool files, while tool registration changes (adding/removing tools) still require a manual MCP restart. -**Configuration.** Configure `.mcp.json` with `"command"` pointing at the daslang binary (`bin/daslang` on Windows MSVC, `build/daslang` on Linux/macOS, `bin/daslang` for the installed SDK), `"args": ["-ignore-manifest", "utils/mcp/main.das"]` (the flag loads every C++ module on start; the server enumerates them). See `utils/mcp/README.md` for details and Claude Code permissions. +**Configuration.** `.mcp.json` names the watchdog's stdio front as the `daslang` server. `"command"` is the `watchdog` binary beside the daslang binary (`bin/watchdog`; `bin\watchdog.exe` on Windows); where it is not built, `"command"` is the daslang binary and `"args"` open with `utils/watchdog/main.das --`. The args are `--stdio --name daslang-mcp --cwd --program -- -ignore-manifest utils/mcp/main.das` (the flag loads every C++ module on start; the server enumerates them); on Windows `--program` is `%SystemRoot%\System32\cmd.exe` with `-- /c /utils/mcp/daslang-mcp-msvc.cmd`, so the server runs under the vcvars launcher and `cpp_compile_check` finds `cl.exe`. The front answers `initialize` and `ping` itself and spawns the server on the first `tools/*` call, so the client connects before any child exists; when the child dies - a kill, a crash, the `shutdown` tool - the next call respawns it. `utils/mcp/setup.das` writes the entry; the bare form - `"command"` on the daslang binary, `"args": ["-ignore-manifest", "utils/mcp/main.das"]` - works, without the respawn. See `utils/mcp/README.md` for details and Claude Code permissions. **Fresh checkouts / worktrees.** `.mcp.json`, `sgconfig.yml`, `bin/`, and the tree-sitter grammar lib are all gitignored, so a new `git worktree add` (or clone) has no daslang MCP at all. Bootstrap it with `daslang utils/mcp/setup.das -- --root ` - it configures `build/` on the cmake generator of the tree running the setup (platform default when that tree has no `build/CMakeCache.txt`), builds a worktree-local binary (+ grammar), copies the platform `sgconfig.yml`, and merges a `daslang` entry into `.mcp.json` (adds no new secrets; existing servers, including any secret env blocks, are preserved as-is). `--no-build` skips the build. Restart the session to pick it up. diff --git a/tests/README.md b/tests/README.md index 050b532062..c081b3ea82 100644 --- a/tests/README.md +++ b/tests/README.md @@ -848,6 +848,14 @@ Coverage of per-iteration `finally` semantics across every loop form. Each cell |---|---|---| | test_pipes.das | lpipe macro - pipe into function calls, chain operators | | +## lsp/ + +| File | Description | Expects errors | +|---|---|---| +| _fixture_clean.das | *(helper)* the clean disk file the protocol test opens with broken buffer text | | +| test_lsp_project_root.das | the validate subtool under -project_root sees a project's own descriptors | | +| test_lsp_protocol.das | the LSP server over a stdio pipe - handshake, overlay diagnostics, navigation, shutdown | | + ## match/ | File | Description | Expects errors | @@ -1116,6 +1124,14 @@ Coverage of per-iteration `finally` semantics across every loop form. Each cell |---|---|---| | test_uri.das | URI parsing, normalize, rebase, query params, edge cases | | +## watchdog/ + +| File | Description | Expects errors | +|---|---|---| +| _fixture_watchdog_child.das | *(helper)* the supervised child - a run counter and a mode pick the story it acts out | | +| test_stdio_front.das | the --stdio front through both hosts - the server's own initialize result, local ping, lazy child, respawn after the server's shutdown | | +| test_watchdog.das | the supervisor - restart backoff, exit-code policy, stages, crash bundles, the stop ladder, the tray | | + ## verify/ | File | Description | Expects errors | diff --git a/tests/watchdog/test_stdio_front.das b/tests/watchdog/test_stdio_front.das new file mode 100644 index 0000000000..b62e118877 --- /dev/null +++ b/tests/watchdog/test_stdio_front.das @@ -0,0 +1,210 @@ +options gen2 +options no_aot +options no_unused_block_arguments = false + +require dastest/testing_boost public +require daslib/fio +require daslib/json_boost +require daslib/command_line +require strings + +//! the stdio front over a pipe, through both hosts where the exe is built: `initialize` and `ping` +//! are answered before any child exists, the first tool call spawns the daslang MCP server, the +//! server's own `shutdown` tool kills it, and the next tool call respawns it unseen + +def private das_exe() : string { + return get_full_file_name(get_das_exe()) +} + +def private watchdog_exe() : string { + let beside = path_join(dir_name(das_exe()), "watchdog{get_platform_name() == "windows" ? ".exe" : ""}") + return fexist(beside) ? beside : "" +} + +def private make_temp(t : T?) : string { + let tmp = create_temp_directory_result("das_stdio_front") + if (!(tmp is value)) { + t |> failure("could not create temp directory: {tmp as error}") + return "" + } + return tmp as value +} + +//! the front's argv after the host: the daslang MCP server as the program, eager like .mcp.json +def private front_args(root, log : string) : array { + return <- ["--stdio", "--name", "wdstdio", "--cwd", root, "--log", log, + "--program", das_exe(), "--", "-ignore-manifest", "utils/mcp/main.das"] +} + +def private write_line(w : file; line : string) { + fprint(w, line) + fprint(w, "\n") + fflush(w) +} + +//! one line from the front, however many fgets chunks it spans; empty on EOF +def private read_reply_line(r : file) : string { + return build_string() $(var w) { + while (!feof(r)) { + let chunk = fgets(r) + let len = length(chunk) + if (len == 0) break + write(w, chunk) + if (character_at(chunk, len - 1) == '\n') break // nolint:PERF003 + } + } +} + +//! one JSON line from the front; null on EOF +def private read_reply(r : file) : JsonValue? { + let line = read_reply_line(r) + return null if (empty(line)) + var err : string + return read_json(line, err) +} + +def private tool_text(js : JsonValue?) : string { + let text = js?["result"]?["content"]?[0]?["text"] + return text != null && text.value is _string ? text.value as _string : "" +} + +def private id_of(js : JsonValue?) : int { + let id = js?["id"] + return id != null && id.value is _longint ? int(id.value as _longint) : -1 +} + +def private count_event(log_path, event : string) : int { + var count = 0 + fopen(log_path, "rb") $(f) { + return if (f == null) + while (!feof(f)) { + let line = fgets(f) + break if (empty(line)) + if (find(line, "\"event\": \"{event}\"") >= 0 || find(line, "\"event\":\"{event}\"") >= 0) { + count++ + } + } + } + return count +} + +//! the front is spawned from the temp directory, as an MCP client spawns it from wherever it +//! runs: `--cwd` is what makes the server's relative `utils/mcp/main.das` reach the tree +def private session(t : T?; host : array; root : string) { + let log = path_join(root, "front.log") + var argv := host + argv |> push_from(front_args(get_das_root(), log)) + let here = getcwd() + t |> success(chdir(root), "entered the temp directory before spawning the front") + var rc : int + unsafe { + rc = popen_argv_pipe(argv) $(w, r) { + write_line(w, "\{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":\{\"protocolVersion\":\"2025-11-25\",\"capabilities\":\{\},\"clientInfo\":\{\"name\":\"dastest\",\"version\":\"0\"\}\}\}") + var init = read_reply(r) + t |> success(init != null && id_of(init) == 1, "initialize is answered") + let version = init?["result"]?["protocolVersion"] + t |> success(version != null && version.value is _string && (version.value as _string) == "2025-11-25", "the server's protocol version") + let name = init?["result"]?["serverInfo"]?["name"] + t |> success(name != null && name.value is _string && (name.value as _string) == "daslang", "the server's name") + t |> equal(count_event(log, "child_started"), 0, "no child was spawned to answer initialize") + unsafe { + delete init + } + write_line(w, "\{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"\}") + write_line(w, "\{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"ping\"\}") + var pong = read_reply(r) + t |> success(pong != null && id_of(pong) == 2 && pong?["result"] != null, "ping is answered locally") + t |> equal(count_event(log, "child_started"), 0, "no child was spawned to answer ping") + unsafe { + delete pong + } + write_line(w, "\{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":\{\"name\":\"list_modules\",\"arguments\":\{\}\}\}") + var listing = read_reply(r) + t |> success(listing != null && id_of(listing) == 3, "the first tool call is answered") + t |> success(find(tool_text(listing), "daslib/json") >= 0, "the child served list_modules") + t |> equal(count_event(log, "child_started"), 1, "the first tool call spawned the child") + unsafe { + delete listing + } + write_line(w, "\{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/list\"\}") + let catalog_line = read_reply_line(r) + t |> success(length(catalog_line) > 16384, "the tool catalog is longer than one fgets chunk, so it crossed the front's line reader in pieces") + var err : string + var catalog = read_json(catalog_line, err) + t |> success(catalog != null && id_of(catalog) == 7, "the catalog line is one whole JSON message: {err}") + let tools = catalog?["result"]?["tools"] + t |> success(tools != null && tools.value is _array && length(tools.value as _array) > 10, "the child's catalog came through the front") + unsafe { + delete catalog + } + write_line(w, "\{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":\{\"name\":\"shutdown\",\"arguments\":\{\}\}\}") + var gone = read_reply(r) + t |> success(gone != null && id_of(gone) == 4, "the shutdown call gets an answer, result or death report") + unsafe { + delete gone + } + write_line(w, "\{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":\{\"name\":\"list_modules\",\"arguments\":\{\"json\":\"true\"\}\}\}") + var again = read_reply(r) + t |> success(again != null && id_of(again) == 5, "the call after the shutdown is answered") + t |> success(find(tool_text(again), "\"daslib\"") >= 0, "a respawned child served it") + t |> equal(count_event(log, "child_started"), 2, "the call after the shutdown spawned a second child") + unsafe { + delete again + } + write_line(w, "\{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"resources/list\"\}") + var refused = read_reply(r) + t |> success(refused != null && id_of(refused) == 6 && refused?["error"] != null, "a method the server has no answer for is refused without a child") + unsafe { + delete refused + } + } + } + chdir(here) + t |> equal(rc, 0, "the front exits 0 when the client closes its input") +} + +//! the `result` of one `initialize` from a program spawned with `argv`, compact; empty on no reply +def private initialize_result(argv : array) : string { + var text : string + unsafe { + popen_argv_pipe(argv) $(w, r) { + write_line(w, "\{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":\{\"protocolVersion\":\"2025-11-25\",\"capabilities\":\{\},\"clientInfo\":\{\"name\":\"dastest\",\"version\":\"0\"\}\}\}") + var reply = read_reply(r) + let result = reply?["result"] + text = result != null ? write_json_compact(result) : "" + unsafe { + delete reply + } + } + } + return text +} + +[test] +def test_stdio_front(t : T?) { + t |> run("the front's handshake is the server's") @(t : T?) { + let root = make_temp(t) + return if (empty(root)) + let server = initialize_result([das_exe(), "-ignore-manifest", path_join(get_das_root(), "utils/mcp/main.das")]) + var front_argv <- [das_exe(), path_join(get_das_root(), "utils/watchdog/main.das"), "--"] + front_argv |> push_from(front_args(get_das_root(), path_join(root, "front.log"))) + let front = initialize_result(front_argv) + t |> success(!empty(server), "the server answered initialize") + t |> equal(front, server, "the front answers initialize with the server's own result") + } + t |> run("through the interpreter host") @(t : T?) { + let root = make_temp(t) + return if (empty(root)) + session(t, [das_exe(), path_join(get_das_root(), "utils/watchdog/main.das"), "--"], root) + } + t |> run("through the static exe") @(t : T?) { + let exe = watchdog_exe() + if (empty(exe)) { + t |> skip("bin/watchdog is not built here") + return + } + let root = make_temp(t) + return if (empty(root)) + session(t, [exe], root) + } +} diff --git a/utils/CMakeLists.txt b/utils/CMakeLists.txt index 6534ff120f..e4b58bc4fe 100644 --- a/utils/CMakeLists.txt +++ b/utils/CMakeLists.txt @@ -94,8 +94,8 @@ elseif(DAS_USE_SANITIZER STREQUAL "thread" OR DAS_USE_SANITIZER STREQUAL "tsan") endif() # Entries are paths relative to utils/; the exe/target name is the basename. -# mcp/lsp never build as exe: their python keep-alive dev setup means an exe -# form would never be dogfooded. +# mcp/lsp never build as exe: their development runs interpreted under a supervisor +# (the watchdog front, lsp_supervisor.py), so an exe form would never be dogfooded. set(DAS_UTILS aot dascov diff --git a/utils/REVIEW.das b/utils/REVIEW.das index 27a49b649e..ce71f51606 100644 --- a/utils/REVIEW.das +++ b/utils/REVIEW.das @@ -13,8 +13,9 @@ require dastest/review_gate // utils/ children that are not tool directories: the shared library hub var private LIBRARY_DIRS <- { "common" } -// tools whose development runs through a python keep-alive supervisor: never built or -// shipped as an exe, since that form would ship without anyone having run it +// tools whose development runs interpreted under a supervisor - the watchdog front for mcp, +// lsp_supervisor.py for lsp: never built or shipped as an exe, since that form would ship +// without anyone having run it. An entry leaves when the supervisor's --program names the exe. var private SUPERVISED_TOOLS <- { "mcp", "lsp" } // Files licensed to name utils/internal outside internal/: @@ -22,7 +23,8 @@ var private INTERNAL_REF_EXEMPT <- { "utils/REVIEW.md", // states the rule "utils/REVIEW.das", // carries the needle "utils/CMakeLists.txt", // builds and CI-tests internal tools, never ships - "utils/mcp/mcp_supervisor.py" // detects the internal das-herd entry to refuse it; same exclusion as ci/smoke_test_bundle.sh + "utils/mcp/setup.das", // wires the internal das-herd shim behind an exists-check; same exclusion as ci/smoke_test_bundle.sh + "utils/mcp/test_setup.das" // plants that shim in a scratch root to drive the exists-check; not installed } // Extensions that can carry a reference by require, include, or command line. @@ -178,7 +180,7 @@ def private check_supervised_tools { hit ||= base_name(b) == s } if (hit) { - gate_finding("utils/CMakeLists.txt", "{s} is in DAS_UTILS or DAS_UTILS_SHIPPED_EXES — its development runs through the python keep-alive supervisor, so an exe form ships without anyone having run it; run it interpreted") + gate_finding("utils/CMakeLists.txt", "{s} is in DAS_UTILS or DAS_UTILS_SHIPPED_EXES — its development runs interpreted under a supervisor, so an exe form ships without anyone having run it; point the supervisor's --program at the exe first") } } } diff --git a/utils/lsp/ROADMAP.md b/utils/lsp/ROADMAP.md index 3272db45ca..964141742c 100644 --- a/utils/lsp/ROADMAP.md +++ b/utils/lsp/ROADMAP.md @@ -25,7 +25,7 @@ subtool pattern, `utils/mcp/tools/common.das`): edited file sees stale macros. Fresh process = fresh state, by construction. - **Binary/DLL locks**: no resident daslang means `bin/daslang` and the `.shared_module` DLLs are never held between requests - builds never block, no - kill-before-rebuild guard, no respawn/replay machinery (cf. `utils/mcp/mcp_supervisor.py`, + kill-before-rebuild guard, no respawn/replay machinery (cf. the watchdog's `--stdio` front, which exists precisely because the MCP das child *is* resident). - **Crash isolation**: a compiler crash on a broken buffer costs one request, not the session. - **Cost**: every request pays a compile (~0.2-1 s) - the same profile as the MCP tools, @@ -60,7 +60,7 @@ unlike `.mcp.json`). The vehicle is one checked-in manifest: ``` Loads on workspace trust; `--plugin-dir` for development. The supervisor locates the -daslang binary like `mcp_supervisor.py::_default_launcher` (bin/Release -> bin -> build). +daslang binary like `utils/mcp/setup.das::locate_binary` (bin/Release -> bin -> build). Claude Code consumes: publish-diagnostics (auto-injected after edits), definition, references, hover, documentSymbol, workspaceSymbol, implementation, call hierarchy. @@ -298,9 +298,12 @@ PR for the whole branch AFTER wave 4 (single preflight + CI round). ## Follow-ups -- The watchdog does not supervise `lsp_supervisor.py` yet; wire it in. With that, an exe form - of the subtools becomes possible again - the same item as the MCP server's - (`utils/mcp/ROADMAP.md`, Follow-ups). +- **Port `lsp_supervisor.py` to das and ship it the watchdog's way** - a `-ctx` static exe that + compiles nothing at run time, so it holds no lock a build replaces and needs no Python on the + box; the MCP side already runs so, as the watchdog's `--stdio` front. The endpoint is framing, + the initialize handshake, the document shadow, debounce and dispatch to the stateless subtools - + `lsp_supervisor.py` is the spec, `tests/lsp/test_lsp_protocol.das` drives it over a pipe end to + end and is the acceptance test. The plugin manifest then names the exe instead of `python3`. ## Non-goals diff --git a/utils/mcp/README.md b/utils/mcp/README.md index bfd1b7046f..f1f7e4f42b 100644 --- a/utils/mcp/README.md +++ b/utils/mcp/README.md @@ -67,16 +67,19 @@ The server has two entry points over the same dispatch core (the provider-neutra - **`main.das`** - the full tool set (everything above). - **`cpp_main.das`** - only the cpp/agnostic subset: `grep_usage`, `outline`, the seven `cpp_*` tools, and `shutdown`. None of the daslang compiler-backed tools (compile / lint / AOT / introspection / live-reload) are registered, so a C++-only project gets a focused tool list without the daslang toolchain. -Register one or both. On **Windows** the same launcher serves both - the server script is the launcher's first argument: +Register one or both. `utils/mcp/setup.das` writes the `daslang` entry as the watchdog's stdio front over the tree's own binary - the front answers `initialize` itself, spawns the server on the first tool call, and respawns it after a kill or a rebuild, so a session never loses its tools (`utils/watchdog/README.md`, "Serving a stdio client"): ```json "mcpServers": { - "daslang": { "command": "cmd", "args": ["/c", "utils\\mcp\\daslang-mcp-msvc.cmd"], "defer_loading": false }, - "daslang-cpp": { "command": "cmd", "args": ["/c", "utils\\mcp\\daslang-mcp-msvc.cmd", "cpp_main.das"], "defer_loading": false } + "daslang": { "command": "/bin/watchdog", + "args": ["--stdio", "--name", "daslang-mcp", "--cwd", "", + "--program", "/bin/daslang", "--", "-ignore-manifest", "utils/mcp/main.das"] } } ``` -On **Linux/macOS** point each entry at the binary directly (no launcher needed): +On **Windows** the child is the vcvars launcher, so `cpp_compile_check` finds `cl.exe`: `"--program", "C:\\Windows\\System32\\cmd.exe", "--", "/c", "\\utils\\mcp\\daslang-mcp-msvc.cmd"`; the launcher's first argument selects the server script, so the C++-only server is the same line with `cpp_main.das` appended. A tree without the watchdog built runs the front through the interpreter: `"command"` is the binary and `"args"` start with `"utils/watchdog/main.das", "--"`. + +The bare form still works, minus the respawn - point the entry at the binary directly: ```json "mcpServers": { @@ -85,9 +88,9 @@ On **Linux/macOS** point each entry at the binary directly (no launcher needed): } ``` -An existing `.mcp.json` needs `-ignore-manifest` added by hand (or a rerun of `utils/mcp/setup.das`): without it the server enumerates only the modules a compile loaded, so `list_modules` and the all-modules symbol scans come up short. +Either way the server needs `-ignore-manifest`: without it the server enumerates only the modules a compile loaded, so `list_modules` and the all-modules symbol scans come up short. -Tools are namespaced by server, so the cpp server's tools appear as `mcp__daslang-cpp__cpp_compile_check` etc. `cpp-mcp` - a standalone static AOT build of `cpp_main.das` for C++-only consumers - exists as a gated target (`DAS_BUILD_CPP_MCP`, OFF by default; bundled by `ci/make_cpp_mcp_bundle.sh`, released via `cpp_mcp_release.yml`, setup in `cpp-mcp-setup.md`); the interpreted form above is the same server. It is a separate product: the mcp server itself never ships as a `daslang -exe` binary - development runs it through the python keep-alive supervisor, so that exe form would never be dogfooded. +Tools are namespaced by server, so the cpp server's tools appear as `mcp__daslang-cpp__cpp_compile_check` etc. `cpp-mcp` - a standalone static AOT build of `cpp_main.das` for C++-only consumers - exists as a gated target (`DAS_BUILD_CPP_MCP`, OFF by default; bundled by `ci/make_cpp_mcp_bundle.sh`, released via `cpp_mcp_release.yml`, setup in `cpp-mcp-setup.md`); the interpreted form above is the same server. It is a separate product: the mcp server itself never ships as a `daslang -exe` binary - development runs it interpreted under the watchdog front, so that exe form would never be dogfooded; `utils/REVIEW.das` refuses the target until the front's `--program` names the exe. ### Duplicate Detection diff --git a/utils/mcp/REVIEW.md b/utils/mcp/REVIEW.md index 877cc1839f..1e6aa8111b 100644 --- a/utils/mcp/REVIEW.md +++ b/utils/mcp/REVIEW.md @@ -4,11 +4,11 @@ `README.md`. Planned work: `ROADMAP.md`. **A diff that adds a top-level file under `utils/mcp/` that the shipped SDK runs or loads - -`main.das` reaches it, the supervisor or the `.cmd` launcher runs it, or it has its own `main` -that something in the shipped SDK runs - also adds it to the `install(FILES ...)` block that -lists `utils/mcp/main.das` in `CMakeLists.txt` (repo root), in the same change.** `tools/` and -`subtools/` are globbed; a top-level file left out of the list is absent in the shipped SDK -while the in-tree server keeps working. +`main.das` reaches it, the `.mcp.json` entry `setup.das` writes or the `.cmd` launcher runs +it, or it has its own `main` that something in the shipped SDK runs - also adds it to the +`install(FILES ...)` block that lists `utils/mcp/main.das` in `CMakeLists.txt` (repo root), +in the same change.** `tools/` and `subtools/` are globbed; a top-level file left out of the +list is absent in the shipped SDK while the in-tree server keeps working. **Weakening the kept-comment cases in `test_tools.das` is a defect** - they pin the formatter's kept set (the leading header block, `//!` docs, `//fmt:` directives, `nolint:` diff --git a/utils/mcp/ROADMAP.md b/utils/mcp/ROADMAP.md index 7536c9052f..0da35b812b 100644 --- a/utils/mcp/ROADMAP.md +++ b/utils/mcp/ROADMAP.md @@ -337,10 +337,9 @@ Building the foundational tools well creates a platform for everything else. ## Follow-ups -- **Back to an exe form.** `tests/_pretend_root` now carries a descriptor that registers a - C++ module and a require path, and `test_tools.das` proves `list_modules`, `find_symbol` and - `compile_check` see both under `project_root`, eager and plain. With that, the server can build and ship as an exe - again: the reason it runs interpreted - development through the python keep-alive supervisor, - so an exe would ship unrun - is met by the watchdog, which now covers what the supervisor - did. `utils/REVIEW.das` bans the exe today; lifting the ban is part of this item. -- **The watchdog does not supervise this server or `lsp_supervisor.py` yet.** Wire both in. +- **Back to an exe form.** `tests/_pretend_root` carries a descriptor that registers a C++ + module and a require path, and `test_tools.das` proves `list_modules`, `find_symbol` and + `compile_check` see both under `project_root`, eager and plain; the watchdog's `--stdio` front + is what `.mcp.json` spawns, so an exe form of the server would be the front's child and run + every session. What remains is the build: the server as a `-exe` target beside the other + shipped utilities, and `setup.das` naming it as `--program` where it is built. diff --git a/utils/mcp/mcp_supervisor.py b/utils/mcp/mcp_supervisor.py deleted file mode 100644 index 129ef08525..0000000000 --- a/utils/mcp/mcp_supervisor.py +++ /dev/null @@ -1,432 +0,0 @@ -#!/usr/bin/env python3 -"""daslang MCP stdio supervisor. - -Claude Code spawns THIS over stdio (so CC's HTTP-transport OAuth probe never -happens — see anthropics/claude-code#46879). It forwards MCP JSON-RPC to a -daslang child (spawned via daslang-mcp-msvc.cmd, which sets up vcvars), and -respawns that child on death/rebuild, replaying the MCP `initialize` handshake. - -Result: daslang can be killed (e.g. to release DLL locks before a rebuild) or -crash, and CC never sees a disconnect — CC's pipe is to this supervisor, which -does not die when the child does. The supervisor answers `initialize` and -`ping` itself and spawns the daslang child lazily on the first real `tools/*` -call, so idle keepalives during a build don't respawn a deliberately-killed -child and re-lock the DLLs. - -Per-worktree / per-session isolation is automatic: every CC session spawns its -own supervisor, which spawns that worktree's own daslang. No ports, no config. - -.mcp.json (written by --emit-config / setup.das): - "daslang": { "command": "python", "args": ["utils/mcp/mcp_supervisor.py"] } -(the command is whichever of python3/python resolves on PATH — python3 first, -since a bare python can be Python 2 — falling back to the absolute interpreter path) - -Pre-build guard: kill the daslang child (a `daslang.exe` running mcp\\main.das) -to release its DLL locks; build; the next tool call respawns the fresh binary. -The supervisor stays up throughout, so CC never disconnects. -""" -from __future__ import annotations - -import argparse -import json -import os -import shutil -import subprocess -import sys -import tempfile -import threading - -IS_WINDOWS = os.name == "nt" -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -REPO_ROOT = os.path.dirname(os.path.dirname(SCRIPT_DIR)) # utils/mcp -> utils -> repo - -# The daslang MCP server's InitializeResult is a fixed constant -# (utils/mcp/protocol_core.das: handle_initialize). Answering it here lets CC -# connect instantly while the daslang child stays unspawned until first use. -INIT_RESULT = { - "protocolVersion": "2025-11-25", - "capabilities": {"tools": {}}, - "serverInfo": {"name": "daslang", "version": "0.1.0"}, -} - -SUPERVISOR_LOG = os.path.join(tempfile.gettempdir(), "daslang_mcp_supervisor.log") - - -def _log(msg: str): - try: - with open(SUPERVISOR_LOG, "a", encoding="utf-8") as f: - f.write(msg + "\n") - except Exception: - pass - - -class ChildDead(Exception): - """The daslang child exited / its pipe broke mid-exchange.""" - - -class DaslangChild: - """Supervises the stdio daslang MCP child: lazy spawn, transparent respawn - with handshake replay, serialized forwarding.""" - - def __init__(self, launcher: list[str], stderr_log: str, cwd: str): - self.launcher = launcher - self.stderr_log = stderr_log - self.cwd = cwd - self.proc: subprocess.Popen | None = None - self.init_request: dict | None = None # cached client `initialize`, replayed on spawn - self.initialized_seen = False - self.lock = threading.RLock() - self._stderr_fh = None - - def cache_init(self, msg: dict): - with self.lock: - self.init_request = msg - - def mark_initialized(self): - with self.lock: - self.initialized_seen = True - - # ---- lifecycle ------------------------------------------------------ - def _kill_proc(self): - """Terminate (if alive), reap, and close the pipes of the current child. - Run before every respawn and on stop() so a long kill-rebuild session - never orphans a live daslang or leaks its pipe fds.""" - p = self.proc - self.proc = None - if p is None: - return - try: - if p.poll() is None: - if IS_WINDOWS: - subprocess.run(["taskkill", "/F", "/T", "/PID", str(p.pid)], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - else: - p.terminate() - try: - p.wait(timeout=10) - except subprocess.TimeoutExpired: - p.kill() # graceful terminate ignored -> force (POSIX SIGKILL) - p.wait(timeout=10) - except Exception: - pass - for pipe in (p.stdin, p.stdout): - try: - if pipe is not None: - pipe.close() - except Exception: - pass - - def _spawn_and_replay(self): - self._kill_proc() # clean up any prior child before respawning - if self._stderr_fh is None: - self._stderr_fh = open(self.stderr_log, "ab", buffering=0) - env = None - picked = _pick_binary(self.cwd) # re-resolved per spawn: a rebuild mid-session - launcher = list(self.launcher) # can change which layout is newest - if picked is not None: - # Windows goes through the vcvars .cmd, which reads this; POSIX execs the binary - # directly, so the pick has to replace argv[0] or it silently would not apply - env = dict(os.environ, DASLANG_MCP_BIN=picked) - if not IS_WINDOWS: - launcher[0] = picked - self.proc = subprocess.Popen( - launcher, - stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self._stderr_fh, - cwd=self.cwd, text=True, encoding="utf-8", errors="replace", bufsize=1, env=env, - ) - _log(f"spawned daslang child pid={self.proc.pid} bin={picked if IS_WINDOWS else launcher[0]}") - if self.init_request is not None: - self._write_line(json.dumps(self.init_request, separators=(",", ":"))) - self._read_line() # consume & discard the replayed initialize result - if self.initialized_seen: - self._write_line('{"jsonrpc":"2.0","method":"notifications/initialized"}') - - def _ensure_alive(self): - if self.proc is None or self.proc.poll() is not None: - self._spawn_and_replay() - - def _write_line(self, line: str): - try: - self.proc.stdin.write(line + "\n") - self.proc.stdin.flush() - except (BrokenPipeError, OSError) as e: - raise ChildDead(str(e)) - - def _read_line(self) -> str: - """Return the child's next JSON-RPC line. Anything else on its stdout is - DIAGNOSTIC NOISE, not a response — a daslang compile failure (stale - binary, build-id mismatch, missing shared_module) prints there, and - returning it as a frame silently corrupts the pipe and makes the client - drop the whole server. Noise is logged and skipped; if the child then - dies, it rides the ChildDead message so the client sees the real error.""" - noise: list[str] = [] - while True: - try: - line = self.proc.stdout.readline() - except (OSError, ValueError) as e: - raise ChildDead(self._with_noise(str(e), noise)) - if line == "": - raise ChildDead(self._with_noise("eof", noise)) - line = line.strip() - if not line: - continue - if line.startswith("{"): - try: - json.loads(line) - return line - except Exception: - pass - _log(f"child noise: {line[:400]}") - if len(noise) < 40: - noise.append(line) - - @staticmethod - def _with_noise(reason: str, noise: list[str]) -> str: - # cap the WHOLE message, not just the noise tail — the reason can itself be an arbitrarily - # long OSError string, and this ends up inside a JSON-RPC error the client has to render - if not noise: - return reason[:2000] - return (f"{reason}; daslang child said: " + " | ".join(noise))[:2000] - - # ---- forwarding ----------------------------------------------------- - def request(self, msg: dict) -> str: - """Forward a request (has id) and return the daslang response line. - The retry covers a child that's dead / dies *before* the request is - delivered (the build-guard case). Once the write succeeds we do NOT - re-send on a later failure — a retry could double-execute a - side-effecting tool (run_test, live_*, format_file); a child that dies - while we read the response surfaces as an error instead.""" - with self.lock: - line = json.dumps(msg, separators=(",", ":")) - try: - self._ensure_alive() - self._write_line(line) - except ChildDead: - self._spawn_and_replay() # not yet delivered -> respawn + resend once - self._write_line(line) - return self._read_line() # ChildDead here propagates (no re-send) - - def notify(self, msg: dict): - """Forward a notification (no id) to a live child; skip if dead (state - is re-established via handshake replay on the next spawn).""" - with self.lock: - if self.proc is None or self.proc.poll() is not None: - return - try: - self._write_line(json.dumps(msg, separators=(",", ":"))) - except ChildDead: - pass - - def stop(self): - with self.lock: - self._kill_proc() - if self._stderr_fh is not None: - try: - self._stderr_fh.close() - except Exception: - pass - self._stderr_fh = None # reopened on the next spawn - - -def handle(child: DaslangChild, msg: dict) -> str | None: - """Return a response line for a request, or None for a notification. - initialize/ping/notifications are answered locally; only tools/* is - forwarded (spawning the child on first use). Any other id-bearing method - gets a local method-not-found — the daslang server answers those the same - way, so we avoid spawning the child (and re-locking DLLs mid-build) for - stray resources/*/prompts/* probes.""" - method = msg.get("method") - has_id = "id" in msg - mid = msg.get("id") - - if method == "initialize": - child.cache_init(msg) # replayed to the child when it first spawns - return json.dumps({"jsonrpc": "2.0", "id": mid, "result": INIT_RESULT}) - if method in ("notifications/initialized", "initialized"): - child.mark_initialized() - child.notify(msg) - return None - if method == "ping" and has_id: - return json.dumps({"jsonrpc": "2.0", "id": mid, "result": {}}) - if not has_id: - child.notify(msg) - return None - if method is not None and method.startswith("tools/"): - return child.request(msg) # the only methods that need the compiler - return json.dumps({"jsonrpc": "2.0", "id": mid, - "error": {"code": -32601, "message": f"method not found: {method}"}}) - - -BIN_CANDIDATES = ("bin/Release/daslang", "bin/daslang", "build/daslang", "build/bin/daslang") - - -def _pick_binary(repo_root: str) -> str | None: - """The NEWEST existing daslang binary among the single/multi-config output - locations. Newest-wins, not first-wins: a box that has built both layouts - (e.g. a stale Ninja `bin/daslang.exe` beside a fresh MSVC - `bin/Release/daslang.exe`) otherwise runs the stale one, whose DAS_BUILD_ID - no longer matches the tree's dynamic modules — every `require` of a native - module fails and the server never answers a single tool call. - DASLANG_MCP_BIN in the environment pins an explicit binary (bisect hatch); a - pin that does not exist is announced and ignored rather than handed on, since - it would otherwise surface as a bare FileNotFoundError at spawn — or, worse, - get written into .mcp.json — instead of naming the bad path.""" - pinned = os.environ.get("DASLANG_MCP_BIN") - if pinned: - if os.path.exists(pinned): - return pinned - _log(f"DASLANG_MCP_BIN points at a missing path, ignoring it: {pinned}") - print(f"WARNING: DASLANG_MCP_BIN={pinned} does not exist — falling back to auto-pick", - file=sys.stderr, flush=True) - exe = ".exe" if IS_WINDOWS else "" - found = [c for c in (os.path.join(repo_root, rel + exe) for rel in BIN_CANDIDATES) - if os.path.exists(c)] - if not found: - return None - return max(found, key=os.path.getmtime) - - -def _default_launcher() -> list[str]: - if IS_WINDOWS: - # The .cmd sets up vcvars (cpp_compile_check needs cl.exe on PATH) and - # then runs whichever binary DASLANG_MCP_BIN names — chosen here so the - # newest-wins rule is one implementation, not two. - return ["cmd", "/c", os.path.join(SCRIPT_DIR, "daslang-mcp-msvc.cmd")] - main_das = os.path.join(SCRIPT_DIR, "main.das") - picked = _pick_binary(REPO_ROOT) - # -ignore-manifest: the server enumerates modules, so every C++ module loads on start - return [picked or os.path.join(REPO_ROOT, "bin", "daslang"), "-ignore-manifest", main_das] - - -def _python_launcher() -> str: - """The launcher Claude Code should spawn the supervisor with: python3 first, - then python IF it is actually Python 3 (a bare `python` can be Python 2 on - legacy boxes, and this script is Python 3; python.org Windows installs ship - no python3 alias), else the absolute path of the interpreter running this - emit — always a working Python 3.""" - for name in ("python3", "python"): - exe = shutil.which(name) - if not exe: - continue - try: - probe = subprocess.run( - [exe, "-c", "import sys; sys.exit(0 if sys.version_info[0] >= 3 else 1)"], - capture_output=True, timeout=10) - if probe.returncode == 0: - return name - except Exception: - continue - return sys.executable - - -def _daslang_binary(repo_root: str) -> str: - """The tree's own built daslang binary (cross-tree guard: a worktree's - .mcp.json must point at that worktree's binary).""" - exe = ".exe" if IS_WINDOWS else "" - picked = _pick_binary(repo_root) - if picked is not None: - return picked.replace(os.sep, "/") - return os.path.join(repo_root, "bin", "daslang" + exe).replace(os.sep, "/") - - -def write_mcp_json(repo_root: str) -> bool: - """Set mcpServers.daslang to spawn this supervisor over stdio, preserving - every other server (github, …). Also wires mcpServers.dasherd at the - dasHerd coordination shim when the tree ships it. Atomic; never clobbers - an unparseable file. Returns True if the file was (re)written, False if - left untouched.""" - path = os.path.join(repo_root, ".mcp.json") - data = {"mcpServers": {}} - if os.path.exists(path): - try: - with open(path, encoding="utf-8") as f: - data = json.load(f) - except Exception as e: - print(f" WARNING: {path} did not parse ({e}); leaving it untouched", flush=True) - return False - if not isinstance(data, dict): - print(f" WARNING: {path} is not a JSON object; leaving it untouched", flush=True) - return False - servers = data.get("mcpServers") - if servers is None: - servers = {} - data["mcpServers"] = servers - elif not isinstance(servers, dict): - print(f" WARNING: {path} has a non-object 'mcpServers'; leaving it untouched", flush=True) - return False - prev = servers.get("daslang", {}) - entry = {"command": _python_launcher(), "args": ["utils/mcp/mcp_supervisor.py"]} - if isinstance(prev, dict) and "defer_loading" in prev: - entry["defer_loading"] = prev["defer_loading"] - servers["daslang"] = entry - if os.path.exists(os.path.join(repo_root, "utils", "internal", "das-herd", "mcp_main.das")): - prev_herd = servers.get("dasherd", {}) - herd_entry = {"command": _daslang_binary(repo_root), - "args": ["-ignore-manifest", "utils/internal/das-herd/mcp_main.das"]} - if isinstance(prev_herd, dict) and "defer_loading" in prev_herd: - herd_entry["defer_loading"] = prev_herd["defer_loading"] - servers["dasherd"] = herd_entry - tmp = path + ".tmp" - with open(tmp, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - f.write("\n") - os.replace(tmp, path) - return True - - -def serve(repo_root: str, stderr_log: str): - child = DaslangChild(_default_launcher(), stderr_log, cwd=repo_root) - _log(f"supervisor up (repo={repo_root})") - out = sys.stdout.buffer # binary: avoid Windows \r\n translation on the JSON-RPC pipe - try: - for raw in sys.stdin.buffer: # newline-delimited JSON-RPC from Claude Code - text = raw.decode("utf-8", "replace").strip() - if not text: - continue - try: - msg = json.loads(text) - except Exception as e: - _log(f"parse error: {e}: {text[:200]}") - continue - if not isinstance(msg, dict): # MCP 2025-06-18 removed JSON-RPC batching - _log(f"ignoring non-object message: {text[:120]}") - continue - try: - resp = handle(child, msg) - except Exception as e: - _log(f"handle error: {e}") - # Respond whenever the request carried an `id` field (same test - # handle() uses to tell requests from notifications) — including - # an explicit null id — so the client isn't left hanging. - resp = (json.dumps({"jsonrpc": "2.0", "id": msg.get("id"), - "error": {"code": -32000, "message": f"supervisor error: {e}"}}) - if "id" in msg else None) - if resp is not None: - out.write(resp.encode("utf-8") + b"\n") - out.flush() - finally: - child.stop() - _log("supervisor down (stdin closed)") - - -def main(): - ap = argparse.ArgumentParser(description="daslang MCP stdio supervisor with auto-respawn") - ap.add_argument("--repo-root", default=REPO_ROOT) - ap.add_argument("--stderr-log", default=os.environ.get( - "DASLANG_MCP_CHILD_LOG", - os.path.join(tempfile.gettempdir(), "daslang_mcp_child_stderr.log"))) - ap.add_argument("--emit-config", action="store_true", - help="write the worktree's .mcp.json daslang entry and exit") - args = ap.parse_args() - repo_root = os.path.abspath(args.repo_root) - - if args.emit_config: - if write_mcp_json(repo_root): - print(f"wrote {os.path.join(repo_root, '.mcp.json')}: daslang -> stdio supervisor (utils/mcp/mcp_supervisor.py)") - return - sys.exit(1) # left an existing malformed .mcp.json untouched -> fail so callers surface it - serve(repo_root, args.stderr_log) - - -if __name__ == "__main__": - main() diff --git a/utils/mcp/setup.das b/utils/mcp/setup.das index 567c51d4f5..6f0a2b1539 100644 --- a/utils/mcp/setup.das +++ b/utils/mcp/setup.das @@ -2,6 +2,7 @@ options gen2 require daslib/clargs require daslib/fio +require daslib/json_boost require daslib/strings_boost require strings require jobque @@ -16,9 +17,9 @@ require jobque // built `bin/` binary, or the tree-sitter grammar shared lib — so a Claude // session there has zero daslang tools. This tool fixes that: it builds a // worktree-local daslang (+ tree-sitter grammar), copies the platform -// `sgconfig.yml`, and points `.mcp.json` at the stdio supervisor -// (mcp_supervisor.py, via `--emit-config`) — Claude Code spawns the supervisor, -// which forwards to a daslang child and respawns it on death/rebuild. +// `sgconfig.yml`, and points `.mcp.json` at the watchdog's stdio front over +// that binary — Claude Code spawns the front, which forwards to a daslang child +// and respawns it on death/rebuild. // // It adds no NEW secrets; an existing .mcp.json is rewritten with any other // server entries (e.g. github, including their env blocks) preserved as-is. @@ -236,29 +237,112 @@ def stage_jit_backend(root : string) { } } -// Point .mcp.json at the stdio supervisor (mcp_supervisor.py). The supervisor -// owns the .mcp.json merge (preserving other servers like github), so setup -// just invokes it. Probes `python3` first (a bare `python` can be Python 2 on -// legacy boxes, and the supervisor is Python 3), then `python` (python.org -// Windows installs ship no python3 alias); the supervisor emits whichever -// launcher resolves into .mcp.json. -def emit_mcp_config(root : string) : bool { - let script = path_join(root, "utils/mcp/mcp_supervisor.py") - if (!fexist(script)) { - print("ERROR: {script} not found\n") - return false +//! the front's command line for this tree: the watchdog exe beside the binary when it is built, +//! else the interpreter host on the binary itself; Windows serves through the vcvars launcher so +//! cpp_compile_check finds cl.exe, elsewhere the child is the binary on the server script +def front_command(root, binary : string) : tuple> { + let exe_suffix = get_platform_name() == "windows" ? ".exe" : "" + let binary_abs = path_join(root, binary) + let watchdog = path_join(dir_name(binary_abs), "watchdog{exe_suffix}") + var args : array + var command = watchdog + if (!fexist(watchdog)) { + command = binary_abs + args |> push_from(["utils/watchdog/main.das", "--"]) } - for (py in ["python3", "python"]) { - continue if (run_visible("{py} --version") != 0) - let rc = run_visible("{py} \"{script}\" --emit-config --repo-root \"{root}\"") - if (rc != 0) { - print("ERROR: emit-config failed (exit {rc})\n") + args |> push_from(["--stdio", "--name", "daslang-mcp", "--cwd", root]) + if (get_platform_name() == "windows") { + let cmd_exe = path_join(get_env_variable("SystemRoot"), "System32/cmd.exe") + args |> push_from(["--program", cmd_exe, "--", "/c", path_join(root, "utils/mcp/daslang-mcp-msvc.cmd")]) + } else { + args |> push_from(["--program", binary_abs, "--", "-ignore-manifest", "utils/mcp/main.das"]) + } + return <- (command = command, args <- args) +} + +def private replace_server(var servers : table&; name, command : string; args : array) { + var entry <- {"command" => JV(command), "args" => JV(args)} + var old = servers?[name] ?? null + if (old != null) { + let defer = old?["defer_loading"] + if (defer != null && defer.value is _bool) { + entry["defer_loading"] = JV(defer.value as _bool) + } + unsafe { + delete old + } + } + servers[name] = JV(entry) +} + +//! .mcp.json: the daslang entry spawns the watchdog's stdio front over this tree's own binary, +//! every other server stays as it is, the entry's own defer_loading survives, and the das-herd +//! shim rides along where the tree carries it. An unparseable file is left alone. +def emit_mcp_config(root, binary : string) : bool { + let path = path_join(root, ".mcp.json") + var config : JsonValue? + if (fexist(path)) { + var err : string + config = read_json(fread(path), err) + if (config == null || !(config.value is _object)) { + print(" WARNING: {path} is not a JSON object ({err}); leaving it untouched\n") return false } - return true + } else { + var empty_root : table + config = JV(empty_root) + } + if (!((config.value as _object) |> key_exists("mcpServers"))) { + var empty_servers : table + (config.value as _object)["mcpServers"] = JV(empty_servers) } - print("ERROR: neither python3 nor python found on PATH\n") - return false + var servers_node = config?["mcpServers"] + if (servers_node == null || !(servers_node.value is _object)) { + print(" WARNING: {path} has a non-object 'mcpServers'; leaving it untouched\n") + unsafe { + delete config + } + return false + } + let front = front_command(root, binary) + replace_server(servers_node.value as _object, "daslang", front.command, front.args) + if (fexist(path_join(root, "utils/internal/das-herd/mcp_main.das"))) { + replace_server(servers_node.value as _object, "dasherd", path_join(root, binary), ["-ignore-manifest", "utils/internal/das-herd/mcp_main.das"]) + } + let text = write_json(config) + unsafe { + delete config + } + let tmp = "{path}.tmp" + var written = false + fopen(tmp, "wb") $(f) { + return if (f == null) + fwrite(f, text) + fwrite(f, "\n") + written = true + } + if (!written) { + print("ERROR: cannot write {tmp}\n") + return false + } + let kept = "{path}.bak" + let had = fexist(path) + if (had && !rename(path, kept)) { + print("ERROR: cannot move {path} aside; the new configuration is in {tmp}\n") + return false + } + if (!rename(tmp, path)) { + if (had) { + rename(kept, path) + } + print("ERROR: cannot replace {path}; the new configuration is in {tmp}\n") + return false + } + if (had) { + remove(kept) + } + print("wrote {path}: daslang -> {front.command} {join(front.args, " ")}\n") + return true } [export] @@ -327,12 +411,13 @@ def main() : int { ensure_grammar(root) ensure_sgconfig(root) stage_jit_backend(root) - if (!emit_mcp_config(root)) { + if (!emit_mcp_config(root, binary)) { return 1 } print("\nDone. Restart the Claude Code session in {root} to pick up the daslang MCP server.\n") - print("Claude Code spawns the stdio supervisor (utils/mcp/mcp_supervisor.py); no persistent process to keep running.\n") + print("Claude Code spawns the watchdog's stdio front, which spawns the server on the first tool call\n") + print("and respawns it after a kill or a rebuild; no persistent process to keep running.\n") print("The daslang LSP plugin (.claude/skills/daslang-lsp, checked in) also loads automatically\n") print("when the session starts at the worktree root — it uses the binary built above (needs python3 on PATH).\n") print("NOTE: the parse-aware tools (grep_usage/outline/cpp_*) also need the ast-grep `sg` CLI on PATH.\n") diff --git a/utils/mcp/test_setup.das b/utils/mcp/test_setup.das new file mode 100644 index 0000000000..dad9ba497d --- /dev/null +++ b/utils/mcp/test_setup.das @@ -0,0 +1,130 @@ +options gen2 +options no_aot +options no_unused_block_arguments = false + +require dastest/testing_boost public +require daslib/fio +require daslib/json_boost +require setup + +//! emit_mcp_config against a scratch root: the daslang entry written fresh, merged into a file +//! that carries other servers and its own defer_loading, refused on a file that is not a JSON +//! object, and left intact when the old file cannot be moved aside + +def private make_root(t : T?) : string { + let tmp = create_temp_directory_result("das_mcp_setup") + if (!(tmp is value)) { + t |> failure("could not create temp directory: {tmp as error}") + return "" + } + return tmp as value +} + +def private write_text(path, text : string) { + fopen(path, "wb") $(f) { + return if (f == null) + fwrite(f, text) + } +} + +def private read_doc(path : string) : JsonValue? { + var err : string + return read_json(fread(path), err) +} + +def private json_array_has(arr : JsonValue?; needle : string) : bool { + return false if (arr == null || !(arr.value is _array)) + for (item in arr.value as _array) { + if (item.value is _string && (item.value as _string) == needle) return true + } + return false +} + +def private string_of(js : JsonValue?) : string { + return js != null && js.value is _string ? js.value as _string : "" +} + +def private no_leftovers(t : T?; root : string) { + t |> success(!fexist(path_join(root, ".mcp.json.tmp")), "no .mcp.json.tmp is left behind") + t |> success(!fexist(path_join(root, ".mcp.json.bak")), "no .mcp.json.bak is left behind") +} + +let private SEEDED = "\{\"mcpServers\":\{\"github\":\{\"command\":\"gh\"\},\"daslang\":\{\"command\":\"python3\",\"args\":[\"x\"],\"defer_loading\":true\}\}\}\n" + +[test] +def test_emit_mcp_config(t : T?) { + t |> run("a root with no .mcp.json gets the front over its own binary") @(t : T?) { + let root = make_root(t) + return if (empty(root)) + t |> success(emit_mcp_config(root, "bin/daslang"), "emit_mcp_config reports success") + var doc = read_doc(path_join(root, ".mcp.json")) + let entry = doc?["mcpServers"]?["daslang"] + t |> success(entry != null, "the daslang entry is written") + t |> equal(string_of(entry?["command"]), path_join(root, "bin/daslang"), "no watchdog exe beside the binary: the interpreter host is the command") + t |> success(json_array_has(entry?["args"], "utils/watchdog/main.das"), "the interpreter host runs the watchdog's main.das") + t |> success(json_array_has(entry?["args"], "--stdio"), "the front is asked for") + t |> success(json_array_has(entry?["args"], root), "--cwd names the root") + let herd = doc?["mcpServers"]?["dasherd"] + t |> success(herd == null || herd.value is _null, "no dasherd entry without the das-herd shim") + unsafe { + delete doc + } + no_leftovers(t, root) + } + t |> run("an existing file keeps its other servers and the entry's defer_loading") @(t : T?) { + let root = make_root(t) + return if (empty(root)) + write_text(path_join(root, ".mcp.json"), SEEDED) + t |> success(emit_mcp_config(root, "bin/daslang"), "emit_mcp_config reports success") + var doc = read_doc(path_join(root, ".mcp.json")) + t |> equal(string_of(doc?["mcpServers"]?["github"]?["command"]), "gh", "the other server is untouched") + let entry = doc?["mcpServers"]?["daslang"] + t |> equal(string_of(entry?["command"]), path_join(root, "bin/daslang"), "the daslang entry is replaced") + let deferred = entry?["defer_loading"] + t |> success(deferred != null && deferred.value is _bool && (deferred.value as _bool), "defer_loading survives the replacement") + unsafe { + delete doc + } + no_leftovers(t, root) + } + t |> run("the das-herd shim gets its own entry") @(t : T?) { + let root = make_root(t) + return if (empty(root)) + let shim = path_join(root, "utils/internal/das-herd/mcp_main.das") + mkdir(path_join(root, "utils")) + mkdir(path_join(root, "utils/internal")) + mkdir(path_join(root, "utils/internal/das-herd")) + write_text(shim, "options gen2\n") + t |> success(emit_mcp_config(root, "bin/daslang"), "emit_mcp_config reports success") + var doc = read_doc(path_join(root, ".mcp.json")) + let entry = doc?["mcpServers"]?["dasherd"] + t |> success(entry != null, "the dasherd entry is written") + t |> success(json_array_has(entry?["args"], "utils/internal/das-herd/mcp_main.das"), "it runs the shim") + unsafe { + delete doc + } + } + t |> run("a file that is not a JSON object is refused and left alone") @(t : T?) { + let root = make_root(t) + return if (empty(root)) + let path = path_join(root, ".mcp.json") + write_text(path, "nope\n") + t |> success(!emit_mcp_config(root, "bin/daslang"), "unparseable: refused") + t |> equal(fread(path), "nope\n", "unparseable: the file is untouched") + write_text(path, "\{\"mcpServers\":3\}\n") + t |> success(!emit_mcp_config(root, "bin/daslang"), "non-object mcpServers: refused") + t |> equal(fread(path), "\{\"mcpServers\":3\}\n", "non-object mcpServers: the file is untouched") + no_leftovers(t, root) + } + t |> run("the old file stays when it cannot be moved aside") @(t : T?) { + let root = make_root(t) + return if (empty(root)) + let path = path_join(root, ".mcp.json") + write_text(path, SEEDED) + mkdir(path_join(root, ".mcp.json.bak")) + write_text(path_join(root, ".mcp.json.bak/occupied"), "x") + t |> success(!emit_mcp_config(root, "bin/daslang"), "a non-empty directory in the way of the move-aside: refused") + t |> equal(fread(path), SEEDED, "the old configuration is intact") + t |> success(fexist(path_join(root, ".mcp.json.tmp")), "the new configuration is left in .mcp.json.tmp") + } +} diff --git a/utils/watchdog/README.md b/utils/watchdog/README.md index d00e655315..66e68a725f 100644 --- a/utils/watchdog/README.md +++ b/utils/watchdog/README.md @@ -86,9 +86,14 @@ between them. ## The log `logs/-watchdog.log` (`--log`), one JSON object per line, `{"ts", "event", ...}`, rotated -at 20 MB with five backups, and echoed to stdout. `health_heartbeat` and `watchdog_stopped` +at 20 MB with five backups, and echoed to stdout, except under `--stdio`, where stdout is the +client's. `health_heartbeat` and `watchdog_stopped` carry `heap_bytes` and `string_heap_bytes`, the supervisor's own live heaps: the host collects -them between ticks, and a number that only grows across heartbeats is a leak. The events: `watchdog_started`, +them between ticks, and a number that only grows across heartbeats is a leak. `child_started` +carries `pid` from the supervisor and `command` from the front, whose pipe reports no pid; +`child_exited` carries `code`, with `uptime_seconds` from the supervisor and `answered` from +the front, whether the child answered a request before it died; `child_noise` and +`client_noise` carry the `line` that was not a JSON message. The events: `watchdog_started`, `child_started`, `spawn_failed`, `child` (one per line the child wrote), `stage`, `tune`, `health`, `health_heartbeat`, `recovered`, `child_exited`, `intentional_shutdown`, `tune_bootstrap_complete`, `tune_incomplete`, `config_restart_relaunch`, `crash`, @@ -96,8 +101,9 @@ them between ticks, and a number that only grows across heartbeats is a leak. Th `terminate_requested`, `kill_requested`, `child_unkillable`, `watchdog_already_running`, `wer_ready` / `wer_not_ready` / `wer_installed` / `wer_install_failed`, `tray_started`, `tray_unavailable`, `tray_icon_unavailable`, `tray_open_requested`, `tray_open_failed`, -`tray_shutdown_requested`, `watchdog_stopped`. -In-tree readers: `smoke_test.cmake` and `tests/watchdog/test_watchdog.das`. +`tray_shutdown_requested`, `child_noise`, `client_noise`, `watchdog_stopped`. +In-tree readers: `smoke_test.cmake`, `tests/watchdog/test_watchdog.das` and +`tests/watchdog/test_stdio_front.das`. ## Crash capture @@ -153,10 +159,30 @@ a supervisor. In the bundle the watchdog discovers the baked exe beside it (the the directory that is not the watchdog), so the same `watchdog.json` serves a `daspkg release` bundle and a `daslang -jit main.das` deployment. +## Serving a stdio client + +`--stdio` turns the watchdog into the client's pipe: a newline-delimited JSON-RPC client (Claude +Code, for the daslang MCP server) spawns the watchdog, and the watchdog spawns the program as the +server. It answers `initialize` and `ping` itself, so the client connects before any child +exists; the first `tools/*` request spawns the child, in `--cwd`, with the client's `initialize` +replayed; requests are forwarded one at a time; a child that died before a request was delivered is +respawned and the request re-sent once, while one that dies while answering gets an error reply +and no re-send, since a tool call could otherwise run twice. Anything on the child's stdout that +is not a JSON line is logged as `child_noise` and carried in that error text. Stdout is the +protocol, so the log goes to its file only; no pid file, no health poll, no tray. + +``` +bin/watchdog --stdio --name daslang-mcp --cwd --program /bin/daslang -- -ignore-manifest utils/mcp/main.das +``` + +`utils/mcp/setup.das` writes that line into a tree's `.mcp.json`; `tests/watchdog/test_stdio_front.das` +drives it through both hosts. + ## Layout - `watchdog.das` - the library: configuration, discovery, the log, stages, crash capture, the tray, and `Supervisor`, a state machine the host ticks (`tick()` / `request_stop()` / `run()`). +- `stdio_front.das` - `StdioFront`, the `--stdio` mode: one child lifetime per tick. - `main.das` - the entry for both hosts: `start` / `tick` / `request_stop` / `result` for the executable, `main` for the interpreter; it collects the heaps between ticks. - `main.cpp` - the executable's `main`: argv, the pid, the signals, the loop. diff --git a/utils/watchdog/REVIEW.das b/utils/watchdog/REVIEW.das new file mode 100644 index 0000000000..de234995cd --- /dev/null +++ b/utils/watchdog/REVIEW.das @@ -0,0 +1,78 @@ +options gen2 + +require strings +require daslib/strings_boost +require daslib/fio +require daslib/regex +require daslib/regex_boost +require dastest/review_gate + +// The mechanical half of utils/watchdog/REVIEW.md (contract: REVIEW_COMMON.md at the repo root). +// Run from the repo root: bin/daslang utils/watchdog/REVIEW.das - exit 0 clean, 1 with findings. + +let private FOLDER = "utils/watchdog" +let private README = "utils/watchdog/README.md" + +var private EMIT <- %regex~emit\("([a-z_]+)"%% +var private LISTED <- %regex~`([a-z_]+)`%% + +//! every `emit(""` in the folder's sources, name -> the file that writes it +def private emitted_events() : table { + var events : table + dir(FOLDER) $(file) { + return if (!ends_with(file, ".das") || file == "REVIEW.das") + let path = "{FOLDER}/{file}" + let text = fread(path) + var from = 0 + while (true) { + let m = regex_search(EMIT, text, from) + break if (m.x < 0) + events[regex_group(EMIT, 1, text)] = path + from = m.y + } + } + return <- events +} + +//! the names between `The events:` and `In-tree readers:` in the README's `## The log` section +def private listed_events(readme : string) : table { + var listed : table + let start = find(readme, "The events:") + let stop = find(readme, "In-tree readers:") + if (start < 0 || stop < start) { + gate_finding(README, "the `## The log` section has no `The events:` list ending at `In-tree readers:`") + return <- listed + } + let section = slice(readme, start, stop) + var from = 0 + while (true) { + let m = regex_search(LISTED, section, from) + break if (m.x < 0) + listed |> insert(regex_group(LISTED, 1, section)) + from = m.y + } + return <- listed +} + +//! an `event` value the folder writes is in the README's list, and a listed one is written +def private check_events_listed { + let readme = fread(README) + var inscope listed <- listed_events(readme) + var inscope emitted <- emitted_events() + for (name, path in keys(emitted), values(emitted)) { + if (!key_exists(listed, name)) { + gate_finding(path, "emit(\"{name}\") is not in the `The events:` list of {README} - add it there, in this change") + } + } + for (name in keys(listed)) { + if (!key_exists(emitted, name)) { + gate_finding(README, "`{name}` is in the `The events:` list but nothing under {FOLDER}/ emits it") + } + } +} + +[export] +def main() : int { + check_events_listed() + return gate_verdict("watchdog") +} diff --git a/utils/watchdog/REVIEW.md b/utils/watchdog/REVIEW.md index ab37e22cac..17f4aa996b 100644 --- a/utils/watchdog/REVIEW.md +++ b/utils/watchdog/REVIEW.md @@ -5,8 +5,17 @@ doc: `README.md`. **A diff that adds an `event` value to the JSON-lines log adds it to the `The events:` list in `README.md`, and a diff that adds a field key to such a line describes it in the same `## The -log` section, in the same change** - that section is the list the next rule sweeps when a name -is renamed. +log` section, in the same change** - that section is the list a rename sweep starts from. +`REVIEW.das` (beside this file) checks the `event` half: every name the folder's sources emit +is listed, and every listed name is emitted. + +**A diff that adds an `event` value or a field key to the log in `watchdog.das` or +`stdio_front.das` uses the name the other file already writes for the same thing** - one +meaning under two spellings matches a log reader on only one of the two, and the miss is +silent. + +**Weakening `REVIEW.das` (beside this file) is a defect: dropping a check, narrowing what a +check walks, or rewriting a finding text so it no longer names what failed.** **A diff that renames or removes a name the supervisor writes or reads - an `event` value on the JSON-lines log, a field key on such a line, a startup stage name, or a `@tune` kind or key @@ -24,15 +33,15 @@ tree in the same change, and names any out-of-tree `watchdog.json` in the PR des an unknown key refuses the start, so a stale key in a bundled config is a supervisor that never comes up. -**A diff that adds a `require` to `watchdog.das` for an optional module - one a build can leave -out, so `has_module` reports it absent - adds that module to the `watchdog` arm of +**A diff that adds a `require` to a `.das` in this folder for an optional module - one a build +can leave out, so `has_module` reports it absent - adds that module to the `watchdog` arm of `tests/.das_test` (repo root) in the same change** - without the entry the whole test suite fails to compile on a machine where that module is missing. -**A diff that adds a `require` to `watchdog.das` for an optional module adds that module to the -`if(TARGET ...)` guard and the link line of the `watchdog` target in `utils/CMakeLists.txt` -(repo root), in the same change** - without the guard a configure that leaves the module out -fails at the link instead of skipping the target. +**A diff that adds a `require` to a `.das` in this folder for an optional module adds that +module to the `if(TARGET ...)` guard and the link line of the `watchdog` target in +`utils/CMakeLists.txt` (repo root), in the same change** - without the guard a configure that +leaves the module out fails at the link instead of skipping the target. **A diff that makes the tray or a notification depend on something the host machine may not have - a call into `stddlg`, a spawned program that shows something on the desktop, or a file diff --git a/utils/watchdog/main.das b/utils/watchdog/main.das index 467dea90cf..0d9b10dba2 100644 --- a/utils/watchdog/main.das +++ b/utils/watchdog/main.das @@ -6,6 +6,7 @@ options stack = 65536 require daslib/fio require daslib/clargs require watchdog +require stdio_front //! Two hosts share this entry. The standalone exe (`bin/watchdog`, a `-ctx` context on the full //! runtime) calls `start` / `tick` / `request_stop` / `result` from its C++ main, which owns argv, @@ -18,7 +19,17 @@ let private COLLECT_SECONDS = 60 let private COLLECT_STRING_BYTES = 1024ul * 1024ul var private g_supervisor : Supervisor? +var private g_front : StdioFront? var private g_exit_code = 0 + +//! `--stdio` before the `--` picks the front over the supervisor +def private wants_stdio(args : array) : bool { + for (arg in args) { + if (arg == "--") break + if (arg == "--stdio") return true + } + return false +} var private g_last_collect = 0l var private g_strings_after_collect = 0ul @@ -51,13 +62,22 @@ def start(self_pid : int) : bool { let argv <- get_command_line_arguments() let args <- [for (i in range(1, length(argv))); argv[i]] let program_dir = !empty(argv) ? dir_name(get_full_file_name(argv[0])) : getcwd() + if (wants_stdio(args)) { + g_front = stdio_front_start(args, program_dir, g_exit_code) + return g_front != null + } g_supervisor = watchdog_start(args, program_dir, self_pid, g_exit_code) return g_supervisor != null } [export] def tick() : bool { - let done = g_supervisor == null ? true : g_supervisor->tick() + var done = true + if (g_front != null) { + done = g_front->tick() + } elif (g_supervisor != null) { + done = g_supervisor->tick() + } collect_if_due() return done } @@ -71,6 +91,12 @@ def request_stop() { [export] def result() : int { + if (g_front != null) { + g_exit_code = g_front.result + unsafe { + delete g_front + } + } if (g_supervisor != null) { g_exit_code = g_supervisor.result unsafe { @@ -85,6 +111,18 @@ def result() : int { def main() : int { let args <- get_cli_arguments() var code = 0 + if (wants_stdio(args)) { + var front = stdio_front_start(args, program_dir_from(args), code) + if (front == null) return code + while (!front->tick()) { + collect_if_due() + } + code = front.result + unsafe { + delete front + } + return code + } var sup = watchdog_start(args, program_dir_from(args), 0, code) if (sup == null) return code while (!sup->tick()) { diff --git a/utils/watchdog/stdio_front.das b/utils/watchdog/stdio_front.das new file mode 100644 index 0000000000..1415579d3b --- /dev/null +++ b/utils/watchdog/stdio_front.das @@ -0,0 +1,290 @@ +options gen2 + +require daslib/fio +require daslib/json_boost +require daslib/strings_boost +require strings +require watchdog + +//! The stdio front: this process is the client's newline-delimited JSON-RPC pipe and the child is +//! the server. It answers `initialize` and `ping` itself, spawns the child on the first `tools/*` +//! request with the client's `initialize` replayed, forwards one request at a time, and respawns a +//! child that died before a request was delivered - never after one was, since a re-sent tool call +//! could run twice. The log goes to its file only: stdout is the protocol. + +//! the daslang MCP server's own InitializeResult (utils/mcp/mcp_core.das), answered here so the +//! client connects before any child exists +struct private ServerInfo { + name : string + version : string +} + +struct private ToolsCapability { +} + +struct private Capabilities { + tools : ToolsCapability +} + +struct private InitializeResult { + protocolVersion : string + capabilities : Capabilities + serverInfo : ServerInfo +} + +def public stdio_init_result() : string { + let result = InitializeResult(protocolVersion = "2025-11-25", serverInfo = ServerInfo(name = "daslang", version = "0.1.0")) + return sprint_json(result, false) +} + +let private SPAWN_ATTEMPTS = 3 +let private NOISE_LINES = 40 + +//! one line without its newline; the server's own loop, since fgets caps a chunk at 16 KB and a +//! tool call's arguments or a listing's result run past that +def private read_line(f : file) : string { + return build_string() $(var w) { + while (!feof(f)) { + let chunk = fgets(f) + let len = length(chunk) + if (len == 0) break + if (character_at(chunk, len - 1) == '\n') { // nolint:PERF003 + let cr = len > 1 && character_at(chunk, len - 2) == '\r' // nolint:PERF003 + write(w, slice(chunk, 0, len - (cr ? 2 : 1))) + break + } + write(w, chunk) + } + } +} + +struct private ClientMessage { + line : string + method : string + has_id : bool + id_text : string +} + +def private classify(line : string) : ClientMessage { + var msg = ClientMessage(line = line) + var err : string + var js = read_json(line, err) + if (js == null || !(js.value is _object)) { + msg.method = "" + } else { + let method = js?["method"] + if (method != null && method.value is _string) { + msg.method = method.value as _string + } + if ((js.value as _object) |> key_exists("id")) { + msg.has_id = true + let id = js?["id"] + msg.id_text = id == null ? "null" : write_json_compact(id) + } + } + unsafe { + delete js + } + return msg +} + +def private jsonrpc_result(id_text, result_json : string) : string { + return "\{\"jsonrpc\":\"2.0\",\"id\":{id_text},\"result\":{result_json}\}" +} + +def private jsonrpc_error(id_text : string; code : int; message : string) : string { + return "\{\"jsonrpc\":\"2.0\",\"id\":{id_text},\"error\":\{\"code\":{code},\"message\":{write_json_compact(JV(message))}\}\}" +} + +class public StdioFront { + cfg : WatchdogConfig = WatchdogConfig() + log : Emitter? + command : array + init_line : string + initialized : bool = false + pending : ClientMessage + has_pending : bool = false + failed_spawns : int = 0 + done : bool = false + result : int = 0 + sin : file + sout : file + + def StdioFront(var config : WatchdogConfig) { + cfg <- config + log = new Emitter(cfg.log) + log.tee = false + command <- child_command(cfg) + sin = fstdin() + sout = fstdout() + } + + def finalize() { + unsafe { + delete log + } + } + + def private reply(line : string) { + fprint(sout, line) + fprint(sout, "\n") + fflush(sout) + } + + def private write_child(w : file; line : string) : bool { + fprint(w, line) + fprint(w, "\n") + fflush(w) + return !feof(w) + } + + //! the child's next JSON line; anything else on its stdout is a diagnostic - a compile failure, + //! a module that did not load - logged and skipped, and carried in the error text if the child + //! then dies. Empty means the child is gone. + def private read_child(r : file; var noise : array&) : string { + while (true) { + let line = read_line(r) + if (empty(line)) { + if (feof(r)) return "" + continue + } + if (character_at(line, 0) == '{') { // nolint:PERF003 + var err : string + var js = read_json(line, err) + let ok = js != null + unsafe { + delete js + } + if (ok) return line + } + log->emit("child_noise", JV({"line" => JV(slice(line, 0, 400))})) + if (length(noise) < NOISE_LINES) { + noise |> push(line) + } + } + return "" + } + + def private death_message(reason : string; noise : array) : string { + let text = empty(noise) ? reason : "{reason}; daslang child said: {join(noise, " | ")}" + return slice(text, 0, 2000) + } + + //! one client line; false at end of input. Everything but `tools/*` is answered here; a + //! notification reaches the child only while one is up (`w` is null between children), since + //! the handshake replay re-establishes its state on the next spawn. + def private serve_client_line(w : file) : bool { + let line = read_line(sin) + if (empty(line)) return !feof(sin) + let msg = classify(line) + if (empty(msg.method) && !msg.has_id) { + log->emit("client_noise", JV({"line" => JV(slice(line, 0, 200))})) + return true + } + if (msg.method == "initialize") { + init_line = line + reply(jsonrpc_result(msg.id_text, stdio_init_result())) + } elif (msg.method == "notifications/initialized" || msg.method == "initialized") { + initialized = true + if (w != null) { + write_child(w, line) + } + } elif (msg.method == "ping" && msg.has_id) { + reply(jsonrpc_result(msg.id_text, "\{\}")) + } elif (!msg.has_id) { + if (w != null) { + write_child(w, line) + } + } elif (msg.method |> starts_with("tools/")) { + pending = msg + has_pending = true + } else { + reply(jsonrpc_error(msg.id_text, -32601, "method not found: {msg.method}")) + } + return true + } + + //! the client's `initialize` and, when it followed, `initialized`, so the child's session state + //! matches the client's before the first real request + def private replay_handshake(w, r : file; var noise : array&) : bool { + if (empty(init_line)) return true + if (!write_child(w, init_line) || empty(read_child(r, noise))) return false + if (initialized) { + return write_child(w, "\{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"\}") + } + return true + } + + //! one child lifetime: local service until a request needs the child, then the child until it + //! dies or the client's input ends. True when the front is done. + def tick() : bool { + while (!done && !has_pending) { + if (!serve_client_line(null)) { + done = true + } + } + if (done) return true + var noise : array + var answered = false + log->emit("child_started", JV({"command" => JV(join(command, " "))})) + var rc : int + unsafe { + rc = popen_argv_pipe(command) $(w, r) { + if (!replay_handshake(w, r, noise)) return + while (!done) { + if (has_pending) { + if (!write_child(w, pending.line)) return + has_pending = false + answered = true + let response = read_child(r, noise) + if (empty(response)) { + reply(jsonrpc_error(pending.id_text, -32000, death_message("the daslang child died while answering", noise))) + return + } + reply(response) + } + if (!serve_client_line(w)) { + done = true + } + } + } + } + log->emit("child_exited", JV({"code" => JV(rc), "answered" => JV(answered)})) + if (done) return true + if (answered) { + failed_spawns = 0 + } else { + failed_spawns++ + if (failed_spawns >= SPAWN_ATTEMPTS && has_pending) { + reply(jsonrpc_error(pending.id_text, -32000, death_message("the daslang child died {failed_spawns} times before answering", noise))) + has_pending = false + failed_spawns = 0 + } + } + return false + } +} + +//! `args` is the command line after the executable; `program_dir` is where the watchdog sits. A +//! configuration error goes to stderr - stdout belongs to the client - and sets `exit_code`. The +//! process enters `--cwd` here: the child inherits it, as it does from the supervisor's spawn, +//! and the client spawned the front from wherever the client runs. +def public stdio_front_start(args : array; program_dir : string; var exit_code : int&) : StdioFront? { + var resolved <- resolve_config(args, program_dir) + if (!empty(resolved.error)) { + fprint(fstderr(), "{resolved.error}\n") + exit_code = 2 + return null + } + if (empty(resolved.cfg.program) && empty(resolved.cfg.script)) { + fprint(fstderr(), "watchdog --stdio: nothing to serve; pass --program or --script \n") + exit_code = 2 + return null + } + if (!chdir(resolved.cfg.cwd)) { + fprint(fstderr(), "watchdog --stdio: cannot enter {resolved.cfg.cwd}\n") + exit_code = 2 + return null + } + return new StdioFront(resolved.cfg) +} diff --git a/utils/watchdog/watchdog.das b/utils/watchdog/watchdog.das index 37196e5af5..f876f1ba15 100644 --- a/utils/watchdog/watchdog.das +++ b/utils/watchdog/watchdog.das @@ -95,6 +95,8 @@ struct public WatchdogConfig { tray_url : string @clarg_doc = "Image the tray icon shows - a .png, or an .ico with PNG frames - relative to --cwd; the plain disc when unset" tray_icon : string + @clarg_doc = "Serve a stdio JSON-RPC client: this process is the client's pipe and the child the server, respawned on death with the handshake replayed" + stdio : bool @clarg_skip server_args : array }