From 695a7b3cd80529f7c6bd6878b4b6032d275390dd Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 22 Oct 2026 17:13:48 +0900 Subject: [PATCH 1/3] fix(lsp): skip an unrunnable typescript-language-server and fall back to bunx A mise shim for typescript-language-server resolved on PATH but exited 1 ("No version is set for shim") in the Vue fixture, so the server died and three Vue integration tests timed out. Unversioned bunx also picked up the shim. Share the dart --version probe as findRunnableBin and run the bunx fallback as typescript-language-server@latest so it installs from the registry. Closes #127 --- packages/lsp/src/server/dart.ts | 39 +-------------- packages/lsp/src/server/typescript.ts | 9 ++-- packages/lsp/src/server/utils.ts | 36 ++++++++++++++ packages/lsp/test/unit/server.test.ts | 70 +++++++++++++-------------- 4 files changed, 78 insertions(+), 76 deletions(-) diff --git a/packages/lsp/src/server/dart.ts b/packages/lsp/src/server/dart.ts index 051aea1..5e87cb7 100644 --- a/packages/lsp/src/server/dart.ts +++ b/packages/lsp/src/server/dart.ts @@ -9,7 +9,7 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import process from 'node:process' -import { attachLSPProcessHandlers, downloadAndExtract, getPlatformId, log, nearestRoot } from './utils' +import { attachLSPProcessHandlers, downloadAndExtract, findRunnableBin, getPlatformId, log, nearestRoot } from './utils' /** * Dart SDK runtime dependency configuration @@ -111,47 +111,12 @@ async function setupDartDependencies(platformId: PlatformId): Promise { - // Bun.which defaults to the PATH captured at startup; follow the current environment - const systemDart = Bun.which('dart', { PATH: process.env.PATH ?? '' }) - if (!systemDart) { - return undefined - } - - try { - // Run in the project root: version managers resolve the SDK per directory - const proc = Bun.spawn([systemDart, '--version'], { - cwd: root, - stdout: 'ignore', - stderr: 'ignore', - timeout: DART_PROBE_TIMEOUT_MS, - }) - const exitCode = await proc.exited - if (exitCode !== 0) { - log.warn({ systemDart, exitCode, signal: proc.signalCode }, 'System Dart failed --version, trying auto-download') - return undefined - } - } - catch (err) { - log.warn({ systemDart, err }, 'Failed to spawn system Dart for version check, trying auto-download') - return undefined - } - - return systemDart -} - export const DartServer: LSPServerInfo = { id: 'dart', extensions: ['.dart'], root: nearestRoot(['pubspec.yaml', 'pubspec.lock']), async spawn(root) { - const systemDart = await findSystemDart(root) + const systemDart = await findRunnableBin('dart', root) if (systemDart) { try { const proc = spawn(systemDart, ['language-server', '--client-id', 'dora.dart', '--client-version', '1.0'], { diff --git a/packages/lsp/src/server/typescript.ts b/packages/lsp/src/server/typescript.ts index e759b54..238c495 100644 --- a/packages/lsp/src/server/typescript.ts +++ b/packages/lsp/src/server/typescript.ts @@ -14,7 +14,7 @@ import fs from 'node:fs' import { createRequire } from 'node:module' import path from 'node:path' import process from 'node:process' -import { attachLSPProcessHandlers, log, nearestRoot } from './utils' +import { attachLSPProcessHandlers, findRunnableBin, log, nearestRoot } from './utils' const TS_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'] @@ -306,11 +306,12 @@ export const TypescriptServer: LSPServerInfo = { } // Classic fallback: typescript-language-server. - const tsserver = Bun.which('typescript-language-server') + const tsserver = await findRunnableBin('typescript-language-server', root) if (!tsserver) { - // Try via bunx + // Try via bunx. The version tag makes bunx install from the registry instead of + // reusing a typescript-language-server on PATH, which may be the shim just rejected. try { - const proc = spawn('bunx', ['typescript-language-server', '--stdio'], { + const proc = spawn('bunx', ['typescript-language-server@latest', '--stdio'], { cwd: root, env: { ...process.env, BUN_BE_BUN: '1' }, }) diff --git a/packages/lsp/src/server/utils.ts b/packages/lsp/src/server/utils.ts index 4031d69..48285c8 100644 --- a/packages/lsp/src/server/utils.ts +++ b/packages/lsp/src/server/utils.ts @@ -7,6 +7,7 @@ import type { ChildProcessWithoutNullStreams } from 'node:child_process' import type { RootFunction } from './types' import fs from 'node:fs/promises' import path from 'node:path' +import process from 'node:process' import { createLogger } from '@pleaseai/logger' // Re-export from @pleaseai/binaries @@ -47,6 +48,41 @@ export function attachLSPProcessHandlers( }) } +const VERSION_PROBE_TIMEOUT_MS = 10_000 + +/** + * Find `name` on PATH, but only if `name --version` exits 0 when run in `root`. + * A version-manager shim (e.g. mise) can resolve on PATH while exiting non-zero + * because no version is configured for that directory. + */ +export async function findRunnableBin(name: string, root: string): Promise { + // Bun.which defaults to the PATH captured at startup; follow the current environment + const bin = Bun.which(name, { PATH: process.env.PATH ?? '' }) + if (!bin) { + return undefined + } + + try { + const proc = Bun.spawn([bin, '--version'], { + cwd: root, + stdout: 'ignore', + stderr: 'ignore', + timeout: VERSION_PROBE_TIMEOUT_MS, + }) + const exitCode = await proc.exited + if (exitCode !== 0) { + log.warn({ bin, exitCode, signal: proc.signalCode }, 'Binary on PATH failed --version, skipping it') + return undefined + } + } + catch (err) { + log.warn({ bin, err }, 'Failed to spawn binary on PATH for version check, skipping it') + return undefined + } + + return bin +} + // ============================================================================= // Root Detection Utilities // ============================================================================= diff --git a/packages/lsp/test/unit/server.test.ts b/packages/lsp/test/unit/server.test.ts index 1749aaa..53e9b79 100644 --- a/packages/lsp/test/unit/server.test.ts +++ b/packages/lsp/test/unit/server.test.ts @@ -18,7 +18,7 @@ import { TypescriptServer, VueServer, } from '../../src/server' -import { findSystemDart } from '../../src/server/dart' +import { findRunnableBin } from '../../src/server/utils' describe('LSP_SERVERS', () => { test('contains expected servers', () => { @@ -399,47 +399,47 @@ describe('DartServer', () => { await fs.rm(tempDir, { recursive: true, force: true }) } }) +}) - describe.skipIf(process.platform === 'win32')('findSystemDart', () => { - async function findSystemDartWith(dartScript: string, rootFiles: string[] = []): Promise<{ result: string | undefined, fakeDart: string }> { - const binDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dart-bin-')) - const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dart-root-')) - const savedPath = process.env.PATH - try { - const fakeDart = path.join(binDir, 'dart') - await fs.writeFile(fakeDart, dartScript, { mode: 0o755 }) - for (const file of rootFiles) { - await fs.writeFile(path.join(rootDir, file), '') - } - process.env.PATH = binDir - return { result: await findSystemDart(rootDir), fakeDart } - } - finally { - process.env.PATH = savedPath - await fs.rm(binDir, { recursive: true, force: true }) - await fs.rm(rootDir, { recursive: true, force: true }) +describe.skipIf(process.platform === 'win32')('findRunnableBin', () => { + async function findRunnableBinWith(script: string, rootFiles: string[] = []): Promise<{ result: string | undefined, fakeBin: string }> { + const binDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runnable-bin-')) + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runnable-root-')) + const savedPath = process.env.PATH + try { + const fakeBin = path.join(binDir, 'fake-tool') + await fs.writeFile(fakeBin, script, { mode: 0o755 }) + for (const file of rootFiles) { + await fs.writeFile(path.join(rootDir, file), '') } + process.env.PATH = binDir + return { result: await findRunnableBin('fake-tool', rootDir), fakeBin } + } + finally { + process.env.PATH = savedPath + await fs.rm(binDir, { recursive: true, force: true }) + await fs.rm(rootDir, { recursive: true, force: true }) } + } - test('ignores a dart on PATH that fails --version', async () => { - const { result } = await findSystemDartWith('#!/bin/sh\necho "No version is set for shim: dart" >&2\nexit 1\n') - expect(result).toBeUndefined() - }) + test('ignores a binary on PATH that fails --version', async () => { + const { result } = await findRunnableBinWith('#!/bin/sh\necho "No version is set for shim: fake-tool" >&2\nexit 1\n') + expect(result).toBeUndefined() + }) - test('ignores a dart on PATH that cannot be spawned', async () => { - const { result } = await findSystemDartWith('\x7FELF not a real binary') - expect(result).toBeUndefined() - }) + test('ignores a binary on PATH that cannot be spawned', async () => { + const { result } = await findRunnableBinWith('\x7FELF not a real binary') + expect(result).toBeUndefined() + }) - test('returns a dart on PATH that runs --version', async () => { - const { result, fakeDart } = await findSystemDartWith('#!/bin/sh\nexit 0\n') - expect(result).toBe(fakeDart) - }) + test('returns a binary on PATH that runs --version', async () => { + const { result, fakeBin } = await findRunnableBinWith('#!/bin/sh\nexit 0\n') + expect(result).toBe(fakeBin) + }) - test('probes --version in the project root', async () => { - const { result, fakeDart } = await findSystemDartWith('#!/bin/sh\n[ -f .tool-versions ] || exit 1\n', ['.tool-versions']) - expect(result).toBe(fakeDart) - }) + test('probes --version in the project root', async () => { + const { result, fakeBin } = await findRunnableBinWith('#!/bin/sh\n[ -f .tool-versions ] || exit 1\n', ['.tool-versions']) + expect(result).toBe(fakeBin) }) }) From b865f7544674244cc0832459b2744e0c40962880 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 22 Oct 2026 19:26:29 +0900 Subject: [PATCH 2/3] chore(lsp): apply AI code review suggestions - Prefer a project-local typescript-language-server, probing it with --version first - Probe every PATH match in findRunnableBin instead of only the first - Pin the bunx fallback to the typescript-language-server version the Vue server installs - Launch the classic server through a shell on Windows so .cmd shims run - Test the TypescriptServer.spawn fallback order --- packages/lsp/src/server/typescript.ts | 19 ++++-- packages/lsp/src/server/utils.ts | 32 +++++++---- packages/lsp/src/server/vue.ts | 3 +- packages/lsp/test/unit/server.test.ts | 83 +++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 16 deletions(-) diff --git a/packages/lsp/src/server/typescript.ts b/packages/lsp/src/server/typescript.ts index 238c495..4cc2080 100644 --- a/packages/lsp/src/server/typescript.ts +++ b/packages/lsp/src/server/typescript.ts @@ -14,10 +14,13 @@ import fs from 'node:fs' import { createRequire } from 'node:module' import path from 'node:path' import process from 'node:process' -import { attachLSPProcessHandlers, findRunnableBin, log, nearestRoot } from './utils' +import { attachLSPProcessHandlers, findRunnableBin, log, nearestRoot, runsVersion } from './utils' const TS_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'] +/** typescript-language-server version installed by the bunx fallback and the Vue server. */ +export const TS_LANGUAGE_SERVER_VERSION = '4.3.3' + /** Walk up from `start` to the nearest `node_modules/typescript` package dir. */ function findTypeScriptPackageRoot(start: string): string | undefined { let dir = path.resolve(start) @@ -305,13 +308,16 @@ export const TypescriptServer: LSPServerInfo = { return { process: proc } } - // Classic fallback: typescript-language-server. - const tsserver = await findRunnableBin('typescript-language-server', root) + // Classic fallback: typescript-language-server, project-local before PATH. + const local = findPackageBin(root, 'typescript-language-server', 'typescript-language-server') + const tsserver = local && await runsVersion(local, root) + ? local + : await findRunnableBin('typescript-language-server', root) if (!tsserver) { // Try via bunx. The version tag makes bunx install from the registry instead of // reusing a typescript-language-server on PATH, which may be the shim just rejected. try { - const proc = spawn('bunx', ['typescript-language-server@latest', '--stdio'], { + const proc = spawn('bunx', [`typescript-language-server@${TS_LANGUAGE_SERVER_VERSION}`, '--stdio'], { cwd: root, env: { ...process.env, BUN_BE_BUN: '1' }, }) @@ -324,9 +330,12 @@ export const TypescriptServer: LSPServerInfo = { } } - const proc = spawn(tsserver, ['--stdio'], { + // On Windows the bin is a .cmd shim, which only runs through a shell. + const launch = nativeServerSpawnArgs(tsserver) + const proc = spawn(launch.command, ['--stdio'], { cwd: root, env: { ...process.env, BUN_BE_BUN: '1' }, + shell: launch.shell, }) attachLSPProcessHandlers(proc, 'typescript') return { process: proc } diff --git a/packages/lsp/src/server/utils.ts b/packages/lsp/src/server/utils.ts index 48285c8..65970a1 100644 --- a/packages/lsp/src/server/utils.ts +++ b/packages/lsp/src/server/utils.ts @@ -51,17 +51,30 @@ export function attachLSPProcessHandlers( const VERSION_PROBE_TIMEOUT_MS = 10_000 /** - * Find `name` on PATH, but only if `name --version` exits 0 when run in `root`. + * Find the first `name` on PATH whose `name --version` exits 0 when run in `root`. * A version-manager shim (e.g. mise) can resolve on PATH while exiting non-zero - * because no version is configured for that directory. + * because no version is configured for that directory, and a working install may + * sit in a later PATH entry. */ export async function findRunnableBin(name: string, root: string): Promise { - // Bun.which defaults to the PATH captured at startup; follow the current environment - const bin = Bun.which(name, { PATH: process.env.PATH ?? '' }) - if (!bin) { - return undefined + // Read PATH now rather than the copy Bun captured at startup + const dirs = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean) + const seen = new Set() + for (const dir of dirs) { + const bin = Bun.which(name, { PATH: dir }) + if (!bin || seen.has(bin)) { + continue + } + seen.add(bin) + if (await runsVersion(bin, root)) { + return bin + } } + return undefined +} +/** Whether `bin --version` exits 0 when run in `root`. */ +export async function runsVersion(bin: string, root: string): Promise { try { const proc = Bun.spawn([bin, '--version'], { cwd: root, @@ -72,15 +85,14 @@ export async function findRunnableBin(name: string, root: string): Promise { const { result, fakeBin } = await findRunnableBinWith('#!/bin/sh\n[ -f .tool-versions ] || exit 1\n', ['.tool-versions']) expect(result).toBe(fakeBin) }) + + test('skips a failing binary for a runnable one later on PATH', async () => { + const shimDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runnable-shim-')) + const installDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runnable-install-')) + const savedPath = process.env.PATH + try { + await fs.writeFile(path.join(shimDir, 'fake-tool'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }) + const installed = path.join(installDir, 'fake-tool') + await fs.writeFile(installed, '#!/bin/sh\nexit 0\n', { mode: 0o755 }) + process.env.PATH = [shimDir, installDir].join(path.delimiter) + expect(await findRunnableBin('fake-tool', installDir)).toBe(installed) + } + finally { + process.env.PATH = savedPath + await fs.rm(shimDir, { recursive: true, force: true }) + await fs.rm(installDir, { recursive: true, force: true }) + } + }) +}) + +describe.skipIf(process.platform === 'win32')('TypescriptServer classic fallback', () => { + // Each fake server records its name and arguments in `invoked` under the project root. + const recorder = (name: string): string => `#!/bin/sh\necho "${name} $*" > invoked\n` + + async function spawnTypescriptWith( + pathBins: Record, + setupRoot: (rootDir: string) => Promise = async () => {}, + ): Promise { + const binDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-fallback-bin-')) + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-fallback-root-')) + const savedPath = process.env.PATH + try { + for (const [name, script] of Object.entries(pathBins)) { + await fs.writeFile(path.join(binDir, name), script, { mode: 0o755 }) + } + await setupRoot(rootDir) + process.env.PATH = binDir + const handle = await TypescriptServer.spawn(rootDir) + process.env.PATH = savedPath + expect(handle).toBeDefined() + const proc = handle!.process + if (proc.exitCode === null && proc.signalCode === null) { + await new Promise(resolve => proc.once('exit', resolve)) + } + return (await fs.readFile(path.join(rootDir, 'invoked'), 'utf8')).trim() + } + finally { + process.env.PATH = savedPath + await fs.rm(binDir, { recursive: true, force: true }) + await fs.rm(rootDir, { recursive: true, force: true }) + } + } + + test('falls back to bunx with a pinned typescript-language-server when the one on PATH cannot run', async () => { + const invoked = await spawnTypescriptWith({ + 'typescript-language-server': '#!/bin/sh\nexit 1\n', + 'bunx': recorder('bunx'), + }) + expect(invoked).toBe('bunx typescript-language-server@4.3.3 --stdio') + }) + + async function installLocalServer(rootDir: string, script: string): Promise { + const packageDir = path.join(rootDir, 'node_modules', 'typescript-language-server') + await fs.mkdir(path.join(packageDir, 'lib'), { recursive: true }) + await fs.writeFile( + path.join(packageDir, 'package.json'), + JSON.stringify({ name: 'typescript-language-server', bin: { 'typescript-language-server': 'lib/cli.mjs' } }), + ) + await fs.writeFile(path.join(packageDir, 'lib', 'cli.mjs'), script, { mode: 0o755 }) + const binDir = path.join(rootDir, 'node_modules', '.bin') + await fs.mkdir(binDir) + await fs.symlink(path.join('..', 'typescript-language-server', 'lib', 'cli.mjs'), path.join(binDir, 'typescript-language-server')) + } + + test('prefers a project-local typescript-language-server', async () => { + const invoked = await spawnTypescriptWith({ bunx: recorder('bunx') }, rootDir => installLocalServer(rootDir, recorder('local'))) + expect(invoked).toBe('local --stdio') + }) + + test('skips a project-local typescript-language-server that fails --version', async () => { + const invoked = await spawnTypescriptWith({ bunx: recorder('bunx') }, rootDir => installLocalServer(rootDir, '#!/bin/sh\nexit 1\n')) + expect(invoked).toBe('bunx typescript-language-server@4.3.3 --stdio') + }) }) describe('VueServer', () => { From f43239f4301b98694fb451123a5745c039492acd Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 22 Oct 2026 19:32:14 +0900 Subject: [PATCH 3/3] chore(lsp): word runsVersion warnings for local and PATH binaries --- packages/lsp/src/server/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lsp/src/server/utils.ts b/packages/lsp/src/server/utils.ts index 65970a1..955f493 100644 --- a/packages/lsp/src/server/utils.ts +++ b/packages/lsp/src/server/utils.ts @@ -84,13 +84,13 @@ export async function runsVersion(bin: string, root: string): Promise { }) const exitCode = await proc.exited if (exitCode !== 0) { - log.warn({ bin, exitCode, signal: proc.signalCode }, 'Binary on PATH failed --version, skipping it') + log.warn({ bin, exitCode, signal: proc.signalCode }, 'Binary failed --version, skipping it') return false } return true } catch (err) { - log.warn({ bin, err }, 'Failed to spawn binary on PATH for version check, skipping it') + log.warn({ bin, err }, 'Failed to spawn binary for version check, skipping it') return false } }