Skip to content

Commit cfed908

Browse files
committed
Address inline script persistence review
Keep inline-script manager settings scoped to exact script projects and prevent stale warm validation from superseding a newer selection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1a9f6ba1-9bd3-4664-bc25-a0d34d7a2e91
1 parent 6f8e633 commit cfed908

4 files changed

Lines changed: 167 additions & 18 deletions

File tree

‎src/features/envManagers.ts‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,11 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
348348
// Only persist to settings when explicitly requested
349349
if (shouldPersistSettings && scope) {
350350
const packageManager = this.getPackageManager(environment);
351-
if (project && packageManager) {
351+
const canPersistSettings =
352+
project &&
353+
packageManager &&
354+
this.canPersistManagerSettingForScope(scope, manager, project);
355+
if (canPersistSettings) {
352356
await setAllManagerSettings([
353357
{
354358
project,
@@ -361,8 +365,8 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
361365
`[setEnvironment] scope=${scope instanceof Uri ? scope.fsPath : scope}, ` +
362366
`env=${environment?.envId?.id ?? 'undefined'}, manager=${manager.id}, ` +
363367
`project=${project?.uri?.toString() ?? 'none'}, ` +
364-
`packageManager=${this.getPackageManager(environment)?.id ?? 'UNDEFINED'}, ` +
365-
`settingsPersisted=${!!(project && this.getPackageManager(environment))}`,
368+
`packageManager=${packageManager?.id ?? 'UNDEFINED'}, ` +
369+
`settingsPersisted=${!!canPersistSettings}`,
366370
);
367371
}
368372

@@ -423,16 +427,19 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
423427
});
424428
scope.forEach((uri) => {
425429
const m = this.getEnvironmentManager(uri);
430+
const project = this.pm.get(uri);
426431
// Always add settings when persisting, OR when manager differs
427-
if (shouldPersistSettings || manager.id !== m?.id) {
432+
if (
433+
(shouldPersistSettings || manager.id !== m?.id) &&
434+
this.canPersistManagerSettingForScope(uri, manager, project)
435+
) {
428436
settings.push({
429-
project: this.pm.get(uri),
437+
project,
430438
envManager: manager.id,
431439
packageManager: manager.preferredPackageManagerId,
432440
});
433441
}
434442

435-
const project = this.pm.get(uri);
436443
const key = this.getActiveSelectionKey(uri, manager, project);
437444
const oldEnv = this._activeSelection.get(key);
438445
if (oldEnv?.envId.id !== environment?.envId.id) {
@@ -663,6 +670,18 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
663670
return `inline-script:${normalizePath(scope.fsPath)}`;
664671
}
665672

673+
private canPersistManagerSettingForScope(
674+
scope: Uri,
675+
manager: InternalEnvironmentManager,
676+
project: PythonProject | undefined,
677+
): boolean {
678+
// Inline associations are per file; never promote one to its containing project's manager setting.
679+
return (
680+
manager.id !== INLINE_SCRIPT_MANAGER_ID ||
681+
(!!project && normalizePath(project.uri.fsPath) === normalizePath(scope.fsPath))
682+
);
683+
}
684+
666685
private bumpSelectionRevision(key: string): number {
667686
const revision = (this._selectionRevisions.get(key) ?? 0) + 1;
668687
this._selectionRevisions.set(key, revision);

‎src/managers/builtin/inlineScript/envManager.ts‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
400400
}
401401

402402
const cached = this.fsPathToEnv.get(scriptPath);
403+
const revision = this.associationRevisions.get(scriptPath) ?? 0;
403404
if (cached) {
404405
const validatedAt = this.cachedAssociationValidatedAt.get(scriptPath);
405406
if (
@@ -408,7 +409,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
408409
) {
409410
return cached;
410411
}
411-
const validation = this.validateCachedAssociation(scriptPath, scriptUri, cached);
412+
const validation = this.validateCachedAssociation(scriptPath, scriptUri, cached, revision);
412413
this.pendingRehydrations.set(scriptPath, validation);
413414
try {
414415
return await validation;
@@ -419,7 +420,6 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
419420
}
420421
}
421422

422-
const revision = this.associationRevisions.get(scriptPath) ?? 0;
423423
const rehydration = this.rehydrateAssociation(scriptPath, scriptUri, revision);
424424
this.pendingRehydrations.set(scriptPath, rehydration);
425425
try {
@@ -444,16 +444,23 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
444444
scriptPath: string,
445445
scriptUri: Uri,
446446
cached: PythonEnvironment,
447+
revision: number,
447448
): Promise<PythonEnvironment | undefined> {
448449
const environmentPath = cached.environmentPath.fsPath;
449450
const envDirPath = path.dirname(path.dirname(environmentPath));
450-
if (await this.isCacheEntryBusy(envDirPath)) {
451+
const busy = await this.isCacheEntryBusy(envDirPath);
452+
if (!this.isCurrentAssociationRevision(scriptPath, revision)) {
453+
return this.fsPathToEnv.get(scriptPath);
454+
}
455+
if (busy) {
451456
return undefined;
452457
}
453458
try {
454459
const stat = await fs.stat(environmentPath);
460+
if (!this.isCurrentAssociationRevision(scriptPath, revision)) {
461+
return this.fsPathToEnv.get(scriptPath);
462+
}
455463
if (stat.isFile()) {
456-
const revision = this.associationRevisions.get(scriptPath) ?? 0;
457464
const resolved = await resolveVenvPythonEnvironmentPath(
458465
environmentPath,
459466
this.nativeFinder,
@@ -491,21 +498,32 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
491498
this._onDidChangeEnvironment.fire({ uri: scriptUri, old: cached, new: resolved });
492499
return resolved;
493500
}
494-
if (!(await this.isCacheEntryBusy(envDirPath))) {
501+
const becameBusy = await this.isCacheEntryBusy(envDirPath);
502+
if (!this.isCurrentAssociationRevision(scriptPath, revision)) {
503+
return this.fsPathToEnv.get(scriptPath);
504+
}
505+
if (!becameBusy) {
495506
await this.removeStalePersistedAssociation(
496507
scriptPath,
497508
environmentPath,
498-
this.associationRevisions.get(scriptPath) ?? 0,
509+
revision,
499510
scriptUri,
500511
);
501512
}
502513
} catch (error) {
514+
if (!this.isCurrentAssociationRevision(scriptPath, revision)) {
515+
return this.fsPathToEnv.get(scriptPath);
516+
}
503517
if (this.isDefinitivelyStalePathError(error)) {
504-
if (!(await this.isCacheEntryBusy(envDirPath))) {
518+
const becameBusy = await this.isCacheEntryBusy(envDirPath);
519+
if (!this.isCurrentAssociationRevision(scriptPath, revision)) {
520+
return this.fsPathToEnv.get(scriptPath);
521+
}
522+
if (!becameBusy) {
505523
await this.removeStalePersistedAssociation(
506524
scriptPath,
507525
environmentPath,
508-
this.associationRevisions.get(scriptPath) ?? 0,
526+
revision,
509527
scriptUri,
510528
);
511529
}

‎src/test/features/envManagers.lastKnown.unit.test.ts‎

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,18 @@ import {
2020
GetEnvironmentScope,
2121
PythonEnvironment,
2222
PythonEnvironmentId,
23+
PythonProject,
2324
} from '../../api';
2425
import * as extensionApis from '../../common/extension.apis';
2526
import { PythonEnvironmentManagers } from '../../features/envManagers';
2627
import * as settingHelpers from '../../features/settings/settingHelpers';
27-
import { PythonProjectManager } from '../../internal.api';
28+
import { InternalPackageManager, PythonProjectManager } from '../../internal.api';
2829
import { setupNonThenable } from '../mocks/helper';
2930

3031
suite('PythonEnvironmentManagers getLastKnownEnvironment', () => {
3132
let envManagers: PythonEnvironmentManagers;
3233
let projectManager: typeMoq.IMock<PythonProjectManager>;
34+
let projectsByUri: Map<string, PythonProject>;
3335

3436
function makeEnv(id: string): PythonEnvironment {
3537
const envId: PythonEnvironmentId = { id, managerId: 'test-manager' };
@@ -58,8 +60,10 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => {
5860

5961
projectManager = typeMoq.Mock.ofType<PythonProjectManager>();
6062
setupNonThenable(projectManager);
61-
// No project for a scope -> refreshEnvironment/getLastKnownEnvironment use the 'global' key.
62-
projectManager.setup((pm) => pm.get(typeMoq.It.isAny())).returns(() => undefined);
63+
projectsByUri = new Map();
64+
projectManager
65+
.setup((pm) => pm.get(typeMoq.It.isAny()))
66+
.returns((uri) => projectsByUri.get(uri.toString()));
6367

6468
envManagers = new PythonEnvironmentManagers(projectManager.object);
6569
});
@@ -96,6 +100,13 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => {
96100
return id;
97101
}
98102

103+
function stubPackageManager(id = 'ms-python.python:pip'): void {
104+
const packageManager = typeMoq.Mock.ofType<InternalPackageManager>();
105+
setupNonThenable(packageManager);
106+
packageManager.setup((manager) => manager.id).returns(() => id);
107+
sinon.stub(envManagers, 'getPackageManager').returns(packageManager.object);
108+
}
109+
99110
test('returns undefined before any environment has been resolved', () => {
100111
registerManager(async () => makeEnv('env1'));
101112
assert.strictEqual(envManagers.getLastKnownEnvironment(undefined), undefined);
@@ -127,7 +138,7 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => {
127138
test('does not update selection, settings, or events when a registered manager rejects a selection', async () => {
128139
const scope = Uri.file('/workspace/script.py');
129140
const project = { name: 'script.py', uri: scope };
130-
projectManager.setup((pm) => pm.get(scope)).returns(() => project);
141+
projectsByUri.set(scope.toString(), project);
131142
const managerSet = sinon.stub().rejects(new Error('Inline-script environment is not an owned cache entry.'));
132143
const managerId = registerManager(async () => undefined, managerSet);
133144
const rejected = {
@@ -239,6 +250,65 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => {
239250
assert.strictEqual(envManagers.getLastKnownEnvironment(secondUri), second);
240251
});
241252

253+
test('does not persist an inline-script manager for the containing project', async () => {
254+
const script = Uri.file('/workspace/project/script.py');
255+
const containingProject = { name: 'project', uri: Uri.file('/workspace/project') };
256+
projectsByUri.set(script.toString(), containingProject);
257+
const managerId = registerManager(async () => undefined, async () => undefined, 'inline-script');
258+
const environment = { ...makeEnv('inline'), envId: { id: 'inline', managerId } };
259+
stubPackageManager();
260+
const settings = sinon.stub(settingHelpers, 'setAllManagerSettings').resolves();
261+
262+
await envManagers.setEnvironment(script, environment);
263+
264+
assert.strictEqual(settings.callCount, 0);
265+
});
266+
267+
test('persists an inline-script manager when the script is its own project', async () => {
268+
const script = Uri.file('/workspace/script.py');
269+
const scriptProject = { name: 'script.py', uri: script };
270+
projectsByUri.set(script.toString(), scriptProject);
271+
const managerId = registerManager(async () => undefined, async () => undefined, 'inline-script');
272+
const environment = { ...makeEnv('inline'), envId: { id: 'inline', managerId } };
273+
stubPackageManager();
274+
const settings = sinon.stub(settingHelpers, 'setAllManagerSettings').resolves();
275+
276+
await envManagers.setEnvironment(script, environment);
277+
278+
sinon.assert.calledOnce(settings);
279+
assert.deepStrictEqual(settings.firstCall.args[0], [
280+
{
281+
project: scriptProject,
282+
envManager: managerId,
283+
packageManager: 'ms-python.python:pip',
284+
},
285+
]);
286+
});
287+
288+
test('persists batch inline settings only for scripts registered as exact projects', async () => {
289+
const exactScript = Uri.file('/workspace/exact.py');
290+
const nestedScript = Uri.file('/workspace/project/nested.py');
291+
const looseScript = Uri.file('/outside/loose.py');
292+
const exactProject = { name: 'exact.py', uri: exactScript };
293+
const containingProject = { name: 'project', uri: Uri.file('/workspace/project') };
294+
projectsByUri.set(exactScript.toString(), exactProject);
295+
projectsByUri.set(nestedScript.toString(), containingProject);
296+
const managerId = registerManager(async () => undefined, async () => undefined, 'inline-script');
297+
const environment = { ...makeEnv('inline'), envId: { id: 'inline', managerId } };
298+
const settings = sinon.stub(settingHelpers, 'setAllManagerSettings').resolves();
299+
300+
await envManagers.setEnvironments([exactScript, nestedScript, looseScript], environment);
301+
302+
sinon.assert.calledOnce(settings);
303+
assert.deepStrictEqual(settings.firstCall.args[0], [
304+
{
305+
project: exactProject,
306+
envManager: managerId,
307+
packageManager: 'ms-python.python:pip',
308+
},
309+
]);
310+
});
311+
242312
test('retains an earlier successful refresh when a later refresh fails', async () => {
243313
const refreshed = makeEnv('refreshed');
244314
let resolveFirst: ((environment: PythonEnvironment) => void) | undefined;

‎src/test/managers/builtin/inlineScript/envManager.unit.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1754,6 +1754,48 @@ suite('InlineScriptEnvManager', () => {
17541754
sinon.assert.calledOnceWithExactly(listener, { uri, old: environment, new: rebuilt });
17551755
});
17561756

1757+
test('lets an explicit selection win while warm validation awaits filesystem inspection', async () => {
1758+
const uri = scriptUri();
1759+
const oldEnvironment = await createOwnedEnvironment();
1760+
const selectedEnvironment = await createOwnedEnvironment('fedcba9876543210');
1761+
await manager.set(uri, oldEnvironment);
1762+
const rebuiltOldEnvironment = {
1763+
...oldEnvironment,
1764+
envId: { ...oldEnvironment.envId, id: 'rebuilt-old' },
1765+
version: '3.13.1',
1766+
};
1767+
resolveVenvStub.resolves(rebuiltOldEnvironment);
1768+
let releaseBusyCheck: (() => void) | undefined;
1769+
const busyCheckGate = new Promise<boolean>((resolve) => {
1770+
releaseBusyCheck = () => resolve(false);
1771+
});
1772+
const validationManager = manager as unknown as {
1773+
isCacheEntryBusy(envDirPath: string): Promise<boolean>;
1774+
};
1775+
const busyCheckStub = sinon.stub(validationManager, 'isCacheEntryBusy').callThrough();
1776+
busyCheckStub.onFirstCall().returns(busyCheckGate);
1777+
const listener = sinon.spy();
1778+
manager.onDidChangeEnvironment(listener);
1779+
clock.tick(5_000);
1780+
1781+
const pendingGet = manager.get(uri);
1782+
await waitForStubCall(busyCheckStub);
1783+
await manager.set(uri, selectedEnvironment);
1784+
releaseBusyCheck!();
1785+
1786+
assert.strictEqual(await pendingGet, selectedEnvironment);
1787+
assert.strictEqual(await manager.get(uri), selectedEnvironment);
1788+
assert.deepStrictEqual(persistedAssociations, {
1789+
[normalizePath(uri.fsPath)]: selectedEnvironment.environmentPath.fsPath,
1790+
});
1791+
assert.strictEqual(resolveVenvStub.callCount, 0);
1792+
sinon.assert.calledOnceWithExactly(listener, {
1793+
uri,
1794+
old: oldEnvironment,
1795+
new: selectedEnvironment,
1796+
});
1797+
});
1798+
17571799
test('unsets a persisted association after transient rehydration failure', async () => {
17581800
const uri = scriptUri();
17591801
const environment = await createOwnedEnvironment();

0 commit comments

Comments
 (0)