Repository: posit-dev/positron
Pull request #6844: Fix `positron-r` LSP race conditions
Author: login: samclark2015
Created: 2025-03-18T17:26:54Z | Merged: 2025-03-18T18:52:24Z

When session changes, positron-r waits for all LSPs to shut down before starting the new session's.

Defer LSP from starting on session start, instead awaiting for it to become the foreground session.

<!-- 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.
-->


### 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

- Duplicate outlines no longer shown for R runtimes when starting or switching sessions


### QA Notes

<!--
  Add additional information for QA on how to validate the change,
  paying special attention to the level of risk, adjacent areas that
  could be affected by the change, and any important contextual
  information not present in the linked issues.
-->
e2e: @:console @:sessions



Diff:
diff --git a/extensions/positron-r/src/session-manager.ts b/extensions/positron-r/src/session-manager.ts
index ed5cf13ae77f..1ca038082157 100644
--- a/extensions/positron-r/src/session-manager.ts
+++ b/extensions/positron-r/src/session-manager.ts
@@ -27,15 +27,22 @@ export class RSessionManager {
 			if (sessionId) {
 				const session = this._sessions.get(sessionId);
 				if (session) {
-					// Start LSP for the foreground session
-					session.activateLsp();
-
 					// Stop LSPs for other console sessions
-					this._sessions.forEach(s => {
-						if (s.metadata.sessionId !== sessionId &&
-							s.metadata.sessionMode === positron.LanguageRuntimeSessionMode.Console) {
-							s.deactivateLsp();
-						}
+					const stopPromises = Array.from(this._sessions)
+						// Convert RSession to Promise to stop LSP
+						.map(([, s]) => {
+							if (s.metadata.sessionId !== sessionId &&
+								s.metadata.sessionMode === positron.LanguageRuntimeSessionMode.Console) {
+								return s.deactivateLsp();
+							}
+						})
+						// Remove undefined values
+						.filter(p => p !== undefined);
+
+					// Wait for all LSPs to stop before starting the foreground
+					Promise.all(stopPromises).then(async () => {
+						// Start LSP for the foreground session
+						await session.activateLsp();
 					});
 				}
 			}
diff --git a/extensions/positron-r/src/session.ts b/extensions/positron-r/src/session.ts
index 159755f9126e..0651ad8490d8 100644
--- a/extensions/positron-r/src/session.ts
+++ b/extensions/positron-r/src/session.ts
@@ -699,11 +699,8 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa
 		if (state === positron.RuntimeState.Ready) {
 			// Start the LSP and DAP servers
 			this._queue.add(async () => {
-				const lsp = this.startLsp();
-				this._lspStarting = lsp;
-
 				const dap = this.startDap();
-				await Promise.all([lsp, dap]);
+				await dap;
 			});
 
 			this._queue.add(async () => {
@@ -739,23 +736,31 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa
 
 	public async activateLsp() {
 		// Start LSP for the foreground session only if its been previously stopped
-		if (this._lsp.state === LspState.stopped) {
-			this._queue.add(async () => {
-				const port = await this.adapterApi!.findAvailablePort([], 25);
-				if (this._kernel) {
-					this._kernel.emitJupyterLog(`Starting Positron LSP server on port ${port}`);
-					await this._kernel.startPositronLsp(`127.0.0.1:${port}`);
-				}
-				this._lsp.activate(port, this.context);
+		if (this._lsp.state === LspState.stopped ||
+			this._lsp.state === LspState.uninitialized) {
+			// Use this promise to await this function until
+			// the LSP call has been popped off the queue & executed
+			await new Promise<void>((resolve) => {
+				this._queue.add(async () => {
+					this._lspStarting = this.startLsp();
+					this._lspStarting.then(() => {
+						resolve();
+					});
+				});
 			});
 		}
 	}
 
-	public deactivateLsp() {
+	public async deactivateLsp() {
 		// Stop LSP if it's running
 		if (this._lsp.state === LspState.running) {
-			this._queue.add(async () => {
-				await this._lsp.deactivate(true);
+			// Use this promise to await this function until
+			// the LSP call has been popped off the queue & executed
+			await new Promise<void>((resolve) => {
+				this._queue.add(async () => {
+					await this._lsp.deactivate(true);
+					resolve();
+				});
 			});
 		}
 	}


Reviews:
user: login: samclark2015
body: Added some general explainers to the code.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T17:30:25Z
user: login: juliasilge
body: This is working well and looks good to me. When the Python extension lifts `onDidChangeForegroundSession` up to a session manager (from where it sits now in the session object), it will also need this behavior to wait for LSPs to shut down before starting the new one. cc @seeM 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T18:50:11Z

Review comments:
diff_hunk: @@ -27,15 +27,22 @@ export class RSessionManager {
 			if (sessionId) {
 				const session = this._sessions.get(sessionId);
 				if (session) {
-					// Start LSP for the foreground session
-					session.activateLsp();
-
 					// Stop LSPs for other console sessions
-					this._sessions.forEach(s => {
-						if (s.metadata.sessionId !== sessionId &&
-							s.metadata.sessionMode === positron.LanguageRuntimeSessionMode.Console) {
-							s.deactivateLsp();
-						}
+					const stopPromises = Array.from(this._sessions)
+						// Convert RSession to Promise to stop LSP
+						.map(([, s]) => {
+							if (s.metadata.sessionId !== sessionId &&
+								s.metadata.sessionMode === positron.LanguageRuntimeSessionMode.Console) {
+								return s.deactivateLsp();
+							}
+						})
+						// Remove undefined values
+						.filter(p => p !== undefined);
+
+					// Wait for all LSPs to stop before starting the foreground
+					Promise.all(stopPromises).then(async () => {
+						// Start LSP for the foreground session
+						await session.activateLsp();
path: extensions/positron-r/src/session-manager.ts
user: login: samclark2015
body: This modifies things to fully wait for all LSPs to shutdown before starting the new session's back up.
created_at: 2025-03-18T17:28:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -739,23 +736,31 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa
 
 	public async activateLsp() {
 		// Start LSP for the foreground session only if its been previously stopped
-		if (this._lsp.state === LspState.stopped) {
-			this._queue.add(async () => {
-				const port = await this.adapterApi!.findAvailablePort([], 25);
-				if (this._kernel) {
-					this._kernel.emitJupyterLog(`Starting Positron LSP server on port ${port}`);
-					await this._kernel.startPositronLsp(`127.0.0.1:${port}`);
-				}
-				this._lsp.activate(port, this.context);
+		if (this._lsp.state === LspState.stopped ||
+			this._lsp.state === LspState.uninitialized) {
+			// Use this promise to await this function until
+			// the LSP call has been popped off the queue & executed
+			await new Promise<void>((resolve) => {
+				this._queue.add(async () => {
+					this._lspStarting = this.startLsp();
path: extensions/positron-r/src/session.ts
user: login: samclark2015
body: Use existing logic in `startLsp` call.
created_at: 2025-03-18T17:29:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -739,23 +736,31 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa
 
 	public async activateLsp() {
 		// Start LSP for the foreground session only if its been previously stopped
-		if (this._lsp.state === LspState.stopped) {
-			this._queue.add(async () => {
-				const port = await this.adapterApi!.findAvailablePort([], 25);
-				if (this._kernel) {
-					this._kernel.emitJupyterLog(`Starting Positron LSP server on port ${port}`);
-					await this._kernel.startPositronLsp(`127.0.0.1:${port}`);
-				}
-				this._lsp.activate(port, this.context);
+		if (this._lsp.state === LspState.stopped ||
+			this._lsp.state === LspState.uninitialized) {
+			// Use this promise to await this function until
+			// the LSP call has been popped off the queue & executed
+			await new Promise<void>((resolve) => {
+				this._queue.add(async () => {
+					this._lspStarting = this.startLsp();
+					this._lspStarting.then(() => {
+						resolve();
+					});
+				});
 			});
 		}
 	}
 
-	public deactivateLsp() {
+	public async deactivateLsp() {
 		// Stop LSP if it's running
 		if (this._lsp.state === LspState.running) {
-			this._queue.add(async () => {
-				await this._lsp.deactivate(true);
+			// Use this promise to await this function until
+			// the LSP call has been popped off the queue & executed
+			await new Promise<void>((resolve) => {
+				this._queue.add(async () => {
+					await this._lsp.deactivate(true);
+					resolve();
+				});
path: extensions/positron-r/src/session.ts
user: login: samclark2015
body: This promise gets resolved once the call to deactivate the LSP popped from the queue and executed. Same with the above in `activateLsp`
created_at: 2025-03-18T17:30:10Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-18T17:27:10Z
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` `@:console` `@:sessions`

<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: samclark2015
created_at: 2025-03-18T17:28:00Z
body: For context, we no longer should start the LSP upon session start as that will always cause an LSP conflict with other sessions. Instead, we wait for the new session to become foreground, which then kicks off the LSP start in the same way any other session switch would.
author_association: CONTRIBUTOR