Skip to content

Commit 23c4479

Browse files
committed
add logging, fix race condition
1 parent 3319a01 commit 23c4479

2 files changed

Lines changed: 101 additions & 30 deletions

File tree

src/managers/common/nativePythonFinder.ts

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

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)