@@ -768,9 +768,15 @@ class NativePythonFinderImpl implements NativePythonFinder {
768768 /**
769769 * Returns true when all server restart attempts have been exhausted.
770770 * Used to decide whether to fall back to CLI mode.
771+ * Does NOT return true while a restart is in progress — the server is not exhausted
772+ * if it is still mid-restart (concurrent callers must not bypass to CLI prematurely).
771773 */
772774 private isServerExhausted ( ) : boolean {
773- return this . restartAttempts >= MAX_RESTART_ATTEMPTS && ( this . startFailed || this . processExited ) ;
775+ return (
776+ ! this . isRestarting &&
777+ this . restartAttempts >= MAX_RESTART_ATTEMPTS &&
778+ ( this . startFailed || this . processExited )
779+ ) ;
774780 }
775781
776782 /**
@@ -786,8 +792,16 @@ class NativePythonFinderImpl implements NativePythonFinder {
786792 return new Promise ( ( resolve , reject ) => {
787793 const proc = spawnProcess ( this . toolPath , args , { stdio : 'pipe' } ) ;
788794 let stdout = '' ;
795+ // Guard against settling the promise more than once.
796+ // The timeout handler and the 'close'/'error' handlers can both fire
797+ // (e.g. timeout fires → SIGTERM sent → close event fires shortly after).
798+ let settled = false ;
789799
790800 const timer = setTimeout ( ( ) => {
801+ if ( settled ) {
802+ return ;
803+ }
804+ settled = true ;
791805 try {
792806 proc . kill ( 'SIGTERM' ) ;
793807 // Force kill after a short grace period if still running
@@ -810,16 +824,29 @@ class NativePythonFinderImpl implements NativePythonFinder {
810824 this . outputChannel . debug ( `[pet CLI] ${ data . toString ( ) . trimEnd ( ) } ` ) ;
811825 } ) ;
812826 proc . on ( 'close' , ( code ) => {
827+ if ( settled ) {
828+ return ;
829+ }
813830 clearTimeout ( timer ) ;
831+ settled = true ;
814832 // If the process failed and produced no output, reject so caller gets a clear error
815833 if ( code !== 0 && stdout . trim ( ) . length === 0 ) {
816834 reject ( new Error ( `PET CLI process exited with code ${ code } ` ) ) ;
817835 return ;
818836 }
837+ if ( code !== 0 ) {
838+ this . outputChannel . warn (
839+ `[pet CLI] Process exited with code ${ code } but produced output; using output` ,
840+ ) ;
841+ }
819842 resolve ( stdout ) ;
820843 } ) ;
821844 proc . on ( 'error' , ( err ) => {
845+ if ( settled ) {
846+ return ;
847+ }
822848 clearTimeout ( timer ) ;
849+ settled = true ;
823850 reject ( err ) ;
824851 } ) ;
825852 } ) ;
@@ -874,25 +901,31 @@ class NativePythonFinderImpl implements NativePythonFinder {
874901 nativeInfo . push ( manager ) ;
875902 }
876903
904+ // Resolve incomplete environments in parallel, mirroring doRefreshAttempt's Promise.all pattern.
905+ const resolvePromises : Promise < void > [ ] = [ ] ;
877906 for ( const env of parsed . environments ?? [ ] ) {
878907 if ( env . executable && ( ! env . version || ! env . prefix ) ) {
879908 // Environment has an executable but incomplete metadata — resolve individually
880- try {
881- const resolved = await this . resolveViaJsonCli ( env . executable ) ;
882- this . outputChannel . info ( `[pet CLI] Resolved env: ${ resolved . executable } ` ) ;
883- nativeInfo . push ( resolved ) ;
884- } catch {
885- // If resolve fails, still include the partial env so nothing is silently dropped
886- this . outputChannel . warn (
887- `[pet CLI] Could not resolve incomplete env, using partial data: ${ env . executable } ` ,
888- ) ;
889- nativeInfo . push ( env ) ;
890- }
909+ resolvePromises . push (
910+ this . resolveViaJsonCli ( env . executable )
911+ . then ( ( resolved ) => {
912+ this . outputChannel . info ( `[pet CLI] Resolved env: ${ resolved . executable } ` ) ;
913+ nativeInfo . push ( resolved ) ;
914+ } )
915+ . catch ( ( ) => {
916+ // If resolve fails, still include the partial env so nothing is silently dropped
917+ this . outputChannel . warn (
918+ `[pet CLI] Could not resolve incomplete env, using partial data: ${ env . executable } ` ,
919+ ) ;
920+ nativeInfo . push ( env ) ;
921+ } ) ,
922+ ) ;
891923 } else {
892924 this . outputChannel . info ( `[pet CLI] Discovered env: ${ env . executable ?? env . prefix } ` ) ;
893925 nativeInfo . push ( env ) ;
894926 }
895927 }
928+ await Promise . all ( resolvePromises ) ;
896929
897930 sendTelemetryEvent ( EventNames . PET_JSON_CLI_FALLBACK , stopWatch . elapsedTime , {
898931 operation : 'refresh' ,
@@ -926,6 +959,7 @@ class NativePythonFinderImpl implements NativePythonFinder {
926959 operation : 'resolve' ,
927960 result : 'error' ,
928961 } ) ;
962+ this . outputChannel . error ( '[pet] JSON CLI fallback resolve failed:' , ex ) ;
929963 throw ex ;
930964 }
931965
@@ -1019,7 +1053,7 @@ export function parseRefreshCliOutput(stdout: string): {
10191053} {
10201054 // May throw SyntaxError on malformed JSON — callers must handle
10211055 const parsed = JSON . parse ( stdout ) ;
1022- if ( typeof parsed !== 'object' || parsed === null ) {
1056+ if ( typeof parsed !== 'object' || parsed === null || Array . isArray ( parsed ) ) {
10231057 throw new SyntaxError ( 'PET find --json output is not a JSON object' ) ;
10241058 }
10251059 return {
@@ -1042,6 +1076,9 @@ export function parseResolveCliOutput(stdout: string, executable: string): Nativ
10421076 if ( parsed === null ) {
10431077 throw new Error ( `PET could not identify environment for executable: ${ executable } ` ) ;
10441078 }
1079+ if ( typeof parsed !== 'object' || Array . isArray ( parsed ) ) {
1080+ throw new SyntaxError ( `PET resolve --json output is not a JSON object for ${ executable } ` ) ;
1081+ }
10451082 return parsed ;
10461083}
10471084
@@ -1077,12 +1114,23 @@ export function buildFindCliArgs(
10771114 // In server mode, `build_refresh_config` sets search_scope = Workspace, which causes
10781115 // find_and_report_envs to skip all global discovery phases (locators, PATH, global venvs)
10791116 // and only search the provided paths. Mirror that with --workspace.
1080- args . push ( '--workspace' ) ;
1081- for ( const uri of options ) {
1082- args . push ( uri . fsPath ) ;
1083- }
1084- for ( const folder of venvFolders ) {
1085- args . push ( folder ) ;
1117+ //
1118+ // Edge case: if both options and venvFolders are empty, omit --workspace entirely.
1119+ // PET's CLI has no "search nothing" mode — with --workspace but no positional paths it
1120+ // falls back to CWD. Falling through to the workspace-dirs path is a better approximation
1121+ // of server-mode's empty-searchPaths behavior (which searches nothing meaningful) and
1122+ // avoids scanning an arbitrary directory.
1123+ const searchPaths = [ ...options . map ( ( u ) => u . fsPath ) , ...venvFolders ] ;
1124+ if ( searchPaths . length > 0 ) {
1125+ args . push ( '--workspace' ) ;
1126+ for ( const p of searchPaths ) {
1127+ args . push ( p ) ;
1128+ }
1129+ } else {
1130+ // No search paths at all: fall back to workspace dirs as positional args
1131+ for ( const dir of config . workspaceDirectories ) {
1132+ args . push ( dir ) ;
1133+ }
10861134 }
10871135 }
10881136 } else {
@@ -1105,9 +1153,11 @@ export function buildFindCliArgs(
11051153 if ( config . poetryExecutable ) {
11061154 args . push ( '--poetry-executable' , config . poetryExecutable ) ;
11071155 }
1108- if ( config . environmentDirectories . length > 0 ) {
1109- // PET accepts comma-separated dirs for --environment-directories
1110- args . push ( '--environment-directories' , config . environmentDirectories . join ( ',' ) ) ;
1156+ // Pass each environment directory as a separate flag repetition.
1157+ // PET's --environment-directories uses value_delimiter=',' for env-var parsing, but
1158+ // repeating the flag on the CLI is the safe way to handle paths that contain commas.
1159+ for ( const dir of config . environmentDirectories ) {
1160+ args . push ( '--environment-directories' , dir ) ;
11111161 }
11121162
11131163 return args ;
0 commit comments