Skip to content

Commit fbc0d76

Browse files
Prebuild issues roundup (#20000)
* redirect in the offline case * [public-api] generate noise * [server] Refactor Prebuild resolution to happen in a single place * [dashboard, api] Show Prebuild details * fixup! [server] Refactor Prebuild resolution to happen in a single place * [bridge] Revive Prebuild QUEUED state: it's everything before running * [server] Fix dead-end for streaming logs when starting too early * [dashboard] Adds SHA + duration, fix re-rendering when prebuildId changes, and uses the new/old streaming logic WIP because still has the "duplicate (sometimes triple!) logs" react re-rendering issue * Prevent unnecessary re-renders of task output * remove double-comment * Duration display improvements * Properly dismiss toasts and render durations * Remove SHA from prebuild list * Clean up and implement the `reset` event for workspace logs * fix comment * Tiny cleanup --------- Co-authored-by: Filip Troníček <[email protected]>
1 parent 42159b8 commit fbc0d76

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+1344
-1006
lines changed

components/dashboard/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,6 @@
9696
"postcss": "^8.4.38",
9797
"react-scripts": "^5.0.1",
9898
"tailwind-underline-utils": "^1.1.2",
99-
"tailwindcss": "^3.4.3",
10099
"tailwindcss-filters": "^3.0.0",
101100
"typescript": "^5.4.5",
102101
"web-vitals": "^1.1.1"

components/dashboard/src/components/PrebuildLogs.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ function watchHeadlessLogs(
263263
}
264264

265265
const streamUrl = logSources.streams[streamIds[0]];
266-
console.log("fetching from streamUrl: " + streamUrl);
266+
console.debug("fetching from streamUrl: " + streamUrl);
267267
response = await fetch(streamUrl, {
268268
method: "GET",
269269
cache: "no-cache",

components/dashboard/src/components/WorkspaceLogs.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,18 @@ export default function WorkspaceLogs(props: WorkspaceLogsProps) {
6060
}
6161
};
6262

63+
const resetListener = () => {
64+
terminal.clear();
65+
};
66+
6367
const emitter = props.logsEmitter.on("logs", logListener);
68+
emitter.on("reset", resetListener);
6469
fitAddon.fit();
6570

6671
return () => {
6772
terminal.dispose();
6873
emitter.removeListener("logs", logListener);
74+
emitter.removeListener("reset", resetListener);
6975
};
7076
// eslint-disable-next-line react-hooks/exhaustive-deps
7177
}, [props.logsEmitter]);

components/dashboard/src/data/prebuilds/prebuild-logs-emitter.ts

Lines changed: 189 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -4,66 +4,82 @@
44
* See License.AGPL.txt in the project root for license information.
55
*/
66

7-
import { prebuildClient } from "../../service/public-api";
8-
import { useEffect, useMemo, useState } from "react";
9-
import { matchPrebuildError, onDownloadPrebuildLogsUrl } from "@gitpod/public-api-common/lib/prebuild-utils";
7+
import { useEffect, useMemo } from "react";
8+
import { matchPrebuildError } from "@gitpod/public-api-common/lib/prebuild-utils";
109
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
10+
import { Disposable, DisposableCollection, HEADLESS_LOG_STREAM_STATUS_CODE_REGEX } from "@gitpod/gitpod-protocol";
11+
import { Prebuild, PrebuildPhase_Phase } from "@gitpod/public-api/lib/gitpod/v1/prebuild_pb";
12+
import { PlainMessage } from "@bufbuild/protobuf";
1113
import { ReplayableEventEmitter } from "../../utils";
12-
import { Disposable } from "@gitpod/gitpod-protocol";
1314

1415
type LogEventTypes = {
1516
error: [Error];
1617
logs: [string];
1718
"logs-error": [ApplicationError];
19+
reset: [];
1820
};
1921

2022
/**
2123
* Watches the logs of a prebuild task by returning an EventEmitter that emits logs, logs-error, and error events.
2224
* @param prebuildId ID of the prebuild to watch
2325
* @param taskId ID of the task to watch.
2426
*/
25-
export function usePrebuildLogsEmitter(prebuildId: string, taskId: string) {
27+
export function usePrebuildLogsEmitter(prebuild: PlainMessage<Prebuild>, taskId: string) {
2628
const emitter = useMemo(
2729
() => new ReplayableEventEmitter<LogEventTypes>(),
2830
// We would like to re-create the emitter when the prebuildId or taskId changes, so that logs of old tasks / prebuilds are not mixed with the new ones.
2931
// eslint-disable-next-line react-hooks/exhaustive-deps
30-
[prebuildId, taskId],
32+
[prebuild.id, taskId],
3133
);
32-
const [disposable, setDisposable] = useState<Disposable | undefined>();
3334

34-
useEffect(() => {
35-
// The abortcontroller is meant to abort all activity on unmounting this effect
36-
const abortController = new AbortController();
37-
const watch = async () => {
38-
let dispose: () => void | undefined;
39-
abortController.signal.addEventListener("abort", () => {
40-
dispose?.();
41-
});
35+
const shouldFetchLogs = useMemo<boolean>(() => {
36+
const phase = prebuild.status?.phase?.name;
37+
if (phase === PrebuildPhase_Phase.QUEUED && taskId === "image-build") {
38+
return true;
39+
}
40+
switch (phase) {
41+
case PrebuildPhase_Phase.QUEUED:
42+
case PrebuildPhase_Phase.UNSPECIFIED:
43+
return false;
44+
// This is the online case: we do the actual streaming
45+
// All others below are terminal states, where we get re-directed to the logs stored in content-service
46+
case PrebuildPhase_Phase.BUILDING:
47+
case PrebuildPhase_Phase.AVAILABLE:
48+
case PrebuildPhase_Phase.FAILED:
49+
case PrebuildPhase_Phase.ABORTED:
50+
case PrebuildPhase_Phase.TIMEOUT:
51+
return true;
52+
}
4253

43-
const { prebuild } = await prebuildClient.getPrebuild({ prebuildId });
44-
if (!prebuild) {
45-
throw new ApplicationError(ErrorCodes.NOT_FOUND, `Prebuild ${prebuildId} not found`);
46-
}
54+
return false;
55+
}, [prebuild.status?.phase?.name, taskId]);
4756

48-
const task = {
49-
taskId,
50-
logUrl: "",
51-
};
52-
if (taskId === "image-build") {
53-
if (!prebuild.status?.imageBuildLogUrl) {
54-
throw new ApplicationError(ErrorCodes.NOT_FOUND, `Image build logs URL not found in response`);
55-
}
56-
task.logUrl = prebuild.status?.imageBuildLogUrl;
57-
} else {
58-
const logUrl = prebuild?.status?.taskLogs?.find((log) => log.taskId === taskId)?.logUrl;
59-
if (!logUrl) {
60-
throw new ApplicationError(ErrorCodes.NOT_FOUND, `Task ${taskId} not found`);
61-
}
57+
useEffect(() => {
58+
if (!shouldFetchLogs || emitter.hasReachedEnd()) {
59+
return;
60+
}
6261

63-
task.logUrl = logUrl;
62+
const task = {
63+
taskId,
64+
logUrl: "",
65+
};
66+
if (taskId === "image-build") {
67+
if (!prebuild.status?.imageBuildLogUrl) {
68+
throw new ApplicationError(ErrorCodes.NOT_FOUND, `Image build logs URL not found in response`);
69+
}
70+
task.logUrl = prebuild.status?.imageBuildLogUrl;
71+
} else {
72+
const logUrl = prebuild?.status?.taskLogs?.find((log) => log.taskId === taskId)?.logUrl;
73+
if (!logUrl) {
74+
throw new ApplicationError(ErrorCodes.NOT_FOUND, `Task ${taskId} not found`);
6475
}
6576

66-
dispose = onDownloadPrebuildLogsUrl(
77+
task.logUrl = logUrl;
78+
}
79+
80+
const disposables = new DisposableCollection();
81+
disposables.push(
82+
streamPrebuildLogs(
6783
task.logUrl,
6884
(msg) => {
6985
const error = matchPrebuildError(msg);
@@ -73,32 +89,148 @@ export function usePrebuildLogsEmitter(prebuildId: string, taskId: string) {
7389
emitter.emit("logs-error", error);
7490
}
7591
},
76-
{
77-
includeCredentials: true,
78-
maxBackoffTimes: 3,
79-
onEnd: () => {},
92+
async () => false,
93+
() => {
94+
emitter.markReachedEnd();
8095
},
81-
);
82-
};
83-
watch()
84-
.then(() => {})
85-
.catch((err) => {
86-
emitter.emit("error", err);
87-
});
88-
89-
// The Disposable is meant as to give clients a way to stop watching logs before the component is unmounted. As such it decouples the individual AbortControllers that might get re-created multiple times.
90-
setDisposable(
91-
Disposable.create(() => {
92-
abortController.abort();
93-
}),
96+
),
9497
);
9598

9699
return () => {
97-
abortController.abort();
98-
emitter.clearLog();
99-
emitter.removeAllListeners();
100+
disposables.dispose();
101+
if (!emitter.hasReachedEnd()) {
102+
// If we haven't finished yet, but the page is re-rendered, clear the output we already got.
103+
emitter.emit("reset");
104+
}
105+
};
106+
// eslint-disable-next-line react-hooks/exhaustive-deps
107+
}, [emitter, prebuild.id, taskId, shouldFetchLogs]);
108+
109+
return { emitter };
110+
}
111+
112+
function streamPrebuildLogs(
113+
streamUrl: string,
114+
onLog: (chunk: string) => void,
115+
checkIsDone: () => Promise<boolean>,
116+
onEnd?: () => void,
117+
): DisposableCollection {
118+
const disposables = new DisposableCollection();
119+
120+
// initializing non-empty here to use this as a stopping signal for the retries down below
121+
disposables.push(Disposable.NULL);
122+
123+
// retry configuration goes here
124+
const initialDelaySeconds = 1;
125+
const backoffFactor = 1.2;
126+
const maxBackoffSeconds = 5;
127+
let delayInSeconds = initialDelaySeconds;
128+
129+
const startWatchingLogs = async () => {
130+
if (await checkIsDone()) {
131+
return;
132+
}
133+
134+
const retryBackoff = async (reason: string, err?: Error) => {
135+
delayInSeconds = Math.min(delayInSeconds * backoffFactor, maxBackoffSeconds);
136+
137+
console.debug("re-trying headless-logs because: " + reason, err);
138+
await new Promise((resolve) => {
139+
setTimeout(resolve, delayInSeconds * 1000);
140+
});
141+
if (disposables.disposed) {
142+
return; // and stop retrying
143+
}
144+
startWatchingLogs().catch(console.error);
100145
};
101-
}, [emitter, prebuildId, taskId]);
102146

103-
return { emitter, disposable };
147+
let response: Response | undefined = undefined;
148+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined = undefined;
149+
try {
150+
console.debug("fetching from streamUrl: " + streamUrl);
151+
response = await fetch(streamUrl, {
152+
method: "GET",
153+
cache: "no-cache",
154+
credentials: "include",
155+
keepalive: true,
156+
headers: {
157+
TE: "trailers", // necessary to receive stream status code
158+
},
159+
redirect: "follow",
160+
});
161+
reader = response.body?.getReader();
162+
if (!reader) {
163+
await retryBackoff("no reader");
164+
return;
165+
}
166+
disposables.push({ dispose: () => reader?.cancel() });
167+
168+
const decoder = new TextDecoder("utf-8");
169+
let chunk = await reader.read();
170+
while (!chunk.done) {
171+
const msg = decoder.decode(chunk.value, { stream: true });
172+
173+
// In an ideal world, we'd use res.addTrailers()/response.trailer here. But despite being introduced with HTTP/1.1 in 1999, trailers are not supported by popular proxies (nginx, for example).
174+
// So we resort to this hand-written solution:
175+
const matches = msg.match(HEADLESS_LOG_STREAM_STATUS_CODE_REGEX);
176+
const prebuildMatches = matchPrebuildError(msg);
177+
if (matches) {
178+
if (matches.length < 2) {
179+
console.debug("error parsing log stream status code. msg: " + msg);
180+
} else {
181+
const code = parseStatusCode(matches[1]);
182+
if (code !== 200) {
183+
throw new StreamError(code);
184+
}
185+
}
186+
} else if (prebuildMatches && prebuildMatches.code === ErrorCodes.HEADLESS_LOG_NOT_YET_AVAILABLE) {
187+
// reset backoff because this error is expected
188+
delayInSeconds = initialDelaySeconds;
189+
throw prebuildMatches;
190+
} else {
191+
onLog(msg);
192+
}
193+
194+
chunk = await reader.read();
195+
}
196+
reader.cancel();
197+
198+
if (await checkIsDone()) {
199+
return;
200+
}
201+
} catch (err) {
202+
reader?.cancel().catch(console.debug);
203+
if (err.code === 400) {
204+
// sth is really off, and we _should not_ retry
205+
console.error("stopped watching headless logs", err);
206+
return;
207+
}
208+
await retryBackoff("error while listening to stream", err);
209+
} finally {
210+
reader?.cancel().catch(console.debug);
211+
if (onEnd) {
212+
onEnd();
213+
}
214+
}
215+
};
216+
startWatchingLogs().catch(console.error);
217+
218+
return disposables;
219+
}
220+
221+
class StreamError extends Error {
222+
constructor(readonly code?: number) {
223+
super(`stream status code: ${code}`);
224+
}
225+
}
226+
227+
function parseStatusCode(code: string | undefined): number | undefined {
228+
try {
229+
if (!code) {
230+
return undefined;
231+
}
232+
return Number.parseInt(code);
233+
} catch (err) {
234+
return undefined;
235+
}
104236
}

0 commit comments

Comments
 (0)