diff --git a/.changeset/mcp-session-lifecycle-hygiene.md b/.changeset/mcp-session-lifecycle-hygiene.md new file mode 100644 index 00000000..5349fdfc --- /dev/null +++ b/.changeset/mcp-session-lifecycle-hygiene.md @@ -0,0 +1,5 @@ +--- +"codemap": minor +--- + +MCP session lifecycle hygiene: stdio disconnect detection (stdin EOF, stdout EPIPE, parent-PID poll) with graceful watcher shutdown on client exit; HTTP `serve --watch` refcount-gates the watcher per request (5s release grace, `/health` excluded). No MCP idle shutdown. diff --git a/docs/agents.md b/docs/agents.md index dfe92f5c..59cac192 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -101,6 +101,16 @@ All three transports resolve to the same `assembleAgentContent(kind)` function i Recipe ids cited in the playbook are machine-validated in tests against the live catalog (`extractMcpInstructionRecipeIds`). +## MCP session lifecycle + +Long-running **`codemap mcp`** stays up for the whole IDE session while the stdio pipe is open — **there is no idle timeout** that exits after N minutes without tool calls. Cursor / Claude Code spawn MCP once; they do not reliably respawn it mid-conversation, so an idle shutdown would break long pauses with no recovery path. + +**Exit triggers (MCP):** client disconnect only — stdin EOF, stdout broken pipe (`EPIPE`), boot parent process gone, or SIGINT/SIGTERM. Implementation: `src/application/session-lifecycle.ts` (`createStdioDisconnectMonitor`). + +**Not idle timeout:** HTTP **`serve --watch`** uses a 5s **watch release grace** after the last non-`/health` request — that stops chokidar between stateless requests, not the MCP/HTTP process. **`GET /health`** never acquires a watch client. + +See [architecture.md § Session lifecycle wiring](./architecture.md#cli-usage). + ## MCP tool allowlist **`context.index_freshness`** — session bootstrap includes index-level freshness metadata: `commit_drift` (HEAD ≠ `last_indexed_commit`), `pending_sync` (watcher debounce queue or in-flight reindex), optional disk-drift counts when watch is off, and a single `warning` string when agents should pause or re-index. **`context.start_here`** (non-compact) adds inline index summary, intent-ranked `query_recipe` cards, and top hub files with export signatures (adaptive caps by file count; optional MCP/HTTP `include_snippets` for one-line previews). Debug intent biases `sample_markers` toward FIXME/TODO. **MCP:** array-shaped JSON tools (`query`, …) keep row payloads verbatim and append a second `content` block prefixed `@codemap/index_freshness`; object-shaped tools merge `index_freshness` inline. **HTTP:** `POST /tool/*` adds `X-Codemap-Pending-Sync`, `X-Codemap-Commit-Drift`, and `X-Codemap-Warning` headers without changing JSON bodies; **`GET /health`** includes full cheap `index_freshness` when the DB is readable. Complements per-file `validate` / snippet `stale`. See [`architecture.md` § Context wiring](./architecture.md). diff --git a/docs/architecture.md b/docs/architecture.md index 26f71f70..56af5ce0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -145,11 +145,13 @@ A local SQLite database (`.codemap/index.db`) indexes the project tree and store **Tool / resource handlers (transport-agnostic):** **`src/application/tool-handlers.ts`** + **`src/application/resource-handlers.ts`** — pure functions that take the args object an MCP tool / resource URI accepts and return a discriminated **`ToolResult`** (`{ok: true, format: 'json'|'sarif'|'annotations'|'mermaid'|'diff'|'diff-json', payload}` / `{ok: false, error}`) or a **`ResourcePayload`** (`{mimeType, text}`). MCP and HTTP both wrap the same handlers — MCP translates to `{content: [{type: "text", text}]}`, HTTP translates to `(status, body)` with the right `Content-Type`. Engine layer untouched; transport changes don't ripple into the SQL. -**MCP wiring:** **`src/cli/cmd-mcp.ts`** (argv — `--watch` / `--no-watch` / `--debounce` + `--help`; bootstrap absorbs `--root`/`--config`) + **`src/application/mcp-server.ts`** (transport — tool / resource registry, SDK glue). Mirrors the `cmd-audit.ts ↔ audit-engine.ts` seam — CLI parses + lifecycle; engine owns the SDK. **`runMcpServer`** bootstraps codemap once at server boot (config + resolver + DB access become module-level state), instantiates `McpServer` from **`@modelcontextprotocol/sdk`**, attaches a **`StdioServerTransport`**, and resolves when stdin closes (clean shutdown). Tool handlers reuse the existing engine entry-points: **`query`** + **`query_recipe`** call **`executeQuery`** in **`src/application/query-engine.ts`** (a pure transport-agnostic engine extracted from `printQueryResult`'s JSON branch — same `[...rows]` / `{count}` / `{group_by, groups}` envelope `--json` would print); **`query_batch`** loops per statement via **`handleQueryBatch`** → **`executeQuery`** (batch-wide defaults + per-item overrides; items are `string | {sql, summary?, changed_since?, group_by?}`); **`audit`** runs `resolveAuditBaselines` + `runAudit` from PR #33 unchanged; **`context`** / **`validate`** call `buildContextEnvelope` / `computeValidateRows` from **`src/application/context-engine.ts`** + **`src/application/validate-engine.ts`** (lifted out of `src/cli/cmd-*.ts` in PR #41 — see § Tool / resource handlers above). **`save_baseline`** is one polymorphic tool (`{name, sql? | recipe?}`) with a runtime exclusivity check — mirrors the CLI's single `--save-baseline=` verb. **Tool naming**: snake_case throughout — Codemap convention matching the patterns in MCP spec examples and reference servers (GitHub MCP, Cursor built-ins); the spec itself doesn't mandate it. CLI stays kebab — translation lives at the MCP-arg layer. **Resources** split by freshness contract: `codemap://schema`, `codemap://skill`, `codemap://rule`, and `codemap://mcp-instructions` use **lazy memoisation** — first `read_resource` populates a per-server-instance cache; constant for the server-process lifetime so eager-vs-lazy produce identical observable behavior. `codemap://recipes`, `codemap://recipes/{id}`, `codemap://files/{+path}`, and `codemap://symbols/{name}` are **live read-per-call** (no cache) so inline recency fields and index mutations under `--watch` don't freeze at first-read. `codemap://schema` queries `sqlite_schema` live (on first read, then cached); `codemap://skill` / `codemap://rule` / `codemap://mcp-instructions` call `assembleAgentContent(kind)` from `application/agent-content.ts`, which concatenates section files under `templates/agent-content//` and dispatches `*.gen.md` files through `RENDERERS` (live recipe catalog, live `createTables()` DDL) — see [agents.md § Section assembler](./agents.md#section-assembler-and-genmd). Output shape: each tool returns the JSON payload its CLI counterpart's `--json` flag would print when a CLI verb exists (no CLI verb: `query_batch`, `trace`, `explore`, `node`); MCP wraps via `content: [{type: "text", text: JSON.stringify(payload)}]`. `--changed-since` git lookups are memoised per `(root, ref)` pair across batch items so a `query_batch` of N items sharing the same ref does one git invocation, not N. Per-statement errors in `query_batch` are isolated — failed statements return `{error}` in their slot while siblings still execute. +**MCP wiring:** **`src/cli/cmd-mcp.ts`** (argv — `--watch` / `--no-watch` / `--debounce` + `--help`; bootstrap absorbs `--root`/`--config`) + **`src/application/mcp-server.ts`** (transport — tool / resource registry, SDK glue). Mirrors the `cmd-audit.ts ↔ audit-engine.ts` seam — CLI parses + lifecycle; engine owns the SDK. **`runMcpServer`** bootstraps codemap once at server boot (config + resolver + DB access become module-level state), instantiates `McpServer` from **`@modelcontextprotocol/sdk`**, attaches a **`StdioServerTransport`**, and resolves on client disconnect via **`src/application/session-lifecycle.ts`** (`createStdioDisconnectMonitor` — stdin EOF, stdout EPIPE, parent-PID poll — plus SDK `transport.onclose` and SIGINT/SIGTERM). With `--watch`, **`createManagedWatchSession`** holds one client for the stdio session and **`forceStop`** drains the watcher on exit. Tool handlers reuse the existing engine entry-points: **`query`** + **`query_recipe`** call **`executeQuery`** in **`src/application/query-engine.ts`** (a pure transport-agnostic engine extracted from `printQueryResult`'s JSON branch — same `[...rows]` / `{count}` / `{group_by, groups}` envelope `--json` would print); **`query_batch`** loops per statement via **`handleQueryBatch`** → **`executeQuery`** (batch-wide defaults + per-item overrides; items are `string | {sql, summary?, changed_since?, group_by?}`); **`audit`** runs `resolveAuditBaselines` + `runAudit` from PR #33 unchanged; **`context`** / **`validate`** call `buildContextEnvelope` / `computeValidateRows` from **`src/application/context-engine.ts`** + **`src/application/validate-engine.ts`** (lifted out of `src/cli/cmd-*.ts` in PR #41 — see § Tool / resource handlers above). **`save_baseline`** is one polymorphic tool (`{name, sql? | recipe?}`) with a runtime exclusivity check — mirrors the CLI's single `--save-baseline=` verb. **Tool naming**: snake_case throughout — Codemap convention matching the patterns in MCP spec examples and reference servers (GitHub MCP, Cursor built-ins); the spec itself doesn't mandate it. CLI stays kebab — translation lives at the MCP-arg layer. **Resources** split by freshness contract: `codemap://schema`, `codemap://skill`, `codemap://rule`, and `codemap://mcp-instructions` use **lazy memoisation** — first `read_resource` populates a per-server-instance cache; constant for the server-process lifetime so eager-vs-lazy produce identical observable behavior. `codemap://recipes`, `codemap://recipes/{id}`, `codemap://files/{+path}`, and `codemap://symbols/{name}` are **live read-per-call** (no cache) so inline recency fields and index mutations under `--watch` don't freeze at first-read. `codemap://schema` queries `sqlite_schema` live (on first read, then cached); `codemap://skill` / `codemap://rule` / `codemap://mcp-instructions` call `assembleAgentContent(kind)` from `application/agent-content.ts`, which concatenates section files under `templates/agent-content//` and dispatches `*.gen.md` files through `RENDERERS` (live recipe catalog, live `createTables()` DDL) — see [agents.md § Section assembler](./agents.md#section-assembler-and-genmd). Output shape: each tool returns the JSON payload its CLI counterpart's `--json` flag would print when a CLI verb exists (no CLI verb: `query_batch`, `trace`, `explore`, `node`); MCP wraps via `content: [{type: "text", text: JSON.stringify(payload)}]`. `--changed-since` git lookups are memoised per `(root, ref)` pair across batch items so a `query_batch` of N items sharing the same ref does one git invocation, not N. Per-statement errors in `query_batch` are isolated — failed statements return `{error}` in their slot while siblings still execute. **HTTP wiring:** **`src/cli/cmd-serve.ts`** (argv — `--host` / `--port` / `--token`; bootstrap absorbs `--root`/`--config`) + **`src/application/http-server.ts`** (transport — bare `node:http`; routes `POST /tool/{name}` to `tool-handlers`, `GET /resources/{encoded-uri}` to `resource-handlers`, plus `GET /health` / `GET /tools` / `GET /resources`). Default bind **`127.0.0.1:7878`** (loopback only — refuse `0.0.0.0` unless explicitly opted in via `--host 0.0.0.0`). Optional **`--token `** requires `Authorization: Bearer ` on every request; `GET /health` is auth-exempt so liveness probes work without leaking the token. **CSRF + DNS-rebinding guard** (`csrfCheck`) runs before every route — rejects `Sec-Fetch-Site: cross-site` / `same-site` (modern-browser CSRF), any present `Origin` header (including the opaque string `null`; older-browser CSRF fallback), and `Host` header mismatch on loopback bind (DNS rebinding). Non-browser clients (curl, fetch from Node, MCP hosts, CI scripts) don't send those headers and pass through. The guard runs even on `/health` so a malicious local webpage can't probe for liveness. Output shape: HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper — HTTP doesn't need that transport artifact); `query` / `query_recipe` match `codemap query --json` row arrays (or `{count}` / `{group_by,groups}` when `summary` / `group_by` is set — baseline save/compare is separate tools, not MCP/HTTP `query`); other tools match their CLI `--json` envelopes; `format: "sarif"` payloads ship as `application/sarif+json`, `format: "annotations"` / `"mermaid"` / `"diff"` as `text/plain; charset=utf-8`, `format: "diff-json"` as `application/json; charset=utf-8`, JSON otherwise. Per-request DB lifecycle: open / `PRAGMA query_only = 1` / close per call (SQLite reader concurrency); 1 MiB request-body cap rejects trivial DoS. SIGINT / SIGTERM → graceful drain via `server.close()`. Every response carries **`X-Codemap-Version: `** so consumers can pin / detect upgrades. -**Watch wiring:** **`src/cli/cmd-watch.ts`** (argv — `--debounce ` / `--quiet`; bootstrap absorbs `--root`/`--config`) + **`src/application/watcher.ts`** (engine — pure debouncer + glob filter + injectable backend; production wires [chokidar v5](https://github.com/paulmillr/chokidar) selected via the 6-watcher audit in PR #46 — pure JS, runs identically on Bun + Node, ~30M repos use it). On every change/add/unlink event chokidar emits, the engine filters via `shouldIndexPath` (same indexed extensions as the indexer + project-local recipes; skips `node_modules` / `.git` / `dist`), debounces with a sliding window (default 250 ms), then calls `createReindexOnChange` which opens a DB, runs `runCodemapIndex({mode: 'files', files: [...changed]})`, closes the DB, and logs `reindex N file(s) in Mms` to stderr unless `--quiet`. SIGINT / SIGTERM drains pending edits via `flushNow()` before the watcher closes. **Default-ON for `mcp` / `serve` since 2026-05:** both transports boot the watcher in-process so every tool reads a live index — eliminates the per-request reindex prelude. Opt out with `--no-watch`, `CODEMAP_WATCH=0`, or `CODEMAP_NO_WATCH=1`. **`src/application/watch-policy.ts`** disables the watcher on WSL2 Windows drive mounts (`/mnt/*`) unless `CODEMAP_FORCE_WATCH=1`; stderr points at `codemap agents init --git-hooks` for git-triggered freshness. Standalone `codemap watch` runs the watcher decoupled from a transport for users wiring it next to a separate MCP / HTTP process. **Audit prelude optimization:** module-level `watchActive` flag; `handleAudit` skips its incremental-index prelude when active (and marks the close as readonly to avoid a wasted checkpoint). Explicit `no_index: false` still forces the prelude. +**Watch wiring:** **`src/cli/cmd-watch.ts`** (argv — `--debounce ` / `--quiet`; bootstrap absorbs `--root`/`--config`) + **`src/application/watcher.ts`** (engine — pure debouncer + glob filter + injectable backend; production wires [chokidar v5](https://github.com/paulmillr/chokidar) selected via the 6-watcher audit in PR #46 — pure JS, runs identically on Bun + Node, ~30M repos use it). On every change/add/unlink event chokidar emits, the engine filters via `shouldIndexPath` (same indexed extensions as the indexer + project-local recipes; skips `node_modules` / `.git` / `dist`), debounces with a sliding window (default 250 ms), then calls `createReindexOnChange` which opens a DB, runs `runCodemapIndex({mode: 'files', files: [...changed]})`, closes the DB, and logs `reindex N file(s) in Mms` to stderr unless `--quiet`. SIGINT / SIGTERM drains pending edits via `flushNow()` before the watcher closes. **Default-ON for `mcp` / `serve` since 2026-05:** both transports embed the watcher via **`createManagedWatchSession`** in **`session-lifecycle.ts`** — MCP holds one client for the stdio session; HTTP acquires per request (excluding `/health`) and stops the watcher after the last client plus a 5s release grace (not an MCP idle shutdown). Opt out with `--no-watch`, `CODEMAP_WATCH=0`, or `CODEMAP_NO_WATCH=1`. **`src/application/watch-policy.ts`** disables the watcher on WSL2 Windows drive mounts (`/mnt/*`) unless `CODEMAP_FORCE_WATCH=1`; stderr points at `codemap agents init --git-hooks` for git-triggered freshness. Standalone `codemap watch` runs the watcher decoupled from a transport for users wiring it next to a separate MCP / HTTP process. **Audit prelude optimization:** module-level `watchActive` flag; `handleAudit` skips its incremental-index prelude when active (and marks the close as readonly to avoid a wasted checkpoint). Explicit `no_index: false` still forces the prelude. + +**Session lifecycle wiring:** **`src/application/session-lifecycle.ts`** — transport-specific start/stop rules for long-running `mcp` / `serve` processes (one-shot CLI unchanged). **`createStdioDisconnectMonitor`** (MCP only) exits the process when the agent host is actually gone: stdin EOF, stdout `EPIPE`, boot parent PID no longer alive (2s poll), or SIGINT/SIGTERM. The MCP SDK's stdio `transport.onclose` alone is insufficient — it fires only after an explicit `transport.close()`, not when the parent crashes without tearing down the pipe. **`createManagedWatchSession`** refcount-gates chokidar: MCP acquires one client before `connect` and **`forceStop`** drains the watcher on disconnect; HTTP acquires per authenticated request (after auth; **`GET /health`** excluded) and **`releaseClient`** stops the watcher when the count hits zero. **No MCP idle timeout:** `codemap mcp` does **not** exit after N minutes without tool calls while the stdio pipe stays open. IDE hosts spawn MCP once per session and do not reliably respawn it mid-conversation — an idle shutdown would break long pauses (human think time, reading, multi-step plans) with no recovery path. Orphan cleanup is handled by **disconnect detection**, not inactivity timers. **HTTP watch release grace (`HTTP_WATCH_RELEASE_GRACE_MS` = 5000):** distinct from idle timeout — only stops chokidar between stateless requests so the watcher is not started/stopped on every POST; the HTTP listener keeps running. **`GET /health`** liveness probes do not acquire a watch client (probes must not keep chokidar hot). Future **`MCP shared daemon per project`** could revisit opt-in idle policies with explicit client reconnect; not planned for stdio MCP today. **Performance wiring:** **`--performance`** plumbs through **`RunIndexOptions.performance`** → **`indexFiles({ performance, collectMs })`**. `parse-worker-core.ts` records per-file **`parseMs`** on each `ParsedFile`; main thread times the eight phases (`collect`, `parse`, `insert`, `index_create`, `bindings`, `module_cycles`, `re_export_chains`, `heritage`) and assembles **`IndexPerformanceReport`** under `IndexRunStats.performance`. Note: `total_ms` is `indexFiles` wall-clock (parse + insert + DDL + bindings + cycles + re_exports + heritage), **not** end-to-end run wall — `collect_ms` happens before `indexFiles` and is reported separately. Env var **`CODEMAP_PERFORMANCE_JSON=`** dumps the report as JSON post-run (consumed by [`bun run check:perf-baseline`](./benchmark.md#perf-baseline-regression-guardrail) for local + weekly scheduled drift checks — not a PR merge gate). diff --git a/docs/glossary.md b/docs/glossary.md index 0712d5bc..651760cf 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -353,7 +353,7 @@ Rust-based CSS parser (NAPI bindings). Codemap's `src/css-parser.ts` uses its vi ### `codemap mcp` / MCP server -Stdio MCP (Model Context Protocol) server exposing codemap's structural-query surface to agent hosts (Claude Code, Cursor, Codex, generic MCP clients) as JSON-RPC tools — eliminates the bash round-trip on every agent invocation. **17 tools:** `query`, `query_batch` (no CLI verb), `query_recipe`, `audit`, `save_baseline`, `list_baselines`, `drop_baseline`, `context`, `validate`, `show`, `snippet`, `impact`, `affected`, `trace`, `explore`, `node`, `apply`. Subset via **`CODEMAP_MCP_TOOLS`** ([agents.md § MCP tool allowlist](./agents.md#mcp-tool-allowlist)). **Resources:** `codemap://schema`, `codemap://skill`, `codemap://rule`, `codemap://mcp-instructions`, `codemap://recipes`, `codemap://recipes/{id}`, `codemap://files/{path}`, `codemap://symbols/{name}`. Resource freshness is split by contract: schema / skill / rule / mcp-instructions are lazy-cached per server process; recipes, files, and symbols are live read-per-call so inline recency fields and index mutations under `--watch` don't freeze at first read. HTTP's `GET /resources/{encoded-uri}` uses the same resource handler. **Baseline tools** (`save_baseline`, `list_baselines`, `drop_baseline`) mirror `query --save-baseline` / `--baselines` / `--drop-baseline`. **`query_batch`**, **`trace`**, **`explore`**, and **`node`** have no CLI verb (MCP/HTTP composers only). Tool input/output keys are snake_case — Codemap's convention, matching the patterns in MCP spec examples and reference servers (GitHub MCP, Cursor built-ins); the spec itself doesn't mandate it. CLI stays kebab — translation lives at the MCP-arg layer. Output shape matches each tool's CLI `--json` payload where a CLI verb exists (no CLI verb: `query_batch`, `trace`, `explore`, `node`); MCP wraps payloads in `{content: [{type: "text", text: …}]}`. Bootstrap once at server boot; tool handlers (in `application/tool-handlers.ts`) and resource handlers (in `application/resource-handlers.ts`) are pure transport-agnostic — the same handlers serve `codemap serve` (HTTP) via `POST /tool/{name}` and `GET /resources/{encoded-uri}`. Implementation: `src/cli/cmd-mcp.ts` (CLI shell) + `src/application/mcp-server.ts` (engine). See [`architecture.md` § MCP wiring](./architecture.md#cli-usage). +Stdio MCP (Model Context Protocol) server exposing codemap's structural-query surface to agent hosts (Claude Code, Cursor, Codex, generic MCP clients) as JSON-RPC tools — eliminates the bash round-trip on every agent invocation. **17 tools:** `query`, `query_batch` (no CLI verb), `query_recipe`, `audit`, `save_baseline`, `list_baselines`, `drop_baseline`, `context`, `validate`, `show`, `snippet`, `impact`, `affected`, `trace`, `explore`, `node`, `apply`. Subset via **`CODEMAP_MCP_TOOLS`** ([agents.md § MCP tool allowlist](./agents.md#mcp-tool-allowlist)). **Resources:** `codemap://schema`, `codemap://skill`, `codemap://rule`, `codemap://mcp-instructions`, `codemap://recipes`, `codemap://recipes/{id}`, `codemap://files/{path}`, `codemap://symbols/{name}`. Resource freshness is split by contract: schema / skill / rule / mcp-instructions are lazy-cached per server process; recipes, files, and symbols are live read-per-call so inline recency fields and index mutations under `--watch` don't freeze at first read. HTTP's `GET /resources/{encoded-uri}` uses the same resource handler. **Baseline tools** (`save_baseline`, `list_baselines`, `drop_baseline`) mirror `query --save-baseline` / `--baselines` / `--drop-baseline`. **`query_batch`**, **`trace`**, **`explore`**, and **`node`** have no CLI verb (MCP/HTTP composers only). Tool input/output keys are snake_case — Codemap's convention, matching the patterns in MCP spec examples and reference servers (GitHub MCP, Cursor built-ins); the spec itself doesn't mandate it. CLI stays kebab — translation lives at the MCP-arg layer. Output shape matches each tool's CLI `--json` payload where a CLI verb exists (no CLI verb: `query_batch`, `trace`, `explore`, `node`); MCP wraps payloads in `{content: [{type: "text", text: …}]}`. Bootstrap once at server boot; tool handlers (in `application/tool-handlers.ts`) and resource handlers (in `application/resource-handlers.ts`) are pure transport-agnostic — the same handlers serve `codemap serve` (HTTP) via `POST /tool/{name}` and `GET /resources/{encoded-uri}`. **Session lifecycle:** exits on client disconnect (stdin EOF, stdout broken pipe, parent process exit, SIGINT/SIGTERM) via `session-lifecycle.ts`; **no idle timeout** — the process stays up while the pipe is open even without tool calls (see [§ Session lifecycle](./architecture.md#cli-usage)). With `--watch`, the watcher starts before connect and drains on exit. Implementation: `src/cli/cmd-mcp.ts` (CLI shell) + `src/application/mcp-server.ts` (engine). See [`architecture.md` § MCP wiring](./architecture.md#cli-usage). ### `query_batch` (no CLI verb; MCP + HTTP) @@ -508,6 +508,10 @@ Conceptually, the structure of the SQLite database — every table, column, cons Integer constant in `src/db.ts`. Bumped only for rebuild-forcing DDL changes; additive tables / columns / indexes can land through `CREATE ... IF NOT EXISTS` without a version bump. `createSchema()` reads `meta.schema_version` and triggers a full rebuild on mismatch. +### Session lifecycle + +Long-running transport shutdown rules in `src/application/session-lifecycle.ts`. **MCP:** `createStdioDisconnectMonitor` exits `codemap mcp` on client disconnect (stdin EOF, stdout `EPIPE`, boot parent PID gone, SIGINT/SIGTERM). **No idle timeout** — the process stays up while stdio is open even without tool calls; IDE hosts do not respawn MCP mid-session. **HTTP:** `createManagedWatchSession` refcount-gates chokidar per request (`GET /health` excluded); **`HTTP_WATCH_RELEASE_GRACE_MS`** (5s) stops the watcher between stateless requests without shutting down the HTTP listener — not an MCP-style idle kill. See [architecture.md § Session lifecycle wiring](./architecture.md#cli-usage). + ### show `codemap show ` — one-step lookup that returns metadata (`file_path:line_start-line_end` + `signature` + `kind`) for symbol(s). **Exact mode:** `` is case-sensitive; flags `--kind`, `--in`. **Field-qualified mode:** `--query 'kind:… name:… path:… in:…'` with optional free text; `--with-fts` (or `fts5: true` when indexed) searches file bodies via `source_fts` and returns every symbol in matching files; `--print-sql` prints Moat-A equivalent SQL. Output: `{matches, disambiguation?, warning?}` (`warning` when FTS was requested but `source_fts` is empty). MCP: `show` with `{name}` or `{query, with_fts?}`. Distinct from **snippet** (adds source text) and from hand-composed `query` SQL. See [`architecture.md` § Show / snippet wiring](./architecture.md#cli-usage). @@ -525,14 +529,14 @@ Opt-in substrate. Markers parser recognises `// codemap-ignore-next-line ` requires `Authorization: Bearer ` on every request. HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper); `query` / `query_recipe` match `codemap query --json` row arrays unless `summary` / `group_by` reshape the envelope (baseline save/compare is separate tools — not on MCP/HTTP `query` / `query_recipe`); other tools match their CLI `--json` payloads; `format: "sarif"` payloads ship as `application/sarif+json`, `format: "annotations"` / `"mermaid"` / `"diff"` as `text/plain; charset=utf-8`, `format: "diff-json"` as `application/json; charset=utf-8`. Routes: `POST /tool/{name}` (every MCP tool), `GET /resources/{encoded-uri}` (resource handler for `codemap://recipes`, `codemap://recipes/{id}`, `codemap://schema`, `codemap://skill`, `codemap://rule`, `codemap://mcp-instructions`, `codemap://files/{path}`, and `codemap://symbols/{name}`), `GET /health` (auth-exempt liveness probe), `GET /tools` / `GET /resources` (catalogs). Pure transport — same `tool-handlers.ts` / `resource-handlers.ts` MCP uses; no engine duplication. Errors → `{"error": "..."}` with HTTP status 400 / 401 / 403 / 404 / 500. SIGINT / SIGTERM → graceful drain. Every response carries `X-Codemap-Version: `. **CSRF + DNS-rebinding guard:** every request (including auth-exempt `/health`) is evaluated against `Sec-Fetch-Site` / `Origin` / `Host` when present — modern browsers send `Sec-Fetch-Site` and `Origin` on cross-origin fetches (header presence varies by request type, browser, and privacy settings), so the guard rejects browser-driven cross-origin requests like a malicious local webpage `fetch`-ing `http://127.0.0.1:7878/tool/save_baseline` to mutate `.codemap/index.db`. `Host` mismatch on a loopback bind blocks DNS rebinding (an attacker resolving `evil.com` to `127.0.0.1` post-load). Non-browser clients (curl, fetch from Node, MCP hosts, CI scripts) typically omit these headers and pass through. Implementation: `src/cli/cmd-serve.ts` (CLI shell) + `src/application/http-server.ts` (transport). See [`architecture.md` § HTTP wiring](./architecture.md#cli-usage). +Long-running HTTP server exposing the same tool taxonomy as `codemap mcp` over `POST /tool/{name}` for non-MCP consumers (CI scripts, simple `curl`, IDE plugins that don't speak MCP). Default bind **`127.0.0.1:7878`** (loopback only — refuse `0.0.0.0` unless explicitly opted in via `--host 0.0.0.0`); optional `--token ` requires `Authorization: Bearer ` on every request. HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper); `query` / `query_recipe` match `codemap query --json` row arrays unless `summary` / `group_by` reshape the envelope (baseline save/compare is separate tools — not on MCP/HTTP `query` / `query_recipe`); other tools match their CLI `--json` payloads; `format: "sarif"` payloads ship as `application/sarif+json`, `format: "annotations"` / `"mermaid"` / `"diff"` as `text/plain; charset=utf-8`, `format: "diff-json"` as `application/json; charset=utf-8`. Routes: `POST /tool/{name}` (every MCP tool), `GET /resources/{encoded-uri}` (resource handler for `codemap://recipes`, `codemap://recipes/{id}`, `codemap://schema`, `codemap://skill`, `codemap://rule`, `codemap://mcp-instructions`, `codemap://files/{path}`, and `codemap://symbols/{name}`), `GET /health` (auth-exempt liveness probe — does not start the watcher), `GET /tools` / `GET /resources` (catalogs). With `--watch`, chokidar is refcount-gated per request and stops 5s after the last client (`HTTP_WATCH_RELEASE_GRACE_MS`) — distinct from MCP idle shutdown; the HTTP process keeps listening. Pure transport — same `tool-handlers.ts` / `resource-handlers.ts` MCP uses; no engine duplication. Errors → `{"error": "..."}` with HTTP status 400 / 401 / 403 / 404 / 500. SIGINT / SIGTERM → graceful drain. Every response carries `X-Codemap-Version: `. **CSRF + DNS-rebinding guard:** every request (including auth-exempt `/health`) is evaluated against `Sec-Fetch-Site` / `Origin` / `Host` when present — modern browsers send `Sec-Fetch-Site` and `Origin` on cross-origin fetches (header presence varies by request type, browser, and privacy settings), so the guard rejects browser-driven cross-origin requests like a malicious local webpage `fetch`-ing `http://127.0.0.1:7878/tool/save_baseline` to mutate `.codemap/index.db`. `Host` mismatch on a loopback bind blocks DNS rebinding (an attacker resolving `evil.com` to `127.0.0.1` post-load). Non-browser clients (curl, fetch from Node, MCP hosts, CI scripts) typically omit these headers and pass through. Implementation: `src/cli/cmd-serve.ts` (CLI shell) + `src/application/http-server.ts` (transport). See [`architecture.md` § HTTP wiring](./architecture.md#cli-usage). ### SARIF diff --git a/docs/roadmap.md b/docs/roadmap.md index 7eb6f2f6..0905453a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -80,7 +80,7 @@ Long-running MCP / HTTP sessions dominate agent workflows; one-shot CLI keeps th - [ ] **Codebase map in bootstrap responses** — hash-stable structural summary (top hubs, CLI entry hints, schema version, index freshness) auto-included in `context` / MCP initialize payload. **Partial:** hubs + `start_here.index_summary` + `index_freshness` ship on `context`; CLI entry hints + hash-stable map id still open. Opt-out via flag. Effort: S–M. - [x] **Index staleness surfacing** — `index_freshness.pending_sync` on `context`, MCP tool metadata, and HTTP headers when the watcher debounce queue or in-flight reindex is active. Shipped [#149](https://github.com/stainless-code/codemap/pull/149). - [x] **Adaptive output budgets** — scale trace/explore/node snippet char caps (and explore row limits) from indexed file counts via **`resolveOutputBudget(file_count)`** in `output-budget.ts`. Shipped [#152](https://github.com/stainless-code/codemap/pull/152). **`context`** hub/signature caps remain in **`resolveContextBudget()`**. -- [ ] **MCP session lifecycle hygiene** — idle timeout, client disconnect detection, graceful watcher shutdown on last client; avoid orphaned watchers after agent host crashes. Effort: S–M. +- [x] **MCP session lifecycle hygiene** — stdio disconnect detection (stdin EOF, stdout EPIPE, parent-PID poll, SIGINT/SIGTERM) and refcount-gated watcher stop on MCP client exit; HTTP `serve --watch` starts/stops the watcher per client (5s release grace between stateless requests; `/health` excluded). **Explicitly no MCP idle timeout** — process stays up while the stdio pipe is open even without tool calls (IDE hosts do not respawn mid-session). See [architecture.md § Session lifecycle wiring](./architecture.md#cli-usage). Effort: S–M. - [ ] **`agents init` uninstall (teardown)** — symmetric inverse of init for failed pilots, template mistakes, or leaving a repo: remove codemap-managed MCP entries, pointer sections, and IDE symlinks only (same scoped paths as init; never delete user-authored `.agents/` siblings). `--target` filter, `--yes` non-interactive. Not the happy-path docs story — adoption stays `init --mcp --git-hooks` + committed `.agents/`. Effort: S. - [x] **HEAD / index freshness warning** — `index_freshness.commit_drift` + `warning` on `context` / tool metadata; boot stderr on `codemap mcp` / `serve` when concerns remain after prime. Shipped [#149](https://github.com/stainless-code/codemap/pull/149). diff --git a/src/application/http-server.test.ts b/src/application/http-server.test.ts index 5979e228..5fafaf37 100644 --- a/src/application/http-server.test.ts +++ b/src/application/http-server.test.ts @@ -9,6 +9,8 @@ import { resolveCodemapConfig } from "../config"; import { closeDb, createTables, openDb } from "../db"; import { initCodemap } from "../runtime"; import { handleRequest } from "./http-server"; +import { createManagedWatchSession } from "./session-lifecycle"; +import { _resetWatchStateForTests } from "./watcher"; let benchDir: string; let serverHandle: { close: () => Promise; port: number } | undefined; @@ -37,6 +39,7 @@ afterEach(async () => { await serverHandle.close(); serverHandle = undefined; } + _resetWatchStateForTests(); rmSync(benchDir, { recursive: true, force: true }); }); @@ -134,6 +137,73 @@ describe("http-server — health + tools catalog", () => { }); }); +describe("http-server — managed watch session", () => { + it("acquires a watch client for tool routes but not /health", async () => { + let backendStarted = false; + const session = createManagedWatchSession({ + root: benchDir, + excludeDirNames: new Set(["node_modules", ".git", "dist"]), + recipesWatchPrefix: ".codemap/recipes/", + debounceMs: 0, + onChange: () => {}, + releaseGraceMs: 25, + backend: { + start() { + backendStarted = true; + }, + async stop() {}, + }, + }); + + const serverRef: Server = createServer((req, res) => { + void handleRequest(req, res, { + version: "0.0.0-test", + root: benchDir, + host: "127.0.0.1", + port: 0, + token: undefined, + managedWatchSession: session, + }).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + res.statusCode = 500; + res.end(JSON.stringify({ error: msg })); + }); + }); + await new Promise((resolve, reject) => { + serverRef.once("error", reject); + serverRef.listen(0, "127.0.0.1", () => resolve()); + }); + const addr = serverRef.address(); + if (typeof addr !== "object" || addr === null) { + throw new Error("expected AddressInfo"); + } + + try { + expect(session.isWatching()).toBe(false); + const health = await fetch(`http://127.0.0.1:${addr.port}/health`); + expect(health.status).toBe(200); + expect(session.clientCount()).toBe(0); + expect(session.isWatching()).toBe(false); + expect(backendStarted).toBe(false); + + const tool = await postTool(addr.port, "query", { + sql: "SELECT COUNT(*) AS count FROM symbols", + }); + expect(tool.status).toBe(200); + expect(session.clientCount()).toBe(0); + expect(backendStarted).toBe(true); + expect(session.isWatching()).toBe(true); + + await Bun.sleep(50); + await session.forceStop(); + } finally { + await new Promise((resolve) => { + serverRef.close(() => resolve()); + }); + } + }); +}); + describe("http-server — POST /tool/query", () => { it("returns row array for ad-hoc SQL", async () => { serverHandle = await startServer(); diff --git a/src/application/http-server.ts b/src/application/http-server.ts index 1d79b20c..d2c16cbc 100644 --- a/src/application/http-server.ts +++ b/src/application/http-server.ts @@ -19,6 +19,12 @@ import { } from "./index-freshness"; import type { IndexFreshness } from "./index-freshness"; import { listResources, readResource } from "./resource-handlers"; +import { + bindWatchClientRelease, + createManagedWatchSession, + HTTP_WATCH_RELEASE_GRACE_MS, +} from "./session-lifecycle"; +import type { ManagedWatchSession } from "./session-lifecycle"; import { affectedArgsSchema, applyArgsSchema, @@ -61,7 +67,6 @@ import { createReindexOnChange, DEFAULT_DEBOUNCE_MS, resolveRecipesWatchPrefix, - runWatchLoop, } from "./watcher"; /** @@ -93,6 +98,8 @@ export interface HttpServerOpts { watch?: boolean; /** Coalesce burst events into one reindex after `debounceMs` of quiet. Only meaningful when `watch: true`. */ debounceMs?: number; + /** Injected by `runHttpServer` or tests. */ + managedWatchSession?: ManagedWatchSession; } const TOOL_NAMES = [ @@ -124,18 +131,17 @@ const TOOL_NAMES = [ export async function runHttpServer(opts: HttpServerOpts): Promise { await bootstrapForServe(opts); - let stopWatch: (() => Promise) | undefined; - let watchReady: Promise = Promise.resolve(); + let managedWatchSession: ManagedWatchSession | undefined; if (opts.watch === true) { - const prime = createPrimeIndex({ quiet: false, label: "codemap serve" }); - const handle = runWatchLoop({ + managedWatchSession = createManagedWatchSession({ root: getProjectRoot(), excludeDirNames: getExcludeDirNames(), recipesWatchPrefix: resolveRecipesWatchPrefix(getProjectRoot()), debounceMs: opts.debounceMs ?? DEFAULT_DEBOUNCE_MS, + releaseGraceMs: HTTP_WATCH_RELEASE_GRACE_MS, onPrime: async () => { try { - await prime(); + await createPrimeIndex({ quiet: false, label: "codemap serve" })(); } finally { warnIndexFreshnessToStderr("codemap serve"); } @@ -145,16 +151,17 @@ export async function runHttpServer(opts: HttpServerOpts): Promise { label: "codemap serve", }), }); - stopWatch = handle.stop; - watchReady = handle.ready; } else { warnIndexFreshnessToStderr("codemap serve"); } - await watchReady; + const serveOpts: HttpServerOpts = { + ...opts, + managedWatchSession, + }; const server = createServer((req, res) => { - handleRequest(req, res, opts).catch((err: unknown) => { + handleRequest(req, res, serveOpts).catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); writeJson(res, 500, { error: msg }, opts.version); }); @@ -180,12 +187,9 @@ export async function runHttpServer(opts: HttpServerOpts): Promise { const closeServer = (): void => { server.close(() => resolve()); }; - if (stopWatch !== undefined) { - // .finally(closeServer) so a watcher stop() rejection still - // closes the HTTP listener — without it, a rejected stop() - // means closeServer never runs and runHttpServer never resolves - // on SIGTERM/SIGINT (caught by CodeRabbit on PR #47). - stopWatch() + if (managedWatchSession !== undefined) { + managedWatchSession + .forceStop() .catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); // eslint-disable-next-line no-console -- intentional shutdown-error log @@ -257,7 +261,7 @@ export async function handleRequest( ); } - // Liveness probe — auth-exempt so monitoring works without the token. + // Liveness probe — auth-exempt; no watch client (probes must not keep chokidar hot). if (method === "GET" && path === "/health") { const freshness = readCheapIndexFreshness(); applyIndexFreshnessHeaders(res, freshness); @@ -289,6 +293,11 @@ export async function handleRequest( } } + if (opts.managedWatchSession !== undefined) { + await opts.managedWatchSession.acquireClient(); + bindWatchClientRelease(res, opts.managedWatchSession); + } + if (method === "GET" && path === "/tools") { return writeJson( res, diff --git a/src/application/mcp-server.ts b/src/application/mcp-server.ts index 562576d0..ffa25ed3 100644 --- a/src/application/mcp-server.ts +++ b/src/application/mcp-server.ts @@ -29,6 +29,11 @@ import type { McpToolName } from "./mcp-tool-allowlist"; import { listQueryRecipeCatalog } from "./query-recipes"; import { readResource } from "./resource-handlers"; import type { ResourcePayload } from "./resource-handlers"; +import { + createManagedWatchSession, + createStdioDisconnectMonitor, +} from "./session-lifecycle"; +import type { ManagedWatchSession } from "./session-lifecycle"; import { affectedArgsSchema, applyArgsSchema, @@ -71,7 +76,6 @@ import { createReindexOnChange, DEFAULT_DEBOUNCE_MS, resolveRecipesWatchPrefix, - runWatchLoop, } from "./watcher"; /** @@ -573,28 +577,25 @@ async function bootstrapForMcp(opts: ServerOpts): Promise { } /** - * Starts the MCP server over stdio. HTTP consumers use `codemap serve` - * (`src/application/http-server.ts`) against the same tool handlers. - * Resolves when the transport closes (stdin EOF). Logs to stderr per MCP - * convention so stdout stays dedicated to JSON-RPC framing. + * Starts the MCP server over stdio. Resolves on client disconnect + * (`session-lifecycle.ts`). Logs to stderr per MCP convention. */ export async function runMcpServer(opts: ServerOpts): Promise { await bootstrapForMcp(opts); - let stopWatch: (() => Promise) | undefined; - let watchReady: Promise = Promise.resolve(); + let watchSession: ManagedWatchSession | undefined; if (opts.watch === true) { // eslint-disable-next-line no-console -- intentional bootstrap log on stderr console.error("codemap mcp: --watch enabled, booting file watcher..."); - const prime = createPrimeIndex({ quiet: false, label: "codemap mcp" }); - const handle = runWatchLoop({ + watchSession = createManagedWatchSession({ root: getProjectRoot(), excludeDirNames: getExcludeDirNames(), recipesWatchPrefix: resolveRecipesWatchPrefix(getProjectRoot()), debounceMs: opts.debounceMs ?? DEFAULT_DEBOUNCE_MS, + releaseGraceMs: 0, onPrime: async () => { try { - await prime(); + await createPrimeIndex({ quiet: false, label: "codemap mcp" })(); } finally { warnIndexFreshnessToStderr("codemap mcp"); } @@ -604,29 +605,61 @@ export async function runMcpServer(opts: ServerOpts): Promise { label: "codemap mcp", }), }); - stopWatch = handle.stop; - watchReady = handle.ready; } else { warnIndexFreshnessToStderr("codemap mcp"); } - await watchReady; - const server = createMcpServer(opts); const transport = new StdioServerTransport(); + + if (watchSession !== undefined) { + await watchSession.acquireClient(); + } + await server.connect(transport); + let shuttingDown = false; await new Promise((resolve) => { - transport.onclose = () => resolve(); - }); + const shutdown = (reason: string): void => { + if (shuttingDown) return; + shuttingDown = true; + // eslint-disable-next-line no-console -- intentional shutdown log on stderr + console.error(`codemap mcp: ${reason}, shutting down...`); + disconnectMonitor.dispose(); + void (async () => { + try { + await server.close(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // eslint-disable-next-line no-console -- intentional shutdown-error log + console.error(`codemap mcp: server close failed — ${msg}`); + } + if (watchSession !== undefined) { + try { + await watchSession.forceStop(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // eslint-disable-next-line no-console -- intentional shutdown-error log + console.error(`codemap mcp: watcher stop failed — ${msg}`); + } + } + resolve(); + })(); + }; - if (stopWatch !== undefined) { - try { - await stopWatch(); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - // eslint-disable-next-line no-console -- intentional shutdown-error log - console.error(`codemap mcp: watcher stop failed — ${msg}`); - } - } + transport.onclose = () => { + shutdown("transport closed"); + }; + + const disconnectMonitor = createStdioDisconnectMonitor((reason) => { + shutdown(reason); + }); + + process.once("SIGINT", () => { + shutdown("SIGINT"); + }); + process.once("SIGTERM", () => { + shutdown("SIGTERM"); + }); + }); } diff --git a/src/application/session-lifecycle.test.ts b/src/application/session-lifecycle.test.ts new file mode 100644 index 00000000..b4713b66 --- /dev/null +++ b/src/application/session-lifecycle.test.ts @@ -0,0 +1,310 @@ +import { afterEach, describe, expect, it, spyOn } from "bun:test"; +import { EventEmitter } from "node:events"; + +import { + bindWatchClientRelease, + createManagedWatchSession, + createStdioDisconnectMonitor, + isProcessAlive, +} from "./session-lifecycle"; +import type { WatchBackend } from "./watcher"; +import { _resetWatchStateForTests } from "./watcher"; + +function fakeBackend(): WatchBackend { + return { + start() {}, + async stop() {}, + }; +} + +afterEach(() => { + _resetWatchStateForTests(); +}); + +describe("isProcessAlive", () => { + it("returns true for the current process", () => { + expect(isProcessAlive(process.pid)).toBe(true); + }); + + it("returns false for a non-existent pid", () => { + expect(isProcessAlive(2_147_483_647)).toBe(false); + }); +}); + +describe("createStdioDisconnectMonitor", () => { + const originalStdin = process.stdin; + const originalStdout = process.stdout; + + afterEach(() => { + Object.defineProperty(process, "stdin", { + configurable: true, + value: originalStdin, + }); + Object.defineProperty(process, "stdout", { + configurable: true, + value: originalStdout, + }); + }); + + it("fires on stdin end", () => { + const stdin = new EventEmitter(); + const stdout = new EventEmitter(); + Object.defineProperty(process, "stdin", { + configurable: true, + value: stdin, + }); + Object.defineProperty(process, "stdout", { + configurable: true, + value: stdout, + }); + + const reasons: string[] = []; + const monitor = createStdioDisconnectMonitor( + (reason) => reasons.push(reason), + { + parentPid: process.pid, + pollIntervalMs: 60_000, + }, + ); + stdin.emit("end"); + expect(reasons).toEqual(["client stdin closed"]); + monitor.dispose(); + }); + + it("fires on stdout EPIPE", () => { + const stdin = new EventEmitter(); + const stdout = new EventEmitter(); + Object.defineProperty(process, "stdin", { + configurable: true, + value: stdin, + }); + Object.defineProperty(process, "stdout", { + configurable: true, + value: stdout, + }); + + const reasons: string[] = []; + const monitor = createStdioDisconnectMonitor( + (reason) => reasons.push(reason), + { + parentPid: process.pid, + pollIntervalMs: 60_000, + }, + ); + stdout.emit( + "error", + Object.assign(new Error("broken pipe"), { code: "EPIPE" }), + ); + expect(reasons).toEqual(["client stdout broken pipe"]); + monitor.dispose(); + }); + + it("fires when the parent pid is no longer alive", async () => { + const stdin = new EventEmitter(); + const stdout = new EventEmitter(); + Object.defineProperty(process, "stdin", { + configurable: true, + value: stdin, + }); + Object.defineProperty(process, "stdout", { + configurable: true, + value: stdout, + }); + + const alive = spyOn( + await import("./session-lifecycle"), + "isProcessAlive", + ).mockReturnValue(false); + + try { + const reasons: string[] = []; + const monitor = createStdioDisconnectMonitor( + (reason) => reasons.push(reason), + { + parentPid: 42, + pollIntervalMs: 5, + }, + ); + + await Bun.sleep(20); + expect(reasons).toEqual(["parent process exited"]); + monitor.dispose(); + } finally { + alive.mockRestore(); + } + }); +}); + +describe("createManagedWatchSession", () => { + it("starts the watcher on first acquire and stops on last release", async () => { + const backend = fakeBackend(); + const session = createManagedWatchSession({ + root: "/tmp", + excludeDirNames: new Set(["node_modules"]), + recipesWatchPrefix: ".codemap/recipes/", + debounceMs: 0, + onChange: () => {}, + releaseGraceMs: 0, + backend, + }); + + expect(session.isWatching()).toBe(false); + await session.acquireClient(); + expect(session.isWatching()).toBe(true); + expect(session.clientCount()).toBe(1); + + await session.releaseClient(); + expect(session.isWatching()).toBe(false); + expect(session.clientCount()).toBe(0); + }); + + it("keeps the watcher alive while multiple clients are held", async () => { + const backend = fakeBackend(); + const session = createManagedWatchSession({ + root: "/tmp", + excludeDirNames: new Set(["node_modules"]), + recipesWatchPrefix: ".codemap/recipes/", + debounceMs: 0, + onChange: () => {}, + releaseGraceMs: 0, + backend, + }); + + await session.acquireClient(); + await session.acquireClient(); + expect(session.clientCount()).toBe(2); + + await session.releaseClient(); + expect(session.isWatching()).toBe(true); + + await session.releaseClient(); + expect(session.isWatching()).toBe(false); + }); + + it("debounces watcher stop for HTTP grace", async () => { + const graceMs = 25; + const backend = fakeBackend(); + const session = createManagedWatchSession({ + root: "/tmp", + excludeDirNames: new Set(["node_modules"]), + recipesWatchPrefix: ".codemap/recipes/", + debounceMs: 0, + onChange: () => {}, + releaseGraceMs: graceMs, + backend, + }); + + await session.acquireClient(); + await session.releaseClient(); + expect(session.isWatching()).toBe(true); + + await Bun.sleep(graceMs + 20); + expect(session.isWatching()).toBe(false); + }); + + it("cancels scheduled stop when a new client acquires during grace", async () => { + const graceMs = 25; + const backend = fakeBackend(); + const session = createManagedWatchSession({ + root: "/tmp", + excludeDirNames: new Set(["node_modules"]), + recipesWatchPrefix: ".codemap/recipes/", + debounceMs: 0, + onChange: () => {}, + releaseGraceMs: graceMs, + backend, + }); + + await session.acquireClient(); + await session.releaseClient(); + await Bun.sleep(10); + await session.acquireClient(); + await Bun.sleep(graceMs + 20); + expect(session.isWatching()).toBe(true); + await session.forceStop(); + }); + + it("forceStop waits for in-flight startup before returning", async () => { + const primeGate = Promise.withResolvers(); + let stopCalled = false; + const backend: WatchBackend = { + start() {}, + async stop() { + stopCalled = true; + }, + }; + + const session = createManagedWatchSession({ + root: "/tmp", + excludeDirNames: new Set(["node_modules"]), + recipesWatchPrefix: ".codemap/recipes/", + debounceMs: 0, + onChange: () => {}, + releaseGraceMs: 0, + onPrime: async () => { + await primeGate.promise; + }, + backend, + }); + + const acquirePromise = session.acquireClient(); + await Bun.sleep(0); + expect(session.isWatching()).toBe(false); + + const forceStopPromise = session.forceStop(); + await Bun.sleep(5); + expect(session.isWatching()).toBe(false); + expect(stopCalled).toBe(false); + + primeGate.resolve(); + await Promise.all([acquirePromise, forceStopPromise]); + + expect(session.isWatching()).toBe(false); + expect(stopCalled).toBe(true); + }); + + it("rolls back client count when ensureStarted fails", async () => { + const session = createManagedWatchSession({ + root: "/tmp", + excludeDirNames: new Set(["node_modules"]), + recipesWatchPrefix: ".codemap/recipes/", + debounceMs: 0, + onChange: () => {}, + releaseGraceMs: 0, + backend: { + start() { + throw new Error("start failed"); + }, + async stop() {}, + }, + }); + + await expect(session.acquireClient()).rejects.toThrow("start failed"); + expect(session.clientCount()).toBe(0); + expect(session.isWatching()).toBe(false); + }); +}); + +describe("bindWatchClientRelease", () => { + it("releases once on finish or close", async () => { + const backend = fakeBackend(); + const session = createManagedWatchSession({ + root: "/tmp", + excludeDirNames: new Set(["node_modules"]), + recipesWatchPrefix: ".codemap/recipes/", + debounceMs: 0, + onChange: () => {}, + releaseGraceMs: 0, + backend, + }); + await session.acquireClient(); + + const res = new EventEmitter(); + bindWatchClientRelease(res, session); + res.emit("finish"); + res.emit("close"); + await Bun.sleep(0); + expect(session.clientCount()).toBe(0); + expect(session.isWatching()).toBe(false); + }); +}); diff --git a/src/application/session-lifecycle.ts b/src/application/session-lifecycle.ts new file mode 100644 index 00000000..69937df6 --- /dev/null +++ b/src/application/session-lifecycle.ts @@ -0,0 +1,233 @@ +import type { WatchBackend } from "./watcher"; +import { runWatchLoop } from "./watcher"; + +/** Session lifecycle for long-running `mcp` / `serve` — see docs/architecture.md § Session lifecycle wiring. */ + +/** Watcher stop grace between HTTP requests — not MCP idle shutdown. */ +export const HTTP_WATCH_RELEASE_GRACE_MS = 5000; + +export const STDIO_PARENT_POLL_MS = 2000; + +export function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (err: unknown) { + if ( + err !== null && + typeof err === "object" && + "code" in err && + err.code === "ESRCH" + ) { + return false; + } + // EPERM — process exists but we cannot signal it. + return true; + } +} + +export interface StdioDisconnectMonitor { + dispose(): void; +} + +/** SDK stdio `transport.onclose` fires only after explicit `transport.close()`. */ +export function createStdioDisconnectMonitor( + onDisconnect: (reason: string) => void, + opts?: { + parentPid?: number; + pollIntervalMs?: number; + }, +): StdioDisconnectMonitor { + let disposed = false; + const parentPid = opts?.parentPid ?? process.ppid; + + const finish = (reason: string): void => { + if (disposed) return; + disposed = true; + clearInterval(pollTimer); + process.stdin.off("end", onStdinClosed); + process.stdin.off("close", onStdinClosed); + process.stdout.off("error", onStdoutError); + onDisconnect(reason); + }; + + const onStdinClosed = (): void => { + finish("client stdin closed"); + }; + + const onStdoutError = (err: NodeJS.ErrnoException): void => { + if (err.code === "EPIPE") { + finish("client stdout broken pipe"); + } + }; + + process.stdin.on("end", onStdinClosed); + process.stdin.on("close", onStdinClosed); + process.stdout.on("error", onStdoutError); + + const pollTimer = setInterval(() => { + if (!isProcessAlive(parentPid)) { + finish("parent process exited"); + } + }, opts?.pollIntervalMs ?? STDIO_PARENT_POLL_MS); + pollTimer.unref?.(); + + return { + dispose(): void { + if (disposed) return; + disposed = true; + clearInterval(pollTimer); + process.stdin.off("end", onStdinClosed); + process.stdin.off("close", onStdinClosed); + process.stdout.off("error", onStdoutError); + }, + }; +} + +export interface ManagedWatchSessionOpts { + root: string; + excludeDirNames: ReadonlySet; + recipesWatchPrefix: string; + debounceMs: number; + onPrime?: () => Promise; + onChange: (paths: ReadonlySet) => void | Promise; + /** MCP passes 0; HTTP uses `HTTP_WATCH_RELEASE_GRACE_MS`. */ + releaseGraceMs?: number; + backend?: WatchBackend; +} + +export interface ManagedWatchSession { + acquireClient(): Promise; + releaseClient(): Promise; + forceStop(): Promise; + clientCount(): number; + isWatching(): boolean; +} + +export function createManagedWatchSession( + opts: ManagedWatchSessionOpts, +): ManagedWatchSession { + let clients = 0; + let handle: { stop: () => Promise; ready: Promise } | undefined; + let starting: Promise | undefined; + let stopInFlight: Promise | undefined; + let stopTimer: ReturnType | undefined; + const releaseGraceMs = opts.releaseGraceMs ?? 0; + + const cancelScheduledStop = (): void => { + if (stopTimer !== undefined) { + clearTimeout(stopTimer); + stopTimer = undefined; + } + }; + + const ensureStarted = async (): Promise => { + if (handle !== undefined) { + await handle.ready; + return; + } + if (starting !== undefined) { + await starting; + return; + } + starting = (async () => { + const loop = runWatchLoop({ + root: opts.root, + excludeDirNames: opts.excludeDirNames, + recipesWatchPrefix: opts.recipesWatchPrefix, + debounceMs: opts.debounceMs, + onPrime: opts.onPrime, + onChange: opts.onChange, + backend: opts.backend, + }); + await loop.ready; + handle = loop; + })(); + try { + await starting; + } finally { + starting = undefined; + } + }; + + const stopWatcher = async (): Promise => { + cancelScheduledStop(); + if (starting !== undefined) { + await starting; + } + if (handle === undefined) return; + const current = handle; + handle = undefined; + const stopping = current.stop().finally(() => { + if (stopInFlight === stopping) stopInFlight = undefined; + }); + stopInFlight = stopping; + await stopping; + }; + + return { + async acquireClient() { + cancelScheduledStop(); + if (stopInFlight !== undefined) { + await stopInFlight; + } + clients++; + try { + await ensureStarted(); + } catch (err) { + clients--; + throw err; + } + }, + async releaseClient() { + if (clients <= 0) return; + clients--; + if (clients > 0) return; + if (releaseGraceMs <= 0) { + await stopWatcher(); + return; + } + cancelScheduledStop(); + stopTimer = setTimeout(() => { + stopTimer = undefined; + if (clients === 0) { + void stopWatcher().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + // eslint-disable-next-line no-console -- grace stop failure should be visible + console.error(`codemap watch: grace stop failed — ${msg}`); + }); + } + }, releaseGraceMs); + stopTimer.unref?.(); + }, + async forceStop() { + clients = 0; + await stopWatcher(); + }, + clientCount() { + return clients; + }, + isWatching() { + return handle !== undefined; + }, + }; +} + +export function bindWatchClientRelease( + res: { once(event: "finish" | "close", listener: () => void): void }, + session: ManagedWatchSession, +): void { + let released = false; + const release = (): void => { + if (released) return; + released = true; + void session.releaseClient().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + // eslint-disable-next-line no-console -- release failure should be visible + console.error(`codemap watch: client release failed — ${msg}`); + }); + }; + res.once("finish", release); + res.once("close", release); +} diff --git a/src/cli/cmd-mcp.ts b/src/cli/cmd-mcp.ts index 28fb39b0..e5dd7a5c 100644 --- a/src/cli/cmd-mcp.ts +++ b/src/cli/cmd-mcp.ts @@ -147,14 +147,17 @@ Global flags (parsed by bootstrap, forwarded to the server): --config Config file path (defaults to /config.{ts,js,json}, i.e. .codemap/config.{ts,js,json} unless --state-dir overrides). -The server stays running until stdin closes (the agent host disconnects). -With --watch, the file watcher is drained before the server exits. +The server stays running until the MCP client disconnects (stdin EOF, +stdout broken pipe, parent process exit, or SIGINT/SIGTERM). There is +no idle timeout — silence without tool calls does not exit the process +(the IDE host would not reliably respawn it mid-session). With --watch, +the file watcher starts before connect and is drained before exit. `); } /** * Entry-point for `codemap mcp`. Boots the MCP server over stdio and - * resolves when the transport closes (clean shutdown via stdin EOF). + * resolves when the client disconnects (see session-lifecycle.ts). * With `watch: true`, also boots an in-process file watcher so the * server's tools always read live data. Bootstrap / DB / SDK errors * propagate as exit code 1 via main. diff --git a/src/cli/cmd-serve.ts b/src/cli/cmd-serve.ts index 597c7854..8e85157b 100644 --- a/src/cli/cmd-serve.ts +++ b/src/cli/cmd-serve.ts @@ -187,6 +187,11 @@ Flags: watcher is active (default; no-op with --no-watch). --help, -h Show this help. +With --watch, chokidar starts on the first authenticated non-/health +request and stops 5s after the last request completes. That release +grace stops the watcher between stateless requests — not an idle shutdown +of the HTTP listener itself. + Routes (every MCP tool maps to POST /tool/; HTTP returns each tool's native JSON payload directly — NOT the MCP {content: [...]} wrapper):