pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
669 lines (668 loc) • 26.3 kB
JavaScript
/**
* 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);
});
}