Repository navigation
fix: load server envs from interactive shell env - #261
Conversation
There was a problem hiding this comment.
Pull request overview
This PR addresses a startup failure where the language server (oxlint/oxfmt) cannot find node on the PATH when the VS Code Electron process is launched from a GUI on macOS/Linux. The fix introduces a getShellEnv() helper that spawns the user's $SHELL with -ilc "env -0", parses the result, and uses that environment (instead of process.env) as the base for the language server's serverEnv. runExecutable is converted to async and its two call sites are updated to await it.
Changes:
- Add
getShellEnv()inclient/tools/lsp_helper.tsthat runs an interactive login shell to capture env vars (with a module-level cache and a Windows short-circuit). - Convert
runExecutabletoasync/Promise<Executable>and baseserverEnvon the shell env; include a temporary debug block warning when shell env diverges fromprocess.env. - Update
linter.tsandformatter.tstoawait runExecutable(...).
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 11 comments.
| File | Description |
|---|---|
| client/tools/lsp_helper.ts | Adds getShellEnv() and makes runExecutable async, sourcing the server env from a login shell. |
| client/tools/linter.ts | Adds await for the now-async runExecutable. |
| client/tools/formatter.ts | Adds await for the now-async runExecutable. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (6)
client/tools/lsp_helper.ts:61
- Spawning the user's shell with
-i(interactive) can have undesired side effects: interactive shells often print MOTDs, prompt strings, or run hooks (e.g.oh-my-zsh,starship, nvm/direnvinitialization that prints to stdout) that will be intermixed with theenv -0output, causing entries to be silently parsed asKEY=...garbage.... It can also block on prompts (e.g. SSH agent passphrase,nvm useprompts). Most well-known implementations (e.g. VS Code'sshellEnv, theshell-envnpm package) wrap the command with sentinel markers (e.g.echo -n "_SHELL_ENV_DELIMITER_"; env -0; echo -n "_SHELL_ENV_DELIMITER_") and slice between the markers to avoid this. Consider doing the same to make the env extraction robust.
try {
// POSIX shells
// Run the shell as a login shell to get the environment variables. The `-i` flag is for interactive shell, which is needed to load the shell configuration files.
// The `-l` flag is for login shell, which is needed to load the environment variables defined in the shell configuration files.
const { stdout } = await execFileAsync(shell, ["-ilc", "env -0"], {
env: {
HOME: process.env.HOME,
},
timeout: 5000,
});
const env: Record<string, string> = {};
for (const entry of stdout.split("\0")) {
if (!entry) continue;
const i = entry.indexOf("=");
if (i === -1) continue;
env[entry.slice(0, i)] = entry.slice(i + 1);
}
return env;
client/tools/lsp_helper.ts:65
env -0is not portable. On macOS, BSDenvdid not support the-0flag until relatively recently (macOS 12+), and other POSIX systems may not support it at all. If the user'senvbinary lacks-0, the command fails, the catch swallows the error, and the extension silently falls back toprocess.env— defeating the entire purpose of the change without any indication to the user. Consider either logging the failure to the output channel, or using a more portable strategy (e.g. printing env via the shell's own builtin and a delimiter, or splitting on\nwith proper escaping).
try {
// POSIX shells
// Run the shell as a login shell to get the environment variables. The `-i` flag is for interactive shell, which is needed to load the shell configuration files.
// The `-l` flag is for login shell, which is needed to load the environment variables defined in the shell configuration files.
const { stdout } = await execFileAsync(shell, ["-ilc", "env -0"], {
env: {
HOME: process.env.HOME,
},
timeout: 5000,
});
const env: Record<string, string> = {};
for (const entry of stdout.split("\0")) {
if (!entry) continue;
const i = entry.indexOf("=");
if (i === -1) continue;
env[entry.slice(0, i)] = entry.slice(i + 1);
}
return env;
} catch {
// If there is an error (e.g., timeout, shell not found, etc.), return the current process.env as a fallback.
return { ...process.env } as Record<string, string>;
}
client/tools/lsp_helper.ts:65
- Silently swallowing the error here means users whose shell startup files actually do define
nodein PATH but whose shell invocation fails (timeout, syntax error in a dotfile, fish/nushell incompatibility with-ilc, etc.) will keep seeing the original "command not found: node" failure with no diagnostic in the output channel. At minimum, log the error (and ideally which shell was tried) to the existingLogOutputChannelso the failure is debuggable.
} catch {
// If there is an error (e.g., timeout, shell not found, etc.), return the current process.env as a fallback.
return { ...process.env } as Record<string, string>;
}
client/tools/lsp_helper.ts:47
- A 5s timeout for shell startup is on the aggressive side. Real-world
~/.zshrcsetups (with nvm, rbenv, conda, brew shellenv, etc.) routinely take 1–3+ seconds; on a cold start or a busy machine, exceeding 5s is plausible. When the timeout fires, the catch silently returnsprocess.env, so the user's PATH from their shell is never picked up and they see the original "command not found: node" without any hint of what went wrong. Consider raising the timeout (e.g. 10–15s) and/or surfacing the timeout in the output channel.
timeout: 5000,
client/tools/lsp_helper.ts:48
process.env.HOMEmay beundefinedon some platforms / sandboxed contexts; passing{ HOME: undefined }will result in the spawned shell having noHOMEset at all, which can break startup of many shell rc files (e.g. zsh trying to read~/.zshrcresolves to/.zshrc). Either omit the key when undefined, or fall back toos.homedir().
const { stdout } = await execFileAsync(shell, ["-ilc", "env -0"], {
env: {
HOME: process.env.HOME,
},
timeout: 5000,
});
client/tools/lsp_helper.ts:82
runExecutableis now async and is called inside long client-startup paths (linter.ts, formatter.ts). On non-Windows the first call now blocks on spawning a login+interactive shell (up to 5s by the timeout). This adds noticeable startup latency to every extension activation, even when the user's PATH already containsnode(which is the common case for users who launch VS Code from a terminal). Consider doing the shell-env lookup lazily/in parallel with other startup work, or only when an actualnode-not-found failure has been observed.
export async function runExecutable(
binary: BinarySearchResult,
useExecPath: boolean = false,
nodePath?: string,
tsgolintPath?: string,
suppressProgramErrors?: boolean,
): Promise<Executable> {
const shellEnv = await getShellEnv();
const serverEnv: Record<string, string> = {
...shellEnv,
RUST_LOG: process.env.RUST_LOG || "info", // Keep for backward compatibility for a while
OXC_LOG: process.env.OXC_LOG || "info",
NO_COLOR: "1",
};
closes #255
closes #21