Skip to content

Commit 414ee45

Browse files
authored
[supervisor] Frontend: re-try + track "checkReady" (#20175)
* [ws-proxy] Log whenever we can't connect to an upstream * [protocol] Fix linter error * [supervisor] Frontend: re-connect every 5s + track behavior * [supervisor] Frontend: Guard connection re-try with feature flag "supervisor_check_ready_retry"
1 parent a25cbb8 commit 414ee45

File tree

10 files changed

+258
-19
lines changed

10 files changed

+258
-19
lines changed

components/dashboard/src/service/service.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ export class IDEFrontendService implements IDEFrontendDashboardService.IServer {
184184
private clientWindow: Window,
185185
) {
186186
this.processServerInfo();
187+
this.sendFeatureFlagsUpdate();
187188
window.addEventListener("message", (event: MessageEvent) => {
188189
if (IDEFrontendDashboardService.isTrackEventData(event.data)) {
189190
this.trackEvent(event.data.msg);
@@ -200,6 +201,9 @@ export class IDEFrontendService implements IDEFrontendDashboardService.IServer {
200201
if (IDEFrontendDashboardService.isOpenDesktopIDE(event.data)) {
201202
this.openDesktopIDE(event.data.url);
202203
}
204+
if (IDEFrontendDashboardService.isFeatureFlagsRequestEventData(event.data)) {
205+
this.sendFeatureFlagsUpdate();
206+
}
203207
});
204208
window.addEventListener("unload", () => {
205209
if (!this.instanceID) {
@@ -376,6 +380,23 @@ export class IDEFrontendService implements IDEFrontendDashboardService.IServer {
376380
);
377381
}
378382

383+
private async sendFeatureFlagsUpdate() {
384+
const supervisor_check_ready_retry = await getExperimentsClient().getValueAsync(
385+
"supervisor_check_ready_retry",
386+
false,
387+
{
388+
gitpodHost: gitpodHostUrl.toString(),
389+
},
390+
);
391+
this.clientWindow.postMessage(
392+
{
393+
type: "ide-feature-flag-update",
394+
flags: { supervisor_check_ready_retry },
395+
} as IDEFrontendDashboardService.FeatureFlagsUpdateEventData,
396+
"*",
397+
);
398+
}
399+
379400
relocate(url: string): void {
380401
this.clientWindow.postMessage(
381402
{ type: "ide-relocate", url } as IDEFrontendDashboardService.RelocateEventData,

components/gitpod-protocol/src/frontend-dashboard-service.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,15 @@ export namespace IDEFrontendDashboardService {
6969
info: Info;
7070
}
7171

72+
export interface FeatureFlagsUpdateEventData {
73+
type: "ide-feature-flag-update";
74+
flags: { supervisor_check_ready_retry: boolean };
75+
}
76+
77+
export interface FeatureFlagsRequestEventData {
78+
type: "ide-feature-flag-request";
79+
}
80+
7281
export interface HeartbeatEventData {
7382
type: "ide-heartbeat";
7483
}
@@ -101,6 +110,14 @@ export namespace IDEFrontendDashboardService {
101110
return obj != null && typeof obj === "object" && obj.type === "ide-info-update";
102111
}
103112

113+
export function isFeatureFlagsUpdateEventData(obj: any): obj is FeatureFlagsUpdateEventData {
114+
return obj != null && typeof obj === "object" && obj.type === "ide-feature-flag-update";
115+
}
116+
117+
export function isFeatureFlagsRequestEventData(obj: any): obj is FeatureFlagsRequestEventData {
118+
return obj != null && typeof obj === "object" && obj.type === "ide-feature-flag-request";
119+
}
120+
104121
export function isHeartbeatEventData(obj: any): obj is HeartbeatEventData {
105122
return obj != null && typeof obj === "object" && obj.type === "ide-heartbeat";
106123
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Copyright (c) 2020 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import * as chai from "chai";
8+
const expect = chai.expect;
9+
import { suite, test } from "@testdeck/mocha";
10+
import { Timeout } from "./timeout";
11+
12+
@suite()
13+
export class TimeoutSpec {
14+
@test
15+
async testSimpleRun() {
16+
const timeout = new Timeout(1);
17+
timeout.start();
18+
await timeout.await();
19+
expect(timeout.signal()?.aborted).to.be.true;
20+
}
21+
22+
@test
23+
async testSimpleRunNotStarted() {
24+
const timeout = new Timeout(1);
25+
await timeout.await();
26+
expect(timeout.signal()).to.be.undefined;
27+
}
28+
29+
@test
30+
async testRestart() {
31+
const timeout = new Timeout(20);
32+
timeout.start();
33+
await timeout.await();
34+
expect(timeout.signal()?.aborted).to.be.true;
35+
36+
timeout.restart();
37+
expect(timeout.signal()).to.not.be.undefined;
38+
expect(timeout.signal()?.aborted).to.be.false;
39+
await timeout.await();
40+
expect(timeout.signal()?.aborted).to.be.true;
41+
}
42+
43+
@test
44+
async testClear() {
45+
const timeout = new Timeout(1000);
46+
timeout.restart();
47+
timeout.clear();
48+
expect(timeout.signal()).to.be.undefined;
49+
}
50+
51+
@test
52+
async testAbortCondition() {
53+
const timeout = new Timeout(1, () => false); // will never trigger abort
54+
timeout.start();
55+
await new Promise((resolve) => setTimeout(resolve, 50));
56+
expect(timeout.signal()).to.not.be.undefined;
57+
expect(timeout.signal()?.aborted).to.be.false;
58+
}
59+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
/**
8+
* A restartable timeout, based on an AbortController + setTimeout.
9+
*
10+
* Note: When cleared/reset, the AbortController is _NOT_ aborted.
11+
*/
12+
export class Timeout {
13+
private _timer: NodeJS.Timeout | undefined;
14+
private _abortController: AbortController | undefined;
15+
16+
/**
17+
* @param timeout The timeout in milliseconds.
18+
* @param abortCondition An optional condition evaluated on timeout: If set, the abort is only emitted if it evaluates to true.
19+
*/
20+
constructor(readonly timeout: number, readonly abortCondition?: () => boolean) {}
21+
22+
/**
23+
* Starts a new timeout (and clears the old one). Identical to `reset`.
24+
*/
25+
public start() {
26+
this.restart();
27+
}
28+
29+
/**
30+
* Starts a new timeout (and clears the old one).
31+
*/
32+
public restart() {
33+
this.clear();
34+
35+
const abortController = new AbortController();
36+
this._abortController = abortController;
37+
this._timer = setTimeout(() => {
38+
if (this.abortCondition && !this.abortCondition()) {
39+
return;
40+
}
41+
42+
abortController.abort();
43+
}, this.timeout);
44+
}
45+
46+
/**
47+
* Clears the current timeout.
48+
*/
49+
public clear() {
50+
if (this._timer) {
51+
clearTimeout(this._timer);
52+
this._timer = undefined;
53+
}
54+
if (this._abortController) {
55+
this._abortController = undefined;
56+
}
57+
}
58+
59+
/**
60+
* Convenience method to await the timeout.
61+
* @returns
62+
*/
63+
public async await(): Promise<boolean> {
64+
const abortController = this._abortController;
65+
if (!abortController) {
66+
return false;
67+
}
68+
69+
return new Promise((resolve) => {
70+
abortController.signal.addEventListener("abort", () => resolve(true));
71+
});
72+
}
73+
74+
/**
75+
* @returns The AbortSignal of the current timeout, if one is active.
76+
*/
77+
public signal(): AbortSignal | undefined {
78+
return this._abortController?.signal;
79+
}
80+
}

components/gitpod-protocol/tsconfig.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@
1616
"es6",
1717
"es2018",
1818
"dom",
19-
"ES2018.Regexp",
20-
"DOM.AsyncIterable"
19+
"ES2018.Regexp"
2120
],
2221
"sourceMap": true,
2322
"declaration": true,

components/supervisor/frontend/src/ide/supervisor-service-client.ts

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,17 @@ import {
1111
} from "@gitpod/supervisor-api-grpc/lib/status_pb";
1212
import { WorkspaceInfoResponse } from "@gitpod/supervisor-api-grpc/lib/info_pb";
1313
import { workspaceUrl } from "../shared/urls";
14+
import { FrontendDashboardServiceClient } from "../shared/frontend-dashboard-service";
15+
import { Timeout } from "@gitpod/gitpod-protocol/lib/util/timeout";
1416

1517
export class SupervisorServiceClient {
16-
private static _instance: SupervisorServiceClient | undefined;
17-
static get(): SupervisorServiceClient {
18-
if (!SupervisorServiceClient._instance) {
19-
SupervisorServiceClient._instance = new SupervisorServiceClient();
20-
}
21-
return SupervisorServiceClient._instance;
22-
}
23-
2418
readonly supervisorReady = this.checkReady("supervisor");
2519
readonly ideReady = this.supervisorReady.then(() => this.checkReady("ide"));
2620
readonly contentReady = Promise.all([this.supervisorReady]).then(() => this.checkReady("content"));
2721
readonly getWorkspaceInfoPromise = this.supervisorReady.then(() => this.getWorkspaceInfo());
2822
private _supervisorWillShutdown: Promise<void> | undefined;
2923

30-
private constructor() {}
24+
constructor(readonly serviceClient: FrontendDashboardServiceClient) {}
3125

3226
public get supervisorWillShutdown() {
3327
if (!this._supervisorWillShutdown) {
@@ -84,13 +78,44 @@ export class SupervisorServiceClient {
8478
if (kind == "supervisor") {
8579
wait = "";
8680
}
81+
82+
// track whenever a) we are done, or b) we try to connect (again)
83+
const trackCheckReady = (p: { aborted?: boolean }, err?: any): void => {
84+
const props: Record<string, string> = {
85+
component: "supervisor-frontend",
86+
instanceId: this.serviceClient.latestInfo?.instanceId ?? "",
87+
userId: this.serviceClient.latestInfo?.loggedUserId ?? "",
88+
readyKind: kind,
89+
};
90+
if (err) {
91+
props.errorName = err.name;
92+
props.errorStack = err.message ?? String(err);
93+
}
94+
95+
props.aborted = String(!!p.aborted);
96+
props.wait = wait;
97+
98+
this.serviceClient.trackEvent({
99+
event: "supervisor_check_ready",
100+
properties: props,
101+
});
102+
};
103+
104+
// setup a timeout, which is meant to re-establish the connection every 5 seconds
105+
let isError = false;
106+
const timeout = new Timeout(5000, () => this.serviceClient.isCheckReadyRetryEnabled());
87107
try {
108+
timeout.restart();
109+
88110
const wsSupervisorStatusUrl = workspaceUrl.with(() => {
89111
return {
90112
pathname: "/_supervisor/v1/status/" + kind + wait,
91113
};
92114
});
93-
const response = await fetch(wsSupervisorStatusUrl.toString(), { credentials: "include" });
115+
const response = await fetch(wsSupervisorStatusUrl.toString(), {
116+
credentials: "include",
117+
signal: timeout.signal(),
118+
});
94119
let result;
95120
if (response.ok) {
96121
result = await response.json();
@@ -112,6 +137,16 @@ export class SupervisorServiceClient {
112137
);
113138
} catch (e) {
114139
console.debug(`failed to check whether ${kind} is ready, trying again...`, e);
140+
141+
// we want to track this kind of errors, as they are on the critical path (of revealing the workspace)
142+
isError = true;
143+
trackCheckReady({ aborted: timeout.signal()?.aborted }, e);
144+
} finally {
145+
if (!isError) {
146+
// make sure we don't track twice in case of an error
147+
trackCheckReady({ aborted: timeout.signal()?.aborted });
148+
}
149+
timeout.clear();
115150
}
116151
return this.checkReady(kind, true);
117152
}

components/supervisor/frontend/src/index.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,9 @@ LoadingFrame.load().then(async (loading) => {
8585
window.gitpod.encrypt = frontendDashboardServiceClient.encrypt.bind(frontendDashboardServiceClient);
8686
window.gitpod.isEncryptedData = frontendDashboardServiceClient.isEncryptedData.bind(frontendDashboardServiceClient);
8787

88-
(async () => {
89-
const supervisorServiceClient = SupervisorServiceClient.get();
88+
const supervisorServiceClient = new SupervisorServiceClient(frontendDashboardServiceClient);
9089

90+
(async () => {
9191
let hideDesktopIde = false;
9292
const hideDesktopIdeEventListener = frontendDashboardServiceClient.onOpenBrowserIDE(() => {
9393
hideDesktopIdeEventListener.dispose();
@@ -271,7 +271,6 @@ LoadingFrame.load().then(async (loading) => {
271271
});
272272
});
273273
}
274-
const supervisorServiceClient = SupervisorServiceClient.get();
275274
if (debugWorkspace) {
276275
supervisorServiceClient.supervisorWillShutdown.then(() => {
277276
window.open("", "_self")?.close();

components/supervisor/frontend/src/shared/frontend-dashboard-service.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export class FrontendDashboardServiceClient implements IDEFrontendDashboardServi
2626

2727
private resolveInit!: () => void;
2828
private initPromise = new Promise<void>((resolve) => (this.resolveInit = resolve));
29+
private featureFlags: Partial<IDEFrontendDashboardService.FeatureFlagsUpdateEventData["flags"]> = {};
2930

3031
private version?: number;
3132

@@ -59,7 +60,11 @@ export class FrontendDashboardServiceClient implements IDEFrontendDashboardServi
5960
if (IDEFrontendDashboardService.isOpenBrowserIDE(event.data)) {
6061
this.onOpenBrowserIDEEmitter.fire(undefined);
6162
}
63+
if (IDEFrontendDashboardService.isFeatureFlagsUpdateEventData(event.data)) {
64+
this.featureFlags = event.data.flags;
65+
}
6266
});
67+
this.requestFreshFeatureFlags();
6368
}
6469
initialize(): Promise<void> {
6570
return this.initPromise;
@@ -140,6 +145,17 @@ export class FrontendDashboardServiceClient implements IDEFrontendDashboardServi
140145
serverUrl.url.origin,
141146
);
142147
}
148+
149+
requestFreshFeatureFlags(): void {
150+
window.postMessage(
151+
{ type: "ide-feature-flag-request" } as IDEFrontendDashboardService.FeatureFlagsRequestEventData,
152+
serverUrl.url.origin,
153+
);
154+
}
155+
156+
isCheckReadyRetryEnabled(): boolean {
157+
return !!this.featureFlags.supervisor_check_ready_retry;
158+
}
143159
}
144160

145161
function isSerializedEncryptedData(obj: any): obj is { iv: string; encrypted: string; tag: string } {

components/ws-proxy/pkg/proxy/pass.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,12 @@ func withHTTPErrorHandler(h http.Handler) proxyPassOpt {
210210
}
211211
}
212212

213+
func withErrorHandler(h errorHandler) proxyPassOpt {
214+
return func(cfg *proxyPassConfig) {
215+
cfg.ErrorHandler = h
216+
}
217+
}
218+
213219
func createDefaultTransport(config *TransportConfig) *http.Transport {
214220
// TODO equivalent of client_max_body_size 2048m; necessary ???
215221
// this is based on http.DefaultTransport, with some values exposed to config

0 commit comments

Comments
 (0)