Skip to content

Commit b6832ad

Browse files
authored
fix: Fix zombie process spawning from shell PATH resolution (#1435)
1 parent 7f4ec8f commit b6832ad

1 file changed

Lines changed: 83 additions & 5 deletions

File tree

apps/code/src/main/utils/fixPath.ts

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,24 @@
33
* (/usr/bin:/bin:/usr/sbin:/sbin) instead of the user's shell PATH which
44
* includes /opt/homebrew/bin, ~/.local/bin, etc.
55
*
6-
* This reads the PATH from the user's default shell (in interactive login mode)
7-
* and applies it to process.env.PATH so child processes have access to
6+
* This reads the PATH from the user's default shell (in login mode) and
7+
* applies it to process.env.PATH so child processes have access to
88
* user-installed binaries.
9+
*
10+
* IMPORTANT: We use `-lc` (login, non-interactive) instead of `-ilc`
11+
* (interactive login) to avoid loading the user's full .zshrc which may
12+
* include heavy plugins (Oh My Zsh, NVM, thefuck, etc.) that spawn
13+
* subprocesses and cause zombie process chains when the timeout kills
14+
* only the parent shell.
15+
*
16+
* See: https://github.com/PostHog/code/issues/1399
917
*/
1018

11-
import { execSync } from "node:child_process";
19+
import { spawnSync } from "node:child_process";
20+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1221
import { userInfo } from "node:os";
22+
import { dirname, join } from "node:path";
23+
import { app } from "electron";
1324

1425
const DELIMITER = "_SHELL_ENV_DELIMITER_";
1526

@@ -25,6 +36,9 @@ const ANSI_REGEX =
2536
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional for ANSI stripping
2637
/[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
2738

39+
/** Max age of cached PATH before re-resolving (1 hour) */
40+
const CACHE_MAX_AGE_MS = 60 * 60 * 1000;
41+
2842
function stripAnsi(str: string): string {
2943
return str.replace(ANSI_REGEX, "");
3044
}
@@ -50,20 +64,75 @@ function detectDefaultShell(): string {
5064
return process.env.SHELL || "/bin/sh";
5165
}
5266

67+
function getCachePath(): string {
68+
return join(app.getPath("userData"), "shell-env-cache.json");
69+
}
70+
71+
function readCachedPath(): string | undefined {
72+
try {
73+
const cachePath = getCachePath();
74+
if (!existsSync(cachePath)) {
75+
return undefined;
76+
}
77+
78+
const raw = readFileSync(cachePath, "utf-8");
79+
const cache = JSON.parse(raw) as { path: string; timestamp: number };
80+
81+
if (Date.now() - cache.timestamp > CACHE_MAX_AGE_MS) {
82+
return undefined;
83+
}
84+
85+
return cache.path;
86+
} catch {
87+
return undefined;
88+
}
89+
}
90+
91+
function writeCachedPath(resolvedPath: string): void {
92+
try {
93+
const cachePath = getCachePath();
94+
const dir = dirname(cachePath);
95+
if (!existsSync(dir)) {
96+
mkdirSync(dir, { recursive: true });
97+
}
98+
writeFileSync(
99+
cachePath,
100+
JSON.stringify({ path: resolvedPath, timestamp: Date.now() }),
101+
"utf-8",
102+
);
103+
} catch {
104+
// Cache write failure is non-fatal
105+
}
106+
}
107+
53108
function executeShell(shell: string): string | undefined {
54109
const command = `echo -n "${DELIMITER}"; env; echo -n "${DELIMITER}"; exit`;
55110

56111
try {
57-
return execSync(`${shell} -ilc '${command}'`, {
112+
const result = spawnSync(shell, ["-lc", command], {
58113
encoding: "utf-8",
59114
timeout: 5000,
60115
stdio: ["ignore", "pipe", "ignore"],
116+
// Kill the entire process group on timeout, not just the parent shell.
117+
// This prevents orphaned children (node -v, printf, tail, sed) from
118+
// surviving as zombies.
119+
killSignal: "SIGKILL",
61120
env: {
62121
...process.env,
63122
// Disable Oh My Zsh auto-update which can block
64123
DISABLE_AUTO_UPDATE: "true",
124+
// Signal to user's shell config that we're resolving the environment.
125+
// Users with heavy configs can check this and fast-exit:
126+
// [[ -n "$POSTHOG_CODE_RESOLVING_ENVIRONMENT" ]] && return
127+
POSTHOG_CODE_RESOLVING_ENVIRONMENT: "1",
65128
},
66129
});
130+
131+
if (result.status !== 0 && !result.stdout) {
132+
return undefined;
133+
}
134+
135+
return result.stdout || undefined;
67136
} catch {
68137
return undefined;
69138
}
@@ -110,11 +179,20 @@ export function fixPath(): void {
110179
return;
111180
}
112181

182+
// Try cached PATH first (instant, no shell spawn)
183+
const cached = readCachedPath();
184+
if (cached) {
185+
process.env.PATH = cached;
186+
return;
187+
}
188+
113189
const shell = detectDefaultShell();
114190
const shellPath = getShellPath(shell);
115191

116192
if (shellPath) {
117-
process.env.PATH = stripAnsi(shellPath);
193+
const cleaned = stripAnsi(shellPath);
194+
process.env.PATH = cleaned;
195+
writeCachedPath(cleaned);
118196
} else {
119197
process.env.PATH = buildFallbackPath();
120198
}

0 commit comments

Comments
 (0)