Skip to content

Commit 1a9c792

Browse files
committed
Extract timeout and apply to it to CLI downloads
1 parent feeb9d6 commit 1a9c792

File tree

6 files changed

+122
-33
lines changed

6 files changed

+122
-33
lines changed

extensions/ql-vscode/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,11 @@
178178
"type": "string",
179179
"default": "",
180180
"markdownDescription": "Path to the CodeQL executable that should be used by the CodeQL extension. The executable is named `codeql` on Linux/Mac and `codeql.exe` on Windows. If empty, the extension will look for a CodeQL executable on your shell PATH, or if CodeQL is not on your PATH, download and manage its own CodeQL executable (note: if you later introduce CodeQL on your PATH, the extension will prefer a CodeQL executable it has downloaded itself)."
181+
},
182+
"codeQL.cli.downloadTimeout": {
183+
"type": "integer",
184+
"default": 10,
185+
"description": "Download timeout in seconds for downloading the CLI distribution."
181186
}
182187
}
183188
},

extensions/ql-vscode/src/codeql-cli/distribution.ts

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { WriteStream } from "fs";
12
import { createWriteStream, mkdtemp, pathExists, remove } from "fs-extra";
23
import { tmpdir } from "os";
34
import { delimiter, dirname, join } from "path";
@@ -26,6 +27,8 @@ import { unzipToDirectoryConcurrently } from "../common/unzip-concurrently";
2627
import { reportUnzipProgress } from "../common/vscode/unzip-progress";
2728
import type { Release } from "./distribution/release";
2829
import { ReleasesApiConsumer } from "./distribution/releases-api-consumer";
30+
import { createTimeoutSignal } from "../common/fetch-stream";
31+
import { AbortError } from "node-fetch";
2932

3033
/**
3134
* distribution.ts
@@ -384,15 +387,25 @@ class ExtensionSpecificDistributionManager {
384387
);
385388
}
386389

387-
const assetStream =
388-
await this.createReleasesApiConsumer().streamBinaryContentOfAsset(
389-
assets[0],
390-
);
390+
const {
391+
signal,
392+
onData,
393+
dispose: disposeTimeout,
394+
} = createTimeoutSignal(this.config.downloadTimeout);
395+
391396
const tmpDirectory = await mkdtemp(join(tmpdir(), "vscode-codeql"));
392397

398+
let archiveFile: WriteStream | undefined = undefined;
399+
393400
try {
401+
const assetStream =
402+
await this.createReleasesApiConsumer().streamBinaryContentOfAsset(
403+
assets[0],
404+
signal,
405+
);
406+
394407
const archivePath = join(tmpDirectory, "distributionDownload.zip");
395-
const archiveFile = createWriteStream(archivePath);
408+
archiveFile = createWriteStream(archivePath);
396409

397410
const contentLength = assetStream.headers.get("content-length");
398411
const totalNumBytes = contentLength
@@ -405,12 +418,23 @@ class ExtensionSpecificDistributionManager {
405418
progressCallback,
406419
);
407420

408-
await new Promise((resolve, reject) =>
421+
assetStream.body.on("data", onData);
422+
423+
await new Promise((resolve, reject) => {
424+
if (!archiveFile) {
425+
throw new Error("Invariant violation: archiveFile not set");
426+
}
427+
409428
assetStream.body
410429
.pipe(archiveFile)
411430
.on("finish", resolve)
412-
.on("error", reject),
413-
);
431+
.on("error", reject);
432+
433+
// If an error occurs on the body, we also want to reject the promise (e.g. during a timeout error).
434+
assetStream.body.on("error", reject);
435+
});
436+
437+
disposeTimeout();
414438

415439
await this.bumpDistributionFolderIndex();
416440

@@ -427,7 +451,19 @@ class ExtensionSpecificDistributionManager {
427451
)
428452
: undefined,
429453
);
454+
} catch (e) {
455+
if (e instanceof AbortError) {
456+
const thrownError = new AbortError("The download timed out.");
457+
thrownError.stack = e.stack;
458+
throw thrownError;
459+
}
460+
461+
throw e;
430462
} finally {
463+
disposeTimeout();
464+
465+
archiveFile?.close();
466+
431467
await remove(tmpDirectory);
432468
}
433469
}

extensions/ql-vscode/src/codeql-cli/distribution/releases-api-consumer.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,21 +90,28 @@ export class ReleasesApiConsumer {
9090

9191
public async streamBinaryContentOfAsset(
9292
asset: ReleaseAsset,
93+
signal?: AbortSignal,
9394
): Promise<Response> {
9495
const apiPath = `/repos/${this.repositoryNwo}/releases/assets/${asset.id}`;
9596

96-
return await this.makeApiCall(apiPath, {
97-
accept: "application/octet-stream",
98-
});
97+
return await this.makeApiCall(
98+
apiPath,
99+
{
100+
accept: "application/octet-stream",
101+
},
102+
signal,
103+
);
99104
}
100105

101106
protected async makeApiCall(
102107
apiPath: string,
103108
additionalHeaders: { [key: string]: string } = {},
109+
signal?: AbortSignal,
104110
): Promise<Response> {
105111
const response = await this.makeRawRequest(
106112
ReleasesApiConsumer.apiBase + apiPath,
107113
Object.assign({}, this.defaultHeaders, additionalHeaders),
114+
signal,
108115
);
109116

110117
if (!response.ok) {
@@ -129,11 +136,13 @@ export class ReleasesApiConsumer {
129136
private async makeRawRequest(
130137
requestUrl: string,
131138
headers: { [key: string]: string },
139+
signal?: AbortSignal,
132140
redirectCount = 0,
133141
): Promise<Response> {
134142
const response = await fetch(requestUrl, {
135143
headers,
136144
redirect: "manual",
145+
signal,
137146
});
138147

139148
const redirectUrl = response.headers.get("location");
@@ -153,7 +162,12 @@ export class ReleasesApiConsumer {
153162
// mechanism is provided.
154163
delete headers["authorization"];
155164
}
156-
return await this.makeRawRequest(redirectUrl, headers, redirectCount + 1);
165+
return await this.makeRawRequest(
166+
redirectUrl,
167+
headers,
168+
signal,
169+
redirectCount + 1,
170+
);
157171
}
158172

159173
return response;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { clearTimeout } from "node:timers";
2+
3+
export function createTimeoutSignal(timeoutSeconds: number): {
4+
signal: AbortSignal;
5+
onData: () => void;
6+
dispose: () => void;
7+
} {
8+
const timeout = timeoutSeconds * 1000;
9+
10+
const abortController = new AbortController();
11+
12+
let timeoutId: NodeJS.Timeout;
13+
14+
// If we don't get any data within the timeout, abort the download
15+
timeoutId = setTimeout(() => {
16+
abortController.abort();
17+
}, timeout);
18+
19+
// If we receive any data within the timeout, reset the timeout
20+
const onData = () => {
21+
clearTimeout(timeoutId);
22+
timeoutId = setTimeout(() => {
23+
abortController.abort();
24+
}, timeout);
25+
};
26+
27+
const dispose = () => {
28+
clearTimeout(timeoutId);
29+
};
30+
31+
return {
32+
signal: abortController.signal,
33+
onData,
34+
dispose,
35+
};
36+
}

extensions/ql-vscode/src/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ const PERSONAL_ACCESS_TOKEN_SETTING = new Setting(
9393
"personalAccessToken",
9494
DISTRIBUTION_SETTING,
9595
);
96+
const CLI_DOWNLOAD_TIMEOUT_SETTING = new Setting(
97+
"downloadTimeout",
98+
DISTRIBUTION_SETTING,
99+
);
96100
const CLI_CHANNEL_SETTING = new Setting("channel", DISTRIBUTION_SETTING);
97101

98102
// Query History configuration
@@ -118,6 +122,7 @@ export interface DistributionConfig {
118122
updateCustomCodeQlPath: (newPath: string | undefined) => Promise<void>;
119123
includePrerelease: boolean;
120124
personalAccessToken?: string;
125+
downloadTimeout: number;
121126
channel: CLIChannel;
122127
onDidChangeConfiguration?: Event<void>;
123128
}
@@ -272,6 +277,10 @@ export class DistributionConfigListener
272277
return PERSONAL_ACCESS_TOKEN_SETTING.getValue() || undefined;
273278
}
274279

280+
public get downloadTimeout(): number {
281+
return CLI_DOWNLOAD_TIMEOUT_SETTING.getValue() || 10;
282+
}
283+
275284
public async updateCustomCodeQlPath(newPath: string | undefined) {
276285
await CUSTOM_CODEQL_PATH_SETTING.updateValue(
277286
newPath,

extensions/ql-vscode/src/databases/database-fetcher.ts

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import { showAndLogInformationMessage } from "../common/logging";
3737
import { AppOctokit } from "../common/octokit";
3838
import { getLanguageDisplayName } from "../common/query-language";
3939
import type { DatabaseOrigin } from "./local-databases/database-origin";
40-
import { clearTimeout } from "node:timers";
40+
import { createTimeoutSignal } from "../common/fetch-stream";
4141

4242
/**
4343
* Prompts a user to fetch a database from a remote location. Database is assumed to be an archive file.
@@ -483,28 +483,23 @@ async function fetchAndUnzip(
483483
step: 1,
484484
});
485485

486-
const abortController = new AbortController();
487-
488-
const timeout = downloadTimeout() * 1000;
489-
490-
let timeoutId: NodeJS.Timeout;
491-
492-
// If we don't get any data within the timeout, abort the download
493-
timeoutId = setTimeout(() => {
494-
abortController.abort();
495-
}, timeout);
486+
const {
487+
signal,
488+
onData,
489+
dispose: disposeTimeout,
490+
} = createTimeoutSignal(downloadTimeout());
496491

497492
let response: Response;
498493
try {
499494
response = await checkForFailingResponse(
500495
await fetch(databaseUrl, {
501496
headers: requestHeaders,
502-
signal: abortController.signal,
497+
signal,
503498
}),
504499
"Error downloading database",
505500
);
506501
} catch (e) {
507-
clearTimeout(timeoutId);
502+
disposeTimeout();
508503

509504
if (e instanceof AbortError) {
510505
const thrownError = new AbortError("The request timed out.");
@@ -526,13 +521,7 @@ async function fetchAndUnzip(
526521
progress,
527522
);
528523

529-
// If we receive any data within the timeout, reset the timeout
530-
response.body.on("data", () => {
531-
clearTimeout(timeoutId);
532-
timeoutId = setTimeout(() => {
533-
abortController.abort();
534-
}, timeout);
535-
});
524+
response.body.on("data", onData);
536525

537526
try {
538527
await new Promise((resolve, reject) => {
@@ -558,7 +547,7 @@ async function fetchAndUnzip(
558547

559548
throw e;
560549
} finally {
561-
clearTimeout(timeoutId);
550+
disposeTimeout();
562551
}
563552

564553
await readAndUnzip(

0 commit comments

Comments
 (0)