Repository: posit-dev/positron
Pull request #7337: correctly dispose of PythonRuntimeManager
Author: login: austin3dickey
Created: 2025-04-22T19:44:31Z | Merged: 2025-04-25T15:11:09Z

<!-- Thank you for submitting a pull request.
If this is your first pull request you can find information about
contributing here:
  * https://github.com/posit-dev/positron/blob/main/CONTRIBUTING.md

We recommend synchronizing your branch with the latest changes in the
main branch by either pulling or rebasing.
-->

<!--
  Describe briefly what problem this pull request resolves, or what
  new feature it introduces. Include screenshots of any new or altered
  UI. Link to any GitHub issues but avoid "magic" keywords that will
  automatically close the issue. If there are any details about your
  approach that are unintuitive or you want to draw attention to, please
  describe them here.
-->
Addresses #7206. There is a lot of discussion in that issue for context.

Basically, I think what was happening is that the `PythonRuntimeManager` was not being correctly disposed when the user trusts a workspace and everything gets deactivated/activated again. After this change, it does get disposed during that flow, and I can't reproduce the bug anymore.

### Release Notes

<!--
  Optionally, replace `N/A` with text to be included in the next release notes.
  The `N/A` bullets are ignored. If you refer to one or more Positron issues,
  these issues are used to collect information about the feature or bugfix, such
  as the relevant language pack as determined by Github labels of type `lang: `.
  The note will automatically be tagged with the language.

  These notes are typically filled by the Positron team. If you are an external
  contributor, you may ignore this section.
-->

#### New Features

- N/A

#### Bug Fixes

- Fixed bug in Python startup (#7206)


### QA Notes

1. Create a new project. I used uv:
```
uv init proj
cd proj
uv venv
```
2. Open a fresh Positron with no state, but with PET and multiconsole on. I do this with the following:
```
rm -rf ~/.vscode-oss-dev
rm -rf ~/.positron-dev
mkdir -p ~/.vscode-oss-dev/User
echo '{"console.multipleConsoleSessions":true,"python.locator":"native"}' > ~/.vscode-oss-dev/User/settings.json
```
4. No workspace should be selected. It should start "Discovering interpreters".
5. Open Folder to the `proj` workspace
6. Click "trust"
7. The uv interpreter should successfully open a session.

Maybe try this a few times at varying levels of speed.


Diff:
diff --git a/extensions/positron-python/src/client/positron/manager.ts b/extensions/positron-python/src/client/positron/manager.ts
index b8c603fb5e6c..e60d492034a0 100644
--- a/extensions/positron-python/src/client/positron/manager.ts
+++ b/extensions/positron-python/src/client/positron/manager.ts
@@ -11,14 +11,21 @@ import * as vscode from 'vscode';
 import * as path from 'path';
 import * as os from 'os';
 
-import { Event, EventEmitter } from 'vscode';
+import { Event, EventEmitter, Disposable } from 'vscode';
 import { inject, injectable } from 'inversify';
 import * as fs from '../common/platform/fs-paths';
 import { IServiceContainer } from '../ioc/types';
 import { pythonRuntimeDiscoverer } from './discoverer';
 import { IInterpreterService } from '../interpreter/contracts';
 import { traceError, traceInfo, traceLog } from '../logging';
-import { IConfigurationService, IDisposable, IInstaller, InstallerResponse, Product } from '../common/types';
+import {
+    IConfigurationService,
+    IDisposable,
+    IDisposableRegistry,
+    IInstaller,
+    InstallerResponse,
+    Product,
+} from '../common/types';
 import { PythonRuntimeSession } from './session';
 import { createPythonRuntimeMetadata, PythonRuntimeExtraData } from './runtime';
 import { Commands, EXTENSION_ROOT_DIR } from '../common/constants';
@@ -51,7 +58,7 @@ export interface IPythonRuntimeManager extends positron.LanguageRuntimeManager {
  * implements positron.LanguageRuntimeManager.
  */
 @injectable()
-export class PythonRuntimeManager implements IPythonRuntimeManager, vscode.Disposable {
+export class PythonRuntimeManager implements IPythonRuntimeManager, Disposable {
     /**
      * A map of Python interpreter paths to their language runtime metadata.
      */
@@ -74,9 +81,11 @@ export class PythonRuntimeManager implements IPythonRuntimeManager, vscode.Dispo
         @inject(IServiceContainer) private readonly serviceContainer: IServiceContainer,
         @inject(IInterpreterService) private readonly interpreterService: IInterpreterService,
     ) {
-        positron.runtime.registerLanguageRuntimeManager('python', this);
+        const disposables = this.serviceContainer.get<Disposable[]>(IDisposableRegistry);
+        disposables.push(this);
 
         this.disposables.push(
+            positron.runtime.registerLanguageRuntimeManager('python', this),
             // When an interpreter is added, register a corresponding language runtime.
             interpreterService.onDidChangeInterpreters(async (event) => {
                 if (!event.old && event.new) {
diff --git a/extensions/positron-python/src/test/positron/manager.unit.test.ts b/extensions/positron-python/src/test/positron/manager.unit.test.ts
index 535e824c85b1..31100f34b102 100644
--- a/extensions/positron-python/src/test/positron/manager.unit.test.ts
+++ b/extensions/positron-python/src/test/positron/manager.unit.test.ts
@@ -20,7 +20,12 @@ import * as interpreterSettings from '../../client/positron/interpreterSettings'
 import * as environmentTypeComparer from '../../client/interpreter/configuration/environmentTypeComparer';
 import * as util from '../../client/positron/util';
 import { IEnvironmentVariablesProvider } from '../../client/common/variables/types';
-import { IConfigurationService, IDisposable, InspectInterpreterSettingType } from '../../client/common/types';
+import {
+    IConfigurationService,
+    IDisposable,
+    IDisposableRegistry,
+    InspectInterpreterSettingType,
+} from '../../client/common/types';
 import { IServiceContainer } from '../../client/ioc/types';
 import { PythonRuntimeManager } from '../../client/positron/manager';
 import { IInterpreterService } from '../../client/interpreter/contracts';
@@ -38,6 +43,7 @@ suite('Python runtime manager', () => {
     let interpreterService: TypeMoq.IMock<IInterpreterService>;
     let serviceContainer: TypeMoq.IMock<IServiceContainer>;
     let workspaceConfig: TypeMoq.IMock<WorkspaceConfiguration>;
+    let disposableRegistry: TypeMoq.IMock<IDisposableRegistry>;
 
     let getConfigurationStub: sinon.SinonStub;
     let isVersionSupportedStub: sinon.SinonStub;
@@ -53,6 +59,7 @@ suite('Python runtime manager', () => {
         interpreterService = createTypeMoq<IInterpreterService>();
         serviceContainer = createTypeMoq<IServiceContainer>();
         workspaceConfig = createTypeMoq<WorkspaceConfiguration>();
+        disposableRegistry = createTypeMoq<IDisposableRegistry>();
 
         runtimeMetadata.setup((r) => r.runtimeId).returns(() => 'runtimeId');
         runtimeMetadata.setup((r) => r.extraRuntimeData).returns(() => ({ pythonPath }));
@@ -64,6 +71,7 @@ suite('Python runtime manager', () => {
         serviceContainer.setup((s) => s.get(IConfigurationService)).returns(() => configService.object);
         serviceContainer.setup((s) => s.get(IEnvironmentVariablesProvider)).returns(() => envVarsProvider.object);
         serviceContainer.setup((s) => s.get(IInterpreterService)).returns(() => interpreterService.object);
+        serviceContainer.setup((s) => s.get(IDisposableRegistry)).returns(() => disposableRegistry.object);
 
         getConfigurationStub = sinon.stub(workspaceApis, 'getConfiguration');
         getConfigurationStub.callsFake((section?: string, _scope?: any) => {
@@ -191,6 +199,7 @@ suite('Python runtime manager - recommendedWorkspaceRuntime', () => {
     let interpreterService: TypeMoq.IMock<IInterpreterService>;
     let interpreter: TypeMoq.IMock<PythonEnvironment>;
     let runtimeMetadata: positron.LanguageRuntimeMetadata;
+    let disposableRegistry: TypeMoq.IMock<IDisposableRegistry>;
 
     let getUserDefaultInterpreterStub: sinon.SinonStub;
     let hasFilesStub: sinon.SinonStub;
@@ -201,9 +210,11 @@ suite('Python runtime manager - recommendedWorkspaceRuntime', () => {
         serviceContainer = createTypeMoq<IServiceContainer>();
         interpreterService = createTypeMoq<IInterpreterService>();
         interpreter = createTypeMoq<PythonEnvironment>();
+        disposableRegistry = createTypeMoq<IDisposableRegistry>();
 
         // Setup interpreter service
         serviceContainer.setup((s) => s.get(IInterpreterService)).returns(() => interpreterService.object);
+        serviceContainer.setup((s) => s.get(IDisposableRegistry)).returns(() => disposableRegistry.object);
 
         getUserDefaultInterpreterStub = sinon.stub(interpreterSettings, 'getUserDefaultInterpreter');
         hasFilesStub = sinon.stub(util, 'hasFiles');
diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/positron/runtime.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/positron/runtime.test.ts
index bb832eed695e..008c409a7525 100644
--- a/extensions/vscode-api-tests/src/singlefolder-tests/positron/runtime.test.ts
+++ b/extensions/vscode-api-tests/src/singlefolder-tests/positron/runtime.test.ts
@@ -299,14 +299,11 @@ suite('positron API - runtime', () => {
 
 		managerDisposable.dispose();
 
-		// TODO: Unregistering a manager unregisters its runtimes, but doesn't remove them from
-		//       the list returned by positron.runtime.getRegisteredRuntimes. Is that a bug?
-		//       It also means that this test will currently fail if run out of order.
-		// await poll(
-		// 	getRegisteredRuntimes,
-		// 	(runtimes) => runtimes.length === 0,
-		// 	'test runtimes should be unregistered',
-		// );
+		await poll(
+			getRegisteredRuntimes,
+			(runtimes) => runtimes.length === 0,
+			'test runtimes should be unregistered',
+		);
 	});
 
 });
@@ -585,7 +582,7 @@ suite('positron API - executeCode', () => {
 			positron.runtime.onDidExecuteCode((e) => {
 				event = e;
 			})
-		)
+		);
 
 		// Execute the code
 		await positron.runtime.executeCode(
diff --git a/src/vs/workbench/api/common/positron/extHostLanguageRuntime.ts b/src/vs/workbench/api/common/positron/extHostLanguageRuntime.ts
index 55a90e6b623a..dd0a3f7a2561 100644
--- a/src/vs/workbench/api/common/positron/extHostLanguageRuntime.ts
+++ b/src/vs/workbench/api/common/positron/extHostLanguageRuntime.ts
@@ -1073,6 +1073,17 @@ export class ExtHostLanguageRuntime implements extHostProtocol.ExtHostLanguageRu
 			if (index >= 0) {
 				this._runtimeManagers.splice(index, 1);
 			}
+
+			// Remove its associated runtimes
+			this._runtimeManagersByRuntimeId.forEach((m, runtimeId) => {
+				if (m.manager === manager) {
+					this._runtimeManagersByRuntimeId.delete(runtimeId);
+					const index = this._registeredRuntimes.findIndex(runtime => runtime.runtimeId === runtimeId);
+					if (index >= 0) {
+						this._registeredRuntimes.splice(index, 1);
+					}
+				}
+			});
 		});
 	}
 


Reviews:
user: login: seeM
body: LGTM, great catch! Failing unit test might need `positron.runtime.registerLanguageRuntimeManager` to be mocked.

If this relies on the runtime manager being disposed, could the todo below be an issue? I'm not actually sure if that's still a bug or if it's been fixed.

https://github.com/posit-dev/positron/blob/69407dbc9fea626d595f90ebf0cccbf5a7c9c44d/extensions/vscode-api-tests/src/singlefolder-tests/positron/runtime.test.ts#L302-L309
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T10:47:24Z
user: login: seeM
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T16:24:01Z
user: login: austin3dickey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T18:00:45Z
user: login: isabelizimm
body: Noting that this does not seem to solve #7141, which is failing in the same, or at least very similar, way where `resolveEnv` does not resolve in the native locator. 

https://github.com/posit-dev/positron/blob/8f94cae7969a13ee9f396f0c05156d7ef49a1586/extensions/positron-python/src/client/pythonEnvironments/legacyIOC.ts#L166

(This is still probably a step in the right direction and should be merged, but wanted to connect these issues in case its useful context later!)
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T21:12:04Z
user: login: seeM
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T09:31:57Z
user: login: seeM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T09:32:09Z
user: login: austin3dickey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T17:01:52Z

Review comments:
diff_hunk: @@ -299,14 +299,11 @@ suite('positron API - runtime', () => {
 
 		managerDisposable.dispose();
 
-		// TODO: Unregistering a manager unregisters its runtimes, but doesn't remove them from
-		//       the list returned by positron.runtime.getRegisteredRuntimes. Is that a bug?
-		//       It also means that this test will currently fail if run out of order.
-		// await poll(
-		// 	getRegisteredRuntimes,
-		// 	(runtimes) => runtimes.length === 0,
-		// 	'test runtimes should be unregistered',
-		// );
+		await poll(
+			getRegisteredRuntimes,
+			(runtimes) => runtimes.length === 0,
+			'test runtimes should be unregistered',
+		);
path: extensions/vscode-api-tests/src/singlefolder-tests/positron/runtime.test.ts
user: login: austin3dickey
body: Okay @seeM I looked into this a bit and fixed the bug, below. I'm not sure it would have affected anything but it seems like a good change. Let me know what you think.
created_at: 2025-04-23T18:00:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -299,14 +299,11 @@ suite('positron API - runtime', () => {
 
 		managerDisposable.dispose();
 
-		// TODO: Unregistering a manager unregisters its runtimes, but doesn't remove them from
-		//       the list returned by positron.runtime.getRegisteredRuntimes. Is that a bug?
-		//       It also means that this test will currently fail if run out of order.
-		// await poll(
-		// 	getRegisteredRuntimes,
-		// 	(runtimes) => runtimes.length === 0,
-		// 	'test runtimes should be unregistered',
-		// );
+		await poll(
+			getRegisteredRuntimes,
+			(runtimes) => runtimes.length === 0,
+			'test runtimes should be unregistered',
+		);
path: extensions/vscode-api-tests/src/singlefolder-tests/positron/runtime.test.ts
user: login: seeM
body: Code looks good to me, thanks! Does the test pass locally? Open VSX is still down.
created_at: 2025-04-24T09:31:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -299,14 +299,11 @@ suite('positron API - runtime', () => {
 
 		managerDisposable.dispose();
 
-		// TODO: Unregistering a manager unregisters its runtimes, but doesn't remove them from
-		//       the list returned by positron.runtime.getRegisteredRuntimes. Is that a bug?
-		//       It also means that this test will currently fail if run out of order.
-		// await poll(
-		// 	getRegisteredRuntimes,
-		// 	(runtimes) => runtimes.length === 0,
-		// 	'test runtimes should be unregistered',
-		// );
+		await poll(
+			getRegisteredRuntimes,
+			(runtimes) => runtimes.length === 0,
+			'test runtimes should be unregistered',
+		);
path: extensions/vscode-api-tests/src/singlefolder-tests/positron/runtime.test.ts
user: login: austin3dickey
body: Yep the whole test file passes locally! I won't merge until it's up again, though
created_at: 2025-04-24T17:01:51Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-22T19:44:50Z
body: <!-- PR Tags -->
<!-- 
🚨 RED ALERT! ✋ Rule breaker detected! Tags don’t go here, they belong above ^ in the PR description using this proper format: `@:tag`. Changing them here won't do anything (trust us, we’ve tried). Confused? Check out the README hyperlink.
-->

**E2E Tests** 🚀
This PR will run tests tagged with: `@:critical`

<sup>[readme](https://github.com/posit-dev/positron/blob/main/test/e2e/README.md#pull-requests-and-test-tags)</sup>&nbsp;&nbsp;<sup>[valid tags](https://github.com/posit-dev/positron/blob/main/test/e2e/infra/test-runner/test-tags.ts)</sup>
author_association: NONE
user: login: austin3dickey
created_at: 2025-04-23T11:56:37Z
body: >If this relies on the runtime manager being disposed,

I'm not actually positive this fix does rely on the manager being disposed; this is basically the state of my codebase when the bug stopped being reproducible. Let me play around a little.
author_association: CONTRIBUTOR
user: login: austin3dickey
created_at: 2025-04-23T12:04:30Z
body: Yeah, the bug reappears when I revert either one of the two changes (disposing the instance or disposing the registration of the instance). So I'll fix the test with a mock and check out the TODO.
author_association: CONTRIBUTOR
user: login: austin3dickey
created_at: 2025-04-25T15:11:04Z
body: The failing tests look unrelated (`Positron Assistant Chat Editing`). I'll go ahead and merge.
author_association: CONTRIBUTOR