Skip to content

Commit 46fbbc0

Browse files
RodgeFuCopilot
andauthored
fix: handle unhandled exceptions in LSP server and VS Code extension (#10567)
## Problem Multiple unhandled exceptions can escape to the VS Code extension host, causing error telemetry and degraded user experience: 1. **Server crash with no user notification** — When the LSP server process terminates unexpectedly, the default `LanguageClient` behavior silently retries and eventually gives up without informing the user. 2. **DocumentSymbol crash** (`"name must not be falsy"`) — When the TypeSpec compiler returns a document symbol with an empty name (e.g., from empty string literal properties or parse error recovery), VS Code's `DocumentSymbol` constructor rejects it. This throws in the `vscode-languageclient` protocol converter, which has no extension-level catch. 3. **Server handler crashes** (`"Cannot read properties of undefined (reading 'kind')"`) — `getSymNode()` can return `undefined` when a symbol's `declarations` array is empty, but callers access `.kind` without null checks. This crashes hover, completion, and signature help requests. 4. **Extension command exceptions escape** — Commands like `ShowOpenApi3`, `EmitCode`, and `RestartServer` can throw unhandled exceptions that propagate through `doOperationWithTelemetry` (which re-throws after logging telemetry). ## Fix ### VS Code Extension (`packages/typespec-vscode`) - **Custom `ErrorHandler`** on the `LanguageClient`: Shows a notification with "Restart Server" button when the server crashes, instead of silently failing. - **`onUnhandledException` callback** added to all `doOperationWithTelemetry` calls: Allows callers to handle exceptions gracefully (log + show notification) instead of re-throwing to the extension host. The callback now returns `T` so each caller provides the correct typed fallback value. - **All commands covered**: `StartExtension`, `EmitCode`, `RestartServer`, `InstallGlobalCompilerCli`, `CreateProject`, `ImportFromOpenApi3`, `ShowOpenApi3`, `ServerPathSettingChanged`, `StartServer` — every command now has proper exception handling. - **`recreateLSPClient` wrapped in try-catch**: Catches any unexpected errors during server startup/restart and shows a user notification. - **Null guard for `tspLanguageClient`** in `ShowOpenApi3` command: Prevents `TypeError` when client is undefined. - **`resolveTypeSpecServer` no longer throws**: Invalid `TspServerPath` config now logs an error and returns `undefined` instead of throwing. - **`deactivate()` wrapped in try-catch**: Prevents errors during extension shutdown from crashing the host. ### Compiler LSP Server (`packages/compiler`) - **DocumentSymbol empty name fix**: `createDocumentSymbol()` now uses `"<anonymous>"` fallback when name is empty/falsy. - **5 `getSymNode()` null check fixes**: - `serverlib.ts` (hover): `sn.kind` → `sn && sn.kind` - `type-details.ts`: Guard before `getTypeOrValueForNode()` - `type-signature.ts`: Guard before `getTypeOrValueForNode()` - `completion.ts`: Guard before `getTypeForNode()` and `symNode?.kind` for namespace check ### Telemetry Improvements - **Added `compilerStartType` property** to `OperationDetailPropertyName` enum to track how the server was launched (`node` vs `standalone-tsp-cli`). - **Compiler resolution telemetry**: Every branch in `resolveTypeSpecServer` now logs to telemetry — invalid setting type, local/global/configured resolution, development mode, and the "skipped" case when no workspace is open. - **Missing error telemetry filled**: Added `logOperationDetailTelemetry` calls for error paths in `emit-code.ts` (LSP client null, empty compile result) and `openapi3-preview.ts` (temp folder creation failure). - **`tel.lastStep` annotations**: Added to LSP client null-guard branches in `EmitCode` and `ShowOpenApi3` for easier troubleshooting. - **`recreateLSPClient` improved**: Error messages now include client state and specific failure reason for better diagnostics. ## Testing - All 17 document symbol tests pass - All 732 server tests pass (3 pre-existing failures in rename-files unrelated to this change) - TypeScript compilation clean (`tsc --noEmit`) - VS Code extension unit tests pass --------- Co-authored-by: Copilot <copilot@github.com>
1 parent 71c925f commit 46fbbc0

17 files changed

Lines changed: 217 additions & 33 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: fix
3+
packages:
4+
- "@typespec/compiler"
5+
---
6+
7+
Fix server crashes caused by undefined symbol declarations: add null checks for `getSymNode()` in hover, completion, type-details, and type-signature handlers, and use fallback name for empty DocumentSymbol names
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: fix
3+
packages:
4+
- typespec-vscode
5+
---
6+
7+
Handle unhandled exceptions in VS Code extension: add custom error handler for server crashes with restart notification, wrap commands with graceful exception handling, and add null guards to prevent extension host errors when LSP client is unavailable

‎packages/compiler/src/server/completion.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -421,20 +421,20 @@ async function addIdentifierCompletion(
421421
let kind: CompletionItemKind;
422422
let deprecated = false;
423423
const symNode = getSymNode(sym);
424-
const type = sym.type ?? program.checker.getTypeForNode(symNode);
424+
const type = sym.type ?? (symNode ? program.checker.getTypeForNode(symNode) : undefined);
425425
if (sym.flags & (SymbolFlags.Function | SymbolFlags.Decorator)) {
426426
kind = CompletionItemKind.Function;
427427
} else if (
428428
sym.flags & SymbolFlags.Namespace &&
429-
symNode.kind !== SyntaxKind.NamespaceStatement
429+
symNode?.kind !== SyntaxKind.NamespaceStatement
430430
) {
431431
kind = CompletionItemKind.Module;
432432
} else if (symNode?.kind === SyntaxKind.AliasStatement) {
433433
kind = CompletionItemKind.Variable;
434434
deprecated = getDeprecationDetails(program, symNode) !== undefined;
435435
} else {
436-
kind = getCompletionItemKind(program, type);
437-
deprecated = getDeprecationDetails(program, type) !== undefined;
436+
kind = type ? getCompletionItemKind(program, type) : CompletionItemKind.Variable;
437+
deprecated = type ? getDeprecationDetails(program, type) !== undefined : false;
438438
}
439439
const documentation = await getSymbolDetails(program, sym);
440440

‎packages/compiler/src/server/serverlib.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -822,7 +822,7 @@ export function createServer(
822822
// Avoid showing full definition in other cases which can be long and not useful
823823
let includeExpandedDefinition = false;
824824
const sn = getSymNode(sym[0]);
825-
if (sn.kind !== SyntaxKind.AliasStatement) {
825+
if (sn && sn.kind !== SyntaxKind.AliasStatement) {
826826
const type = sym[0].type ?? program.checker.getTypeOrValueForNode(sn);
827827
if (type && "kind" in type) {
828828
const modelHasExtendOrIs: boolean =

‎packages/compiler/src/server/symbol-structure.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ export function getSymbolStructure(ast: TypeSpecScriptNode): DocumentSymbol[] {
109109
const start = file.getLineAndCharacterOfPosition(node.pos);
110110
const end = file.getLineAndCharacterOfPosition(node.end);
111111
const range = Range.create(start, end);
112-
return DocumentSymbol.create(name, undefined, kind, range, range, symbols);
112+
// VS Code requires name to be non-empty; use a placeholder for empty/invalid names
113+
// that can occur during parse errors or with empty string literal properties.
114+
const safeName = name || "<anonymous>";
115+
return DocumentSymbol.create(safeName, undefined, kind, range, range, symbols);
113116
}
114117

115118
function getName(id: IdentifierNode | StringLiteralNode): string {

‎packages/compiler/src/server/type-details.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,12 @@ function getSymbolDocumentation(program: Program, symbol: Sym) {
8080
// Add @doc(...) API docs
8181
let type = symbol.type;
8282
if (!type) {
83-
const entity = program.checker.getTypeOrValueForNode(getSymNode(symbol));
84-
if (entity && isType(entity)) {
85-
type = entity;
83+
const symNode = getSymNode(symbol);
84+
if (symNode) {
85+
const entity = program.checker.getTypeOrValueForNode(symNode);
86+
if (entity && isType(entity)) {
87+
type = entity;
88+
}
8689
}
8790
}
8891
if (type) {

‎packages/compiler/src/server/type-signature.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export async function getSymbolSignature(
4646
case SyntaxKind.AliasStatement:
4747
return fence(`alias ${await getAliasSignature(decl)}`);
4848
}
49-
const entity = sym.type ?? program.checker.getTypeOrValueForNode(decl);
49+
const entity = sym.type ?? (decl ? program.checker.getTypeOrValueForNode(decl) : null);
5050
return getEntitySignature(sym, entity, options);
5151
}
5252

‎packages/typespec-vscode/src/extension.ts‎

Lines changed: 94 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ export async function activate(context: ExtensionContext) {
126126
async (tel): Promise<ResultCode> => {
127127
return await emitCode(context, uri, tel);
128128
},
129+
undefined,
130+
(e) => {
131+
logger.error("Unexpected error when emitting code from TypeSpec.", [e], {
132+
showPopup: true,
133+
});
134+
return ResultCode.Fail;
135+
},
129136
);
130137
},
131138
);
@@ -165,6 +172,14 @@ export async function activate(context: ExtensionContext) {
165172
}
166173
},
167174
args?.activityId,
175+
(e) => {
176+
logger.error(
177+
"Unexpected error when restarting TypeSpec language server.",
178+
[e],
179+
{ showPopup: true },
180+
);
181+
return { code: ResultCode.Fail };
182+
},
168183
);
169184
},
170185
);
@@ -198,7 +213,26 @@ export async function activate(context: ExtensionContext) {
198213
await telemetryClient.doOperationWithTelemetry(
199214
TelemetryEventName.PreviewOpenApi3,
200215
async (tel): Promise<ResultCode> => {
201-
return await showOpenApi3(uri, context, tspLanguageClient!, tel);
216+
if (!tspLanguageClient || tspLanguageClient.state !== State.Running) {
217+
logger.error(
218+
"TypeSpec language server is not running. Please restart the server.",
219+
[],
220+
{ showPopup: true },
221+
);
222+
telemetryClient.logOperationDetailTelemetry(tel.activityId, {
223+
error: "LSP client is not running",
224+
});
225+
tel.lastStep = "Check LSP client";
226+
return ResultCode.Fail;
227+
}
228+
return await showOpenApi3(uri, context, tspLanguageClient, tel);
229+
},
230+
undefined,
231+
(e) => {
232+
logger.error("Unexpected error when previewing OpenAPI3.", [e], {
233+
showPopup: true,
234+
});
235+
return ResultCode.Fail;
202236
},
203237
);
204238
}),
@@ -213,6 +247,13 @@ export async function activate(context: ExtensionContext) {
213247
async (tel) => {
214248
return await recreateLSPClient(context, tel.activityId);
215249
},
250+
undefined,
251+
(e) => {
252+
logger.error("Unexpected error when restarting server after path change.", [e], {
253+
showPopup: true,
254+
});
255+
return { code: ResultCode.Fail };
256+
},
216257
);
217258
}
218259
}),
@@ -318,14 +359,30 @@ export async function activate(context: ExtensionContext) {
318359
return installResult.code;
319360
},
320361
tel.activityId,
362+
(e) => {
363+
logger.error("Unexpected error when starting TypeSpec language server.", [e], {
364+
showPopup: true,
365+
});
366+
return ResultCode.Fail;
367+
},
321368
);
322369
} else {
323370
logger.info("No workspace opened, Skip starting TypeSpec language service.");
371+
telemetryClient.logOperationDetailTelemetry(tel.activityId, {
372+
compilerLocation: "skipped-no-workspace-or-tsp-file",
373+
});
324374
}
325375
showStartUpMessages(stateManager);
326376
telemetryClient.sendDelayedTelemetryEvents();
327377
return ResultCode.Success;
328378
},
379+
undefined,
380+
(e) => {
381+
logger.error("Unexpected error when starting TypeSpec extension.", [e], {
382+
showPopup: true,
383+
});
384+
return ResultCode.Fail;
385+
},
329386
);
330387

331388
// Expose API for other extensions to consume
@@ -339,36 +396,52 @@ export async function activate(context: ExtensionContext) {
339396
}
340397

341398
export async function deactivate() {
342-
await tspLanguageClient?.stop();
343-
await clearOpenApi3PreviewTempFolders();
399+
try {
400+
await tspLanguageClient?.stop();
401+
await clearOpenApi3PreviewTempFolders();
402+
} catch (e) {
403+
logger.error("Error during extension deactivation", [e]);
404+
}
344405
}
345406

346407
async function recreateLSPClient(
347408
context: ExtensionContext,
348409
activityId: string,
349410
): Promise<Result<TspLanguageClient>> {
350-
logger.info("Recreating TypeSpec LSP server...");
351-
const oldClient = tspLanguageClient;
352-
setTspLanguageClient(await TspLanguageClient.create(activityId, context, outputChannel));
353-
await oldClient?.stop();
354-
if (!tspLanguageClient) {
355-
telemetryClient.logOperationDetailTelemetry(activityId, {
356-
error: "Failed to create TspLanguageClient",
357-
});
358-
return { code: ResultCode.Fail, details: "Failed to create TspLanguageClient." };
359-
} else {
360-
await tspLanguageClient.start(activityId);
361-
if (tspLanguageClient.state === State.Running) {
411+
try {
412+
logger.info("Recreating TypeSpec LSP server...");
413+
const oldClient = tspLanguageClient;
414+
setTspLanguageClient(await TspLanguageClient.create(activityId, context, outputChannel));
415+
await oldClient?.stop();
416+
if (!tspLanguageClient) {
362417
telemetryClient.logOperationDetailTelemetry(activityId, {
363-
compilerVersion: tspLanguageClient.initializeResult?.serverInfo?.version ?? "< 0.64.0",
418+
error: "Failed to create TspLanguageClient. Compiler could not be resolved.",
364419
});
365-
return { code: ResultCode.Success, value: tspLanguageClient };
420+
return { code: ResultCode.Fail, details: "Failed to create TspLanguageClient." };
366421
} else {
367-
telemetryClient.logOperationDetailTelemetry(activityId, {
368-
error: `Failed to start TspLanguageClient.`,
369-
});
370-
return { code: ResultCode.Fail, details: "TspLanguageClient is not running." };
422+
await tspLanguageClient.start(activityId);
423+
if (tspLanguageClient.state === State.Running) {
424+
telemetryClient.logOperationDetailTelemetry(activityId, {
425+
compilerVersion: tspLanguageClient.initializeResult?.serverInfo?.version ?? "< 0.64.0",
426+
});
427+
return { code: ResultCode.Success, value: tspLanguageClient };
428+
} else {
429+
telemetryClient.logOperationDetailTelemetry(activityId, {
430+
error: `Failed to start TspLanguageClient. State: ${tspLanguageClient.state}`,
431+
});
432+
return { code: ResultCode.Fail, details: "TspLanguageClient is not running." };
433+
}
371434
}
435+
} catch (e) {
436+
logger.error(
437+
"TypeSpec language server is unavailable due to an unexpected error. Please restart the server.",
438+
[e],
439+
{ showPopup: true },
440+
);
441+
telemetryClient.logOperationDetailTelemetry(activityId, {
442+
error: `Unexpected error in recreateLSPClient: ${e}`,
443+
});
444+
return { code: ResultCode.Fail, details: `Unexpected error: ${e}` };
372445
}
373446
}
374447

‎packages/typespec-vscode/src/telemetry/telemetry-client.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,13 @@ export class TelemetryClient {
120120
sendTelemetryEvent: (result: ResultCode, delay: boolean) => void,
121121
) => Promise<T>,
122122
activityId?: string,
123+
/**
124+
* Optional callback to handle unhandled exceptions from the operation.
125+
* If provided, the exception will NOT be re-thrown and this callback will be invoked instead,
126+
* and its return value will be used as the result of the operation.
127+
* If not provided, the exception will be re-thrown after telemetry logging (default behavior).
128+
*/
129+
onUnhandledException?: (error: unknown) => T,
123130
): Promise<T> {
124131
const opTelemetryEvent = this.createOperationTelemetryEvent(eventName, activityId);
125132
let eventSent = false;
@@ -149,6 +156,9 @@ export class TelemetryClient {
149156
error: "Unhandled exception from operation to doOperationWithTelemetry: \n" + inspect(e),
150157
});
151158
opTelemetryEvent.result = ResultCode.Fail;
159+
if (onUnhandledException) {
160+
return onUnhandledException(e);
161+
}
152162
// just report the issue in telemetry and re-throw the error
153163
throw e;
154164
} finally {

‎packages/typespec-vscode/src/telemetry/telemetry-event.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export enum OperationDetailPropertyName {
3939
emitResult,
4040
compilerLocation,
4141
compilerVersion,
42+
compilerStartType,
4243
CompileStartTime,
4344
CompileEndTime,
4445
}

0 commit comments

Comments
 (0)