Repository: posit-dev/positron
Pull request #7286: Experiment with not timing out RPC calls from PositronComms
Author: login: dfalbel
Created: 2025-04-17T12:33:53Z | Merged: 2025-04-28T17:24:13Z

This adresses https://github.com/posit-dev/positron/issues/6169 and https://github.com/posit-dev/positron/issues/3659 
Related to https://github.com/posit-dev/positron/issues/5954
Different approach to (closes https://github.com/posit-dev/positron/pull/7273)

Currently anything that is backed by a comm will suffer from the following issue:

- User runs a long computation eg `time.sleep(40)`
- While waiting the console, user tries to interact with the UI, triggering RPC calls
- Most RPC's calls tend to timeout because the kernel is executing a long operation and can't handle the RPC messages. (This is a limitation in ipykernel https://github.com/ipython/ipykernel/issues/65, but will also happen with ark if the RPC needs to run R code)
- (Another side problem of this is that, even if the RPC times out, in general it will still be executed once the kernel becomes idle because the RPC is already on the kernel queue)

This happens often because our timeout is small 5s, but there's no good ammount of timeout because we expect users to be running long computations on the kernel, that might take hours. Instead, the UI should be able to handle long asynchronous operations.

This PR makes comms default to never timeout. We may still revisit it. 
We try to be as careful as possible to indentify if the RPC executed and the kernel didn't send any result/error. Eg:
- Python RPC handling has now a `try` block that always sends errors on failing message handling.
- We look at the kernel state messages to see if the RPC has been executed, and raise an error if the RPC is executed but a response never gets back.

Comms can still configure timeouts for specific RPC's that may still want timeouts.

We also added UI handling for longer computations in the Variables Pane by adding a small progress bar to the top. See the preview below:

https://github.com/user-attachments/assets/2e17905d-19cf-4baa-a382-736eebb508b2

### 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

- N/A


### 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.
-->

@:variables
@:data-explorer
@:connections


Diff:
diff --git a/extensions/positron-python/python_files/posit/positron/positron_comm.py b/extensions/positron-python/python_files/posit/positron/positron_comm.py
index bea0dd38fbb3..a924195c89f2 100644
--- a/extensions/positron-python/python_files/posit/positron/positron_comm.py
+++ b/extensions/positron-python/python_files/posit/positron/positron_comm.py
@@ -187,8 +187,14 @@ def _handle_msg(
             callback(comm_msg, raw_msg)
 
         def handle_msg(raw_msg: JsonRecord) -> None:
-            with self.send_lock:
-                _handle_msg(raw_msg)
+            try:
+                with self.send_lock:
+                    _handle_msg(raw_msg)
+            except Exception as exception:
+                self.send_error(
+                    JsonRpcErrorCode.INTERNAL_ERROR,
+                    f"Internal error: {exception}",
+                )
 
         self.comm.on_msg(handle_msg)
 
diff --git a/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts b/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
index 26a2d638bca0..c9cf4abdf935 100644
--- a/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
+++ b/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
@@ -20,7 +20,7 @@ import { IPositronVariablesService } from '../../../services/positronVariables/c
 import { INotificationService } from '../../../../platform/notification/common/notification.js';
 import { ICommandService } from '../../../../platform/commands/common/commands.js';
 import { ILogService } from '../../../../platform/log/common/log.js';
-import { IRuntimeClientInstance, IRuntimeClientOutput, RuntimeClientState, RuntimeClientType } from '../../../services/languageRuntime/common/languageRuntimeClientInstance.js';
+import { IRuntimeClientInstance, IRuntimeClientOutput, RuntimeClientState, RuntimeClientStatus, RuntimeClientType } from '../../../services/languageRuntime/common/languageRuntimeClientInstance.js';
 import { DeferredPromise } from '../../../../base/common/async.js';
 import { generateUuid } from '../../../../base/common/uuid.js';
 import { IPositronPlotsService } from '../../../services/positronPlots/common/positronPlots.js';
@@ -44,6 +44,7 @@ import { IPositronConnectionsService } from '../../../services/positronConnectio
 import { IRuntimeNotebookKernelService } from '../../../contrib/runtimeNotebookKernel/browser/interfaces/runtimeNotebookKernelService.js';
 import { LanguageRuntimeSessionChannel } from '../../common/positron/extHostTypes.positron.js';
 import { basename } from '../../../../base/common/resources.js';
+import { RuntimeOnlineState } from '../../common/extHostTypes.js';
 
 /**
  * Represents a language runtime event (for example a message or state change)
@@ -306,6 +307,9 @@ class ExtHostLanguageRuntimeSessionAdapter implements ILanguageRuntimeSession {
 	}
 
 	emitDidReceiveRuntimeMessageState(languageRuntimeMessageState: ILanguageRuntimeMessageState) {
+		for (const client of this.clientInstances) {
+			client.updatePendingRpcState(languageRuntimeMessageState);
+		}
 		this._onDidReceiveRuntimeMessageStateEmitter.fire(languageRuntimeMessageState);
 	}
 
@@ -959,17 +963,67 @@ class ExtHostLanguageRuntimeSessionAdapter implements ILanguageRuntimeSession {
 	}
 }
 
+enum PendingRpcStatus {
+	/**
+	 * The RPC was sent to the kernel, but the kernel didn't start working on it yet.
+	 */
+	Pending,
+	/**
+	 * The Kernel sent a state message with the RPC ID (as the parent id) and state 'Busy'
+	 * indicating that it has started handling the RPC.
+	 */
+	Executing,
+	/**
+	 * The Kernel sent a state message with the RPC ID (as the parent id) and status 'Idle'
+	 * indicating that it has finished handling the RPC.
+	 * Note: Completed means that the server finished processing the RPC, but it doesn't
+	 * mean that the RPC was successful. The promise may still be rejected.
+	 */
+	Completed,
+	/***
+	 * The Kernel closed before the RPC was completed, or the RPC response never arrived
+	 * within the timeout period.
+	 */
+	Error,
+}
+
 /**
  * Represents a pending RPC call that has been sent to the server side of a
  * comm.
  */
 class PendingRpc<T> {
 	public readonly promise: DeferredPromise<IRuntimeClientOutput<T>>;
+
+	private readonly _onDidChangeStatus = new Emitter<PendingRpcStatus>();
+	private _status: PendingRpcStatus = PendingRpcStatus.Pending;
+
+	/**
+	 * Can be used to check if the RPC is waiting to execute, or if it has already started.
+	 */
+	public readonly onDidChangeStatus: Event<PendingRpcStatus> = this._onDidChangeStatus.event;
+
+
 	constructor(
 		public readonly responseKeys: Array<string>
 	) {
 		this.promise = new DeferredPromise<IRuntimeClientOutput<T>>();
 	}
+
+	public setStatus(status: PendingRpcStatus): void {
+		if (this._status !== status) {
+			this._status = status;
+			this._onDidChangeStatus.fire(status);
+		}
+	}
+
+	public getStatus(): PendingRpcStatus {
+		return this._status;
+	}
+
+	public error(error: Error): void {
+		this.setStatus(PendingRpcStatus.Error);
+		this.promise.error(error);
+	}
 }
 
 /**
@@ -999,6 +1053,11 @@ class ExtHostRuntimeClientInstance<Input, Output>
 	 */
 	public clientState: ISettableObservable<RuntimeClientState>;
 
+	/**
+	 * An observable value that tracks the current status of the client.
+	 */
+	public clientStatus: ISettableObservable<RuntimeClientStatus>;
+
 	constructor(
 		private readonly _id: string,
 		private readonly _type: RuntimeClientType,
@@ -1009,6 +1068,26 @@ class ExtHostRuntimeClientInstance<Input, Output>
 		this.messageCounter = observableValue(`msg-counter-${this._id}`, 0);
 
 		this.clientState = observableValue(`client-state-${this._id}`, RuntimeClientState.Uninitialized);
+		this.clientStatus = observableValue(`client-status-${this._id}`, RuntimeClientStatus.Disconnected);
+
+		const clientStateEvent = Event.fromObservable(this.clientState);
+		this._register(clientStateEvent((state) => {
+			switch (state) {
+				case RuntimeClientState.Connected:
+					if (this._pendingRpcs.size > 0) {
+						this.setClientStatus(RuntimeClientStatus.Busy);
+					} else {
+						this.setClientStatus(RuntimeClientStatus.Idle);
+					}
+					break;
+				case RuntimeClientState.Closed:
+				case RuntimeClientState.Closing:
+				case RuntimeClientState.Uninitialized:
+				case RuntimeClientState.Opening:
+					this.setClientStatus(RuntimeClientStatus.Disconnected);
+					break;
+			}
+		}));
 
 		this.onDidReceiveData = this._dataEmitter.event;
 		this._register(this._dataEmitter);
@@ -1019,19 +1098,46 @@ class ExtHostRuntimeClientInstance<Input, Output>
 	 *
 	 * @param request The request to send to the server.
 	 * @param timeout Timeout in milliseconds after which to error if the server
-	 *   does not respond.
+	 *   does not respond. `undefined` to disable timeouts.
 	 * @param responseKeys Optional list of keys; if specified, at least one
 	 *   must be present in the response. This helps distinguish RPC responses
 	 *   from other messages that have the request as the parent ID.
 	 * @returns A promise that will be resolved with the response from the server.
 	 */
-	performRpcWithBuffers<T>(request: Input, timeout: number, responseKeys: Array<string> = []): Promise<IRuntimeClientOutput<T>> {
+	performRpcWithBuffers<T>(request: Input, timeout: number | undefined, responseKeys: Array<string> = []): Promise<IRuntimeClientOutput<T>> {
 		// Generate a unique ID for this message.
 		const messageId = generateUuid();
 
 		// Add the promise to the list of pending RPCs.
 		const pending = new PendingRpc<T>(responseKeys);
 		this._pendingRpcs.set(messageId, pending);
+		this.setClientStatus(RuntimeClientStatus.Busy);
+
+		// When the status becomes complete, we wait a maximum of 2s for the
+		// promise to be resolved, otherwise we reject it.
+		const statusListener = pending.onDidChangeStatus((status) => {
+			if (status === PendingRpcStatus.Completed) {
+				statusListener.dispose();
+				// Kernels might return the the result of an RPC slightly after the 'Idle' status is sent,
+				// so we need to wait a bit before rejecting the promise.
+				// This currently only happens with Ark/Amalthea. IPykernel always sends the RPC result before
+				// sending the 'Idle' status.
+				const timeout = 2000;
+				setTimeout(() => {
+					if (pending.promise.isSettled) {
+						return;
+					}
+					const timeoutSeconds = Math.round(timeout / 100) / 10;  // round to 1 decimal place
+					pending.error(new Error(`RPC request completed, but response not received after ${timeoutSeconds} seconds: ${JSON.stringify(request)}`));
+					this.deletePendingRpc(messageId);
+				}, timeout);
+			}
+		});
+
+		// When the promise resolves/rejects we dispose of the listener
+		pending.promise.p.finally(() => {
+			statusListener.dispose();
+		});
 
 		// Send the message to the server side.
 		this._proxy.$sendClientMessage(this._handle, this._id, messageId, request);
@@ -1040,17 +1146,19 @@ class ExtHostRuntimeClientInstance<Input, Output>
 		this.messageCounter.set(this.messageCounter.get() + 1, undefined);
 
 		// Start a timeout to reject the promise if the server doesn't respond.
-		setTimeout(() => {
-			// If the promise has already been resolved, do nothing.
-			if (pending.promise.isSettled) {
-				return;
-			}
+		if (timeout) {
+			setTimeout(() => {
+				// If the promise has already been resolved, do nothing.
+				if (pending.promise.isSettled) {
+					return;
+				}
 
-			// Otherwise, reject the promise and remove it from the list of pending RPCs.
-			const timeoutSeconds = Math.round(timeout / 100) / 10;  // round to 1 decimal place
-			pending.promise.error(new Error(`RPC timed out after ${timeoutSeconds} seconds: ${JSON.stringify(request)}`));
-			this._pendingRpcs.delete(messageId);
-		}, timeout);
+				// Otherwise, reject the promise and remove it from the list of pending RPCs.
+				const timeoutSeconds = Math.round(timeout / 100) / 10;  // round to 1 decimal place
+				pending.error(new Error(`RPC timed out after ${timeoutSeconds} seconds: ${JSON.stringify(request)}`));
+				this.deletePendingRpc(messageId);
+			}, timeout);
+		}
 
 		// Return a promise that will be resolved when the server responds.
 		return pending.promise.p;
@@ -1066,6 +1174,34 @@ class ExtHostRuntimeClientInstance<Input, Output>
 		return (await this.performRpcWithBuffers<T>(request, timeout, responseKeys)).data;
 	}
 
+	updatePendingRpcState(message: ILanguageRuntimeMessageState): void {
+		// Check to see if the message is related to a pending RPC.
+		if (this._pendingRpcs.has(message.parent_id)) {
+			const pending = this._pendingRpcs.get(message.parent_id)!;
+			switch (message.state) {
+				case RuntimeOnlineState.Busy:
+					// The server is busy processing the RPC; update the status.
+					pending.setStatus(PendingRpcStatus.Executing);
+					break;
+				case RuntimeOnlineState.Idle:
+					// The server is idle; update the status.
+					pending.setStatus(PendingRpcStatus.Completed);
+					break;
+				case RuntimeOnlineState.Starting:
+					// This is probably impossible to happen. In this case
+					// we shouldn't change the RPC state.
+					break;
+			}
+		}
+	}
+
+	deletePendingRpc(messageId: string): void {
+		this._pendingRpcs.delete(messageId);
+		if (this._pendingRpcs.size <= 0) {
+			this.setClientStatus(RuntimeClientStatus.Idle);
+		}
+	}
+
 	/**
 	 * Sends a message (of any type) to the server side of the comm. This is only used for
 	 * fire-and-forget messages; RPCs should use performRpc instead.
@@ -1105,7 +1241,7 @@ class ExtHostRuntimeClientInstance<Input, Output>
 			if (pending.responseKeys.length === 0 || pending.responseKeys.some((key: string) => responseKeys.includes(key))) {
 				// This is a response to an RPC call; resolve the promise.
 				pending.promise.complete(message);
-				this._pendingRpcs.delete(message.parent_id);
+				this.deletePendingRpc(message.parent_id);
 			} else {
 				// This is a regular message or event; emit it to the client as-is
 				this._dataEmitter.fire({ data: message.data as Output, buffers: message.buffers });
@@ -1125,6 +1261,15 @@ class ExtHostRuntimeClientInstance<Input, Output>
 		this.clientState.set(state, undefined);
 	}
 
+	/**
+	 * Sets the status of the client by firing an event bearing the new status.
+	 *
+	 * @param status The new status of the client
+	 */
+	setClientStatus(status: RuntimeClientStatus): void {
+		this.clientStatus.set(status, undefined);
+	}
+
 	onDidReceiveData: Event<IRuntimeClientOutput<Output>>;
 
 	getClientId(): string {
@@ -1137,8 +1282,9 @@ class ExtHostRuntimeClientInstance<Input, Output>
 
 	public override dispose(): void {
 		// Cancel any pending RPCs
-		for (const pending of this._pendingRpcs.values()) {
-			pending.promise.error('The language runtime exited before the RPC completed.');
+		for (const [id, pending] of this._pendingRpcs) {
+			pending.error(new Error('The language runtime exited before the RPC completed.'));
+			this.deletePendingRpc(id);
 		}
 
 		// If we aren't currently closed, clean up before completing disposal.
diff --git a/src/vs/workbench/contrib/positronVariables/browser/components/variablesCore.css b/src/vs/workbench/contrib/positronVariables/browser/components/variablesCore.css
index b3dd9ea276e8..e7fb859f05c6 100644
--- a/src/vs/workbench/contrib/positronVariables/browser/components/variablesCore.css
+++ b/src/vs/workbench/contrib/positronVariables/browser/components/variablesCore.css
@@ -17,3 +17,7 @@
 .positron-variables .variables-core .variables-instances-container:focus {
 	outline: none !important;
 }
+
+#variables-progress > .monaco-progress-container {
+	top: 0px;
+}
diff --git a/src/vs/workbench/contrib/positronVariables/browser/components/variablesCore.tsx b/src/vs/workbench/contrib/positronVariables/browser/components/variablesCore.tsx
index 18d787fcd24a..7063abd4efff 100644
--- a/src/vs/workbench/contrib/positronVariables/browser/components/variablesCore.tsx
+++ b/src/vs/workbench/contrib/positronVariables/browser/components/variablesCore.tsx
@@ -7,7 +7,7 @@
 import './variablesCore.css';
 
 // React.
-import React from 'react';
+import React, { useEffect, useRef } from 'react';
 
 // Other dependencies.
 import { IReactComponentContainer } from '../../../../../base/browser/positronReactRenderer.js';
@@ -15,6 +15,9 @@ import { ActionBars } from './actionBars.js';
 import { PositronVariablesProps } from '../positronVariables.js';
 import { VariablesInstance } from './variablesInstance.js';
 import { usePositronVariablesContext } from '../positronVariablesContext.js';
+import { DisposableStore } from '../../../../../base/common/lifecycle.js';
+import { ProgressBar } from '../../../../../base/browser/ui/progressbar/progressbar.js';
+import { RuntimeClientStatus } from '../../../../services/languageRuntime/common/languageRuntimeClientInstance.js';
 
 // VariablesCoreProps interface.
 interface VariablesCoreProps extends PositronVariablesProps {
@@ -31,6 +34,70 @@ interface VariablesCoreProps extends PositronVariablesProps {
 export const VariablesCore = (props: VariablesCoreProps) => {
 	// Context hooks.
 	const positronVariablesContext = usePositronVariablesContext();
+	const progressRef = useRef<HTMLDivElement>(null);
+
+	useEffect(() => {
+		const disposables = new DisposableStore();
+
+		let progressBar: ProgressBar | undefined;
+		let debounceTimeout: NodeJS.Timeout | undefined;
+
+		const clearProgressBar = () => {
+			if (debounceTimeout) {
+				clearTimeout(debounceTimeout);
+				debounceTimeout = undefined;
+			}
+
+			if (progressBar) {
+				progressBar.done();
+				progressBar.dispose();
+				progressBar = undefined;
+				progressRef.current?.replaceChildren();
+			}
+		}
+
+		const setProgressBar = (timeout: number) => {
+			// If there's a progress bar already scheduled to appear we'll clean it up,
+			// and schedule a new one.
+			if (debounceTimeout) {
+				clearTimeout(debounceTimeout);
+				debounceTimeout = undefined;
+			}
+
+			debounceTimeout = setTimeout(() => {
+				// No work to do if we don't have a progress bar.
+				if (!progressRef.current) {
+					return;
+				}
+				// Before starting a new render, remove any existing progress bars. This prevents
+				// a buildup of progress bars when rendering multiple times and ensures the progress bar
+				// is removed when a new render is requested before the previous one completes.
+				progressRef.current.replaceChildren();
+				// Create the progress bar.
+				progressBar = new ProgressBar(progressRef.current);
+				progressBar.infinite();
+			}, timeout)
+		}
+
+		if (positronVariablesContext.activePositronVariablesInstance) {
+			disposables.add(positronVariablesContext.activePositronVariablesInstance.onDidChangeStatus((status) => {
+				if (status === RuntimeClientStatus.Busy) {
+					setProgressBar(500);
+				} else {
+					clearProgressBar();
+				}
+			}));
+
+			if (positronVariablesContext.activePositronVariablesInstance.status === RuntimeClientStatus.Busy) {
+				setProgressBar(100);
+			}
+		}
+
+		return () => {
+			clearProgressBar();
+			disposables.dispose()
+		};
+	}, [positronVariablesContext.activePositronVariablesInstance])
 
 	// If there are no instances, render nothing.
 	// TODO@softwarenerd - Render something specific for this case. TBD.
@@ -44,6 +111,7 @@ export const VariablesCore = (props: VariablesCoreProps) => {
 	// Render.
 	return (
 		<div className='variables-core'>
+			<div ref={progressRef} id='variables-progress' />
 			<ActionBars {...props} />
 			<div className='variables-instances-container' style={{ width: props.width, height: adjustedHeight }}>
 				{positronVariablesContext.positronVariablesInstances.map(positronVariablesInstance =>
diff --git a/src/vs/workbench/services/languageRuntime/common/languageRuntimeClientInstance.ts b/src/vs/workbench/services/languageRuntime/common/languageRuntimeClientInstance.ts
index 5fce4406d902..9c7f1d3991be 100644
--- a/src/vs/workbench/services/languageRuntime/common/languageRuntimeClientInstance.ts
+++ b/src/vs/workbench/services/languageRuntime/common/languageRuntimeClientInstance.ts
@@ -7,6 +7,7 @@ import { VSBuffer } from '../../../../base/common/buffer.js';
 import { Event } from '../../../../base/common/event.js';
 import { Disposable } from '../../../../base/common/lifecycle.js';
 import { ISettableObservable } from '../../../../base/common/observableInternal/base.js';
+import { ILanguageRuntimeMessageState } from './languageRuntimeService.js';
 
 /**
  * The possible states for a language runtime client instance. These
@@ -30,6 +31,16 @@ export enum RuntimeClientState {
 	Closed = 'closed',
 }
 
+/**
+ * Represents the status of a language runtime client. It's used to indicate
+ * if it's currently executing RPC requests or not.
+ */
+export enum RuntimeClientStatus {
+	Idle = 'idle',
+	Busy = 'busy',
+	Disconnected = 'closed',
+}
+
 /**
  * The set of client types that can be generated by a language runtime. Note
  * that, because client types can share a namespace with other kinds of
@@ -83,8 +94,10 @@ export interface IRuntimeClientInstance<Input, Output> extends Disposable {
 	getClientId(): string;
 	getClientType(): RuntimeClientType;
 	performRpcWithBuffers(request: Input, timeout: number): Promise<IRuntimeClientOutput<Output>>;
-	performRpc(request: Input, timeout: number, responseKeys: Array<string>): Promise<Output>;
+	performRpc(request: Input, timeout: number | undefined, responseKeys: Array<string>): Promise<Output>;
 	sendMessage(message: any): void;
 	messageCounter: ISettableObservable<number>;
 	clientState: ISettableObservable<RuntimeClientState>;
+	clientStatus: ISettableObservable<RuntimeClientStatus>;
+	updatePendingRpcState(message: ILanguageRuntimeMessageState): void;
 }
diff --git a/src/vs/workbench/services/languageRuntime/common/languageRuntimeVariablesClient.ts b/src/vs/workbench/services/languageRuntime/common/languageRuntimeVariablesClient.ts
index 96ed69c1e6f7..4ea8ebcddc73 100644
--- a/src/vs/workbench/services/languageRuntime/common/languageRuntimeVariablesClient.ts
+++ b/src/vs/workbench/services/languageRuntime/common/languageRuntimeVariablesClient.ts
@@ -6,7 +6,7 @@
 import { Emitter } from '../../../../base/common/event.js';
 import { Disposable } from '../../../../base/common/lifecycle.js';
 import { ISettableObservable } from '../../../../base/common/observableInternal/base.js';
-import { IRuntimeClientInstance, RuntimeClientState } from './languageRuntimeClientInstance.js';
+import { IRuntimeClientInstance, RuntimeClientState, RuntimeClientStatus } from './languageRuntimeClientInstance.js';
 import { ClipboardFormatFormat, PositronVariablesComm, RefreshEvent, UpdateEvent, Variable } from './positronVariablesComm.js';
 
 /**
@@ -142,6 +142,11 @@ export class VariablesClientInstance extends Disposable {
 	 */
 	public clientState: ISettableObservable<RuntimeClientState>;
 
+	/**
+	 * The status of the client instance
+	 */
+	public clientStatus: ISettableObservable<RuntimeClientStatus>;
+
 	/**
 	 * Ceate a new variable client instance.
 	 *
@@ -150,8 +155,19 @@ export class VariablesClientInstance extends Disposable {
 	constructor(client: IRuntimeClientInstance<any, any>) {
 		super();
 
-		this._comm = new PositronVariablesComm(client);
+		// Clipboard formatting should have a small timeout.
+		this._comm = new PositronVariablesComm(client, {
+			clipboard_format: { timeout: 3000 },
+			// Explicitly never timeout the other requests
+			list: { timeout: undefined },
+			clear: { timeout: undefined },
+			delete: { timeout: undefined },
+			inspect: { timeout: undefined },
+			view: { timeout: undefined },
+		});
+
 		this.clientState = client.clientState;
+		this.clientStatus = client.clientStatus;
 
 		// Connect the client instance to the back end
 		this.connectClient(this._comm);
@@ -245,3 +261,4 @@ export class VariablesClientInstance extends Disposable {
 		}));
 	}
 }
+
diff --git a/src/vs/workbench/services/languageRuntime/common/positronBaseComm.ts b/src/vs/workbench/services/languageRuntime/common/positronBaseComm.ts
index ccf583b51727..83f27f1b0631 100644
--- a/src/vs/workbench/services/languageRuntime/common/positronBaseComm.ts
+++ b/src/vs/workbench/services/languageRuntime/common/positronBaseComm.ts
@@ -3,7 +3,7 @@
  *  Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
  *--------------------------------------------------------------------------------------------*/
 
-import { IRuntimeClientInstance, RuntimeClientState } from './languageRuntimeClientInstance.js';
+import { IRuntimeClientInstance, RuntimeClientState, RuntimeClientStatus } from './languageRuntimeClientInstance.js';
 import { Event, Emitter } from '../../../../base/common/event.js';
 import { Disposable } from '../../../../base/common/lifecycle.js';
 import { ISettableObservable } from '../../../../base/common/observableInternal/base.js';
@@ -55,9 +55,9 @@ export type PositronCommOptions<T extends string> = {
 export interface PositronCommRpcOptions {
 	/**
 	 * Timeout in milliseconds after which to error if the server does not respond.
-	 * Defaults to 5 seconds.
+	 * Defaults to 5 seconds. Undefined means no timeout.
 	 */
-	timeout: number;
+	timeout: number | undefined;
 }
 
 /**
@@ -101,6 +101,11 @@ export class PositronBaseComm extends Disposable {
 	 */
 	public readonly clientState: ISettableObservable<RuntimeClientState>;
 
+	/**
+	 * The current status of the client instance. Ie, are there pending RPC's?
+	 */
+	public readonly clientStatus: ISettableObservable<RuntimeClientStatus>;
+
 	/**
 	 * Create a new Positron com
 	 *
@@ -170,6 +175,9 @@ export class PositronBaseComm extends Disposable {
 
 		// Forward the client state from the client instance
 		this.clientState = clientInstance.clientState;
+
+		// Forward the client status from the client instance
+		this.clientStatus = clientInstance.clientStatus;
 	}
 
 	/**
@@ -234,7 +242,11 @@ export class PositronBaseComm extends Disposable {
 		// Perform the RPC
 		let response = {} as any;
 		try {
-			const timeout = this.options?.[rpcName]?.timeout ?? 5000;
+			// Check for explicitly set timeout in options, otherwise use the default.
+			const defaultTimeout = 5000; // 5 seconds
+			const timeout = (this.options?.[rpcName] && 'timeout' in this.options[rpcName])
+				? this.options[rpcName].timeout
+				: defaultTimeout;
 			// Wait for a response to this message that includes a 'result' or
 			// 'error' field.
 			response = await this.clientInstance.performRpc(request, timeout, ['result', 'error']);
diff --git a/src/vs/workbench/services/languageRuntime/test/common/testRuntimeClientInstance.ts b/src/vs/workbench/services/languageRuntime/test/common/testRuntimeClientInstance.ts
index 0917b1b3c374..49f7953f39bb 100644
--- a/src/vs/workbench/services/languageRuntime/test/common/testRuntimeClientInstance.ts
+++ b/src/vs/workbench/services/languageRuntime/test/common/testRuntimeClientInstance.ts
@@ -6,7 +6,8 @@
 import { Emitter } from '../../../../../base/common/event.js';
 import { Disposable } from '../../../../../base/common/lifecycle.js';
 import { observableValue } from '../../../../../base/common/observable.js';
-import { IRuntimeClientInstance, IRuntimeClientOutput, RuntimeClientState, RuntimeClientType } from '../../common/languageRuntimeClientInstance.js';
+import { IRuntimeClientInstance, IRuntimeClientOutput, RuntimeClientState, RuntimeClientStatus, RuntimeClientType } from '../../common/languageRuntimeClientInstance.js';
+import { ILanguageRuntimeMessageState } from '../../common/languageRuntimeService.js';
 
 export class TestRuntimeClientInstance extends Disposable implements IRuntimeClientInstance<any, any> {
 	private readonly _dataEmitter = this._register(new Emitter<IRuntimeClientOutput<any>>());
@@ -16,6 +17,7 @@ export class TestRuntimeClientInstance extends Disposable implements IRuntimeCli
 	readonly messageCounter = observableValue(`msg-counter`, 0);
 
 	readonly clientState = observableValue(`client-state`, RuntimeClientState.Uninitialized);
+	readonly clientStatus = observableValue(`client-status`, RuntimeClientStatus.Disconnected);
 
 	constructor(
 		private readonly _id: string,
@@ -75,4 +77,8 @@ export class TestRuntimeClientInstance extends Disposable implements IRuntimeCli
 	setClientState(state: RuntimeClientState): void {
 		this.clientState.set(state, undefined);
 	}
+
+	updatePendingRpcState(message: ILanguageRuntimeMessageState): void {
+		// No-op
+	}
 }
diff --git a/src/vs/workbench/services/positronVariables/common/interfaces/positronVariablesInstance.ts b/src/vs/workbench/services/positronVariables/common/interfaces/positronVariablesInstance.ts
index 214d22c2d982..1b26ad01ff94 100644
--- a/src/vs/workbench/services/positronVariables/common/interfaces/positronVariablesInstance.ts
+++ b/src/vs/workbench/services/positronVariables/common/interfaces/positronVariablesInstance.ts
@@ -8,7 +8,7 @@ import { IVariableItem } from './variableItem.js';
 import { IVariableGroup } from './variableGroup.js';
 import { IVariableOverflow as IVariableOverflow } from './variableOverflow.js';
 import { ILanguageRuntimeSession } from '../../../runtimeSession/common/runtimeSessionService.js';
-import { RuntimeClientState } from '../../../languageRuntime/common/languageRuntimeClientInstance.js';
+import { RuntimeClientState, RuntimeClientStatus } from '../../../languageRuntime/common/languageRuntimeClientInstance.js';
 
 /**
  * PositronVariablesGrouping enumeration.
@@ -89,6 +89,11 @@ export interface IPositronVariablesInstance {
 	 */
 	highlightRecent: boolean;
 
+	/**
+	 * Gets the current status
+	 */
+	readonly status: RuntimeClientStatus;
+
 	/**
 	 * The onDidChangeEntries event.
 	 */
@@ -100,6 +105,12 @@ export interface IPositronVariablesInstance {
 	 */
 	readonly onDidChangeState: Event<RuntimeClientState>;
 
+	/**
+	 * Event that fires when the status of the underlying comm
+	 * changes.
+	 */
+	readonly onDidChangeStatus: Event<RuntimeClientStatus>;
+
 	/**
 	 * The onFocusElement event.
 	 * Used by variable widgets to respond to focus requests.
diff --git a/src/vs/workbench/services/positronVariables/common/positronVariablesInstance.ts b/src/vs/workbench/services/positronVariables/common/positronVariablesInstance.ts
index b9d5f8eb5d28..666915823d54 100644
--- a/src/vs/workbench/services/positronVariables/common/positronVariablesInstance.ts
+++ b/src/vs/workbench/services/positronVariables/common/positronVariablesInstance.ts
@@ -18,7 +18,7 @@ import { VariableKind } from '../../languageRuntime/common/positronVariablesComm
 import { INotificationService } from '../../../../platform/notification/common/notification.js';
 import { PositronCommError } from '../../languageRuntime/common/positronBaseComm.js';
 import { localize } from '../../../../nls.js';
-import { RuntimeClientState } from '../../languageRuntime/common/languageRuntimeClientInstance.js';
+import { RuntimeClientState, RuntimeClientStatus } from '../../languageRuntime/common/languageRuntimeClientInstance.js';
 import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js';
 
 /**
@@ -48,7 +48,7 @@ export class PositronVariablesInstance extends Disposable implements IPositronVa
 	 * Gets or sets the runtime disposable store. This contains things that are disposed when a
 	 * runtime is detached.
 	 */
-	private _runtimeDisposableStore = this._register(new DisposableStore());
+	private readonly _runtimeDisposableStore = this._register(new DisposableStore());
 
 	/**
 	 * Gets or sets the variable items map.
@@ -109,6 +109,13 @@ export class PositronVariablesInstance extends Disposable implements IPositronVa
 	 */
 	private readonly _onDidChangeStateEmitter = this._register(new Emitter<RuntimeClientState>());
 
+	/**
+	 * The onDidChangeStatus event emitter
+	 * Similar to the above onDidChangeState, we have a separate emitter so we can attach
+	 * to the event before the comm has been set up.
+	 */
+	private readonly _onDidChangeStatusEmitter = this._register(new Emitter<RuntimeClientStatus>());
+
 	/**
 	 * The _onFocusInput event emitter.
 	 */
@@ -172,6 +179,14 @@ export class PositronVariablesInstance extends Disposable implements IPositronVa
 			this._variablesClient.clientState.get() : RuntimeClientState.Uninitialized;
 	}
 
+	/**
+	 * Gets the current status.
+	 */
+	get status(): RuntimeClientStatus {
+		return this._variablesClient ?
+			this._variablesClient.clientStatus.get() : RuntimeClientStatus.Disconnected;
+	}
+
 	/**
 	 * Gets the variable items.
 	 */
@@ -239,6 +254,11 @@ export class PositronVariablesInstance extends Disposable implements IPositronVa
 	 */
 	readonly onDidChangeState: Event<RuntimeClientState> = this._onDidChangeStateEmitter.event;
 
+	/**
+	 * onDidChangeStatus event.
+	 */
+	readonly onDidChangeStatus: Event<RuntimeClientStatus> = this._onDidChangeStatusEmitter.event;
+
 	/**
 	 * onFocusElement event.
 	 */
@@ -478,8 +498,8 @@ export class PositronVariablesInstance extends Disposable implements IPositronVa
 
 		// Remove all disposables associated with the attached runtime. This has
 		// the side effect of disposing the client.
-		this._runtimeDisposableStore.dispose();
-		this._runtimeDisposableStore = new DisposableStore();
+		// We use clear() instead of dispose() to not mark the store as disposed.
+		this._runtimeDisposableStore.clear();
 	}
 
 	/**
@@ -509,7 +529,7 @@ export class PositronVariablesInstance extends Disposable implements IPositronVa
 
 			// Create an event handler for the client state.
 			const event = Event.fromObservable(
-				this._variablesClient.clientState, this._runtimeDisposableStore)
+				this._variablesClient.clientState, this._runtimeDisposableStore);
 
 			this._runtimeDisposableStore.add(event(state => {
 				// Clear all expanded paths if the client is closed.
@@ -521,6 +541,11 @@ export class PositronVariablesInstance extends Disposable implements IPositronVa
 				this._onDidChangeStateEmitter.fire(state);
 			}));
 
+			const onDidChangeStatusEvent = Event.fromObservable(this._variablesClient.clientStatus);
+			this._runtimeDisposableStore.add(onDidChangeStatusEvent(status => {
+				this._onDidChangeStatusEmitter.fire(status);
+			}));
+
 			// Add the runtime client to the runtime disposable store.
 			this._runtimeDisposableStore.add(this._variablesClient);
 		} catch (error) {
diff --git a/test/e2e/pages/popups.ts b/test/e2e/pages/popups.ts
index 8b2bccc475ef..2c78f3353801 100644
--- a/test/e2e/pages/popups.ts
+++ b/test/e2e/pages/popups.ts
@@ -12,6 +12,7 @@ const POSITRON_MODAL_DIALOG_BOX_OK = '.positron-modal-dialog-box .ok-cancel-acti
 const POSITRON_MODAL_DIALOG_BOX_CANCEL = '.positron-modal-dialog-box .ok-cancel-action-bar .positron-button.action-bar-button:not(.default)';
 const POSITRON_MODAL_DIALOG_BOX_TITLE = '.positron-modal-dialog-box .simple-title-bar-title';
 const POSITRON_MODAL_DIALOG_POPUP_OPTION = '.positron-modal-popup .positron-modal-popup-children';
+const POSITRON_MODAL_DIALOG_DELETE = '.positron-modal-dialog-box .action-bar-button.default';
 const NOTIFICATION_TOAST = '.notification-toast';
 
 /*
@@ -37,12 +38,23 @@ export class Popups {
 			this.code.logger.log('Checking for modal dialog box');
 			// fail fast if the modal is not present
 			await expect(this.code.driver.page.locator(POSITRON_MODAL_DIALOG_BOX)).toBeVisible();
-			await this.code.driver.page.locator(POSITRON_MODAL_DIALOG_BOX_OK).click();
+			await this.code.driver.page.locator(POSITRON_MODAL_DIALOG_DELETE).click();
 		} catch {
 			this.code.logger.log('Did not find modal dialog box');
 		}
 	}
 
+	async confirmDeleteModalDialog() {
+		try {
+			this.code.logger.log('Checking for confirm delete modal dialog box');
+			// fail fast if the modal is not present
+			await expect(this.code.driver.page.locator(POSITRON_MODAL_DIALOG_BOX)).toBeVisible();
+			await this.code.driver.page.locator(POSITRON_MODAL_DIALOG_DELETE).click();
+		} catch {
+			this.code.logger.log('Did not find confirm delete modal dialog box');
+		}
+	}
+
 	async installIPyKernel() {
 
 		try {
diff --git a/test/e2e/pages/variables.ts b/test/e2e/pages/variables.ts
index e8fad1572291..0b22bead3a0b 100644
--- a/test/e2e/pages/variables.ts
+++ b/test/e2e/pages/variables.ts
@@ -80,6 +80,11 @@ export class Variables {
 		});
 	}
 
+	async hasProgressBar(): Promise<boolean> {
+		const progressBar = this.code.driver.page.locator('.variables-core .monaco-progress-container');
+		return await progressBar.isVisible();
+	}
+
 	/**
 	 * Action: Show or hide the secondary side bar (variables pane).
 	 * @param action show or hide the secondary side bar
@@ -191,6 +196,10 @@ export class Variables {
 		await this.code.driver.page.getByLabel('Active View Switcher').getByText('Session').click();
 	}
 
+	async clickDeleteAllVariables() {
+		await this.code.driver.page.getByLabel('Delete all objects').click();
+	}
+
 	/**
 	 * Verify: Confirm the runtime is visible in the variables pane.
 	 * @param language the language of the runtime: Python or R
diff --git a/test/e2e/tests/variables/variables-progress.test.ts b/test/e2e/tests/variables/variables-progress.test.ts
new file mode 100644
index 000000000000..12695cbe1b2a
--- /dev/null
+++ b/test/e2e/tests/variables/variables-progress.test.ts
@@ -0,0 +1,82 @@
+/*---------------------------------------------------------------------------------------------
+ *  Copyright (C) 2025 Posit Software, PBC. All rights reserved.
+ *  Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { test, expect, tags } from '../_test.setup';
+
+test.use({
+	suiteId: __filename
+});
+
+test.describe('Variables - Progress bar', { tag: [tags.WEB, tags.VARIABLES] }, () => {
+
+	test.afterEach(async function ({ app }) {
+		await app.workbench.layouts.enterLayout('stacked');
+	});
+
+	test('Run a long computation and see the progress bar appearing', async function ({ app, sessions }) {
+
+		const session1 = await sessions.start('r');
+		await app.workbench.layouts.enterLayout('fullSizedAuxBar');
+		await app.workbench.console.pasteCodeToConsole('hello <- 1; foo <- 2', true);
+		await app.workbench.console.pasteCodeToConsole('Sys.sleep(20)', true);
+
+		const variables = app.workbench.variables;
+
+		await expect(async () => {
+			expect(await variables.hasProgressBar()).toBe(false);
+		}).toPass({ timeout: 2000 });
+
+		// Now click delete all variables an expect the progress bar to appear
+		await variables.clickDeleteAllVariables();
+		await app.workbench.popups.confirmDeleteModalDialog();
+
+		// Wait for the progress bar to appear
+		await expect(async () => {
+			expect(await variables.hasProgressBar()).toBe(true);
+		}).toPass({ timeout: 5000 });
+
+		// Wait for the progress bar to disappear
+		await expect(async () => {
+			expect(await variables.hasProgressBar()).toBe(false);
+		}).toPass({ timeout: 30000 });
+
+		// Next critical UI path is that we need to not show the progress bar when
+		// user switches between sessions.
+
+		// startup new session
+		const session2 = await sessions.start('r', { reuse: false });
+
+		await sessions.select(session2.id);
+		await app.workbench.console.pasteCodeToConsole('hello <- 1; foo <- 2', true);
+		await app.workbench.console.pasteCodeToConsole('Sys.sleep(20)', true);
+
+		// Now click delete all variables an expect the progress bar to appear
+		await variables.clickDeleteAllVariables();
+		await app.workbench.popups.confirmDeleteModalDialog();
+
+		// Wait for the progress bar to appear
+		await expect(async () => {
+			expect(await variables.hasProgressBar()).toBe(true);
+		}).toPass({ timeout: 5000 });
+
+		// Make sure the progress bar is not shown when switching sessions
+		await sessions.select(session1.id);
+		await expect(async () => {
+			expect(await variables.hasProgressBar()).toBe(false);
+		}).toPass({ timeout: 20000 });
+
+		// Go back to session2 and make sure the progress bar is shown
+		await sessions.select(session2.id);
+		await expect(async () => {
+			expect(await variables.hasProgressBar()).toBe(true);
+		}).toPass({ timeout: 20000 });
+
+		// Wait for the progress bar to disappear again
+		await expect(async () => {
+			expect(await variables.hasProgressBar()).toBe(false);
+		}).toPass({ timeout: 30000 });
+
+	});
+});


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 9 out of 10 changed files in this pull request and generated no comments.

<details>
<summary>Files not reviewed (1)</summary>

* **src/vs/workbench/contrib/positronVariables/browser/components/variablesCore.css**: Language not supported
</details>

<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**src/vs/workbench/services/languageRuntime/common/languageRuntimeVariablesClient.ts:23**
* There appears to be a typographical error in the identifier string; consider correcting 'statu' to 'status' for clarity.
```
this.status = observableValue<VariablesClientStatus>(`variables-comm-statu-${instance.getClientId()}`, VariablesClientStatus.Disconnected);
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T12:54:31Z
user: login: copilot-pull-request-reviewer[bot]
body: Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T17:30:02Z
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 12 out of 13 changed files in this pull request and generated 1 comment.

<details>
<summary>Files not reviewed (1)</summary>

* **src/vs/workbench/contrib/positronVariables/browser/components/variablesCore.css**: Language not supported
</details>

<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**src/vs/workbench/services/languageRuntime/common/positronBaseComm.ts:237**
* Add a clarifying comment that a timeout value of -1 is used intentionally to indicate that RPC calls should never timeout, to help future maintainers understand this design choice.
```
const timeout = this.options?.[rpcName]?.timeout ?? -1;
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T18:25:00Z
user: login: seeM
body: I really like this change @dfalbel! Thanks for the extra work to add more visibility and safety to our RPCs.

I've left a bunch of nits, please feel free to decide which you think are worthwhile.

I recommend running the full test suite against this branch when you're ready given that it's a big low-level change. We could also get Jonathan's review at that point.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T11:15:30Z
user: login: dfalbel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T11:40:30Z
user: login: dfalbel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T11:54:01Z
user: login: seeM
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T12:09:26Z
user: login: dfalbel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T12:11:34Z
user: login: dfalbel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T12:15:20Z
user: login: dfalbel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T12:51:22Z
user: login: dfalbel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T12:52:12Z
user: login: dfalbel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T12:53:27Z
user: login: dfalbel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T13:21:23Z
user: login: seeM
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T13:29:06Z
user: login: jmcphers
body: The goal of enforcing a default timeout is to ensure that the UI never gets stuck in a pending state even if the backend does not handle the request correctly (misses/drops the RPC, fails to respond, etc.). Without a timeout, this can lead to the kinds of bugs where you have to reload the window (or, for average users, fully close and reopen Positron) to get back to a good state.

I'm okay removing the default timeout with the understanding that we now need to be more vigilant for bugs caused by entering and never leaving the pending state. I think that to more fully address this issue we will need to work on the RPC contract so that (a) the client understands whether the request can be handled outside the main thread, (b) the server acknowledges requests and lets the client know when they are being processed, and (c) notifications can be sent outside the shell socket (Lionel has some thoughts on this). 
state: DISMISSED
author_association: COLLABORATOR
submitted_at: 2025-04-23T15:59:13Z
user: login: dfalbel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T18:02:56Z
user: login: dfalbel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T18:03:10Z
user: login: dfalbel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T18:03:25Z
user: login: jmcphers
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-28T17:18:02Z

Review comments:
diff_hunk: @@ -171,7 +243,7 @@ export class VariablesClientInstance extends Disposable {
 	 * Requests that the variables client clear all variables.
 	 */
 	public async requestClear(includeHiddenObjects: boolean): Promise<void> {
-		return this._comm.clear(includeHiddenObjects);
+		return await this._comm.clear(includeHiddenObjects);
path: src/vs/workbench/services/languageRuntime/common/languageRuntimeVariablesClient.ts
user: login: Copilot
body: [nitpick] Consider removing the 'await' since returning the promise directly in an async function avoids redundant async/await wrapping.
```suggestion
		return this._comm.clear(includeHiddenObjects);
```
created_at: 2025-04-17T18:25:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1031,6 +1102,34 @@ class ExtHostRuntimeClientInstance<Input, Output>
 		// Add the promise to the list of pending RPCs.
 		const pending = new PendingRpc<T>(responseKeys);
 		this._pendingRpcs.set(messageId, pending);
+		if (this._pendingRpcs.size > 0) {
+			this.setClientStatus(RuntimeClientStatus.Busy);
+		}
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: seeM
body: Is the `if` needed given the `set` before?
```suggestion
		this.setClientStatus(RuntimeClientStatus.Busy);
```
created_at: 2025-04-23T10:49:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1031,6 +1102,34 @@ class ExtHostRuntimeClientInstance<Input, Output>
 		// Add the promise to the list of pending RPCs.
 		const pending = new PendingRpc<T>(responseKeys);
 		this._pendingRpcs.set(messageId, pending);
+		if (this._pendingRpcs.size > 0) {
+			this.setClientStatus(RuntimeClientStatus.Busy);
+		}
+
+		// When the status becomes complete, we wait a maximum of 5s for the
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: seeM
body: ```suggestion
		// When the status becomes complete, we wait a maximum of 2s for the
```
created_at: 2025-04-23T10:50:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1031,6 +1102,34 @@ class ExtHostRuntimeClientInstance<Input, Output>
 		// Add the promise to the list of pending RPCs.
 		const pending = new PendingRpc<T>(responseKeys);
 		this._pendingRpcs.set(messageId, pending);
+		if (this._pendingRpcs.size > 0) {
+			this.setClientStatus(RuntimeClientStatus.Busy);
+		}
+
+		// When the status becomes complete, we wait a maximum of 5s for the
+		// promise to be resolved, otherwise we reject it.
+		const statusListener = pending.onDidChangeStatus((status) => {
+			if (status === PendingRpcStatus.Completed) {
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: seeM
body: Should we also dispose the listener here?
created_at: 2025-04-23T10:51:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1136,8 +1274,10 @@ class ExtHostRuntimeClientInstance<Input, Output>
 
 	public override dispose(): void {
 		// Cancel any pending RPCs
-		for (const pending of this._pendingRpcs.values()) {
+		for (const [id, pending] of this._pendingRpcs) {
+			pending.setStatus(PendingRpcStatus.Error);
 			pending.promise.error('The language runtime exited before the RPC completed.');
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: seeM
body: Nit: would it make sense to add an `error` method to the pending RPC class that sets the status and rejects the promise?
created_at: 2025-04-23T10:52:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -234,7 +242,7 @@ export class PositronBaseComm extends Disposable {
 		// Perform the RPC
 		let response = {} as any;
 		try {
-			const timeout = this.options?.[rpcName]?.timeout ?? 5000;
+			const timeout = this.options?.[rpcName]?.timeout ?? -1;
path: src/vs/workbench/services/languageRuntime/common/positronBaseComm.ts
user: login: seeM
body: When you're ready, we may want to run the full test suite against this branch given this change. I agree with removing the default timeout, though, since it doesn't actually cancel the request.
created_at: 2025-04-23T10:55:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,8 +155,13 @@ export class VariablesClientInstance extends Disposable {
 	constructor(client: IRuntimeClientInstance<any, any>) {
 		super();
 
-		this._comm = new PositronVariablesComm(client);
+		// Clipboard formatting should have a small timeout.
path: src/vs/workbench/services/languageRuntime/common/languageRuntimeVariablesClient.ts
user: login: seeM
body: Why is this? Is it prone to hanging?

In future, it could be useful to expose the `PendingRPCStatus` in comm classes so that we can have specific UI treatments for specific comm methods.
created_at: 2025-04-23T10:56:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -306,6 +307,9 @@ class ExtHostLanguageRuntimeSessionAdapter implements ILanguageRuntimeSession {
 	}
 
 	emitDidReceiveRuntimeMessageState(languageRuntimeMessageState: ILanguageRuntimeMessageState) {
+		for (const client of this.clientInstances) {
+			client.updatePendingRpcState(languageRuntimeMessageState);
+		}
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: seeM
body: Nit: what about having client instances listen to `onDidReceiveRuntimeMessageState`? It would keep the pending RPC logic in one place (the client instance) and remove the need for `updatePendingRpcState` in the interface. We could even move that logic all the way through to the `PendingRPC` class, but I'm not sure what the tradeoffs are.
created_at: 2025-04-23T10:59:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -958,17 +962,59 @@ class ExtHostLanguageRuntimeSessionAdapter implements ILanguageRuntimeSession {
 	}
 }
 
+enum PendingRpcStatus {
+	/**
+	 * The RPC was sent to the server but kernel, but the kernel didn't start working on it yet.
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: seeM
body: ```suggestion
	 * The RPC was sent to the kernel, but the kernel didn't start working on it yet.
```
created_at: 2025-04-23T10:59:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -958,17 +962,59 @@ class ExtHostLanguageRuntimeSessionAdapter implements ILanguageRuntimeSession {
 	}
 }
 
+enum PendingRpcStatus {
+	/**
+	 * The RPC was sent to the server but kernel, but the kernel didn't start working on it yet.
+	 */
+	Pending,
+	/**
+	 * The Kernel sent a state message with the RPC ID (as the parent id) and state 'Busy'
+	 * indicating that it has started handling the RPC.
+	 */
+	Executing,
+	/**
+	 * The Kernel sent a state message with the RPC ID (as the parent id) and status 'Idle'
+	 * indicating that it has finished handling the RPC.
+	 * Note: Completed means that the server finished processing the RPC, but it doesn't
+	 * mean that the RPC was successful. The promise may still be rejected.
+	 */
+	Completed,
+	/***
+	 * The Kernel closed before the RPC was completed.
+	 */
+	Error,
+}
+
 /**
  * Represents a pending RPC call that has been sent to the server side of a
  * comm.
  */
 class PendingRpc<T> {
 	public readonly promise: DeferredPromise<IRuntimeClientOutput<T>>;
+
+	private readonly _onDidChangeStatus = new Emitter<PendingRpcStatus>();
+	private _status: PendingRpcStatus = PendingRpcStatus.Pending;
+
+	/**
+	 * Can be used to check if the RPC is waiting to execute, or if it has already started.
+	 */
+	public readonly onDidChangeStatus: Event<PendingRpcStatus> = this._onDidChangeStatus.event;
+
+
 	constructor(
 		public readonly responseKeys: Array<string>
 	) {
 		this.promise = new DeferredPromise<IRuntimeClientOutput<T>>();
 	}
+
+	public setStatus(status: PendingRpcStatus): void {
+		this._status = status;
+		this._onDidChangeStatus.fire(status);
+	}
+
+	public get getStatus(): PendingRpcStatus {
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: seeM
body: Nit: could rename this to `status` or make it an ordinary method
created_at: 2025-04-23T11:04:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -958,17 +962,59 @@ class ExtHostLanguageRuntimeSessionAdapter implements ILanguageRuntimeSession {
 	}
 }
 
+enum PendingRpcStatus {
+	/**
+	 * The RPC was sent to the server but kernel, but the kernel didn't start working on it yet.
+	 */
+	Pending,
+	/**
+	 * The Kernel sent a state message with the RPC ID (as the parent id) and state 'Busy'
+	 * indicating that it has started handling the RPC.
+	 */
+	Executing,
+	/**
+	 * The Kernel sent a state message with the RPC ID (as the parent id) and status 'Idle'
+	 * indicating that it has finished handling the RPC.
+	 * Note: Completed means that the server finished processing the RPC, but it doesn't
+	 * mean that the RPC was successful. The promise may still be rejected.
+	 */
+	Completed,
+	/***
+	 * The Kernel closed before the RPC was completed.
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: seeM
body: Should the pending RPC also enter the error state when the promise is rejected? I.e. when no result is received after the request was handled, or when the request times out.
created_at: 2025-04-23T11:12:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,8 +155,13 @@ export class VariablesClientInstance extends Disposable {
 	constructor(client: IRuntimeClientInstance<any, any>) {
 		super();
 
-		this._comm = new PositronVariablesComm(client);
+		// Clipboard formatting should have a small timeout.
path: src/vs/workbench/services/languageRuntime/common/languageRuntimeVariablesClient.ts
user: login: dfalbel
body: I think it's most a UX thing, I don't think anyone would wait more than a few seconds to get something copied to the clipboard. I'd rather see an error than getting the clipboard buffer erased unexpectedly when the RPC resolves.
created_at: 2025-04-23T11:40:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -306,6 +307,9 @@ class ExtHostLanguageRuntimeSessionAdapter implements ILanguageRuntimeSession {
 	}
 
 	emitDidReceiveRuntimeMessageState(languageRuntimeMessageState: ILanguageRuntimeMessageState) {
+		for (const client of this.clientInstances) {
+			client.updatePendingRpcState(languageRuntimeMessageState);
+		}
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: dfalbel
body: I think that's a nice idea, I actually tried to do that. On the other side, the reason I ended up with the current is that we would need to make the `clientInstance` depend on the the `RuntimeSessionAdapter`, which we seem to currently avoid. Eg, the way we resolve RPC's is also not by listening to `CommData` messages: 

https://github.com/posit-dev/positron/blob/9eeb734dd9a1cecb86a5a22a99b8cdaaa6eaf635/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts#L363-L371


created_at: 2025-04-23T11:54:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -306,6 +307,9 @@ class ExtHostLanguageRuntimeSessionAdapter implements ILanguageRuntimeSession {
 	}
 
 	emitDidReceiveRuntimeMessageState(languageRuntimeMessageState: ILanguageRuntimeMessageState) {
+		for (const client of this.clientInstances) {
+			client.updatePendingRpcState(languageRuntimeMessageState);
+		}
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: seeM
body: I see, seems fine as is then
created_at: 2025-04-23T12:09:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1031,6 +1102,34 @@ class ExtHostRuntimeClientInstance<Input, Output>
 		// Add the promise to the list of pending RPCs.
 		const pending = new PendingRpc<T>(responseKeys);
 		this._pendingRpcs.set(messageId, pending);
+		if (this._pendingRpcs.size > 0) {
+			this.setClientStatus(RuntimeClientStatus.Busy);
+		}
+
+		// When the status becomes complete, we wait a maximum of 5s for the
+		// promise to be resolved, otherwise we reject it.
+		const statusListener = pending.onDidChangeStatus((status) => {
+			if (status === PendingRpcStatus.Completed) {
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: dfalbel
body: It will be disposed in https://github.com/posit-dev/positron/blob/41761c940b8e9bd54e1e7a07cd486c9ed8feb2c9/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts#L1131-L1134 once the promise errors, but it makes sense to just do it at this point.
created_at: 2025-04-23T12:11:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -958,17 +962,59 @@ class ExtHostLanguageRuntimeSessionAdapter implements ILanguageRuntimeSession {
 	}
 }
 
+enum PendingRpcStatus {
+	/**
+	 * The RPC was sent to the server but kernel, but the kernel didn't start working on it yet.
+	 */
+	Pending,
+	/**
+	 * The Kernel sent a state message with the RPC ID (as the parent id) and state 'Busy'
+	 * indicating that it has started handling the RPC.
+	 */
+	Executing,
+	/**
+	 * The Kernel sent a state message with the RPC ID (as the parent id) and status 'Idle'
+	 * indicating that it has finished handling the RPC.
+	 * Note: Completed means that the server finished processing the RPC, but it doesn't
+	 * mean that the RPC was successful. The promise may still be rejected.
+	 */
+	Completed,
+	/***
+	 * The Kernel closed before the RPC was completed.
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: dfalbel
body: I've changed this here: https://github.com/posit-dev/positron/pull/7286/commits/ae9ebb925d83c335f9e098dc917fe58be1af44bd

created_at: 2025-04-23T12:15:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -958,17 +962,59 @@ class ExtHostLanguageRuntimeSessionAdapter implements ILanguageRuntimeSession {
 	}
 }
 
+enum PendingRpcStatus {
+	/**
+	 * The RPC was sent to the server but kernel, but the kernel didn't start working on it yet.
+	 */
+	Pending,
+	/**
+	 * The Kernel sent a state message with the RPC ID (as the parent id) and state 'Busy'
+	 * indicating that it has started handling the RPC.
+	 */
+	Executing,
+	/**
+	 * The Kernel sent a state message with the RPC ID (as the parent id) and status 'Idle'
+	 * indicating that it has finished handling the RPC.
+	 * Note: Completed means that the server finished processing the RPC, but it doesn't
+	 * mean that the RPC was successful. The promise may still be rejected.
+	 */
+	Completed,
+	/***
+	 * The Kernel closed before the RPC was completed.
+	 */
+	Error,
+}
+
 /**
  * Represents a pending RPC call that has been sent to the server side of a
  * comm.
  */
 class PendingRpc<T> {
 	public readonly promise: DeferredPromise<IRuntimeClientOutput<T>>;
+
+	private readonly _onDidChangeStatus = new Emitter<PendingRpcStatus>();
+	private _status: PendingRpcStatus = PendingRpcStatus.Pending;
+
+	/**
+	 * Can be used to check if the RPC is waiting to execute, or if it has already started.
+	 */
+	public readonly onDidChangeStatus: Event<PendingRpcStatus> = this._onDidChangeStatus.event;
+
+
 	constructor(
 		public readonly responseKeys: Array<string>
 	) {
 		this.promise = new DeferredPromise<IRuntimeClientOutput<T>>();
 	}
+
+	public setStatus(status: PendingRpcStatus): void {
+		this._status = status;
+		this._onDidChangeStatus.fire(status);
+	}
+
+	public get getStatus(): PendingRpcStatus {
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: dfalbel
body: Done in https://github.com/posit-dev/positron/pull/7286/commits/c75f1894cdfecae84e2ef318e76355af64accfde
created_at: 2025-04-23T12:51:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1136,8 +1274,10 @@ class ExtHostRuntimeClientInstance<Input, Output>
 
 	public override dispose(): void {
 		// Cancel any pending RPCs
-		for (const pending of this._pendingRpcs.values()) {
+		for (const [id, pending] of this._pendingRpcs) {
+			pending.setStatus(PendingRpcStatus.Error);
 			pending.promise.error('The language runtime exited before the RPC completed.');
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: dfalbel
body: Done in https://github.com/posit-dev/positron/pull/7286/commits/6ffeeb1f385b3501fa31bc6df9bcacb949a8ecd9
created_at: 2025-04-23T12:52:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1031,6 +1102,34 @@ class ExtHostRuntimeClientInstance<Input, Output>
 		// Add the promise to the list of pending RPCs.
 		const pending = new PendingRpc<T>(responseKeys);
 		this._pendingRpcs.set(messageId, pending);
+		if (this._pendingRpcs.size > 0) {
+			this.setClientStatus(RuntimeClientStatus.Busy);
+		}
+
+		// When the status becomes complete, we wait a maximum of 5s for the
+		// promise to be resolved, otherwise we reject it.
+		const statusListener = pending.onDidChangeStatus((status) => {
+			if (status === PendingRpcStatus.Completed) {
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: dfalbel
body: Done in https://github.com/posit-dev/positron/pull/7286/commits/ae9ebb925d83c335f9e098dc917fe58be1af44bd
created_at: 2025-04-23T12:53:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -234,7 +242,7 @@ export class PositronBaseComm extends Disposable {
 		// Perform the RPC
 		let response = {} as any;
 		try {
-			const timeout = this.options?.[rpcName]?.timeout ?? 5000;
+			const timeout = this.options?.[rpcName]?.timeout ?? -1;
path: src/vs/workbench/services/languageRuntime/common/positronBaseComm.ts
user: login: dfalbel
body: Laucnhed a full suite run: https://github.com/posit-dev/positron/actions/runs/14618676676
created_at: 2025-04-23T13:21:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -958,17 +962,65 @@ class ExtHostLanguageRuntimeSessionAdapter implements ILanguageRuntimeSession {
 	}
 }
 
+enum PendingRpcStatus {
+	/**
+	 * The RPC was sent to the kernel, but the kernel didn't start working on it yet.
+	 */
+	Pending,
+	/**
+	 * The Kernel sent a state message with the RPC ID (as the parent id) and state 'Busy'
+	 * indicating that it has started handling the RPC.
+	 */
+	Executing,
+	/**
+	 * The Kernel sent a state message with the RPC ID (as the parent id) and status 'Idle'
+	 * indicating that it has finished handling the RPC.
+	 * Note: Completed means that the server finished processing the RPC, but it doesn't
+	 * mean that the RPC was successful. The promise may still be rejected.
+	 */
+	Completed,
+	/***
+	 * The Kernel closed before the RPC was completed, or the RPC response never arrived
+	 * within the timeout period.
+	 */
+	Error,
+}
+
 /**
  * Represents a pending RPC call that has been sent to the server side of a
  * comm.
  */
 class PendingRpc<T> {
 	public readonly promise: DeferredPromise<IRuntimeClientOutput<T>>;
+
+	private readonly _onDidChangeStatus = new Emitter<PendingRpcStatus>();
+	private _status: PendingRpcStatus = PendingRpcStatus.Pending;
+
+	/**
+	 * Can be used to check if the RPC is waiting to execute, or if it has already started.
+	 */
+	public readonly onDidChangeStatus: Event<PendingRpcStatus> = this._onDidChangeStatus.event;
+
+
 	constructor(
 		public readonly responseKeys: Array<string>
 	) {
 		this.promise = new DeferredPromise<IRuntimeClientOutput<T>>();
 	}
+
+	public setStatus(status: PendingRpcStatus): void {
+		this._status = status;
+		this._onDidChangeStatus.fire(status);
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: jmcphers
body: This could use a guard to avoid firing events where the status has not changed, unless the intent is for that to be possible.
created_at: 2025-04-23T15:23:12Z
author_association: COLLABORATOR
diff_hunk: @@ -1018,7 +1095,7 @@ class ExtHostRuntimeClientInstance<Input, Output>
 	 *
 	 * @param request The request to send to the server.
 	 * @param timeout Timeout in milliseconds after which to error if the server
-	 *   does not respond.
+	 *   does not respond. If <= 0 the promise will never timeout.
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: jmcphers
body: I think it would be clearer to use `undefined` to signal no timeout rather than passing a special/arbitrary numeric value. 
created_at: 2025-04-23T15:29:23Z
author_association: COLLABORATOR
diff_hunk: @@ -31,6 +34,56 @@ interface VariablesCoreProps extends PositronVariablesProps {
 export const VariablesCore = (props: VariablesCoreProps) => {
 	// Context hooks.
 	const positronVariablesContext = usePositronVariablesContext();
+	const progressRef = useRef<HTMLDivElement>(null);
+
+	useEffect(() => {
+		const disposables = new DisposableStore();
+
+		let progressBar: ProgressBar | undefined;
+
+		const clearProgressBar = () => {
+			if (progressBar) {
+				progressBar.done();
+				progressBar.dispose();
+				progressBar = undefined;
+				progressRef.current?.replaceChildren();
+			}
+		}
+
+		const setProgressBar = () => {
+			// No work to do if we don't have a progress bar.
+			if (!progressRef.current) {
+				return;
+			}
+
+			// Before starting a new render, remove any existing progress bars. This prevents
+			// a buildup of progress bars when rendering multiple times and ensures the progress bar
+			// is removed when a new render is requested before the previous one completes.
+			progressRef.current.replaceChildren();
+			// Create the progress bar.
+			progressBar = new ProgressBar(progressRef.current);
+			progressBar.infinite();
+		}
+
+		if (positronVariablesContext.activePositronVariablesInstance) {
+			disposables.add(positronVariablesContext.activePositronVariablesInstance.onDidChangeStatus((status) => {
+				if (status === RuntimeClientStatus.Busy) {
+					setProgressBar();
path: src/vs/workbench/contrib/positronVariables/browser/components/variablesCore.tsx
user: login: jmcphers
body: Should this be slightly debounced? I don't know if we'd want to flicker a progress bar in the average case (e.g. expansion takes < 400ms). 
created_at: 2025-04-23T15:57:35Z
author_association: COLLABORATOR
diff_hunk: @@ -958,17 +962,65 @@ class ExtHostLanguageRuntimeSessionAdapter implements ILanguageRuntimeSession {
 	}
 }
 
+enum PendingRpcStatus {
+	/**
+	 * The RPC was sent to the kernel, but the kernel didn't start working on it yet.
+	 */
+	Pending,
+	/**
+	 * The Kernel sent a state message with the RPC ID (as the parent id) and state 'Busy'
+	 * indicating that it has started handling the RPC.
+	 */
+	Executing,
+	/**
+	 * The Kernel sent a state message with the RPC ID (as the parent id) and status 'Idle'
+	 * indicating that it has finished handling the RPC.
+	 * Note: Completed means that the server finished processing the RPC, but it doesn't
+	 * mean that the RPC was successful. The promise may still be rejected.
+	 */
+	Completed,
+	/***
+	 * The Kernel closed before the RPC was completed, or the RPC response never arrived
+	 * within the timeout period.
+	 */
+	Error,
+}
+
 /**
  * Represents a pending RPC call that has been sent to the server side of a
  * comm.
  */
 class PendingRpc<T> {
 	public readonly promise: DeferredPromise<IRuntimeClientOutput<T>>;
+
+	private readonly _onDidChangeStatus = new Emitter<PendingRpcStatus>();
+	private _status: PendingRpcStatus = PendingRpcStatus.Pending;
+
+	/**
+	 * Can be used to check if the RPC is waiting to execute, or if it has already started.
+	 */
+	public readonly onDidChangeStatus: Event<PendingRpcStatus> = this._onDidChangeStatus.event;
+
+
 	constructor(
 		public readonly responseKeys: Array<string>
 	) {
 		this.promise = new DeferredPromise<IRuntimeClientOutput<T>>();
 	}
+
+	public setStatus(status: PendingRpcStatus): void {
+		this._status = status;
+		this._onDidChangeStatus.fire(status);
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: dfalbel
body: Done in https://github.com/posit-dev/positron/pull/7286/commits/07e9d3167531f67af28c31a0e6bb119a5fae95f1
created_at: 2025-04-23T18:02:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1018,7 +1095,7 @@ class ExtHostRuntimeClientInstance<Input, Output>
 	 *
 	 * @param request The request to send to the server.
 	 * @param timeout Timeout in milliseconds after which to error if the server
-	 *   does not respond.
+	 *   does not respond. If <= 0 the promise will never timeout.
path: src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts
user: login: dfalbel
body: Done in https://github.com/posit-dev/positron/pull/7286/commits/8b30cb7d58270e848da22a5b8deafadad30429b0
created_at: 2025-04-23T18:03:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,6 +34,56 @@ interface VariablesCoreProps extends PositronVariablesProps {
 export const VariablesCore = (props: VariablesCoreProps) => {
 	// Context hooks.
 	const positronVariablesContext = usePositronVariablesContext();
+	const progressRef = useRef<HTMLDivElement>(null);
+
+	useEffect(() => {
+		const disposables = new DisposableStore();
+
+		let progressBar: ProgressBar | undefined;
+
+		const clearProgressBar = () => {
+			if (progressBar) {
+				progressBar.done();
+				progressBar.dispose();
+				progressBar = undefined;
+				progressRef.current?.replaceChildren();
+			}
+		}
+
+		const setProgressBar = () => {
+			// No work to do if we don't have a progress bar.
+			if (!progressRef.current) {
+				return;
+			}
+
+			// Before starting a new render, remove any existing progress bars. This prevents
+			// a buildup of progress bars when rendering multiple times and ensures the progress bar
+			// is removed when a new render is requested before the previous one completes.
+			progressRef.current.replaceChildren();
+			// Create the progress bar.
+			progressBar = new ProgressBar(progressRef.current);
+			progressBar.infinite();
+		}
+
+		if (positronVariablesContext.activePositronVariablesInstance) {
+			disposables.add(positronVariablesContext.activePositronVariablesInstance.onDidChangeStatus((status) => {
+				if (status === RuntimeClientStatus.Busy) {
+					setProgressBar();
path: src/vs/workbench/contrib/positronVariables/browser/components/variablesCore.tsx
user: login: dfalbel
body: Done in https://github.com/posit-dev/positron/pull/7286/commits/4bb14d52a9e83a9975f0538de0b9ea1655152c67
created_at: 2025-04-23T18:03:25Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-17T12:34:14Z
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` `@:variables` `@:data-explorer` `@:connections`

<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: dfalbel
created_at: 2025-04-23T18:11:05Z
body: I ended up deciding it's better to be more conservative and only change the default timeout for the variables pane for now and wait for some more feedback regarding this change.

So I set back the default timeout to 5s and now explicitly set the timeout for variables pane comms to `undefined` to they don't time out: https://github.com/posit-dev/positron/pull/7286/commits/04d567e98c99d781cebb6c5deb0481a0c343b1df

I also rebased and just started a new full test suite run: https://github.com/posit-dev/positron/actions/runs/14625098616

Regarding:

> (a) the client understands whether the request can be handled outside the main thread, (b) the server acknowledges requests and lets the client know when they are being processed, and (c) notifications can be sent outside the shell socket (Lionel has some thoughts on this).

One current limitation, is that ipykernel only processes comm messages in the main thread(https://github.com/ipython/ipykernel/issues/65), so even if there were some requests that could be served outside of it, they wouldn't leave the ZMQ socket until the main thread is idle (or it calls eg `asyncio.sleep(5)`). Maybe that's what (c) is about though, I'm not sure I fully follow the implications of moving notifications outside of the shell socket. In Ark, it's totally possible to serve requests from worker threads if they don't try to access the R API. So at least, an RPC can be used to schedule a task that will be executed later. For instance we do this for the data explorer here: https://github.com/posit-dev/ark/blob/274c5a72541dba9ce9fb2cbdf616cc8e9c813029/crates/ark/src/data_explorer/r_data_explorer.rs#L515-L523
author_association: CONTRIBUTOR
user: login: dfalbel
created_at: 2025-04-26T13:47:24Z
body: Rebased and kicked another full test run: https://github.com/posit-dev/positron/actions/runs/14680936755 which is successful.
author_association: CONTRIBUTOR