@@ -763,9 +763,15 @@ class NativePythonFinderImpl implements NativePythonFinder {
763763 /**
764764 * Returns true when all server restart attempts have been exhausted.
765765 * Used to decide whether to fall back to CLI mode.
766+ * Does NOT return true while a restart is in progress — the server is not exhausted
767+ * if it is still mid-restart (concurrent callers must not bypass to CLI prematurely).
766768 */
767769 private isServerExhausted ( ) : boolean {
768- return this . restartAttempts >= MAX_RESTART_ATTEMPTS && ( this . startFailed || this . processExited ) ;
770+ return (
771+ ! this . isRestarting &&
772+ this . restartAttempts >= MAX_RESTART_ATTEMPTS &&
773+ ( this . startFailed || this . processExited )
774+ ) ;
769775 }
770776
771777 /**
@@ -781,8 +787,16 @@ class NativePythonFinderImpl implements NativePythonFinder {
781787 return new Promise ( ( resolve , reject ) => {
782788 const proc = spawnProcess ( this . toolPath , args , { stdio : 'pipe' } ) ;
783789 let stdout = '' ;
790+ // Guard against settling the promise more than once.
791+ // The timeout handler and the 'close'/'error' handlers can both fire
792+ // (e.g. timeout fires → SIGTERM sent → close event fires shortly after).
793+ let settled = false ;
784794
785795 const timer = setTimeout ( ( ) => {
796+ if ( settled ) {
797+ return ;
798+ }
799+ settled = true ;
786800 try {
787801 proc . kill ( 'SIGTERM' ) ;
788802 // Force kill after a short grace period if still running
@@ -805,16 +819,29 @@ class NativePythonFinderImpl implements NativePythonFinder {
805819 this . outputChannel . debug ( `[pet CLI] ${ data . toString ( ) . trimEnd ( ) } ` ) ;
806820 } ) ;
807821 proc . on ( 'close' , ( code ) => {
822+ if ( settled ) {
823+ return ;
824+ }
808825 clearTimeout ( timer ) ;
826+ settled = true ;
809827 // If the process failed and produced no output, reject so caller gets a clear error
810828 if ( code !== 0 && stdout . trim ( ) . length === 0 ) {
811829 reject ( new Error ( `PET CLI process exited with code ${ code } ` ) ) ;
812830 return ;
813831 }
832+ if ( code !== 0 ) {
833+ this . outputChannel . warn (
834+ `[pet CLI] Process exited with code ${ code } but produced output; using output` ,
835+ ) ;
836+ }
814837 resolve ( stdout ) ;
815838 } ) ;
816839 proc . on ( 'error' , ( err ) => {
840+ if ( settled ) {
841+ return ;
842+ }
817843 clearTimeout ( timer ) ;
844+ settled = true ;
818845 reject ( err ) ;
819846 } ) ;
820847 } ) ;
@@ -869,25 +896,31 @@ class NativePythonFinderImpl implements NativePythonFinder {
869896 nativeInfo . push ( manager ) ;
870897 }
871898
899+ // Resolve incomplete environments in parallel, mirroring doRefreshAttempt's Promise.all pattern.
900+ const resolvePromises : Promise < void > [ ] = [ ] ;
872901 for ( const env of parsed . environments ?? [ ] ) {
873902 if ( env . executable && ( ! env . version || ! env . prefix ) ) {
874903 // Environment has an executable but incomplete metadata — resolve individually
875- try {
876- const resolved = await this . resolveViaJsonCli ( env . executable ) ;
877- this . outputChannel . info ( `[pet CLI] Resolved env: ${ resolved . executable } ` ) ;
878- nativeInfo . push ( resolved ) ;
879- } catch {
880- // If resolve fails, still include the partial env so nothing is silently dropped
881- this . outputChannel . warn (
882- `[pet CLI] Could not resolve incomplete env, using partial data: ${ env . executable } ` ,
883- ) ;
884- nativeInfo . push ( env ) ;
885- }
904+ resolvePromises . push (
905+ this . resolveViaJsonCli ( env . executable )
906+ . then ( ( resolved ) => {
907+ this . outputChannel . info ( `[pet CLI] Resolved env: ${ resolved . executable } ` ) ;
908+ nativeInfo . push ( resolved ) ;
909+ } )
910+ . catch ( ( ) => {
911+ // If resolve fails, still include the partial env so nothing is silently dropped
912+ this . outputChannel . warn (
913+ `[pet CLI] Could not resolve incomplete env, using partial data: ${ env . executable } ` ,
914+ ) ;
915+ nativeInfo . push ( env ) ;
916+ } ) ,
917+ ) ;
886918 } else {
887919 this . outputChannel . info ( `[pet CLI] Discovered env: ${ env . executable ?? env . prefix } ` ) ;
888920 nativeInfo . push ( env ) ;
889921 }
890922 }
923+ await Promise . all ( resolvePromises ) ;
891924
892925 sendTelemetryEvent ( EventNames . PET_JSON_CLI_FALLBACK , stopWatch . elapsedTime , {
893926 operation : 'refresh' ,
@@ -921,6 +954,7 @@ class NativePythonFinderImpl implements NativePythonFinder {
921954 operation : 'resolve' ,
922955 result : 'error' ,
923956 } ) ;
957+ this . outputChannel . error ( '[pet] JSON CLI fallback resolve failed:' , ex ) ;
924958 throw ex ;
925959 }
926960
@@ -1014,7 +1048,7 @@ export function parseRefreshCliOutput(stdout: string): {
10141048} {
10151049 // May throw SyntaxError on malformed JSON — callers must handle
10161050 const parsed = JSON . parse ( stdout ) ;
1017- if ( typeof parsed !== 'object' || parsed === null ) {
1051+ if ( typeof parsed !== 'object' || parsed === null || Array . isArray ( parsed ) ) {
10181052 throw new SyntaxError ( 'PET find --json output is not a JSON object' ) ;
10191053 }
10201054 return {
@@ -1037,6 +1071,9 @@ export function parseResolveCliOutput(stdout: string, executable: string): Nativ
10371071 if ( parsed === null ) {
10381072 throw new Error ( `PET could not identify environment for executable: ${ executable } ` ) ;
10391073 }
1074+ if ( typeof parsed !== 'object' || Array . isArray ( parsed ) ) {
1075+ throw new SyntaxError ( `PET resolve --json output is not a JSON object for ${ executable } ` ) ;
1076+ }
10401077 return parsed ;
10411078}
10421079
@@ -1072,12 +1109,23 @@ export function buildFindCliArgs(
10721109 // In server mode, `build_refresh_config` sets search_scope = Workspace, which causes
10731110 // find_and_report_envs to skip all global discovery phases (locators, PATH, global venvs)
10741111 // and only search the provided paths. Mirror that with --workspace.
1075- args . push ( '--workspace' ) ;
1076- for ( const uri of options ) {
1077- args . push ( uri . fsPath ) ;
1078- }
1079- for ( const folder of venvFolders ) {
1080- args . push ( folder ) ;
1112+ //
1113+ // Edge case: if both options and venvFolders are empty, omit --workspace entirely.
1114+ // PET's CLI has no "search nothing" mode — with --workspace but no positional paths it
1115+ // falls back to CWD. Falling through to the workspace-dirs path is a better approximation
1116+ // of server-mode's empty-searchPaths behavior (which searches nothing meaningful) and
1117+ // avoids scanning an arbitrary directory.
1118+ const searchPaths = [ ...options . map ( ( u ) => u . fsPath ) , ...venvFolders ] ;
1119+ if ( searchPaths . length > 0 ) {
1120+ args . push ( '--workspace' ) ;
1121+ for ( const p of searchPaths ) {
1122+ args . push ( p ) ;
1123+ }
1124+ } else {
1125+ // No search paths at all: fall back to workspace dirs as positional args
1126+ for ( const dir of config . workspaceDirectories ) {
1127+ args . push ( dir ) ;
1128+ }
10811129 }
10821130 }
10831131 } else {
@@ -1100,9 +1148,11 @@ export function buildFindCliArgs(
11001148 if ( config . poetryExecutable ) {
11011149 args . push ( '--poetry-executable' , config . poetryExecutable ) ;
11021150 }
1103- if ( config . environmentDirectories . length > 0 ) {
1104- // PET accepts comma-separated dirs for --environment-directories
1105- args . push ( '--environment-directories' , config . environmentDirectories . join ( ',' ) ) ;
1151+ // Pass each environment directory as a separate flag repetition.
1152+ // PET's --environment-directories uses value_delimiter=',' for env-var parsing, but
1153+ // repeating the flag on the CLI is the safe way to handle paths that contain commas.
1154+ for ( const dir of config . environmentDirectories ) {
1155+ args . push ( '--environment-directories' , dir ) ;
11061156 }
11071157
11081158 return args ;
0 commit comments