From 51f054f3e24d883c6e85848f2884211a70edbabe Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 1 Apr 2026 16:53:36 -0700 Subject: [PATCH] Fix zombie process spawning from shell PATH resolution --- apps/code/src/main/utils/fixPath.ts | 88 +++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/apps/code/src/main/utils/fixPath.ts b/apps/code/src/main/utils/fixPath.ts index cc64cbed8..ae4a41fb5 100644 --- a/apps/code/src/main/utils/fixPath.ts +++ b/apps/code/src/main/utils/fixPath.ts @@ -3,13 +3,24 @@ * (/usr/bin:/bin:/usr/sbin:/sbin) instead of the user's shell PATH which * includes /opt/homebrew/bin, ~/.local/bin, etc. * - * This reads the PATH from the user's default shell (in interactive login mode) - * and applies it to process.env.PATH so child processes have access to + * This reads the PATH from the user's default shell (in login mode) and + * applies it to process.env.PATH so child processes have access to * user-installed binaries. + * + * IMPORTANT: We use `-lc` (login, non-interactive) instead of `-ilc` + * (interactive login) to avoid loading the user's full .zshrc which may + * include heavy plugins (Oh My Zsh, NVM, thefuck, etc.) that spawn + * subprocesses and cause zombie process chains when the timeout kills + * only the parent shell. + * + * See: https://github.com/PostHog/code/issues/1399 */ -import { execSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { userInfo } from "node:os"; +import { dirname, join } from "node:path"; +import { app } from "electron"; const DELIMITER = "_SHELL_ENV_DELIMITER_"; @@ -25,6 +36,9 @@ const ANSI_REGEX = // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional for ANSI stripping /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g; +/** Max age of cached PATH before re-resolving (1 hour) */ +const CACHE_MAX_AGE_MS = 60 * 60 * 1000; + function stripAnsi(str: string): string { return str.replace(ANSI_REGEX, ""); } @@ -50,20 +64,75 @@ function detectDefaultShell(): string { return process.env.SHELL || "/bin/sh"; } +function getCachePath(): string { + return join(app.getPath("userData"), "shell-env-cache.json"); +} + +function readCachedPath(): string | undefined { + try { + const cachePath = getCachePath(); + if (!existsSync(cachePath)) { + return undefined; + } + + const raw = readFileSync(cachePath, "utf-8"); + const cache = JSON.parse(raw) as { path: string; timestamp: number }; + + if (Date.now() - cache.timestamp > CACHE_MAX_AGE_MS) { + return undefined; + } + + return cache.path; + } catch { + return undefined; + } +} + +function writeCachedPath(resolvedPath: string): void { + try { + const cachePath = getCachePath(); + const dir = dirname(cachePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync( + cachePath, + JSON.stringify({ path: resolvedPath, timestamp: Date.now() }), + "utf-8", + ); + } catch { + // Cache write failure is non-fatal + } +} + function executeShell(shell: string): string | undefined { const command = `echo -n "${DELIMITER}"; env; echo -n "${DELIMITER}"; exit`; try { - return execSync(`${shell} -ilc '${command}'`, { + const result = spawnSync(shell, ["-lc", command], { encoding: "utf-8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"], + // Kill the entire process group on timeout, not just the parent shell. + // This prevents orphaned children (node -v, printf, tail, sed) from + // surviving as zombies. + killSignal: "SIGKILL", env: { ...process.env, // Disable Oh My Zsh auto-update which can block DISABLE_AUTO_UPDATE: "true", + // Signal to user's shell config that we're resolving the environment. + // Users with heavy configs can check this and fast-exit: + // [[ -n "$POSTHOG_CODE_RESOLVING_ENVIRONMENT" ]] && return + POSTHOG_CODE_RESOLVING_ENVIRONMENT: "1", }, }); + + if (result.status !== 0 && !result.stdout) { + return undefined; + } + + return result.stdout || undefined; } catch { return undefined; } @@ -110,11 +179,20 @@ export function fixPath(): void { return; } + // Try cached PATH first (instant, no shell spawn) + const cached = readCachedPath(); + if (cached) { + process.env.PATH = cached; + return; + } + const shell = detectDefaultShell(); const shellPath = getShellPath(shell); if (shellPath) { - process.env.PATH = stripAnsi(shellPath); + const cleaned = stripAnsi(shellPath); + process.env.PATH = cleaned; + writeCachedPath(cleaned); } else { process.env.PATH = buildFallbackPath(); }