UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

669 lines (668 loc) • 26.3 kB
/** * LSP Process Launch Utilities * * Handles spawning LSP servers via various methods: * - Direct binary execution (using absolute paths on Windows) * - Node.js scripts (npx/bun) * - Package manager execution */ import { execFileSync, spawn as nodeSpawn, } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { isTestMode } from "../env-utils.js"; import { getGlobalPiLensDir } from "../file-utils.js"; import { findGlobalBinary } from "../package-manager.js"; const isWindows = process.platform === "win32"; /** * Whether a resolved command must be spawned through a shell on Windows. * `.cmd`/`.bat` are scripts cmd.exe must interpret; extensionless or spaced-path * commands also go through the shell so cmd resolves/quotes them. */ function computeNeedsShell(resolvedCommand) { return (isWindows && (resolvedCommand.includes(" ") || /\.(cmd|bat)$/i.test(resolvedCommand) || !/\.(exe|cmd|bat)$/i.test(resolvedCommand))); } const DEFAULT_STARTUP_FAILURE_WINDOW_MS = 50; const WINDOWS_NAV_STARTUP_FAILURE_WINDOW_MS = 500; const SESSIONSTART_LOG_DIR = getGlobalPiLensDir(); const SESSIONSTART_LOG = path.join(SESSIONSTART_LOG_DIR, "sessionstart.log"); const PI_LENS_BIN_DIR = path.join(getGlobalPiLensDir(), "bin"); const PI_LENS_TOOLS_BIN_DIR = path.join(getGlobalPiLensDir(), "tools", "node_modules", ".bin"); function logSessionStart(msg) { if (isTestMode()) { return; } const line = `[${new Date().toISOString()}] ${msg}\n`; try { fs.mkdirSync(SESSIONSTART_LOG_DIR, { recursive: true }); fs.appendFileSync(SESSIONSTART_LOG, line); } catch { } } function compactLogValue(value, max = 280) { const normalized = value.replace(/\s+/g, " ").trim(); if (!normalized) return ""; return normalized.length > max ? `${normalized.slice(0, max)}...` : normalized; } function delimiterForPlatform(platform) { return platform === "win32" ? ";" : ":"; } function splitPathEntries(value, delimiter) { if (!value) return []; return value .split(delimiter) .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); } function normalizePathEntry(entry, platform) { const normalized = path.normalize(entry); return platform === "win32" ? normalized.toLowerCase() : normalized; } export function combinePathValuesForPlatform(values, platform = process.platform) { const unique = []; const seen = new Set(); const delimiter = delimiterForPlatform(platform); for (const value of values) { for (const entry of splitPathEntries(value, delimiter)) { const key = normalizePathEntry(entry, platform); if (seen.has(key)) continue; seen.add(key); unique.push(entry); } } return unique.join(delimiter); } function resolvePathValue(env) { return combinePathValuesForPlatform([env.PATH, env.Path, env.path]); } /** Read live system+user PATH from Windows registry (bypasses stale process.env.PATH). */ function readWindowsRegistryPath() { try { const { execFileSync } = require("node:child_process"); const out = execFileSync("powershell.exe", [ "-NoProfile", "-NonInteractive", "-Command", "[System.Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path','User')", ], { timeout: 3000, encoding: "utf8" }); return out.trim(); } catch { return ""; } } let _liveWindowsPath = null; function getLiveWindowsPath() { if (_liveWindowsPath === null) { _liveWindowsPath = readWindowsRegistryPath(); } return _liveWindowsPath; } function buildAugmentedPath(basePath) { const candidates = []; const nodeDir = path.dirname(process.execPath); if (nodeDir) { candidates.push(nodeDir); } if (isWindows) { const home = os.homedir(); const driveRoot = path.parse(home).root; // e.g. "C:\" candidates.push(path.join(home, ".cargo", "bin")); candidates.push(path.join(home, "go", "bin")); candidates.push(path.join(home, ".dotnet", "tools")); candidates.push(PI_LENS_BIN_DIR); candidates.push(PI_LENS_TOOLS_BIN_DIR); candidates.push(path.join(driveRoot, "Program Files", "Go", "bin")); candidates.push(path.join(driveRoot, "Go", "bin")); // Ruby installer drops versioned dirs (e.g. Ruby34-x64) on the drive root — scan dynamically try { for (const entry of fs.readdirSync(driveRoot)) { if (/^ruby\d/i.test(entry)) { candidates.push(path.join(driveRoot, entry, "bin")); } } } catch { // drive root not readable — skip } } // On Windows, merge the live registry PATH so newly installed tools are visible // even if the pi agent process started before they were installed. const effectiveBase = isWindows ? combinePathValuesForPlatform([basePath, getLiveWindowsPath()]) : basePath; const existing = new Set(); for (const entry of splitPathEntries(effectiveBase, path.delimiter)) { if (!entry) continue; existing.add(normalizePathEntry(entry, process.platform)); } const toAppend = []; for (const candidate of candidates) { if (!candidate || !fs.existsSync(candidate)) continue; const normalized = normalizePathEntry(candidate, process.platform); if (existing.has(normalized)) continue; toAppend.push(candidate); existing.add(normalized); } if (toAppend.length === 0) return basePath ?? ""; if (!basePath) return toAppend.join(path.delimiter); return `${basePath}${path.delimiter}${toAppend.join(path.delimiter)}`; } /** * Validate that a .cmd shim's target JS/script exists before attempting to * spawn it. npm-generated .cmd files reference the actual script via a path * like `"%~dp0\..\yaml-language-server\bin\yaml-language-server"`. If that * target is missing the shim will exit immediately with code 1 after a 500ms * startup window — pre-checking avoids the delay. * Returns true if the shim is valid (or we can't determine), false if the * target is definitively missing. */ export function isCmdShimValid(cmdPath) { try { const content = fs.readFileSync(cmdPath, "utf-8"); // npm cmd shim pattern: "%~dp0\..\<relpath>" or "%~dp0/<relpath>" // biome-ignore format: regex char-class \- must stay escaped — formatter strips it const match = content.match(/"%~dp0[/\\]\.\.[/\\]((?:[\w./@-]|\\)+\.(?:mjs|cjs|js))"/i); if (!match) return true; // non-npm shim — let it through const relPath = match[1].replace(/[/\\]/g, path.sep); const target = path.resolve(path.dirname(cmdPath), "..", relPath); return fs.existsSync(target); } catch { return true; // can't read — be permissive } } /** * On Windows, npm creates .ps1 wrappers that hang indefinitely when PowerShell * execution policy is Restricted or AllSigned. Bypass by preferring the .cmd * sibling (runs under cmd.exe, no execution policy) or falling back to direct * Node.js execution of the JS entry point extracted from the PS1 script. * Returns undefined if no safe bypass is found. */ function bypassPs1OnWindows(ps1Path, args) { // 1. Prefer the .cmd sibling — cmd.exe handles it without execution policy issues const cmdPath = `${ps1Path.slice(0, -4)}.cmd`; if (fs.existsSync(cmdPath)) { return { command: cmdPath, args, needsShell: true }; } // 2. Parse the .ps1 to find the JS entry point and invoke via node directly. // npm-generated PS1 pattern: "$basedir/../<package>/bin/cli.js" try { const content = fs.readFileSync(ps1Path, "utf-8"); // biome-ignore format: regex char-class \- must stay escaped — formatter strips it const match = content.match(/"\$basedir[/\\]\.\.[/\\]((?:[\w./@-]|\\)+\.(?:mjs|cjs|js))"/i); if (match) { const relPath = match[1].replace(/[/\\]/g, path.sep); const jsPath = path.resolve(path.dirname(ps1Path), "..", relPath); if (fs.existsSync(jsPath)) { return { command: process.execPath, args: [jsPath, ...args], needsShell: false, }; } } } catch { // Can't read PS1 — no bypass available } return undefined; } function findBinaryOnPath(command, env) { try { const result = execFileSync(isWindows ? "where" : "which", [command], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], env, }) .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); for (const candidate of result) { if (fs.existsSync(candidate)) { return candidate; } } } catch { // ignore lookup failures } return undefined; } /** * Try to spawn a process, throwing immediately if it fails */ function trySpawn(command, args, cwd, env, needsShell) { let proc; if (needsShell) { // Build a cmd.exe-safe command string: wrap in double quotes, escape internal // quotes by doubling them, and escape cmd metacharacters (& | < > ^ ( ) !) with ^ const escapeCmdArg = (s) => { // Escape cmd.exe metacharacters first, then wrap in quotes if needed const escaped = s.replace(/([&|<>^()!])/g, "^$1"); return /[\s"]/.test(escaped) ? `"${escaped.replace(/"/g, '""')}"` : escaped; }; // shell:true justified: Windows .cmd/.bat LSP binaries (e.g. typescript-language-server.cmd) // cannot be spawned via execFile — cmd.exe must interpret the script wrapper. const shellCommand = `"${command}" ${args.map(escapeCmdArg).join(" ")}`; proc = nodeSpawn(shellCommand, [], { cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: !isWindows, windowsHide: true, shell: true, }); } else { // Use normal spawn without shell proc = nodeSpawn(command, args, { cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: !isWindows, windowsHide: isWindows, }); } if (!proc.stdin || !proc.stdout || !proc.stderr) { throw new Error(`Failed to spawn LSP server: ${command}`); } // Check if process exited immediately (spawn failure - synchronous check) if (proc.exitCode !== null || proc.killed) { throw new Error(`LSP server ${command} exited immediately (code: ${proc.exitCode}). ` + `The binary may be missing or corrupted.`); } return proc; } /** * Attach error handler to a spawned process to prevent ENOENT crashes * This catches "command not found" errors and other spawn failures * Returns a promise that rejects if an immediate error occurs */ function unrefLspProcessHandles(proc) { try { proc.unref(); } catch { // best-effort } for (const stream of [proc.stdin, proc.stdout, proc.stderr]) { try { stream?.unref?.(); } catch { // best-effort } } } function _attachErrorHandler(proc, context, logContext, rejectOnImmediateError) { let stderrPreview = ""; let closeLogged = false; const onStderr = (chunk) => { if (stderrPreview.length >= 4000) return; stderrPreview += chunk.toString(); }; proc.stderr?.on("data", onStderr); proc.on("error", (err) => { if (logContext) { logSessionStart("lsp process " + context + ": spawn-error command=" + logContext.command + " args=" + JSON.stringify(logContext.args) + " cwd=" + logContext.cwd + " pid=" + (logContext.pid ?? 0) + " error=" + err.message + (stderrPreview ? " stderr=" + compactLogValue(stderrPreview) : "")); } // If we have a reject function and this is an immediate spawn error, reject if (rejectOnImmediateError && err.code === "ENOENT") { rejectOnImmediateError(err); } }); proc.on("close", (code, signal) => { if (closeLogged) return; closeLogged = true; proc.stderr?.off("data", onStderr); if (code !== 0 && code !== null) { if (logContext) { logSessionStart("lsp process " + context + ": closed code=" + code + (signal ? " signal=" + signal : "") + " command=" + logContext.command + " args=" + JSON.stringify(logContext.args) + " cwd=" + logContext.cwd + " pid=" + (logContext.pid ?? 0) + (stderrPreview ? " stderr=" + compactLogValue(stderrPreview) : "")); } } else if (signal && logContext) { logSessionStart("lsp process " + context + ": closed signal=" + signal + " command=" + logContext.command + " args=" + JSON.stringify(logContext.args) + " cwd=" + logContext.cwd + " pid=" + (logContext.pid ?? 0) + (stderrPreview ? " stderr=" + compactLogValue(stderrPreview) : "")); } }); } /** * Spawn an LSP server process * * Key fixes for Windows: * - Uses absolute paths (relative paths fail in shell mode) * - Uses shell: true for .cmd files * - Uses windowsHide to prevent console window popup * - Detects immediate spawn failures (ENOENT) before returning * * @param command - Command to run (e.g., "typescript-language-server") * @param args - Arguments (e.g., ["--stdio"]) * @param options - Spawn options including cwd, env * @returns LSPProcess handle */ export async function launchLSP(command, args = [], options = {}) { const cwd = String(options.cwd ?? process.cwd()); const mergedEnv = { ...process.env, ...options.env }; const augmentedPath = buildAugmentedPath(resolvePathValue(mergedEnv)); const env = { ...mergedEnv, PATH: augmentedPath, ...(isWindows ? { Path: augmentedPath } : {}), }; // Resolve command path // - If already absolute, use as-is // - If it's a simple command (no path separators), let system find it via PATH // - Otherwise, resolve relative to cwd const isRelativePath = !path.isAbsolute(command) && (command.includes(path.sep) || command.includes("/")); const explicitCommand = isRelativePath ? path.resolve(cwd, command) : command; const resolvedCommand = !path.isAbsolute(command) && !command.includes(path.sep) && !command.includes("/") ? (findBinaryOnPath(command, env) ?? explicitCommand) : explicitCommand; // Compute needsShell based on command // On Windows, shell: true is needed for .cmd/.bat files and extensionless binaries // .exe files can be spawned directly, but .cmd/.bat require shell interpretation let needsShell = computeNeedsShell(resolvedCommand); // Try to spawn the process // If command not found, try npm global as fallback (handles PATH caching after install) let spawnCommand = resolvedCommand; // First, try to find in npm global if it's a simple command name if (!path.isAbsolute(command) && !command.includes(path.sep) && !command.includes("/")) { const globalBinPath = await findGlobalBinary(command); if (globalBinPath) { spawnCommand = globalBinPath; // Recompute needsShell for the resolved global path needsShell = computeNeedsShell(spawnCommand); } } // Pre-validate .cmd shims: if the underlying script is missing the shim will // exit with code 1 after a 500ms wait. Catching this early avoids the delay. if (isWindows && /\.(cmd|bat)$/i.test(spawnCommand) && !isCmdShimValid(spawnCommand)) { logSessionStart(`lsp cmd-shim-invalid: ${spawnCommand} target missing — skipping candidate`); throw new Error(`LSP .cmd shim target not found: ${spawnCommand}. The npm package may not be installed.`); } // P0 FIX: Never spawn .ps1 wrappers on Windows — they hang when PowerShell // execution policy is Restricted/AllSigned. Prefer .cmd or direct node. if (isWindows && /\.ps1$/i.test(spawnCommand)) { const bypass = bypassPs1OnWindows(spawnCommand, args); if (bypass) { logSessionStart(`lsp ps1-bypass: ${spawnCommand} → ${bypass.command} shell=${bypass.needsShell}`); spawnCommand = bypass.command; args = bypass.args; needsShell = bypass.needsShell; } else { logSessionStart(`lsp ps1-bypass: no .cmd or JS entry found for ${spawnCommand}, spawn may hang`); } } let proc; try { proc = trySpawn(spawnCommand, args, cwd, env, needsShell); } catch (err) { // If spawn failed with simple command, try npm global if (!path.isAbsolute(command) && !command.includes(path.sep) && !command.includes("/")) { const globalBinPath = await findGlobalBinary(command); if (globalBinPath && globalBinPath !== spawnCommand) { // Recompute needsShell for the resolved global path const needsShellGlobal = computeNeedsShell(globalBinPath); proc = trySpawn(globalBinPath, args, cwd, env, needsShellGlobal); } else { throw err; } } else { throw err; } } if (!proc.stdin || !proc.stdout || !proc.stderr) { throw new Error(`Failed to spawn LSP server: ${command}`); } // Check if process exited immediately (spawn failure - synchronous check) if (proc.exitCode !== null || proc.killed) { throw new Error(`LSP server ${command} exited immediately (code: ${proc.exitCode}). ` + `The binary may be missing or corrupted.`); } logSessionStart(`lsp launch: command=${command} resolved=${spawnCommand} args=${JSON.stringify(args)} cwd=${cwd} shell=${needsShell ? "true" : "false"} pid=${proc.pid ?? 0}`); const formatStartupStderr = (stderr) => { const normalized = compactLogValue(stderr); if (!normalized) return ""; return ` stderr=${normalized}`; }; let startupStderr = ""; const onStartupStderr = (chunk) => { if (startupStderr.length >= 4000) return; startupStderr += chunk.toString(); }; proc.stderr?.on("data", onStartupStderr); // For Windows and certain spawn failures, the error is async (ENOENT) // We need to wait a small tick to catch immediate spawn failures try { await new Promise((resolve, reject) => { let settled = false; // Attach error handler that can reject for immediate errors proc.on("error", (err) => { if (!settled && (err.code === "ENOENT" || err.code === "EINVAL")) { settled = true; reject(new Error(`LSP server binary not found: ${command}. ` + `Install it or check your PATH.${formatStartupStderr(startupStderr)}`)); } }); // Also listen for immediate exit proc.on("exit", (code) => { if (!settled && code !== null) { settled = true; // On Windows, .cmd shims fail with code 1 when the underlying binary isn't installed // This is different from ENOENT - the shim exists but can't find the binary const isWindowsCmd = isWindows && command.endsWith(".cmd"); const errorMsg = isWindowsCmd && code === 1 ? `npm .cmd shim failed (underlying binary not installed). Run 'npm install' in this project or use a global installation.` : `The binary may be missing or corrupted.`; reject(new Error(`LSP server ${command} exited immediately with code ${code}. ${errorMsg}${formatStartupStderr(startupStderr)}`)); } }); const startupFailureWindowMs = (() => { if (options?.startupFailureWindowMs) { return options.startupFailureWindowMs; } else if (isWindows && needsShell) { return WINDOWS_NAV_STARTUP_FAILURE_WINDOW_MS; } else { return DEFAULT_STARTUP_FAILURE_WINDOW_MS; } })(); // Give shell-backed Windows launches a slightly longer window because // npm/cmd shims can fail asynchronously after the initial spawn succeeds. setTimeout(() => { if (!settled) { settled = true; resolve(); } }, startupFailureWindowMs); }); } finally { proc.stderr?.off("data", onStartupStderr); } // Re-attach the permanent error handler now that we've passed the danger zone _attachErrorHandler(proc, command, { command: spawnCommand, args, cwd, pid: proc.pid ?? 0, }); unrefLspProcessHandles(proc); return { process: proc, stdin: proc.stdin, stdout: proc.stdout, stderr: proc.stderr, pid: proc.pid ?? 0, command: spawnCommand, args, }; } /** * Spawn via Node.js directly */ export async function launchViaNode(scriptPath, args = [], options = {}) { return launchLSP(process.execPath, [scriptPath, ...args], options); } /** * Spawn via Python module */ export async function launchViaPython(moduleName, args = [], options = {}) { // On Windows, prefer 'py' launcher, fall back to 'python' const pythonCmd = process.platform === "win32" ? "py" : "python3"; return launchLSP(pythonCmd, ["-m", moduleName, ...args], options); } /** * Stop an LSP process gracefully */ export async function stopLSP(handle) { if (handle.process.exitCode !== null || handle.process.signalCode !== null) { return; } return new Promise((resolve) => { let settled = false; let forceTimeout; let giveUpTimeout; const done = () => { if (settled) return; settled = true; if (forceTimeout) clearTimeout(forceTimeout); if (giveUpTimeout) clearTimeout(giveUpTimeout); handle.process.off("exit", done); handle.process.off("error", done); resolve(); }; handle.process.once("exit", done); handle.process.once("error", done); const killWindowsTree = () => { if (!isWindows || handle.pid <= 0) return false; // If our child has already exited, its PID is dead and the OS may have // RECYCLED it to an unrelated process. `taskkill /F /T` on a recycled PID // force-kills that process AND its whole tree — in the test suite this // occasionally nuked a vitest worker fork ("Worker exited unexpectedly", // no fatal dump, e.g. via the "stopLSP after the process already exited" // path); in production it could kill an unrelated user process. Never // tree-kill a PID we no longer own — fall back to handle.process.kill(), // which on Windows signals via the retained process HANDLE (not the raw // PID), so it's a safe no-op on an already-exited child. if (handle.process.exitCode !== null || handle.process.signalCode !== null) return false; try { // Absolute path avoids PATH-resolution substitution on Windows. const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`; const killer = nodeSpawn(taskkill, ["/F", "/T", "/PID", String(handle.pid)], { shell: false, windowsHide: true, }); killer.once("error", done); return true; } catch { return false; } }; try { // On Windows, kill the tree first; killing the direct child can orphan // grandchildren (e.g. tsserver.js behind a cmd/npm shim). if (!killWindowsTree()) { handle.process.kill("SIGTERM"); } } catch { done(); return; } forceTimeout = setTimeout(() => { if (settled) return; try { if (!killWindowsTree()) { handle.process.kill("SIGKILL"); } } catch { done(); return; } // If the process had already exited before listeners were attached, no // exit event will arrive. Resolve rather than hanging test cleanup forever. giveUpTimeout = setTimeout(done, 500); }, 5000); }); }