diff --git a/src/tools/lsp/client.test.ts b/src/tools/lsp/client.test.ts index d7fcf336b..3ca6ee3b8 100644 --- a/src/tools/lsp/client.test.ts +++ b/src/tools/lsp/client.test.ts @@ -12,7 +12,7 @@ mock.module("vscode-jsonrpc/node", () => ({ StreamMessageWriter: function StreamMessageWriter() {}, })) -import { LSPClient } from "./client" +import { LSPClient, validateCwd } from "./client" import type { ResolvedServer } from "./types" describe("LSPClient", () => { @@ -60,4 +60,91 @@ describe("LSPClient", () => { } }) }) + + describe("validateCwd", () => { + it("returns valid for existing directory", () => { + // #given + const dir = mkdtempSync(join(tmpdir(), "lsp-cwd-test-")) + + try { + // #when + const result = validateCwd(dir) + + // #then + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it("returns invalid for non-existent directory", () => { + // #given + const nonExistentDir = join(tmpdir(), "lsp-cwd-nonexistent-" + Date.now()) + + // #when + const result = validateCwd(nonExistentDir) + + // #then + expect(result.valid).toBe(false) + expect(result.error).toContain("Working directory does not exist") + }) + + it("returns invalid when path is a file", () => { + // #given + const dir = mkdtempSync(join(tmpdir(), "lsp-cwd-file-test-")) + const filePath = join(dir, "not-a-dir.txt") + writeFileSync(filePath, "test content") + + try { + // #when + const result = validateCwd(filePath) + + // #then + expect(result.valid).toBe(false) + expect(result.error).toContain("Path is not a directory") + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + }) + + describe("start", () => { + it("throws error when working directory does not exist", async () => { + // #given + const nonExistentDir = join(tmpdir(), "lsp-test-nonexistent-" + Date.now()) + const server: ResolvedServer = { + id: "typescript", + command: ["typescript-language-server", "--stdio"], + extensions: [".ts"], + priority: 0, + } + const client = new LSPClient(nonExistentDir, server) + + // #when / #then + await expect(client.start()).rejects.toThrow("Working directory does not exist") + }) + + it("throws error when path is a file instead of directory", async () => { + // #given + const dir = mkdtempSync(join(tmpdir(), "lsp-client-test-")) + const filePath = join(dir, "not-a-dir.txt") + writeFileSync(filePath, "test content") + + const server: ResolvedServer = { + id: "typescript", + command: ["typescript-language-server", "--stdio"], + extensions: [".ts"], + priority: 0, + } + const client = new LSPClient(filePath, server) + + try { + // #when / #then + await expect(client.start()).rejects.toThrow("Path is not a directory") + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + }) }) diff --git a/src/tools/lsp/client.ts b/src/tools/lsp/client.ts index 9e0845295..57f9a5c8c 100644 --- a/src/tools/lsp/client.ts +++ b/src/tools/lsp/client.ts @@ -1,6 +1,7 @@ -import { spawn, type Subprocess } from "bun" +import { spawn as bunSpawn, type Subprocess } from "bun" +import { spawn as nodeSpawn, spawnSync, type ChildProcess } from "node:child_process" import { Readable, Writable } from "node:stream" -import { readFileSync } from "fs" +import { existsSync, readFileSync, statSync } from "fs" import { extname, resolve } from "path" import { pathToFileURL } from "node:url" import { @@ -13,35 +14,205 @@ import { getLanguageId } from "./config" import type { Diagnostic, ResolvedServer } from "./types" import { log } from "../../shared/logger" -/** - * Check if the current Bun version is affected by Windows LSP crash bug. - * Bun v1.3.5 and earlier have a known segmentation fault issue on Windows - * when spawning LSP servers. This was fixed in Bun v1.3.6. - * See: https://github.com/oven-sh/bun/issues/25798 - */ -function checkWindowsBunVersion(): { isAffected: boolean; message: string } | null { - if (process.platform !== "win32") return null +// Bun spawn segfaults on Windows (oven-sh/bun#25798) — unfixed as of v1.3.8+ +function shouldUseNodeSpawn(): boolean { + return process.platform === "win32" +} - const version = Bun.version - const [major, minor, patch] = version.split(".").map((v) => parseInt(v.split("-")[0], 10)) +// Prevents segfaults when libuv gets a non-existent cwd (oven-sh/bun#25798) +export function validateCwd(cwd: string): { valid: boolean; error?: string } { + try { + if (!existsSync(cwd)) { + return { valid: false, error: `Working directory does not exist: ${cwd}` } + } + const stats = statSync(cwd) + if (!stats.isDirectory()) { + return { valid: false, error: `Path is not a directory: ${cwd}` } + } + return { valid: true } + } catch (err) { + return { valid: false, error: `Cannot access working directory: ${cwd} (${err instanceof Error ? err.message : String(err)})` } + } +} + +function isBinaryAvailableOnWindows(command: string): boolean { + if (process.platform !== "win32") return true + + if (command.includes("/") || command.includes("\\")) { + return existsSync(command) + } + + try { + const result = spawnSync("where", [command], { + shell: true, + windowsHide: true, + timeout: 5000, + }) + return result.status === 0 + } catch { + return true + } +} + +interface StreamReader { + read(): Promise<{ done: boolean; value: Uint8Array | undefined }> +} + +// Bridges Bun Subprocess and Node.js ChildProcess under a common API +interface UnifiedProcess { + stdin: { write(chunk: Uint8Array | string): void } + stdout: { getReader(): StreamReader } + stderr: { getReader(): StreamReader } + exitCode: number | null + exited: Promise + kill(signal?: string): void +} + +function wrapNodeProcess(proc: ChildProcess): UnifiedProcess { + let resolveExited: (code: number) => void + let exitCode: number | null = null + + const exitedPromise = new Promise((resolve) => { + resolveExited = resolve + }) + + proc.on("exit", (code) => { + exitCode = code ?? 1 + resolveExited(exitCode) + }) + + proc.on("error", () => { + if (exitCode === null) { + exitCode = 1 + resolveExited(1) + } + }) + + const createStreamReader = (nodeStream: NodeJS.ReadableStream | null): StreamReader => { + const chunks: Uint8Array[] = [] + let streamEnded = false + type ReadResult = { done: boolean; value: Uint8Array | undefined } + let waitingResolve: ((result: ReadResult) => void) | null = null + + if (nodeStream) { + nodeStream.on("data", (chunk: Buffer) => { + const uint8 = new Uint8Array(chunk) + if (waitingResolve) { + const resolve = waitingResolve + waitingResolve = null + resolve({ done: false, value: uint8 }) + } else { + chunks.push(uint8) + } + }) + + nodeStream.on("end", () => { + streamEnded = true + if (waitingResolve) { + const resolve = waitingResolve + waitingResolve = null + resolve({ done: true, value: undefined }) + } + }) + + nodeStream.on("error", () => { + streamEnded = true + if (waitingResolve) { + const resolve = waitingResolve + waitingResolve = null + resolve({ done: true, value: undefined }) + } + }) + } else { + streamEnded = true + } - // Bun v1.3.5 and earlier are affected - if (major < 1 || (major === 1 && minor < 3) || (major === 1 && minor === 3 && patch < 6)) { return { - isAffected: true, - message: - `⚠️ Windows + Bun v${version} detected: Known segmentation fault bug with LSP.\n` + - ` This causes crashes when using LSP tools (lsp_diagnostics, lsp_goto_definition, etc.).\n` + - ` \n` + - ` SOLUTION: Upgrade to Bun v1.3.6 or later:\n` + - ` powershell -c "irm bun.sh/install.ps1|iex"\n` + - ` \n` + - ` WORKAROUND: Use WSL instead of native Windows.\n` + - ` See: https://github.com/oven-sh/bun/issues/25798`, + read(): Promise { + return new Promise((resolve) => { + if (chunks.length > 0) { + resolve({ done: false, value: chunks.shift()! }) + } else if (streamEnded) { + resolve({ done: true, value: undefined }) + } else { + waitingResolve = resolve + } + }) + }, } } - return null + return { + stdin: { + write(chunk: Uint8Array | string) { + if (proc.stdin) { + proc.stdin.write(chunk) + } + }, + }, + stdout: { + getReader: () => createStreamReader(proc.stdout), + }, + stderr: { + getReader: () => createStreamReader(proc.stderr), + }, + get exitCode() { + return exitCode + }, + exited: exitedPromise, + kill(signal?: string) { + try { + if (signal === "SIGKILL") { + proc.kill("SIGKILL") + } else { + proc.kill() + } + } catch {} + }, + } +} + +function spawnProcess( + command: string[], + options: { cwd: string; env: Record } +): UnifiedProcess { + const cwdValidation = validateCwd(options.cwd) + if (!cwdValidation.valid) { + throw new Error(`[LSP] ${cwdValidation.error}`) + } + + if (shouldUseNodeSpawn()) { + const [cmd, ...args] = command + + if (!isBinaryAvailableOnWindows(cmd)) { + throw new Error( + `[LSP] Binary '${cmd}' not found on Windows. ` + + `Ensure the LSP server is installed and available in PATH. ` + + `For npm packages, try: npm install -g ${cmd}` + ) + } + + log("[LSP] Using Node.js child_process on Windows to avoid Bun spawn segfault") + + const proc = nodeSpawn(cmd, args, { + cwd: options.cwd, + env: options.env as NodeJS.ProcessEnv, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + shell: true, + }) + return wrapNodeProcess(proc) + } + + const proc = bunSpawn(command, { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + cwd: options.cwd, + env: options.env, + }) + + return proc as unknown as UnifiedProcess } interface ManagedClient { @@ -252,7 +423,7 @@ class LSPServerManager { export const lspManager = LSPServerManager.getInstance() export class LSPClient { - private proc: Subprocess<"pipe", "pipe", "pipe"> | null = null + private proc: UnifiedProcess | null = null private connection: MessageConnection | null = null private openedFiles = new Set() private documentVersions = new Map() @@ -268,17 +439,7 @@ export class LSPClient { ) {} async start(): Promise { - const windowsCheck = checkWindowsBunVersion() - if (windowsCheck?.isAffected) { - throw new Error( - `LSP server cannot be started safely.\n\n${windowsCheck.message}` - ) - } - - this.proc = spawn(this.server.command, { - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", + this.proc = spawnProcess(this.server.command, { cwd: this.root, env: { ...process.env, @@ -306,7 +467,7 @@ export class LSPClient { async read() { try { const { done, value } = await stdoutReader.read() - if (done) { + if (done || !value) { this.push(null) } else { this.push(Buffer.from(value))