feat(lsp): add background daemon for language servers

This commit is contained in:
2026-04-29 00:04:06 -04:00
parent 60b8900a09
commit 076eee4e96
12 changed files with 707 additions and 76 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

View File

@@ -40,9 +40,16 @@ npm install
## CLI Usage (for development/testing) ## CLI Usage (for development/testing)
``` ```
tsx ./cli.ts <file> <lsp_command> <req_data_json> tsx ./cli.ts <file> <lsp_command> <req_data_json> [--no-daemon]
tsx ./cli.ts daemon <status|stop>
``` ```
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 `req_data_json` is the raw LSP params for the command, minus
`textDocument.uri` (we inject that from `<file>`). `textDocument.uri` (we inject that from `<file>`).
@@ -71,9 +78,15 @@ npm run lsp -- backend/api/server.go documentSymbol '{}'
# Diagnostics # Diagnostics
npm run lsp -- backend/api/server.go 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 ## Adding A Server
@@ -97,7 +110,5 @@ Edit `server.ts`:
## Future ## Future
- **Daemon with TTL** - `ServerConfig.idleTtlMs` is reserved for a future - **Daemon hardening** - persistent metrics, health checks, and richer status output.
daemon that keeps language servers alive per `(server.id, rootUri)` to - **Build output** - ship compiled JS entrypoints instead of relying on tsx for development.
avoid cold-start latency. Not implemented; the CLI is short-lived and
spawns fresh each invocation.

151
cli.ts
View File

@@ -3,28 +3,138 @@ import * as path from "node:path";
import { startClientForFile } from "./src/client.ts"; import { startClientForFile } from "./src/client.ts";
import { isLspCommand, listCommands, runCommand } from "./src/commands.ts"; import { isLspCommand, listCommands, runCommand } from "./src/commands.ts";
import { pickServer } from "./src/root.ts"; import { pickServer } from "./src/root.ts";
import {
daemonDiagnostics,
daemonRequest,
daemonShutdown,
daemonStatus,
} from "./src/daemonClient.ts";
import { socketPath } from "./src/daemonProtocol.ts";
// Usage // Usage
function usage(): never { function usage(): never {
process.stderr.write( process.stderr.write(
`Usage: cli.ts <file> <lsp_command> <req_data_json>\n` + `Usage:\n` +
` cli.ts <file> <lsp_command> <req_data_json> [--no-daemon]\n` +
` cli.ts daemon <status|stop>\n` +
`\n` +
`Commands: ${listCommands().join(", ")}\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` + `Example:\n` +
` cli.ts backend/api/server.go hover '{"position":{"line":223,"character":22}}'\n`, ` cli.ts backend/api/server.go hover '{"position":{"line":223,"character":22}}'\n`,
); );
process.exit(2); process.exit(2);
} }
async function main() { // Daemon Subcommand - `daemon status` / `daemon stop`. Start is implicit:
const [, , fileArg, cmdArg, jsonArg] = process.argv; // any LSP request autospawns the daemon if it isn't running.
if (!fileArg || !cmdArg || jsonArg === undefined) usage(); async function daemonSubcommand(action: string | undefined): Promise<void> {
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 <status|stop>\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<string, unknown>,
): Promise<void> {
if (!isLspCommand(cmdArg)) { if (!isLspCommand(cmdArg)) {
process.stderr.write( process.stderr.write(
`Unknown command "${cmdArg}". Known: ${listCommands().join(", ")}\n`, `Unknown command "${cmdArg}". Known: ${listCommands().join(", ")}\n`,
); );
process.exit(2); 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<string, unknown>,
): Promise<void> {
const methodMap: Record<string, string> = {
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 // Parse Request JSON
let params: Record<string, unknown>; let params: Record<string, unknown>;
@@ -37,25 +147,20 @@ async function main() {
process.exit(2); process.exit(2);
} }
const filePath = path.resolve(fileArg); if (noDaemon) {
const server = pickServer(filePath); await runInProcess(fileArg, cmdArg, params);
const { client, uri } = await startClientForFile(server, filePath); } else {
await runViaDaemon(fileArg, cmdArg, params);
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();
} }
// 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) => { main()
process.stderr.write(`${(err as Error).stack ?? err}\n`); .then(() => {
process.exit(1); // 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);
});

11
daemon.ts Executable file
View File

@@ -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);
});

View File

@@ -21,6 +21,7 @@
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
packages = with pkgs; [ packages = with pkgs; [
nodejs_22 nodejs_22
typescript-language-server
]; ];
}; };
} }

View File

@@ -1,12 +1,12 @@
// LSP Extension - Registers tools that let the LLM query language servers // LSP Extension - Registers tools that let the LLM query language servers
// for hover, definition, references, completions, document symbols, and // for hover, definition, references, completions, document symbols, and
// diagnostics. Each tool spawns a short-lived server, runs one request, // diagnostics. Tool calls are forwarded to the long-lived pi-lsp daemon
// and tears it down (same lifecycle as the CLI). // (autospawned on first use) so LSP servers stay warm across calls.
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "typebox"; import { Type } from "typebox";
import * as path from "node:path"; import * as path from "node:path";
import { LspClient, uriToPath } from "./src/client.ts"; import { uriToPath } from "./src/client.ts";
import { pickServer, findRoot } from "./src/root.ts"; import { daemonDiagnostics, daemonRequest } from "./src/daemonClient.ts";
// Format Hover - Turn an LSP hover response into readable text. // Format Hover - Turn an LSP hover response into readable text.
function formatHover(result: unknown): string { function formatHover(result: unknown): string {
@@ -202,47 +202,21 @@ function formatDiagnostics(result: unknown): string {
.join("\n"); .join("\n");
} }
// Run LSP Request - Spawn a server, open the file, run one request, dispose. // Run LSP Request - Forwards to the daemon, which owns the long-lived
// Mirrors the CLI lifecycle: fresh server per request. // LspClient cache and handles didOpen/didChange syncing. The daemon
// injects textDocument.uri from the file path, so we omit it here.
async function runLsp( async function runLsp(
filePath: string, filePath: string,
method: string, method: string,
params: Record<string, unknown>, params: Record<string, unknown>,
): Promise<unknown> { ): Promise<unknown> {
const server = pickServer(filePath); return daemonRequest(filePath, method, params);
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();
}
} }
// Run LSP Diagnostics - Diagnostics arrive as a notification, so we use // Run LSP Diagnostics - Diagnostics arrive as a notification, so the
// the dedicated waitForDiagnostics helper instead of sendRequest. // daemon has a dedicated op that waits for the next publish.
async function runDiagnostics(filePath: string): Promise<unknown> { async function runDiagnostics(filePath: string): Promise<unknown> {
const server = pickServer(filePath); return daemonDiagnostics(filePath, 1500);
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();
}
} }
// Shared Parameters Schema - All position-based tools accept file + optional // 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) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const filePath = path.resolve(ctx.cwd, params.file); const filePath = path.resolve(ctx.cwd, params.file);
const lspParams = { const lspParams = {
textDocument: {},
position: { line: params.line ?? 0, character: params.character ?? 0 }, position: { line: params.line ?? 0, character: params.character ?? 0 },
}; };
const result = await runLsp(filePath, "textDocument/hover", lspParams); const result = await runLsp(filePath, "textDocument/hover", lspParams);
@@ -289,7 +262,6 @@ export default function (pi: ExtensionAPI) {
async execute(_toolCallId, params, _signal, _onUpdate, ctx) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const filePath = path.resolve(ctx.cwd, params.file); const filePath = path.resolve(ctx.cwd, params.file);
const lspParams = { const lspParams = {
textDocument: {},
position: { line: params.line ?? 0, character: params.character ?? 0 }, position: { line: params.line ?? 0, character: params.character ?? 0 },
}; };
const result = await runLsp( const result = await runLsp(
@@ -314,7 +286,6 @@ export default function (pi: ExtensionAPI) {
async execute(_toolCallId, params, _signal, _onUpdate, ctx) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const filePath = path.resolve(ctx.cwd, params.file); const filePath = path.resolve(ctx.cwd, params.file);
const lspParams = { const lspParams = {
textDocument: {},
position: { line: params.line ?? 0, character: params.character ?? 0 }, position: { line: params.line ?? 0, character: params.character ?? 0 },
context: { includeDeclaration: true }, context: { includeDeclaration: true },
}; };
@@ -340,7 +311,6 @@ export default function (pi: ExtensionAPI) {
async execute(_toolCallId, params, _signal, _onUpdate, ctx) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const filePath = path.resolve(ctx.cwd, params.file); const filePath = path.resolve(ctx.cwd, params.file);
const lspParams = { const lspParams = {
textDocument: {},
position: { line: params.line ?? 0, character: params.character ?? 0 }, position: { line: params.line ?? 0, character: params.character ?? 0 },
}; };
const result = await runLsp( const result = await runLsp(
@@ -366,9 +336,11 @@ export default function (pi: ExtensionAPI) {
}), }),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const filePath = path.resolve(ctx.cwd, params.file); const filePath = path.resolve(ctx.cwd, params.file);
const result = await runLsp(filePath, "textDocument/documentSymbol", { const result = await runLsp(
textDocument: {}, filePath,
}); "textDocument/documentSymbol",
{},
);
return { return {
content: [{ type: "text", text: formatDocumentSymbols(result) }], content: [{ type: "text", text: formatDocumentSymbols(result) }],
details: { raw: result }, details: { raw: result },

View File

@@ -60,6 +60,10 @@ export class LspClient {
// can await readiness. // can await readiness.
private progressTokens = new Set<string | number>(); private progressTokens = new Set<string | number>();
private progressListeners = new Set<() => void>(); 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<string, number>();
constructor(private readonly server: ServerConfig) {} constructor(private readonly server: ServerConfig) {}
@@ -182,9 +186,12 @@ export class LspClient {
// Open Document - Reads the file from disk and sends didOpen. Most // Open Document - Reads the file from disk and sends didOpen. Most
// servers require this before they'll answer hover/definition/etc. // 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 { openDocument(filePath: string): string {
const uri = pathToUri(filePath); const uri = pathToUri(filePath);
const text = fs.readFileSync(filePath, "utf8"); const text = fs.readFileSync(filePath, "utf8");
this.versions.set(uri, 1);
this.conn.sendNotification("textDocument/didOpen", { this.conn.sendNotification("textDocument/didOpen", {
textDocument: { textDocument: {
uri, uri,
@@ -196,11 +203,32 @@ export class LspClient {
return uri; 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. // Send Raw LSP Request - Passthrough used by the command dispatcher.
sendRequest<R = unknown>(method: string, params: unknown): Promise<R> { sendRequest<R = unknown>(method: string, params: unknown): Promise<R> {
return this.conn.sendRequest(method, params) as Promise<R>; return this.conn.sendRequest(method, params) as Promise<R>;
} }
// 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 // Wait For Diagnostics - Resolves on the first publish for `uri` or
// after `timeoutMs`. Returns whatever we have for that URI. // after `timeoutMs`. Returns whatever we have for that URI.
async waitForDiagnostics( async waitForDiagnostics(

299
src/daemon.ts Normal file
View File

@@ -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<void>;
// opened: URI -> last-synced mtimeMs. Used to decide didOpen vs didChange vs nothing.
opened: Map<string, number>;
// 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<unknown>;
idleTimer: NodeJS.Timeout | null;
ttlMs: number;
lastUsed: number;
}
const entries = new Map<string, ClientEntry>();
// 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<ClientEntry> {
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<string, unknown>): Record<string, unknown> {
const existing = (params.textDocument as Record<string, unknown>) ?? {};
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<DaemonResponse> {
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<void> {
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<void>((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"));
}

45
src/daemonClient.ts Normal file
View File

@@ -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<string, unknown>,
): Promise<unknown> {
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<unknown> {
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<unknown> {
return unwrap(await sendOnce({ op: "status" }));
}
// Shutdown - Asks the daemon to dispose all LspClients and exit.
export async function daemonShutdown(): Promise<unknown> {
return unwrap(await sendOnce({ op: "shutdown" }));
}

158
src/daemonProtocol.ts Normal file
View File

@@ -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<string, unknown>;
}
| { 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<string, unknown>;
}
| { 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<net.Socket> {
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<net.Socket> {
// 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<DaemonResponse> {
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");
});
}

View File

@@ -12,8 +12,8 @@ export interface ServerConfig {
rootMarkers: string[]; rootMarkers: string[];
// LSP languageId sent in didOpen. Defaults to match[0] if omitted. // LSP languageId sent in didOpen. Defaults to match[0] if omitted.
languageId?: string; languageId?: string;
// TTL Planning - When we eventually add a daemon, servers will be kept // Idle TTL - Daemon keeps one server alive per (id, rootDir) and evicts
// alive per (id, rootUri) for this many ms of idleness. Not used yet. // it after this many ms of inactivity. Defaults to 5 minutes.
idleTtlMs?: number; idleTtlMs?: number;
} }

View File

@@ -10,5 +10,5 @@
"noEmit": true, "noEmit": true,
"allowImportingTsExtensions": true "allowImportingTsExtensions": true
}, },
"include": ["cli.ts", "server.ts", "src/**/*.ts"] "include": ["cli.ts", "daemon.ts", "server.ts", "src/**/*.ts"]
} }