diff --git a/.github/workflows/review-desktop-ci.yml b/.github/workflows/review-desktop-ci.yml index 9945a973e..1f269ee82 100644 --- a/.github/workflows/review-desktop-ci.yml +++ b/.github/workflows/review-desktop-ci.yml @@ -190,10 +190,13 @@ jobs: pnpm -r --filter '!@dev.fast/review-desktop' --filter '!./diffr/**' --workspace-concurrency=1 --if-present typecheck # Typecheck prepared the shared tutorial assets before concurrent pretests. + # Desktop's tests build a remote tarball, which starves concurrent browser + # tests of CPU until Chromium drops their module fetches; run them last. - name: Test run: | node --test "scripts/**/*.test.mjs" - pnpm -r --filter '!./diffr/**' --no-sort --workspace-concurrency=3 --if-present test + pnpm -r --filter '!./diffr/**' --filter '!@dev.fast/review-desktop' --no-sort --workspace-concurrency=3 --if-present test + pnpm --filter @dev.fast/review-desktop test - name: Upload browser test diagnostics if: failure() diff --git a/apps/review-desktop/UPSTREAM b/apps/review-desktop/UPSTREAM index d64ffab20..30aa24add 100644 --- a/apps/review-desktop/UPSTREAM +++ b/apps/review-desktop/UPSTREAM @@ -372,6 +372,10 @@ one of these entries: fatal either: it ends that connection only. Isolation is unconditional, so a remote Source window gets no upstream reload dialog; it reconnects through the fork's resolver and shows its host state in the status bar. +- `src/vs/platform/window/common/window.ts` and + `src/vs/platform/native/electron-main/nativeHostMainService.ts`: the + `openWindow` option `reviewReferencesToShow` runs Find References in the + opened window. - `extensions/package.json` and the extension `package.json`/`package-lock.json` files: `@vscode/extension-telemetry` is hoisted to the shared extensions install (github keeps its own ^1.0.0 copy; the shared copy is ^0.9.8). The diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.test.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.test.ts index 195dcc3e3..ce6b6cfb1 100644 --- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.test.ts +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.test.ts @@ -12,7 +12,7 @@ import test from "node:test"; import type { ReviewGatewayHost } from "../../common/reviewProtocol.js"; import { attachOutput, COMMIT, detectOutput, FAKE_SERVER_ID, fakeClock, fakeSsh, flush, healthServer, until, type FakeRemote } from "./test/fakeSsh.js"; import type { ReviewRemoteInstallFlow } from "./reviewRemoteHost.js"; -import { ReviewRemoteHosts } from "./reviewRemoteHosts.js"; +import { closeRemoteHostWindows, ReviewRemoteHosts, type ReviewRemoteHostsOptions } from "./reviewRemoteHosts.js"; import { openRemoteInstallConsent } from "./reviewRemoteInstallConsent.js"; import type { SshPromptRequest } from "./reviewSshAskpass.js"; import { reviewSshInstancePrefix } from "./reviewSshCommand.js"; @@ -23,6 +23,7 @@ async function managerFor( answer?: string, before?: (directory: string) => Promise, install?: ReviewRemoteInstallFlow, + removed?: ReviewRemoteHostsOptions["removed"], ) { const port = await healthServer(t); const directory = await mkdtemp(join(tmpdir(), "wb-hosts-")); @@ -48,6 +49,7 @@ async function managerFor( clock, timeouts: { poll: 1 }, install, + removed, }); t.after(async () => { await manager.dispose(); @@ -98,6 +100,39 @@ test("removing a host from the setting closes its connection", async (t) => { assert.ok(ssh.master("wb-test-b")!.alive); }); +test("removing a machine's last alias closes the windows bound to it, and no other window", async (t) => { + const closed: string[] = []; + const windows = [`whiteboard+${FAKE_SERVER_ID}`, "whiteboard+wb-test-b-server", "ssh-remote+x", undefined].map((remoteAuthority) => ({ + remoteAuthority, + close: () => closed.push(String(remoteAuthority)), + })); + const b = { attach: { code: 0, stdout: attachOutput(41234, "remote-token", { serverId: "wb-test-b-server" }) } }; + const { manager, sentUntil } = await managerFor(t, { "wb-test-a": {}, "wb-test-a2": {}, "wb-test-b": b }, undefined, undefined, undefined, (serverId) => + closeRemoteHostWindows({ getWindows: () => windows }, serverId), + ); + + manager.update(true, ["wb-test-a", "wb-test-a2", "wb-test-b"]); + await sentUntil((hosts) => hosts.length === 3 && hosts.every((h) => h.endpoint)); + manager.update(true, ["wb-test-a", "wb-test-b"]); + assert.deepEqual(closed, []); + manager.update(true, ["wb-test-b"]); + assert.deepEqual(closed, [`whiteboard+${FAKE_SERVER_ID}`]); +}); + +test("a machine whose entry lost its id while unreachable is known through its alias until that alias leaves the setting", async (t) => { + const { manager, sentUntil } = await managerFor(t, { "wb-test-a": {} }); + manager.update(true, ["wb-test-a"]); + await sentUntil((hosts) => byAlias(hosts, "wb-test-a")?.endpoint !== undefined); + + const unreachable = [{ alias: "wb-test-a", state: "unreachable" as const }]; + assert.deepEqual(manager.hostState(FAKE_SERVER_ID, unreachable), { alias: "wb-test-a", state: "unreachable" }); + assert.deepEqual(manager.hostState(FAKE_SERVER_ID, []), { alias: "wb-test-a", state: "connecting" }); + assert.equal(manager.hostState("other-machine", unreachable), undefined); + + manager.update(true, []); + assert.equal(manager.hostState(FAKE_SERVER_ID, unreachable), undefined); +}); + test("an alias added again while its old host closes starts only after the old master exited", async (t) => { const { manager, ssh, sentUntil } = await managerFor(t, { "wb-test-a": { exitDelayMs: 150 } }); diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.ts index d2fabf456..db1709ad9 100644 --- a/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.ts +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/remote/reviewRemoteHosts.ts @@ -18,6 +18,7 @@ import { type SpawnSsh, } from "./reviewRemoteHost.js"; import type { ReviewSshAskpass, SshPromptRequest } from "./reviewSshAskpass.js"; +import { reviewRemoteAuthority } from "../../services/remote/reviewRemoteAuthority.js"; import { uninstallRemote } from "./reviewRemoteUninstall.js"; import { prepareSshControlDirectory, @@ -48,6 +49,26 @@ export interface ReviewRemoteHostsOptions { readonly clock?: ReviewRemoteClock; readonly timeouts?: ReviewRemoteHostOptions["timeouts"]; readonly install?: ReviewRemoteInstallFlow; + /** No host in the setting serves this machine any more. */ + removed?(serverId: string): void; +} + +/** The gateway's state of the machine `serverId`; `configured` is the alias that served it. */ +export function remoteHostState( + serverId: string, + states: readonly ReviewGatewayHostState[], + configured?: string, +): ({ alias?: string } & Pick) | undefined { + const own = states.filter((state) => state.serverId === serverId); + const best = own.find((state) => state.state === "online") ?? own.find((state) => state.state === "connecting") ?? own[0]; + if (best) return best; + if (configured) return states.find((state) => state.alias === configured) ?? { alias: configured, state: "connecting" }; + return states.some((state) => state.serverId === undefined && state.state === "connecting") ? { state: "connecting" } : undefined; +} + +export function closeRemoteHostWindows(windows: { getWindows(): readonly { readonly remoteAuthority?: string; close(): void }[] }, serverId: string): void { + const authority = reviewRemoteAuthority(serverId); + if (authority) for (const window of windows.getWindows()) if (window.remoteAuthority === authority) window.close(); } export function freeLoopbackPort(): Promise { @@ -110,6 +131,7 @@ export class ReviewRemoteHosts { this.hosts.delete(alias); this.options.log(`${alias}: removed from the setting; closing its connection.`); this.closing.set(host, host.dispose().finally(() => this.closing.delete(host))); + this.forget(host); } this.order = wanted; this.refused.clear(); @@ -183,6 +205,7 @@ export class ReviewRemoteHosts { if (host && this.hosts.get(alias) === host) { this.hosts.delete(alias); this.removed.add(alias); + this.forget(host); this.publish(); } if (!host) return; @@ -203,6 +226,12 @@ export class ReviewRemoteHosts { return online && this.hosts.get(online.alias)?.languageEndpoint(serverId); } + /** The state of the machine `serverId`, through the alias in the setting that served it this session. */ + hostState(serverId: string, states: readonly ReviewGatewayHostState[]): ReturnType { + const configured = [...this.hosts.values()].find((host) => host.serverId === serverId)?.state.alias; + return remoteHostState(serverId, states, configured); + } + resume(): void { for (const host of this.hosts.values()) void host.resume(); } @@ -239,6 +268,11 @@ export class ReviewRemoteHosts { }); } + private forget(host: ReviewRemoteHost): void { + const { serverId } = host; + if (serverId && ![...this.hosts.values()].some((other) => other.serverId === serverId)) this.options.removed?.(serverId); + } + private askpass(): Promise { return (this.prepared ??= (async () => { await prepareSshControlDirectory(this.options.controlDirectory); diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopChannel.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopChannel.ts index a538b0703..e2621582d 100644 --- a/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopChannel.ts +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopChannel.ts @@ -57,6 +57,10 @@ export class ReviewDesktopChannel implements IServerChannel { if (typeof arg !== "string") return undefined as T; return (await this.host.getRemoteLanguageEndpoint(arg)) as T; } + if (command === "getRemoteHostState") { + if (typeof arg !== "string") return undefined as T; + return (await this.host.getRemoteHostState(arg)) as T; + } if (command === "retryRemoteHost") { if (typeof arg === "string") this.host.retryRemoteHost(arg); return undefined as T; diff --git a/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopHost.ts b/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopHost.ts index fa0ad8c39..a49f7b91b 100644 --- a/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopHost.ts +++ b/apps/review-desktop/code-oss/src/vs/review/electron-main/reviewDesktopHost.ts @@ -9,6 +9,7 @@ import { Disposable, toDisposable } from "../../base/common/lifecycle.js"; import { join } from "../../base/common/path.js"; import { IConfigurationService } from "../../platform/configuration/common/configuration.js"; import { IEnvironmentMainService } from "../../platform/environment/electron-main/environmentMainService.js"; +import { IInstantiationService } from "../../platform/instantiation/common/instantiation.js"; import { ILifecycleMainService, LifecycleMainPhase, @@ -20,6 +21,7 @@ import { IApplicationStorageMainService } from "../../platform/storage/electron- import { NullTelemetryService } from "../../platform/telemetry/common/telemetryUtils.js"; import { IUpdateService } from "../../platform/update/common/update.js"; import { UtilityProcess } from "../../platform/utilityProcess/electron-main/utilityProcess.js"; +import { IWindowsMainService } from "../../platform/windows/electron-main/windows.js"; import type { ReviewDesktopConnection } from "../common/reviewDesktopBootstrap.js"; import type { ReviewGatewayHostState, @@ -52,7 +54,7 @@ import type { ReviewRemoteInstallFlow, ReviewRemoteInstallMode, } from "./remote/reviewRemoteHost.js"; -import { ReviewRemoteHosts } from "./remote/reviewRemoteHosts.js"; +import { closeRemoteHostWindows, ReviewRemoteHosts } from "./remote/reviewRemoteHosts.js"; import { openRemoteInstallConsent, reviewRemoteInstallConsentPath, @@ -100,6 +102,8 @@ export class ReviewDesktopHost extends Disposable { @IUpdateService private readonly updateService: IUpdateService, @IApplicationStorageMainService private readonly applicationStorageMainService: IApplicationStorageMainService, + @IInstantiationService + private readonly instantiationService: IInstantiationService, ) { super(); let resolvedEnvironment: Promise | undefined; @@ -255,17 +259,23 @@ export class ReviewDesktopHost extends Disposable { async getRemoteLanguageEndpoint(serverId: string) { const manager = this.remoteHosts; - if (!manager) return undefined; + const states = manager && (await this.remoteHostStates()); + return states && manager.languageEndpoint(serverId, states).catch(() => undefined); + } + + async getRemoteHostState(serverId: string) { + const manager = this.remoteHosts; + const states = manager && (await this.remoteHostStates()); + return states && manager.hostState(serverId, states); + } + + private async remoteHostStates(): Promise { try { const { url, token } = await this.whenConnected(); const response = await fetch(new URL("/remote-hosts", url), { headers: { "x-review-token": token }, }); - if (!response.ok) return undefined; - return await manager.languageEndpoint( - serverId, - (await response.json()) as ReviewGatewayHostState[], - ); + return response.ok ? ((await response.json()) as ReviewGatewayHostState[]) : undefined; } catch { return undefined; } @@ -354,6 +364,10 @@ export class ReviewDesktopHost extends Disposable { send: (hosts) => this.supervisor.setRemoteHosts(hosts), log: (message) => this.logService.info(`[Remote hosts] ${message}`), install: this.remoteInstallFlow(), + removed: (serverId) => + this.instantiationService.invokeFunction((accessor) => + closeRemoteHostWindows(accessor.get(IWindowsMainService), serverId), + ), }); this.remoteHosts = manager; const update = () => { diff --git a/apps/review-desktop/code-oss/src/vs/review/navigator.desktop.main.ts b/apps/review-desktop/code-oss/src/vs/review/navigator.desktop.main.ts index d4bbff646..0863dea68 100644 --- a/apps/review-desktop/code-oss/src/vs/review/navigator.desktop.main.ts +++ b/apps/review-desktop/code-oss/src/vs/review/navigator.desktop.main.ts @@ -47,6 +47,19 @@ import { ILanguageFeaturesService } from '../editor/common/services/languageFeat import { SymbolNavigationAnchor } from '../editor/contrib/gotoSymbol/browser/goToCommands.js'; import { CommandsRegistry, ICommandService } from '../platform/commands/common/commands.js'; import { IEditorService } from '../workbench/services/editor/common/editorService.js'; +import { NativeExtensionService } from '../workbench/services/extensions/electron-browser/nativeExtensionService.js'; +import { IExtensionService } from '../workbench/services/extensions/common/extensions.js'; +import { IRemoteAuthorityResolverService, type ResolverResult } from '../platform/remote/common/remoteAuthorityResolver.js'; +import { IMainProcessService } from '../platform/ipc/common/mainProcessService.js'; +import { REVIEW_DESKTOP_CHANNEL } from './common/reviewDesktopBootstrap.js'; +import { isReviewRemoteAuthority, reviewRemoteServerId } from './services/remote/reviewRemoteAuthority.js'; +import { reloadWhenOnline, reviewWindowAuthorityResolver, reviewWindowHosts } from './services/remote/reviewWindowAuthorityResolver.js'; +import { IHostService } from '../workbench/services/host/browser/host.js'; +import { FilesConfigurationService, IFilesConfigurationService } from '../workbench/services/filesConfiguration/common/filesConfigurationService.js'; +import type { IBaseFileStat } from '../platform/files/common/files.js'; +import { isEqual } from '../base/common/resources.js'; +import type { IMarkdownString } from '../base/common/htmlContent.js'; +import type { IWorkspaceContextService } from '../platform/workspace/common/workspace.js'; class NavigatorDefaults { constructor(@IStorageService storage: IStorageService) { @@ -60,6 +73,45 @@ class NavigatorDefaults { registerWorkbenchContribution2('review.navigator.defaults', NavigatorDefaults, WorkbenchPhase.BlockStartup); +class NavigatorExtensionService extends NativeExtensionService { + private readonly windowHosts = this._instantiationService.invokeFunction((accessor) => reviewWindowHosts(accessor.get(IMainProcessService).getChannel(REVIEW_DESKTOP_CHANNEL))); + protected override readonly _remoteAuthorityResolverService = this._instantiationService.invokeFunction((accessor) => + reviewWindowAuthorityResolver(accessor.get(IRemoteAuthorityResolverService), this.windowHosts)); + private retrying = false; + + protected override async _resolveAuthority(remoteAuthority: string): Promise { + if (!isReviewRemoteAuthority(remoteAuthority)) return super._resolveAuthority(remoteAuthority); + try { + return await this._remoteAuthorityResolverService.resolveAuthority(remoteAuthority); + } catch (error) { + if (!this.retrying) { + this.retrying = true; + const host = this._instantiationService.invokeFunction((accessor) => accessor.get(IHostService)); + this._register(reloadWhenOnline(this.windowHosts, reviewRemoteServerId(remoteAuthority), () => void host.reload())); + } + throw error; + } + } +} + +registerSingleton(IExtensionService, NavigatorExtensionService, InstantiationType.Eager); + +/** Every file in a Source window's folders is read-only, whatever a folder's settings say. */ +class NavigatorFilesConfigurationService extends FilesConfigurationService { + private readonly workspace: IWorkspaceContextService; + + constructor(...args: ConstructorParameters) { + super(...args); + this.workspace = args[2]; + } + + override isReadonly(resource: URI, stat?: IBaseFileStat): boolean | IMarkdownString { + return super.isReadonly(resource, stat) || (!!this.workspace.getWorkspaceFolder(resource) && !isEqual(resource, this.workspace.getWorkspace().configuration ?? undefined)); + } +} + +registerSingleton(IFilesConfigurationService, NavigatorFilesConfigurationService, InstantiationType.Eager); + CommandsRegistry.registerCommand('review.action.showReferencesInSource', async (accessor, resource: string, lineNumber: number, column: number) => { const editorService = accessor.get(IEditorService); const references = accessor.get(ILanguageFeaturesService).referenceProvider; diff --git a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteAuthority.ts b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteAuthority.ts index ca32e269b..c2223b36f 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteAuthority.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewRemoteAuthority.ts @@ -6,14 +6,26 @@ import { Schemas } from "../../../base/common/network.js"; import type { URI } from "../../../base/common/uri.js"; +const PREFIX = "whiteboard+"; + export function reviewRemoteAuthority(serverId: string): string | undefined { - return /^[0-9a-z-]+$/i.test(serverId) ? `whiteboard+${serverId.toLowerCase()}` : undefined; + return /^[0-9a-z-]+$/i.test(serverId) ? `${PREFIX}${serverId.toLowerCase()}` : undefined; +} + +/** The server id in a `reviewRemoteAuthority`. */ +export function reviewRemoteServerId(authority: string): string { + return authority.slice(PREFIX.length); } export function ownsRemoteResource(authority: string, resource: URI): boolean { return resource.scheme === Schemas.vscodeRemote && resource.authority.toLowerCase() === authority; } +/** Exactly the lower-case `whiteboard+` that `reviewRemoteAuthority` makes. */ +export function isReviewRemoteAuthority(authority: string | null | undefined): authority is string { + return !!authority && /^whiteboard\+[0-9a-z-]+$/.test(authority); +} + export function override(base: T, members: Partial): T { return new Proxy(base, { get(target, key) { diff --git a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewWindowAuthorityResolver.test.ts b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewWindowAuthorityResolver.test.ts new file mode 100644 index 000000000..342fb84a4 --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewWindowAuthorityResolver.test.ts @@ -0,0 +1,165 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { URI } from "../../../base/common/uri.js"; +import type { IChannel } from "../../../base/parts/ipc/common/ipc.js"; +import { + type IRemoteAuthorityResolverService, + RemoteAuthorityResolverError, + RemoteAuthorityResolverErrorCode, + RemoteConnectionType, + type ResolverResult, +} from "../../../platform/remote/common/remoteAuthorityResolver.js"; +import type { ReviewGatewayHostState } from "../../common/reviewProtocol.js"; +import { remoteHostState } from "../../electron-main/remote/reviewRemoteHosts.js"; +import { reloadWhenOnline, reviewWindowAuthorityResolver, reviewWindowHosts, type ReviewWindowHosts } from "./reviewWindowAuthorityResolver.js"; + +const AUTHORITY = "whiteboard+abc-1"; + +/** Main's answers, in order; the last one repeats. */ +function fakeChannel(answers: { endpoint?: { host: string; port: number; connectionToken: string }; state?: { alias: string; state: string } }[]) { + const calls: [string, unknown][] = []; + let index = 0; + const channel = { + call: async (command: string, arg: unknown) => { + calls.push([command, arg]); + const answer = answers[Math.min(index, answers.length - 1)]; + if (command === "getRemoteLanguageEndpoint") return answer.endpoint; + index++; + return answer.state; + }, + listen: () => { + throw new Error("not used"); + }, + } as unknown as IChannel; + return { channel, calls }; +} + +/** + * Main as it behaves: each phase is what the gateway lists and which alias in + * the setting served the machine; the state goes through main's own lookup. + */ +function fakeMain(phases: { endpoint?: number; states: ReviewGatewayHostState[]; configured?: string }[]) { + let index = 0; + const phase = () => phases[Math.min(index, phases.length - 1)]; + const hosts: ReviewWindowHosts = { + endpoint: async () => { + const port = phase().endpoint; + return port === undefined ? undefined : endpoint(port); + }, + state: async (serverId) => { + const { states, configured } = phase(); + index++; + return remoteHostState(serverId, states, configured); + }, + }; + return { hosts, next: (...more: typeof phases) => phases.splice(0, phases.length, ...more) }; +} + +function fakeBase() { + let provider: ((uri: URI) => Promise) | undefined; + const base = { + resolveAuthority: async (authority: string): Promise => ({ + authority: { authority, connectTo: { type: RemoteConnectionType.WebSocket, host: "base", port: 1 }, connectionToken: undefined }, + }), + _setCanonicalURIProvider: (next: (uri: URI) => Promise) => (provider = next), + getCanonicalURI: (uri: URI) => provider!(uri), + } as unknown as IRemoteAuthorityResolverService; + return base; +} + +const endpoint = (port: number) => ({ host: "127.0.0.1", port, connectionToken: "vscode-token" }); +const connectTo = (result: ResolverResult) => { + const { connectTo, connectionToken } = result.authority; + assert.equal(connectTo.type, RemoteConnectionType.WebSocket); + return { host: connectTo.host, port: connectTo.port, connectionToken }; +}; + +test("a whiteboard+ authority resolves to its host's forward, afresh each time", async () => { + const { channel, calls } = fakeChannel([{ endpoint: endpoint(41000) }]); + const resolver = reviewWindowAuthorityResolver(fakeBase(), reviewWindowHosts(channel)); + + assert.deepEqual(connectTo(await resolver.resolveAuthority(AUTHORITY)), { host: "127.0.0.1", port: 41000, connectionToken: "vscode-token" }); + assert.deepEqual(calls, [["getRemoteLanguageEndpoint", "abc-1"]]); +}); + +test("a resolved authority waits through offline and unreachable for the moved forward", async () => { + const online = { alias: "devbox", serverId: "abc-1", state: "online" as const }; + const { hosts, next } = fakeMain([{ endpoint: 41000, states: [online], configured: "devbox" }]); + const resolver = reviewWindowAuthorityResolver(fakeBase(), hosts, { wait: 20, poll: 5 }); + assert.equal(connectTo(await resolver.resolveAuthority(AUTHORITY)).port, 41000); + + // The gateway keeps the id while offline and drops it with Desktop's problem report; each phase outlasts the wait. + const offline = Array.from({ length: 6 }, () => ({ states: [{ ...online, state: "offline" as const }], configured: "devbox" })); + const unreachable = Array.from({ length: 6 }, () => ({ states: [{ alias: "devbox", state: "unreachable" as const }], configured: "devbox" })); + next(...offline, ...unreachable, { endpoint: 42000, states: [online], configured: "devbox" }); + assert.equal(connectTo(await resolver.resolveAuthority(AUTHORITY)).port, 42000); +}); + +test("a resolved authority whose machine is gone after the wait fails; one still known keeps waiting", async () => { + const answers: Parameters[0] = [{ endpoint: endpoint(41000) }]; + const { channel } = fakeChannel(answers); + const resolver = reviewWindowAuthorityResolver(fakeBase(), reviewWindowHosts(channel), { wait: 20, poll: 5 }); + await resolver.resolveAuthority(AUTHORITY); + + answers.splice(0, 1, {}); + await assert.rejects(resolver.resolveAuthority(AUTHORITY), (error: RemoteAuthorityResolverError) => error._code === RemoteAuthorityResolverErrorCode.NotAvailable); + + answers.splice(0, 1, ...Array.from({ length: 10 }, () => ({ state: { alias: "devbox", state: "offline" } })), { endpoint: endpoint(43000) }); + assert.equal(connectTo(await resolver.resolveAuthority(AUTHORITY)).port, 43000); +}); + +test("a connecting host is waited for, and resolves once its forward appears", async () => { + const connecting = { state: { alias: "devbox", state: "connecting" } }; + const { channel } = fakeChannel([connecting, connecting, { endpoint: endpoint(43000) }]); + const resolver = reviewWindowAuthorityResolver(fakeBase(), reviewWindowHosts(channel), { wait: 1_000, poll: 1 }); + + assert.equal(connectTo(await resolver.resolveAuthority(AUTHORITY)).port, 43000); +}); + +test("a host still connecting after the wait, or not connecting at all, is offline", async () => { + for (const [state, wait] of [["connecting", 20], ["offline", 60_000]] as const) { + const { channel, calls } = fakeChannel([{ state: { alias: "devbox", state } }]); + const resolver = reviewWindowAuthorityResolver(fakeBase(), reviewWindowHosts(channel), { wait, poll: 5 }); + + await assert.rejects(resolver.resolveAuthority(AUTHORITY), (error: RemoteAuthorityResolverError) => { + assert.ok(error instanceof RemoteAuthorityResolverError); + assert.equal(error._code, RemoteAuthorityResolverErrorCode.NotAvailable); + assert.equal(error._detail, "devbox is offline"); + return true; + }); + if (state === "offline") assert.equal(calls.length, 2); + } +}); + +test("canonical URIs of whiteboard+ are themselves; every other authority is the window's", async () => { + const { channel, calls } = fakeChannel([{ endpoint: endpoint(41000) }]); + const resolver = reviewWindowAuthorityResolver(fakeBase(), reviewWindowHosts(channel)); + resolver._setCanonicalURIProvider(async () => { + throw new Error("no resolver extension"); + }); + + const own = URI.parse(`vscode-remote://${AUTHORITY}/home/dev/repo/f.ts`); + assert.equal(await resolver.getCanonicalURI(own), own); + await assert.rejects(resolver.getCanonicalURI(URI.parse("vscode-remote://ssh-remote+x/f.ts")), /no resolver extension/); + + for (const other of ["ssh-remote+x", "Whiteboard+ABC", "whiteboard+a/b"]) { + assert.equal(connectTo(await resolver.resolveAuthority(other)).host, "base"); + } + assert.deepEqual(calls, []); +}); + +test("a window that could not reach its host reloads when the host's forward answers", async () => { + let polls = 0; + const hosts: ReviewWindowHosts = { endpoint: async () => (++polls >= 3 ? endpoint(45000) : undefined), state: async () => undefined }; + const reloaded = Promise.withResolvers(); + const watch = reloadWhenOnline(hosts, "abc-1", reloaded.resolve, 1); + + await reloaded.promise; + watch.dispose(); + assert.equal(polls, 3); +}); diff --git a/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewWindowAuthorityResolver.ts b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewWindowAuthorityResolver.ts new file mode 100644 index 000000000..25c8ca500 --- /dev/null +++ b/apps/review-desktop/code-oss/src/vs/review/services/remote/reviewWindowAuthorityResolver.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) dev.fast. All rights reserved. + * Licensed under the MIT License. See LICENSE in the repository root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { timeout } from "../../../base/common/async.js"; +import { toDisposable, type IDisposable } from "../../../base/common/lifecycle.js"; +import type { IChannel } from "../../../base/parts/ipc/common/ipc.js"; +import { + type IRemoteAuthorityResolverService, + RemoteAuthorityResolverError, + RemoteAuthorityResolverErrorCode, + type ResolverResult, + WebSocketRemoteConnection, +} from "../../../platform/remote/common/remoteAuthorityResolver.js"; +import type { ReviewGatewayHostState } from "../../common/reviewProtocol.js"; +import type { ReviewRemoteLanguageEndpoint } from "../reviewDesktopConnectionService.js"; +import { isReviewRemoteAuthority, override, reviewRemoteServerId } from "./reviewRemoteAuthority.js"; + +/** How long a Source window waits for its host to finish connecting. */ +export const REMOTE_WINDOW_RESOLVE_WAIT_MS = 60_000; + +export interface ReviewWindowHosts { + endpoint(serverId: string): Promise; + state(serverId: string): Promise<({ alias?: string } & Pick) | undefined>; +} + +/** Reloads a window that could not reach its host, once the host's forward answers. */ +export function reloadWhenOnline(hosts: ReviewWindowHosts, serverId: string, reload: () => void, every = 5_000): IDisposable { + let done = false; + const check = async () => { + const endpoint = await hosts.endpoint(serverId).catch(() => undefined); + if (!endpoint || done) return; + done = true; + clearInterval(handle); + reload(); + }; + const handle = setInterval(() => void check(), every); + return toDisposable(() => { + done = true; + clearInterval(handle); + }); +} + +export function reviewWindowHosts(channel: IChannel): ReviewWindowHosts { + return { + endpoint: (serverId) => channel.call("getRemoteLanguageEndpoint", serverId), + state: (serverId) => channel.call("getRemoteHostState", serverId), + }; +} + +/** Resolves `whiteboard+` to the host's current forward; other authorities go to `base`. */ +export function reviewWindowAuthorityResolver( + base: IRemoteAuthorityResolverService, + hosts: ReviewWindowHosts, + { wait = REMOTE_WINDOW_RESOLVE_WAIT_MS, poll = 1_000 } = {}, +): IRemoteAuthorityResolverService { + const resolved = new Set(); + const resolve = async (authority: string): Promise => { + const serverId = reviewRemoteServerId(authority); + const again = resolved.has(authority); + const deadline = Date.now() + wait; + for (;;) { + const endpoint = await hosts.endpoint(serverId); + if (endpoint) { + resolved.add(authority); + const { host, port, connectionToken } = endpoint; + return { authority: { authority, connectTo: new WebSocketRemoteConnection(host, port), connectionToken }, options: {} }; + } + const state = await hosts.state(serverId); + const left = deadline - Date.now(); + // A moved forward passes through offline or unreachable; only a machine gone from the setting ends a reconnect. + const waiting = again ? state !== undefined || left > 0 : (state === undefined || state.state === "connecting") && left > 0; + if (!waiting) { + const offline = `${state?.alias ?? serverId.slice(0, 8)} is offline`; + throw new RemoteAuthorityResolverError(offline, RemoteAuthorityResolverErrorCode.NotAvailable, offline); + } + await timeout(left > 0 ? Math.min(poll, left) : poll); + } + }; + return override(base, { + resolveAuthority: (authority) => (isReviewRemoteAuthority(authority) ? resolve(authority) : base.resolveAuthority(authority)), + _setCanonicalURIProvider: (provider) => + base._setCanonicalURIProvider((uri) => (isReviewRemoteAuthority(uri.authority) ? Promise.resolve(uri) : provider(uri))), + }); +} diff --git a/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.ts b/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.ts index 8c214ffea..d74460cc4 100644 --- a/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.ts +++ b/apps/review-desktop/code-oss/src/vs/review/services/reviewDesktopConnectionService.ts @@ -258,12 +258,7 @@ export class ReviewDesktopConnectionService extends Disposable implements IRevie } async getRemoteLanguageEndpoint(serverId: string): Promise { - const endpoint: unknown = await this.mainProcessService.getChannel(REVIEW_DESKTOP_CHANNEL).call("getRemoteLanguageEndpoint", serverId); - if (typeof endpoint !== "object" || endpoint === null) return undefined; - const { host, port, connectionToken } = endpoint as Record; - return typeof host === "string" && Number.isInteger(port) && typeof connectionToken === "string" - ? { host, port: port as number, connectionToken } - : undefined; + return this.mainProcessService.getChannel(REVIEW_DESKTOP_CHANNEL).call("getRemoteLanguageEndpoint", serverId); } async installRemoteHost(alias: string): Promise {