Skip to content

Commit f61652b

Browse files
edvilmeCopilot
andcommitted
fix: address scoped package manager lifecycle feedback
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 338bbb0 commit f61652b

7 files changed

Lines changed: 221 additions & 19 deletions

File tree

‎src/features/envManagers.ts‎

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
175175
private _projectPackageManagers: Map<string, InternalPackageManager> = new Map();
176176
private readonly _packageManagerEventSubscriptions = new Map<
177177
string,
178-
Map<Event<DidChangePackagesEventArgs>, Disposable>
178+
Map<Event<DidChangePackagesEventArgs>, { disposable: Disposable; references: number }>
179179
>();
180180
private readonly subscriptions: Disposable[] = [];
181181

@@ -235,6 +235,15 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
235235
private readonly pm: PythonProjectManager,
236236
private readonly inlineScriptRouting?: InlineScriptRoutingRegistry,
237237
) {
238+
const projectChanges = this.pm.onDidChangeProjects;
239+
if (projectChanges) {
240+
const subscription = projectChanges((projects) =>
241+
this.evictRemovedProjectPackageManagers(projects ?? this.pm.getProjects()),
242+
);
243+
if (subscription) {
244+
this.subscriptions.push(subscription);
245+
}
246+
}
238247
if (this.inlineScriptRouting) {
239248
this.subscriptions.push(
240249
this.inlineScriptRouting.onDidChangeRouteability((e) => {
@@ -328,6 +337,7 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
328337
for (const [key, scopedManager] of this._projectPackageManagers) {
329338
if (scopedManager.id === managerId) {
330339
this._projectPackageManagers.delete(key);
340+
this.unsubscribeFromPackageManagerEvents(scopedManager);
331341
}
332342
}
333343
this.disposePackageManagerEventSubscriptions(managerId);
@@ -485,32 +495,72 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
485495
subscriptions = new Map();
486496
this._packageManagerEventSubscriptions.set(provider.id, subscriptions);
487497
}
488-
if (subscriptions.has(event)) {
498+
const existing = subscriptions.get(event);
499+
if (existing) {
500+
existing.references += 1;
489501
return;
490502
}
491503

492504
subscriptions.set(
493505
event,
494-
event((e) => {
495-
this._onDidChangePackageProviderPackages.fire(e);
496-
const eventManager =
497-
Array.from(this._projectPackageManagers.values()).find(
498-
(candidate) => candidate.id === provider.id && candidate.wraps(e.manager),
499-
) ?? provider;
500-
setImmediate(() =>
501-
this._onDidChangePackages.fire({
502-
environment: e.environment,
503-
manager: eventManager,
504-
changes: e.changes,
505-
}),
506-
);
507-
}),
506+
{
507+
references: 1,
508+
disposable: event((e) => {
509+
this._onDidChangePackageProviderPackages.fire(e);
510+
const eventManager =
511+
Array.from(this._projectPackageManagers.values()).find(
512+
(candidate) => candidate.id === provider.id && candidate.wraps(e.manager),
513+
) ?? provider;
514+
setImmediate(() =>
515+
this._onDidChangePackages.fire({
516+
environment: e.environment,
517+
manager: eventManager,
518+
changes: e.changes,
519+
}),
520+
);
521+
}),
522+
},
508523
);
509524
}
510525

526+
private unsubscribeFromPackageManagerEvents(manager: InternalPackageManager): void {
527+
const event = manager.packageChangeEvent;
528+
if (!event) {
529+
return;
530+
}
531+
532+
const subscriptions = this._packageManagerEventSubscriptions.get(manager.id);
533+
const subscription = subscriptions?.get(event);
534+
if (!subscription) {
535+
return;
536+
}
537+
538+
subscription.references -= 1;
539+
if (subscription.references === 0) {
540+
subscription.disposable.dispose();
541+
subscriptions?.delete(event);
542+
if (subscriptions?.size === 0) {
543+
this._packageManagerEventSubscriptions.delete(manager.id);
544+
}
545+
}
546+
}
547+
548+
private evictRemovedProjectPackageManagers(projects: readonly PythonProject[]): void {
549+
const projectsByPath = new Map(
550+
projects.map((project) => [normalizePath(project.uri.fsPath), project] as const),
551+
);
552+
for (const [key, manager] of this._projectPackageManagers) {
553+
const projectPath = manager.project && normalizePath(manager.project.uri.fsPath);
554+
if (!projectPath || projectsByPath.get(projectPath) !== manager.project) {
555+
this._projectPackageManagers.delete(key);
556+
this.unsubscribeFromPackageManagerEvents(manager);
557+
}
558+
}
559+
}
560+
511561
private disposePackageManagerEventSubscriptions(managerId: string): void {
512562
const subscriptions = this._packageManagerEventSubscriptions.get(managerId);
513-
subscriptions?.forEach((subscription) => subscription.dispose());
563+
subscriptions?.forEach(({ disposable }) => disposable.dispose());
514564
this._packageManagerEventSubscriptions.delete(managerId);
515565
}
516566

‎src/managers/common/packageWatcher.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,22 @@ export function registerPackageWatchers(
188188
const terminalActivationDisposable = terminalActivation.onDidChangeTerminalActivationState((changes) => {
189189
if (changes.activated) {
190190
if (!closedTerminals.has(changes.terminal)) {
191-
watchEnvironment(changes.terminal, changes.environment, changes.environment);
191+
const projectScope = Array.from(activeEnvironmentByScope.values()).find(
192+
({ scope, environment }) =>
193+
scope &&
194+
environment.envId.id === changes.environment.envId.id &&
195+
environment.envId.managerId === changes.environment.envId.managerId,
196+
)?.scope;
197+
const managerContext = projectScope ?? changes.environment;
198+
const packageManager = envManagers.getPackageManager(managerContext);
199+
if (!projectScope && packageManager?.supportsProjectBinding) {
200+
releaseConsumer(changes.terminal);
201+
log.debug(
202+
`Skipping unscoped package watcher for project-aware manager ${packageManager.id}`,
203+
);
204+
return;
205+
}
206+
watchEnvironment(changes.terminal, managerContext, changes.environment);
192207
}
193208
} else {
194209
releaseConsumer(changes.terminal);

‎src/managers/common/registeredManagers.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,10 @@ export class InternalPackageManager implements PackageManager {
291291
return this.manager.onDidChangePackages;
292292
}
293293

294+
get supportsProjectBinding(): boolean {
295+
return this.manager.createForProject !== undefined;
296+
}
297+
294298
wraps(other: PackageManager): boolean {
295299
return this.manager === other;
296300
}

‎src/managers/poetry/poetryPackageManager.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export class PoetryPackageManager implements PackageManager, Disposable {
7878
}
7979

8080
async manage(environment: PythonEnvironment, options: PackageManagementOptions): Promise<void> {
81+
const cwd = await this.getProjectCwd();
8182
let toInstall: string[] = [...(options.install ?? [])];
8283
let toUninstall: string[] = [...(options.uninstall ?? [])];
8384

@@ -104,7 +105,6 @@ export class PoetryPackageManager implements PackageManager, Disposable {
104105
}
105106
}
106107

107-
const cwd = await this.getProjectCwd();
108108
const execute = async (token?: CancellationToken): Promise<void> => {
109109
try {
110110
await this.runPoetryManage({ install: toInstall, uninstall: toUninstall }, cwd, token);

‎src/test/features/packageManager.api.unit.test.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ suite('PythonPackageManagerApi Tests', () => {
4848
let environment: typeMoq.IMock<PythonEnvironment>;
4949
let packageManager: typeMoq.IMock<PackageManager>;
5050
let onDidChangePackagesEmitter: EventEmitter<DidChangePackagesEventArgs>;
51+
let projectChangesEmitter: EventEmitter<PythonProject[] | undefined>;
5152
let getExtensionStub: sinon.SinonStub;
5253

5354
setup(() => {
@@ -71,6 +72,9 @@ suite('PythonPackageManagerApi Tests', () => {
7172

7273
// Mock project manager
7374
projectManager = typeMoq.Mock.ofType<PythonProjectManager>();
75+
projectChangesEmitter = new EventEmitter<PythonProject[] | undefined>();
76+
projectManager.setup((pm) => pm.getProjects()).returns(() => []);
77+
projectManager.setup((pm) => pm.onDidChangeProjects).returns(() => projectChangesEmitter.event);
7478
setupNonThenable(projectManager);
7579

7680
// Create environment managers instance
@@ -96,6 +100,7 @@ suite('PythonPackageManagerApi Tests', () => {
96100
sinon.restore();
97101
envManagers.dispose();
98102
onDidChangePackagesEmitter.dispose();
103+
projectChangesEmitter.dispose();
99104
});
100105

101106
/**
@@ -806,5 +811,65 @@ suite('PythonPackageManagerApi Tests', () => {
806811

807812
assert.strictEqual(envManagers.getPackageManager(project.uri), registeredManager);
808813
});
814+
815+
test('Should evict scoped package managers when their project is removed', async () => {
816+
disposable.dispose();
817+
const projectUri = Uri.file(path.join(process.cwd(), 'removed-project'));
818+
let currentProject = { name: 'original', uri: projectUri } as PythonProject;
819+
projectManager.setup((pm) => pm.get(projectUri)).returns(() => currentProject);
820+
const scopedManagers: PackageManager[] = [];
821+
const scopedEmitters: EventEmitter<DidChangePackagesEventArgs>[] = [];
822+
const provider: PackageManager = {
823+
name: 'project-pkg-mgr',
824+
manage: async () => undefined,
825+
refresh: async () => undefined,
826+
getPackages: async () => [],
827+
createForProject: () => {
828+
const emitter = new EventEmitter<DidChangePackagesEventArgs>();
829+
const scopedManager: PackageManager = {
830+
name: 'project-pkg-mgr',
831+
manage: async () => undefined,
832+
refresh: async () => undefined,
833+
getPackages: async () => [],
834+
onDidChangePackages: emitter.event,
835+
};
836+
scopedEmitters.push(emitter);
837+
scopedManagers.push(scopedManager);
838+
return scopedManager;
839+
},
840+
};
841+
disposable = envManagers.registerPackageManager(provider);
842+
const registeredManager = envManagers.packageManagers[0];
843+
sinon.stub(workspaceApis, 'getConfiguration').returns({
844+
get: (section: string, defaultValue?: unknown) =>
845+
section === 'defaultPackageManager' ? registeredManager.id : defaultValue,
846+
} as WorkspaceConfiguration);
847+
const events: unknown[] = [];
848+
const eventDisposable = envManagers.onDidChangePackages((event) => events.push(event));
849+
850+
const original = envManagers.getPackageManager(projectUri);
851+
currentProject = { name: 'replacement', uri: projectUri } as PythonProject;
852+
projectChangesEmitter.fire([currentProject]);
853+
const replacement = envManagers.getPackageManager(projectUri);
854+
855+
assert.notStrictEqual(original, replacement);
856+
assert.strictEqual(scopedManagers.length, 2);
857+
assert.strictEqual(replacement?.project, currentProject);
858+
859+
const packageChange = {
860+
environment: environment.object,
861+
changes: [],
862+
};
863+
scopedEmitters[0].fire({ ...packageChange, manager: scopedManagers[0] });
864+
await new Promise<void>((resolve) => setImmediate(resolve));
865+
assert.strictEqual(events.length, 0);
866+
867+
scopedEmitters[1].fire({ ...packageChange, manager: scopedManagers[1] });
868+
await new Promise<void>((resolve) => setImmediate(resolve));
869+
assert.strictEqual(events.length, 1);
870+
871+
eventDisposable.dispose();
872+
scopedEmitters.forEach((emitter) => emitter.dispose());
873+
});
809874
});
810875
});

‎src/test/managers/common/packageWatcher.unit.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,61 @@ suite('Package Watcher', () => {
538538
assert.strictEqual(createFileSystemWatcherStub.callCount, 1);
539539
});
540540

541+
test('should reuse a project-scoped watcher for terminal activation', () => {
542+
createFileSystemWatcherStub.returns(createMockWatcher());
543+
const environmentChanges = new EventEmitter<DidChangeEnvironmentEventArgs>();
544+
const scope = Uri.file('workspace');
545+
const project = { name: 'project', uri: scope } as PythonProject;
546+
const rootManager = new InternalPackageManager('poetry', {
547+
...createMockPackageManager(),
548+
createForProject: () => createMockPackageManager() as PackageManager,
549+
} as PackageManager);
550+
const scopedManager = new InternalPackageManager(
551+
'poetry',
552+
createMockPackageManager() as PackageManager,
553+
project,
554+
);
555+
const env = createMockEnvironment();
556+
const terminal = { name: 'terminal' } as Terminal;
557+
const envManagers = {
558+
onDidChangeActiveEnvironment: environmentChanges.event,
559+
getPackageManager: sandbox
560+
.stub()
561+
.callsFake((context) => (context instanceof Uri ? scopedManager : rootManager)),
562+
} as unknown as EnvironmentManagers;
563+
564+
registerPackageWatchers(envManagers, mockTerminalActivation, mockLogOutputChannel as LogOutputChannel);
565+
environmentChanges.fire({ uri: scope, new: env, old: undefined });
566+
terminalActivationChanges.fire({ terminal, environment: env, activated: true });
567+
568+
assert.strictEqual(createFileSystemWatcherStub.callCount, 1);
569+
assert.ok((envManagers.getPackageManager as sinon.SinonStub).calledWith(scope));
570+
});
571+
572+
test('should skip terminal-only watchers for project-aware package managers', () => {
573+
const environmentChanges = new EventEmitter<DidChangeEnvironmentEventArgs>();
574+
const packageManager = new InternalPackageManager('poetry', {
575+
...createMockPackageManager(),
576+
createForProject: () => createMockPackageManager() as PackageManager,
577+
} as PackageManager);
578+
const env = createMockEnvironment();
579+
const terminal = { name: 'terminal' } as Terminal;
580+
const envManagers = {
581+
onDidChangeActiveEnvironment: environmentChanges.event,
582+
getPackageManager: sandbox.stub().returns(packageManager),
583+
} as unknown as EnvironmentManagers;
584+
585+
registerPackageWatchers(envManagers, mockTerminalActivation, mockLogOutputChannel as LogOutputChannel);
586+
terminalActivationChanges.fire({ terminal, environment: env, activated: true });
587+
588+
assert.strictEqual(createFileSystemWatcherStub.callCount, 0);
589+
assert.ok(
590+
(mockLogOutputChannel.debug as sinon.SinonStub).calledWith(
591+
'Skipping unscoped package watcher for project-aware manager poetry',
592+
),
593+
);
594+
});
595+
541596
test('should release a terminal environment watcher when the terminal closes', () => {
542597
const mockWatcher = createMockWatcher();
543598
createFileSystemWatcherStub.returns(mockWatcher);

‎src/test/managers/poetry/poetryPackageManager.unit.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,19 @@ suite('PoetryPackageManager', () => {
9797
assert.strictEqual(runPoetryStub.callCount, 0);
9898
});
9999

100+
test('unbound package management rejects before no-op exits or prompts', async () => {
101+
const showInputBox = sinon.stub(windowApis, 'showInputBox');
102+
103+
await assert.rejects(
104+
manager.manage(environment, { install: [], runHeadless: true }),
105+
/require a Python project/,
106+
);
107+
await assert.rejects(manager.manage(environment, { install: [] }), /require a Python project/);
108+
109+
assert.ok(showInputBox.notCalled);
110+
assert.strictEqual(runPoetryStub.callCount, 0);
111+
});
112+
100113
test('refresh rejects operations without a project', async () => {
101114
await assert.rejects(manager.refresh(environment), /require a Python project/);
102115
assert.strictEqual(runPoetryStub.callCount, 0);

0 commit comments

Comments
 (0)