diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/README.md b/README.md index e325922..df5bde6 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,16 @@ npm install ## CLI Usage (for development/testing) ``` -tsx ./cli.ts +tsx ./cli.ts [--no-daemon] +tsx ./cli.ts daemon ``` +Requests use a long-lived background daemon by default. The daemon is +autospawned on first use, keeps one language server alive per +`(server.id, project root)`, and evicts idle servers after +`ServerConfig.idleTtlMs` (default: 5 minutes). Pass `--no-daemon` to use the +legacy one-shot path for debugging. + `req_data_json` is the raw LSP params for the command, minus `textDocument.uri` (we inject that from ``). @@ -71,9 +78,15 @@ npm run lsp -- backend/api/server.go documentSymbol '{}' # Diagnostics npm run lsp -- backend/api/server.go diagnostics '{}' + +# Inspect/stop the background daemon +npm run lsp -- daemon status +npm run lsp -- daemon stop ``` -Set `LSP_DEBUG=1` to forward server stderr. +Set `LSP_DEBUG=1` to forward server stderr to the daemon log. The daemon +socket is `$XDG_RUNTIME_DIR/pi-lsp-$UID.sock` (tmpdir fallback); logs are in +`/tmp/pi-lsp-daemon.log`. ## Adding A Server @@ -97,7 +110,5 @@ Edit `server.ts`: ## Future -- **Daemon with TTL** - `ServerConfig.idleTtlMs` is reserved for a future - daemon that keeps language servers alive per `(server.id, rootUri)` to - avoid cold-start latency. Not implemented; the CLI is short-lived and - spawns fresh each invocation. +- **Daemon hardening** - persistent metrics, health checks, and richer status output. +- **Build output** - ship compiled JS entrypoints instead of relying on tsx for development. diff --git a/cli.ts b/cli.ts index 88d2b2b..76a611b 100755 --- a/cli.ts +++ b/cli.ts @@ -3,28 +3,138 @@ import * as path from "node:path"; import { startClientForFile } from "./src/client.ts"; import { isLspCommand, listCommands, runCommand } from "./src/commands.ts"; import { pickServer } from "./src/root.ts"; +import { + daemonDiagnostics, + daemonRequest, + daemonShutdown, + daemonStatus, +} from "./src/daemonClient.ts"; +import { socketPath } from "./src/daemonProtocol.ts"; // Usage function usage(): never { process.stderr.write( - `Usage: cli.ts \n` + + `Usage:\n` + + ` cli.ts [--no-daemon]\n` + + ` cli.ts daemon \n` + + `\n` + `Commands: ${listCommands().join(", ")}\n` + + `\n` + + `By default requests are routed through the long-lived pi-lsp\n` + + `daemon (autospawned). Pass --no-daemon for a one-shot in-process\n` + + `client (useful for debugging).\n` + + `\n` + `Example:\n` + ` cli.ts backend/api/server.go hover '{"position":{"line":223,"character":22}}'\n`, ); process.exit(2); } -async function main() { - const [, , fileArg, cmdArg, jsonArg] = process.argv; - if (!fileArg || !cmdArg || jsonArg === undefined) usage(); +// Daemon Subcommand - `daemon status` / `daemon stop`. Start is implicit: +// any LSP request autospawns the daemon if it isn't running. +async function daemonSubcommand(action: string | undefined): Promise { + switch (action) { + case "status": { + const result = await daemonStatus(); + process.stdout.write(JSON.stringify(result, null, 2) + "\n"); + return; + } + case "stop": { + try { + const result = await daemonShutdown(); + process.stdout.write(JSON.stringify(result, null, 2) + "\n"); + } catch (err) { + // Already-stopped daemons surface as connect errors; treat as no-op. + process.stderr.write( + `daemon not running (${(err as Error).message})\n`, + ); + } + return; + } + default: + process.stderr.write( + `Usage: cli.ts daemon \nSocket: ${socketPath()}\n`, + ); + process.exit(2); + } +} +// Run In-Process - The legacy one-shot path: spawn the LSP server here, +// run the command, dispose, exit. Kept for `--no-daemon` debugging. +async function runInProcess( + fileArg: string, + cmdArg: string, + params: Record, +): Promise { if (!isLspCommand(cmdArg)) { process.stderr.write( `Unknown command "${cmdArg}". Known: ${listCommands().join(", ")}\n`, ); process.exit(2); } + const filePath = path.resolve(fileArg); + const server = pickServer(filePath); + const { client, uri } = await startClientForFile(server, filePath); + try { + const result = await runCommand(cmdArg, client, uri, params); + process.stdout.write(JSON.stringify(result, null, 2) + "\n"); + } finally { + // Fire-And-Forget Shutdown - With `gopls -remote=auto` (and similar) + // the spawned process is a thin client to a background daemon; a + // graceful shutdown can hang the parent. Kick it off but don't wait. + void client.dispose(); + } +} + +// Run Via Daemon - The default path. Hover/definition/references/completion/ +// documentSymbol map to specific LSP method strings; diagnostics uses a +// dedicated op since it's notification-driven. +async function runViaDaemon( + fileArg: string, + cmdArg: string, + params: Record, +): Promise { + const methodMap: Record = { + hover: "textDocument/hover", + definition: "textDocument/definition", + references: "textDocument/references", + completion: "textDocument/completion", + documentSymbol: "textDocument/documentSymbol", + }; + const filePath = path.resolve(fileArg); + let result: unknown; + if (cmdArg === "diagnostics") { + result = await daemonDiagnostics(filePath); + } else if (cmdArg in methodMap) { + // References Default - Match commands.ts: include declaration unless + // caller explicitly overrode `context`. + if (cmdArg === "references" && !("context" in params)) { + params.context = { includeDeclaration: true }; + } + result = await daemonRequest(filePath, methodMap[cmdArg], params); + } else { + process.stderr.write( + `Unknown command "${cmdArg}". Known: ${listCommands().join(", ")}\n`, + ); + process.exit(2); + } + process.stdout.write(JSON.stringify(result, null, 2) + "\n"); +} + +async function main() { + const argv = process.argv.slice(2); + + // Daemon Subcommand - First arg is the literal word "daemon". + if (argv[0] === "daemon") { + await daemonSubcommand(argv[1]); + return; + } + + // Parse Flags - Pull out --no-daemon; positional args are unchanged. + const noDaemon = argv.includes("--no-daemon"); + const positional = argv.filter((a) => a !== "--no-daemon"); + const [fileArg, cmdArg, jsonArg] = positional; + if (!fileArg || !cmdArg || jsonArg === undefined) usage(); // Parse Request JSON let params: Record; @@ -37,25 +147,20 @@ async function main() { process.exit(2); } - const filePath = path.resolve(fileArg); - const server = pickServer(filePath); - const { client, uri } = await startClientForFile(server, filePath); - - try { - const result = await runCommand(cmdArg, client, uri, params); - process.stdout.write(JSON.stringify(result, null, 2) + "\n"); - } finally { - // Fire-And-Forget Shutdown - With `gopls -remote=auto` (and similar) - // the spawned process is a thin client to a background daemon; a - // graceful shutdown can hang the parent. Kick it off but don't wait. - void client.dispose(); + if (noDaemon) { + await runInProcess(fileArg, cmdArg, params); + } else { + await runViaDaemon(fileArg, cmdArg, params); } - // Hard Exit - Any lingering handles (LSP stdio, daemon stubs) would keep - // the event loop alive. For a short-lived CLI we just exit. - process.exit(0); } -main().catch((err) => { - process.stderr.write(`${(err as Error).stack ?? err}\n`); - process.exit(1); -}); +main() + .then(() => { + // Hard Exit - Any lingering handles (sockets, LSP stdio, daemon stubs) + // would keep the event loop alive. For a short-lived CLI we just exit. + process.exit(0); + }) + .catch((err) => { + process.stderr.write(`${(err as Error).stack ?? err}\n`); + process.exit(1); + }); diff --git a/daemon.ts b/daemon.ts new file mode 100755 index 0000000..6d2c706 --- /dev/null +++ b/daemon.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env -S npx tsx +// Daemon Entrypoint - Spawned (detached) by ensureDaemon() in +// src/daemonProtocol.ts the first time any client tries to connect. +// Stays alive across CLI invocations; idle LSP servers within are reaped +// per ServerConfig.idleTtlMs. +import { startDaemon } from "./src/daemon.ts"; + +startDaemon().catch((err) => { + process.stderr.write(`pi-lsp daemon failed to start: ${err?.stack ?? err}\n`); + process.exit(1); +}); diff --git a/flake.nix b/flake.nix index 0bdbf45..e3db9f4 100644 --- a/flake.nix +++ b/flake.nix @@ -21,6 +21,7 @@ devShells.default = pkgs.mkShell { packages = with pkgs; [ nodejs_22 + typescript-language-server ]; }; } diff --git a/index.ts b/index.ts index 16ef18f..223710d 100644 --- a/index.ts +++ b/index.ts @@ -1,12 +1,12 @@ // LSP Extension - Registers tools that let the LLM query language servers // for hover, definition, references, completions, document symbols, and -// diagnostics. Each tool spawns a short-lived server, runs one request, -// and tears it down (same lifecycle as the CLI). +// diagnostics. Tool calls are forwarded to the long-lived pi-lsp daemon +// (autospawned on first use) so LSP servers stay warm across calls. import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "typebox"; import * as path from "node:path"; -import { LspClient, uriToPath } from "./src/client.ts"; -import { pickServer, findRoot } from "./src/root.ts"; +import { uriToPath } from "./src/client.ts"; +import { daemonDiagnostics, daemonRequest } from "./src/daemonClient.ts"; // Format Hover - Turn an LSP hover response into readable text. function formatHover(result: unknown): string { @@ -202,47 +202,21 @@ function formatDiagnostics(result: unknown): string { .join("\n"); } -// Run LSP Request - Spawn a server, open the file, run one request, dispose. -// Mirrors the CLI lifecycle: fresh server per request. +// Run LSP Request - Forwards to the daemon, which owns the long-lived +// LspClient cache and handles didOpen/didChange syncing. The daemon +// injects textDocument.uri from the file path, so we omit it here. async function runLsp( filePath: string, method: string, params: Record, ): Promise { - const server = pickServer(filePath); - const rootDir = findRoot(filePath, server.rootMarkers); - const client = new LspClient(server); - - try { - await client.start(rootDir); - const uri = client.openDocument(filePath); - await client.waitForReady(); - // Populate textDocument.uri if the params have a textDocument field - if (params.textDocument && typeof params.textDocument === "object") { - params.textDocument = { ...params.textDocument, uri }; - } - return client.sendRequest(method, params); - } finally { - // Fire-and-forget shutdown; don't wait for graceful exit. - void client.dispose(); - } + return daemonRequest(filePath, method, params); } -// Run LSP Diagnostics - Diagnostics arrive as a notification, so we use -// the dedicated waitForDiagnostics helper instead of sendRequest. +// Run LSP Diagnostics - Diagnostics arrive as a notification, so the +// daemon has a dedicated op that waits for the next publish. async function runDiagnostics(filePath: string): Promise { - const server = pickServer(filePath); - const rootDir = findRoot(filePath, server.rootMarkers); - const client = new LspClient(server); - - try { - await client.start(rootDir); - const uri = client.openDocument(filePath); - await client.waitForReady(); - return client.waitForDiagnostics(uri, 1500); - } finally { - void client.dispose(); - } + return daemonDiagnostics(filePath, 1500); } // Shared Parameters Schema - All position-based tools accept file + optional @@ -268,7 +242,6 @@ export default function (pi: ExtensionAPI) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const filePath = path.resolve(ctx.cwd, params.file); const lspParams = { - textDocument: {}, position: { line: params.line ?? 0, character: params.character ?? 0 }, }; const result = await runLsp(filePath, "textDocument/hover", lspParams); @@ -289,7 +262,6 @@ export default function (pi: ExtensionAPI) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const filePath = path.resolve(ctx.cwd, params.file); const lspParams = { - textDocument: {}, position: { line: params.line ?? 0, character: params.character ?? 0 }, }; const result = await runLsp( @@ -314,7 +286,6 @@ export default function (pi: ExtensionAPI) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const filePath = path.resolve(ctx.cwd, params.file); const lspParams = { - textDocument: {}, position: { line: params.line ?? 0, character: params.character ?? 0 }, context: { includeDeclaration: true }, }; @@ -340,7 +311,6 @@ export default function (pi: ExtensionAPI) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const filePath = path.resolve(ctx.cwd, params.file); const lspParams = { - textDocument: {}, position: { line: params.line ?? 0, character: params.character ?? 0 }, }; const result = await runLsp( @@ -366,9 +336,11 @@ export default function (pi: ExtensionAPI) { }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const filePath = path.resolve(ctx.cwd, params.file); - const result = await runLsp(filePath, "textDocument/documentSymbol", { - textDocument: {}, - }); + const result = await runLsp( + filePath, + "textDocument/documentSymbol", + {}, + ); return { content: [{ type: "text", text: formatDocumentSymbols(result) }], details: { raw: result }, diff --git a/src/client.ts b/src/client.ts index 1bcb76a..bbf5527 100644 --- a/src/client.ts +++ b/src/client.ts @@ -60,6 +60,10 @@ export class LspClient { // can await readiness. private progressTokens = new Set(); private progressListeners = new Set<() => void>(); + // Per-URI Version Counter - LSP requires monotonically increasing + // version numbers in didOpen/didChange. We track them so the daemon + // can resync files via notifyChange after on-disk edits. + private versions = new Map(); constructor(private readonly server: ServerConfig) {} @@ -182,9 +186,12 @@ export class LspClient { // Open Document - Reads the file from disk and sends didOpen. Most // servers require this before they'll answer hover/definition/etc. + // Idempotent-ish: callers should track whether they've already opened + // a URI and prefer notifyChange for subsequent syncs. openDocument(filePath: string): string { const uri = pathToUri(filePath); const text = fs.readFileSync(filePath, "utf8"); + this.versions.set(uri, 1); this.conn.sendNotification("textDocument/didOpen", { textDocument: { uri, @@ -196,11 +203,32 @@ export class LspClient { return uri; } + // Notify Change - Re-reads the file from disk and sends a full-text + // didChange. Used by the daemon to keep the server in sync after the + // agent's edit/write tools modify a file. + notifyChange(filePath: string): string { + const uri = pathToUri(filePath); + const text = fs.readFileSync(filePath, "utf8"); + const version = (this.versions.get(uri) ?? 1) + 1; + this.versions.set(uri, version); + this.conn.sendNotification("textDocument/didChange", { + textDocument: { uri, version }, + contentChanges: [{ text }], + }); + return uri; + } + // Send Raw LSP Request - Passthrough used by the command dispatcher. sendRequest(method: string, params: unknown): Promise { return this.conn.sendRequest(method, params) as Promise; } + // Clear Diagnostics - Drops the cached diagnostics for a URI so callers + // can force waitForDiagnostics to await a fresh publish after didChange. + clearDiagnostics(uri: string): void { + this.diagnostics.delete(uri); + } + // Wait For Diagnostics - Resolves on the first publish for `uri` or // after `timeoutMs`. Returns whatever we have for that URI. async waitForDiagnostics( diff --git a/src/daemon.ts b/src/daemon.ts new file mode 100644 index 0000000..b810b54 --- /dev/null +++ b/src/daemon.ts @@ -0,0 +1,299 @@ +// Daemon Server - Owns long-lived LspClient instances keyed by +// (server.id, rootDir). Accepts NDJSON requests over a Unix socket and +// dispatches them to the appropriate client, lazily spawning servers and +// reaping idle ones via ServerConfig.idleTtlMs. +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as path from "node:path"; +import { LspClient } from "./client.ts"; +import { findRoot, pickServer, pathToUri } from "./root.ts"; +import type { ServerConfig } from "./types.ts"; +import { + logPath, + socketPath, + tryConnect, + type DaemonRequest, + type DaemonResponse, +} from "./daemonProtocol.ts"; + +// Default Idle TTL - 5 minutes. Per-server overrides via ServerConfig.idleTtlMs. +const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000; + +// Client Entry - One LspClient per (server.id, rootDir), plus the bookkeeping +// needed to keep files in sync and evict on idleness. +interface ClientEntry { + key: string; + server: ServerConfig; + rootDir: string; + client: LspClient; + // ready: gates concurrent requests during startup so we only initialize once. + ready: Promise; + // opened: URI -> last-synced mtimeMs. Used to decide didOpen vs didChange vs nothing. + opened: Map; + // serializer: per-entry mutex so file-sync (didOpen/didChange) can't race + // with itself when two requests for the same file land concurrently. + serializer: Promise; + idleTimer: NodeJS.Timeout | null; + ttlMs: number; + lastUsed: number; +} + +const entries = new Map(); + +// Log - Single helper so we can prefix and easily silence in tests. +function log(...args: unknown[]) { + process.stdout.write( + `[${new Date().toISOString()}] ` + + args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ") + + "\n", + ); +} + +// Get Or Create Entry - Looks up the cached client for a file, spawning a +// fresh LspClient if needed. The returned entry is guaranteed to have its +// `ready` promise resolved before the caller uses it. +async function getOrCreateEntry(filePath: string): Promise { + const server = pickServer(filePath); + const rootDir = findRoot(filePath, server.rootMarkers); + const key = `${server.id}::${rootDir}`; + const existing = entries.get(key); + if (existing) { + await existing.ready; + return existing; + } + // Cold Start - Build the entry synchronously so concurrent callers all + // await the same `ready` promise instead of racing to spawn duplicates. + const client = new LspClient(server); + const ttlMs = server.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; + const entry: ClientEntry = { + key, + server, + rootDir, + client, + ready: (async () => { + log(`spawn`, server.id, rootDir); + await client.start(rootDir); + await client.waitForReady(); + log(`ready`, server.id); + })(), + opened: new Map(), + serializer: Promise.resolve(), + idleTimer: null, + ttlMs, + lastUsed: Date.now(), + }; + entries.set(key, entry); + try { + await entry.ready; + } catch (err) { + entries.delete(key); + throw err; + } + bumpIdle(entry); + return entry; +} + +// Bump Idle - Resets the idle eviction timer. Called on every request that +// touches the entry. We log evictions so the daemon's behavior is visible. +function bumpIdle(entry: ClientEntry) { + entry.lastUsed = Date.now(); + if (entry.idleTimer) clearTimeout(entry.idleTimer); + entry.idleTimer = setTimeout(() => evict(entry, "idle"), entry.ttlMs); +} + +function evict(entry: ClientEntry, reason: string) { + if (!entries.has(entry.key)) return; + log(`evict`, entry.key, reason); + entries.delete(entry.key); + if (entry.idleTimer) clearTimeout(entry.idleTimer); + void entry.client.dispose(); +} + +// Sync File - Ensures the language server has the current contents of the +// file. Sends didOpen on first access, didChange on subsequent calls when +// the on-disk mtime has advanced. Serialized per-entry to avoid races. +async function syncFile( + entry: ClientEntry, + filePath: string, +): Promise<{ uri: string; changed: boolean }> { + const uri = pathToUri(filePath); + const run = async () => { + const stat = fs.statSync(filePath); + const prev = entry.opened.get(uri); + if (prev === undefined) { + entry.client.openDocument(filePath); + entry.opened.set(uri, stat.mtimeMs); + return { uri, changed: true }; + } else if (prev !== stat.mtimeMs) { + entry.client.notifyChange(filePath); + entry.opened.set(uri, stat.mtimeMs); + return { uri, changed: true }; + } + return { uri, changed: false }; + }; + // Chain onto the per-entry serializer so concurrent syncs queue up. + const next = entry.serializer.then(run, run); + entry.serializer = next.catch(() => undefined); + return next; +} + +// Inject textDocument.uri - Mirrors the helper in commands.ts; we don't +// reuse it because the daemon path operates on raw method strings rather +// than the LspCommand union. +function withDoc(uri: string, params: Record): Record { + const existing = (params.textDocument as Record) ?? {}; + return { ...params, textDocument: { uri, ...existing } }; +} + +// Handle Request - Dispatches a single parsed DaemonRequest. Returns a +// DaemonResponse; never throws (errors are returned as { ok: false }). +async function handle(req: DaemonRequest): Promise { + try { + switch (req.op) { + case "request": { + const filePath = path.resolve(req.file); + const entry = await getOrCreateEntry(filePath); + const { uri } = await syncFile(entry, filePath); + bumpIdle(entry); + const result = await entry.client.sendRequest( + req.method, + withDoc(uri, req.params), + ); + return { id: req.id, ok: true, result }; + } + case "diagnostics": { + const filePath = path.resolve(req.file); + const entry = await getOrCreateEntry(filePath); + const { uri, changed } = await syncFile(entry, filePath); + bumpIdle(entry); + if (changed) entry.client.clearDiagnostics(uri); + const result = await entry.client.waitForDiagnostics( + uri, + req.timeoutMs ?? 1500, + ); + return { id: req.id, ok: true, result }; + } + case "status": { + const result = { + socket: socketPath(), + servers: Array.from(entries.values()).map((e) => ({ + id: e.server.id, + rootDir: e.rootDir, + openedFiles: Array.from(e.opened.keys()), + idleMs: Date.now() - e.lastUsed, + ttlMs: e.ttlMs, + })), + }; + return { id: req.id, ok: true, result }; + } + case "shutdown": { + // Acknowledge first, then tear down on next tick so the response + // has a chance to flush before we close listeners. + setImmediate(() => shutdownDaemon("shutdown request")); + return { id: req.id, ok: true, result: { stopping: true } }; + } + } + } catch (err) { + return { + id: req.id, + ok: false, + error: (err as Error)?.message ?? String(err), + }; + } + // Exhaustiveness - Should be unreachable given the union above. + throw new Error("unreachable"); +} + +// Handle Connection - Reads NDJSON from a client socket; each line is one +// independent request. Multiple requests may share a connection. +function handleConnection(sock: net.Socket) { + let buf = ""; + sock.on("data", async (chunk) => { + buf += chunk.toString("utf8"); + let nl: number; + // Process All Complete Lines - Leftover stays in buf for the next chunk. + while ((nl = buf.indexOf("\n")) !== -1) { + const line = buf.slice(0, nl); + buf = buf.slice(nl + 1); + if (!line.trim()) continue; + let req: DaemonRequest; + try { + req = JSON.parse(line); + } catch (err) { + sock.write( + JSON.stringify({ + id: 0, + ok: false, + error: `bad json: ${(err as Error).message}`, + }) + "\n", + ); + continue; + } + const resp = await handle(req); + sock.write(JSON.stringify(resp) + "\n"); + } + }); + sock.on("error", (err) => log("conn error", err.message)); +} + +let server: net.Server | null = null; + +// Shutdown Daemon - Stops accepting connections, disposes all LspClients, +// removes the socket file, and exits. Called on SIGTERM/SIGINT and via +// the explicit `shutdown` op. +function shutdownDaemon(reason: string) { + log(`shutdown`, reason); + if (server) server.close(); + for (const entry of entries.values()) { + if (entry.idleTimer) clearTimeout(entry.idleTimer); + void entry.client.dispose(); + } + entries.clear(); + try { + fs.unlinkSync(socketPath()); + } catch { + // Ignore - already gone. + } + // Give pending writes a moment, then exit. + setTimeout(() => process.exit(0), 100); +} + +// Start Daemon - Binds the Unix socket, handling stale-socket cleanup. +// If another daemon is already listening, we exit cleanly so racing +// `ensureDaemon` callers converge on a single instance. +export async function startDaemon(): Promise { + const sock = socketPath(); + // Stale Socket Detection - If something exists at the path, try to + // connect. A successful connect means another daemon owns it (we exit); + // a failed connect means the socket file is stale (we unlink it). + if (fs.existsSync(sock)) { + try { + const probe = await tryConnect(sock, 200); + probe.destroy(); + log(`another daemon already listening on ${sock}, exiting`); + process.exit(0); + } catch { + try { + fs.unlinkSync(sock); + } catch { + // Ignore - listen() will surface a clearer error if this matters. + } + } + } + server = net.createServer(handleConnection); + await new Promise((resolve, reject) => { + server!.once("error", reject); + server!.listen(sock, () => { + // Restrict Permissions - Socket is per-user; nobody else should poke at it. + try { + fs.chmodSync(sock, 0o600); + } catch { + // Ignore - best effort. + } + resolve(); + }); + }); + log(`listening on ${sock} (logs: ${logPath()})`); + process.on("SIGTERM", () => shutdownDaemon("SIGTERM")); + process.on("SIGINT", () => shutdownDaemon("SIGINT")); +} diff --git a/src/daemonClient.ts b/src/daemonClient.ts new file mode 100644 index 0000000..6f9d0cb --- /dev/null +++ b/src/daemonClient.ts @@ -0,0 +1,45 @@ +// Daemon Client - High-level helpers used by cli.ts and index.ts to send +// LSP work to the long-lived daemon. The first call autospawns the +// daemon; subsequent calls reuse it. +// +// Why Not One Persistent Socket - For now we open a fresh connection per +// request. The cost is negligible (Unix socket, same machine) compared to +// the LSP request itself, and it keeps client code stateless. +import { sendOnce, type DaemonResponse } from "./daemonProtocol.ts"; + +// Unwrap - Throws on { ok: false }, returns result on { ok: true }. All +// callers want the result-or-throw shape, so we centralize it. +function unwrap(resp: DaemonResponse): unknown { + if (resp.ok) return resp.result; + throw new Error(resp.error); +} + +// Send LSP Request - Forwards an arbitrary LSP method to the daemon. The +// daemon injects textDocument.uri from `file`, so callers omit it. +export async function daemonRequest( + file: string, + method: string, + params: Record, +): Promise { + return unwrap(await sendOnce({ op: "request", file, method, params })); +} + +// Wait For Diagnostics - Diagnostics arrive as a notification, not a +// response, so the daemon has a dedicated op that awaits the next publish. +export async function daemonDiagnostics( + file: string, + timeoutMs = 1500, +): Promise { + return unwrap(await sendOnce({ op: "diagnostics", file, timeoutMs })); +} + +// Status - Lists currently-cached LSP servers (id, root, opened files, +// idle time). Useful for `pi-lsp daemon status`. +export async function daemonStatus(): Promise { + return unwrap(await sendOnce({ op: "status" })); +} + +// Shutdown - Asks the daemon to dispose all LspClients and exit. +export async function daemonShutdown(): Promise { + return unwrap(await sendOnce({ op: "shutdown" })); +} diff --git a/src/daemonProtocol.ts b/src/daemonProtocol.ts new file mode 100644 index 0000000..70749fa --- /dev/null +++ b/src/daemonProtocol.ts @@ -0,0 +1,158 @@ +// Daemon Protocol - Shared types, socket-path resolution, and the +// auto-spawn helper used by both the daemon (server) and client lib. +// +// Wire Format - Newline-delimited JSON. Each line is one message. We use +// NDJSON instead of LSP-style framing because the messages are small and +// synchronous, and it keeps the implementation trivial. +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { spawn } from "node:child_process"; + +// Request Shapes - Sent client -> daemon. +export type DaemonRequest = + | { + id: number; + op: "request"; + file: string; + method: string; + params: Record; + } + | { id: number; op: "diagnostics"; file: string; timeoutMs?: number } + | { id: number; op: "status" } + | { id: number; op: "shutdown" }; + +export type DaemonRequestWithoutId = + | { + op: "request"; + file: string; + method: string; + params: Record; + } + | { op: "diagnostics"; file: string; timeoutMs?: number } + | { op: "status" } + | { op: "shutdown" }; + +// Response Shapes - Sent daemon -> client. `ok: true` carries result, else +// `error` is a human-readable message string. +export type DaemonResponse = + | { id: number; ok: true; result: unknown } + | { id: number; ok: false; error: string }; + +// Socket Path - Per-user socket; XDG_RUNTIME_DIR when available (tmpfs, +// auto-cleaned on logout), tmpdir() otherwise. We include the uid so two +// users on the same box don't collide on a shared tmpdir. +export function socketPath(): string { + const uid = + typeof process.getuid === "function" ? String(process.getuid()) : "0"; + const dir = process.env.XDG_RUNTIME_DIR ?? os.tmpdir(); + return path.join(dir, `pi-lsp-${uid}.sock`); +} + +// Log Path - Where the spawned daemon writes stdout/stderr. +export function logPath(): string { + return path.join(os.tmpdir(), "pi-lsp-daemon.log"); +} + +// Try Connect - Resolves with a connected socket or rejects on error. +// Used both by clients and by the daemon's stale-socket check on startup. +export function tryConnect(sockPath: string, timeoutMs = 500): Promise { + return new Promise((resolve, reject) => { + const sock = net.createConnection(sockPath); + const timer = setTimeout(() => { + sock.destroy(); + reject(new Error("connect timeout")); + }, timeoutMs); + sock.once("connect", () => { + clearTimeout(timer); + resolve(sock); + }); + sock.once("error", (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +// Sleep - Tiny helper for the autospawn retry loop. +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// Spawn Daemon - Detached background process. We resolve the daemon +// entrypoint relative to this file so it works whether run via tsx (dev) +// or after a future build step. +export function spawnDaemon(): void { + // Locate Entrypoint - daemon.ts sits at the package root, two levels + // up from this file (src/daemonProtocol.ts). + const entry = path.resolve(import.meta.dirname, "..", "daemon.ts"); + const out = fs.openSync(logPath(), "a"); + const child = spawn(entry, [], { + detached: true, + stdio: ["ignore", out, out], + env: process.env, + }); + child.unref(); +} + +// Ensure Daemon - Connects to the daemon, spawning it first if the socket +// doesn't exist or is stale. Returns a connected socket on success. +export async function ensureDaemon(sockPath = socketPath()): Promise { + // Fast Path - Already running. + try { + return await tryConnect(sockPath); + } catch { + // Fallthrough to spawn. + } + // Cleanup Stale Socket - If the file exists but no one's listening, + // remove it so the daemon can rebind. The daemon does this defensively + // too, but doing it here avoids a race on first spawn. + try { + fs.unlinkSync(sockPath); + } catch { + // Ignore - file may not exist. + } + spawnDaemon(); + // Retry Loop - Wait up to ~5s for the daemon to bind. + const deadline = Date.now() + 5000; + let lastErr: unknown; + while (Date.now() < deadline) { + await sleep(100); + try { + return await tryConnect(sockPath); + } catch (err) { + lastErr = err; + } + } + throw new Error( + `Failed to connect to pi-lsp daemon at ${sockPath}: ${ + (lastErr as Error)?.message ?? "unknown" + }. See ${logPath()} for daemon logs.`, + ); +} + +// Send One Request - Opens (or reuses) a connection, sends one NDJSON +// request, awaits the matching response, and closes the socket. Caller +// owns the connection lifetime when batching is desired. +export async function sendOnce(req: DaemonRequestWithoutId): Promise { + const sock = await ensureDaemon(); + return new Promise((resolve, reject) => { + const id = 1; + let buf = ""; + sock.on("data", (chunk) => { + buf += chunk.toString("utf8"); + const nl = buf.indexOf("\n"); + if (nl === -1) return; + const line = buf.slice(0, nl); + try { + const resp = JSON.parse(line) as DaemonResponse; + sock.end(); + resolve(resp); + } catch (err) { + sock.destroy(); + reject(err); + } + }); + sock.on("error", reject); + sock.write(JSON.stringify({ ...req, id }) + "\n"); + }); +} diff --git a/src/types.ts b/src/types.ts index cf5e266..5255aa5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,8 +12,8 @@ export interface ServerConfig { rootMarkers: string[]; // LSP languageId sent in didOpen. Defaults to match[0] if omitted. languageId?: string; - // TTL Planning - When we eventually add a daemon, servers will be kept - // alive per (id, rootUri) for this many ms of idleness. Not used yet. + // Idle TTL - Daemon keeps one server alive per (id, rootDir) and evicts + // it after this many ms of inactivity. Defaults to 5 minutes. idleTtlMs?: number; } diff --git a/tsconfig.json b/tsconfig.json index 3b67c97..61439f3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,5 +10,5 @@ "noEmit": true, "allowImportingTsExtensions": true }, - "include": ["cli.ts", "server.ts", "src/**/*.ts"] + "include": ["cli.ts", "daemon.ts", "server.ts", "src/**/*.ts"] }