4
4
* See License.AGPL.txt in the project root for license information.
5
5
*/
6
6
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" ;
10
9
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" ;
11
13
import { ReplayableEventEmitter } from "../../utils" ;
12
- import { Disposable } from "@gitpod/gitpod-protocol" ;
13
14
14
15
type LogEventTypes = {
15
16
error : [ Error ] ;
16
17
logs : [ string ] ;
17
18
"logs-error" : [ ApplicationError ] ;
19
+ reset : [ ] ;
18
20
} ;
19
21
20
22
/**
21
23
* Watches the logs of a prebuild task by returning an EventEmitter that emits logs, logs-error, and error events.
22
24
* @param prebuildId ID of the prebuild to watch
23
25
* @param taskId ID of the task to watch.
24
26
*/
25
- export function usePrebuildLogsEmitter ( prebuildId : string , taskId : string ) {
27
+ export function usePrebuildLogsEmitter ( prebuild : PlainMessage < Prebuild > , taskId : string ) {
26
28
const emitter = useMemo (
27
29
( ) => new ReplayableEventEmitter < LogEventTypes > ( ) ,
28
30
// 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.
29
31
// eslint-disable-next-line react-hooks/exhaustive-deps
30
- [ prebuildId , taskId ] ,
32
+ [ prebuild . id , taskId ] ,
31
33
) ;
32
- const [ disposable , setDisposable ] = useState < Disposable | undefined > ( ) ;
33
34
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
+ }
42
53
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 ] ) ;
47
56
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
+ }
62
61
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` ) ;
64
75
}
65
76
66
- dispose = onDownloadPrebuildLogsUrl (
77
+ task . logUrl = logUrl ;
78
+ }
79
+
80
+ const disposables = new DisposableCollection ( ) ;
81
+ disposables . push (
82
+ streamPrebuildLogs (
67
83
task . logUrl ,
68
84
( msg ) => {
69
85
const error = matchPrebuildError ( msg ) ;
@@ -73,32 +89,148 @@ export function usePrebuildLogsEmitter(prebuildId: string, taskId: string) {
73
89
emitter . emit ( "logs-error" , error ) ;
74
90
}
75
91
} ,
76
- {
77
- includeCredentials : true ,
78
- maxBackoffTimes : 3 ,
79
- onEnd : ( ) => { } ,
92
+ async ( ) => false ,
93
+ ( ) => {
94
+ emitter . markReachedEnd ( ) ;
80
95
} ,
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
+ ) ,
94
97
) ;
95
98
96
99
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 ) ;
100
145
} ;
101
- } , [ emitter , prebuildId , taskId ] ) ;
102
146
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
+ }
104
236
}
0 commit comments