Merge pull request #1510 from code-yeongyu/fix/windows-lsp-node-spawn-v2
fix(lsp): use Node.js child_process on Windows to avoid Bun spawn segfault
This commit is contained in:
commit
955ce710d9
@ -12,7 +12,7 @@ mock.module("vscode-jsonrpc/node", () => ({
|
|||||||
StreamMessageWriter: function StreamMessageWriter() {},
|
StreamMessageWriter: function StreamMessageWriter() {},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { LSPClient } from "./client"
|
import { LSPClient, validateCwd } from "./client"
|
||||||
import type { ResolvedServer } from "./types"
|
import type { ResolvedServer } from "./types"
|
||||||
|
|
||||||
describe("LSPClient", () => {
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 { Readable, Writable } from "node:stream"
|
||||||
import { readFileSync } from "fs"
|
import { existsSync, readFileSync, statSync } from "fs"
|
||||||
import { extname, resolve } from "path"
|
import { extname, resolve } from "path"
|
||||||
import { pathToFileURL } from "node:url"
|
import { pathToFileURL } from "node:url"
|
||||||
import {
|
import {
|
||||||
@ -13,35 +14,205 @@ import { getLanguageId } from "./config"
|
|||||||
import type { Diagnostic, ResolvedServer } from "./types"
|
import type { Diagnostic, ResolvedServer } from "./types"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
/**
|
// Bun spawn segfaults on Windows (oven-sh/bun#25798) — unfixed as of v1.3.8+
|
||||||
* Check if the current Bun version is affected by Windows LSP crash bug.
|
function shouldUseNodeSpawn(): boolean {
|
||||||
* Bun v1.3.5 and earlier have a known segmentation fault issue on Windows
|
return process.platform === "win32"
|
||||||
* 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
|
|
||||||
|
|
||||||
const version = Bun.version
|
// Prevents segfaults when libuv gets a non-existent cwd (oven-sh/bun#25798)
|
||||||
const [major, minor, patch] = version.split(".").map((v) => parseInt(v.split("-")[0], 10))
|
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<number>
|
||||||
|
kill(signal?: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapNodeProcess(proc: ChildProcess): UnifiedProcess {
|
||||||
|
let resolveExited: (code: number) => void
|
||||||
|
let exitCode: number | null = null
|
||||||
|
|
||||||
|
const exitedPromise = new Promise<number>((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 {
|
return {
|
||||||
isAffected: true,
|
read(): Promise<ReadResult> {
|
||||||
message:
|
return new Promise((resolve) => {
|
||||||
`⚠️ Windows + Bun v${version} detected: Known segmentation fault bug with LSP.\n` +
|
if (chunks.length > 0) {
|
||||||
` This causes crashes when using LSP tools (lsp_diagnostics, lsp_goto_definition, etc.).\n` +
|
resolve({ done: false, value: chunks.shift()! })
|
||||||
` \n` +
|
} else if (streamEnded) {
|
||||||
` SOLUTION: Upgrade to Bun v1.3.6 or later:\n` +
|
resolve({ done: true, value: undefined })
|
||||||
` powershell -c "irm bun.sh/install.ps1|iex"\n` +
|
} else {
|
||||||
` \n` +
|
waitingResolve = resolve
|
||||||
` WORKAROUND: Use WSL instead of native Windows.\n` +
|
}
|
||||||
` See: https://github.com/oven-sh/bun/issues/25798`,
|
})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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<string, string | undefined> }
|
||||||
|
): 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 {
|
interface ManagedClient {
|
||||||
@ -252,7 +423,7 @@ class LSPServerManager {
|
|||||||
export const lspManager = LSPServerManager.getInstance()
|
export const lspManager = LSPServerManager.getInstance()
|
||||||
|
|
||||||
export class LSPClient {
|
export class LSPClient {
|
||||||
private proc: Subprocess<"pipe", "pipe", "pipe"> | null = null
|
private proc: UnifiedProcess | null = null
|
||||||
private connection: MessageConnection | null = null
|
private connection: MessageConnection | null = null
|
||||||
private openedFiles = new Set<string>()
|
private openedFiles = new Set<string>()
|
||||||
private documentVersions = new Map<string, number>()
|
private documentVersions = new Map<string, number>()
|
||||||
@ -268,17 +439,7 @@ export class LSPClient {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
const windowsCheck = checkWindowsBunVersion()
|
this.proc = spawnProcess(this.server.command, {
|
||||||
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",
|
|
||||||
cwd: this.root,
|
cwd: this.root,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@ -306,7 +467,7 @@ export class LSPClient {
|
|||||||
async read() {
|
async read() {
|
||||||
try {
|
try {
|
||||||
const { done, value } = await stdoutReader.read()
|
const { done, value } = await stdoutReader.read()
|
||||||
if (done) {
|
if (done || !value) {
|
||||||
this.push(null)
|
this.push(null)
|
||||||
} else {
|
} else {
|
||||||
this.push(Buffer.from(value))
|
this.push(Buffer.from(value))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user