diff --git a/src/api/index.ts b/src/api/index.ts index 53a80938..2687537e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -8,9 +8,8 @@ import BasicAuthorization, { extensionContext, workspaceState, panel, - checkConnection, + ensureConnection, schemas, - checkingConnection, inactiveServerIds, } from "../extension"; import { currentWorkspaceFolder, outputChannel, outputConsole } from "../utils"; @@ -121,10 +120,8 @@ export class AtelierAPI { return filename; } - public constructor(wsOrFile?: string | vscode.Uri, retryAfter401 = true) { - if (retryAfter401) { - this.wsOrFile = wsOrFile; - } + public constructor(wsOrFile?: string | vscode.Uri) { + this.wsOrFile = wsOrFile; let workspaceFolderName = ""; let namespace = ""; if (wsOrFile) { @@ -328,12 +325,19 @@ export class AtelierAPI { private async request( minVersion: number, - method: string, + method: "GET" | "HEAD" | "PUT" | "POST" | "DELETE", path?: string, body?: any, params?: any, headers?: any, - options?: any + options?: { + /** Abort the request if it hasn't completed within this many milliseconds. */ + timeout?: number; + /** Suppress writing this request/response to the ObjectScript output channel, even when `objectscript.outputRESTTraffic` is on. */ + noOutput?: boolean; + /** On a 401 response, suppress the automatic single retry with fresh credentials. */ + _retriedAfter401?: boolean; + } ): Promise { const { active, apiVersion, host, port, https } = this.config; if (!active || !port || !host) { @@ -365,7 +369,6 @@ export class AtelierAPI { }); return result.length ? "?" + result.join("&") : ""; }; - method = method.toUpperCase(); if (body && !headers["Content-Type"]) { headers["Content-Type"] = "application/json"; } @@ -465,7 +468,7 @@ export class AtelierAPI { if (response.status === 401) { authRequestMap.delete(mapKey); cookiesMap.delete(mapKey); - if (this.wsOrFile && !checkingConnection) { + if (this.wsOrFile) { if (!options?._retriedAfter401) { return this.request(minVersion, method, originalPath, body, params, headers, { ...options, @@ -473,7 +476,7 @@ export class AtelierAPI { }); } setTimeout(() => { - checkConnection( + ensureConnection( this.config.auth.resolved(), typeof this.wsOrFile === "object" ? this.wsOrFile : undefined, true @@ -596,9 +599,7 @@ export class AtelierAPI { panel.tooltip = "Disconnected"; workspaceState.update(this.configName.toLowerCase() + ":host", undefined); workspaceState.update(this.configName.toLowerCase() + ":port", undefined); - if (!checkingConnection) { - setTimeout(() => checkConnection(false, undefined, true), 30000); - } + setTimeout(() => ensureConnection(false, undefined, true), 30000); } throw error; } diff --git a/src/commands/serverActions.ts b/src/commands/serverActions.ts index 23176c50..aeec30b1 100644 --- a/src/commands/serverActions.ts +++ b/src/commands/serverActions.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import { config, workspaceState, - checkConnection, + ensureConnection, explorerProvider, filesystemSchemas, FILESYSTEM_SCHEMA, @@ -72,7 +72,7 @@ export async function serverActions(): Promise { return connConfig.update("conn", { ...targetConfig, active: !active }, target); } case "refreshConnection": { - await checkConnection(true, undefined, true); + await ensureConnection(true, undefined, true); break; } case "switchNamespace": { diff --git a/src/extension.ts b/src/extension.ts index f3496d8b..fc4ad561 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -215,8 +215,6 @@ export function config(setting?: string, workspaceFolderName?: string): any { let reporter: TelemetryReporter | undefined; -export let checkingConnection = false; - export let serverManagerApi: serverManager.ServerManagerAPI; type ConnSpec = serverManager.IServerSpec & { @@ -375,18 +373,29 @@ export function getResolvedConnectionSpec( /** The `api.serverId`s of all servers that are known to be inactive */ export const inactiveServerIds: Set = new Set(); -export async function checkConnection( +/** `configName`s for which an `ensureConnection` call is currently in progress */ +const ensuringConnection: Set = new Set(); + +/** + * Verify `uri`'s connection works, repairing it if not (may show a modal credential prompt). + * Reflects the outcome in the status bar, `objectscript.conn.active`, and `workspaceState`. + * No-ops if a call is already in progress. + * @param clearState Discard cached connection details first, forcing fresh resolution. + * @param triggerRefreshes Refresh the explorer/projects views once settled. + * @param withTimeout Time out the check so it can't hang extension activation. + */ +export async function ensureConnection( clearState = false, uri?: vscode.Uri, triggerRefreshes?: boolean, - inActivate = false + withTimeout = false ): Promise { - // Do nothing if already checking the connection - if (checkingConnection) { + const { apiTarget, configName } = connectionTarget(uri); + // Do nothing if already checking this connection + if (ensuringConnection.has(configName)) { return; } - const { apiTarget, configName } = connectionTarget(uri); const wsKey = configName.toLowerCase(); if (clearState) { // clean-up cached values @@ -399,7 +408,7 @@ export async function checkConnection( await workspaceState.update(wsKey + ":docker", undefined); _onDidChangeConnection.fire(); } - let api = new AtelierAPI(apiTarget, false); + let api = new AtelierAPI(apiTarget); const { active, host = "", port = 0, superserverPort = 0, ns = "", auth } = api.config; vscode.commands.executeCommand("setContext", "vscode-objectscript.connectActive", active); if (!panel.text) { @@ -463,7 +472,7 @@ export async function checkConnection( if (api.externalServer) { inactiveServerIds.delete(api.serverId); } - api = new AtelierAPI(apiTarget, false); + api = new AtelierAPI(apiTarget); if (!api.config.host || !api.config.port || !api.config.ns) { const message = "'host', 'port' and 'ns' must be specified."; @@ -474,7 +483,7 @@ export async function checkConnection( if (!api.externalServer) await setConnectionState(configName, false); return; } - checkingConnection = true; + ensuringConnection.add(configName); const username = auth.username || "UnknownUser"; const identity = username.startsWith("*") ? `using ${username.slice(1, -1)}` : `as user \`${username}\``; @@ -496,7 +505,7 @@ export async function checkConnection( // Do the check // Only time out requests when called from activate() // Timeout is needed in that case to prevent extension activation from hanging - const serverInfoTimeout = inActivate ? 5000 : undefined; + const serverInfoTimeout = withTimeout ? 5000 : undefined; return api .serverInfo(true, serverInfoTimeout) .then(gotServerInfo) @@ -524,7 +533,7 @@ export async function checkConnection( if (newSpec) { // We were able to resolve credentials, so try again await workspaceState.update(wsKey + ":password", newSpec.auth?.accessToken); - api = new AtelierAPI(apiTarget, false); + api = new AtelierAPI(apiTarget); await api .serverInfo(true, serverInfoTimeout) .then(async (info) => { @@ -537,9 +546,6 @@ export async function checkConnection( if (error?.statusCode != 401) errorMessage = undefined; await workspaceState.update(wsKey + ":password", undefined); success = false; - }) - .finally(() => { - checkingConnection = false; }); } } else { @@ -572,9 +578,6 @@ export async function checkConnection( await workspaceState.update(wsKey + ":password", undefined); return false; }) - .finally(() => { - checkingConnection = false; - }) ); } else { inactiveServerIds.add(api.serverId); @@ -600,7 +603,7 @@ export async function checkConnection( if (!api.externalServer) await setConnectionState(configName, false); }) .finally(() => { - checkingConnection = false; + ensuringConnection.delete(configName); if (triggerRefreshes) { setTimeout(() => { explorerProvider.refresh(); @@ -948,7 +951,7 @@ export async function activate(context: vscode.ExtensionContext): Promise