feat(lsp): add background daemon for language servers
This commit is contained in:
23
README.md
23
README.md
@@ -40,9 +40,16 @@ npm install
|
||||
## 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
|
||||
`textDocument.uri` (we inject that from `<file>`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
151
cli.ts
151
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 <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` +
|
||||
`\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<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)) {
|
||||
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<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
|
||||
let params: Record<string, unknown>;
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
11
daemon.ts
Executable file
11
daemon.ts
Executable 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);
|
||||
});
|
||||
@@ -21,6 +21,7 @@
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
nodejs_22
|
||||
typescript-language-server
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
60
index.ts
60
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<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
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<unknown> {
|
||||
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 },
|
||||
|
||||
@@ -60,6 +60,10 @@ export class LspClient {
|
||||
// can await readiness.
|
||||
private progressTokens = new Set<string | number>();
|
||||
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) {}
|
||||
|
||||
@@ -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<R = unknown>(method: string, params: unknown): 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
|
||||
// after `timeoutMs`. Returns whatever we have for that URI.
|
||||
async waitForDiagnostics(
|
||||
|
||||
299
src/daemon.ts
Normal file
299
src/daemon.ts
Normal 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
45
src/daemonClient.ts
Normal 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
158
src/daemonProtocol.ts
Normal 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");
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["cli.ts", "server.ts", "src/**/*.ts"]
|
||||
"include": ["cli.ts", "daemon.ts", "server.ts", "src/**/*.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user