diff --git a/.chronus/changes/fix-lsp-fatal-logging-2026-05-28.md b/.chronus/changes/fix-lsp-fatal-logging-2026-05-28.md new file mode 100644 index 00000000000..f00f13e5e0f --- /dev/null +++ b/.chronus/changes/fix-lsp-fatal-logging-2026-05-28.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/compiler" +--- + +Language server fatal errors now write pending logs and the fatal stack trace directly to stderr so crash details remain visible. diff --git a/packages/compiler/src/server/fatal-error.ts b/packages/compiler/src/server/fatal-error.ts new file mode 100644 index 00000000000..d64b2342336 --- /dev/null +++ b/packages/compiler/src/server/fatal-error.ts @@ -0,0 +1,34 @@ +import { inspect } from "util"; +import type { ServerLog } from "./types.js"; + +export type FatalErrorWriter = (message: string) => void; + +export function writeServerFatalError( + write: FatalErrorWriter, + pendingMessages: readonly ServerLog[], + error: unknown, +) { + for (const pending of pendingMessages) { + write(`${formatPendingServerLog(pending)}\n`); + } + write(`${formatFatalError(error)}\n`); +} + +export function formatFatalError(error: unknown): string { + if (error instanceof Error) { + return error.stack ?? `${error.name}: ${error.message}`; + } + return typeof error === "string" ? error : inspect(error); +} + +function formatPendingServerLog(log: ServerLog): string { + const detail = + log.detail === undefined + ? undefined + : typeof log.detail === "string" + ? log.detail + : inspect(log.detail); + return detail === undefined + ? `[${log.level}] ${log.message}` + : `[${log.level}] ${log.message}:\n${detail}`; +} diff --git a/packages/compiler/src/server/server.ts b/packages/compiler/src/server/server.ts index 20aa781198b..a18f5d95f85 100644 --- a/packages/compiler/src/server/server.ts +++ b/packages/compiler/src/server/server.ts @@ -15,16 +15,19 @@ import { import { NodeHost } from "../core/node-host.js"; import { typespecVersion } from "../manifest.js"; import { createClientConfigProvider } from "./client-config-provider.js"; +import { writeServerFatalError } from "./fatal-error.js"; import { createServer } from "./serverlib.js"; import { CustomRequestName, Server, ServerHost, ServerLog } from "./types.js"; let server: Server | undefined = undefined; +const writeStderr = process.stderr.write.bind(process.stderr) as (message: string) => void; const profileDir = process.env.TYPESPEC_SERVER_PROFILE_DIR; const logTiming = process.env.TYPESPEC_SERVER_LOG_TIMING === "true"; let profileSession: inspector.Session | undefined; process.on("unhandledRejection", fatalError); +process.on("uncaughtException", fatalError); try { main(); } catch (e) { @@ -162,12 +165,7 @@ function main() { function fatalError(e: unknown) { // If we failed to send any log messages over LSP pipe, send them to // stderr before exiting. - for (const pending of server?.pendingMessages ?? []) { - // eslint-disable-next-line no-console - console.error(pending); - } - // eslint-disable-next-line no-console - console.error(e); + writeServerFatalError(writeStderr, server?.pendingMessages ?? [], e); process.exit(1); } diff --git a/packages/compiler/test/server/misc.test.ts b/packages/compiler/test/server/misc.test.ts index 85c7b8afe75..cc01e4a892b 100644 --- a/packages/compiler/test/server/misc.test.ts +++ b/packages/compiler/test/server/misc.test.ts @@ -2,6 +2,7 @@ import { ok, strictEqual } from "assert"; import { describe, it } from "vitest"; import { SyntaxKind, TypeSpecScriptNode, parse } from "../../src/ast/index.js"; import type { PositionDetail } from "../../src/index.js"; +import { formatFatalError, writeServerFatalError } from "../../src/server/fatal-error.js"; import { getCompletionNodeAtPosition } from "../../src/server/serverlib.js"; import { extractCursor } from "../../src/testing/source-utils.js"; import { dumpAST } from "../ast-test-utils.js"; @@ -85,4 +86,25 @@ describe("compiler: server: misc", () => { }); }); }); + + describe("fatal error logging", () => { + it("writes pending server logs and fatal stack to the provided writer", () => { + const messages: string[] = []; + const error = new Error("boom"); + + writeServerFatalError( + (message) => messages.push(message), + [{ level: "info", message: "pending message", detail: { value: 123 } }], + error, + ); + + const output = messages.join(""); + ok(output.includes("[info] pending message:\n{ value: 123 }\n")); + ok(output.includes("Error: boom")); + }); + + it("formats non-error fatal values", () => { + strictEqual(formatFatalError({ reason: "boom" }), "{ reason: 'boom' }"); + }); + }); });