Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions packages/typespec-vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// sort-imports-ignore
import "./pre-extension-activate.js";

import vscode, { commands, ExtensionContext, TabInputText } from "vscode";
import vscode, { CancellationError, commands, ExtensionContext, TabInputText } from "vscode";
import { State } from "vscode-languageclient";
import { createCodeActionProvider } from "./code-action-provider.js";
import { setTspLanguageClient, tspLanguageClient } from "./extension-context.js";
Expand Down Expand Up @@ -316,11 +316,25 @@ export async function activate(context: ExtensionContext) {
}
// client will be undefined only when we can't find compiler locally or globally
// otherwise, the client should always be created though the start command may fail which is a different case
const choice: "Yes" | "Ignore" | undefined = await vscode.window.showWarningMessage(
"No TypeSpec compiler found which is required to start TypeSpec language server. Do you want to install TypeSpec compiler?",
"Yes",
"Ignore",
);
let choice: "Yes" | "Ignore" | undefined;
try {
choice = await vscode.window.showWarningMessage(
"No TypeSpec compiler found which is required to start TypeSpec language server. Do you want to install TypeSpec compiler?",
"Yes",
"Ignore",
);
} catch (e) {
// VS Code throws CancellationError when the window is closing/reloading
// while the dialog is shown. Treat it as user cancellation.
if (e instanceof CancellationError) {
logger.info(
"Prompt to install TypeSpec compiler was cancelled due to window closing.",
);
ssTel.lastStep = "Prompt to install TypeSpec compiler (window closed).";
return ResultCode.Cancelled;
}
throw e;
}
if (choice === undefined || choice === "Ignore") {
logger.info("User cancelled the prompt to install TypeSpec compiler.");
ssTel.lastStep = "Prompt to install TypeSpec compiler (cancelled).";
Expand Down Expand Up @@ -412,7 +426,11 @@ async function recreateLSPClient(
logger.info("Recreating TypeSpec LSP server...");
const oldClient = tspLanguageClient;
setTspLanguageClient(await TspLanguageClient.create(activityId, context, outputChannel));
await oldClient?.stop();
// Use dispose() instead of stop() to ensure proper cleanup even when the old client
// is in StartFailed state. stop() skips cleanup when needsStop() returns false
// (e.g. after a failed start), which can leave commands like 'typespec.applyCodeFix'
// globally registered, causing "command already exists" errors on the new client.
await oldClient?.dispose();
if (!tspLanguageClient) {
telemetryClient.logOperationDetailTelemetry(activityId, {
error: "Failed to create TspLanguageClient. Compiler could not be resolved.",
Expand Down
44 changes: 43 additions & 1 deletion packages/typespec-vscode/src/tsp-language-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,11 @@ export class TspLanguageClient {
{ showOutput: false, showPopup: true },
);
logger.error("Error detail", [e]);
} else if (isExpectedClientLifecycleError(e)) {
// Expected during initialization failures — the vscode-languageclient library
// internally calls stop() which throws when the client is in "starting" state,
// or pending requests get rejected when the connection is disposed.
logger.debug(`Expected lifecycle error during start: ${e}`);
} else {
logger.error("Unexpected error when starting TypeSpec server", [e], {
showOutput: false,
Expand All @@ -229,7 +234,17 @@ export class TspLanguageClient {

async dispose(): Promise<void> {
if (this.client) {
await this.client.dispose();
try {
await this.client.dispose();
} catch (e) {
// LanguageClient.dispose() calls stop() internally, which throws when
// the client is in StartFailed state ("Client is not running and can't be stopped").
// Pipe/stream errors also occur if the server process has already exited.
// These are expected — swallow them to allow the dispose flow to complete gracefully.
if (!isExpectedClientLifecycleError(e)) {
logger.warning(`Unexpected error during TspLanguageClient dispose: ${e}`);
}
}
}
}

Expand Down Expand Up @@ -276,6 +291,11 @@ export class TspLanguageClient {
outputChannel,
errorHandler: {
error(error, message, count): ErrorHandlerResult {
// Pipe/stream errors are expected when the server process exits unexpectedly.
// The closed() handler below will take care of prompting the user.
if (isExpectedClientLifecycleError(error)) {
return { action: ErrorAction.Shutdown };
}
logger.error(`TypeSpec language server encountered an error: ${error.message ?? error}`, [
message,
]);
Expand Down Expand Up @@ -339,3 +359,25 @@ export class TspLanguageClient {
return result;
}
}

/**
* Identifies errors that are expected during the LSP client lifecycle and should
* not be surfaced to users as unexpected errors. These errors come from the
* vscode-languageclient library during client start/stop/restart transitions.
*/
function isExpectedClientLifecycleError(e: unknown): boolean {
const msg = e instanceof Error ? e.message : typeof e === "string" ? e : "";
return (
// Thrown by LanguageClient.shutdown() when doInitialize() calls stop()
// while the client is still in "starting" state
msg.includes("Client is not running and can't be stopped") ||
// Thrown when pending LSP requests are rejected because the connection
// was disposed during a restart or failed initialization
msg.includes("Pending response rejected since connection got disposed") ||
// Thrown when the server process dies and the client tries to write to
// the broken pipe (common on macOS/Linux)
msg.includes("EPIPE") ||
// Thrown when writing to a stream after the server process has exited
msg.includes("Cannot call write after a stream was destroyed")
);
}
Loading