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)
|
## 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
151
cli.ts
@@ -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
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 {
|
devShells.default = pkgs.mkShell {
|
||||||
packages = with pkgs; [
|
packages = with pkgs; [
|
||||||
nodejs_22
|
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
|
// 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 },
|
||||||
|
|||||||
@@ -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
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[];
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user