refactor(daemon): require explicit serverId on all daemon ops
Move all server matching logic to the extension/CLI side. The daemon no longer calls pickServer() — it receives an explicit serverId (or serverIds[] for diagnostics) and uses it directly for cache lookup and server spawning. Key changes: - request op requires serverId: string - diagnostics op requires serverIds: string[] — daemon fans out in parallel via Promise.allSettled and returns grouped map - formatDiagnostics() handles grouped results with per-server headers when multiple servers contribute (single-server omits header) - CLI picks servers locally before calling daemon helpers - New pickDiagnosticServers() in extension returns all available, non-disabled servers matching the file extension This makes multi-server diagnostics (e.g., typescript-language-server + oxlint) work naturally — the extension decides which servers to query, the daemon just executes.
This commit is contained in:
13
cli.ts
13
cli.ts
@@ -2,7 +2,8 @@
|
|||||||
import * as path from "node:path";
|
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, isServerAvailable } from "./src/root.ts";
|
||||||
|
import { servers } from "./server.ts";
|
||||||
import {
|
import {
|
||||||
daemonDiagnostics,
|
daemonDiagnostics,
|
||||||
daemonRequest,
|
daemonRequest,
|
||||||
@@ -104,14 +105,20 @@ async function runViaDaemon(
|
|||||||
const filePath = path.resolve(fileArg);
|
const filePath = path.resolve(fileArg);
|
||||||
let result: unknown;
|
let result: unknown;
|
||||||
if (cmdArg === "diagnostics") {
|
if (cmdArg === "diagnostics") {
|
||||||
result = await daemonDiagnostics(filePath);
|
// Pick All Available Servers For Diagnostics
|
||||||
|
const ext = path.extname(filePath).replace(/^\./, "");
|
||||||
|
const serverIds = servers
|
||||||
|
.filter((s) => s.match.includes(ext) && isServerAvailable(s))
|
||||||
|
.map((s) => s.id);
|
||||||
|
result = await daemonDiagnostics(filePath, serverIds);
|
||||||
} else if (cmdArg in methodMap) {
|
} else if (cmdArg in methodMap) {
|
||||||
|
const server = pickServer(filePath);
|
||||||
// References Default - Match commands.ts: include declaration unless
|
// References Default - Match commands.ts: include declaration unless
|
||||||
// caller explicitly overrode `context`.
|
// caller explicitly overrode `context`.
|
||||||
if (cmdArg === "references" && !("context" in params)) {
|
if (cmdArg === "references" && !("context" in params)) {
|
||||||
params.context = { includeDeclaration: true };
|
params.context = { includeDeclaration: true };
|
||||||
}
|
}
|
||||||
result = await daemonRequest(filePath, methodMap[cmdArg], params);
|
result = await daemonRequest(filePath, server.id, methodMap[cmdArg], params);
|
||||||
} else {
|
} else {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`Unknown command "${cmdArg}". Known: ${listCommands().join(", ")}\n`,
|
`Unknown command "${cmdArg}". Known: ${listCommands().join(", ")}\n`,
|
||||||
|
|||||||
83
index.ts
83
index.ts
@@ -12,7 +12,7 @@ import {
|
|||||||
daemonRequest,
|
daemonRequest,
|
||||||
daemonStatus,
|
daemonStatus,
|
||||||
} from "./src/daemonClient.ts";
|
} from "./src/daemonClient.ts";
|
||||||
import { pickServer } from "./src/root.ts";
|
import { pickServer, isServerAvailable } from "./src/root.ts";
|
||||||
import { servers } from "./server.ts";
|
import { servers } from "./server.ts";
|
||||||
import {
|
import {
|
||||||
ServerNotFoundError,
|
ServerNotFoundError,
|
||||||
@@ -201,17 +201,8 @@ function formatDocumentSymbols(result: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Format Diagnostics - Turn diagnostic messages into readable text.
|
// Format Diagnostics - Turn diagnostic messages into readable text.
|
||||||
function formatDiagnostics(result: unknown, limit = 20): string {
|
// Format Single Server Diagnostics - Renders one server's diagnostics list.
|
||||||
if (
|
function formatServerDiagnostics(diags: any[], limit: number): string {
|
||||||
!result ||
|
|
||||||
typeof result !== "object" ||
|
|
||||||
!("diagnostics" in result)
|
|
||||||
) {
|
|
||||||
return "(no diagnostics)";
|
|
||||||
}
|
|
||||||
const diags = (result as any).diagnostics;
|
|
||||||
if (!Array.isArray(diags) || diags.length === 0) return "(no diagnostics)";
|
|
||||||
|
|
||||||
const severityNames: Record<number, string> = {
|
const severityNames: Record<number, string> = {
|
||||||
1: "Error",
|
1: "Error",
|
||||||
2: "Warning",
|
2: "Warning",
|
||||||
@@ -236,11 +227,45 @@ function formatDiagnostics(result: unknown, limit = 20): string {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
if (diags.length > limit) {
|
if (diags.length > limit) {
|
||||||
return `${formatted}\n\n... and ${diags.length - limit} more diagnostics (showing first ${limit})`;
|
return `${formatted}\n\n... and ${diags.length - limit} more (showing first ${limit})`;
|
||||||
}
|
}
|
||||||
return formatted;
|
return formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format Diagnostics - Handles the grouped result map from the daemon:
|
||||||
|
// { [serverId]: { uri, diagnostics[] } }. Single-server results omit the
|
||||||
|
// header to avoid noise.
|
||||||
|
function formatDiagnostics(result: unknown, limit = 20): string {
|
||||||
|
if (!result || typeof result !== "object") return "(no diagnostics)";
|
||||||
|
|
||||||
|
const grouped = result as Record<string, any>;
|
||||||
|
const serverIds = Object.keys(grouped);
|
||||||
|
if (serverIds.length === 0) return "(no diagnostics)";
|
||||||
|
|
||||||
|
// Collect Servers With Diagnostics
|
||||||
|
const sections: { id: string; diags: any[] }[] = [];
|
||||||
|
for (const id of serverIds) {
|
||||||
|
const entry = grouped[id];
|
||||||
|
const diags = entry?.diagnostics;
|
||||||
|
if (Array.isArray(diags) && diags.length > 0) {
|
||||||
|
sections.push({ id, diags });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sections.length === 0) return "(no diagnostics)";
|
||||||
|
|
||||||
|
// Single Server - Skip header for brevity.
|
||||||
|
if (sections.length === 1) {
|
||||||
|
return formatServerDiagnostics(sections[0].diags, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple Servers - Group with headers.
|
||||||
|
const perServer = Math.max(5, Math.floor(limit / sections.length));
|
||||||
|
return sections
|
||||||
|
.map((s) => `## ${s.id}\n${formatServerDiagnostics(s.diags, perServer)}`)
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
// Is Expected Error - Returns true if the error is an expected condition
|
// Is Expected Error - Returns true if the error is an expected condition
|
||||||
// (unsupported file type or missing server binary) that should be
|
// (unsupported file type or missing server binary) that should be
|
||||||
// suppressed rather than surfaced to the user.
|
// suppressed rather than surfaced to the user.
|
||||||
@@ -281,7 +306,7 @@ async function runLsp(
|
|||||||
`LSP server "${server.id}" is disabled. Use /lsp-enable ${server.id} to re-enable.`,
|
`LSP server "${server.id}" is disabled. Use /lsp-enable ${server.id} to re-enable.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return await daemonRequest(filePath, method, params);
|
return await daemonRequest(filePath, server.id, method, params);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isExpectedError(error)) {
|
if (isExpectedError(error)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -300,19 +325,26 @@ async function runLsp(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run LSP Diagnostics - Diagnostics arrive as a notification, so the
|
// Pick Diagnostic Servers - Returns all available, non-disabled servers
|
||||||
// daemon has a dedicated op that waits for the next publish. Expected
|
// matching the file's extension. Used for fan-out diagnostics.
|
||||||
// errors (unsupported file type, missing binary) are suppressed.
|
function pickDiagnosticServers(filePath: string): string[] {
|
||||||
|
const ext = path.extname(filePath).replace(/^\./, "");
|
||||||
|
return servers
|
||||||
|
.filter((s) => s.match.includes(ext) && isServerAvailable(s) && !disabledServers.has(s.id))
|
||||||
|
.map((s) => s.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run LSP Diagnostics - Fans out to all matching servers in a single
|
||||||
|
// daemon call. Returns the grouped result map or undefined if no servers.
|
||||||
async function runDiagnostics(filePath: string): Promise<unknown> {
|
async function runDiagnostics(filePath: string): Promise<unknown> {
|
||||||
try {
|
try {
|
||||||
return await daemonDiagnostics(filePath, 1500);
|
const serverIds = pickDiagnosticServers(filePath);
|
||||||
|
if (serverIds.length === 0) return undefined;
|
||||||
|
return await daemonDiagnostics(filePath, serverIds, 1500);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isExpectedError(error)) {
|
if (isExpectedError(error)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
// Daemon-wrapped errors (plain Error with expected message) are also
|
|
||||||
// expected — the daemon catches pickServer() throws and returns them
|
|
||||||
// as string error messages.
|
|
||||||
if (
|
if (
|
||||||
error instanceof Error &&
|
error instanceof Error &&
|
||||||
(error.message.includes("No LSP server registered") ||
|
(error.message.includes("No LSP server registered") ||
|
||||||
@@ -492,9 +524,12 @@ export default function (pi: ExtensionAPI) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const absolutePath = path.resolve(ctx.cwd, filePath);
|
const absolutePath = path.resolve(ctx.cwd, filePath);
|
||||||
// daemonDiagnostics triggers getOrCreateEntry + syncFile in the daemon.
|
// Warm Diagnostic Servers - Fire-and-forget so servers are ready by
|
||||||
// We don't await it — just fire and forget so the server starts warming up.
|
// the time an LSP tool is called.
|
||||||
void daemonDiagnostics(absolutePath).catch(() => {});
|
const serverIds = pickDiagnosticServers(absolutePath);
|
||||||
|
if (serverIds.length > 0) {
|
||||||
|
void daemonDiagnostics(absolutePath, serverIds).catch(() => {});
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore — unsupported file type, missing binary, etc.
|
// Silently ignore — unsupported file type, missing binary, etc.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import * as fs from "node:fs";
|
|||||||
import * as net from "node:net";
|
import * as net from "node:net";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { LspClient } from "./client.ts";
|
import { LspClient } from "./client.ts";
|
||||||
import { findRoot, pickServer, pathToUri } from "./root.ts";
|
import { findRoot, pathToUri } from "./root.ts";
|
||||||
import type { ServerConfig } from "./types.ts";
|
import type { ServerConfig } from "./types.ts";
|
||||||
|
import { servers } from "../server.ts";
|
||||||
import {
|
import {
|
||||||
logPath,
|
logPath,
|
||||||
socketPath,
|
socketPath,
|
||||||
@@ -50,14 +51,25 @@ function log(...args: unknown[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Or Create Entry - Looks up the cached client for a file, spawning a
|
// Find Server By ID - Looks up a ServerConfig from the registry by ID.
|
||||||
// fresh LspClient if needed. The returned entry is guaranteed to have its
|
// Throws if the ID is not registered.
|
||||||
// `ready` promise resolved before the caller uses it.
|
function findServerById(serverId: string): ServerConfig {
|
||||||
|
const server = servers.find((s) => s.id === serverId);
|
||||||
|
if (!server) {
|
||||||
|
throw new Error(`Unknown server ID: "${serverId}". Registered: ${servers.map((s) => s.id).join(", ")}`);
|
||||||
|
}
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Or Create Entry - Looks up the cached client for a server+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(
|
async function getOrCreateEntry(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
|
serverId: string,
|
||||||
launch: LaunchContext,
|
launch: LaunchContext,
|
||||||
): Promise<ClientEntry> {
|
): Promise<ClientEntry> {
|
||||||
const server = pickServer(filePath);
|
const server = findServerById(serverId);
|
||||||
const rootDir = findRoot(filePath, server.rootMarkers);
|
const rootDir = findRoot(filePath, server.rootMarkers);
|
||||||
const key = `${server.id}::${rootDir}`;
|
const key = `${server.id}::${rootDir}`;
|
||||||
const existing = entries.get(key);
|
const existing = entries.get(key);
|
||||||
@@ -161,7 +173,7 @@ async function handle(req: DaemonRequest): Promise<DaemonResponse> {
|
|||||||
switch (req.op) {
|
switch (req.op) {
|
||||||
case "request": {
|
case "request": {
|
||||||
const filePath = path.resolve(req.file);
|
const filePath = path.resolve(req.file);
|
||||||
const entry = await getOrCreateEntry(filePath, req.launch);
|
const entry = await getOrCreateEntry(filePath, req.serverId, req.launch);
|
||||||
const { uri } = await syncFile(entry, filePath);
|
const { uri } = await syncFile(entry, filePath);
|
||||||
bumpIdle(entry);
|
bumpIdle(entry);
|
||||||
const result = await entry.client.sendRequest(
|
const result = await entry.client.sendRequest(
|
||||||
@@ -172,15 +184,27 @@ async function handle(req: DaemonRequest): Promise<DaemonResponse> {
|
|||||||
}
|
}
|
||||||
case "diagnostics": {
|
case "diagnostics": {
|
||||||
const filePath = path.resolve(req.file);
|
const filePath = path.resolve(req.file);
|
||||||
const entry = await getOrCreateEntry(filePath, req.launch);
|
const timeoutMs = req.timeoutMs ?? 1500;
|
||||||
const { uri, changed } = await syncFile(entry, filePath);
|
|
||||||
bumpIdle(entry);
|
// Fan-Out - Run diagnostics against all requested servers in
|
||||||
if (changed) entry.client.clearDiagnostics(uri);
|
// parallel. Individual failures are captured, not thrown.
|
||||||
const result = await entry.client.waitForDiagnostics(
|
const results: Record<string, unknown> = {};
|
||||||
uri,
|
const settled = await Promise.allSettled(
|
||||||
req.timeoutMs ?? 1500,
|
req.serverIds.map(async (serverId) => {
|
||||||
|
const entry = await getOrCreateEntry(filePath, serverId, req.launch);
|
||||||
|
const { uri, changed } = await syncFile(entry, filePath);
|
||||||
|
bumpIdle(entry);
|
||||||
|
if (changed) entry.client.clearDiagnostics(uri);
|
||||||
|
const diag = await entry.client.waitForDiagnostics(uri, timeoutMs);
|
||||||
|
return { serverId, diag };
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
return { id: req.id, ok: true, result };
|
for (const outcome of settled) {
|
||||||
|
if (outcome.status === "fulfilled") {
|
||||||
|
results[outcome.value.serverId] = outcome.value.diag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id: req.id, ok: true, result: results };
|
||||||
}
|
}
|
||||||
case "status": {
|
case "status": {
|
||||||
const result = {
|
const result = {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function unwrap(resp: DaemonResponse): unknown {
|
|||||||
// daemon injects textDocument.uri from `file`, so callers omit it.
|
// daemon injects textDocument.uri from `file`, so callers omit it.
|
||||||
export async function daemonRequest(
|
export async function daemonRequest(
|
||||||
file: string,
|
file: string,
|
||||||
|
serverId: string,
|
||||||
method: string,
|
method: string,
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
@@ -29,6 +30,7 @@ export async function daemonRequest(
|
|||||||
await sendOnce({
|
await sendOnce({
|
||||||
op: "request",
|
op: "request",
|
||||||
file,
|
file,
|
||||||
|
serverId,
|
||||||
method,
|
method,
|
||||||
params,
|
params,
|
||||||
launch: buildLaunchContext(),
|
launch: buildLaunchContext(),
|
||||||
@@ -38,14 +40,18 @@ export async function daemonRequest(
|
|||||||
|
|
||||||
// Wait For Diagnostics - Diagnostics arrive as a notification, not a
|
// Wait For Diagnostics - Diagnostics arrive as a notification, not a
|
||||||
// response, so the daemon has a dedicated op that awaits the next publish.
|
// response, so the daemon has a dedicated op that awaits the next publish.
|
||||||
|
// Accepts an array of server IDs; daemon fans out in parallel and returns
|
||||||
|
// a grouped map: { [serverId]: { uri, diagnostics[] } }.
|
||||||
export async function daemonDiagnostics(
|
export async function daemonDiagnostics(
|
||||||
file: string,
|
file: string,
|
||||||
|
serverIds: string[],
|
||||||
timeoutMs = 1500,
|
timeoutMs = 1500,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return unwrap(
|
return unwrap(
|
||||||
await sendOnce({
|
await sendOnce({
|
||||||
op: "diagnostics",
|
op: "diagnostics",
|
||||||
file,
|
file,
|
||||||
|
serverIds,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
launch: buildLaunchContext(),
|
launch: buildLaunchContext(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export type DaemonRequest =
|
|||||||
id: number;
|
id: number;
|
||||||
op: "request";
|
op: "request";
|
||||||
file: string;
|
file: string;
|
||||||
|
serverId: string;
|
||||||
method: string;
|
method: string;
|
||||||
params: Record<string, unknown>;
|
params: Record<string, unknown>;
|
||||||
launch: LaunchContext;
|
launch: LaunchContext;
|
||||||
@@ -46,6 +47,7 @@ export type DaemonRequest =
|
|||||||
id: number;
|
id: number;
|
||||||
op: "diagnostics";
|
op: "diagnostics";
|
||||||
file: string;
|
file: string;
|
||||||
|
serverIds: string[];
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
launch: LaunchContext;
|
launch: LaunchContext;
|
||||||
}
|
}
|
||||||
@@ -57,6 +59,7 @@ export type DaemonRequestWithoutId =
|
|||||||
| {
|
| {
|
||||||
op: "request";
|
op: "request";
|
||||||
file: string;
|
file: string;
|
||||||
|
serverId: string;
|
||||||
method: string;
|
method: string;
|
||||||
params: Record<string, unknown>;
|
params: Record<string, unknown>;
|
||||||
launch: LaunchContext;
|
launch: LaunchContext;
|
||||||
@@ -64,6 +67,7 @@ export type DaemonRequestWithoutId =
|
|||||||
| {
|
| {
|
||||||
op: "diagnostics";
|
op: "diagnostics";
|
||||||
file: string;
|
file: string;
|
||||||
|
serverIds: string[];
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
launch: LaunchContext;
|
launch: LaunchContext;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user