Merge PR #8392: fix(lsp): isolate document overlays across sessions (@roboomp)

This commit is contained in:
can1357
2026-08-13 05:48:27 +02:00
5 changed files with 101 additions and 136 deletions
+12 -13
View File
@@ -2,9 +2,9 @@
* Cross-process contract for the broker-owned LSP mux daemon.
*
* One mux daemon runs per project scope (launched through the same daemon
* broker that owns the shared Chromium and `hub start` processes). It spawns
* each language server once and multiplexes every omp instance in the project
* onto that single server over a local socket. The link speaks plain
* broker that owns the shared Chromium and `hub start` processes). It assigns
* each concurrent OMP link its own language-server process, then retains idle
* processes briefly for reuse by later links. The link speaks plain
* Content-Length-framed LSP JSON-RPC after a one-request handshake
* ({@link MUX_CONNECT_METHOD}); everything below is shared by the worker
* entry (`server.ts`), the client connector (`daemon.ts`), and tests.
@@ -46,9 +46,9 @@ export function lspMuxEndpoint(projectDir: string, runtimeDir: string): string {
/**
* First (and only pre-LSP) request on a fresh connection: binds the link to
* one shared server instance, spawning it on first use. Params:
* {@link MuxConnectParams}, result: {@link MuxConnectResult}. After the
* response the link carries ordinary LSP traffic for that server.
* an idle server instance or spawns one. Params: {@link MuxConnectParams},
* result: {@link MuxConnectResult}. After the response the link carries
* ordinary LSP traffic for that server.
*/
export const MUX_CONNECT_METHOD = "omp/muxConnect";
@@ -60,15 +60,14 @@ export const MUX_PING_METHOD = "omp/muxPing";
export const MUX_PING_RESULT = "pong";
/**
* Notification on a bound link: kill the shared server process (all sessions
* on it are disconnected). Sent by `lsp reload` when the generic reload path
* decides the server is wedged — a plain per-session `shutdown`/`exit` is
* intercepted by the mux and would leave the wedged server running for
* every other instance.
* Notification on a bound link: kill that link's server process. Sent by
* `lsp reload` when the generic reload path decides the server is wedged —
* a plain per-session `shutdown`/`exit` is intercepted by the mux so the
* process can linger for reuse.
*/
export const MUX_RESTART_METHOD = "omp/muxRestartServer";
/** Handshake parameters identifying (and if needed spawning) a shared server. */
/** Handshake parameters identifying a reusable server process. */
export interface MuxConnectParams {
/** Executable to spawn (the client's `resolvedCommand ?? command`). */
command: string;
@@ -86,7 +85,7 @@ export interface MuxConnectResult {
key: string;
/** True when this handshake spawned the server process. */
spawned: boolean;
/** Pid of the shared server process. */
/** Pid of the server process assigned to this link. */
pid?: number;
}
+28 -79
View File
@@ -22,16 +22,6 @@ const SHUTDOWN_BUDGET_MS = 2_000;
type RpcMessage = LspJsonRpcRequest | LspJsonRpcResponse | LspJsonRpcNotification;
interface SessionDocumentVersion {
clientVersion: number;
serverVersion: number;
}
interface DocumentRecord {
serverVersion: number;
perSession: Map<Session, SessionDocumentVersion>;
}
interface ForwardedRequest {
session?: Session;
originalId?: LspJsonRpcId;
@@ -92,7 +82,7 @@ class ServerInstance {
readonly key: string;
readonly proc: ptree.ChildProcess<"pipe">;
readonly sessions = new Set<Session>();
readonly documents = new Map<string, DocumentRecord>();
readonly documents = new Set<string>();
readonly diagnostics = new Map<string, DiagnosticsParams>();
readonly registrations: RegistrationBatch[] = [];
readonly progress = new Map<string | number, ProgressParams>();
@@ -188,7 +178,7 @@ function cloneParams<T>(params: T): T {
export class LspMuxServer {
/** Called after the mux has had no connected sessions for its idle grace period. */
onIdle?: () => void;
readonly #servers = new Map<string, ServerInstance>();
readonly #servers = new Set<ServerInstance>();
readonly #sessions = new Set<Session>();
#netServer?: net.Server;
#endpoint?: string;
@@ -204,7 +194,7 @@ export class LspMuxServer {
/** Keys of currently live shared language-server children. */
get serverKeys(): string[] {
return [...this.#servers.keys()];
return [...this.#servers].map(server => server.key);
}
/** Listen for Content-Length framed mux links at a Unix socket or named pipe. */
@@ -235,7 +225,7 @@ export class LspMuxServer {
this.#shuttingDown = true;
clearTimeout(this.#idleTimer);
for (const session of [...this.#sessions]) session.socket.destroy();
await Promise.all([...this.#servers.values()].map(server => this.#stopServer(server)));
await Promise.all([...this.#servers].map(server => this.#stopServer(server)));
const listener = this.#netServer;
this.#netServer = undefined;
if (listener) {
@@ -330,7 +320,7 @@ export class LspMuxServer {
return;
}
const key = muxServerKey(params.command, params.cwd);
let server = this.#servers.get(key);
let server = [...this.#servers].find(candidate => candidate.key === key && candidate.sessions.size === 0);
if (server && server.proc.exitCode !== null) {
this.#serverExited(server);
server = undefined;
@@ -432,63 +422,26 @@ export class LspMuxServer {
const params = parseDocumentParams(message.params);
if (!params) return;
const uri = params.textDocument.uri;
const clientVersion = params.textDocument.version;
session.openUris.add(uri);
const existing = server.documents.get(uri);
if (!existing) {
server.documents.set(uri, {
serverVersion: clientVersion,
perSession: new Map([[session, { clientVersion, serverVersion: clientVersion }]]),
});
await this.#writeServer(server, message);
return;
}
existing.serverVersion = Math.max(existing.serverVersion + 1, clientVersion);
existing.perSession.set(session, { clientVersion, serverVersion: existing.serverVersion });
await this.#writeServer(server, {
jsonrpc: "2.0",
method: "textDocument/didChange",
params: {
textDocument: { uri, version: existing.serverVersion },
contentChanges: [{ text: params.textDocument.text ?? "" }],
},
});
server.documents.add(uri);
await this.#writeServer(server, message);
}
async #didChange(session: Session, server: ServerInstance, message: LspJsonRpcNotification): Promise<void> {
const params = parseDocumentParams(message.params);
if (!params) return;
const uri = params.textDocument.uri;
const record = server.documents.get(uri);
if (!record) {
await this.#writeServer(server, message);
return;
}
const clientVersion = params.textDocument.version;
record.serverVersion = Math.max(record.serverVersion + 1, clientVersion);
record.perSession.set(session, { clientVersion, serverVersion: record.serverVersion });
// Map each client's version stream into the one monotonically increasing server stream.
await this.#writeServer(server, {
...message,
params: { ...params, textDocument: { ...params.textDocument, version: record.serverVersion } },
});
async #didChange(_session: Session, server: ServerInstance, message: LspJsonRpcNotification): Promise<void> {
await this.#writeServer(server, message);
}
async #didClose(session: Session, server: ServerInstance, message: LspJsonRpcNotification): Promise<void> {
const uri = parseUri(message.params);
if (!uri) return;
session.openUris.delete(uri);
const record = server.documents.get(uri);
if (!record) return;
record.perSession.delete(session);
if (record.perSession.size > 0) return;
server.documents.delete(uri);
await this.#writeServer(server, message);
}
#spawnServer(key: string, params: MuxConnectParams): ServerInstance {
const server = new ServerInstance(key, params);
this.#servers.set(key, server);
this.#servers.add(server);
void this.#readServer(server);
server.proc.exited.then(
() => this.#serverExited(server),
@@ -537,7 +490,7 @@ export class LspMuxServer {
const params = parseDiagnostics(message.params);
if (!params) return;
server.diagnostics.set(params.uri, cloneParams(params));
for (const session of server.sessions) if (session.initialized) this.#sendDiagnostics(session, server, params);
for (const session of server.sessions) if (session.initialized) this.#sendDiagnostics(session, params);
return;
}
if (message.method === "$/progress") {
@@ -641,13 +594,12 @@ export class LspMuxServer {
void this.#writeServer(session.server, { ...message, id: pending.serverId });
}
#sendDiagnostics(session: Session, server: ServerInstance, params: DiagnosticsParams): void {
const rewritten = cloneParams(params);
const version = server.documents.get(params.uri)?.perSession.get(session);
if (params.version !== undefined && version && params.version === version.serverVersion)
rewritten.version = version.clientVersion;
else delete rewritten.version;
this.#sendSession(session, { jsonrpc: "2.0", method: "textDocument/publishDiagnostics", params: rewritten });
#sendDiagnostics(session: Session, params: DiagnosticsParams): void {
this.#sendSession(session, {
jsonrpc: "2.0",
method: "textDocument/publishDiagnostics",
params: cloneParams(params),
});
}
#replayState(session: Session, server: ServerInstance): void {
@@ -660,7 +612,7 @@ export class LspMuxServer {
params: cloneParams(batch),
});
}
for (const params of server.diagnostics.values()) this.#sendDiagnostics(session, server, params);
for (const params of server.diagnostics.values()) this.#sendDiagnostics(session, params);
for (const params of server.progress.values()) {
this.#sendSession(session, { jsonrpc: "2.0", method: "$/progress", params: cloneParams(params) });
}
@@ -691,26 +643,23 @@ export class LspMuxServer {
this.#sessions.delete(session);
const server = session.server;
if (server) {
server.sessions.delete(session);
let cleanup: Promise<void> | undefined;
for (const uri of session.openUris) {
const record = server.documents.get(uri);
if (!record) continue;
record.perSession.delete(session);
if (record.perSession.size === 0) {
server.documents.delete(uri);
await this.#writeServer(server, {
jsonrpc: "2.0",
method: "textDocument/didClose",
params: { textDocument: { uri } },
});
}
server.documents.delete(uri);
cleanup = this.#writeServer(server, {
jsonrpc: "2.0",
method: "textDocument/didClose",
params: { textDocument: { uri } },
});
}
await cleanup;
for (const [muxId, pending] of server.pending) {
if (pending.session !== session) continue;
pending.drop = true;
await this.#writeServer(server, { jsonrpc: "2.0", method: "$/cancelRequest", params: { id: muxId } });
}
server.initializeWaiters.delete(session);
server.sessions.delete(session);
if (server.sessions.size === 0 && !server.stopping) {
server.lingerTimer = setTimeout(() => {
if (server.sessions.size === 0) void this.#stopServer(server);
@@ -722,7 +671,7 @@ export class LspMuxServer {
#serverExited(server: ServerInstance): void {
server.stopping = true;
if (this.#servers.get(server.key) === server) this.#servers.delete(server.key);
this.#servers.delete(server);
if (server.lingerTimer) clearTimeout(server.lingerTimer);
server.pending.clear();
for (const session of [...server.sessions]) session.socket.destroy();