Skip to content
Merged
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
39 changes: 2 additions & 37 deletions packages/lsp/src/server/dart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -111,47 +111,12 @@ async function setupDartDependencies(platformId: PlatformId): Promise<string | u
}
}

const DART_PROBE_TIMEOUT_MS = 10_000

/**
* Find a system Dart that actually runs. A version-manager shim (e.g. mise) can resolve
* on PATH while exiting non-zero because no SDK version is configured.
*/
export async function findSystemDart(root: string): Promise<string | undefined> {
// 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'], {
Expand Down
22 changes: 16 additions & 6 deletions packages/lsp/src/server/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@
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, 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)
Expand Down Expand Up @@ -305,12 +308,16 @@
return { process: proc }
}

// Classic fallback: typescript-language-server.
const tsserver = Bun.which('typescript-language-server')
// 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
// 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@${TS_LANGUAGE_SERVER_VERSION}`, '--stdio'], {

Check warning on line 320 in packages/lsp/src/server/typescript.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure the "PATH" variable only contains fixed, unwriteable directories.

See more on https://sonarcloud.io/project/issues?id=pleaseai_code-intelligence&issues=AaFjJphUmWl4S2GFlFAO&open=AaFjJphUmWl4S2GFlFAO&pullRequest=131
Comment thread
sonarqubecloud[bot] marked this conversation as resolved.
Dismissed
cwd: root,
env: { ...process.env, BUN_BE_BUN: '1' },
})
Expand All @@ -323,9 +330,12 @@
}
}

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 }
Expand Down
48 changes: 48 additions & 0 deletions packages/lsp/src/server/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -47,6 +48,53 @@ export function attachLSPProcessHandlers(
})
}

const VERSION_PROBE_TIMEOUT_MS = 10_000

/**
* 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, and a working install may
* sit in a later PATH entry.
*/
export async function findRunnableBin(name: string, root: string): Promise<string | 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<string>()
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`. */
Comment thread
amondnet marked this conversation as resolved.
export async function runsVersion(bin: string, root: string): Promise<boolean> {
Comment thread
amondnet marked this conversation as resolved.
try {
const proc = Bun.spawn([bin, '--version'], {
cwd: root,
stdout: 'ignore',
stderr: 'ignore',
timeout: VERSION_PROBE_TIMEOUT_MS,
})
Comment thread
amondnet marked this conversation as resolved.
Comment on lines +79 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] Windows compatibility and error handling issues in runsVersion

Problem: On Windows, typescript-language-server and other Node-based tools are installed as .cmd or .bat batch file shims. Spawning these files directly via Bun.spawn without a shell will fail, causing runsVersion to always return false on Windows. Additionally, spawning process calls (such as Bun.spawn) when probing system binaries on the PATH can throw synchronous OS-level errors (e.g., ENOEXEC due to invalid ELF headers, or permission issues) which must be caught to prevent application crashes.

Rationale: Correctness, cross-platform compatibility, and robustness.

Suggestion: Pass shell: process.platform === 'win32' to Bun.spawn to ensure batch file shims can be executed on Windows, and wrap the Bun.spawn call in a try/catch block to handle synchronous errors gracefully.

Suggested change
const proc = Bun.spawn([bin, '--version'], {
cwd: root,
stdout: 'ignore',
stderr: 'ignore',
timeout: VERSION_PROBE_TIMEOUT_MS,
})
let proc;
try {
proc = Bun.spawn([bin, '--version'], {
cwd: root,
stdout: 'ignore',
stderr: 'ignore',
timeout: VERSION_PROBE_TIMEOUT_MS,
shell: process.platform === 'win32',
});
} catch (e) {
return false;
}
References
  1. Always wrap process spawning calls (such as Bun.spawn) in a try/catch block when probing system binaries on the PATH. OS-level errors (e.g., ENOEXEC due to invalid ELF headers, or permission issues) can cause the spawn call to throw synchronous errors, which must be caught to prevent application crashes and allow graceful fallbacks.

const exitCode = await proc.exited
if (exitCode !== 0) {
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 for version check, skipping it')
return false
}
Comment thread
amondnet marked this conversation as resolved.
}

// =============================================================================
// Root Detection Utilities
// =============================================================================
Expand Down
3 changes: 2 additions & 1 deletion packages/lsp/src/server/vue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { TS_LANGUAGE_SERVER_VERSION } from './typescript'
import { attachLSPProcessHandlers, log, nearestRoot } from './utils'

/**
Expand All @@ -29,7 +30,7 @@ const VUE_RUNTIME_DEPS = {
},
typeScriptLanguageServer: {
package: 'typescript-language-server',
version: '4.3.3',
version: TS_LANGUAGE_SERVER_VERSION,
},
}

Expand Down
151 changes: 117 additions & 34 deletions packages/lsp/test/unit/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -399,47 +399,130 @@ 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()
Comment thread
amondnet marked this conversation as resolved.
})

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, 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<string, string>,
setupRoot: (rootDir: string) => Promise<void> = async () => {},
): Promise<string> {
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('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('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<void> {
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')
})
})

Expand Down
Loading