fix(lsp): accepted dynamic capability registration

- Accepted client/registerCapability and client/unregisterCapability server requests so Expert can continue startup before semantic requests.\n- Allowed JSON-RPC string ids on LSP request/response handling.\n- Added a regression test for dynamic registration gating hover.\n\nFixes #3029
This commit is contained in:
roboomp
2026-06-19 06:50:02 +00:00
parent ed1c977bbb
commit 1a9fef5014
4 changed files with 91 additions and 12 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed LSP servers that dynamically register capabilities, including Expert, hanging semantic requests after `client/registerCapability` was rejected ([#3029](https://github.com/can1357/oh-my-pi/issues/3029)).
## [16.1.0] - 2026-06-19
### Added
+9 -9
View File
@@ -5,6 +5,7 @@ import { applyWorkspaceEdit } from "./edits";
import { getLspmuxCommand, isLspmuxSupported } from "./lspmux";
import type {
LspClient,
LspJsonRpcId,
LspJsonRpcNotification,
LspJsonRpcRequest,
LspJsonRpcResponse,
@@ -416,7 +417,6 @@ function currentWorkspaceFolders(client: LspClient): Array<{ uri: string; name:
* Handle workspace/workspaceFolders requests from the server.
*/
async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJsonRpcRequest): Promise<void> {
if (typeof message.id !== "number") return;
await sendResponse(client, message.id, currentWorkspaceFolders(client), "workspace/workspaceFolders");
}
@@ -424,7 +424,6 @@ async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJson
* Handle workspace/configuration requests from the server.
*/
async function handleConfigurationRequest(client: LspClient, message: LspJsonRpcRequest): Promise<void> {
if (typeof message.id !== "number") return;
const params = message.params as { items?: Array<{ section?: string }> };
const items = params?.items ?? [];
const result = items.map(item => {
@@ -438,7 +437,6 @@ async function handleConfigurationRequest(client: LspClient, message: LspJsonRpc
* Handle workspace/applyEdit requests from the server.
*/
async function handleApplyEditRequest(client: LspClient, message: LspJsonRpcRequest): Promise<void> {
if (typeof message.id !== "number") return;
const params = message.params as { edit?: WorkspaceEdit };
if (!params?.edit) {
await sendResponse(
@@ -475,13 +473,15 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest
return;
}
if (message.method === "window/workDoneProgress/create") {
// Accept progress token registration from the server
if (typeof message.id === "number") {
await sendResponse(client, message.id, null, message.method);
}
// Accept progress token registration from the server.
await sendResponse(client, message.id, null, message.method);
return;
}
if (message.method === "client/registerCapability" || message.method === "client/unregisterCapability") {
// Some servers block semantic requests until dynamic registration succeeds.
await sendResponse(client, message.id, null, message.method);
return;
}
if (typeof message.id !== "number") return;
await sendResponse(client, message.id, null, message.method, {
code: -32601,
message: `Method not found: ${message.method}`,
@@ -493,7 +493,7 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest
*/
async function sendResponse(
client: LspClient,
id: number,
id: LspJsonRpcId,
result: unknown,
method: string,
error?: { code: number; message: string; data?: unknown },
+6 -3
View File
@@ -399,7 +399,7 @@ export interface LspClient {
diagnostics: Map<string, PublishedDiagnostics>;
diagnosticsVersion: number;
openFiles: Map<string, OpenFile>;
pendingRequests: Map<number, PendingRequest>;
pendingRequests: Map<number | string, PendingRequest>;
messageBuffer: Uint8Array;
isReading: boolean;
/** Lifecycle state: "connecting" until initialize completes, then "ready"; "error" on init failure or reader death. */
@@ -420,16 +420,19 @@ export interface LspClient {
// JSON-RPC Protocol Types
// =============================================================================
/** JSON-RPC request/response identifier accepted by LSP peers. */
export type LspJsonRpcId = number | string;
export interface LspJsonRpcRequest {
jsonrpc: "2.0";
id: number;
id: LspJsonRpcId;
method: string;
params: unknown;
}
export interface LspJsonRpcResponse {
jsonrpc: "2.0";
id?: number;
id?: LspJsonRpcId;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
@@ -308,6 +308,78 @@ describe("lsp regressions", () => {
}
});
it("accepts dynamic capability registration before semantic requests", async () => {
const tempDir = TempDir.createSync("@omp-lsp-dynamic-registration-");
try {
let dynamicRegistrationAccepted = false;
const server = installFakeLsp((message, srv) => {
if (message.method === "initialize") {
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: { hoverProvider: true } } });
} else if (message.method === "initialized") {
srv.send({
jsonrpc: "2.0",
id: 9002,
method: "client/registerCapability",
params: {
registrations: [
{
id: "-42",
method: "workspace/didChangeWatchedFiles",
registerOptions: {
watchers: [{ globPattern: "**/mix.lock" }, { globPattern: "**/*.{ex,exs}" }],
},
},
],
},
});
srv.send({
jsonrpc: "2.0",
id: "expert-unregister-1",
method: "client/unregisterCapability",
params: { unregisterations: [{ id: "-42", method: "workspace/didChangeWatchedFiles" }] },
});
} else if (message.id === 9002 && message.method === undefined) {
dynamicRegistrationAccepted = message.error === undefined;
} else if (message.method === "textDocument/hover" && dynamicRegistrationAccepted) {
srv.send({ jsonrpc: "2.0", id: message.id, result: { contents: "Atas.version()" } });
} else if (message.method === "shutdown") {
srv.send({ jsonrpc: "2.0", id: message.id, result: null });
} else if (message.method === "exit") {
srv.exit(0);
}
});
const config: ServerConfig = {
command: "fake-lsp",
fileTypes: ["ex"],
rootMarkers: [],
};
const client = await lspClient.getOrCreateClient(config, tempDir.path(), 1_000);
const registerResponse = await server.waitFor(message => message.id === 9002 && message.method === undefined);
const unregisterResponse = await server.waitFor(
message => message.id === "expert-unregister-1" && message.method === undefined,
);
expect(registerResponse.error).toBeUndefined();
expect(unregisterResponse.error).toBeUndefined();
const result = await lspClient.sendRequest(
client,
"textDocument/hover",
{
textDocument: { uri: fileToUri(path.join(tempDir.path(), "lib", "atas.ex")) },
position: { line: 0, character: 0 },
},
undefined,
50,
);
expect(result).toEqual({ contents: "Atas.version()" });
} finally {
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("opens rust-analyzer Cargo workspace files before polling workspace readiness", async () => {
const tempDir = TempDir.createSync("@omp-lsp-rust-workspace-");
try {