From 6b20055ccfa28c288273a2057f7bd3dcd4f11f3f Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Tue, 16 Jun 2026 12:15:13 -0500 Subject: [PATCH 01/24] Attach to existing workers (#DH-22593) --- .gitignore | 2 +- docs/manual-smoke-attach-existing-workers.md | 27 +++ package-lock.json | 61 ++++-- package.json | 6 +- src/dh/ambientModules.d.ts | 19 ++ src/dh/dhe.ts | 98 +++++++-- src/dh/modules.d.ts | 9 - src/services/DheService.ts | 148 ++++++++++--- src/services/ServerManager.ts | 212 ++++++++++++++++--- src/types/serviceTypes.d.ts | 6 +- 10 files changed, 471 insertions(+), 117 deletions(-) create mode 100644 docs/manual-smoke-attach-existing-workers.md create mode 100644 src/dh/ambientModules.d.ts diff --git a/.gitignore b/.gitignore index 161357f41..819f78389 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ dist node_modules /*.vsix .claude -.tasks +.plans .vscode-test/ .DS_Store e2e-testing/.resources diff --git a/docs/manual-smoke-attach-existing-workers.md b/docs/manual-smoke-attach-existing-workers.md new file mode 100644 index 000000000..f256b8a4c --- /dev/null +++ b/docs/manual-smoke-attach-existing-workers.md @@ -0,0 +1,27 @@ +# Manual Smoke: Attach to Existing Workers (CLI-free) + +Prereqs: a configured Core+ DHE server in extension settings; the ability to +create a worker from a second source (a second VS Code window, the web Query +Monitor, or `dh worker create -p `). + +1. Ensure you have NO running InteractiveConsole workers you own. +2. Click the DHE server in the Deephaven server tree. + -> Login prompt (if needed), then exactly ONE worker is created and + appears as a connected session. (attachable.length === 0 → create path.) +3. From a second source, create another IC worker as the SAME user. + -> Within a couple seconds the new worker auto-attaches under the server + as a second connection. No click required. +4. In an editor connected to the auto-attached worker, run: + from deephaven import empty_table; t = empty_table(5) + -> "t" appears in the variables panel. +5. From the second source, run a script on that worker creating a table. + -> The new variable appears in VS Code's panel (subscribeToFieldUpdates). +6. Disconnect the auto-attached worker's connection from the tree. + -> The connection drops. Verify the worker is STILL ALIVE in the web + Query Monitor (the extension did NOT delete a worker it didn't create). +7. Delete that worker from the second source. + -> Within a couple seconds the connection disappears from the VS Code tree. +8. Disconnect the DHE server. + -> All worker connections drop. The worker the extension auto-created in + step 2 is deleted (owned); any still-running externally-created workers + survive. diff --git a/package-lock.json b/package-lock.json index bc4679aea..a013b203e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "packages/*" ], "dependencies": { - "@deephaven-enterprise/auth-nodejs": "^1.20250219.134-beta", - "@deephaven-enterprise/query-utils": "^1.20250219.134-beta", + "@deephaven-enterprise/auth-nodejs": "^2026.1.38", + "@deephaven-enterprise/query-utils": "^2026.1.38", "@deephaven/jsapi-nodejs": "^1.15.0", "@deephaven/jsapi-utils": "^1.16.0", "@modelcontextprotocol/sdk": "^1.27.1", @@ -23,7 +23,7 @@ }, "devDependencies": { "@cfworker/json-schema": "^4.1.1", - "@deephaven-enterprise/jsapi-types": "^1.20250219.134-beta", + "@deephaven-enterprise/jsapi-types": "^2026.1.38", "@deephaven/jsapi-types": "^41.2.0", "@react-types/shared": "^3.33.1", "@types/archiver": "^6.0.3", @@ -556,9 +556,9 @@ } }, "node_modules/@deephaven-enterprise/auth-nodejs": { - "version": "1.20250219.134-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/auth-nodejs/-/auth-nodejs-1.20250219.134-beta.tgz", - "integrity": "sha512-INkEml6Al6dQckHhx2gKy53uk2QMb+1IcpL0mu5Z7H7kfRcH7o54KfB9j8DsSYmFZyjdRd1wmUzgfAf8xtxqQA==", + "version": "2026.1.38", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/auth-nodejs/-/auth-nodejs-2026.1.38.tgz", + "integrity": "sha512-eMSlC52lQSnuPIiarHKBv7Yu5lufgKYgHXlrFWxAR+1UJIGf7SgtiW44X6ijsY1bSPrJAjgbaLUnD3j6DtlX2g==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@deephaven-enterprise/jsapi-types": "file:../jsapi-types", @@ -570,9 +570,9 @@ "link": true }, "node_modules/@deephaven-enterprise/jsapi-types": { - "version": "1.20250219.134-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-1.20250219.134-beta.tgz", - "integrity": "sha512-PRb6Z2J4msFDgooPTcGinml4xcoTc/YYkxK5PVOYV8ax71JXeicsGl4ndIcmgDZwdaciJhvVwhtuY5lMJkmTMg==", + "version": "2026.1.38", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-2026.1.38.tgz", + "integrity": "sha512-k+VEeI0QSF3eCqpc+N3LDNiC1WBfJ7yVEz1eLaFzHerYY7JwRKmJe6WmPjFn6CuMozqE/yhKAor3fFyPu5NltQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@deephaven/jsapi-types": "^1.0.0-dev0.36.1" @@ -585,11 +585,12 @@ "license": "Apache-2.0" }, "node_modules/@deephaven-enterprise/query-utils": { - "version": "1.20250219.134-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-1.20250219.134-beta.tgz", - "integrity": "sha512-zkrzFGRgWScDvMA1/tLBE/LtwFDKEoZYSiOdxtgih2+xzP6mgn+VnjWevtcBxPTDJCf6+n7+R+YfGW7TqJtXcw==", + "version": "2026.1.38", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-2026.1.38.tgz", + "integrity": "sha512-9BOb/0mJP/kst72aL4jmsRmidR+Z9gXkihxnfILdWLU/D/Go+KRZ5ObYVHPiO2ntiJ1ymYxUFie6TMNpb31WyQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { + "@deephaven-enterprise/jsapi-types": "file:../jsapi-types", "@deephaven/jsapi-types": "^1.0.0-dev0.36.1", "@deephaven/log": "^0.97.0", "@deephaven/utils": "^0.97.0", @@ -597,6 +598,10 @@ "nanoid": "^5.1.6" } }, + "node_modules/@deephaven-enterprise/query-utils/node_modules/@deephaven-enterprise/jsapi-types": { + "resolved": "node_modules/@deephaven-enterprise/jsapi-types", + "link": true + }, "node_modules/@deephaven-enterprise/query-utils/node_modules/@deephaven/jsapi-types": { "version": "1.0.0-dev0.40.9", "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.9.tgz", @@ -15000,9 +15005,9 @@ "dev": true }, "@deephaven-enterprise/auth-nodejs": { - "version": "1.20250219.134-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/auth-nodejs/-/auth-nodejs-1.20250219.134-beta.tgz", - "integrity": "sha512-INkEml6Al6dQckHhx2gKy53uk2QMb+1IcpL0mu5Z7H7kfRcH7o54KfB9j8DsSYmFZyjdRd1wmUzgfAf8xtxqQA==", + "version": "2026.1.38", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/auth-nodejs/-/auth-nodejs-2026.1.38.tgz", + "integrity": "sha512-eMSlC52lQSnuPIiarHKBv7Yu5lufgKYgHXlrFWxAR+1UJIGf7SgtiW44X6ijsY1bSPrJAjgbaLUnD3j6DtlX2g==", "requires": { "@deephaven-enterprise/jsapi-types": "file:../jsapi-types", "@deephaven/utils": "^0.97.0" @@ -15024,9 +15029,9 @@ } }, "@deephaven-enterprise/jsapi-types": { - "version": "1.20250219.134-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-1.20250219.134-beta.tgz", - "integrity": "sha512-PRb6Z2J4msFDgooPTcGinml4xcoTc/YYkxK5PVOYV8ax71JXeicsGl4ndIcmgDZwdaciJhvVwhtuY5lMJkmTMg==", + "version": "2026.1.38", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-2026.1.38.tgz", + "integrity": "sha512-k+VEeI0QSF3eCqpc+N3LDNiC1WBfJ7yVEz1eLaFzHerYY7JwRKmJe6WmPjFn6CuMozqE/yhKAor3fFyPu5NltQ==", "requires": { "@deephaven/jsapi-types": "^1.0.0-dev0.36.1" }, @@ -15039,10 +15044,11 @@ } }, "@deephaven-enterprise/query-utils": { - "version": "1.20250219.134-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-1.20250219.134-beta.tgz", - "integrity": "sha512-zkrzFGRgWScDvMA1/tLBE/LtwFDKEoZYSiOdxtgih2+xzP6mgn+VnjWevtcBxPTDJCf6+n7+R+YfGW7TqJtXcw==", + "version": "2026.1.38", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-2026.1.38.tgz", + "integrity": "sha512-9BOb/0mJP/kst72aL4jmsRmidR+Z9gXkihxnfILdWLU/D/Go+KRZ5ObYVHPiO2ntiJ1ymYxUFie6TMNpb31WyQ==", "requires": { + "@deephaven-enterprise/jsapi-types": "file:../jsapi-types", "@deephaven/jsapi-types": "^1.0.0-dev0.36.1", "@deephaven/log": "^0.97.0", "@deephaven/utils": "^0.97.0", @@ -15050,6 +15056,19 @@ "nanoid": "^5.1.6" }, "dependencies": { + "@deephaven-enterprise/jsapi-types": { + "version": "file:node_modules/@deephaven-enterprise/jsapi-types", + "requires": { + "@deephaven/jsapi-types": "^1.0.0-dev0.36.1" + }, + "dependencies": { + "@deephaven/jsapi-types": { + "version": "1.0.0-dev0.40.9", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.9.tgz", + "integrity": "sha512-NwMxFmNCnRV4/2+MdN/8vUGiEtXFgL1K/+iXTKKvi+Brje5JHOSCn2miCKR9tAn0LNb/UdmJq+DSIZqvz8cU/Q==" + } + } + }, "@deephaven/jsapi-types": { "version": "1.0.0-dev0.40.9", "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.9.tgz", diff --git a/package.json b/package.json index 1e7374e9b..51897203d 100644 --- a/package.json +++ b/package.json @@ -1125,8 +1125,8 @@ } }, "dependencies": { - "@deephaven-enterprise/auth-nodejs": "^1.20250219.134-beta", - "@deephaven-enterprise/query-utils": "^1.20250219.134-beta", + "@deephaven-enterprise/auth-nodejs": "^2026.1.38", + "@deephaven-enterprise/query-utils": "^2026.1.38", "@deephaven/jsapi-nodejs": "^1.15.0", "@deephaven/jsapi-utils": "^1.16.0", "@modelcontextprotocol/sdk": "^1.27.1", @@ -1137,7 +1137,7 @@ }, "devDependencies": { "@cfworker/json-schema": "^4.1.1", - "@deephaven-enterprise/jsapi-types": "^1.20250219.134-beta", + "@deephaven-enterprise/jsapi-types": "^2026.1.38", "@deephaven/jsapi-types": "^41.2.0", "@react-types/shared": "^3.33.1", "@types/archiver": "^6.0.3", diff --git a/src/dh/ambientModules.d.ts b/src/dh/ambientModules.d.ts new file mode 100644 index 000000000..490547c8f --- /dev/null +++ b/src/dh/ambientModules.d.ts @@ -0,0 +1,19 @@ +/** + * Ambient module declarations for packages that are referenced by the type + * declarations of our dependencies but are not installed in this project. + * + * IMPORTANT: This file must stay a global *script* — do NOT add any top-level + * `import` or `export`. A `.d.ts` with a top-level import/export becomes a + * *module*, and inside a module `declare module 'x'` is treated as + * *augmentation* of an already-existing module. These modules are not + * installed, so there is nothing to augment; only a script-file *ambient module + * declaration* actually creates the module and lets the import resolve. + */ + +// `@deephaven-enterprise/query-utils@2026.x` ships a `.d.ts` (QueryUtils.d.ts) +// that does `import type { UriVariableDescriptor } from '@deephaven/jsapi-bootstrap'`, +// but does not declare `@deephaven/jsapi-bootstrap` as a dependency and it is +// not installed. Declare a minimal stub so the type import resolves (TS2307). +declare module '@deephaven/jsapi-bootstrap' { + type UriVariableDescriptor = string; +} diff --git a/src/dh/dhe.ts b/src/dh/dhe.ts index 82c828e45..00c9a2215 100644 --- a/src/dh/dhe.ts +++ b/src/dh/dhe.ts @@ -42,7 +42,7 @@ import { UnsupportedFeatureQueryError, } from '../common'; import { withResolvers } from '../util'; -import type { QuerySerial } from '../shared'; +import { assertDefined, type QuerySerial } from '../shared'; export type IDraftQuery = EditableQueryInfo & { isClientSide: boolean; @@ -411,6 +411,80 @@ export async function getDheAuthConfig( return authConfig; } +/** + * Determine if a given query info represents an attachable IC worker for the + * current effective user. An attachable worker is an InteractiveConsole type, + * owned by `operateAs`, and currently Running. + * @param queryInfo Query info to check. + * @param operateAs The effective user to match ownership against. + * @returns True if the query is attachable, false otherwise. + */ +export function isAttachableWorker( + queryInfo: QueryInfo, + operateAs: string +): boolean { + return ( + queryInfo.type === INTERACTIVE_CONSOLE_QUERY_TYPE && + queryInfo.owner === operateAs && + queryInfo.designated?.status === 'Running' + ); +} + +/** + * List all running InteractiveConsole workers owned by the current effective + * user. + * @param dheClient DHE client to use. + * @returns A promise resolving to the filtered QueryInfo array. + */ +export async function listAttachableWorkers( + dheClient: DheAuthenticatedClient +): Promise { + const userInfo = await dheClient.getUserInfo(); + const operateAs = userInfo.operateAs; + return dheClient + .getKnownConfigs() + .filter(qi => isAttachableWorker(qi, operateAs)); +} + +/** + * Build WorkerInfo from an already-Running QueryInfo without any I/O or + * side effects. Returns undefined when `queryInfo.designated` is null. + * @param tagId Unique tag id to associate with the worker. + * @param queryInfo The running query info. + * @returns WorkerInfo or undefined. + */ +export function buildWorkerInfo( + tagId: UniqueID, + queryInfo: QueryInfo +): WorkerInfo | undefined { + if (queryInfo.designated == null) { + return; + } + + assertDefined( + queryInfo.designated.ideUrl, + 'designated.ideUrl must be defined' + ); + + const { envoyPrefix, grpcUrl, ideUrl, jsApiUrl, processInfoId, workerName } = + queryInfo.designated; + + const workerUrl = new URL(jsApiUrl) as WorkerURL; + workerUrl.pathname = workerUrl.pathname.replace(/jsapi\/dh-core.js$/, ''); + + return { + tagId, + serial: queryInfo.serial as QuerySerial, + envoyPrefix, + grpcUrl: new URL(grpcUrl) as GrpcURL, + ideUrl: new URL(ideUrl) as IdeURL, + jsapiUrl: new URL(jsApiUrl) as JsapiURL, + processInfoId, + workerName, + workerUrl, + }; +} + /** * Search existing queries for a query with the given tag id and return its serial. * @param tagId Unique tag id to search for. @@ -498,27 +572,7 @@ export async function getWorkerInfoFromQuery( } } - if (queryInfo.designated == null) { - return; - } - - const { envoyPrefix, grpcUrl, ideUrl, jsApiUrl, processInfoId, workerName } = - queryInfo.designated; - - const workerUrl = new URL(jsApiUrl) as WorkerURL; - workerUrl.pathname = workerUrl.pathname.replace(/jsapi\/dh-core.js$/, ''); - - return { - tagId, - serial: querySerial, - envoyPrefix, - grpcUrl: new URL(grpcUrl) as GrpcURL, - ideUrl: new URL(ideUrl) as IdeURL, - jsapiUrl: new URL(jsApiUrl) as JsapiURL, - processInfoId, - workerName, - workerUrl, - }; + return buildWorkerInfo(tagId, queryInfo); } /** diff --git a/src/dh/modules.d.ts b/src/dh/modules.d.ts index d7de75a7b..ea6f96fce 100644 --- a/src/dh/modules.d.ts +++ b/src/dh/modules.d.ts @@ -1,12 +1,3 @@ -/** - * Augment types that are missing in current jsapi-types. - */ -declare module '@deephaven-enterprise/jsapi-types' { - interface EnterpriseClient { - deleteQueries(querySerials: string[]): Promise; - } -} - export {}; /** diff --git a/src/services/DheService.ts b/src/services/DheService.ts index 79b504668..a9dfe02aa 100644 --- a/src/services/DheService.ts +++ b/src/services/DheService.ts @@ -18,14 +18,17 @@ import { type WorkerInfo, type WorkerURL, } from '../types'; -import { Logger, URLMap } from '../util'; +import { Logger, uniqueId, URLMap } from '../util'; import { + buildWorkerInfo, createInteractiveConsoleQuery, createQueryName, deleteQueries, getDheFeatures, getSerialFromTagId, getWorkerInfoFromQuery, + isAttachableWorker, + listAttachableWorkers as listAttachableWorkersHelper, } from '../dh/dhe'; import { CLOSE_CREATE_QUERY_VIEW_CMD, @@ -101,6 +104,8 @@ export class DheService implements IDheService { private _clientPromise: Promise | null = null; private _isConnected: boolean = false; + private _operateAs: string | null = null; + private _removeConfigListeners: (() => void) | null = null; private readonly _config: IConfigService; private readonly _dheClientCache: URLMap; private readonly _dheJsApiCache: IAsyncCacheService; @@ -110,8 +115,12 @@ export class DheService implements IDheService { private readonly _toaster: IToastService; private readonly _workerInfoMap: URLMap; - private readonly _onDidWorkerTerminate = new vscode.EventEmitter(); - readonly onDidWorkerTerminate = this._onDidWorkerTerminate.event; + private readonly _onDidWorkerAttachable = + new vscode.EventEmitter(); + readonly onDidWorkerAttachable = this._onDidWorkerAttachable.event; + + private readonly _onDidWorkerRemoved = new vscode.EventEmitter(); + readonly onDidWorkerRemoved = this._onDidWorkerRemoved.event; readonly serverUrl: URL; @@ -141,7 +150,9 @@ export class DheService implements IDheService { const maybeClient = await this._dheClientCache.get(this.serverUrl); if (maybeClient != null) { - this._subscribeToWorkerTermination(maybeClient); + const userInfo = await maybeClient.client.getUserInfo(); + this._operateAs = userInfo.operateAs; + await this._subscribeToWorkerEvents(maybeClient); } if (!this._dheServerFeaturesCache.has(this.serverUrl)) { @@ -177,7 +188,18 @@ export class DheService implements IDheService { }; private _onDidDheClientCacheInvalidate = (url: URL): void => { - if (url.toString() === this.serverUrl.toString()) { + // Only reset when the client was actually removed from the cache (logout / + // disconnect / failed login). The cache also fires `onDidChange` on `set`, + // which happens during a successful login from inside `_initClient` itself. + // Resetting on that `set` would null out the in-flight `_clientPromise` we + // are currently awaiting, so a subsequent `getClient(false)` (e.g. from + // `listAttachableWorkers`) would see `null` and short-circuit — silently + // skipping the attach path. Guard on the client being absent so we only + // reset for genuine invalidations. + if ( + url.toString() === this.serverUrl.toString() && + !this._dheClientCache.has(this.serverUrl) + ) { // Reset the client promise so that the next call to `getClient` can // reinitialize it if necessary. this._clientPromise = null; @@ -185,37 +207,70 @@ export class DheService implements IDheService { }; /** - * Subscribe to DHE config updates to detect when workers enter a terminal state. + * Subscribe to DHE config added/updated/removed events. Emits + * `onDidWorkerAttachable` when an IC worker becomes attachable, and + * `onDidWorkerRemoved` when a tracked worker is removed or enters a terminal + * state. Replaces any previous subscription. * @param dheClient DHE client to use. */ - private _subscribeToWorkerTermination = async ( + private _subscribeToWorkerEvents = async ( dheClient: DheAuthenticatedClientWrapper ): Promise => { + // Remove any previous listeners before re-registering. + this._removeConfigListeners?.(); + const dhe = await this._dheJsApiCache.get(this.serverUrl); + const operateAs = this._operateAs ?? ''; - dheClient.client.addEventListener( - dhe.Client.EVENT_CONFIG_UPDATED, - ({ detail: queryInfo }: CustomEvent) => { - const status = queryInfo.designated?.status; - if (!isTerminalQueryStatus(status)) { - return; - } + const onConfigAddedOrUpdated = ({ + detail: queryInfo, + }: CustomEvent): void => { + const status = queryInfo.designated?.status; - const workerInfo = [...this._workerInfoMap.values()].find( + if (isTerminalQueryStatus(status)) { + // Terminal status on a tracked worker → signal removal. + const isTracked = [...this._workerInfoMap.values()].some( w => w.serial === queryInfo.serial ); - - if (workerInfo == null) { - return; + if (isTracked) { + logger.info('Worker entered terminal state:', queryInfo.serial); + this._onDidWorkerRemoved.fire(queryInfo.serial as QuerySerial); } + } else if (isAttachableWorker(queryInfo, operateAs)) { + this._onDidWorkerAttachable.fire(queryInfo); + } + }; - logger.info( - 'Worker entered terminal state:', - workerInfo.workerUrl.href - ); - this._onDidWorkerTerminate.fire(workerInfo.workerUrl); + const onConfigRemoved = ({ + detail: queryInfo, + }: CustomEvent): void => { + const isTracked = [...this._workerInfoMap.values()].some( + w => w.serial === queryInfo.serial + ); + if (isTracked) { + logger.info('Worker removed:', queryInfo.serial); + this._onDidWorkerRemoved.fire(queryInfo.serial as QuerySerial); } + }; + + const removeAdded = dheClient.client.addEventListener( + dhe.Client.EVENT_CONFIG_ADDED, + onConfigAddedOrUpdated ); + const removeUpdated = dheClient.client.addEventListener( + dhe.Client.EVENT_CONFIG_UPDATED, + onConfigAddedOrUpdated + ); + const removeRemoved = dheClient.client.addEventListener( + dhe.Client.EVENT_CONFIG_REMOVED, + onConfigRemoved + ); + + this._removeConfigListeners = (): void => { + removeAdded(); + removeUpdated(); + removeRemoved(); + }; }; /** @@ -371,7 +426,41 @@ export class DheService implements IDheService { }; /** - * Delete a worker. + * Attach to a pre-existing Running worker by building WorkerInfo from the + * given QueryInfo. Does NOT add the serial to `_querySerialSet` — attached + * workers are never owned and must never be deleted by the extension. + * @param queryInfo The already-Running QueryInfo for the worker to attach. + * @returns WorkerInfo for the attached worker. + */ + attachWorker = (queryInfo: QueryInfo): WorkerInfo => { + const tagId = uniqueId(); + const workerInfo = buildWorkerInfo(tagId, queryInfo); + if (workerInfo == null) { + throw new Error( + `Cannot attach worker: queryInfo.designated is null for serial ${queryInfo.serial}` + ); + } + this._workerInfoMap.set(workerInfo.workerUrl, workerInfo); + return workerInfo; + }; + + /** + * List all running InteractiveConsole workers owned by the current effective + * user. + * @returns A promise resolving to the filtered QueryInfo array. + */ + listAttachableWorkers = async (): Promise => { + const dheClient = await this.getClient(false); + if (dheClient == null) { + return []; + } + return listAttachableWorkersHelper(dheClient.client); + }; + + /** + * Delete a worker. Only deletes the server-side PQ when the worker is owned + * by this extension (serial is in `_querySerialSet`). Attached workers are + * removed from `_workerInfoMap` but the PQ is left running. * @param workerUrl Worker URL to delete. */ deleteWorker = async (workerUrl: WorkerURL): Promise => { @@ -380,10 +469,14 @@ export class DheService implements IDheService { return; } + const isOwned = this._querySerialSet.has(workerInfo.serial); + this._querySerialSet.delete(workerInfo.serial); this._workerInfoMap.delete(workerUrl); - await this._disposeQueries([workerInfo.serial]); + if (isOwned) { + await this._disposeQueries([workerInfo.serial]); + } }; getQuerySerialFromTag = async ( @@ -407,7 +500,10 @@ export class DheService implements IDheService { const querySerials = [...this._querySerialSet]; this._querySerialSet.clear(); - this._onDidWorkerTerminate.dispose(); + this._removeConfigListeners?.(); + this._removeConfigListeners = null; + this._onDidWorkerAttachable.dispose(); + this._onDidWorkerRemoved.dispose(); await Promise.all([ this._workerInfoMap.dispose(), diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 334b5029c..afc1f08ed 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { randomUUID } from 'node:crypto'; import type { dh as DhcType } from '@deephaven/jsapi-types'; +import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; import { QueryCreationCancelledError, QueryStartupFailureError, @@ -35,6 +36,7 @@ import { } from '../util'; import { DhcService } from './DhcService'; import { getWorkerCredentials, isDheServerRunning } from '../dh/dhe'; +import type { QuerySerial } from '../shared'; import { isDhcServerRunning } from '../dh/dhc'; const logger = new Logger('ServerManager'); @@ -69,6 +71,8 @@ export class ServerManager implements IServerManager { void this.loadServerConfig(); } + private readonly _attachedWorkerSerials: Map = + new Map(); private readonly _configService: IConfigService; private readonly _connectionMap: URLMap; private readonly _pendingConnectionMap: URLMap< @@ -256,8 +260,9 @@ export class ServerManager implements IServerManager { let placeholderUrl: URL | undefined; if (serverState.type === 'DHE') { - const isNewDheService = !this._dheServiceCache.has(serverUrl); - const dheService = await this._dheServiceCache.get(serverUrl); + const dheServerUrl = serverUrl; + const isNewDheService = !this._dheServiceCache.has(dheServerUrl); + const dheService = await this._dheServiceCache.get(dheServerUrl); // Get client. Client will be initialized if it doesn't exist (including // prompting user for login). @@ -265,29 +270,76 @@ export class ServerManager implements IServerManager { return null; } - // Handle workers stopped externally (i.e. web-client Query Monitor) - if (isNewDheService) { - dheService.onDidWorkerTerminate(workerUrl => { - this.disconnectFromServer(workerUrl); + // Mark the DHE server as connected now that we have a live client. + // (connectionCount stays 0 until workers attach; isConnected reflects + // the server-level connection, which is now established.) + const currentServerState = this._serverMap.get(dheServerUrl); + if (currentServerState != null && !currentServerState.isConnected) { + this._serverMap.set(dheServerUrl, { + ...currentServerState, + isConnected: true, }); + this._onDidUpdate.fire(); } - // The `serverUrl` in this block is for the DHE server but gets set to the - // newly created worker url before leaving the block, so we need to update - // the connection count while we still have the reference. - this.updateConnectionCount(serverUrl, 1); - tagId = uniqueId(); + // Wire the config-event subscription exactly once per DHE service + // instance, BEFORE snapshotting, so any worker that appears or disappears + // between the snapshot and now is not missed. `_connectWorker` reserves + // each serial synchronously (before any `await`), so an event-driven + // attach and the snapshot batch below can never double-connect the same + // worker — whichever reaches `_connectWorker` first wins and the other is + // a no-op. + if (isNewDheService) { + dheService.onDidWorkerAttachable(qi => + this._reconcileAttach(dheServerUrl, dheService, qi) + ); + dheService.onDidWorkerRemoved(serial => this._reconcileDetach(serial)); + } - // Put a placeholder connection in place until the worker is ready. - placeholderUrl = this.addWorkerPlaceholderConnection(serverUrl, tagId); + // Snapshot currently-running attachable workers and compute the + // not-yet-attached subset. + const attachable = await dheService.listAttachableWorkers(); + const toAttach = attachable.filter( + qi => !this._attachedWorkerSerials.has(qi.serial as QuerySerial) + ); + + if (toAttach.length > 0) { + // Attach existing workers in batches to avoid server stampede. + const BATCH_SIZE = 4; + let firstConnection: ConnectionState | null = null; + for (let i = 0; i < toAttach.length; i += BATCH_SIZE) { + const batch = toAttach.slice(i, i + BATCH_SIZE); + const connections = await Promise.all( + batch.map(async qi => { + const workerInfo = dheService.attachWorker(qi); + return this._connectWorker(dheServerUrl, workerInfo); + }) + ); + if (firstConnection == null) { + firstConnection = connections.find(c => c != null) ?? null; + } + } + if (toAttach.length > 1) { + this._toaster.info(`Attached to ${toAttach.length} worker(s).`); + } + return firstConnection; + } + + // Nothing left to attach — either no workers exist for this user, or all + // of them already have live connections. Create a fresh one so clicking a + // server always yields a usable session. + const tagId = uniqueId(); + const placeholderUrl = this.addWorkerPlaceholderConnection( + dheServerUrl, + tagId + ); let workerInfo: WorkerInfo; try { workerInfo = await dheService.createWorker(tagId, workerConsoleType); - // If the worker finished creating, but there is no placeholder connection, - // this indicates that the user cancelled the creation before it was ready. - // In this case, dispose of the worker. + // If the worker finished creating but there is no placeholder + // connection, the user cancelled before it was ready. if (!this._connectionMap.has(placeholderUrl)) { dheService.deleteWorker(workerInfo.workerUrl); this._onDidUpdate.fire(); @@ -309,33 +361,20 @@ export class ServerManager implements IServerManager { this._toaster.error(msg); } - this.updateConnectionCount(serverUrl, -1); - this._connectionMap.delete(placeholderUrl); + this.removeWorkerPlaceholderConnection(placeholderUrl); return null; } - // Map the worker URL to the server URL to make things easier to dispose - // later. - this._workerURLToServerURLMap.set( - new URL(workerInfo.workerUrl), - serverUrl - ); - - // Update the server URL to the worker url to be used below with core - // connection creation. - serverUrl = new URL(workerInfo.workerUrl); + this.removeWorkerPlaceholderConnection(placeholderUrl); + return this._connectWorker(dheServerUrl, workerInfo); } - const connection = this._dhcServiceFactory.create(serverUrl, tagId); + // DHC path: single direct connection. + const connection = this._dhcServiceFactory.create(serverUrl, undefined); // Initialize client + prompt for login if necessary const coreClient = await connection.getClient(); - // Cleanup placeholder connection if one exists - if (placeholderUrl) { - this.removeWorkerPlaceholderConnection(placeholderUrl); - } - if (coreClient == null) { return null; } @@ -368,6 +407,102 @@ export class ServerManager implements IServerManager { return this._connectionMap.get(serverUrl) ?? null; }; + /** + * Create a Core+ JS API connection to an existing worker. Populates + * `_workerURLToServerURLMap` for auth lookup and `_attachedWorkerSerials` + * for idempotency/teardown. Used by both the create path and the attach path. + * Does NOT touch placeholder connections — that is the create path's concern. + * @param dheServerUrl The DHE server this worker belongs to. + * @param workerInfo Worker info built from the query. + * @returns The new connection state, or null on failure. + */ + private _connectWorker = async ( + dheServerUrl: URL, + workerInfo: WorkerInfo + ): Promise => { + const workerUrl = new URL(workerInfo.workerUrl); + + // Idempotency gate: reserve the serial synchronously, before any `await`, + // so concurrent attach attempts for the same worker — e.g. the initial + // enumeration racing a streaming config event, or two clicks on the same + // server — cannot both connect and double-count. A later failure rolls the + // reservation back so the worker can be retried. + if (this._attachedWorkerSerials.has(workerInfo.serial)) { + return this._connectionMap.get(workerUrl) ?? null; + } + this._attachedWorkerSerials.set(workerInfo.serial, workerInfo.workerUrl); + + // Map the worker URL to its DHE server so the auth flow can resolve creds. + this._workerURLToServerURLMap.set(workerUrl, dheServerUrl); + + const connection = this._dhcServiceFactory.create( + workerUrl, + workerInfo.tagId + ); + + // Initialize client (drives getWorkerCredentials → createAuthToken). + const coreClient = await connection.getClient(); + if (coreClient == null) { + this._attachedWorkerSerials.delete(workerInfo.serial); + return null; + } + + this._connectionMap.set(workerUrl, connection); + this._onDidUpdate.fire(); + + if (!(await connection.initSession())) { + this._attachedWorkerSerials.delete(workerInfo.serial); + this._coreClientCache.delete(workerUrl); + connection.dispose(); + this._connectionMap.delete(workerUrl); + return null; + } + + connection.onDidDisconnect(() => { + logger.debug('onDidDisconnect fired for:', workerUrl.href); + this.disconnectFromServer(workerUrl); + }); + + connection.onDidChangeRunningCodeStatus?.(() => { + this._onDidUpdate.fire(); + }); + + this.updateConnectionCount(dheServerUrl, 1); + + this._onDidConnect.fire(workerUrl); + this._onDidUpdate.fire(); + + return this._connectionMap.get(workerUrl) ?? null; + }; + + /** + * Attach a single worker when a config event indicates it became attachable. + * Idempotent: no-ops if the serial is already connected. + */ + private _reconcileAttach = async ( + dheServerUrl: URL, + dheService: IDheService, + queryInfo: QueryInfo + ): Promise => { + if (this._attachedWorkerSerials.has(queryInfo.serial as QuerySerial)) { + return; + } + const workerInfo = dheService.attachWorker(queryInfo); + await this._connectWorker(dheServerUrl, workerInfo); + }; + + /** + * Detach a worker when a config event indicates it was removed or died. + * No-ops if the serial is not tracked. + */ + private _reconcileDetach = async (serial: QuerySerial): Promise => { + const workerUrl = this._attachedWorkerSerials.get(serial); + if (workerUrl == null) { + return; + } + await this.disconnectFromServer(workerUrl); + }; + /** * Add a placeholder connection to represent a pending DHE Core+ woker creation. * @param serverUrl The DHE server URL the pending worker is associated with. @@ -459,6 +594,15 @@ export class ServerManager implements IServerManager { await dheService.deleteWorker(serverOrWorkerUrl as WorkerURL); } + // Clear the idempotency/teardown gate so a later reconnect can re-attach. + const urlStr = serverOrWorkerUrl.toString(); + for (const [serial, url] of this._attachedWorkerSerials.entries()) { + if (url.toString() === urlStr) { + this._attachedWorkerSerials.delete(serial); + break; + } + } + const connection = this._connectionMap.get(serverOrWorkerUrl); if (connection == null) { return; diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index 3e7f2890b..3d56a47b1 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import type { dh as DhcType } from '@deephaven/jsapi-types'; +import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; import type { ConsoleType, CoreConnectionConfig, @@ -80,7 +81,8 @@ export interface IDhcService extends IDisposable, ConnectionState { } export interface IDheService extends ConnectionState, IDisposable { - readonly onDidWorkerTerminate: vscode.Event; + readonly onDidWorkerAttachable: vscode.Event; + readonly onDidWorkerRemoved: vscode.Event; getClient( initializeIfNull: false @@ -92,6 +94,8 @@ export interface IDheService extends ConnectionState, IDisposable { getQuerySerialFromTag(tagId: UniqueID): Promise; getServerFeatures(): DheServerFeatures | undefined; getWorkerInfo: (workerUrl: WorkerURL) => WorkerInfo | undefined; + attachWorker: (queryInfo: QueryInfo) => WorkerInfo; + listAttachableWorkers: () => Promise; createWorker: ( tagId: UniqueID, consoleType?: ConsoleType From 89aac333a9f67e683b025e93abe3557914544434 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 4 Jun 2026 11:54:21 -0500 Subject: [PATCH 02/24] Attaching to workers (#DH-22593) --- docs/manual-smoke-attach-existing-workers.md | 27 ------------ src/dh/dhe.ts | 1 + .../ServerConnectionPanelTreeProvider.ts | 7 ++- src/providers/ServerConnectionTreeProvider.ts | 14 +++++- src/services/DheService.ts | 44 ++++++++++++++++++- src/types/commonTypes.d.ts | 2 + src/util/treeViewUtils.ts | 10 +++-- 7 files changed, 71 insertions(+), 34 deletions(-) delete mode 100644 docs/manual-smoke-attach-existing-workers.md diff --git a/docs/manual-smoke-attach-existing-workers.md b/docs/manual-smoke-attach-existing-workers.md deleted file mode 100644 index f256b8a4c..000000000 --- a/docs/manual-smoke-attach-existing-workers.md +++ /dev/null @@ -1,27 +0,0 @@ -# Manual Smoke: Attach to Existing Workers (CLI-free) - -Prereqs: a configured Core+ DHE server in extension settings; the ability to -create a worker from a second source (a second VS Code window, the web Query -Monitor, or `dh worker create -p `). - -1. Ensure you have NO running InteractiveConsole workers you own. -2. Click the DHE server in the Deephaven server tree. - -> Login prompt (if needed), then exactly ONE worker is created and - appears as a connected session. (attachable.length === 0 → create path.) -3. From a second source, create another IC worker as the SAME user. - -> Within a couple seconds the new worker auto-attaches under the server - as a second connection. No click required. -4. In an editor connected to the auto-attached worker, run: - from deephaven import empty_table; t = empty_table(5) - -> "t" appears in the variables panel. -5. From the second source, run a script on that worker creating a table. - -> The new variable appears in VS Code's panel (subscribeToFieldUpdates). -6. Disconnect the auto-attached worker's connection from the tree. - -> The connection drops. Verify the worker is STILL ALIVE in the web - Query Monitor (the extension did NOT delete a worker it didn't create). -7. Delete that worker from the second source. - -> Within a couple seconds the connection disappears from the VS Code tree. -8. Disconnect the DHE server. - -> All worker connections drop. The worker the extension auto-created in - step 2 is deleted (owned); any still-running externally-created workers - survive. diff --git a/src/dh/dhe.ts b/src/dh/dhe.ts index 00c9a2215..dc89450ce 100644 --- a/src/dh/dhe.ts +++ b/src/dh/dhe.ts @@ -479,6 +479,7 @@ export function buildWorkerInfo( grpcUrl: new URL(grpcUrl) as GrpcURL, ideUrl: new URL(ideUrl) as IdeURL, jsapiUrl: new URL(jsApiUrl) as JsapiURL, + name: queryInfo.name, processInfoId, workerName, workerUrl, diff --git a/src/providers/ServerConnectionPanelTreeProvider.ts b/src/providers/ServerConnectionPanelTreeProvider.ts index 6f602be36..b1f0a10cc 100644 --- a/src/providers/ServerConnectionPanelTreeProvider.ts +++ b/src/providers/ServerConnectionPanelTreeProvider.ts @@ -38,10 +38,15 @@ export class ServerConnectionPanelTreeProvider extends ServerTreeProviderBase void) | null = null; + private readonly _creatingTagIds = new Set(); private readonly _config: IConfigService; private readonly _dheClientCache: URLMap; private readonly _dheJsApiCache: IAsyncCacheService; @@ -236,7 +237,21 @@ export class DheService implements IDheService { logger.info('Worker entered terminal state:', queryInfo.serial); this._onDidWorkerRemoved.fire(queryInfo.serial as QuerySerial); } - } else if (isAttachableWorker(queryInfo, operateAs)) { + } else if ( + isAttachableWorker(queryInfo, operateAs) && + !this._querySerialSet.has(queryInfo.serial as QuerySerial) && + !this._isCreatingQuery(queryInfo.name) + ) { + // Skip workers the extension is creating or already owns. The create + // path connects those directly with the tagId it allocated for the + // query name (`IC - VS Code - `). Without this guard, the same + // `EVENT_CONFIG_UPDATED` that flips a freshly-created worker to + // `Running` would auto-attach it here, racing the create path: it would + // double-connect with a *new* random tagId and let the now-functioning + // detach/cleanup machinery tear down a worker mid-creation (surfacing as + // a spurious startup failure). `_querySerialSet` covers the worker once + // its serial is known; `_isCreatingQuery` covers the earlier window + // (e.g. the iframe flow) where only the query name is known (Gotcha 4). this._onDidWorkerAttachable.fire(queryInfo); } }; @@ -328,6 +343,24 @@ export class DheService implements IDheService { return dheClient; } + /** + * Whether the given query name belongs to a worker this extension is + * currently creating. Created workers are named `IC - VS Code - ` + * (the iframe create flow may append a `_vN` version suffix), so a prefix + * match against in-flight tagIds covers both forms. Used to keep the + * auto-attach event path off a worker the create path owns end-to-end, during + * the window before its serial is known and added to `_querySerialSet`. + * @param queryName The query name from a config event. + */ + private _isCreatingQuery = (queryName: string): boolean => { + for (const tagId of this._creatingTagIds) { + if (queryName.startsWith(createQueryName(tagId))) { + return true; + } + } + return false; + }; + /** * Create an InteractiveConsole query and get worker info from it. * @param tagId Unique tag id to include in the worker info. @@ -355,6 +388,14 @@ export class DheService implements IDheService { let startupFailureStatus: string | null = null; const queryName = createQueryName(tagId); + + // Suppress auto-attach for this worker while it is being created. The + // config event that flips it to `Running` would otherwise race the create + // path and auto-attach it (see `onDidWorkerAttachable` guard). Cleared in + // the `finally` below, by which point its serial is in `_querySerialSet` + // (added synchronously right after, with no `await` in between), so the + // serial-based guard takes over without a gap. + this._creatingTagIds.add(tagId); const removeStartupFailureListener = dheClient.client.addEventListener( dhe.Client.EVENT_CONFIG_UPDATED, ({ detail: queryInfo }: CustomEvent) => { @@ -403,6 +444,7 @@ export class DheService implements IDheService { throw err; } finally { removeStartupFailureListener(); + this._creatingTagIds.delete(tagId); } if (querySerial == null) { diff --git a/src/types/commonTypes.d.ts b/src/types/commonTypes.d.ts index de54d9051..0c79e1cb7 100644 --- a/src/types/commonTypes.d.ts +++ b/src/types/commonTypes.d.ts @@ -185,6 +185,8 @@ export interface WorkerInfo { grpcUrl: GrpcURL; ideUrl: IdeURL; jsapiUrl: JsapiURL; + /** Persistent query name (`queryInfo.name`), as shown in the Query Monitor. */ + name: string; processInfoId: string | null; serial: QuerySerial; workerName: string | null; diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index 686ed10c9..e71482b07 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -58,7 +58,8 @@ export async function getPanelConnectionTreeItem( getConsoleType: ( connection: ConnectionState ) => Promise, - serverLabel?: string + serverLabel?: string, + pqName?: string ): Promise { const descriptionTokens: string[] = []; @@ -68,8 +69,11 @@ export async function getPanelConnectionTreeItem( descriptionTokens.push(consoleType); } - if (connection.tagId) { - descriptionTokens.push(connection.tagId); + // Prefer the persistent query name (what the DHE Query Monitor shows) over + // the local correlation tagId. Falls back to tagId for plain DHC connections. + const idToken = pqName ?? connection.tagId; + if (idToken) { + descriptionTokens.push(idToken); } const label = serverLabel ?? connection.serverUrl.host; From 1abc83ad18239191844be4e60efb75d8eb6c6907 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 4 Jun 2026 17:22:00 -0500 Subject: [PATCH 03/24] Moved language identifier to icon instead of description (#DH-22593) --- package.json | 2 +- src/providers/ServerConnectionTreeProvider.ts | 33 ++++++++------- .../__snapshots__/treeViewUtils.spec.ts.snap | 6 +-- src/util/treeViewUtils.ts | 40 +++++++++++++------ 4 files changed, 47 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index 51897203d..deec151a8 100644 --- a/package.json +++ b/package.json @@ -1096,7 +1096,7 @@ }, { "id": "vscode-deephaven.view.serverConnectionTree", - "name": "Connections", + "name": "Workers", "type": "tree", "icon": "images/dh-logo-28.svg" }, diff --git a/src/providers/ServerConnectionTreeProvider.ts b/src/providers/ServerConnectionTreeProvider.ts index f55f61fa8..12cbd8e72 100644 --- a/src/providers/ServerConnectionTreeProvider.ts +++ b/src/providers/ServerConnectionTreeProvider.ts @@ -4,9 +4,14 @@ import { CONNECTION_TREE_ITEM_CONTEXT, ICON_ID } from '../common'; import type { IDhcService, ConnectionState, + ConsoleType, ServerConnectionNode, } from '../types'; -import { isInstanceOf, sortByStringProp } from '../util'; +import { + getConsoleTypeIconId, + isInstanceOf, + sortByStringProp, +} from '../util'; import { DhcService } from '../services'; import { getServerMatchPortIfLocalHost } from '../mcp/utils'; @@ -31,16 +36,13 @@ export class ServerConnectionTreeProvider extends ServerTreeProviderBase should return panel connection tree item: exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:true 1`] = ` { "collapsibleState": 2, - "description": "python", + "description": undefined, "iconPath": ThemeIcon { "color": undefined, "id": "sync~spin", @@ -39,10 +39,10 @@ exports[`getPanelConnectionTreeItem > should return panel connection tree item: exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:true 1`] = ` { "collapsibleState": 2, - "description": "python", + "description": undefined, "iconPath": ThemeIcon { "color": undefined, - "id": "vm-connect", + "id": "dh-python", }, "label": "localhost:10000", } diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index e71482b07..c60ba216d 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -48,6 +48,26 @@ export function getVariableIconPath( } } +/** + * Get the icon id for a console type / language, used for connection tree nodes. + * Falls back to the generic "connected" icon when the console type is unknown + * (e.g. a plain DHC connection or one whose console type has not resolved yet). + * @param consoleType Console type (language) of the connection, if known. + * @returns Icon id from `ICON_ID`. + */ +export function getConsoleTypeIconId( + consoleType: ConsoleType | undefined +): string { + switch (consoleType) { + case 'python': + return ICON_ID.python; + case 'groovy': + return ICON_ID.groovy; + default: + return ICON_ID.connected; + } +} + /** * Get `TreeItem` for a panel connection. * @param connection Connection state @@ -61,31 +81,25 @@ export async function getPanelConnectionTreeItem( serverLabel?: string, pqName?: string ): Promise { - const descriptionTokens: string[] = []; - + // Console type (language) drives the node icon rather than the description. const consoleType = await getConsoleType(connection); - if (consoleType) { - descriptionTokens.push(consoleType); - } - // Prefer the persistent query name (what the DHE Query Monitor shows) over // the local correlation tagId. Falls back to tagId for plain DHC connections. - const idToken = pqName ?? connection.tagId; - if (idToken) { - descriptionTokens.push(idToken); - } + const description = pqName ?? connection.tagId; const label = serverLabel ?? connection.serverUrl.host; - const description = - descriptionTokens.length === 0 ? undefined : descriptionTokens.join(' - '); return { label, description, collapsibleState: vscode.TreeItemCollapsibleState.Expanded, + // Show the language (Python/Groovy) icon when idle/connected; show the + // spinner while busy (connecting or running code). iconPath: new vscode.ThemeIcon( - connection.isConnected ? ICON_ID.connected : ICON_ID.connecting + connection.isRunningCode || !connection.isConnected + ? ICON_ID.connecting + : getConsoleTypeIconId(consoleType) ), }; } From ea1970a91480c4b997ae3266c550ac16a4465d27 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 4 Jun 2026 18:31:48 -0500 Subject: [PATCH 04/24] Added server parent nodes in connections + panels views (#DH-22593) --- src/common/constants.ts | 1 + ...rverConnectionTreeDragAndDropController.ts | 11 ++- .../ServerConnectionPanelTreeProvider.ts | 53 +++++++---- src/providers/ServerConnectionTreeProvider.ts | 83 +++++++++++------ src/services/ServerManager.ts | 17 ++++ src/types/serviceTypes.d.ts | 8 ++ src/types/treeViewTypes.d.ts | 3 +- .../__snapshots__/treeViewUtils.spec.ts.snap | 88 +++++++++++++++++-- src/util/treeViewUtils.spec.ts | 48 ++++++++-- src/util/treeViewUtils.ts | 82 ++++++++++++++++- 10 files changed, 328 insertions(+), 66 deletions(-) diff --git a/src/common/constants.ts b/src/common/constants.ts index 3506aa0ce..b7112fec8 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -159,6 +159,7 @@ export const VARIABLE_UNICODE_ICONS = { export const CONNECTION_TREE_ITEM_CONTEXT = { isConnectionConnected: 'isConnectionConnected', isConnectionConnecting: 'isConnectionConnecting', + isDHEServerConnectionParent: 'isDHEServerConnectionParent', isUri: 'isUri', } as const; diff --git a/src/controllers/ServerConnectionTreeDragAndDropController.ts b/src/controllers/ServerConnectionTreeDragAndDropController.ts index 9ed518904..6ff4f5704 100644 --- a/src/controllers/ServerConnectionTreeDragAndDropController.ts +++ b/src/controllers/ServerConnectionTreeDragAndDropController.ts @@ -1,5 +1,5 @@ import { MIME_TYPE } from '../common'; -import { getEditorForUri } from '../util'; +import { getEditorForUri, isServerStateNode } from '../util'; import type { IServerManager } from '../types'; import type { ServerConnectionNode } from '../types/treeViewTypes'; @@ -21,8 +21,13 @@ export class ServerConnectionTreeDragAndDropController dataTransfer: vscode.DataTransfer, _token: vscode.CancellationToken ): Promise => { - // Only target connection nodes - if (target == null || target instanceof vscode.Uri) { + // Only target connection nodes. DHE server group nodes and uri leaf nodes + // are not valid editor-connection targets. + if ( + target == null || + target instanceof vscode.Uri || + isServerStateNode(target) + ) { return; } diff --git a/src/providers/ServerConnectionPanelTreeProvider.ts b/src/providers/ServerConnectionPanelTreeProvider.ts index b1f0a10cc..603137ffe 100644 --- a/src/providers/ServerConnectionPanelTreeProvider.ts +++ b/src/providers/ServerConnectionPanelTreeProvider.ts @@ -2,13 +2,15 @@ import * as vscode from 'vscode'; import type { IPanelService, IServerManager, - ConnectionState, ServerConnectionPanelNode, } from '../types'; import { ServerTreeProviderBase } from './ServerTreeProviderBase'; import { + getConnectionServerTreeItem, + getConnectionTreeRootNodes, getPanelConnectionTreeItem, getPanelVariableTreeItem, + isServerStateNode, sortByStringProp, } from '../util'; import { getFirstSupportedConsoleType } from '../services'; @@ -27,40 +29,61 @@ export class ServerConnectionPanelTreeProvider extends ServerTreeProviderBase => { - if (Array.isArray(connectionOrVariable)) { - return getPanelVariableTreeItem(connectionOrVariable); + // Variable leaf node. + if (Array.isArray(node)) { + return getPanelVariableTreeItem(node); + } + + // DHE server node grouping its worker connections. + if (isServerStateNode(node)) { + return getConnectionServerTreeItem(node); } const serverLabel = getServerMatchPortIfLocalHost( this.serverManager, - connectionOrVariable.serverUrl + node.serverUrl )?.label; - const workerInfo = await this.serverManager.getWorkerInfo( - connectionOrVariable.serverUrl - ); + const workerInfo = await this.serverManager.getWorkerInfo(node.serverUrl); + + // DHE worker nodes are nested under their server node (worker name as the + // label); flat DHC connections keep the server label. + const isWorkerChild = this.serverManager.getServerForConnection(node) != null; return getPanelConnectionTreeItem( - connectionOrVariable, + node, getFirstSupportedConsoleType, serverLabel, - workerInfo?.name + workerInfo?.name, + isWorkerChild ); }; getChildren = ( - connectionOrRoot?: ConnectionState + elementOrRoot?: ServerConnectionPanelNode ): vscode.ProviderResult => { - if (connectionOrRoot == null) { + // Root: DHE server nodes + flat DHC connection nodes. + if (elementOrRoot == null) { + return getConnectionTreeRootNodes(this.serverManager); + } + + // Variable leaf nodes have no children. + if (Array.isArray(elementOrRoot)) { + return []; + } + + // DHE server node -> its worker connections. + if (isServerStateNode(elementOrRoot)) { return this.serverManager - .getConnections() + .getConnections(elementOrRoot.url) .sort(sortByStringProp('serverUrl')); } - return [...this._panelService.getVariables(connectionOrRoot.serverUrl)] + // Connection node -> its panel variables. + return [...this._panelService.getVariables(elementOrRoot.serverUrl)] .sort(sortByStringProp('title')) - .map(variable => [connectionOrRoot.serverUrl, variable]); + .map(variable => [elementOrRoot.serverUrl, variable]); }; } diff --git a/src/providers/ServerConnectionTreeProvider.ts b/src/providers/ServerConnectionTreeProvider.ts index 12cbd8e72..c7c10868a 100644 --- a/src/providers/ServerConnectionTreeProvider.ts +++ b/src/providers/ServerConnectionTreeProvider.ts @@ -1,15 +1,13 @@ import * as vscode from 'vscode'; import { ServerTreeProviderBase } from './ServerTreeProviderBase'; import { CONNECTION_TREE_ITEM_CONTEXT, ICON_ID } from '../common'; -import type { - IDhcService, - ConnectionState, - ConsoleType, - ServerConnectionNode, -} from '../types'; +import type { ConsoleType, ServerConnectionNode } from '../types'; import { + getConnectionServerTreeItem, + getConnectionTreeRootNodes, getConsoleTypeIconId, isInstanceOf, + isServerStateNode, sortByStringProp, } from '../util'; import { DhcService } from '../services'; @@ -20,29 +18,31 @@ import { getServerMatchPortIfLocalHost } from '../mcp/utils'; */ export class ServerConnectionTreeProvider extends ServerTreeProviderBase { getTreeItem = async ( - connectionOrUri: ServerConnectionNode + node: ServerConnectionNode ): Promise => { // Uri node associated with a parent connection node - if (connectionOrUri instanceof vscode.Uri) { + if (node instanceof vscode.Uri) { return { - description: connectionOrUri.path, + description: node.path, contextValue: CONNECTION_TREE_ITEM_CONTEXT.isUri, command: { command: 'vscode.open', title: 'Open Uri', - arguments: [connectionOrUri], + arguments: [node], }, - resourceUri: connectionOrUri, + resourceUri: node, }; } + // DHE server node grouping its worker connections. + if (isServerStateNode(node)) { + return getConnectionServerTreeItem(node); + } + // Console type (language) drives the node icon rather than the description. let consoleType: ConsoleType | undefined; - if ( - isInstanceOf(connectionOrUri, DhcService) && - connectionOrUri.isInitialized - ) { - [consoleType] = await connectionOrUri.getConsoleTypes(); + if (isInstanceOf(node, DhcService) && node.isInitialized) { + [consoleType] = await node.getConsoleTypes(); } // Prefer the persistent query name (what the DHE Query Monitor shows) over @@ -51,25 +51,33 @@ export class ServerConnectionTreeProvider extends ServerTreeProviderBase => { + // Root: DHE server nodes + flat DHC connection nodes. if (elementOrRoot == null) { + return getConnectionTreeRootNodes(this.serverManager); + } + + // Uri leaf nodes have no children. + if (elementOrRoot instanceof vscode.Uri) { + return []; + } + + // DHE server node -> its worker connections. + if (isServerStateNode(elementOrRoot)) { return this.serverManager - .getConnections() + .getConnections(elementOrRoot.url) .sort(sortByStringProp('serverUrl')); } + // Connection node -> its editor uris. return this.serverManager.getConnectionUris(elementOrRoot); }; @@ -102,11 +122,16 @@ export class ServerConnectionTreeProvider extends ServerTreeProviderBase { + getParent = (element: ServerConnectionNode): ServerConnectionNode | null => { if (element instanceof vscode.Uri) { return this.serverManager.getUriConnection(element); } - return null; + if (isServerStateNode(element)) { + return null; + } + + // Connection node -> its parent DHE server (or null for flat DHC). + return this.serverManager.getServerForConnection(element) ?? null; }; } diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index afc1f08ed..b5da5942a 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -736,6 +736,23 @@ export class ServerManager implements IServerManager { }); }; + /** + * Get the parent DHE server for a connection. Returns the DHE server state + * when the connection is a DHE worker (its `serverUrl` is mapped to a DHE + * server URL), or `undefined` for DHC connections, which have no parent + * server node in the tree. + * @param connection The connection to get the parent server for. + * @returns The parent DHE server state, or `undefined`. + */ + getServerForConnection = ( + connection: ConnectionState + ): ServerState | undefined => { + const dheServerUrl = this._workerURLToServerURLMap.get( + connection.serverUrl + ); + return dheServerUrl == null ? undefined : this.getServer(dheServerUrl); + }; + /** * Get all URIs associated with a connection. * @param connection diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index 3d56a47b1..25080dd9d 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -190,6 +190,14 @@ export interface IServerManager extends IDisposable { getConnection: (serverUrl: URL) => ConnectionState | undefined; getConnections: (serverOrWorkerUrl?: URL) => ConnectionState[]; getConnectionUris: (connection: ConnectionState) => vscode.Uri[]; + /** + * Get the parent DHE server for a connection. Returns the DHE `ServerState` + * when the connection is a DHE worker, or `undefined` for DHC connections + * (which have no parent server node in the tree). + */ + getServerForConnection: ( + connection: ConnectionState + ) => ServerState | undefined; getDheServiceForWorker: (maybeWorkerUrl: URL) => Promise; getEditorConnection: (uri: vscode.Uri) => Promise; getWorkerCredentials: ( diff --git a/src/types/treeViewTypes.d.ts b/src/types/treeViewTypes.d.ts index 7f437f3c4..dd31e1a49 100644 --- a/src/types/treeViewTypes.d.ts +++ b/src/types/treeViewTypes.d.ts @@ -9,12 +9,13 @@ export type ServerGroupState = 'Managed' | 'Running' | 'Stopped'; export type ServerNode = ServerGroupState | ServerState; export interface ServerTreeView extends vscode.TreeView {} -export type ServerConnectionNode = ConnectionState | vscode.Uri; +export type ServerConnectionNode = ServerState | ConnectionState | vscode.Uri; export interface ServerConnectionTreeView extends vscode.TreeView {} export type ServerConnectionPanelNode = + | ServerState | ConnectionState | [URL, VariableDefintion]; diff --git a/src/util/__snapshots__/treeViewUtils.spec.ts.snap b/src/util/__snapshots__/treeViewUtils.spec.ts.snap index 560b52bb6..714a7144b 100644 --- a/src/util/__snapshots__/treeViewUtils.spec.ts.snap +++ b/src/util/__snapshots__/treeViewUtils.spec.ts.snap @@ -1,6 +1,42 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:false 1`] = ` +exports[`getConnectionServerTreeItem > should fall back to the url host when there is no label 1`] = ` +{ + "collapsibleState": 2, + "contextValue": "isDHEServerConnectionParent", + "iconPath": ThemeIcon { + "color": undefined, + "id": "vm-connect", + }, + "label": "my-dhe-server:8123", +} +`; + +exports[`getConnectionServerTreeItem > should return a labeled server tree item 1`] = ` +{ + "collapsibleState": 2, + "contextValue": "isDHEServerConnectionParent", + "iconPath": ThemeIcon { + "color": undefined, + "id": "vm-connect", + }, + "label": "My DHE Server", +} +`; + +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:false, isWorkerChild:false 1`] = ` +{ + "collapsibleState": 2, + "description": "Some Worker PQ", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "Some Server", +} +`; + +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:false, isWorkerChild:true 1`] = ` { "collapsibleState": 2, "description": undefined, @@ -8,11 +44,23 @@ exports[`getPanelConnectionTreeItem > should return panel connection tree item: "color": undefined, "id": "sync~spin", }, - "label": "localhost:10000", + "label": "Some Worker PQ", +} +`; + +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:true, isWorkerChild:false 1`] = ` +{ + "collapsibleState": 2, + "description": "Some Worker PQ", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "Some Server", } `; -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:true 1`] = ` +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:true, isWorkerChild:true 1`] = ` { "collapsibleState": 2, "description": undefined, @@ -20,11 +68,23 @@ exports[`getPanelConnectionTreeItem > should return panel connection tree item: "color": undefined, "id": "sync~spin", }, - "label": "localhost:10000", + "label": "Some Worker PQ", } `; -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:false 1`] = ` +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:false, isWorkerChild:false 1`] = ` +{ + "collapsibleState": 2, + "description": "Some Worker PQ", + "iconPath": ThemeIcon { + "color": undefined, + "id": "vm-connect", + }, + "label": "Some Server", +} +`; + +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:false, isWorkerChild:true 1`] = ` { "collapsibleState": 2, "description": undefined, @@ -32,11 +92,23 @@ exports[`getPanelConnectionTreeItem > should return panel connection tree item: "color": undefined, "id": "vm-connect", }, - "label": "localhost:10000", + "label": "Some Worker PQ", +} +`; + +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:true, isWorkerChild:false 1`] = ` +{ + "collapsibleState": 2, + "description": "Some Worker PQ", + "iconPath": ThemeIcon { + "color": undefined, + "id": "dh-python", + }, + "label": "Some Server", } `; -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:true 1`] = ` +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:true, isWorkerChild:true 1`] = ` { "collapsibleState": 2, "description": undefined, @@ -44,7 +116,7 @@ exports[`getPanelConnectionTreeItem > should return panel connection tree item: "color": undefined, "id": "dh-python", }, - "label": "localhost:10000", + "label": "Some Worker PQ", } `; diff --git a/src/util/treeViewUtils.spec.ts b/src/util/treeViewUtils.spec.ts index 7630e67e7..caff74f23 100644 --- a/src/util/treeViewUtils.spec.ts +++ b/src/util/treeViewUtils.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { bitValues, boolValues, matrix } from '../testUtils'; import { + getConnectionServerTreeItem, getPanelConnectionTreeItem, getPanelVariableTreeItem, getServerContextValue, @@ -47,9 +48,9 @@ describe('getPanelConnectionTreeItem', () => { const serverUrl = new URL('http://localhost:10000'); - it.each(matrix(boolValues, boolValues))( - 'should return panel connection tree item: isConnected:%s, isInitialized:%s', - async (isConnected, isInitialized) => { + it.each(matrix(boolValues, boolValues, boolValues))( + 'should return panel connection tree item: isConnected:%s, isInitialized:%s, isWorkerChild:%s', + async (isConnected, isInitialized, isWorkerChild) => { const connection = { isConnected, isInitialized, @@ -59,15 +60,48 @@ describe('getPanelConnectionTreeItem', () => { vi.mocked(isInstanceOf).mockReturnValue(true); - const actual = await getPanelConnectionTreeItem(connection, async () => { - const [consoleType] = await getConsoleTypes(); - return isInitialized ? consoleType : undefined; - }); + const actual = await getPanelConnectionTreeItem( + connection, + async () => { + const [consoleType] = await getConsoleTypes(); + return isInitialized ? consoleType : undefined; + }, + 'Some Server', + 'Some Worker PQ', + isWorkerChild + ); expect(actual).toMatchSnapshot(); } ); }); +describe('getConnectionServerTreeItem', () => { + it('should return a labeled server tree item', () => { + const server: ServerState = { + type: 'DHE', + url: new URL('https://my-dhe-server:8123'), + label: 'My DHE Server', + isConnected: true, + isRunning: true, + connectionCount: 2, + }; + + expect(getConnectionServerTreeItem(server)).toMatchSnapshot(); + }); + + it('should fall back to the url host when there is no label', () => { + const server: ServerState = { + type: 'DHE', + url: new URL('https://my-dhe-server:8123'), + isConnected: true, + isRunning: true, + connectionCount: 2, + }; + + expect(getConnectionServerTreeItem(server)).toMatchSnapshot(); + }); +}); + describe('getPanelVariableTreeItem', () => { const url = new URL('http://localhost:10000'); diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index c60ba216d..72fba9d23 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import type { ConnectionState, ConsoleType, + IServerManager, NonEmptyArray, ServerGroupState, ServerState, @@ -9,6 +10,7 @@ import type { VariableType, } from '../types'; import { + CONNECTION_TREE_ITEM_CONTEXT, DH_PROTECTED_VARIABLE_NAMES, ICON_ID, OPEN_VARIABLE_PANELS_CMD, @@ -79,16 +81,25 @@ export async function getPanelConnectionTreeItem( connection: ConnectionState ) => Promise, serverLabel?: string, - pqName?: string + pqName?: string, + isWorkerChild = false ): Promise { // Console type (language) drives the node icon rather than the description. const consoleType = await getConsoleType(connection); // Prefer the persistent query name (what the DHE Query Monitor shows) over // the local correlation tagId. Falls back to tagId for plain DHC connections. - const description = pqName ?? connection.tagId; + const workerName = pqName ?? connection.tagId; - const label = serverLabel ?? connection.serverUrl.host; + // DHE worker nodes are nested under their server node, so the worker name + // becomes the node label and the server label lives on the parent. Flat DHC + // connections keep the server label as the node label and show the worker + // name as the description. + const label = isWorkerChild + ? workerName + : (serverLabel ?? connection.serverUrl.host); + + const description = isWorkerChild ? undefined : workerName; return { label, @@ -129,6 +140,71 @@ export function getPanelVariableTreeItem([url, variable]: [ }; } +/** + * Type guard for a (DHE) server node within the connection / panel tree root. + * `ServerState` carries `url`; `ConnectionState` carries `serverUrl`. + * @param node A server or connection root node. + */ +export function isServerStateNode( + node: ServerState | ConnectionState +): node is ServerState { + return 'url' in node; +} + +/** + * Compute the root nodes for the connection / panel tree views. DHE worker + * connections are grouped under their parent DHE server node; DHC connections + * remain flat top-level nodes. Roots are sorted by their displayed label. + * @param serverManager Server manager. + * @returns The root nodes (DHE servers and flat DHC connections). + */ +export function getConnectionTreeRootNodes( + serverManager: IServerManager +): (ServerState | ConnectionState)[] { + const dheServers = new Map(); + const flatConnections: ConnectionState[] = []; + + for (const connection of serverManager.getConnections()) { + const server = serverManager.getServerForConnection(connection); + if (server == null) { + flatConnections.push(connection); + } else { + dheServers.set(server.url.toString(), server); + } + } + + return [...flatConnections, ...dheServers.values()].sort((a, b) => + getConnectionNodeSortKey(a).localeCompare(getConnectionNodeSortKey(b)) + ); +} + +function getConnectionNodeSortKey( + node: ServerState | ConnectionState +): string { + return isServerStateNode(node) + ? (node.label ?? node.url.host) + : node.serverUrl.host; +} + +/** + * Get `TreeItem` for a DHE server node in the connection / panel tree views. + * This is a grouping container whose children are the server's worker + * connections. + * @param server DHE server state + */ +export function getConnectionServerTreeItem( + server: ServerState +): vscode.TreeItem { + return { + label: server.label ?? server.url.host, + // The "computer" icon (`vm-connect`) previously used for worker connection + // nodes, before the language (Python/Groovy) icons took their place. + iconPath: new vscode.ThemeIcon(ICON_ID.connected), + collapsibleState: vscode.TreeItemCollapsibleState.Expanded, + contextValue: CONNECTION_TREE_ITEM_CONTEXT.isDHEServerConnectionParent, + }; +} + /** * Get `contextValue` for server tree items. * @param isConnected Whether the server is connected From 31736302155d9346b31c8689364f915f50fa9b34 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Tue, 16 Jun 2026 12:19:03 -0500 Subject: [PATCH 05/24] Removed unused let vars (#DH-22593) --- src/services/ServerManager.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index b5da5942a..44647341e 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -255,10 +255,6 @@ export class ServerManager implements IServerManager { logger.debug('Connecting to server:', serverUrl.href); - let tagId: UniqueID | undefined; - - let placeholderUrl: URL | undefined; - if (serverState.type === 'DHE') { const dheServerUrl = serverUrl; const isNewDheService = !this._dheServiceCache.has(dheServerUrl); From db0754fb16cd040f08e11cec3b5cd599fd18f4d9 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Tue, 16 Jun 2026 12:25:45 -0500 Subject: [PATCH 06/24] Ran prettier (#DH-22593) --- src/providers/ServerConnectionPanelTreeProvider.ts | 3 ++- src/providers/ServerConnectionTreeProvider.ts | 3 ++- src/util/treeViewUtils.ts | 4 +--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/providers/ServerConnectionPanelTreeProvider.ts b/src/providers/ServerConnectionPanelTreeProvider.ts index 603137ffe..06c2cc901 100644 --- a/src/providers/ServerConnectionPanelTreeProvider.ts +++ b/src/providers/ServerConnectionPanelTreeProvider.ts @@ -50,7 +50,8 @@ export class ServerConnectionPanelTreeProvider extends ServerTreeProviderBase Date: Tue, 16 Jun 2026 12:36:45 -0500 Subject: [PATCH 07/24] Added format script (#DH-22593) --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index deec151a8..f87366fb6 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "docs:start": "./scripts/startDocs", "docs:format": "./scripts/formatDocs", "docs:validate": "./scripts/validateDocs", + "format": "prettier --write \"src/**/*.{ts,js}\"", "icon:gen:dh": "node icons/generate.mjs dh", "icon:gen:ext": "node icons/generate.mjs dh-ext icons/src", "package:dev": "./scripts/package-dev.sh", From f049c2eb40127ac24b37b0ebd28403ea0f42dd8b Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Tue, 16 Jun 2026 16:13:28 -0500 Subject: [PATCH 08/24] Uniform nesting of workers grouped by servers (#DH-22593) --- .../ServerConnectionPanelTreeProvider.ts | 29 +++---- src/providers/ServerConnectionTreeProvider.ts | 44 ++++------- src/services/ServerManager.ts | 18 ++--- src/types/serviceTypes.d.ts | 6 +- .../__snapshots__/treeViewUtils.spec.ts.snap | 68 ++-------------- src/util/treeViewUtils.spec.ts | 58 ++++++++++++-- src/util/treeViewUtils.ts | 79 ++++++++++--------- 7 files changed, 141 insertions(+), 161 deletions(-) diff --git a/src/providers/ServerConnectionPanelTreeProvider.ts b/src/providers/ServerConnectionPanelTreeProvider.ts index 06c2cc901..2ded30729 100644 --- a/src/providers/ServerConnectionPanelTreeProvider.ts +++ b/src/providers/ServerConnectionPanelTreeProvider.ts @@ -8,13 +8,13 @@ import { ServerTreeProviderBase } from './ServerTreeProviderBase'; import { getConnectionServerTreeItem, getConnectionTreeRootNodes, + getConnectionWorkerLabel, getPanelConnectionTreeItem, getPanelVariableTreeItem, isServerStateNode, sortByStringProp, } from '../util'; import { getFirstSupportedConsoleType } from '../services'; -import { getServerMatchPortIfLocalHost } from '../mcp/utils'; export class ServerConnectionPanelTreeProvider extends ServerTreeProviderBase { constructor(serverManager: IServerManager, panelService: IPanelService) { @@ -41,31 +41,28 @@ export class ServerConnectionPanelTreeProvider extends ServerTreeProviderBase => { - // Root: DHE server nodes + flat DHC connection nodes. + // Root: one server node per server that has connections. if (elementOrRoot == null) { return getConnectionTreeRootNodes(this.serverManager); } @@ -75,7 +72,7 @@ export class ServerConnectionPanelTreeProvider extends ServerTreeProviderBase its worker connections. + // Server node -> its worker connections. if (isServerStateNode(elementOrRoot)) { return this.serverManager .getConnections(elementOrRoot.url) diff --git a/src/providers/ServerConnectionTreeProvider.ts b/src/providers/ServerConnectionTreeProvider.ts index ec2144f1c..941bb1c27 100644 --- a/src/providers/ServerConnectionTreeProvider.ts +++ b/src/providers/ServerConnectionTreeProvider.ts @@ -5,13 +5,13 @@ import type { ConsoleType, ServerConnectionNode } from '../types'; import { getConnectionServerTreeItem, getConnectionTreeRootNodes, + getConnectionWorkerLabel, getConsoleTypeIconId, isInstanceOf, isServerStateNode, sortByStringProp, } from '../util'; import { DhcService } from '../services'; -import { getServerMatchPortIfLocalHost } from '../mcp/utils'; /** * Provider for the server connection tree view. @@ -45,39 +45,23 @@ export class ServerConnectionTreeProvider extends ServerTreeProviderBase => { - // Root: DHE server nodes + flat DHC connection nodes. + // Root: one server node per server that has connections. if (elementOrRoot == null) { return getConnectionTreeRootNodes(this.serverManager); } @@ -107,7 +91,7 @@ export class ServerConnectionTreeProvider extends ServerTreeProviderBase its worker connections. + // Server node -> its worker connections. if (isServerStateNode(elementOrRoot)) { return this.serverManager .getConnections(elementOrRoot.url) @@ -132,7 +116,7 @@ export class ServerConnectionTreeProvider extends ServerTreeProviderBase its parent DHE server (or null for flat DHC). + // Connection node -> its parent server. return this.serverManager.getServerForConnection(element) ?? null; }; } diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 44647341e..8fa150f56 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -733,20 +733,20 @@ export class ServerManager implements IServerManager { }; /** - * Get the parent DHE server for a connection. Returns the DHE server state - * when the connection is a DHE worker (its `serverUrl` is mapped to a DHE - * server URL), or `undefined` for DHC connections, which have no parent - * server node in the tree. + * Get the parent server for a connection. For a DHE worker, resolves the DHE + * server via the worker→server map; for a DHC connection, the connection's + * `serverUrl` is itself the server URL. Returns `undefined` only when no + * matching server is registered. * @param connection The connection to get the parent server for. - * @returns The parent DHE server state, or `undefined`. + * @returns The parent server state, or `undefined`. */ getServerForConnection = ( connection: ConnectionState ): ServerState | undefined => { - const dheServerUrl = this._workerURLToServerURLMap.get( - connection.serverUrl - ); - return dheServerUrl == null ? undefined : this.getServer(dheServerUrl); + const serverUrl = + this._workerURLToServerURLMap.get(connection.serverUrl) ?? + connection.serverUrl; + return this.getServer(serverUrl); }; /** diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index 25080dd9d..4cf63a2c7 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -191,9 +191,9 @@ export interface IServerManager extends IDisposable { getConnections: (serverOrWorkerUrl?: URL) => ConnectionState[]; getConnectionUris: (connection: ConnectionState) => vscode.Uri[]; /** - * Get the parent DHE server for a connection. Returns the DHE `ServerState` - * when the connection is a DHE worker, or `undefined` for DHC connections - * (which have no parent server node in the tree). + * Get the parent server for a connection. Resolves the DHE server for a DHE + * worker, or the DHC server for a plain DHC connection. Returns `undefined` + * only when no matching server is registered. */ getServerForConnection: ( connection: ConnectionState diff --git a/src/util/__snapshots__/treeViewUtils.spec.ts.snap b/src/util/__snapshots__/treeViewUtils.spec.ts.snap index 714a7144b..9733b1ba4 100644 --- a/src/util/__snapshots__/treeViewUtils.spec.ts.snap +++ b/src/util/__snapshots__/treeViewUtils.spec.ts.snap @@ -24,99 +24,47 @@ exports[`getConnectionServerTreeItem > should return a labeled server tree item } `; -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:false, isWorkerChild:false 1`] = ` +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:false 1`] = ` { "collapsibleState": 2, - "description": "Some Worker PQ", "iconPath": ThemeIcon { "color": undefined, "id": "sync~spin", }, - "label": "Some Server", + "label": "Some Worker Label", } `; -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:false, isWorkerChild:true 1`] = ` +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:true 1`] = ` { "collapsibleState": 2, - "description": undefined, "iconPath": ThemeIcon { "color": undefined, "id": "sync~spin", }, - "label": "Some Worker PQ", + "label": "Some Worker Label", } `; -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:true, isWorkerChild:false 1`] = ` +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:false 1`] = ` { "collapsibleState": 2, - "description": "Some Worker PQ", - "iconPath": ThemeIcon { - "color": undefined, - "id": "sync~spin", - }, - "label": "Some Server", -} -`; - -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:true, isWorkerChild:true 1`] = ` -{ - "collapsibleState": 2, - "description": undefined, - "iconPath": ThemeIcon { - "color": undefined, - "id": "sync~spin", - }, - "label": "Some Worker PQ", -} -`; - -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:false, isWorkerChild:false 1`] = ` -{ - "collapsibleState": 2, - "description": "Some Worker PQ", "iconPath": ThemeIcon { "color": undefined, "id": "vm-connect", }, - "label": "Some Server", -} -`; - -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:false, isWorkerChild:true 1`] = ` -{ - "collapsibleState": 2, - "description": undefined, - "iconPath": ThemeIcon { - "color": undefined, - "id": "vm-connect", - }, - "label": "Some Worker PQ", -} -`; - -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:true, isWorkerChild:false 1`] = ` -{ - "collapsibleState": 2, - "description": "Some Worker PQ", - "iconPath": ThemeIcon { - "color": undefined, - "id": "dh-python", - }, - "label": "Some Server", + "label": "Some Worker Label", } `; -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:true, isWorkerChild:true 1`] = ` +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:true 1`] = ` { "collapsibleState": 2, - "description": undefined, "iconPath": ThemeIcon { "color": undefined, "id": "dh-python", }, - "label": "Some Worker PQ", + "label": "Some Worker Label", } `; diff --git a/src/util/treeViewUtils.spec.ts b/src/util/treeViewUtils.spec.ts index caff74f23..47040d9ce 100644 --- a/src/util/treeViewUtils.spec.ts +++ b/src/util/treeViewUtils.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { bitValues, boolValues, matrix } from '../testUtils'; import { getConnectionServerTreeItem, + getConnectionWorkerLabel, getPanelConnectionTreeItem, getPanelVariableTreeItem, getServerContextValue, @@ -14,10 +15,12 @@ import { groupServers, } from './treeViewUtils'; import type { + ConnectionState, ConsoleType, IDhcService, Psk, ServerState, + UniqueID, VariableDefintion, VariableType, } from '../types'; @@ -48,9 +51,9 @@ describe('getPanelConnectionTreeItem', () => { const serverUrl = new URL('http://localhost:10000'); - it.each(matrix(boolValues, boolValues, boolValues))( - 'should return panel connection tree item: isConnected:%s, isInitialized:%s, isWorkerChild:%s', - async (isConnected, isInitialized, isWorkerChild) => { + it.each(matrix(boolValues, boolValues))( + 'should return panel connection tree item: isConnected:%s, isInitialized:%s', + async (isConnected, isInitialized) => { const connection = { isConnected, isInitialized, @@ -66,9 +69,7 @@ describe('getPanelConnectionTreeItem', () => { const [consoleType] = await getConsoleTypes(); return isInitialized ? consoleType : undefined; }, - 'Some Server', - 'Some Worker PQ', - isWorkerChild + 'Some Worker Label' ); expect(actual).toMatchSnapshot(); } @@ -102,6 +103,51 @@ describe('getConnectionServerTreeItem', () => { }); }); +describe('getConnectionWorkerLabel', () => { + const connection = { + isConnected: true, + serverUrl: new URL('http://localhost:10000'), + tagId: 'mock.tagId' as UniqueID, + } as ConnectionState; + + const dhcServer: ServerState = { + type: 'DHC', + url: new URL('http://localhost:10000'), + label: 'My DHC Server', + isConnected: true, + isRunning: true, + connectionCount: 1, + }; + + it('should prefer the persistent-query name when present (DHE worker)', () => { + expect( + getConnectionWorkerLabel(dhcServer, connection, 'Some Worker PQ') + ).toBe('Some Worker PQ'); + }); + + it('should fall back to the server label when there is no pq name (DHC)', () => { + expect(getConnectionWorkerLabel(dhcServer, connection, undefined)).toBe( + 'My DHC Server' + ); + }); + + it('should fall back to the server url host when the server has no label', () => { + expect( + getConnectionWorkerLabel( + { ...dhcServer, label: undefined }, + connection, + undefined + ) + ).toBe('localhost:10000'); + }); + + it('should fall back to the connection serverUrl host when no parent server resolves', () => { + expect(getConnectionWorkerLabel(undefined, connection, undefined)).toBe( + 'localhost:10000' + ); + }); +}); + describe('getPanelVariableTreeItem', () => { const url = new URL('http://localhost:10000'); diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index fb8b85205..d0f9548bd 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -80,30 +80,13 @@ export async function getPanelConnectionTreeItem( getConsoleType: ( connection: ConnectionState ) => Promise, - serverLabel?: string, - pqName?: string, - isWorkerChild = false + label: string ): Promise { // Console type (language) drives the node icon rather than the description. const consoleType = await getConsoleType(connection); - // Prefer the persistent query name (what the DHE Query Monitor shows) over - // the local correlation tagId. Falls back to tagId for plain DHC connections. - const workerName = pqName ?? connection.tagId; - - // DHE worker nodes are nested under their server node, so the worker name - // becomes the node label and the server label lives on the parent. Flat DHC - // connections keep the server label as the node label and show the worker - // name as the description. - const label = isWorkerChild - ? workerName - : (serverLabel ?? connection.serverUrl.host); - - const description = isWorkerChild ? undefined : workerName; - return { label, - description, collapsibleState: vscode.TreeItemCollapsibleState.Expanded, // Show the language (Python/Groovy) icon when idle/connected; show the // spinner while busy (connecting or running code). @@ -152,38 +135,60 @@ export function isServerStateNode( } /** - * Compute the root nodes for the connection / panel tree views. DHE worker - * connections are grouped under their parent DHE server node; DHC connections - * remain flat top-level nodes. Roots are sorted by their displayed label. + * Get the label shown for a server node in the connection / panel tree views. + * @param server Server state. + */ +export function getConnectionServerLabel(server: ServerState): string { + return server.label ?? server.url.host; +} + +/** + * Get the label shown for a worker (connection) node in the connection / panel + * tree views. DHE workers use their persistent-query name; DHC connections, + * which have none, fall back to their server's label so the single child + * mirrors its parent server node. + * @param parentServer The connection's parent server, if resolved. + * @param connection The connection. + * @param pqName The persistent-query name (DHE workers only). + */ +export function getConnectionWorkerLabel( + parentServer: ServerState | undefined, + connection: ConnectionState, + pqName: string | undefined +): string { + const serverLabel = + parentServer == null + ? connection.serverUrl.host + : getConnectionServerLabel(parentServer); + + return pqName ?? serverLabel ?? connection.tagId ?? ''; +} + +/** + * Compute the root nodes for the connection / panel tree views. Every + * connection is grouped under its parent server node (DHC and DHE alike), so a + * community server with a single worker has the same hierarchy shape as an + * enterprise server with many. Roots are sorted by their displayed label. * @param serverManager Server manager. - * @returns The root nodes (DHE servers and flat DHC connections). + * @returns The server root nodes (only servers that have connections). */ export function getConnectionTreeRootNodes( serverManager: IServerManager -): (ServerState | ConnectionState)[] { - const dheServers = new Map(); - const flatConnections: ConnectionState[] = []; +): ServerState[] { + const servers = new Map(); for (const connection of serverManager.getConnections()) { const server = serverManager.getServerForConnection(connection); - if (server == null) { - flatConnections.push(connection); - } else { - dheServers.set(server.url.toString(), server); + if (server != null) { + servers.set(server.url.toString(), server); } } - return [...flatConnections, ...dheServers.values()].sort((a, b) => - getConnectionNodeSortKey(a).localeCompare(getConnectionNodeSortKey(b)) + return [...servers.values()].sort((a, b) => + getConnectionServerLabel(a).localeCompare(getConnectionServerLabel(b)) ); } -function getConnectionNodeSortKey(node: ServerState | ConnectionState): string { - return isServerStateNode(node) - ? (node.label ?? node.url.host) - : node.serverUrl.host; -} - /** * Get `TreeItem` for a DHE server node in the connection / panel tree views. * This is a grouping container whose children are the server's worker From 56fd4353dcc59f9423ab39d459e58ce130aadcdf Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Wed, 17 Jun 2026 10:22:45 -0500 Subject: [PATCH 09/24] server connecting status (#DH-22593) --- src/common/constants.ts | 1 + src/providers/ServerTreeProvider.ts | 5 +- src/services/ServerManager.spec.ts | 141 ++++++-- src/services/ServerManager.ts | 305 +++++++++-------- src/types/serviceTypes.d.ts | 3 + .../__snapshots__/treeViewUtils.spec.ts.snap | 320 ++++++++++++++++-- src/util/treeViewUtils.spec.ts | 49 +-- src/util/treeViewUtils.ts | 29 +- 8 files changed, 625 insertions(+), 228 deletions(-) diff --git a/src/common/constants.ts b/src/common/constants.ts index b7112fec8..827be37dc 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -172,6 +172,7 @@ export const SERVER_TREE_ITEM_CONTEXT = { isManagedServerConnected: 'isManagedServerConnected', isManagedServerConnecting: 'isManagedServerConnecting', isManagedServerDisconnected: 'isManagedServerDisconnected', + isServerConnecting: 'isServerConnecting', isServerRunningConnected: 'isServerRunningConnected', isServerRunningDisconnected: 'isServerRunningDisconnected', isServerStopped: 'isServerStopped', diff --git a/src/providers/ServerTreeProvider.ts b/src/providers/ServerTreeProvider.ts index 629382f53..22d6070b8 100644 --- a/src/providers/ServerTreeProvider.ts +++ b/src/providers/ServerTreeProvider.ts @@ -21,7 +21,10 @@ export class ServerTreeProvider extends ServerTreeProviderBase { return getServerGroupTreeItem(element, this.serverManager.canStartServer); } - return getServerTreeItem(element); + return getServerTreeItem( + element, + this.serverManager.isConnecting(element.url) + ); }; getChildren(elementOrRoot?: ServerNode): vscode.ProviderResult { diff --git a/src/services/ServerManager.spec.ts b/src/services/ServerManager.spec.ts index 8ae55c9a4..46cc573b8 100644 --- a/src/services/ServerManager.spec.ts +++ b/src/services/ServerManager.spec.ts @@ -37,7 +37,16 @@ type TestServerManager = PublicOf & { _serverMap: URLMap; _connectionMap: URLMap; _pendingConnectionMap: URLMap>; - _doConnectToServer: ReturnType; + _dhcServiceFactory: { create: ReturnType }; + _dheServiceCache: { + has: ReturnType; + get: ReturnType; + }; + _setPendingConnection: ( + serverUrl: URL, + connectionPromise: Promise + ) => void; + _clearPendingConnection: (serverUrl: URL) => void; }; /** Build a `ServerManager` with minimal mocked dependencies. */ @@ -107,21 +116,13 @@ const dheServer0 = mockServerState({ type: 'DHE', }); const cn1 = mockConnectionState(serverUrl); -const cn2 = mockConnectionState(serverUrl); describe('ServerManager.connectToServer', () => { let manager: TestServerManager; - let promise: Promise; - let resolve: (value: ConnectionState | null) => void; - beforeEach(() => { vi.clearAllMocks(); manager = createServerManager(); - - ({ promise, resolve } = withResolvers()); - - manager._doConnectToServer = vi.fn().mockReturnValue(promise); }); it('throws when the server is not found', async () => { @@ -137,50 +138,122 @@ describe('ServerManager.connectToServer', () => { const result = await manager.connectToServer(serverUrl); expect(result).toBe(cn1); - expect(manager._doConnectToServer).not.toHaveBeenCalled(); + expect(manager._dhcServiceFactory.create).not.toHaveBeenCalled(); }); it('only connects once when called concurrently for the same DHC server', async () => { manager._serverMap.set(dhcServer0.url, dhcServer0); + // Hold the client handshake open so the connection stays in flight. + const { promise: clientPromise, resolve: resolveClient } = + withResolvers(); + const connection = { + getClient: vi.fn().mockReturnValue(clientPromise), + initSession: vi.fn().mockResolvedValue(true), + onDidDisconnect: vi.fn(), + onDidChangeRunningCodeStatus: vi.fn(), + }; + manager._dhcServiceFactory.create.mockReturnValue(connection); + const first = manager.connectToServer(serverUrl); const second = manager.connectToServer(serverUrl); - // The in-progress connection is reused rather than starting a new one. - expect(manager._doConnectToServer).toHaveBeenCalledTimes(1); + // The second call dedupes against the in-flight connection rather than + // creating a new one. + expect(manager._dhcServiceFactory.create).toHaveBeenCalledTimes(1); + expect(manager.isConnecting(serverUrl)).toBe(true); - resolve(cn1); + resolveClient({}); - expect(await first).toBe(cn1); - expect(await second).toBe(cn1); + expect(await first).toBe(connection); + expect(await second).toBe(connection); }); - it('clears the pending connection once it resolves so later calls reconnect', async () => { - manager._serverMap.set(dhcServer0.url, dhcServer0); + it('dedups concurrent client connections for DHE servers', () => { + manager._serverMap.set(dheServer0.url, dheServer0); - manager._doConnectToServer.mockResolvedValue(cn1); - const first = manager.connectToServer(serverUrl); - expect(manager._pendingConnectionMap.has(serverUrl)).toBe(true); - expect(await first).toBe(cn1); + // Leave the DHE service acquisition pending so the client connection stays + // in flight. + const { promise } = withResolvers(); + manager._dheServiceCache.get.mockReturnValue(promise); - expect(manager._pendingConnectionMap.has(serverUrl)).toBe(false); + void manager.connectToServer(serverUrl); + void manager.connectToServer(serverUrl); - // A subsequent connect attempt is not blocked by the resolved pending entry. - manager._doConnectToServer.mockResolvedValue(cn2); - const second = manager.connectToServer(serverUrl); - expect(await second).toBe(cn2); + // The DHE client connection is singular, so concurrent attempts reuse the + // in-flight connection rather than starting a second one (multiple workers + // are created later, off the single client connection). + expect(manager._dheServiceCache.get).toHaveBeenCalledTimes(1); + expect(manager.isConnecting(serverUrl)).toBe(true); + }); +}); + +describe('ServerManager.isConnecting', () => { + let manager: TestServerManager; + + beforeEach(() => { + vi.clearAllMocks(); + manager = createServerManager(); + }); - expect(manager._doConnectToServer).toHaveBeenCalledTimes(2); + it('toggles isConnecting and fires onDidUpdate on set/clear', () => { + const onDidUpdate = vi.fn(); + manager.onDidUpdate(onDidUpdate); + + expect(manager.isConnecting(serverUrl)).toBe(false); + + manager._setPendingConnection(serverUrl, Promise.resolve(null)); + expect(manager.isConnecting(serverUrl)).toBe(true); + expect(onDidUpdate).toHaveBeenCalledTimes(1); + + manager._clearPendingConnection(serverUrl); + expect(manager.isConnecting(serverUrl)).toBe(false); + expect(onDidUpdate).toHaveBeenCalledTimes(2); }); - it('does not track pending connections for DHE servers', async () => { - manager._serverMap.set(dheServer0.url, dheServer0); + it('clears idempotently and does not fire when there is no pending entry', () => { + const onDidUpdate = vi.fn(); + manager.onDidUpdate(onDidUpdate); - void manager.connectToServer(serverUrl); - void manager.connectToServer(serverUrl); + // Clearing when not connecting is a no-op (no fire). + manager._clearPendingConnection(serverUrl); + expect(onDidUpdate).not.toHaveBeenCalled(); + + manager._setPendingConnection(serverUrl, Promise.resolve(null)); + expect(onDidUpdate).toHaveBeenCalledTimes(1); + + manager._clearPendingConnection(serverUrl); + expect(onDidUpdate).toHaveBeenCalledTimes(2); + + // Clearing again is a no-op (no extra fire) — covers DHE's early clear + // followed by the connect-settle clear. + manager._clearPendingConnection(serverUrl); + expect(onDidUpdate).toHaveBeenCalledTimes(2); + }); + + it('clears the pending entry when a connect settles, so a retry is not blocked', async () => { + manager._serverMap.set(dhcServer0.url, dhcServer0); - // DHE supports multiple connections, so concurrent calls each start one. - expect(manager._doConnectToServer).toHaveBeenCalledTimes(2); - expect(manager._pendingConnectionMap.has(serverUrl)).toBe(false); + // First attempt fails to get a client; `_doConnectToServer`'s `finally` + // must clear the pending entry (no stale entry left behind). + const failConnection = { getClient: vi.fn().mockResolvedValue(null) }; + const okConnection = { + getClient: vi.fn().mockResolvedValue({}), + initSession: vi.fn().mockResolvedValue(true), + onDidDisconnect: vi.fn(), + onDidChangeRunningCodeStatus: vi.fn(), + }; + manager._dhcServiceFactory.create + .mockReturnValueOnce(failConnection) + .mockReturnValueOnce(okConnection); + + expect(await manager.connectToServer(serverUrl)).toBeNull(); + expect(manager.isConnecting(serverUrl)).toBe(false); + + // The retry is not blocked by a stale pending entry — it starts a new + // connection rather than deduping against the failed one. + expect(await manager.connectToServer(serverUrl)).toBe(okConnection); + expect(manager._dhcServiceFactory.create).toHaveBeenCalledTimes(2); + expect(manager.isConnecting(serverUrl)).toBe(false); }); }); diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 8fa150f56..0dfead421 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -33,6 +33,7 @@ import { uniqueId, URIMap, URLMap, + withResolvers, } from '../util'; import { DhcService } from './DhcService'; import { getWorkerCredentials, isDheServerRunning } from '../dh/dhe'; @@ -108,6 +109,23 @@ export class ServerManager implements IServerManager { private readonly _onDidUpdate = new vscode.EventEmitter(); readonly onDidUpdate = this._onDidUpdate.event; + private _setPendingConnection = ( + serverUrl: URL, + connectionPromise: Promise + ): void => { + this._pendingConnectionMap.set(serverUrl, connectionPromise); + this._onDidUpdate.fire(); + }; + + private _clearPendingConnection = (serverUrl: URL): void => { + if (this._pendingConnectionMap.delete(serverUrl)) { + this._onDidUpdate.fire(); + } + }; + + isConnecting = (serverUrl: URL): boolean => + this._pendingConnectionMap.has(serverUrl); + private _lastServerRunningStatus = new URLMap(); private _hasEverUpdatedStatus = false; @@ -223,27 +241,11 @@ export class ServerManager implements IServerManager { return this._pendingConnectionMap.getOrThrow(serverUrl); } - const connectionPromise = this._doConnectToServer( + return this._doConnectToServer( serverState, workerConsoleType, operateAsAnotherUser ); - - // We only support 1 connection for DHC servers in the extension, but the - // count doesn't get updated until the connection is established, so we need - // to mark pending connections to prevent multiple simultaneous connection - // attempts to the same DHC server. - if (serverState.type === 'DHC') { - this._pendingConnectionMap.set( - serverUrl, - connectionPromise.then(result => { - this._pendingConnectionMap.delete(serverUrl); - return result; - }) - ); - } - - return connectionPromise; }; private _doConnectToServer = async ( @@ -251,156 +253,183 @@ export class ServerManager implements IServerManager { workerConsoleType?: ConsoleType, operateAsAnotherUser: boolean = false ): Promise => { - let serverUrl = serverState.url; + const serverUrl = serverState.url; logger.debug('Connecting to server:', serverUrl.href); - if (serverState.type === 'DHE') { - const dheServerUrl = serverUrl; - const isNewDheService = !this._dheServiceCache.has(dheServerUrl); - const dheService = await this._dheServiceCache.get(dheServerUrl); + // Register the in-flight connection synchronously — before the first + // await — so concurrent callers dedupe against it. The deferred is settled + // with the result once the connect completes (see `finally`). + const { promise, resolve } = withResolvers(); + this._setPendingConnection(serverUrl, promise); + + let result: ConnectionState | null = null; + try { + if (serverState.type === 'DHE') { + const dheServerUrl = serverUrl; + const isNewDheService = !this._dheServiceCache.has(dheServerUrl); + const dheService = await this._dheServiceCache.get(dheServerUrl); + + // Get client. Client will be initialized if it doesn't exist (including + // prompting user for login). + if (!(await dheService.getClient(true, operateAsAnotherUser))) { + return null; + } - // Get client. Client will be initialized if it doesn't exist (including - // prompting user for login). - if (!(await dheService.getClient(true, operateAsAnotherUser))) { - return null; - } + // Mark the DHE server as connected now that we have a live client. + // (connectionCount stays 0 until workers attach; isConnected reflects + // the server-level connection, which is now established.) + const currentServerState = this._serverMap.get(dheServerUrl); + if (currentServerState != null && !currentServerState.isConnected) { + this._serverMap.set(dheServerUrl, { + ...currentServerState, + isConnected: true, + }); + this._onDidUpdate.fire(); + } - // Mark the DHE server as connected now that we have a live client. - // (connectionCount stays 0 until workers attach; isConnected reflects - // the server-level connection, which is now established.) - const currentServerState = this._serverMap.get(dheServerUrl); - if (currentServerState != null && !currentServerState.isConnected) { - this._serverMap.set(dheServerUrl, { - ...currentServerState, - isConnected: true, - }); - this._onDidUpdate.fire(); - } + // Client is live; the DHE connecting window ends here. The worker + // attach/create phase below is intentionally not covered (the `finally` + // clear is then an idempotent no-op). + this._clearPendingConnection(dheServerUrl); + + // Wire the config-event subscription exactly once per DHE service + // instance, BEFORE snapshotting, so any worker that appears or disappears + // between the snapshot and now is not missed. `_connectWorker` reserves + // each serial synchronously (before any `await`), so an event-driven + // attach and the snapshot batch below can never double-connect the same + // worker — whichever reaches `_connectWorker` first wins and the other is + // a no-op. + if (isNewDheService) { + dheService.onDidWorkerAttachable(qi => + this._reconcileAttach(dheServerUrl, dheService, qi) + ); + dheService.onDidWorkerRemoved(serial => + this._reconcileDetach(serial) + ); + } - // Wire the config-event subscription exactly once per DHE service - // instance, BEFORE snapshotting, so any worker that appears or disappears - // between the snapshot and now is not missed. `_connectWorker` reserves - // each serial synchronously (before any `await`), so an event-driven - // attach and the snapshot batch below can never double-connect the same - // worker — whichever reaches `_connectWorker` first wins and the other is - // a no-op. - if (isNewDheService) { - dheService.onDidWorkerAttachable(qi => - this._reconcileAttach(dheServerUrl, dheService, qi) + // Snapshot currently-running attachable workers and compute the + // not-yet-attached subset. + const attachable = await dheService.listAttachableWorkers(); + const toAttach = attachable.filter( + qi => !this._attachedWorkerSerials.has(qi.serial as QuerySerial) ); - dheService.onDidWorkerRemoved(serial => this._reconcileDetach(serial)); - } - - // Snapshot currently-running attachable workers and compute the - // not-yet-attached subset. - const attachable = await dheService.listAttachableWorkers(); - const toAttach = attachable.filter( - qi => !this._attachedWorkerSerials.has(qi.serial as QuerySerial) - ); - if (toAttach.length > 0) { - // Attach existing workers in batches to avoid server stampede. - const BATCH_SIZE = 4; - let firstConnection: ConnectionState | null = null; - for (let i = 0; i < toAttach.length; i += BATCH_SIZE) { - const batch = toAttach.slice(i, i + BATCH_SIZE); - const connections = await Promise.all( - batch.map(async qi => { - const workerInfo = dheService.attachWorker(qi); - return this._connectWorker(dheServerUrl, workerInfo); - }) - ); - if (firstConnection == null) { - firstConnection = connections.find(c => c != null) ?? null; + if (toAttach.length > 0) { + // Attach existing workers in batches to avoid server stampede. + const BATCH_SIZE = 4; + let firstConnection: ConnectionState | null = null; + for (let i = 0; i < toAttach.length; i += BATCH_SIZE) { + const batch = toAttach.slice(i, i + BATCH_SIZE); + const connections = await Promise.all( + batch.map(async qi => { + const workerInfo = dheService.attachWorker(qi); + return this._connectWorker(dheServerUrl, workerInfo); + }) + ); + if (firstConnection == null) { + firstConnection = connections.find(c => c != null) ?? null; + } } + if (toAttach.length > 1) { + this._toaster.info(`Attached to ${toAttach.length} worker(s).`); + } + result = firstConnection; + return result; } - if (toAttach.length > 1) { - this._toaster.info(`Attached to ${toAttach.length} worker(s).`); - } - return firstConnection; - } - // Nothing left to attach — either no workers exist for this user, or all - // of them already have live connections. Create a fresh one so clicking a - // server always yields a usable session. - const tagId = uniqueId(); - const placeholderUrl = this.addWorkerPlaceholderConnection( - dheServerUrl, - tagId - ); + // Nothing left to attach — either no workers exist for this user, or all + // of them already have live connections. Create a fresh one so clicking a + // server always yields a usable session. + const tagId = uniqueId(); + const placeholderUrl = this.addWorkerPlaceholderConnection( + dheServerUrl, + tagId + ); - let workerInfo: WorkerInfo; - try { - workerInfo = await dheService.createWorker(tagId, workerConsoleType); + let workerInfo: WorkerInfo; + try { + workerInfo = await dheService.createWorker(tagId, workerConsoleType); - // If the worker finished creating but there is no placeholder - // connection, the user cancelled before it was ready. - if (!this._connectionMap.has(placeholderUrl)) { - dheService.deleteWorker(workerInfo.workerUrl); - this._onDidUpdate.fire(); + // If the worker finished creating but there is no placeholder + // connection, the user cancelled before it was ready. + if (!this._connectionMap.has(placeholderUrl)) { + dheService.deleteWorker(workerInfo.workerUrl); + this._onDidUpdate.fire(); + return null; + } + } catch (err) { + if (err instanceof QueryCreationCancelledError) { + logger.info(err); + const msg = 'Connection cancelled.'; + this._outputChannel.appendLine(msg); + this._toaster.info(msg); + } else { + const msg = + err instanceof QueryStartupFailureError + ? err.message + : 'Failed to create worker.'; + logger.error(err); + this._outputChannel.appendLine(msg); + this._toaster.error(msg); + } + + this.removeWorkerPlaceholderConnection(placeholderUrl); return null; } - } catch (err) { - if (err instanceof QueryCreationCancelledError) { - logger.info(err); - const msg = 'Connection cancelled.'; - this._outputChannel.appendLine(msg); - this._toaster.info(msg); - } else { - const msg = - err instanceof QueryStartupFailureError - ? err.message - : 'Failed to create worker.'; - logger.error(err); - this._outputChannel.appendLine(msg); - this._toaster.error(msg); - } this.removeWorkerPlaceholderConnection(placeholderUrl); - return null; + result = await this._connectWorker(dheServerUrl, workerInfo); + return result; } - this.removeWorkerPlaceholderConnection(placeholderUrl); - return this._connectWorker(dheServerUrl, workerInfo); - } - - // DHC path: single direct connection. - const connection = this._dhcServiceFactory.create(serverUrl, undefined); + // DHC path: single direct connection. + const connection = this._dhcServiceFactory.create(serverUrl, undefined); - // Initialize client + prompt for login if necessary - const coreClient = await connection.getClient(); + // Initialize client + prompt for login if necessary + const coreClient = await connection.getClient(); - if (coreClient == null) { - return null; - } + if (coreClient == null) { + return null; + } - this._connectionMap.set(serverUrl, connection); - this._onDidUpdate.fire(); + this._connectionMap.set(serverUrl, connection); + this._onDidUpdate.fire(); - if (!(await connection.initSession())) { - this._coreClientCache.delete(serverUrl); + if (!(await connection.initSession())) { + this._coreClientCache.delete(serverUrl); - connection.dispose(); - this._connectionMap.delete(serverUrl); - return null; - } + connection.dispose(); + this._connectionMap.delete(serverUrl); + return null; + } - connection.onDidDisconnect(() => { - logger.debug('onDidDisconnect fired for:', serverUrl.href); - this.disconnectFromServer(serverUrl); - }); + connection.onDidDisconnect(() => { + logger.debug('onDidDisconnect fired for:', serverUrl.href); + this.disconnectFromServer(serverUrl); + }); - connection.onDidChangeRunningCodeStatus?.(() => { - this._onDidUpdate.fire(); - }); + connection.onDidChangeRunningCodeStatus?.(() => { + this._onDidUpdate.fire(); + }); - this.updateConnectionCount(serverUrl, 1); + this.updateConnectionCount(serverUrl, 1); - this._onDidConnect.fire(serverUrl); - this._onDidUpdate.fire(); + this._onDidConnect.fire(serverUrl); + this._onDidUpdate.fire(); - return this._connectionMap.get(serverUrl) ?? null; + result = this._connectionMap.get(serverUrl) ?? null; + return result; + } finally { + // Settle the deferred for any deduped caller, and clear the pending + // entry. The clear ends the connecting window — DHC: the whole connect; + // DHE: idempotent here, since the explicit clear above already ran once + // the client was established (this still covers DHE failures before + // establishment). + resolve(result); + this._clearPendingConnection(serverUrl); + } }; /** diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index 4cf63a2c7..1566234f9 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -187,6 +187,9 @@ export interface IServerManager extends IDisposable { hasConnectionUris: (connection: ConnectionState) => boolean; + /** Whether a client connection to the given server is currently being established. */ + isConnecting: (serverUrl: URL) => boolean; + getConnection: (serverUrl: URL) => ConnectionState | undefined; getConnections: (serverOrWorkerUrl?: URL) => ConnectionState[]; getConnectionUris: (connection: ConnectionState) => vscode.Uri[]; diff --git a/src/util/__snapshots__/treeViewUtils.spec.ts.snap b/src/util/__snapshots__/treeViewUtils.spec.ts.snap index 9733b1ba4..41d4b4fb1 100644 --- a/src/util/__snapshots__/treeViewUtils.spec.ts.snap +++ b/src/util/__snapshots__/treeViewUtils.spec.ts.snap @@ -332,21 +332,37 @@ exports[`getPanelVariableTreeItem > should return panel variable tree item: type } `; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isManaged=false, isRunning=false 1`] = `"isServerStopped"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=false, isManaged=false, isRunning=false 1`] = `"isServerStopped"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isManaged=false, isRunning=true 1`] = `"isServerRunningDisconnected"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=false, isManaged=false, isRunning=true 1`] = `"isServerRunningDisconnected"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isManaged=true, isRunning=false 1`] = `"isManagedServerConnecting"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=false, isManaged=true, isRunning=false 1`] = `"isManagedServerConnecting"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isManaged=true, isRunning=true 1`] = `"isManagedServerDisconnected"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=false, isManaged=true, isRunning=true 1`] = `"isManagedServerDisconnected"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isManaged=false, isRunning=false 1`] = `"isServerStopped"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=true, isManaged=false, isRunning=false 1`] = `"isServerConnecting"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isManaged=false, isRunning=true 1`] = `"isServerRunningConnected"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=true, isManaged=false, isRunning=true 1`] = `"isServerConnecting"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isManaged=true, isRunning=false 1`] = `"isManagedServerConnected"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=true, isManaged=true, isRunning=false 1`] = `"isServerConnecting"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isManaged=true, isRunning=true 1`] = `"isManagedServerConnected"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=true, isManaged=true, isRunning=true 1`] = `"isServerConnecting"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=false, isManaged=false, isRunning=false 1`] = `"isServerStopped"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=false, isManaged=false, isRunning=true 1`] = `"isServerRunningConnected"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=false, isManaged=true, isRunning=false 1`] = `"isManagedServerConnected"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=false, isManaged=true, isRunning=true 1`] = `"isManagedServerConnected"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=true, isManaged=false, isRunning=false 1`] = `"isServerConnecting"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=true, isManaged=false, isRunning=true 1`] = `"isServerConnecting"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=true, isManaged=true, isRunning=false 1`] = `"isServerConnecting"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=true, isManaged=true, isRunning=true 1`] = `"isServerConnecting"`; exports[`getServerDescription > should return server description based on parameters: connectionCount=0, isManaged=false, label=some label 1`] = `"some label"`; @@ -420,23 +436,39 @@ exports[`getServerGroupTreeItem > should return server group tree item: group=Ru } `; -exports[`getServerIconID > should return icon id based on server state: isConnected=false, isManaged=false, isRunning=false 1`] = `"circle-slash"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=false, isManaged=false, isRunning=false 1`] = `"circle-slash"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=false, isManaged=false, isRunning=true 1`] = `"circle-large-outline"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=false, isManaged=true, isRunning=false 1`] = `"sync~spin"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=false, isManaged=true, isRunning=true 1`] = `"circle-large-outline"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=true, isManaged=false, isRunning=false 1`] = `"sync~spin"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=false, isManaged=false, isRunning=true 1`] = `"circle-large-outline"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=true, isManaged=false, isRunning=true 1`] = `"sync~spin"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=false, isManaged=true, isRunning=false 1`] = `"sync~spin"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=true, isManaged=true, isRunning=false 1`] = `"sync~spin"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=false, isManaged=true, isRunning=true 1`] = `"circle-large-outline"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=true, isManaged=true, isRunning=true 1`] = `"sync~spin"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=true, isManaged=false, isRunning=false 1`] = `"circle-slash"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=false, isManaged=false, isRunning=false 1`] = `"circle-slash"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=true, isManaged=false, isRunning=true 1`] = `"circle-large-filled"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=false, isManaged=false, isRunning=true 1`] = `"circle-large-filled"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=true, isManaged=true, isRunning=false 1`] = `"sync~spin"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=false, isManaged=true, isRunning=false 1`] = `"sync~spin"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=true, isManaged=true, isRunning=true 1`] = `"circle-large-filled"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=false, isManaged=true, isRunning=true 1`] = `"circle-large-filled"`; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isManaged=false, isRunning=false 1`] = ` +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=true, isManaged=false, isRunning=false 1`] = `"sync~spin"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=true, isManaged=false, isRunning=true 1`] = `"sync~spin"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=true, isManaged=true, isRunning=false 1`] = `"sync~spin"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=true, isManaged=true, isRunning=true 1`] = `"sync~spin"`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=false, isManaged=false, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isServerStopped", @@ -450,7 +482,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isManaged=false, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=false, isManaged=false, isRunning=true 1`] = ` { "command": { "arguments": [ @@ -477,7 +509,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isManaged=true, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=false, isManaged=true, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isManagedServerConnecting", @@ -491,7 +523,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isManaged=true, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=false, isManaged=true, isRunning=true 1`] = ` { "command": { "arguments": [ @@ -519,7 +551,63 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isManaged=false, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=true, isManaged=false, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=true, isManaged=false, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=true, isManaged=true, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=true, isManaged=true, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=false, isManaged=false, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isServerStopped", @@ -533,7 +621,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isManaged=false, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=false, isManaged=false, isRunning=true 1`] = ` { "command": undefined, "contextValue": "isServerRunningConnected", @@ -547,7 +635,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isManaged=true, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=false, isManaged=true, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isManagedServerConnected", @@ -561,7 +649,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isManaged=true, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=false, isManaged=true, isRunning=true 1`] = ` { "command": undefined, "contextValue": "isManagedServerConnected", @@ -575,7 +663,63 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isManaged=false, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=true, isManaged=false, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "(1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=true, isManaged=false, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "(1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=true, isManaged=true, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip (1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=true, isManaged=true, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip (1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=false, isManaged=false, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isServerStopped", @@ -589,7 +733,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isManaged=false, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=false, isManaged=false, isRunning=true 1`] = ` { "command": { "arguments": [ @@ -616,7 +760,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isManaged=true, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=false, isManaged=true, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isManagedServerConnecting", @@ -630,7 +774,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isManaged=true, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=false, isManaged=true, isRunning=true 1`] = ` { "command": { "arguments": [ @@ -658,7 +802,63 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isManaged=false, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=true, isManaged=false, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=true, isManaged=false, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=true, isManaged=true, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=true, isManaged=true, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=false, isManaged=false, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isServerStopped", @@ -672,7 +872,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isManaged=false, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=false, isManaged=false, isRunning=true 1`] = ` { "command": { "arguments": [ @@ -699,7 +899,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isManaged=true, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=false, isManaged=true, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isManagedServerConnected", @@ -713,7 +913,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isManaged=true, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=false, isManaged=true, isRunning=true 1`] = ` { "command": undefined, "contextValue": "isManagedServerConnected", @@ -727,6 +927,62 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=true, isManaged=false, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "(1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=true, isManaged=false, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "(1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=true, isManaged=true, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip (1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=true, isManaged=true, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip (1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + exports[`getVariableIconPath > should return icon path for variableType 1`] = ` [ [ diff --git a/src/util/treeViewUtils.spec.ts b/src/util/treeViewUtils.spec.ts index 47040d9ce..6552fa8b0 100644 --- a/src/util/treeViewUtils.spec.ts +++ b/src/util/treeViewUtils.spec.ts @@ -166,11 +166,12 @@ describe('getPanelVariableTreeItem', () => { }); describe('getServerContextValue', () => { - it.each(matrix(boolValues, boolValues, boolValues))( - 'should return contextValue based on server state: isConnected=%s, isManaged=%s, isRunning=%s', - (isConnected, isManaged, isRunning) => { + it.each(matrix(boolValues, boolValues, boolValues, boolValues))( + 'should return contextValue based on server state: isConnected=%s, isConnecting=%s, isManaged=%s, isRunning=%s', + (isConnected, isConnecting, isManaged, isRunning) => { const actual = getServerContextValue({ isConnected, + isConnecting, isDHE: false, isManaged, isRunning, @@ -217,10 +218,15 @@ describe('getServerGroupTreeItem', () => { }); describe('getServerIconID', () => { - it.each(matrix(boolValues, boolValues, boolValues))( - 'should return icon id based on server state: isConnected=%s, isManaged=%s, isRunning=%s', - (isConnected, isManaged, isRunning) => { - const actual = getServerIconID({ isConnected, isManaged, isRunning }); + it.each(matrix(boolValues, boolValues, boolValues, boolValues))( + 'should return icon id based on server state: isConnected=%s, isConnecting=%s, isManaged=%s, isRunning=%s', + (isConnected, isConnecting, isManaged, isRunning) => { + const actual = getServerIconID({ + isConnected, + isConnecting, + isManaged, + isRunning, + }); expect(actual).toMatchSnapshot(); } ); @@ -237,19 +243,22 @@ describe('getServerTreeItem', () => { connectionCount: 0, }; - it.each(matrix(typeValues, boolValues, boolValues, boolValues))( - 'should return server tree item: type=%s, isConnected=%s, isManaged=%s, isRunning=%s', - (type, isConnected, isManaged, isRunning) => { - const actual = getServerTreeItem({ - ...dhcServerState, - ...(isManaged - ? { isManaged: true, psk: 'mock.psk' as Psk } - : { isManaged: false }), - type, - connectionCount: isConnected ? 1 : 0, - isConnected, - isRunning, - }); + it.each(matrix(typeValues, boolValues, boolValues, boolValues, boolValues))( + 'should return server tree item: type=%s, isConnected=%s, isConnecting=%s, isManaged=%s, isRunning=%s', + (type, isConnected, isConnecting, isManaged, isRunning) => { + const actual = getServerTreeItem( + { + ...dhcServerState, + ...(isManaged + ? { isManaged: true, psk: 'mock.psk' as Psk } + : { isManaged: false }), + type, + connectionCount: isConnected ? 1 : 0, + isConnected, + isRunning, + }, + isConnecting + ); expect(actual).toMatchSnapshot(); } diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index d0f9548bd..422c887eb 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -211,21 +211,28 @@ export function getConnectionServerTreeItem( /** * Get `contextValue` for server tree items. * @param isConnected Whether the server is connected + * @param isConnecting Whether a client connection is currently being established * @param isDHE Whether the server is a DHE server * @param isManaged Whether the server is managed * @param isRunning Whether the server is running */ export function getServerContextValue({ isConnected, + isConnecting, isDHE, isManaged, isRunning, }: { isConnected: boolean; + isConnecting: boolean; isDHE: boolean; isManaged: boolean; isRunning: boolean; }): ServerTreeItemContextValue { + if (isConnecting) { + return SERVER_TREE_ITEM_CONTEXT.isServerConnecting; + } + if (isManaged) { return isConnected ? SERVER_TREE_ITEM_CONTEXT.isManagedServerConnected @@ -314,19 +321,26 @@ export function getServerGroupTreeItem( /** * Get icon id for a server in the UI. e.g. for tree nodes. * @param isConnected Whether the server is connected + * @param isConnecting Whether a client connection is currently being established * @param isManaged Whether the server is managed * @param isRunning Whether the server is running * @returns Icon id for server tree item */ export function getServerIconID({ isConnected, + isConnecting, isManaged, isRunning, }: { isConnected: boolean; + isConnecting: boolean; isManaged: boolean; isRunning: boolean; }): string { + if (isConnecting) { + return ICON_ID.connecting; + } + return isRunning ? isConnected ? ICON_ID.serverConnected @@ -343,9 +357,13 @@ export function getServerIconID({ * number of connected workers in the case of DHE) * @param isManaged Whether the server is managed * @param isRunning Whether the server is running + * @param isConnecting Whether a client connection is currently being established * @returns Tree item representing the server */ -export function getServerTreeItem(server: ServerState): vscode.TreeItem { +export function getServerTreeItem( + server: ServerState, + isConnecting: boolean +): vscode.TreeItem { const { connectionCount, isConnected, @@ -356,6 +374,7 @@ export function getServerTreeItem(server: ServerState): vscode.TreeItem { const contextValue = getServerContextValue({ isConnected, + isConnecting, isDHE: type === 'DHE', isManaged, isRunning, @@ -377,10 +396,14 @@ export function getServerTreeItem(server: ServerState): vscode.TreeItem { return { label, description, - tooltip: canConnect ? `Click to connect to ${label}` : label, + tooltip: isConnecting + ? `Connecting to ${label}…` + : canConnect + ? `Click to connect to ${label}` + : label, contextValue, iconPath: new vscode.ThemeIcon( - getServerIconID({ isConnected, isManaged, isRunning }) + getServerIconID({ isConnected, isConnecting, isManaged, isRunning }) ), command: canConnect ? { From 9abde971de6860aae61d5ce4c932f341d6ad9c9a Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Wed, 17 Jun 2026 16:05:55 -0500 Subject: [PATCH 10/24] Cleaned up server / connection management (#DH-22593) --- src/controllers/ConnectionController.ts | 4 + src/dh/dhe.ts | 22 +- src/providers/ServerTreeProvider.ts | 2 +- src/services/DheService.ts | 140 ++++----- src/services/ServerManager.ts | 390 +++++++++++++----------- src/types/serviceTypes.d.ts | 12 +- 6 files changed, 305 insertions(+), 265 deletions(-) diff --git a/src/controllers/ConnectionController.ts b/src/controllers/ConnectionController.ts index bef0cfaff..264f51f05 100644 --- a/src/controllers/ConnectionController.ts +++ b/src/controllers/ConnectionController.ts @@ -185,6 +185,10 @@ export class ConnectionController ); if (cn == null) { + updateConnectionStatusBarItem( + this._connectStatusBarItem, + 'disconnected' + ); return; } diff --git a/src/dh/dhe.ts b/src/dh/dhe.ts index dc89450ce..8ee20f487 100644 --- a/src/dh/dhe.ts +++ b/src/dh/dhe.ts @@ -421,7 +421,7 @@ export async function getDheAuthConfig( */ export function isAttachableWorker( queryInfo: QueryInfo, - operateAs: string + operateAs: string | null ): boolean { return ( queryInfo.type === INTERACTIVE_CONSOLE_QUERY_TYPE && @@ -434,26 +434,34 @@ export function isAttachableWorker( * List all running InteractiveConsole workers owned by the current effective * user. * @param dheClient DHE client to use. + * @param exclude Iterable of query serials to exclude from the results. * @returns A promise resolving to the filtered QueryInfo array. */ export async function listAttachableWorkers( - dheClient: DheAuthenticatedClient + dheClient: DheAuthenticatedClient, + exclude: Iterable ): Promise { const userInfo = await dheClient.getUserInfo(); const operateAs = userInfo.operateAs; + const excludeSet = new Set(exclude); + return dheClient .getKnownConfigs() - .filter(qi => isAttachableWorker(qi, operateAs)); + .filter( + qi => + isAttachableWorker(qi, operateAs) && + !excludeSet.has(qi.serial as QuerySerial) + ); } /** - * Build WorkerInfo from an already-Running QueryInfo without any I/O or + * Convert a QueryInfo object to a WorkerInfo object without any I/O or * side effects. Returns undefined when `queryInfo.designated` is null. * @param tagId Unique tag id to associate with the worker. * @param queryInfo The running query info. * @returns WorkerInfo or undefined. */ -export function buildWorkerInfo( +export function getWorkerInfoFromQueryInfo( tagId: UniqueID, queryInfo: QueryInfo ): WorkerInfo | undefined { @@ -511,7 +519,7 @@ export function getSerialFromTagId( * @param querySerial Serial of the query to get worker info for. * @returns A promise that resolves to the worker info when the worker is ready. */ -export async function getWorkerInfoFromQuery( +export async function getWorkerInfoFromQuerySerial( tagId: UniqueID, dhe: DheType, dheClient: DheAuthenticatedClient, @@ -573,7 +581,7 @@ export async function getWorkerInfoFromQuery( } } - return buildWorkerInfo(tagId, queryInfo); + return getWorkerInfoFromQueryInfo(tagId, queryInfo); } /** diff --git a/src/providers/ServerTreeProvider.ts b/src/providers/ServerTreeProvider.ts index 22d6070b8..8eafaaad3 100644 --- a/src/providers/ServerTreeProvider.ts +++ b/src/providers/ServerTreeProvider.ts @@ -23,7 +23,7 @@ export class ServerTreeProvider extends ServerTreeProviderBase { return getServerTreeItem( element, - this.serverManager.isConnecting(element.url) + this.serverManager.isServerConnecting(element.url) ); }; diff --git a/src/services/DheService.ts b/src/services/DheService.ts index e29096617..9cca2fb28 100644 --- a/src/services/DheService.ts +++ b/src/services/DheService.ts @@ -20,15 +20,15 @@ import { } from '../types'; import { Logger, uniqueId, URLMap } from '../util'; import { - buildWorkerInfo, + getWorkerInfoFromQueryInfo, createInteractiveConsoleQuery, createQueryName, deleteQueries, getDheFeatures, getSerialFromTagId, - getWorkerInfoFromQuery, + getWorkerInfoFromQuerySerial, isAttachableWorker, - listAttachableWorkers as listAttachableWorkersHelper, + listAttachableWorkers, } from '../dh/dhe'; import { CLOSE_CREATE_QUERY_VIEW_CMD, @@ -106,22 +106,22 @@ export class DheService implements IDheService { private _isConnected: boolean = false; private _operateAs: string | null = null; private _removeConfigListeners: (() => void) | null = null; - private readonly _creatingTagIds = new Set(); + private readonly _config: IConfigService; private readonly _dheClientCache: URLMap; private readonly _dheJsApiCache: IAsyncCacheService; private readonly _dheServerFeaturesCache: URLMap; + private readonly _pendingQueryTagIds = new Set(); private readonly _querySerialSet: Set; private readonly _interactiveConsoleQueryFactory: IInteractiveConsoleQueryFactory; private readonly _toaster: IToastService; private readonly _workerInfoMap: URLMap; - private readonly _onDidWorkerAttachable = - new vscode.EventEmitter(); - readonly onDidWorkerAttachable = this._onDidWorkerAttachable.event; + private readonly _onWorkerAttachable = new vscode.EventEmitter(); + readonly onWorkerAttachable = this._onWorkerAttachable.event; - private readonly _onDidWorkerRemoved = new vscode.EventEmitter(); - readonly onDidWorkerRemoved = this._onDidWorkerRemoved.event; + private readonly _onWorkerRemoved = new vscode.EventEmitter(); + readonly onWorkerRemoved = this._onWorkerRemoved.event; readonly serverUrl: URL; @@ -221,7 +221,6 @@ export class DheService implements IDheService { this._removeConfigListeners?.(); const dhe = await this._dheJsApiCache.get(this.serverUrl); - const operateAs = this._operateAs ?? ''; const onConfigAddedOrUpdated = ({ detail: queryInfo, @@ -229,43 +228,32 @@ export class DheService implements IDheService { const status = queryInfo.designated?.status; if (isTerminalQueryStatus(status)) { - // Terminal status on a tracked worker → signal removal. - const isTracked = [...this._workerInfoMap.values()].some( - w => w.serial === queryInfo.serial - ); - if (isTracked) { + // Terminal status on a tracked worker, fire _onWorkerRemoved + if (this._isQueryTracked(queryInfo)) { logger.info('Worker entered terminal state:', queryInfo.serial); - this._onDidWorkerRemoved.fire(queryInfo.serial as QuerySerial); + this._onWorkerRemoved.fire(queryInfo.serial as QuerySerial); } - } else if ( - isAttachableWorker(queryInfo, operateAs) && - !this._querySerialSet.has(queryInfo.serial as QuerySerial) && - !this._isCreatingQuery(queryInfo.name) + + return; + } + + if ( + !this._isQueryOwned(queryInfo) && + isAttachableWorker(queryInfo, this._operateAs) ) { - // Skip workers the extension is creating or already owns. The create - // path connects those directly with the tagId it allocated for the - // query name (`IC - VS Code - `). Without this guard, the same - // `EVENT_CONFIG_UPDATED` that flips a freshly-created worker to - // `Running` would auto-attach it here, racing the create path: it would - // double-connect with a *new* random tagId and let the now-functioning - // detach/cleanup machinery tear down a worker mid-creation (surfacing as - // a spurious startup failure). `_querySerialSet` covers the worker once - // its serial is known; `_isCreatingQuery` covers the earlier window - // (e.g. the iframe flow) where only the query name is known (Gotcha 4). - this._onDidWorkerAttachable.fire(queryInfo); + this._onWorkerAttachable.fire(queryInfo); } }; const onConfigRemoved = ({ detail: queryInfo, }: CustomEvent): void => { - const isTracked = [...this._workerInfoMap.values()].some( - w => w.serial === queryInfo.serial - ); - if (isTracked) { - logger.info('Worker removed:', queryInfo.serial); - this._onDidWorkerRemoved.fire(queryInfo.serial as QuerySerial); + if (!this._isQueryTracked(queryInfo)) { + return; } + + logger.info('Worker removed:', queryInfo.serial); + this._onWorkerRemoved.fire(queryInfo.serial as QuerySerial); }; const removeAdded = dheClient.client.addEventListener( @@ -343,22 +331,32 @@ export class DheService implements IDheService { return dheClient; } - /** - * Whether the given query name belongs to a worker this extension is - * currently creating. Created workers are named `IC - VS Code - ` - * (the iframe create flow may append a `_vN` version suffix), so a prefix - * match against in-flight tagIds covers both forms. Used to keep the - * auto-attach event path off a worker the create path owns end-to-end, during - * the window before its serial is known and added to `_querySerialSet`. - * @param queryName The query name from a config event. - */ - private _isCreatingQuery = (queryName: string): boolean => { - for (const tagId of this._creatingTagIds) { - if (queryName.startsWith(createQueryName(tagId))) { - return true; + private _isQueryOwned = ( + querySerialOrInfo: QuerySerial | QueryInfo + ): boolean => { + const { name, serial } = + typeof querySerialOrInfo === 'object' + ? querySerialOrInfo + : { serial: querySerialOrInfo }; + + if (name != null) { + for (const tagId of this._pendingQueryTagIds) { + // Created workers are named `IC - VS Code - ` (the iframe create + // flow may append a `_vN` version suffix), so a prefix match against + // in-flight tagIds covers both forms + const namePrefix = createQueryName(tagId); + + if (name.startsWith(namePrefix)) { + return true; + } } } - return false; + + return this._querySerialSet.has(serial as QuerySerial); + }; + + private _isQueryTracked = ({ serial }: QueryInfo): boolean => { + return [...this._workerInfoMap.values()].some(w => w.serial === serial); }; /** @@ -395,7 +393,8 @@ export class DheService implements IDheService { // the `finally` below, by which point its serial is in `_querySerialSet` // (added synchronously right after, with no `await` in between), so the // serial-based guard takes over without a gap. - this._creatingTagIds.add(tagId); + this._pendingQueryTagIds.add(tagId); + const removeStartupFailureListener = dheClient.client.addEventListener( dhe.Client.EVENT_CONFIG_UPDATED, ({ detail: queryInfo }: CustomEvent) => { @@ -444,7 +443,7 @@ export class DheService implements IDheService { throw err; } finally { removeStartupFailureListener(); - this._creatingTagIds.delete(tagId); + this._pendingQueryTagIds.delete(tagId); } if (querySerial == null) { @@ -452,7 +451,7 @@ export class DheService implements IDheService { } this._querySerialSet.add(querySerial); - const workerInfo = await getWorkerInfoFromQuery( + const workerInfo = await getWorkerInfoFromQuerySerial( tagId, dhe, dheClient.client, @@ -468,35 +467,40 @@ export class DheService implements IDheService { }; /** - * Attach to a pre-existing Running worker by building WorkerInfo from the - * given QueryInfo. Does NOT add the serial to `_querySerialSet` — attached + * Register a pre-existing Running worker by building WorkerInfo from the + * given QueryInfo. Does NOT add the serial to `_querySerialSet` — registered * workers are never owned and must never be deleted by the extension. - * @param queryInfo The already-Running QueryInfo for the worker to attach. - * @returns WorkerInfo for the attached worker. + * @param queryInfo The already-Running QueryInfo for the worker to register. + * @returns WorkerInfo for the registered worker. */ - attachWorker = (queryInfo: QueryInfo): WorkerInfo => { + registerWorkerInfo = (queryInfo: QueryInfo): WorkerInfo => { const tagId = uniqueId(); - const workerInfo = buildWorkerInfo(tagId, queryInfo); + const workerInfo = getWorkerInfoFromQueryInfo(tagId, queryInfo); if (workerInfo == null) { throw new Error( - `Cannot attach worker: queryInfo.designated is null for serial ${queryInfo.serial}` + `Cannot register worker for query info: ${queryInfo.serial}` ); } + this._workerInfoMap.set(workerInfo.workerUrl, workerInfo); + return workerInfo; }; /** * List all running InteractiveConsole workers owned by the current effective * user. + * @param exclude Iterable of query serials to exclude from the results. * @returns A promise resolving to the filtered QueryInfo array. */ - listAttachableWorkers = async (): Promise => { + listAttachableWorkers = async ( + exclude: Iterable + ): Promise => { const dheClient = await this.getClient(false); if (dheClient == null) { return []; } - return listAttachableWorkersHelper(dheClient.client); + return listAttachableWorkers(dheClient.client, exclude); }; /** @@ -511,12 +515,10 @@ export class DheService implements IDheService { return; } - const isOwned = this._querySerialSet.has(workerInfo.serial); - - this._querySerialSet.delete(workerInfo.serial); this._workerInfoMap.delete(workerUrl); - if (isOwned) { + if (this._isQueryOwned(workerInfo.serial)) { + this._querySerialSet.delete(workerInfo.serial); await this._disposeQueries([workerInfo.serial]); } }; @@ -544,8 +546,8 @@ export class DheService implements IDheService { this._querySerialSet.clear(); this._removeConfigListeners?.(); this._removeConfigListeners = null; - this._onDidWorkerAttachable.dispose(); - this._onDidWorkerRemoved.dispose(); + this._onWorkerAttachable.dispose(); + this._onWorkerRemoved.dispose(); await Promise.all([ this._workerInfoMap.dispose(), diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 0dfead421..02503503e 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -34,6 +34,7 @@ import { URIMap, URLMap, withResolvers, + type PromiseWithResolvers, } from '../util'; import { DhcService } from './DhcService'; import { getWorkerCredentials, isDheServerRunning } from '../dh/dhe'; @@ -55,7 +56,10 @@ export class ServerManager implements IServerManager { ) { this._configService = configService; this._connectionMap = new URLMap(); - this._pendingConnectionMap = new URLMap>(); + this._pendingConnectionMap = new URLMap< + PromiseWithResolvers + >(); + this._pendingServerConnections = new URLMap>(); this._coreClientCache = coreClientCache; this._dhcServiceFactory = dhcServiceFactory; this._dheClientCache = dheClientCache; @@ -77,7 +81,10 @@ export class ServerManager implements IServerManager { private readonly _configService: IConfigService; private readonly _connectionMap: URLMap; private readonly _pendingConnectionMap: URLMap< - Promise + PromiseWithResolvers + >; + private readonly _pendingServerConnections: URLMap< + PromiseWithResolvers >; private readonly _coreClientCache: URLMap; private readonly _dhcServiceFactory: IDhcServiceFactory; @@ -109,22 +116,27 @@ export class ServerManager implements IServerManager { private readonly _onDidUpdate = new vscode.EventEmitter(); readonly onDidUpdate = this._onDidUpdate.event; - private _setPendingConnection = ( + private _resolvePendingConnection = ( serverUrl: URL, - connectionPromise: Promise + connectionState: ConnectionState | null ): void => { - this._pendingConnectionMap.set(serverUrl, connectionPromise); - this._onDidUpdate.fire(); + if (this._pendingConnectionMap.has(serverUrl)) { + this._pendingConnectionMap.getOrThrow(serverUrl).resolve(connectionState); + this._pendingConnectionMap.delete(serverUrl); + this._onDidUpdate.fire(); + } }; - private _clearPendingConnection = (serverUrl: URL): void => { - if (this._pendingConnectionMap.delete(serverUrl)) { + private _resolvePendingServerConnection = (serverUrl: URL): void => { + if (this._pendingServerConnections.has(serverUrl)) { + this._pendingServerConnections.getOrThrow(serverUrl).resolve(); + this._pendingServerConnections.delete(serverUrl); this._onDidUpdate.fire(); } }; - isConnecting = (serverUrl: URL): boolean => - this._pendingConnectionMap.has(serverUrl); + isServerConnecting = (serverUrl: URL): boolean => + this._pendingServerConnections.has(serverUrl); private _lastServerRunningStatus = new URLMap(); private _hasEverUpdatedStatus = false; @@ -219,6 +231,16 @@ export class ServerManager implements IServerManager { this._onDidLoadConfig.fire(); }; + /** + * Connect to a server and attach to any Core / Core+ workers available for + * the server. For DHE, if no workers are available, creates one and attaches + * to it. + * @param serverUrl + * @param workerConsoleType + * @param operateAsAnotherUser + * @returns The connection state of the attached worker, or `null` if the + * connection or worker creation/attachment failed. + */ connectToServer = async ( serverUrl: URL, workerConsoleType?: ConsoleType, @@ -230,7 +252,7 @@ export class ServerManager implements IServerManager { throw new Error(`Server with URL '${serverUrl}' not found.`); } - // We only support 1 connection for DHC servers in the extension + // We only support 1 connection to a DHC server if (serverState.type === 'DHC' && serverState.connectionCount > 0) { logger.info('Already connected to server:', serverUrl.href); return this._connectionMap.getOrThrow(serverUrl); @@ -238,7 +260,7 @@ export class ServerManager implements IServerManager { if (this._pendingConnectionMap.has(serverUrl)) { logger.debug('Connection already in progress:', serverUrl.href); - return this._pendingConnectionMap.getOrThrow(serverUrl); + return this._pendingConnectionMap.getOrThrow(serverUrl).promise; } return this._doConnectToServer( @@ -250,186 +272,130 @@ export class ServerManager implements IServerManager { private _doConnectToServer = async ( serverState: ServerState, - workerConsoleType?: ConsoleType, - operateAsAnotherUser: boolean = false + workerConsoleType: ConsoleType | undefined = undefined, + operateAsAnotherUser: boolean ): Promise => { const serverUrl = serverState.url; logger.debug('Connecting to server:', serverUrl.href); - // Register the in-flight connection synchronously — before the first - // await — so concurrent callers dedupe against it. The deferred is settled - // with the result once the connect completes (see `finally`). - const { promise, resolve } = withResolvers(); - this._setPendingConnection(serverUrl, promise); + // Mark server and worker connection as pending + this._pendingServerConnections.set(serverUrl, withResolvers()); + this._pendingConnectionMap.set(serverUrl, withResolvers()); + this._onDidUpdate.fire(); + + let firstConnection: ConnectionState | null = null; - let result: ConnectionState | null = null; try { if (serverState.type === 'DHE') { - const dheServerUrl = serverUrl; - const isNewDheService = !this._dheServiceCache.has(dheServerUrl); - const dheService = await this._dheServiceCache.get(dheServerUrl); - - // Get client. Client will be initialized if it doesn't exist (including - // prompting user for login). - if (!(await dheService.getClient(true, operateAsAnotherUser))) { - return null; - } + const dheService = await this._connectToDheServer( + serverUrl, + operateAsAnotherUser + ); - // Mark the DHE server as connected now that we have a live client. - // (connectionCount stays 0 until workers attach; isConnected reflects - // the server-level connection, which is now established.) - const currentServerState = this._serverMap.get(dheServerUrl); - if (currentServerState != null && !currentServerState.isConnected) { - this._serverMap.set(dheServerUrl, { - ...currentServerState, - isConnected: true, - }); - this._onDidUpdate.fire(); - } + // server connection is done + this._resolvePendingServerConnection(serverUrl); - // Client is live; the DHE connecting window ends here. The worker - // attach/create phase below is intentionally not covered (the `finally` - // clear is then an idempotent no-op). - this._clearPendingConnection(dheServerUrl); - - // Wire the config-event subscription exactly once per DHE service - // instance, BEFORE snapshotting, so any worker that appears or disappears - // between the snapshot and now is not missed. `_connectWorker` reserves - // each serial synchronously (before any `await`), so an event-driven - // attach and the snapshot batch below can never double-connect the same - // worker — whichever reaches `_connectWorker` first wins and the other is - // a no-op. - if (isNewDheService) { - dheService.onDidWorkerAttachable(qi => - this._reconcileAttach(dheServerUrl, dheService, qi) - ); - dheService.onDidWorkerRemoved(serial => - this._reconcileDetach(serial) - ); + if (dheService == null) { + return null; } - // Snapshot currently-running attachable workers and compute the - // not-yet-attached subset. - const attachable = await dheService.listAttachableWorkers(); - const toAttach = attachable.filter( - qi => !this._attachedWorkerSerials.has(qi.serial as QuerySerial) + const attachableWorkers = await dheService.listAttachableWorkers( + this._attachedWorkerSerials.keys() ); - if (toAttach.length > 0) { - // Attach existing workers in batches to avoid server stampede. - const BATCH_SIZE = 4; - let firstConnection: ConnectionState | null = null; - for (let i = 0; i < toAttach.length; i += BATCH_SIZE) { - const batch = toAttach.slice(i, i + BATCH_SIZE); - const connections = await Promise.all( - batch.map(async qi => { - const workerInfo = dheService.attachWorker(qi); - return this._connectWorker(dheServerUrl, workerInfo); - }) - ); - if (firstConnection == null) { - firstConnection = connections.find(c => c != null) ?? null; - } - } - if (toAttach.length > 1) { - this._toaster.info(`Attached to ${toAttach.length} worker(s).`); - } - result = firstConnection; - return result; - } - - // Nothing left to attach — either no workers exist for this user, or all - // of them already have live connections. Create a fresh one so clicking a - // server always yields a usable session. - const tagId = uniqueId(); - const placeholderUrl = this.addWorkerPlaceholderConnection( - dheServerUrl, - tagId - ); + let workerInfos: WorkerInfo[]; - let workerInfo: WorkerInfo; - try { - workerInfo = await dheService.createWorker(tagId, workerConsoleType); + // If no attachable workers exist, create a new one + if (attachableWorkers.length === 0) { + const workerInfo = await this._createWorker( + dheService, + workerConsoleType + ); - // If the worker finished creating but there is no placeholder - // connection, the user cancelled before it was ready. - if (!this._connectionMap.has(placeholderUrl)) { - dheService.deleteWorker(workerInfo.workerUrl); - this._onDidUpdate.fire(); + if (workerInfo == null) { return null; } - } catch (err) { - if (err instanceof QueryCreationCancelledError) { - logger.info(err); - const msg = 'Connection cancelled.'; - this._outputChannel.appendLine(msg); - this._toaster.info(msg); - } else { - const msg = - err instanceof QueryStartupFailureError - ? err.message - : 'Failed to create worker.'; - logger.error(err); - this._outputChannel.appendLine(msg); - this._toaster.error(msg); - } - this.removeWorkerPlaceholderConnection(placeholderUrl); - return null; + workerInfos = [workerInfo]; + } + // register attachable workers + else { + workerInfos = attachableWorkers.map(queryInfo => + dheService.registerWorkerInfo(queryInfo) + ); } - this.removeWorkerPlaceholderConnection(placeholderUrl); - result = await this._connectWorker(dheServerUrl, workerInfo); - return result; - } - - // DHC path: single direct connection. - const connection = this._dhcServiceFactory.create(serverUrl, undefined); + // Connect to workers in parallel + const connections = ( + await Promise.all( + workerInfos.map(workerInfo => + this._attachToWorker(serverUrl, workerInfo) + ) + ) + ).filter(c => c != null); + + if (attachableWorkers.length > 1) { + this._toaster.info(`Attached to ${connections.length} worker(s).`); + } - // Initialize client + prompt for login if necessary - const coreClient = await connection.getClient(); + [firstConnection = null] = connections; - if (coreClient == null) { - return null; + return firstConnection; } - this._connectionMap.set(serverUrl, connection); - this._onDidUpdate.fire(); - - if (!(await connection.initSession())) { - this._coreClientCache.delete(serverUrl); + // DHC attach to Core worker + firstConnection = await this._attachToWorker(serverUrl); - connection.dispose(); - this._connectionMap.delete(serverUrl); - return null; - } + this._resolvePendingServerConnection(serverUrl); + } finally { + this._resolvePendingConnection(serverUrl, firstConnection); + } - connection.onDidDisconnect(() => { - logger.debug('onDidDisconnect fired for:', serverUrl.href); - this.disconnectFromServer(serverUrl); - }); + return firstConnection; + }; - connection.onDidChangeRunningCodeStatus?.(() => { - this._onDidUpdate.fire(); - }); + private _connectToDheServer = async ( + serverUrl: URL, + operateAsAnotherUser: boolean + ): Promise => { + const dheServerUrl = serverUrl; + const isNewDheService = !this._dheServiceCache.has(dheServerUrl); + const dheService = await this._dheServiceCache.get(dheServerUrl); - this.updateConnectionCount(serverUrl, 1); + // Get client. Client will be initialized if it doesn't exist (including + // prompting user for login). + if (!(await dheService.getClient(true, operateAsAnotherUser))) { + return null; + } - this._onDidConnect.fire(serverUrl); + // Mark the DHE server as connected now that we have a live client. + // (connectionCount stays 0 until workers attach; isConnected reflects + // the server-level connection, which is now established.) + const currentServerState = this._serverMap.get(dheServerUrl); + if (currentServerState != null && !currentServerState.isConnected) { + this._serverMap.set(dheServerUrl, { + ...currentServerState, + isConnected: true, + }); this._onDidUpdate.fire(); + } - result = this._connectionMap.get(serverUrl) ?? null; - return result; - } finally { - // Settle the deferred for any deduped caller, and clear the pending - // entry. The clear ends the connecting window — DHC: the whole connect; - // DHE: idempotent here, since the explicit clear above already ran once - // the client was established (this still covers DHE failures before - // establishment). - resolve(result); - this._clearPendingConnection(serverUrl); + // Wire the config-event subscription exactly once per DHE service + // instance, BEFORE snapshotting, so any worker that appears or disappears + // between the snapshot and now is not missed. `_connectWorker` reserves + // each serial synchronously (before any `await`), so an event-driven + // attach and the snapshot batch below can never double-connect the same + // worker — whichever reaches `_connectWorker` first wins and the other is + // a no-op. + if (isNewDheService) { + dheService.onWorkerAttachable(qi => + this._reconcileAttach(dheServerUrl, dheService, qi) + ); + dheService.onWorkerRemoved(serial => this._reconcileDetach(serial)); } + + return dheService; }; /** @@ -437,38 +403,45 @@ export class ServerManager implements IServerManager { * `_workerURLToServerURLMap` for auth lookup and `_attachedWorkerSerials` * for idempotency/teardown. Used by both the create path and the attach path. * Does NOT touch placeholder connections — that is the create path's concern. - * @param dheServerUrl The DHE server this worker belongs to. + * @param serverUrl The DHE server this worker belongs to. * @param workerInfo Worker info built from the query. * @returns The new connection state, or null on failure. */ - private _connectWorker = async ( - dheServerUrl: URL, - workerInfo: WorkerInfo + private _attachToWorker = async ( + serverUrl: URL, + workerInfo?: WorkerInfo ): Promise => { - const workerUrl = new URL(workerInfo.workerUrl); - - // Idempotency gate: reserve the serial synchronously, before any `await`, - // so concurrent attach attempts for the same worker — e.g. the initial - // enumeration racing a streaming config event, or two clicks on the same - // server — cannot both connect and double-count. A later failure rolls the - // reservation back so the worker can be retried. - if (this._attachedWorkerSerials.has(workerInfo.serial)) { - return this._connectionMap.get(workerUrl) ?? null; - } - this._attachedWorkerSerials.set(workerInfo.serial, workerInfo.workerUrl); + const workerUrl = + workerInfo == null ? serverUrl : new URL(workerInfo.workerUrl); + + if (workerInfo != null) { + // Idempotency gate: reserve the serial synchronously, before any `await`, + // so concurrent attach attempts for the same worker — e.g. the initial + // enumeration racing a streaming config event, or two clicks on the same + // server — cannot both connect and double-count. A later failure rolls the + // reservation back so the worker can be retried. + if (this._attachedWorkerSerials.has(workerInfo.serial)) { + return this._connectionMap.get(workerUrl) ?? null; + } + this._attachedWorkerSerials.set(workerInfo.serial, workerInfo.workerUrl); - // Map the worker URL to its DHE server so the auth flow can resolve creds. - this._workerURLToServerURLMap.set(workerUrl, dheServerUrl); + // Map the worker URL to its DHE server so the auth flow can resolve creds. + this._workerURLToServerURLMap.set(workerUrl, serverUrl); + } const connection = this._dhcServiceFactory.create( workerUrl, - workerInfo.tagId + workerInfo?.tagId ); - // Initialize client (drives getWorkerCredentials → createAuthToken). + // Initialize client (includes auth flow). const coreClient = await connection.getClient(); + if (coreClient == null) { - this._attachedWorkerSerials.delete(workerInfo.serial); + if (workerInfo != null) { + this._attachedWorkerSerials.delete(workerInfo.serial); + } + return null; } @@ -476,8 +449,12 @@ export class ServerManager implements IServerManager { this._onDidUpdate.fire(); if (!(await connection.initSession())) { - this._attachedWorkerSerials.delete(workerInfo.serial); + if (workerInfo != null) { + this._attachedWorkerSerials.delete(workerInfo.serial); + } + this._coreClientCache.delete(workerUrl); + connection.dispose(); this._connectionMap.delete(workerUrl); return null; @@ -492,7 +469,7 @@ export class ServerManager implements IServerManager { this._onDidUpdate.fire(); }); - this.updateConnectionCount(dheServerUrl, 1); + this.updateConnectionCount(serverUrl, 1); this._onDidConnect.fire(workerUrl); this._onDidUpdate.fire(); @@ -500,6 +477,53 @@ export class ServerManager implements IServerManager { return this._connectionMap.get(workerUrl) ?? null; }; + private _createWorker = async ( + dheService: IDheService, + workerConsoleType?: ConsoleType + ): Promise => { + const tagId = uniqueId(); + const placeholderUrl = this.addWorkerPlaceholderConnection( + dheService.serverUrl, + tagId + ); + + try { + const workerInfo = await dheService.createWorker( + tagId, + workerConsoleType + ); + + // If the worker finished creating but there is no placeholder + // connection, the user cancelled before it was ready. + if (!this._connectionMap.has(placeholderUrl)) { + dheService.deleteWorker(workerInfo.workerUrl); + this._onDidUpdate.fire(); + return null; + } + + this.removeWorkerPlaceholderConnection(placeholderUrl); + return workerInfo; + } catch (err) { + if (err instanceof QueryCreationCancelledError) { + logger.info(err); + const msg = 'Connection cancelled.'; + this._outputChannel.appendLine(msg); + this._toaster.info(msg); + } else { + const msg = + err instanceof QueryStartupFailureError + ? err.message + : 'Failed to create worker.'; + logger.error(err); + this._outputChannel.appendLine(msg); + this._toaster.error(msg); + } + + this.removeWorkerPlaceholderConnection(placeholderUrl); + return null; + } + }; + /** * Attach a single worker when a config event indicates it became attachable. * Idempotent: no-ops if the serial is already connected. @@ -512,8 +536,8 @@ export class ServerManager implements IServerManager { if (this._attachedWorkerSerials.has(queryInfo.serial as QuerySerial)) { return; } - const workerInfo = dheService.attachWorker(queryInfo); - await this._connectWorker(dheServerUrl, workerInfo); + const workerInfo = dheService.registerWorkerInfo(queryInfo); + await this._attachToWorker(dheServerUrl, workerInfo); }; /** diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index 1566234f9..e95e55352 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -81,8 +81,8 @@ export interface IDhcService extends IDisposable, ConnectionState { } export interface IDheService extends ConnectionState, IDisposable { - readonly onDidWorkerAttachable: vscode.Event; - readonly onDidWorkerRemoved: vscode.Event; + readonly onWorkerAttachable: vscode.Event; + readonly onWorkerRemoved: vscode.Event; getClient( initializeIfNull: false @@ -94,8 +94,10 @@ export interface IDheService extends ConnectionState, IDisposable { getQuerySerialFromTag(tagId: UniqueID): Promise; getServerFeatures(): DheServerFeatures | undefined; getWorkerInfo: (workerUrl: WorkerURL) => WorkerInfo | undefined; - attachWorker: (queryInfo: QueryInfo) => WorkerInfo; - listAttachableWorkers: () => Promise; + registerWorkerInfo: (queryInfo: QueryInfo) => WorkerInfo; + listAttachableWorkers: ( + exclude: Iterable + ) => Promise; createWorker: ( tagId: UniqueID, consoleType?: ConsoleType @@ -188,7 +190,7 @@ export interface IServerManager extends IDisposable { hasConnectionUris: (connection: ConnectionState) => boolean; /** Whether a client connection to the given server is currently being established. */ - isConnecting: (serverUrl: URL) => boolean; + isServerConnecting: (serverUrl: URL) => boolean; getConnection: (serverUrl: URL) => ConnectionState | undefined; getConnections: (serverOrWorkerUrl?: URL) => ConnectionState[]; From cd658af3b0aab1c2c55ac7c6aaf2dc6fdaa4005f Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Wed, 17 Jun 2026 16:41:46 -0500 Subject: [PATCH 11/24] Added Create Worker action on DHE server nodes (#DH-22593) Add an explicit "+" (Create Worker) inline action to DHE server nodes in the WORKERS tree. It always creates a new IC worker on the server and attaches to it, independent of how many workers already exist. - ServerManager.createWorker composes _createWorker + _attachToWorker. - getConnectionTreeRootNodes also renders connected DHE servers so the server node (and its action) stays reachable with zero workers. - CREATE_WORKER_CMD + CreateWorkerCmdArgs, package.json command/palette/ view-item-context wiring, and ConnectionController.onCreateWorker. Co-Authored-By: Claude Opus 4.8 (1M context) (#DH-22593) --- package.json | 14 ++++++++++++ src/common/commands.ts | 6 +++++ src/controllers/ConnectionController.ts | 23 +++++++++++++++++++ src/services/ServerManager.ts | 30 +++++++++++++++++++++++++ src/types/serviceTypes.d.ts | 4 ++++ src/util/treeViewUtils.ts | 16 ++++++++++++- 6 files changed, 92 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index f87366fb6..bc7690e45 100644 --- a/package.json +++ b/package.json @@ -245,6 +245,11 @@ "title": "Connect to Server as Another User", "icon": "$(account)" }, + { + "command": "vscode-deephaven.createWorker", + "title": "Create Worker", + "icon": "$(add)" + }, { "command": "vscode-deephaven.createNewTextDoc", "title": "New File", @@ -835,6 +840,10 @@ "command": "vscode-deephaven.connectToServerOperateAs", "when": "false" }, + { + "command": "vscode-deephaven.createWorker", + "when": "false" + }, { "command": "vscode-deephaven.disconnectEditor", "when": "false" @@ -1009,6 +1018,11 @@ "when": "view == vscode-deephaven.view.serverConnectionTree && viewItem == isConnectionConnected", "group": "inline@1" }, + { + "command": "vscode-deephaven.createWorker", + "when": "view == vscode-deephaven.view.serverConnectionTree && viewItem == isDHEServerConnectionParent", + "group": "inline" + }, { "command": "vscode-deephaven.disconnectEditor", "when": "view == vscode-deephaven.view.serverConnectionTree && viewItem == isUri", diff --git a/src/common/commands.ts b/src/common/commands.ts index ee2042f83..b52a08018 100644 --- a/src/common/commands.ts +++ b/src/common/commands.ts @@ -26,6 +26,11 @@ export type ConnectToServerCmdArgs = [ operateAsAnotherUser?: boolean, ]; +/** Arguments passed to `CREATE_WORKER_CMD` handler */ +export type CreateWorkerCmdArgs = [ + serverState: Pick, +]; + /** Arguments passed to `OPEN_VARIABLE_PANELS_CMD` handler */ export type OpenVariablePanelsCmdArgs = [ serverUrl: URL, @@ -97,6 +102,7 @@ export const CREATE_DHE_AUTHENTICATED_CLIENT_CMD = cmd( 'createDHEAuthenticatedClient' ); export const CREATE_NEW_TEXT_DOC_CMD = cmd('createNewTextDoc'); +export const CREATE_WORKER_CMD = cmd('createWorker'); export const DELETE_VARIABLE_CMD = cmd('deleteVariable'); export const DISCONNECT_EDITOR_CMD = cmd('disconnectEditor'); export const DISCONNECT_FROM_SERVER_CMD = cmd('disconnectFromServer'); diff --git a/src/controllers/ConnectionController.ts b/src/controllers/ConnectionController.ts index 264f51f05..6c9927ff9 100644 --- a/src/controllers/ConnectionController.ts +++ b/src/controllers/ConnectionController.ts @@ -24,6 +24,8 @@ import { CONNECT_TO_SERVER_CMD, CONNECT_TO_SERVER_OPERATE_AS_CMD, ConnectToServerCmdArgs, + CREATE_WORKER_CMD, + CreateWorkerCmdArgs, DISCONNECT_EDITOR_CMD, DISCONNECT_FROM_SERVER_CMD, SELECT_CONNECTION_COMMAND, @@ -66,6 +68,9 @@ export class ConnectionController this.onConnectToServerOperateAs ); + /** Create a new worker on a DHE server */ + this.registerCommand(CREATE_WORKER_CMD, this.onCreateWorker); + /** Disconnect editor */ this.registerCommand(DISCONNECT_EDITOR_CMD, this.onDisconnectEditor); @@ -338,6 +343,24 @@ export class ConnectionController ); }; + /** + * Handle explicitly creating a new worker on a DHE server (the "+" action). + */ + onCreateWorker = async ( + ...[serverState]: CreateWorkerCmdArgs | [undefined] + ): Promise => { + // Sometimes view/item/context commands pass undefined instead of a value. + // Just ignore. microsoft/vscode#283655 + if (serverState == null) { + return; + } + + const languageId = vscode.window.activeTextEditor?.document.languageId; + const workerConsoleType = getConsoleType(languageId); + + await this._serverManager?.createWorker(serverState.url, workerConsoleType); + }; + /** * Handle connecting to a server as another user. * @param serverState diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 02503503e..4c808a8c7 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -524,6 +524,36 @@ export class ServerManager implements IServerManager { } }; + /** + * Explicitly create a new worker on a DHE server and attach to it. Unlike + * `connectToServer` (which only auto-creates a worker when none are + * attachable), this always creates a worker — it backs the "+" create-worker + * action on DHE server nodes. + * @param dheServerUrl The DHE server to create the worker on. + * @param workerConsoleType Optional console type for the new worker. + * @returns The new connection state, or `null` if client/worker + * creation/attachment failed. + */ + createWorker = async ( + dheServerUrl: URL, + workerConsoleType?: ConsoleType + ): Promise => { + const dheService = await this._dheServiceCache.get(dheServerUrl); + + // Ensure a live client (the server is already connected when the "+" action + // is visible, but guard anyway — this also covers a stale/expired client). + if (!(await dheService.getClient(true, false))) { + return null; + } + + const workerInfo = await this._createWorker(dheService, workerConsoleType); + if (workerInfo == null) { + return null; + } + + return this._attachToWorker(dheServerUrl, workerInfo); + }; + /** * Attach a single worker when a config event indicates it became attachable. * Idempotent: no-ops if the serial is already connected. diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index e95e55352..39c298231 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -182,6 +182,10 @@ export interface IServerManager extends IDisposable { workerConsoleType?: ConsoleType, operateAsAnotherUser?: boolean ) => Promise; + createWorker: ( + dheServerUrl: URL, + workerConsoleType?: ConsoleType + ) => Promise; disconnectEditor: (uri: vscode.Uri) => void; disconnectFromDHEServer: (dheServerUrl: URL) => Promise; disconnectFromServer: (serverUrl: URL) => Promise; diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index 422c887eb..5ea4e4083 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -169,8 +169,13 @@ export function getConnectionWorkerLabel( * connection is grouped under its parent server node (DHC and DHE alike), so a * community server with a single worker has the same hierarchy shape as an * enterprise server with many. Roots are sorted by their displayed label. + * + * In addition to every server that has connections, any DHE server with a live + * client is included so its node (and its "+" create-worker action) stays + * reachable even with zero workers. * @param serverManager Server manager. - * @returns The server root nodes (only servers that have connections). + * @returns The server root nodes (servers with connections, plus connected DHE + * servers). */ export function getConnectionTreeRootNodes( serverManager: IServerManager @@ -184,6 +189,15 @@ export function getConnectionTreeRootNodes( } } + // DHE servers with a live client appear even with zero workers, so the server + // node (and its "+" create-worker action) stays reachable after a cancelled + // worker creation or after the last worker is detached. + for (const server of serverManager.getServers({ type: 'DHE' })) { + if (server.isConnected) { + servers.set(server.url.toString(), server); + } + } + return [...servers.values()].sort((a, b) => getConnectionServerLabel(a).localeCompare(getConnectionServerLabel(b)) ); From 950bdcdf7ee6a08304ee3fa57d94e41a79c44cc5 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Wed, 17 Jun 2026 16:45:04 -0500 Subject: [PATCH 12/24] Align connection picker active items with WORKERS tree (#DH-22593) Active-connection picker items now mirror the WORKERS-tree worker node: leading language icon (getConsoleTypeIconId) + worker-name label (getConnectionWorkerLabel), with the server host:port moved to the description. createConnectionQuickPickOptions is now async and takes an IServerManager to resolve each connection's parent server and worker info. Co-Authored-By: Claude Opus 4.8 (1M context) (#DH-22593) --- src/controllers/ConnectionController.ts | 3 +- src/util/__snapshots__/uiUtils.spec.ts.snap | 24 ++++----- src/util/uiUtils.spec.ts | 37 ++++++++++--- src/util/uiUtils.ts | 59 ++++++++++++++------- 4 files changed, 86 insertions(+), 37 deletions(-) diff --git a/src/controllers/ConnectionController.ts b/src/controllers/ConnectionController.ts index 6c9927ff9..5b105f820 100644 --- a/src/controllers/ConnectionController.ts +++ b/src/controllers/ConnectionController.ts @@ -500,10 +500,11 @@ export class ConnectionController try { selectedCnResult = await createConnectionQuickPick( - createConnectionQuickPickOptions( + await createConnectionQuickPickOptions( [...runningDHCServersWithoutConnections, ...runningDHEServers], connectionsForConsoleType, languageId, + this._serverManager, editorActiveConnectionUrl ) ); diff --git a/src/util/__snapshots__/uiUtils.spec.ts.snap b/src/util/__snapshots__/uiUtils.spec.ts.snap index 9c7e875cf..9009195c7 100644 --- a/src/util/__snapshots__/uiUtils.spec.ts.snap +++ b/src/util/__snapshots__/uiUtils.spec.ts.snap @@ -33,12 +33,12 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed "isConnected": true, "serverUrl": "http://localhost:10000/", }, - "description": "python (current)", + "description": "localhost:10000 (current)", "iconPath": ThemeIcon { "color": undefined, - "id": "vm-connect", + "id": "dh-python", }, - "label": "http://localhost:10000/", + "label": "localhost:10000", "type": "connection", }, { @@ -46,12 +46,12 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed "isConnected": true, "serverUrl": "http://localhost:10002/", }, - "description": "python", + "description": "localhost:10002", "iconPath": ThemeIcon { "color": undefined, - "id": "vm-connect", + "id": "dh-python", }, - "label": "http://localhost:10002/", + "label": "localhost:10002", "type": "connection", }, { @@ -104,12 +104,12 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed "isConnected": true, "serverUrl": "http://localhost:10000/", }, - "description": "python", + "description": "localhost:10000", "iconPath": ThemeIcon { "color": undefined, - "id": "vm-connect", + "id": "dh-python", }, - "label": "http://localhost:10000/", + "label": "localhost:10000", "type": "connection", }, { @@ -117,12 +117,12 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed "isConnected": true, "serverUrl": "http://localhost:10002/", }, - "description": "python", + "description": "localhost:10002", "iconPath": ThemeIcon { "color": undefined, - "id": "vm-connect", + "id": "dh-python", }, - "label": "http://localhost:10002/", + "label": "localhost:10002", "type": "connection", }, { diff --git a/src/util/uiUtils.spec.ts b/src/util/uiUtils.spec.ts index c78b4fb54..575f53f5b 100644 --- a/src/util/uiUtils.spec.ts +++ b/src/util/uiUtils.spec.ts @@ -14,6 +14,7 @@ import type { ConnectionState, CoreConnectionConfig, IDhcService, + IServerManager, ServerState, } from '../types'; @@ -55,7 +56,7 @@ describe('createConnectionQuickPickOptions', () => { ['Active A', serverUrlA], ])( 'should return quick pick options: editorActiveConnectionUrl=%s', - (_label, editorActiveConnectionUrl) => { + async (_label, editorActiveConnectionUrl) => { const serversWithoutConnections: ServerState[] = [ { type: 'DHC', @@ -77,23 +78,47 @@ describe('createConnectionQuickPickOptions', () => { { serverUrl: serverUrlC, isConnected: true }, ]; - const actual = createConnectionQuickPickOptions( + const serverManager = { + getServerForConnection: vi.fn( + (connection: ConnectionState): ServerState => ({ + type: 'DHC', + url: connection.serverUrl, + isConnected: true, + isRunning: true, + connectionCount: 1, + }) + ), + getWorkerInfo: vi.fn(async () => undefined), + } as unknown as IServerManager; + + const actual = await createConnectionQuickPickOptions( serversWithoutConnections, connections, 'python', + serverManager, editorActiveConnectionUrl ); expect(actual).toMatchSnapshot(); } ); - it('should throw if no servers or connections', () => { + it('should throw if no servers or connections', async () => { const servers: ServerState[] = []; const connections: IDhcService[] = []; - expect(() => - createConnectionQuickPickOptions(servers, connections, 'python') - ).toThrowError('No available servers to connect to.'); + const serverManager = { + getServerForConnection: vi.fn(), + getWorkerInfo: vi.fn(async () => undefined), + } as unknown as IServerManager; + + await expect( + createConnectionQuickPickOptions( + servers, + connections, + 'python', + serverManager + ) + ).rejects.toThrowError('No available servers to connect to.'); }); }); diff --git a/src/util/uiUtils.ts b/src/util/uiUtils.ts index 53ceaa19f..5645fc586 100644 --- a/src/util/uiUtils.ts +++ b/src/util/uiUtils.ts @@ -19,6 +19,7 @@ import type { ConnectionType, ConsoleType, ConnectionPickItem, + IServerManager, ServerState, SeparatorPickItem, ConnectionPickOption, @@ -32,6 +33,7 @@ import type { MultiAuthConfig, } from '../types'; import { getFilePathDateToken, sortByStringProp } from './dataUtils'; +import { getConnectionWorkerLabel, getConsoleTypeIconId } from './treeViewUtils'; import { Logger } from './Logger'; const logger = new Logger('uiUtils'); @@ -54,20 +56,27 @@ export interface WorkspaceFolderConfig { /** * Create options for a connection quick pick. + * + * Active-connection items mirror the WORKERS-tree worker node: a leading + * language icon plus the worker name, with the server host:port shown in the + * description. * @param servers The available servers * @param connections The available connections * @param editorLanguageId The language id of the editor + * @param serverManager The server manager used to resolve each connection's + * parent server and worker info. * @param editorActiveConnectionUrl The active connection url of the editor * @returns */ -export function createConnectionQuickPickOptions< +export async function createConnectionQuickPickOptions< TConnection extends ConnectionState, >( servers: ServerState[], connections: TConnection[], editorLanguageId: string, + serverManager: IServerManager, editorActiveConnectionUrl?: URL | null -): ConnectionPickOption[] { +): Promise[]> { const serverOptions: ConnectionPickItem<'server', ServerState>[] = servers.map(data => ({ type: 'server', @@ -77,22 +86,36 @@ export function createConnectionQuickPickOptions< data, })); - const connectionOptions: ConnectionPickItem<'connection', TConnection>[] = []; - - for (const dhService of connections) { - const isActiveConnection = - editorActiveConnectionUrl?.toString() === dhService.serverUrl.toString(); - - connectionOptions.push({ - type: 'connection', - label: dhService.serverUrl.toString(), - iconPath: new vscode.ThemeIcon(ICON_ID.connected), - description: isActiveConnection - ? `${editorLanguageId} (current)` - : editorLanguageId, - data: dhService, - }); - } + const connectionOptions: ConnectionPickItem<'connection', TConnection>[] = + await Promise.all( + connections.map(async dhService => { + const isActiveConnection = + editorActiveConnectionUrl?.toString() === + dhService.serverUrl.toString(); + + const parentServer = + serverManager.getServerForConnection(dhService); + const workerInfo = await serverManager.getWorkerInfo( + dhService.serverUrl + ); + + const host = parentServer?.url.host ?? dhService.serverUrl.host; + + return { + type: 'connection' as const, + label: getConnectionWorkerLabel( + parentServer, + dhService, + workerInfo?.name + ), + iconPath: new vscode.ThemeIcon( + getConsoleTypeIconId(editorLanguageId as ConsoleType) + ), + description: isActiveConnection ? `${host} (current)` : host, + data: dhService, + }; + }) + ); if (serverOptions.length === 0 && connectionOptions.length === 0) { throw new Error('No available servers to connect to.'); From 80434d6c05b22e37fe4654e759e8f9d92e6540d1 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 18 Jun 2026 10:53:00 -0500 Subject: [PATCH 13/24] Only allow removing owned connections (#DH-22593) --- package.json | 20 +- src/common/commands.ts | 1 + src/common/constants.ts | 6 +- src/controllers/ConnectionController.ts | 7 + src/providers/ServerConnectionTreeProvider.ts | 7 +- src/services/DhcService.ts | 10 +- src/services/ServerManager.ts | 193 ++++++++++-------- src/types/serviceTypes.d.ts | 3 +- 8 files changed, 148 insertions(+), 99 deletions(-) diff --git a/package.json b/package.json index bc7690e45..7ff79d550 100644 --- a/package.json +++ b/package.json @@ -265,6 +265,11 @@ "title": "Disconnect from Server", "icon": "$(debug-disconnect)" }, + { + "command": "vscode-deephaven.disconnectFromWorker", + "title": "Disconnect from Worker", + "icon": "$(debug-disconnect)" + }, { "command": "vscode-deephaven.generateDHEKeyPair", "title": "Generate DHE Key Pair", @@ -852,6 +857,10 @@ "command": "vscode-deephaven.disconnectFromServer", "when": "false" }, + { + "command": "vscode-deephaven.disconnectFromWorker", + "when": "false" + }, { "command": "vscode-deephaven.generateDHEKeyPair", "when:": "false" @@ -1015,7 +1024,7 @@ }, { "command": "vscode-deephaven.createNewTextDoc", - "when": "view == vscode-deephaven.view.serverConnectionTree && viewItem == isConnectionConnected", + "when": "view == vscode-deephaven.view.serverConnectionTree && (viewItem == isConnectionConnected || viewItem == isConnectionConnectedRemovable)", "group": "inline@1" }, { @@ -1030,7 +1039,12 @@ }, { "command": "vscode-deephaven.disconnectFromServer", - "when": "(view == vscode-deephaven.view.serverTree && (viewItem == isServerRunningConnected || viewItem == isDHEServerRunningConnected)) || (view == vscode-deephaven.view.serverConnectionTree && (viewItem == isConnectionConnected || viewItem == isConnectionConnecting))", + "when": "view == vscode-deephaven.view.serverTree && (viewItem == isServerRunningConnected || viewItem == isDHEServerRunningConnected)", + "group": "inline@2" + }, + { + "command": "vscode-deephaven.disconnectFromWorker", + "when": "view == vscode-deephaven.view.serverConnectionTree && (viewItem == isConnectionConnectedRemovable || viewItem == isConnectionConnectingRemovable)", "group": "inline@2" }, { @@ -1039,7 +1053,7 @@ }, { "command": "vscode-deephaven.generateRequirementsTxt", - "when": "view == vscode-deephaven.view.serverConnectionTree && viewItem == isConnectionConnected" + "when": "view == vscode-deephaven.view.serverConnectionTree && (viewItem == isConnectionConnected || viewItem == isConnectionConnectedRemovable)" }, { "command": "vscode-deephaven.openInBrowser", diff --git a/src/common/commands.ts b/src/common/commands.ts index b52a08018..3f78a953e 100644 --- a/src/common/commands.ts +++ b/src/common/commands.ts @@ -106,6 +106,7 @@ export const CREATE_WORKER_CMD = cmd('createWorker'); export const DELETE_VARIABLE_CMD = cmd('deleteVariable'); export const DISCONNECT_EDITOR_CMD = cmd('disconnectEditor'); export const DISCONNECT_FROM_SERVER_CMD = cmd('disconnectFromServer'); +export const DISCONNECT_FROM_WORKER_CMD = cmd('disconnectFromWorker'); export const DOWNLOAD_LOGS_CMD = cmd('downloadLogs'); export const GENERATE_DHE_KEY_PAIR_CMD = cmd('generateDHEKeyPair'); export const GENERATE_REQUIREMENTS_TXT_CMD = cmd('generateRequirementsTxt'); diff --git a/src/common/constants.ts b/src/common/constants.ts index 827be37dc..90a597b66 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -157,8 +157,10 @@ export const VARIABLE_UNICODE_ICONS = { /* eslint-enable @typescript-eslint/naming-convention */ export const CONNECTION_TREE_ITEM_CONTEXT = { - isConnectionConnected: 'isConnectionConnected', - isConnectionConnecting: 'isConnectionConnecting', + isConnectionConnected: (isOwned: boolean) => + `isConnectionConnected${isOwned ? 'Removable' : ''}`, + isConnectionConnecting: (isOwned: boolean) => + `isConnectionConnecting${isOwned ? 'Removable' : ''}`, isDHEServerConnectionParent: 'isDHEServerConnectionParent', isUri: 'isUri', } as const; diff --git a/src/controllers/ConnectionController.ts b/src/controllers/ConnectionController.ts index 5b105f820..514fcc221 100644 --- a/src/controllers/ConnectionController.ts +++ b/src/controllers/ConnectionController.ts @@ -28,6 +28,7 @@ import { CreateWorkerCmdArgs, DISCONNECT_EDITOR_CMD, DISCONNECT_FROM_SERVER_CMD, + DISCONNECT_FROM_WORKER_CMD, SELECT_CONNECTION_COMMAND, UnsupportedConsoleTypeError, } from '../common'; @@ -80,6 +81,12 @@ export class ConnectionController this.onDisconnectFromServer ); + /** Disconnect from worker (per-worker action on connection nodes) */ + this.registerCommand( + DISCONNECT_FROM_WORKER_CMD, + this.onDisconnectFromServer + ); + /** Select connection to run scripts against */ this.registerCommand( SELECT_CONNECTION_COMMAND, diff --git a/src/providers/ServerConnectionTreeProvider.ts b/src/providers/ServerConnectionTreeProvider.ts index 941bb1c27..221880183 100644 --- a/src/providers/ServerConnectionTreeProvider.ts +++ b/src/providers/ServerConnectionTreeProvider.ts @@ -59,12 +59,15 @@ export class ServerConnectionTreeProvider extends ServerTreeProviderBase { return { - create: (serverUrl: URL, tagId?: UniqueID): IDhcService => { + create: ( + serverUrl: URL, + isOwned: boolean, + tagId?: UniqueID + ): IDhcService => { return new DhcService( serverUrl, + isOwned, coreClientCache, groovyDiagnosticsCollection, diagnosticsCollection, @@ -95,6 +100,7 @@ export class DhcService extends DisposableBase implements IDhcService { */ private constructor( serverUrl: URL, + isOwned: boolean, coreClientCache: URLMap, groovyDiagnosticsCollection: vscode.DiagnosticCollection, diagnosticsCollection: vscode.DiagnosticCollection, @@ -108,6 +114,7 @@ export class DhcService extends DisposableBase implements IDhcService { super(); this.coreClientCache = coreClientCache; + this.isOwned = isOwned; this.groovyDiagnosticsCollection = groovyDiagnosticsCollection; this.diagnosticsCollection = diagnosticsCollection; this.remoteFileSourceService = remoteFileSourceService; @@ -132,6 +139,7 @@ export class DhcService extends DisposableBase implements IDhcService { private readonly _onDidDisconnect = new vscode.EventEmitter(); readonly onDidDisconnect = this._onDidDisconnect.event; + public readonly isOwned: boolean; public readonly serverUrl: URL; public readonly tagId?: UniqueID; diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 4c808a8c7..10b9d5fec 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -287,7 +287,12 @@ export class ServerManager implements IServerManager { let firstConnection: ConnectionState | null = null; try { - if (serverState.type === 'DHE') { + if (serverState.type === 'DHC') { + // DHC attach to Core worker + firstConnection = await this._attachToWorker(serverUrl, true); + + this._resolvePendingServerConnection(serverUrl); + } else { const dheService = await this._connectToDheServer( serverUrl, operateAsAnotherUser @@ -300,54 +305,13 @@ export class ServerManager implements IServerManager { return null; } - const attachableWorkers = await dheService.listAttachableWorkers( - this._attachedWorkerSerials.keys() + [firstConnection = null] = await this._createOrAttachToWorkers( + dheService, + workerConsoleType ); - let workerInfos: WorkerInfo[]; - - // If no attachable workers exist, create a new one - if (attachableWorkers.length === 0) { - const workerInfo = await this._createWorker( - dheService, - workerConsoleType - ); - - if (workerInfo == null) { - return null; - } - - workerInfos = [workerInfo]; - } - // register attachable workers - else { - workerInfos = attachableWorkers.map(queryInfo => - dheService.registerWorkerInfo(queryInfo) - ); - } - - // Connect to workers in parallel - const connections = ( - await Promise.all( - workerInfos.map(workerInfo => - this._attachToWorker(serverUrl, workerInfo) - ) - ) - ).filter(c => c != null); - - if (attachableWorkers.length > 1) { - this._toaster.info(`Attached to ${connections.length} worker(s).`); - } - - [firstConnection = null] = connections; - return firstConnection; } - - // DHC attach to Core worker - firstConnection = await this._attachToWorker(serverUrl); - - this._resolvePendingServerConnection(serverUrl); } finally { this._resolvePendingConnection(serverUrl, firstConnection); } @@ -355,49 +319,6 @@ export class ServerManager implements IServerManager { return firstConnection; }; - private _connectToDheServer = async ( - serverUrl: URL, - operateAsAnotherUser: boolean - ): Promise => { - const dheServerUrl = serverUrl; - const isNewDheService = !this._dheServiceCache.has(dheServerUrl); - const dheService = await this._dheServiceCache.get(dheServerUrl); - - // Get client. Client will be initialized if it doesn't exist (including - // prompting user for login). - if (!(await dheService.getClient(true, operateAsAnotherUser))) { - return null; - } - - // Mark the DHE server as connected now that we have a live client. - // (connectionCount stays 0 until workers attach; isConnected reflects - // the server-level connection, which is now established.) - const currentServerState = this._serverMap.get(dheServerUrl); - if (currentServerState != null && !currentServerState.isConnected) { - this._serverMap.set(dheServerUrl, { - ...currentServerState, - isConnected: true, - }); - this._onDidUpdate.fire(); - } - - // Wire the config-event subscription exactly once per DHE service - // instance, BEFORE snapshotting, so any worker that appears or disappears - // between the snapshot and now is not missed. `_connectWorker` reserves - // each serial synchronously (before any `await`), so an event-driven - // attach and the snapshot batch below can never double-connect the same - // worker — whichever reaches `_connectWorker` first wins and the other is - // a no-op. - if (isNewDheService) { - dheService.onWorkerAttachable(qi => - this._reconcileAttach(dheServerUrl, dheService, qi) - ); - dheService.onWorkerRemoved(serial => this._reconcileDetach(serial)); - } - - return dheService; - }; - /** * Create a Core+ JS API connection to an existing worker. Populates * `_workerURLToServerURLMap` for auth lookup and `_attachedWorkerSerials` @@ -409,6 +330,7 @@ export class ServerManager implements IServerManager { */ private _attachToWorker = async ( serverUrl: URL, + isOwned: boolean, workerInfo?: WorkerInfo ): Promise => { const workerUrl = @@ -431,6 +353,7 @@ export class ServerManager implements IServerManager { const connection = this._dhcServiceFactory.create( workerUrl, + isOwned, workerInfo?.tagId ); @@ -477,6 +400,96 @@ export class ServerManager implements IServerManager { return this._connectionMap.get(workerUrl) ?? null; }; + private _connectToDheServer = async ( + serverUrl: URL, + operateAsAnotherUser: boolean + ): Promise => { + const dheServerUrl = serverUrl; + const isNewDheService = !this._dheServiceCache.has(dheServerUrl); + const dheService = await this._dheServiceCache.get(dheServerUrl); + + // Get client. Client will be initialized if it doesn't exist (including + // prompting user for login). + if (!(await dheService.getClient(true, operateAsAnotherUser))) { + return null; + } + + // Mark the DHE server as connected now that we have a live client. + // (connectionCount stays 0 until workers attach; isConnected reflects + // the server-level connection, which is now established.) + const currentServerState = this._serverMap.get(dheServerUrl); + if (currentServerState != null && !currentServerState.isConnected) { + this._serverMap.set(dheServerUrl, { + ...currentServerState, + isConnected: true, + }); + this._onDidUpdate.fire(); + } + + // Wire the config-event subscription exactly once per DHE service + // instance, BEFORE snapshotting, so any worker that appears or disappears + // between the snapshot and now is not missed. `_connectWorker` reserves + // each serial synchronously (before any `await`), so an event-driven + // attach and the snapshot batch below can never double-connect the same + // worker — whichever reaches `_connectWorker` first wins and the other is + // a no-op. + if (isNewDheService) { + dheService.onWorkerAttachable(qi => + this._reconcileAttach(dheServerUrl, dheService, qi) + ); + dheService.onWorkerRemoved(serial => this._reconcileDetach(serial)); + } + + return dheService; + }; + + private _createOrAttachToWorkers = async ( + dheService: IDheService, + workerConsoleType: ConsoleType | undefined = undefined + ): Promise => { + const attachableWorkers = await dheService.listAttachableWorkers( + this._attachedWorkerSerials.keys() + ); + + let workerInfos: [boolean, WorkerInfo][]; + + // If no attachable workers exist, create a new one + if (attachableWorkers.length === 0) { + const workerInfo = await this._createWorker( + dheService, + workerConsoleType + ); + + if (workerInfo == null) { + return []; + } + + workerInfos = [[true, workerInfo]]; + } + // register attachable workers + else { + workerInfos = attachableWorkers.map(queryInfo => [ + false, + dheService.registerWorkerInfo(queryInfo), + ]); + } + + // Connect to workers in parallel + const connections = ( + await Promise.all( + workerInfos.map(([isOwned, workerInfo]) => + this._attachToWorker(dheService.serverUrl, isOwned, workerInfo) + ) + ) + ).filter(c => c != null); + + if (attachableWorkers.length > 1) { + this._toaster.info(`Attached to ${connections.length} worker(s).`); + } + + return connections; + }; + private _createWorker = async ( dheService: IDheService, workerConsoleType?: ConsoleType @@ -551,7 +564,7 @@ export class ServerManager implements IServerManager { return null; } - return this._attachToWorker(dheServerUrl, workerInfo); + return this._attachToWorker(dheServerUrl, true, workerInfo); }; /** @@ -567,7 +580,7 @@ export class ServerManager implements IServerManager { return; } const workerInfo = dheService.registerWorkerInfo(queryInfo); - await this._attachToWorker(dheServerUrl, workerInfo); + await this._attachToWorker(dheServerUrl, false, workerInfo); }; /** diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index 39c298231..7bea8e58e 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -58,6 +58,7 @@ export interface IConfigService { export interface IDhcService extends IDisposable, ConnectionState { readonly isInitialized: boolean; readonly isConnected: boolean; + readonly isOwned: boolean; isRunningCode: boolean; readonly onDidDisconnect: vscode.Event; @@ -118,7 +119,7 @@ export type ICoreClientFactory = ( */ export type IDhcServiceFactory = IFactory< IDhcService, - [serverUrl: URL, tagId?: UniqueID] + [serverUrl: URL, isOwned: boolean, tagId?: UniqueID] >; export type IDheClientFactory = ( serverUrl: URL From b7ab9b453a2657c81f26561d284273e35479c33f Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 18 Jun 2026 12:17:41 -0500 Subject: [PATCH 14/24] Added label to ConnectionState (#DH-22593) --- .../ServerConnectionPanelTreeProvider.ts | 14 +----- src/providers/ServerConnectionTreeProvider.ts | 15 +----- src/services/DhcService.ts | 5 ++ src/services/DheService.ts | 15 ++++-- src/services/ServerManager.ts | 10 +++- src/types/commonTypes.d.ts | 1 + src/types/serviceTypes.d.ts | 2 +- src/util/treeViewUtils.spec.ts | 48 ------------------- src/util/treeViewUtils.ts | 22 --------- src/util/uiUtils.ts | 15 ++---- 10 files changed, 33 insertions(+), 114 deletions(-) diff --git a/src/providers/ServerConnectionPanelTreeProvider.ts b/src/providers/ServerConnectionPanelTreeProvider.ts index 2ded30729..98369df70 100644 --- a/src/providers/ServerConnectionPanelTreeProvider.ts +++ b/src/providers/ServerConnectionPanelTreeProvider.ts @@ -8,7 +8,6 @@ import { ServerTreeProviderBase } from './ServerTreeProviderBase'; import { getConnectionServerTreeItem, getConnectionTreeRootNodes, - getConnectionWorkerLabel, getPanelConnectionTreeItem, getPanelVariableTreeItem, isServerStateNode, @@ -41,21 +40,10 @@ export class ServerConnectionPanelTreeProvider extends ServerTreeProviderBase { return { create: ( + label: string, serverUrl: URL, isOwned: boolean, tagId?: UniqueID ): IDhcService => { return new DhcService( + label, serverUrl, isOwned, coreClientCache, @@ -99,6 +101,7 @@ export class DhcService extends DisposableBase implements IDhcService { * mechanism for instantiating. */ private constructor( + label: string, serverUrl: URL, isOwned: boolean, coreClientCache: URLMap, @@ -115,6 +118,7 @@ export class DhcService extends DisposableBase implements IDhcService { this.coreClientCache = coreClientCache; this.isOwned = isOwned; + this.label = label; this.groovyDiagnosticsCollection = groovyDiagnosticsCollection; this.diagnosticsCollection = diagnosticsCollection; this.remoteFileSourceService = remoteFileSourceService; @@ -140,6 +144,7 @@ export class DhcService extends DisposableBase implements IDhcService { readonly onDidDisconnect = this._onDidDisconnect.event; public readonly isOwned: boolean; + public readonly label: string; public readonly serverUrl: URL; public readonly tagId?: UniqueID; diff --git a/src/services/DheService.ts b/src/services/DheService.ts index 9cca2fb28..6e8c25029 100644 --- a/src/services/DheService.ts +++ b/src/services/DheService.ts @@ -64,15 +64,21 @@ export class DheService implements IDheService { toaster: IToastService ): IDheServiceFactory => { return { - create: (serverUrl: URL): IDheService => - new DheService( + create: (serverUrl: URL): IDheService => { + const serverConfig = configService + .getEnterpriseServers() + .find(server => server.url.href === serverUrl.href); + + return new DheService( + serverConfig?.label ?? serverUrl.href, serverUrl, configService, dheClientCache, dheJsApiCache, interactiveConsoleQueryFactory, toaster - ), + ); + }, }; }; @@ -81,6 +87,7 @@ export class DheService implements IDheService { * mechanism for instantiating. */ private constructor( + label: string, serverUrl: URL, configService: IConfigService, dheClientCache: URLMap, @@ -88,6 +95,7 @@ export class DheService implements IDheService { interactiveConsoleQueryFactory: IInteractiveConsoleQueryFactory, toaster: IToastService ) { + this.label = label; this.serverUrl = serverUrl; this._config = configService; this._dheClientCache = dheClientCache; @@ -123,6 +131,7 @@ export class DheService implements IDheService { private readonly _onWorkerRemoved = new vscode.EventEmitter(); readonly onWorkerRemoved = this._onWorkerRemoved.event; + readonly label: string; readonly serverUrl: URL; /** diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 10b9d5fec..9e2f59e8d 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -352,6 +352,7 @@ export class ServerManager implements IServerManager { } const connection = this._dhcServiceFactory.create( + workerInfo?.name ?? serverUrl.host, workerUrl, isOwned, workerInfo?.tagId @@ -496,6 +497,7 @@ export class ServerManager implements IServerManager { ): Promise => { const tagId = uniqueId(); const placeholderUrl = this.addWorkerPlaceholderConnection( + dheService.label, dheService.serverUrl, tagId ); @@ -597,11 +599,16 @@ export class ServerManager implements IServerManager { /** * Add a placeholder connection to represent a pending DHE Core+ woker creation. + * @param label The connection label to show in the UI for this pending worker. * @param serverUrl The DHE server URL the pending worker is associated with. * @param tagId The tag ID of the worker. * @returns The placeholder URL. */ - addWorkerPlaceholderConnection = (serverUrl: URL, tagId: UniqueID): URL => { + addWorkerPlaceholderConnection = ( + label: string, + serverUrl: URL, + tagId: UniqueID + ): URL => { // simple way to keep placeholder urls unique by just adding a tagId as the pathname const placeholderUrl = new URL(serverUrl); placeholderUrl.pathname = tagId; @@ -609,6 +616,7 @@ export class ServerManager implements IServerManager { this._workerURLToServerURLMap.set(placeholderUrl, serverUrl); this._connectionMap.set(placeholderUrl, { + label, isConnected: false, isRunningCode: false, serverUrl: placeholderUrl, diff --git a/src/types/commonTypes.d.ts b/src/types/commonTypes.d.ts index 0c79e1cb7..a5b0dd32d 100644 --- a/src/types/commonTypes.d.ts +++ b/src/types/commonTypes.d.ts @@ -170,6 +170,7 @@ export interface WorkerConfig { export interface ConnectionState { readonly isConnected: boolean; readonly isRunningCode?: boolean; + readonly label: string; readonly serverUrl: URL; readonly tagId?: UniqueID; } diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index 7bea8e58e..4fe0405a7 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -119,7 +119,7 @@ export type ICoreClientFactory = ( */ export type IDhcServiceFactory = IFactory< IDhcService, - [serverUrl: URL, isOwned: boolean, tagId?: UniqueID] + [label: string, serverUrl: URL, isOwned: boolean, tagId?: UniqueID] >; export type IDheClientFactory = ( serverUrl: URL diff --git a/src/util/treeViewUtils.spec.ts b/src/util/treeViewUtils.spec.ts index 6552fa8b0..b294c0fc9 100644 --- a/src/util/treeViewUtils.spec.ts +++ b/src/util/treeViewUtils.spec.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi } from 'vitest'; import { bitValues, boolValues, matrix } from '../testUtils'; import { getConnectionServerTreeItem, - getConnectionWorkerLabel, getPanelConnectionTreeItem, getPanelVariableTreeItem, getServerContextValue, @@ -15,12 +14,10 @@ import { groupServers, } from './treeViewUtils'; import type { - ConnectionState, ConsoleType, IDhcService, Psk, ServerState, - UniqueID, VariableDefintion, VariableType, } from '../types'; @@ -103,51 +100,6 @@ describe('getConnectionServerTreeItem', () => { }); }); -describe('getConnectionWorkerLabel', () => { - const connection = { - isConnected: true, - serverUrl: new URL('http://localhost:10000'), - tagId: 'mock.tagId' as UniqueID, - } as ConnectionState; - - const dhcServer: ServerState = { - type: 'DHC', - url: new URL('http://localhost:10000'), - label: 'My DHC Server', - isConnected: true, - isRunning: true, - connectionCount: 1, - }; - - it('should prefer the persistent-query name when present (DHE worker)', () => { - expect( - getConnectionWorkerLabel(dhcServer, connection, 'Some Worker PQ') - ).toBe('Some Worker PQ'); - }); - - it('should fall back to the server label when there is no pq name (DHC)', () => { - expect(getConnectionWorkerLabel(dhcServer, connection, undefined)).toBe( - 'My DHC Server' - ); - }); - - it('should fall back to the server url host when the server has no label', () => { - expect( - getConnectionWorkerLabel( - { ...dhcServer, label: undefined }, - connection, - undefined - ) - ).toBe('localhost:10000'); - }); - - it('should fall back to the connection serverUrl host when no parent server resolves', () => { - expect(getConnectionWorkerLabel(undefined, connection, undefined)).toBe( - 'localhost:10000' - ); - }); -}); - describe('getPanelVariableTreeItem', () => { const url = new URL('http://localhost:10000'); diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index 5ea4e4083..13a6e5114 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -142,28 +142,6 @@ export function getConnectionServerLabel(server: ServerState): string { return server.label ?? server.url.host; } -/** - * Get the label shown for a worker (connection) node in the connection / panel - * tree views. DHE workers use their persistent-query name; DHC connections, - * which have none, fall back to their server's label so the single child - * mirrors its parent server node. - * @param parentServer The connection's parent server, if resolved. - * @param connection The connection. - * @param pqName The persistent-query name (DHE workers only). - */ -export function getConnectionWorkerLabel( - parentServer: ServerState | undefined, - connection: ConnectionState, - pqName: string | undefined -): string { - const serverLabel = - parentServer == null - ? connection.serverUrl.host - : getConnectionServerLabel(parentServer); - - return pqName ?? serverLabel ?? connection.tagId ?? ''; -} - /** * Compute the root nodes for the connection / panel tree views. Every * connection is grouped under its parent server node (DHC and DHE alike), so a diff --git a/src/util/uiUtils.ts b/src/util/uiUtils.ts index 5645fc586..9cd19c404 100644 --- a/src/util/uiUtils.ts +++ b/src/util/uiUtils.ts @@ -33,7 +33,7 @@ import type { MultiAuthConfig, } from '../types'; import { getFilePathDateToken, sortByStringProp } from './dataUtils'; -import { getConnectionWorkerLabel, getConsoleTypeIconId } from './treeViewUtils'; +import { getConsoleTypeIconId } from './treeViewUtils'; import { Logger } from './Logger'; const logger = new Logger('uiUtils'); @@ -93,21 +93,12 @@ export async function createConnectionQuickPickOptions< editorActiveConnectionUrl?.toString() === dhService.serverUrl.toString(); - const parentServer = - serverManager.getServerForConnection(dhService); - const workerInfo = await serverManager.getWorkerInfo( - dhService.serverUrl - ); - + const parentServer = serverManager.getServerForConnection(dhService); const host = parentServer?.url.host ?? dhService.serverUrl.host; return { type: 'connection' as const, - label: getConnectionWorkerLabel( - parentServer, - dhService, - workerInfo?.name - ), + label: dhService.label, iconPath: new vscode.ThemeIcon( getConsoleTypeIconId(editorLanguageId as ConsoleType) ), From 2e3b69faa8711902b956288979ad9b22c7b52013 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 18 Jun 2026 13:40:55 -0500 Subject: [PATCH 15/24] tweak labels for server + workers (#DH-22593) --- src/services/ServerManager.ts | 27 ++++++++++++++++++++++----- src/util/uiUtils.ts | 18 ++++++++++++++---- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 9e2f59e8d..6812d9215 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -289,7 +289,7 @@ export class ServerManager implements IServerManager { try { if (serverState.type === 'DHC') { // DHC attach to Core worker - firstConnection = await this._attachToWorker(serverUrl, true); + firstConnection = await this._attachToWorker('Core', serverUrl, true); this._resolvePendingServerConnection(serverUrl); } else { @@ -324,11 +324,13 @@ export class ServerManager implements IServerManager { * `_workerURLToServerURLMap` for auth lookup and `_attachedWorkerSerials` * for idempotency/teardown. Used by both the create path and the attach path. * Does NOT touch placeholder connections — that is the create path's concern. + * @param label The connection label to show in the UI for this worker. * @param serverUrl The DHE server this worker belongs to. * @param workerInfo Worker info built from the query. * @returns The new connection state, or null on failure. */ private _attachToWorker = async ( + label: string, serverUrl: URL, isOwned: boolean, workerInfo?: WorkerInfo @@ -352,7 +354,7 @@ export class ServerManager implements IServerManager { } const connection = this._dhcServiceFactory.create( - workerInfo?.name ?? serverUrl.host, + label, workerUrl, isOwned, workerInfo?.tagId @@ -479,7 +481,12 @@ export class ServerManager implements IServerManager { const connections = ( await Promise.all( workerInfos.map(([isOwned, workerInfo]) => - this._attachToWorker(dheService.serverUrl, isOwned, workerInfo) + this._attachToWorker( + workerInfo.name, + dheService.serverUrl, + isOwned, + workerInfo + ) ) ) ).filter(c => c != null); @@ -566,7 +573,12 @@ export class ServerManager implements IServerManager { return null; } - return this._attachToWorker(dheServerUrl, true, workerInfo); + return this._attachToWorker( + workerInfo.name, + dheServerUrl, + true, + workerInfo + ); }; /** @@ -582,7 +594,12 @@ export class ServerManager implements IServerManager { return; } const workerInfo = dheService.registerWorkerInfo(queryInfo); - await this._attachToWorker(dheServerUrl, false, workerInfo); + await this._attachToWorker( + workerInfo.name, + dheServerUrl, + false, + workerInfo + ); }; /** diff --git a/src/util/uiUtils.ts b/src/util/uiUtils.ts index 9cd19c404..f37e86b32 100644 --- a/src/util/uiUtils.ts +++ b/src/util/uiUtils.ts @@ -80,8 +80,8 @@ export async function createConnectionQuickPickOptions< const serverOptions: ConnectionPickItem<'server', ServerState>[] = servers.map(data => ({ type: 'server', - label: data.url.toString(), - description: data.label ?? (data.isManaged ? 'pip' : undefined), + label: data.label ?? data.url.toString(), + description: data.isManaged ? 'pip' : undefined, iconPath: new vscode.ThemeIcon(ICON_ID.server), data, })); @@ -94,7 +94,17 @@ export async function createConnectionQuickPickOptions< dhService.serverUrl.toString(); const parentServer = serverManager.getServerForConnection(dhService); - const host = parentServer?.url.host ?? dhService.serverUrl.host; + assertDefined(parentServer, 'parentServer'); + + const descriptionTokens = [ + parentServer.type === 'DHC' + ? (parentServer.label ?? parentServer.url.host) + : `${parentServer.label ?? parentServer.url.hostname}:${dhService.serverUrl.port}`, + ]; + + if (isActiveConnection) { + descriptionTokens.push('(current)'); + } return { type: 'connection' as const, @@ -102,7 +112,7 @@ export async function createConnectionQuickPickOptions< iconPath: new vscode.ThemeIcon( getConsoleTypeIconId(editorLanguageId as ConsoleType) ), - description: isActiveConnection ? `${host} (current)` : host, + description: descriptionTokens.join(' '), data: dhService, }; }) From af6d921405466d92143a22d22b82aeea5c84d540 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 18 Jun 2026 13:51:01 -0500 Subject: [PATCH 16/24] simplified option description (#DH-22593) --- src/util/uiUtils.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/util/uiUtils.ts b/src/util/uiUtils.ts index f37e86b32..50ac53373 100644 --- a/src/util/uiUtils.ts +++ b/src/util/uiUtils.ts @@ -96,11 +96,7 @@ export async function createConnectionQuickPickOptions< const parentServer = serverManager.getServerForConnection(dhService); assertDefined(parentServer, 'parentServer'); - const descriptionTokens = [ - parentServer.type === 'DHC' - ? (parentServer.label ?? parentServer.url.host) - : `${parentServer.label ?? parentServer.url.hostname}:${dhService.serverUrl.port}`, - ]; + const descriptionTokens = [parentServer.label ?? parentServer.url.host]; if (isActiveConnection) { descriptionTokens.push('(current)'); From 7d55189544c30f9c052b6f2b42e097da894a2ce6 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 18 Jun 2026 14:08:57 -0500 Subject: [PATCH 17/24] Removed create worker action from community server nodes (#DH-22593) --- src/util/treeViewUtils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index 13a6e5114..7294736e1 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -196,7 +196,10 @@ export function getConnectionServerTreeItem( // nodes, before the language (Python/Groovy) icons took their place. iconPath: new vscode.ThemeIcon(ICON_ID.connected), collapsibleState: vscode.TreeItemCollapsibleState.Expanded, - contextValue: CONNECTION_TREE_ITEM_CONTEXT.isDHEServerConnectionParent, + contextValue: + server.type === 'DHE' + ? CONNECTION_TREE_ITEM_CONTEXT.isDHEServerConnectionParent + : undefined, }; } From f1f9c7443cc8bc7885b79775a1900e936f2a0d10 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 18 Jun 2026 15:51:42 -0500 Subject: [PATCH 18/24] Fixed tests (#DH-22593) --- src/util/__snapshots__/uiUtils.spec.ts.snap | 12 ++++++++---- src/util/uiUtils.spec.ts | 12 ++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/util/__snapshots__/uiUtils.spec.ts.snap b/src/util/__snapshots__/uiUtils.spec.ts.snap index 9009195c7..7c067cc81 100644 --- a/src/util/__snapshots__/uiUtils.spec.ts.snap +++ b/src/util/__snapshots__/uiUtils.spec.ts.snap @@ -31,6 +31,7 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed { "data": { "isConnected": true, + "label": "ServerA Connection", "serverUrl": "http://localhost:10000/", }, "description": "localhost:10000 (current)", @@ -38,12 +39,13 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed "color": undefined, "id": "dh-python", }, - "label": "localhost:10000", + "label": "ServerA Connection", "type": "connection", }, { "data": { "isConnected": true, + "label": "ServerC Connection", "serverUrl": "http://localhost:10002/", }, "description": "localhost:10002", @@ -51,7 +53,7 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed "color": undefined, "id": "dh-python", }, - "label": "localhost:10002", + "label": "ServerC Connection", "type": "connection", }, { @@ -102,6 +104,7 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed { "data": { "isConnected": true, + "label": "ServerA Connection", "serverUrl": "http://localhost:10000/", }, "description": "localhost:10000", @@ -109,12 +112,13 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed "color": undefined, "id": "dh-python", }, - "label": "localhost:10000", + "label": "ServerA Connection", "type": "connection", }, { "data": { "isConnected": true, + "label": "ServerC Connection", "serverUrl": "http://localhost:10002/", }, "description": "localhost:10002", @@ -122,7 +126,7 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed "color": undefined, "id": "dh-python", }, - "label": "localhost:10002", + "label": "ServerC Connection", "type": "connection", }, { diff --git a/src/util/uiUtils.spec.ts b/src/util/uiUtils.spec.ts index 575f53f5b..24e61b8a4 100644 --- a/src/util/uiUtils.spec.ts +++ b/src/util/uiUtils.spec.ts @@ -74,8 +74,16 @@ describe('createConnectionQuickPickOptions', () => { }, ]; const connections: ConnectionState[] = [ - { serverUrl: serverUrlA, isConnected: true }, - { serverUrl: serverUrlC, isConnected: true }, + { + label: 'ServerA Connection', + serverUrl: serverUrlA, + isConnected: true, + }, + { + label: 'ServerC Connection', + serverUrl: serverUrlC, + isConnected: true, + }, ]; const serverManager = { From cc47a153aff07604142ec33a6f3a89c2a842a0d3 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 18 Jun 2026 16:12:26 -0500 Subject: [PATCH 19/24] Fixed tests (#DH-22593) --- src/services/ServerManager.spec.ts | 55 +++++++++++++----------------- 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/src/services/ServerManager.spec.ts b/src/services/ServerManager.spec.ts index 46cc573b8..f4e10009e 100644 --- a/src/services/ServerManager.spec.ts +++ b/src/services/ServerManager.spec.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ServerManager } from './ServerManager'; -import { URLMap, withResolvers } from '../util'; +import { URLMap, withResolvers, type PromiseWithResolvers } from '../util'; import type { ConnectionState, IAsyncCacheService, @@ -36,17 +36,14 @@ vi.mock('../dh/dhe', () => ({ type TestServerManager = PublicOf & { _serverMap: URLMap; _connectionMap: URLMap; - _pendingConnectionMap: URLMap>; + _pendingConnectionMap: URLMap>; + _pendingServerConnections: URLMap>; _dhcServiceFactory: { create: ReturnType }; _dheServiceCache: { has: ReturnType; get: ReturnType; }; - _setPendingConnection: ( - serverUrl: URL, - connectionPromise: Promise - ) => void; - _clearPendingConnection: (serverUrl: URL) => void; + _resolvePendingServerConnection: (serverUrl: URL) => void; }; /** Build a `ServerManager` with minimal mocked dependencies. */ @@ -161,7 +158,7 @@ describe('ServerManager.connectToServer', () => { // The second call dedupes against the in-flight connection rather than // creating a new one. expect(manager._dhcServiceFactory.create).toHaveBeenCalledTimes(1); - expect(manager.isConnecting(serverUrl)).toBe(true); + expect(manager.isServerConnecting(serverUrl)).toBe(true); resolveClient({}); @@ -184,11 +181,11 @@ describe('ServerManager.connectToServer', () => { // in-flight connection rather than starting a second one (multiple workers // are created later, off the single client connection). expect(manager._dheServiceCache.get).toHaveBeenCalledTimes(1); - expect(manager.isConnecting(serverUrl)).toBe(true); + expect(manager.isServerConnecting(serverUrl)).toBe(true); }); }); -describe('ServerManager.isConnecting', () => { +describe('ServerManager.isServerConnecting', () => { let manager: TestServerManager; beforeEach(() => { @@ -196,39 +193,35 @@ describe('ServerManager.isConnecting', () => { manager = createServerManager(); }); - it('toggles isConnecting and fires onDidUpdate on set/clear', () => { + it('reflects a pending server connection and fires onDidUpdate when it resolves', () => { const onDidUpdate = vi.fn(); manager.onDidUpdate(onDidUpdate); - expect(manager.isConnecting(serverUrl)).toBe(false); + expect(manager.isServerConnecting(serverUrl)).toBe(false); - manager._setPendingConnection(serverUrl, Promise.resolve(null)); - expect(manager.isConnecting(serverUrl)).toBe(true); - expect(onDidUpdate).toHaveBeenCalledTimes(1); + manager._pendingServerConnections.set(serverUrl, withResolvers()); + expect(manager.isServerConnecting(serverUrl)).toBe(true); - manager._clearPendingConnection(serverUrl); - expect(manager.isConnecting(serverUrl)).toBe(false); - expect(onDidUpdate).toHaveBeenCalledTimes(2); + manager._resolvePendingServerConnection(serverUrl); + expect(manager.isServerConnecting(serverUrl)).toBe(false); + expect(onDidUpdate).toHaveBeenCalledTimes(1); }); - it('clears idempotently and does not fire when there is no pending entry', () => { + it('resolves idempotently and does not fire when there is no pending entry', () => { const onDidUpdate = vi.fn(); manager.onDidUpdate(onDidUpdate); - // Clearing when not connecting is a no-op (no fire). - manager._clearPendingConnection(serverUrl); + // Resolving when not connecting is a no-op (no fire). + manager._resolvePendingServerConnection(serverUrl); expect(onDidUpdate).not.toHaveBeenCalled(); - manager._setPendingConnection(serverUrl, Promise.resolve(null)); + manager._pendingServerConnections.set(serverUrl, withResolvers()); + manager._resolvePendingServerConnection(serverUrl); expect(onDidUpdate).toHaveBeenCalledTimes(1); - manager._clearPendingConnection(serverUrl); - expect(onDidUpdate).toHaveBeenCalledTimes(2); - - // Clearing again is a no-op (no extra fire) — covers DHE's early clear - // followed by the connect-settle clear. - manager._clearPendingConnection(serverUrl); - expect(onDidUpdate).toHaveBeenCalledTimes(2); + // Resolving again is a no-op (no extra fire). + manager._resolvePendingServerConnection(serverUrl); + expect(onDidUpdate).toHaveBeenCalledTimes(1); }); it('clears the pending entry when a connect settles, so a retry is not blocked', async () => { @@ -248,12 +241,12 @@ describe('ServerManager.isConnecting', () => { .mockReturnValueOnce(okConnection); expect(await manager.connectToServer(serverUrl)).toBeNull(); - expect(manager.isConnecting(serverUrl)).toBe(false); + expect(manager.isServerConnecting(serverUrl)).toBe(false); // The retry is not blocked by a stale pending entry — it starts a new // connection rather than deduping against the failed one. expect(await manager.connectToServer(serverUrl)).toBe(okConnection); expect(manager._dhcServiceFactory.create).toHaveBeenCalledTimes(2); - expect(manager.isConnecting(serverUrl)).toBe(false); + expect(manager.isServerConnecting(serverUrl)).toBe(false); }); }); From a12fc365512db7d8119772420502c6452701e87d Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 18 Jun 2026 16:12:59 -0500 Subject: [PATCH 20/24] Fixed type error (#DH-22593) --- src/services/ServerManager.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/ServerManager.spec.ts b/src/services/ServerManager.spec.ts index f4e10009e..3205f41d0 100644 --- a/src/services/ServerManager.spec.ts +++ b/src/services/ServerManager.spec.ts @@ -77,7 +77,7 @@ function createServerManager(): TestServerManager { } function mockConnectionState(url: URL): ConnectionState { - return { isConnected: true, serverUrl: url }; + return { label: 'Mock connection state', isConnected: true, serverUrl: url }; } function mockServerState({ From ef5111e53ad50a5fe4e05d788c6f5b326e48ab8c Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 18 Jun 2026 17:01:02 -0500 Subject: [PATCH 21/24] VS Code settings (#DH-22593) --- .vscode/settings.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index cebd853da..6b687d9c3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,10 @@ "out": true // set this to false to include "out" folder in search results }, // Turn off tsc task auto detection since we have the necessary tasks as npm scripts - "typescript.tsc.autoDetect": "off", - "typescript.tsdk": "node_modules/typescript/lib" + "js/ts.tsc.autoDetect": "off", + // Unit tests are excluded from the primary tsconfig.json used by VS Code + // editor. Setting the implicit project target to match the tsconfig.json so + // that the editor shows proper language features for the test files. + "js/ts.implicitProjectConfig.target": "ES2023", + "js/ts.tsdk.path": "node_modules/typescript/lib" } From 92befcd332ffc1d0767b854ba2d6d6bd6aa883e9 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 22 Jun 2026 11:57:29 -0500 Subject: [PATCH 22/24] Improved error handling + re-use util (#DH-22593) --- src/dh/dhe.ts | 2 +- src/services/DheService.ts | 38 +++++++++++++++++++++-------------- src/services/ServerManager.ts | 34 +++++++++++++++++++++---------- src/util/treeViewUtils.ts | 7 ++----- 4 files changed, 49 insertions(+), 32 deletions(-) diff --git a/src/dh/dhe.ts b/src/dh/dhe.ts index 8ee20f487..3f683e929 100644 --- a/src/dh/dhe.ts +++ b/src/dh/dhe.ts @@ -220,7 +220,7 @@ export async function loginClientWrapper( * @param consoleType The type of console to create. * @returns A promise that resolves to the serial of the created query. Note * that this will resolve before the query is actually ready to use. Use - * `getWorkerInfoFromQuery` to get the worker info when the query is ready. + * `getWorkerInfoFromQuerySerial` to get the worker info when the query is ready. */ export async function createInteractiveConsoleQuery( tagId: UniqueID, diff --git a/src/services/DheService.ts b/src/services/DheService.ts index 6e8c25029..300e862d7 100644 --- a/src/services/DheService.ts +++ b/src/services/DheService.ts @@ -265,24 +265,32 @@ export class DheService implements IDheService { this._onWorkerRemoved.fire(queryInfo.serial as QuerySerial); }; - const removeAdded = dheClient.client.addEventListener( - dhe.Client.EVENT_CONFIG_ADDED, - onConfigAddedOrUpdated + const removeConfigListeners: (() => void)[] = []; + this._removeConfigListeners = (): void => { + for (const remove of removeConfigListeners) { + remove(); + } + removeConfigListeners.length = 0; + }; + + removeConfigListeners.push( + dheClient.client.addEventListener( + dhe.Client.EVENT_CONFIG_ADDED, + onConfigAddedOrUpdated + ) ); - const removeUpdated = dheClient.client.addEventListener( - dhe.Client.EVENT_CONFIG_UPDATED, - onConfigAddedOrUpdated + removeConfigListeners.push( + dheClient.client.addEventListener( + dhe.Client.EVENT_CONFIG_UPDATED, + onConfigAddedOrUpdated + ) ); - const removeRemoved = dheClient.client.addEventListener( - dhe.Client.EVENT_CONFIG_REMOVED, - onConfigRemoved + removeConfigListeners.push( + dheClient.client.addEventListener( + dhe.Client.EVENT_CONFIG_REMOVED, + onConfigRemoved + ) ); - - this._removeConfigListeners = (): void => { - removeAdded(); - removeUpdated(); - removeRemoved(); - }; }; /** diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 6812d9215..093ea2f36 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -471,10 +471,18 @@ export class ServerManager implements IServerManager { } // register attachable workers else { - workerInfos = attachableWorkers.map(queryInfo => [ - false, - dheService.registerWorkerInfo(queryInfo), - ]); + workerInfos = []; + for (const queryInfo of attachableWorkers) { + try { + workerInfos.push([false, dheService.registerWorkerInfo(queryInfo)]); + } catch (err) { + logger.error( + 'Failed to register attachable worker; skipping:', + queryInfo.serial, + err + ); + } + } } // Connect to workers in parallel @@ -593,13 +601,17 @@ export class ServerManager implements IServerManager { if (this._attachedWorkerSerials.has(queryInfo.serial as QuerySerial)) { return; } - const workerInfo = dheService.registerWorkerInfo(queryInfo); - await this._attachToWorker( - workerInfo.name, - dheServerUrl, - false, - workerInfo - ); + try { + const workerInfo = dheService.registerWorkerInfo(queryInfo); + await this._attachToWorker( + workerInfo.name, + dheServerUrl, + false, + workerInfo + ); + } catch (err) { + logger.error('Failed to attach worker:', queryInfo.serial, err); + } }; /** diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index 7294736e1..36dffc4b8 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -191,7 +191,7 @@ export function getConnectionServerTreeItem( server: ServerState ): vscode.TreeItem { return { - label: server.label ?? server.url.host, + label: getConnectionServerLabel(server), // The "computer" icon (`vm-connect`) previously used for worker connection // nodes, before the language (Python/Groovy) icons took their place. iconPath: new vscode.ThemeIcon(ICON_ID.connected), @@ -377,16 +377,13 @@ export function getServerTreeItem( const description = getServerDescription(connectionCount, isManaged); - const urlStr = server.url.toString(); - const canConnect = contextValue === SERVER_TREE_ITEM_CONTEXT.isManagedServerDisconnected || contextValue === SERVER_TREE_ITEM_CONTEXT.isServerRunningDisconnected || contextValue === SERVER_TREE_ITEM_CONTEXT.isDHEServerRunningConnected || contextValue === SERVER_TREE_ITEM_CONTEXT.isDHEServerRunningDisconnected; - const url = new URL(urlStr); - const label = server.label ?? url.host; + const label = getConnectionServerLabel(server); return { label, From b285918dbd532b416d2ad4dadb0e4bcb2efddd7a Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 22 Jun 2026 12:05:56 -0500 Subject: [PATCH 23/24] Added label to mcp connection data (#DH-22593) --- src/mcp/tools/listConnections.spec.ts | 2 ++ src/mcp/tools/listConnections.ts | 4 +++- src/mcp/utils/serverUtils.spec.ts | 3 +++ src/mcp/utils/serverUtils.ts | 3 +++ 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/mcp/tools/listConnections.spec.ts b/src/mcp/tools/listConnections.spec.ts index 69b4fd704..5a8bcbab1 100644 --- a/src/mcp/tools/listConnections.spec.ts +++ b/src/mcp/tools/listConnections.spec.ts @@ -18,6 +18,7 @@ vi.mock('vscode'); const MOCK_CONNECTION_1: ConnectionState = { serverUrl: MOCK_DHC_URL, + label: 'Connection 1', isConnected: true, isRunningCode: false, tagId: 'conn1' as UniqueID, @@ -25,6 +26,7 @@ const MOCK_CONNECTION_1: ConnectionState = { const MOCK_CONNECTION_2: ConnectionState = { serverUrl: new URL('http://localhost:10001'), + label: 'Connection 2', isConnected: true, isRunningCode: true, tagId: 'conn2' as UniqueID, diff --git a/src/mcp/tools/listConnections.ts b/src/mcp/tools/listConnections.ts index 734d4890b..2c471359d 100644 --- a/src/mcp/tools/listConnections.ts +++ b/src/mcp/tools/listConnections.ts @@ -26,6 +26,7 @@ const spec = { .array( z.object({ serverUrl: z.string(), + label: z.string(), isConnected: z.boolean(), isRunningCode: z.boolean().optional(), querySerial: z.string().optional(), @@ -66,7 +67,7 @@ export function createListConnectionsTool({ const connections = await Promise.all( rawConnections.map( - async ({ serverUrl, isConnected, isRunningCode, tagId }) => { + async ({ serverUrl, label, isConnected, isRunningCode, tagId }) => { // Get worker info to retrieve querySerial for DHE connections const workerInfo = await serverManager.getWorkerInfo( serverUrl as WorkerURL @@ -74,6 +75,7 @@ export function createListConnectionsTool({ return { serverUrl: serverUrl.toString(), + label, isConnected, isRunningCode, tagId, diff --git a/src/mcp/utils/serverUtils.spec.ts b/src/mcp/utils/serverUtils.spec.ts index b63df4b8b..64f5a20c5 100644 --- a/src/mcp/utils/serverUtils.spec.ts +++ b/src/mcp/utils/serverUtils.spec.ts @@ -42,11 +42,13 @@ describe('serverUtils', () => { isRunningCode, serverUrl, tagId, + label: 'mock connection', }); expect(resultWithoutTag, 'without tag').toEqual({ isConnected, isRunningCode, + label: 'mock connection', serverUrl: serverUrl.toString(), tagId, }); @@ -389,6 +391,7 @@ describe('serverUtils', () => { const MOCK_CONNECTION: ConnectionState = { isConnected: true, isRunningCode: true, + label: 'mock connection', serverUrl, tagId, } as ConnectionState; diff --git a/src/mcp/utils/serverUtils.ts b/src/mcp/utils/serverUtils.ts index 26e5561ac..8e060d876 100644 --- a/src/mcp/utils/serverUtils.ts +++ b/src/mcp/utils/serverUtils.ts @@ -14,6 +14,7 @@ import { createConnectionNotFoundHint } from './runCodeUtils'; export const connectionResultSchema = z.object({ isConnected: z.boolean(), isRunningCode: z.boolean().optional(), + label: z.string(), serverUrl: z.string(), tagId: z.string().optional(), }); @@ -57,12 +58,14 @@ export type GetFirstConnectionOrCreateResult = export function connectionToResult({ isConnected, isRunningCode, + label, serverUrl, tagId, }: ConnectionState): ConnectionResult { return { isConnected, isRunningCode, + label, serverUrl: serverUrl.toString(), tagId, }; From dc8b70f070d15124c4e08959d61640458ccc6e7b Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 22 Jun 2026 13:50:41 -0500 Subject: [PATCH 24/24] Rename "Connections" -> "Workers" in e2e tests (#DH-22593) --- e2e-testing/src/specs/statusBar.spec.ts | 2 +- e2e-testing/src/util/constants.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e-testing/src/specs/statusBar.spec.ts b/e2e-testing/src/specs/statusBar.spec.ts index 8359892b1..89fafa794 100644 --- a/e2e-testing/src/specs/statusBar.spec.ts +++ b/e2e-testing/src/specs/statusBar.spec.ts @@ -90,7 +90,7 @@ describe('Status Bar Tests', () => { await step(4, 'Verify connection node', async stepLabel => { const simpleTickingEditor = await getSidebarViewItem( - VIEW_NAME.connections, + VIEW_NAME.workers, SIMPLE_TICKING3_PY.name ); diff --git a/e2e-testing/src/util/constants.ts b/e2e-testing/src/util/constants.ts index 53af58fd4..1c06f3960 100644 --- a/e2e-testing/src/util/constants.ts +++ b/e2e-testing/src/util/constants.ts @@ -15,6 +15,6 @@ export const RETRY_SWITCH_IFRAME_ERRORS: ReadonlySet = new Set([ export const VIEW_NAME = { servers: 'Servers', - connections: 'Connections', + workers: 'Workers', panels: 'Panels', } as const;