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" ;
1221import { userInfo } from "node:os" ;
22+ import { dirname , join } from "node:path" ;
23+ import { app } from "electron" ;
1324
1425const 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 - z A - Z \d ] * (?: ; [ - a - z A - Z \d / # & . : = ? % @ ~ _ ] * ) * ) ? \u0007 ) | (?: (?: \d { 1 , 4 } (?: ; \d { 0 , 4 } ) * ) ? [ \d A - P R - T Z c f - n t q r y = > < ~ ] ) ) / g;
2738
39+ /** Max age of cached PATH before re-resolving (1 hour) */
40+ const CACHE_MAX_AGE_MS = 60 * 60 * 1000 ;
41+
2842function 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+
53108function 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