diff --git a/src/extension.ts b/src/extension.ts index 22c9b956..02f8055f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -17,6 +17,7 @@ const EXTENSION_ID = 'gitpod.gitpod-desktop'; const FIRST_INSTALL_KEY = 'gitpod-desktop.firstInstall'; let telemetry: TelemetryReporter; +let remoteConnector: RemoteConnector; export async function activate(context: vscode.ExtensionContext) { const packageJSON = vscode.extensions.getExtension(EXTENSION_ID)!.packageJSON; @@ -71,9 +72,8 @@ export async function activate(context: vscode.ExtensionContext) { })); const authProvider = new GitpodAuthenticationProvider(context, logger, telemetry); - const remoteConnector = new RemoteConnector(context, logger, telemetry); + remoteConnector = new RemoteConnector(context, logger, telemetry); context.subscriptions.push(authProvider); - context.subscriptions.push(remoteConnector); context.subscriptions.push(vscode.window.registerUriHandler({ handleUri(uri: vscode.Uri) { // logger.trace('Handling Uri...', uri.toString()); @@ -85,10 +85,6 @@ export async function activate(context: vscode.ExtensionContext) { } })); - if (await remoteConnector.checkRemoteConnectionSuccessful()) { - context.subscriptions.push(vscode.commands.registerCommand('gitpod.api.autoTunnel', remoteConnector.autoTunnelCommand, remoteConnector)); - } - if (!context.globalState.get(FIRST_INSTALL_KEY, false)) { await context.globalState.update(FIRST_INSTALL_KEY, true); telemetry.sendTelemetryEvent('gitpod_desktop_installation', { kind: 'install' }); @@ -96,7 +92,6 @@ export async function activate(context: vscode.ExtensionContext) { } export async function deactivate() { - if (telemetry) { - await telemetry.dispose(); - } + await remoteConnector?.dispose(); + await telemetry?.dispose(); } diff --git a/src/heartbeat.ts b/src/heartbeat.ts new file mode 100644 index 00000000..c04b830d --- /dev/null +++ b/src/heartbeat.ts @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Gitpod. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { Disposable } from './common/dispose'; +import Log from './common/logger'; +import { withServerApi } from './internalApi'; +import TelemetryReporter from './telemetryReporter'; + +export class HeartbeatManager extends Disposable { + + static HEARTBEAT_INTERVAL = 30000; + + private lastActivity = new Date().getTime(); + private isWorkspaceRunning = true; + private heartBeatHandle: NodeJS.Timer | undefined; + + constructor( + readonly gitpodHost: string, + readonly workspaceId: string, + readonly instanceId: string, + private readonly accessToken: string, + private readonly logger: Log, + private readonly telemetry: TelemetryReporter + ) { + super(); + + this._register(vscode.window.onDidChangeActiveTextEditor(this.updateLastActivitiy, this)); + this._register(vscode.window.onDidChangeVisibleTextEditors(this.updateLastActivitiy, this)); + this._register(vscode.window.onDidChangeTextEditorSelection(this.updateLastActivitiy, this)); + this._register(vscode.window.onDidChangeTextEditorVisibleRanges(this.updateLastActivitiy, this)); + this._register(vscode.window.onDidChangeTextEditorOptions(this.updateLastActivitiy, this)); + this._register(vscode.window.onDidChangeTextEditorViewColumn(this.updateLastActivitiy, this)); + this._register(vscode.window.onDidChangeActiveTerminal(this.updateLastActivitiy, this)); + this._register(vscode.window.onDidOpenTerminal(this.updateLastActivitiy, this)); + this._register(vscode.window.onDidCloseTerminal(this.updateLastActivitiy, this)); + this._register(vscode.window.onDidChangeTerminalState(this.updateLastActivitiy, this)); + this._register(vscode.window.onDidChangeWindowState(this.updateLastActivitiy, this)); + this._register(vscode.window.onDidChangeActiveColorTheme(this.updateLastActivitiy, this)); + this._register(vscode.authentication.onDidChangeSessions(this.updateLastActivitiy, this)); + this._register(vscode.debug.onDidChangeActiveDebugSession(this.updateLastActivitiy, this)); + this._register(vscode.debug.onDidStartDebugSession(this.updateLastActivitiy, this)); + this._register(vscode.debug.onDidReceiveDebugSessionCustomEvent(this.updateLastActivitiy, this)); + this._register(vscode.debug.onDidTerminateDebugSession(this.updateLastActivitiy, this)); + this._register(vscode.debug.onDidChangeBreakpoints(this.updateLastActivitiy, this)); + this._register(vscode.extensions.onDidChange(this.updateLastActivitiy, this)); + this._register(vscode.languages.onDidChangeDiagnostics(this.updateLastActivitiy, this)); + this._register(vscode.tasks.onDidStartTask(this.updateLastActivitiy, this)); + this._register(vscode.tasks.onDidStartTaskProcess(this.updateLastActivitiy, this)); + this._register(vscode.tasks.onDidEndTask(this.updateLastActivitiy, this)); + this._register(vscode.tasks.onDidEndTaskProcess(this.updateLastActivitiy, this)); + this._register(vscode.workspace.onDidChangeWorkspaceFolders(this.updateLastActivitiy, this)); + this._register(vscode.workspace.onDidOpenTextDocument(this.updateLastActivitiy, this)); + this._register(vscode.workspace.onDidCloseTextDocument(this.updateLastActivitiy, this)); + this._register(vscode.workspace.onDidChangeTextDocument(this.updateLastActivitiy, this)); + this._register(vscode.workspace.onDidSaveTextDocument(this.updateLastActivitiy, this)); + this._register(vscode.workspace.onDidChangeNotebookDocument(this.updateLastActivitiy, this)); + this._register(vscode.workspace.onDidSaveNotebookDocument(this.updateLastActivitiy, this)); + this._register(vscode.workspace.onDidOpenNotebookDocument(this.updateLastActivitiy, this)); + this._register(vscode.workspace.onDidCloseNotebookDocument(this.updateLastActivitiy, this)); + this._register(vscode.workspace.onWillCreateFiles(this.updateLastActivitiy, this)); + this._register(vscode.workspace.onDidCreateFiles(this.updateLastActivitiy, this)); + this._register(vscode.workspace.onWillDeleteFiles(this.updateLastActivitiy, this)); + this._register(vscode.workspace.onDidDeleteFiles(this.updateLastActivitiy, this)); + this._register(vscode.workspace.onWillRenameFiles(this.updateLastActivitiy, this)); + this._register(vscode.workspace.onDidRenameFiles(this.updateLastActivitiy, this)); + this._register(vscode.workspace.onDidChangeConfiguration(this.updateLastActivitiy, this)); + this._register(vscode.languages.registerHoverProvider('*', { + provideHover: () => { + this.updateLastActivitiy(); + return null; + } + })); + + this.logger.trace(`Heartbeat manager for workspace ${workspaceId} (${instanceId}) - ${gitpodHost} started`); + + // Start heatbeating interval + this.sendHeartBeat(); + this.heartBeatHandle = setInterval(() => { + // Add an additional random value between 5 and 15 seconds. See https://github.com/gitpod-io/gitpod/pull/5613 + const randomInterval = Math.floor(Math.random() * (15000 - 5000 + 1)) + 5000; + if (this.lastActivity + HeartbeatManager.HEARTBEAT_INTERVAL + randomInterval < new Date().getTime()) { + // no activity, no heartbeat + return; + } + + this.sendHeartBeat(); + }, HeartbeatManager.HEARTBEAT_INTERVAL); + } + + private updateLastActivitiy() { + this.lastActivity = new Date().getTime(); + } + + private async sendHeartBeat(wasClosed?: true) { + const suffix = wasClosed ? 'closed heartbeat' : 'heartbeat'; + try { + await withServerApi(this.accessToken, this.gitpodHost, async service => { + const workspaceInfo = await service.server.getWorkspace(this.workspaceId); + this.isWorkspaceRunning = workspaceInfo.latestInstance?.status?.phase === 'running' && workspaceInfo.latestInstance?.id === this.instanceId; + if (this.isWorkspaceRunning) { + await service.server.sendHeartBeat({ instanceId: this.instanceId, wasClosed }); + if (wasClosed) { + this.telemetry.sendTelemetryEvent('ide_close_signal', { workspaceId: this.workspaceId, instanceId: this.instanceId, gitpodHost: this.gitpodHost, clientKind: 'vscode' }); + this.logger.trace('send ' + suffix); + } + } else { + this.stopHeartbeat(); + } + }, this.logger); + } catch (err) { + this.logger.error(`failed to send ${suffix}:`, err); + } + } + + private stopHeartbeat() { + if (this.heartBeatHandle) { + clearInterval(this.heartBeatHandle); + this.heartBeatHandle = undefined; + } + } + + public override async dispose(): Promise { + this.stopHeartbeat(); + if (this.isWorkspaceRunning) { + await this.sendHeartBeat(true); + } + super.dispose(); + } +} diff --git a/src/internalApi.ts b/src/internalApi.ts index 96a14def..cf8b32bf 100644 --- a/src/internalApi.ts +++ b/src/internalApi.ts @@ -11,7 +11,7 @@ import ReconnectingWebSocket from 'reconnecting-websocket'; import * as vscode from 'vscode'; import Log from './common/logger'; -type UsedGitpodFunction = ['getLoggedInUser', 'getGitpodTokenScopes', 'getWorkspace', 'getOwnerToken', 'getSSHPublicKeys']; +type UsedGitpodFunction = ['getLoggedInUser', 'getWorkspace', 'getOwnerToken', 'getSSHPublicKeys', 'sendHeartBeat']; type Union = Tuple[number] | Union; export type GitpodConnection = Omit, 'server'> & { server: Pick>; diff --git a/src/remoteConnector.ts b/src/remoteConnector.ts index 7ac67fac..d6066414 100644 --- a/src/remoteConnector.ts +++ b/src/remoteConnector.ts @@ -23,6 +23,7 @@ import { withServerApi } from './internalApi'; import TelemetryReporter from './telemetryReporter'; import { addHostToHostFile, checkNewHostInHostkeys } from './ssh/hostfile'; import { checkDefaultIdentityFiles } from './ssh/identityFiles'; +import { HeartbeatManager } from './heartbeat'; interface SSHConnectionParams { workspaceId: string; @@ -106,13 +107,23 @@ class NoSSHGatewayError extends Error { export default class RemoteConnector extends Disposable { + public static SSH_DEST_KEY = 'ssh-dest:'; public static AUTH_COMPLETE_PATH = '/auth-complete'; private static LOCK_COUNT = 0; - private static SSH_DEST_KEY = 'ssh-dest:'; + + private heartbeatManager: HeartbeatManager | undefined; constructor(private readonly context: vscode.ExtensionContext, private readonly logger: Log, private readonly telemetry: TelemetryReporter) { super(); + if (isGitpodRemoteWindow(context)) { + context.subscriptions.push(vscode.commands.registerCommand('gitpod.api.autoTunnel', this.autoTunnelCommand, this)); + + // Don't await this on purpose so it doesn't block extension activation. + // Internally requesting a Gitpod Session requires the extension to be already activated. + this.onGitpodRemoteConnection(); + } + this.releaseStaleLocks(); } @@ -417,22 +428,15 @@ export default class RemoteConnector extends Disposable { } } - private async getWorkspaceSSHDestination(workspaceId: string, gitpodHost: string): Promise<{ destination: string; password?: string }> { - const session = await vscode.authentication.getSession( - 'gitpod', - ['function:getWorkspace', 'function:getOwnerToken', 'function:getLoggedInUser', 'function:getSSHPublicKeys', 'resource:default'], - { createIfNone: true } - ); - + private async getWorkspaceSSHDestination(accessToken: string, { workspaceId, gitpodHost }: SSHConnectionParams): Promise<{ destination: string; password?: string }> { const serviceUrl = new URL(gitpodHost); - const [workspaceInfo, ownerToken, registeredSSHKeys] = await withServerApi(session.accessToken, serviceUrl.toString(), service => Promise.all([ + const [workspaceInfo, ownerToken, registeredSSHKeys] = await withServerApi(accessToken, serviceUrl.toString(), service => Promise.all([ service.server.getWorkspace(workspaceId), service.server.getOwnerToken(workspaceId), service.server.getSSHPublicKeys() ]), this.logger); - if (workspaceInfo.latestInstance?.status?.phase !== 'running') { throw new NoRunningInstanceError(workspaceId); } @@ -645,6 +649,28 @@ export default class RemoteConnector extends Disposable { throw new Error('SSH password modal dialog, Canceled'); } + private async getGitpodSession(gitpodHost: string) { + const config = vscode.workspace.getConfiguration('gitpod'); + const currentGitpodHost = config.get('host')!; + if (new URL(gitpodHost).host !== new URL(currentGitpodHost).host) { + const yes = 'Yes'; + const cancel = 'Cancel'; + const action = await vscode.window.showInformationMessage(`Connecting to a Gitpod workspace in '${gitpodHost}'. Would you like to switch from '${currentGitpodHost}' and continue?`, yes, cancel); + if (action === cancel) { + return; + } + + await config.update('host', gitpodHost, vscode.ConfigurationTarget.Global); + this.logger.info(`Updated 'gitpod.host' setting to '${gitpodHost}' while trying to connect to a Gitpod workspace`); + } + + return vscode.authentication.getSession( + 'gitpod', + ['function:getWorkspace', 'function:getOwnerToken', 'function:getLoggedInUser', 'function:getSSHPublicKeys', 'function:sendHeartBeat', 'resource:default'], + { createIfNone: true } + ); + } + public async handleUri(uri: vscode.Uri) { if (uri.path === RemoteConnector.AUTH_COMPLETE_PATH) { this.logger.info('auth completed'); @@ -656,30 +682,22 @@ export default class RemoteConnector extends Disposable { return; } - const gitpodHost = vscode.workspace.getConfiguration('gitpod').get('host')!; - const forceUseLocalApp = vscode.workspace.getConfiguration('gitpod').get('remote.useLocalApp')!; - const params: SSHConnectionParams = JSON.parse(uri.query); - if (new URL(params.gitpodHost).host !== new URL(gitpodHost).host) { - const yes = 'Yes'; - const cancel = 'Cancel'; - const action = await vscode.window.showInformationMessage(`Connecting to a Gitpod workspace in '${params.gitpodHost}'. Would you like to switch from '${gitpodHost}' and continue?`, yes, cancel); - if (action === cancel) { - return; - } - await vscode.workspace.getConfiguration('gitpod').update('host', params.gitpodHost, vscode.ConfigurationTarget.Global); - this.logger.info(`Updated 'gitpod.host' setting to '${params.gitpodHost}' while trying to connect to a Gitpod workspace`); + const session = await this.getGitpodSession(params.gitpodHost); + if (!session) { + return; } this.logger.info('Opening Gitpod workspace', uri.toString()); + const forceUseLocalApp = vscode.workspace.getConfiguration('gitpod').get('remote.useLocalApp')!; let sshDestination: string | undefined; if (!forceUseLocalApp) { try { this.telemetry.sendTelemetryEvent('vscode_desktop_ssh', { kind: 'gateway', status: 'connecting', ...params }); - const { destination, password } = await this.getWorkspaceSSHDestination(params.workspaceId, params.gitpodHost); + const { destination, password } = await this.getWorkspaceSSHDestination(session.accessToken, params); sshDestination = destination; if (password) { @@ -753,12 +771,13 @@ export default class RemoteConnector extends Disposable { await this.updateRemoteSSHConfig(usingSSHGateway, localAppSSHConfigPath); - await this.context.globalState.update(`${RemoteConnector.SSH_DEST_KEY}${sshDestination!}`, params); + await this.context.globalState.update(`${RemoteConnector.SSH_DEST_KEY}${sshDestination!}`, { ...params, isFirstConnection: true }); + const forceNewWindow = this.context.extensionMode === vscode.ExtensionMode.Production; vscode.commands.executeCommand( 'vscode.openFolder', vscode.Uri.parse(`vscode-remote://ssh-remote+${sshDestination}${uri.path || '/'}`), - { forceNewWindow: true } + { forceNewWindow } ); } @@ -788,62 +807,68 @@ export default class RemoteConnector extends Disposable { } } - public async checkRemoteConnectionSuccessful() { - const isRemoteExtensionHostRunning = async () => { - try { - // Invoke command from gitpot-remote extension to test if connection is successful - await vscode.commands.executeCommand('__gitpod.getGitpodRemoteLogsUri'); - return true; - } catch { - return false; - } - }; + private startHeartBeat(accessToken: string, connectionInfo: SSHConnectionParams) { + if (this.heartbeatManager) { + return; + } - const parseSSHDest = (sshDestStr: string): { user: string; hostName: string } | string => { - let decoded; - try { - decoded = JSON.parse(Buffer.from(sshDestStr, 'hex').toString('utf8')); - } catch { - // no-op - } - return decoded && typeof decoded.hostName === 'string' ? decoded : sshDestStr; - }; + this.heartbeatManager = new HeartbeatManager(connectionInfo.gitpodHost, connectionInfo.workspaceId, connectionInfo.instanceId, accessToken, this.logger, this.telemetry); + } + private async onGitpodRemoteConnection() { const remoteUri = vscode.workspace.workspaceFile || vscode.workspace.workspaceFolders?.[0].uri; - if (vscode.env.remoteName === 'ssh-remote' && this.context.extension.extensionKind === vscode.ExtensionKind.UI && remoteUri) { - const [, sshDestStr] = remoteUri.authority.split('+'); - const sshDest = parseSSHDest(sshDestStr); - - const connectionSuccessful = await isRemoteExtensionHostRunning(); - const connectionInfo = this.context.globalState.get(`${RemoteConnector.SSH_DEST_KEY}${sshDestStr}`); - if (connectionInfo) { - sshDest; - // const usingSSHGateway = typeof sshDest !== 'string'; - // const kind = usingSSHGateway ? 'gateway' : 'local-app'; - // if (connectionSuccessful) { - // this.telemetry.sendTelemetryEvent('vscode_desktop_ssh', { - // kind, - // status: 'connected', - // instanceId: connectionInfo.instanceId, - // workspaceId: connectionInfo.workspaceId, - // gitpodHost: connectionInfo.gitpodHost - // }); - // } else { - // this.telemetry.sendTelemetryEvent('vscode_desktop_ssh', { - // kind, - // status: 'failed', - // reason: 'remote-ssh extension: connection failed', - // instanceId: connectionInfo.instanceId, - // workspaceId: connectionInfo.workspaceId, - // gitpodHost: connectionInfo.gitpodHost - // }); - // } - await this.context.globalState.update(`${RemoteConnector.SSH_DEST_KEY}${sshDestStr}`, undefined); - } + if (!remoteUri) { + return; + } - return connectionSuccessful; + const [, sshDestStr] = remoteUri.authority.split('+'); + const connectionInfo = this.context.globalState.get(`${RemoteConnector.SSH_DEST_KEY}${sshDestStr}`); + if (!connectionInfo) { + return; } - return false; + await this.context.globalState.update(`${RemoteConnector.SSH_DEST_KEY}${sshDestStr}`, { ...connectionInfo, isFirstConnection: false }); + + // gitpod remote extension installation is async so sometimes gitpod-desktop will activate before gitpod-remote + // let's wait a few seconds for it to finish install + setTimeout(async () => { + // Check for gitpod remote extension version to avoid sending heartbeat in both extensions at the same time + const isGitpodRemoteHeartbeatCancelled = await cancelGitpodRemoteHeartbeat(); + if (isGitpodRemoteHeartbeatCancelled) { + const session = await this.getGitpodSession(connectionInfo.gitpodHost); + if (session) { + this.startHeartBeat(session.accessToken, connectionInfo); + } + } + this.telemetry.sendTelemetryEvent('vscode_desktop_heartbeat_state', { enabled: String(!!this.heartbeatManager), gitpodHost: connectionInfo.gitpodHost, workspaceId: connectionInfo.workspaceId, instanceId: connectionInfo.instanceId }); + }, 7000); + } + + public override async dispose(): Promise { + await this.heartbeatManager?.dispose(); + super.dispose(); + } +} + +function isGitpodRemoteWindow(context: vscode.ExtensionContext) { + const remoteUri = vscode.workspace.workspaceFile || vscode.workspace.workspaceFolders?.[0].uri; + if (vscode.env.remoteName === 'ssh-remote' && context.extension.extensionKind === vscode.ExtensionKind.UI && remoteUri) { + const [, sshDestStr] = remoteUri.authority.split('+'); + const connectionInfo = context.globalState.get(`${RemoteConnector.SSH_DEST_KEY}${sshDestStr}`); + + return !!connectionInfo; + } + + return false; +} + +async function cancelGitpodRemoteHeartbeat() { + let result = false; + try { + // Invoke command from gitpot-remote extension + result = await vscode.commands.executeCommand('__gitpod.cancelGitpodRemoteHeartbeat'); + } catch { + // Ignore if not found } + return result; }