Skip to content

Commit b990f0b

Browse files
committed
add logging, fix race condition
1 parent 0daafe7 commit b990f0b

File tree

2 files changed

+101
-30
lines changed

2 files changed

+101
-30
lines changed

src/managers/common/nativePythonFinder.ts

Lines changed: 72 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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;

src/test/managers/common/nativePythonFinder.jsonCli.unit.test.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,18 @@ suite('buildFindCliArgs — Uri[] options', () => {
140140
assert.ok(!args.includes('--kind'), 'Should not include --kind');
141141
});
142142

143-
test('handles empty Uri[] with no venvFolders — only find --json --workspace', () => {
143+
test('handles empty Uri[] with no venvFolders — falls back to workspace dirs, omits --workspace', () => {
144+
// PET's CLI with --workspace but no positional paths falls back to CWD, not an empty search.
145+
// When both options and venvFolders are empty, omit --workspace and use workspaceDirs instead.
146+
const config = makeConfig({ workspaceDirectories: ['/myworkspace'] });
147+
const args = buildFindCliArgs(config, []);
148+
assert.ok(!args.includes('--workspace'), 'Should not include --workspace when no paths');
149+
assert.ok(args.includes('/myworkspace'), 'Should fall back to workspace dirs');
150+
});
151+
152+
test('handles empty Uri[] with no venvFolders and no workspaceDirs — only find --json', () => {
144153
const args = buildFindCliArgs(makeConfig(), []);
145-
assert.deepStrictEqual(args, ['find', '--json', '--workspace']);
154+
assert.deepStrictEqual(args, ['find', '--json']);
146155
});
147156
});
148157

@@ -193,12 +202,25 @@ suite('buildFindCliArgs — configuration flags', () => {
193202
assert.strictEqual(args[idx + 1], '/home/user/.local/bin/poetry');
194203
});
195204

196-
test('adds --environment-directories as comma-joined string', () => {
205+
test('passes each environment directory as a separate flag (not comma-joined)', () => {
197206
const config = makeConfig({ environmentDirectories: ['/home/.venvs', '/opt/envs'] });
198207
const args = buildFindCliArgs(config);
199-
const idx = args.indexOf('--environment-directories');
200-
assert.ok(idx >= 0, 'Should include --environment-directories');
201-
assert.strictEqual(args[idx + 1], '/home/.venvs,/opt/envs', 'Dirs should be comma-joined');
208+
// Each dir must appear as a separate --environment-directories flag
209+
// (comma-joining breaks paths that contain commas on POSIX/Windows)
210+
const flagIndices = args.reduce<number[]>(
211+
(acc, a, i) => (a === '--environment-directories' ? [...acc, i] : acc),
212+
[],
213+
);
214+
assert.strictEqual(flagIndices.length, 2, 'Should have two --environment-directories flags');
215+
assert.strictEqual(args[flagIndices[0] + 1], '/home/.venvs');
216+
assert.strictEqual(args[flagIndices[1] + 1], '/opt/envs');
217+
});
218+
219+
test('paths with commas in environment-directories are passed safely (no splitting)', () => {
220+
const config = makeConfig({ environmentDirectories: ['/my,path/envs', '/normal/envs'] });
221+
const args = buildFindCliArgs(config);
222+
assert.ok(args.includes('/my,path/envs'), 'Comma-containing path should appear as-is');
223+
assert.ok(args.includes('/normal/envs'));
202224
});
203225

204226
test('omits --environment-directories when array is empty', () => {
@@ -237,13 +259,12 @@ suite('buildFindCliArgs — edge cases', () => {
237259
assert.ok(args.includes('/path with spaces/project'));
238260
});
239261

240-
test('environmentDirectories with a single entry produces no comma', () => {
262+
test('environmentDirectories with a single entry passes exactly one flag', () => {
241263
const config = makeConfig({ environmentDirectories: ['/only-one'] });
242264
const args = buildFindCliArgs(config);
243265
const idx = args.indexOf('--environment-directories');
244266
assert.ok(idx >= 0);
245267
assert.strictEqual(args[idx + 1], '/only-one');
246-
assert.ok(!args[idx + 1].includes(','), 'Single entry should not have comma');
247268
});
248269

249270
test('venvFolders are not added when options is a kind string', () => {

0 commit comments

Comments
 (0)