Skip to content

Commit 5ac2c06

Browse files
ganeshrnclaude
andauthored
fix(als): handle client/registerCapability rejection in non-VS Code LSP clients (#2753)
## Summary - Add `.catch()` handlers to both `connection.client.register()` calls in `onInitialized` (`DidChangeConfigurationNotification` and `DidChangeWatchedFilesNotification`) - Routes registration errors through `this.handleError()` instead of producing unhandled promise rejections - Fixes ALS crashing with exit code 1 when used in LSP clients that don't respond to `client/registerCapability` requests (e.g., Claude Code, Neovim built-in LSP) ## Problem When ALS is used outside VS Code — such as in Claude Code's plugin system or Neovim's built-in LSP client — the two `connection.client.register()` calls in `onInitialized` send `client/registerCapability` requests that these clients don't handle. The resulting unhandled promise rejections crash the Node.js process with exit code 1, making ALS unusable in these environments. ## Test plan - [x] Verified ALS starts and remains stable in Claude Code via [claude-ansible-lsp](https://github.com/tima/claude-ansible-lsp) plugin - [x] Hover docs work — module-level (`ansible.builtin.copy`) and parameter-level (`apt.state`, `copy.dest`) documentation returned correctly - [x] Diagnostics work — `ansible-lint` correctly flags `ansible.builtin.fake_module` as unknown module via `syntax-check[unknown-module]` - [x] Server survives multiple sequential LSP requests without crashing - [x] 201 modules loaded successfully on initialization 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> Adds .catch() handlers to client.register() calls to prevent unhandled promise rejections when LSP clients (e.g., Claude, Neovim) don’t support client/registerCapability, routing errors through handleError so the Ansible Language Server remains running. - Add .catch() to DidChangeConfigurationNotification registration ("registerConfigurationCapability") - Add .catch() to DidChangeWatchedFilesNotification registration ("registerWatchedFilesCapability") - Route errors via this.handleError() related: #2753 <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Ganesh Nalawade <gnalawad@redhat.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f855ba3 commit 5ac2c06

2 files changed

Lines changed: 205 additions & 22 deletions

File tree

packages/ansible-language-server/src/ansibleLanguageService.ts

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,16 @@ export class AnsibleLanguageService {
9797

9898
this.connection.onInitialized(() => {
9999
if (this.workspaceManager.clientCapabilities.workspace?.configuration) {
100-
// register for all configuration changes
101-
this.connection.client.register(
102-
DidChangeConfigurationNotification.type,
103-
{
100+
this.connection.client
101+
.register(DidChangeConfigurationNotification.type, {
104102
section: "ansible",
105-
},
106-
);
103+
})
104+
.catch(() => {
105+
this.connection.console.warn(
106+
"Client does not support dynamic configuration registration. " +
107+
"Configuration change notifications will not be received.",
108+
);
109+
});
107110
}
108111
if (
109112
this.workspaceManager.clientCapabilities.workspace?.workspaceFolders
@@ -112,22 +115,30 @@ export class AnsibleLanguageService {
112115
this.workspaceManager.handleWorkspaceChanged(e);
113116
});
114117
}
115-
this.connection.client.register(DidChangeWatchedFilesNotification.type, {
116-
watchers: [
117-
{
118-
// watch ansible configuration
119-
globPattern: "**/ansible.cfg",
120-
},
121-
{
122-
// watch ansible-lint configuration
123-
globPattern: "**/.ansible-lint",
124-
},
125-
{
126-
// watch role meta-configuration
127-
globPattern: "**/meta/main.{yml,yaml}",
128-
},
129-
],
130-
});
118+
this.connection.client
119+
.register(DidChangeWatchedFilesNotification.type, {
120+
watchers: [
121+
{
122+
// watch ansible configuration
123+
globPattern: "**/ansible.cfg",
124+
},
125+
{
126+
// watch ansible-lint configuration
127+
globPattern: "**/.ansible-lint",
128+
},
129+
{
130+
// watch role meta-configuration
131+
globPattern: "**/meta/main.{yml,yaml}",
132+
},
133+
],
134+
})
135+
.catch(() => {
136+
this.connection.console.warn(
137+
"Client does not support dynamic file watcher registration. " +
138+
"Changes to ansible.cfg, .ansible-lint, and role meta files " +
139+
"will require a server restart to take effect.",
140+
);
141+
});
131142
});
132143
}
133144

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { expect } from "vitest";
2+
import sinon from "sinon";
3+
import { Connection } from "vscode-languageserver";
4+
import { AnsibleLanguageService } from "@src/ansibleLanguageService.js";
5+
6+
interface MockConnection extends Connection {
7+
_simulateInitialize: (params: unknown) => void;
8+
_simulateInitialized: () => void;
9+
}
10+
11+
function createMockDocuments() {
12+
return {
13+
listen: sinon.stub(),
14+
get: sinon.stub().returns(undefined),
15+
onDidOpen: sinon.stub(),
16+
onDidClose: sinon.stub(),
17+
onDidSave: sinon.stub(),
18+
onDidChangeContent: sinon.stub(),
19+
};
20+
}
21+
22+
function createMockConnection(): MockConnection {
23+
const onInitializeHandlers: ((params: unknown) => unknown)[] = [];
24+
const onInitializedHandlers: (() => void)[] = [];
25+
26+
return {
27+
console: {
28+
log: sinon.stub(),
29+
info: sinon.stub(),
30+
warn: sinon.stub(),
31+
error: sinon.stub(),
32+
},
33+
client: {
34+
register: sinon
35+
.stub()
36+
.rejects(new Error("client/registerCapability not supported")),
37+
},
38+
workspace: {
39+
onDidChangeWorkspaceFolders: sinon.stub(),
40+
},
41+
onInitialize: (handler: (params: unknown) => unknown) => {
42+
onInitializeHandlers.push(handler);
43+
},
44+
onInitialized: (handler: () => void) => {
45+
onInitializedHandlers.push(handler);
46+
},
47+
onDidChangeConfiguration: sinon.stub(),
48+
onDidChangeWatchedFiles: sinon.stub(),
49+
onDidChangeTextDocument: sinon.stub(),
50+
onCompletion: sinon.stub(),
51+
onCompletionResolve: sinon.stub(),
52+
onHover: sinon.stub(),
53+
onDefinition: sinon.stub(),
54+
onNotification: sinon.stub(),
55+
sendNotification: sinon.stub(),
56+
window: {
57+
showInformationMessage: sinon.stub(),
58+
},
59+
languages: {
60+
semanticTokens: {
61+
on: sinon.stub(),
62+
},
63+
},
64+
_simulateInitialize(params: unknown) {
65+
for (const handler of onInitializeHandlers) {
66+
handler(params);
67+
}
68+
},
69+
_simulateInitialized() {
70+
for (const handler of onInitializedHandlers) {
71+
handler();
72+
}
73+
},
74+
} as unknown as MockConnection;
75+
}
76+
77+
describe("AnsibleLanguageService", () => {
78+
let mockConnection: MockConnection;
79+
let mockDocuments: ReturnType<typeof createMockDocuments>;
80+
81+
beforeEach(() => {
82+
mockConnection = createMockConnection();
83+
mockDocuments = createMockDocuments();
84+
});
85+
86+
afterEach(() => {
87+
sinon.restore();
88+
});
89+
90+
describe("onInitialized client/registerCapability rejection", () => {
91+
it("should warn on registration failure instead of crashing", async () => {
92+
const service = new AnsibleLanguageService(
93+
mockConnection,
94+
mockDocuments as never,
95+
);
96+
service.initialize();
97+
98+
mockConnection._simulateInitialize({
99+
capabilities: {
100+
workspace: { configuration: true, workspaceFolders: true },
101+
},
102+
workspaceFolders: [],
103+
});
104+
mockConnection._simulateInitialized();
105+
106+
await new Promise((resolve) => setTimeout(resolve, 10));
107+
108+
const warnStub = (
109+
mockConnection as unknown as {
110+
console: { warn: sinon.SinonStub };
111+
}
112+
).console.warn;
113+
expect(warnStub.called).toBe(true);
114+
115+
const warnMessages: string[] = warnStub.args.map(
116+
(args: unknown[]) => args[0] as string,
117+
);
118+
expect(
119+
warnMessages.some((msg) =>
120+
msg.includes("dynamic configuration registration"),
121+
),
122+
).toBe(true);
123+
expect(
124+
warnMessages.some((msg) =>
125+
msg.includes("dynamic file watcher registration"),
126+
),
127+
).toBe(true);
128+
});
129+
130+
it("should continue serving requests after registration failure", async () => {
131+
const service = new AnsibleLanguageService(
132+
mockConnection,
133+
mockDocuments as never,
134+
);
135+
service.initialize();
136+
137+
mockConnection._simulateInitialize({
138+
capabilities: {
139+
workspace: { configuration: true },
140+
},
141+
workspaceFolders: [],
142+
});
143+
mockConnection._simulateInitialized();
144+
145+
await new Promise((resolve) => setTimeout(resolve, 10));
146+
147+
const onHoverStub = (
148+
mockConnection as unknown as { onHover: sinon.SinonStub }
149+
).onHover;
150+
expect(onHoverStub.called).toBe(true);
151+
await expect(
152+
onHoverStub.firstCall.args[0]({
153+
textDocument: { uri: "file:///test.yml" },
154+
position: { line: 0, character: 0 },
155+
}),
156+
).resolves.toBeNull();
157+
158+
const onCompletionStub = (
159+
mockConnection as unknown as {
160+
onCompletion: sinon.SinonStub;
161+
}
162+
).onCompletion;
163+
expect(onCompletionStub.called).toBe(true);
164+
await expect(
165+
onCompletionStub.firstCall.args[0]({
166+
textDocument: { uri: "file:///test.yml" },
167+
position: { line: 0, character: 0 },
168+
}),
169+
).resolves.toBeNull();
170+
});
171+
});
172+
});

0 commit comments

Comments
 (0)