Skip to content

fix: load server envs from interactive shell env - #261

Merged
Sysix merged 5 commits into
mainfrom
fix--load-server-envs-from-interactive-shell-env
May 18, 2026
Merged

Sysix merged 5 commits into
mainfrom
fix--load-server-envs-from-interactive-shell-env

Conversation

@Sysix

@Sysix Sysix commented May 13, 2026 •

Copy link
Copy Markdown
Member

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.

closes #255
closes #21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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() in client/tools/lsp_helper.ts that runs an interactive login shell to capture env vars (with a module-level cache and a Windows short-circuit).
  • Convert runExecutable to async/Promise<Executable> and base serverEnv on the shell env; include a temporary debug block warning when shell env diverges from process.env.
  • Update linter.ts and formatter.ts to await 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.

Comment thread client/tools/lsp_helper.ts Resolved
Comment thread client/tools/lsp_helper.ts Resolved
Comment thread client/tools/lsp_helper.ts Resolved
Comment thread client/tools/lsp_helper.ts Resolved
Comment thread client/tools/lsp_helper.ts Resolved
Comment thread client/tools/lsp_helper.ts Resolved
Comment thread client/tools/lsp_helper.ts Resolved
Comment thread client/tools/lsp_helper.ts Resolved
Comment thread client/tools/lsp_helper.ts Resolved
Comment thread client/tools/lsp_helper.ts Resolved

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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/direnv initialization that prints to stdout) that will be intermixed with the env -0 output, causing entries to be silently parsed as KEY=...garbage.... It can also block on prompts (e.g. SSH agent passphrase, nvm use prompts). Most well-known implementations (e.g. VS Code's shellEnv, the shell-env npm 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 -0 is not portable. On macOS, BSD env did not support the -0 flag until relatively recently (macOS 12+), and other POSIX systems may not support it at all. If the user's env binary lacks -0, the command fails, the catch swallows the error, and the extension silently falls back to process.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 \n with 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 node in 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 existing LogOutputChannel so 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 ~/.zshrc setups (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 returns process.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.HOME may be undefined on some platforms / sandboxed contexts; passing { HOME: undefined } will result in the spawned shell having no HOME set at all, which can break startup of many shell rc files (e.g. zsh trying to read ~/.zshrc resolves to /.zshrc). Either omit the key when undefined, or fall back to os.homedir().
    const { stdout } = await execFileAsync(shell, ["-ilc", "env -0"], {
      env: {
        HOME: process.env.HOME,
      },
      timeout: 5000,
    });

client/tools/lsp_helper.ts:82

  • runExecutable is 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 contains node (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 actual node-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",
  };

Comment thread client/tools/lsp_helper.ts Resolved
Comment thread client/tools/lsp_helper.ts Resolved
Comment thread client/tools/lsp_helper.ts Resolved
Comment thread client/tools/lsp_helper.ts Resolved
Comment thread client/tools/lsp_helper.ts Resolved
@Sysix
Sysix marked this pull request as ready for review May 18, 2026 19:49
@Sysix
Sysix merged commit 029b60c into main May 18, 2026
4 checks passed
@Sysix
Sysix deleted the fix--load-server-envs-from-interactive-shell-env branch May 18, 2026 19:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

formatter doesn't work when launched from apps linter: oxc client: couldn't create connection to server.

2 participants