Merge pull request #3373 from github/koesie10/download-timeout

Add timeouts to downloading databases and the CLI
This commit is contained in:
Koen Vlaswinkel
2024-02-22 13:39:38 +01:00
committed by GitHub
7 changed files with 188 additions and 24 deletions

View File

@@ -3,6 +3,7 @@
## [UNRELEASED] ## [UNRELEASED]
- Remove support for CodeQL CLI versions older than 2.13.5. [#3371](https://github.com/github/vscode-codeql/pull/3371) - Remove support for CodeQL CLI versions older than 2.13.5. [#3371](https://github.com/github/vscode-codeql/pull/3371)
- Add a timeout to downloading databases and the CodeQL CLI. These can be changed using the `codeQL.addingDatabases.downloadTimeout` and `codeQL.cli.downloadTimeout` settings respectively. [#3373](https://github.com/github/vscode-codeql/pull/3373)
## 1.12.2 - 14 February 2024 ## 1.12.2 - 14 February 2024

View File

@@ -178,6 +178,11 @@
"type": "string", "type": "string",
"default": "", "default": "",
"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)." "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)."
},
"codeQL.cli.downloadTimeout": {
"type": "integer",
"default": 10,
"description": "Download timeout in seconds for downloading the CLI distribution."
} }
} }
}, },
@@ -376,6 +381,11 @@
"title": "Adding databases", "title": "Adding databases",
"order": 6, "order": 6,
"properties": { "properties": {
"codeQL.addingDatabases.downloadTimeout": {
"type": "integer",
"default": 10,
"description": "Download timeout in seconds for adding a CodeQL database."
},
"codeQL.addingDatabases.allowHttp": { "codeQL.addingDatabases.allowHttp": {
"type": "boolean", "type": "boolean",
"default": false, "default": false,

View File

@@ -1,3 +1,4 @@
import type { WriteStream } from "fs";
import { createWriteStream, mkdtemp, pathExists, remove } from "fs-extra"; import { createWriteStream, mkdtemp, pathExists, remove } from "fs-extra";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { delimiter, dirname, join } from "path"; import { delimiter, dirname, join } from "path";
@@ -26,6 +27,8 @@ import { unzipToDirectoryConcurrently } from "../common/unzip-concurrently";
import { reportUnzipProgress } from "../common/vscode/unzip-progress"; import { reportUnzipProgress } from "../common/vscode/unzip-progress";
import type { Release } from "./distribution/release"; import type { Release } from "./distribution/release";
import { ReleasesApiConsumer } from "./distribution/releases-api-consumer"; import { ReleasesApiConsumer } from "./distribution/releases-api-consumer";
import { createTimeoutSignal } from "../common/fetch-stream";
import { AbortError } from "node-fetch";
/** /**
* distribution.ts * distribution.ts
@@ -384,15 +387,25 @@ class ExtensionSpecificDistributionManager {
); );
} }
const assetStream = const {
await this.createReleasesApiConsumer().streamBinaryContentOfAsset( signal,
assets[0], onData,
); dispose: disposeTimeout,
} = createTimeoutSignal(this.config.downloadTimeout);
const tmpDirectory = await mkdtemp(join(tmpdir(), "vscode-codeql")); const tmpDirectory = await mkdtemp(join(tmpdir(), "vscode-codeql"));
let archiveFile: WriteStream | undefined = undefined;
try { try {
const assetStream =
await this.createReleasesApiConsumer().streamBinaryContentOfAsset(
assets[0],
signal,
);
const archivePath = join(tmpDirectory, "distributionDownload.zip"); const archivePath = join(tmpDirectory, "distributionDownload.zip");
const archiveFile = createWriteStream(archivePath); archiveFile = createWriteStream(archivePath);
const contentLength = assetStream.headers.get("content-length"); const contentLength = assetStream.headers.get("content-length");
const totalNumBytes = contentLength const totalNumBytes = contentLength
@@ -405,12 +418,23 @@ class ExtensionSpecificDistributionManager {
progressCallback, progressCallback,
); );
await new Promise((resolve, reject) => assetStream.body.on("data", onData);
await new Promise((resolve, reject) => {
if (!archiveFile) {
throw new Error("Invariant violation: archiveFile not set");
}
assetStream.body assetStream.body
.pipe(archiveFile) .pipe(archiveFile)
.on("finish", resolve) .on("finish", resolve)
.on("error", reject), .on("error", reject);
);
// If an error occurs on the body, we also want to reject the promise (e.g. during a timeout error).
assetStream.body.on("error", reject);
});
disposeTimeout();
await this.bumpDistributionFolderIndex(); await this.bumpDistributionFolderIndex();
@@ -427,7 +451,19 @@ class ExtensionSpecificDistributionManager {
) )
: undefined, : undefined,
); );
} catch (e) {
if (e instanceof AbortError) {
const thrownError = new AbortError("The download timed out.");
thrownError.stack = e.stack;
throw thrownError;
}
throw e;
} finally { } finally {
disposeTimeout();
archiveFile?.close();
await remove(tmpDirectory); await remove(tmpDirectory);
} }
} }

View File

@@ -90,21 +90,28 @@ export class ReleasesApiConsumer {
public async streamBinaryContentOfAsset( public async streamBinaryContentOfAsset(
asset: ReleaseAsset, asset: ReleaseAsset,
signal?: AbortSignal,
): Promise<Response> { ): Promise<Response> {
const apiPath = `/repos/${this.repositoryNwo}/releases/assets/${asset.id}`; const apiPath = `/repos/${this.repositoryNwo}/releases/assets/${asset.id}`;
return await this.makeApiCall(apiPath, { return await this.makeApiCall(
accept: "application/octet-stream", apiPath,
}); {
accept: "application/octet-stream",
},
signal,
);
} }
protected async makeApiCall( protected async makeApiCall(
apiPath: string, apiPath: string,
additionalHeaders: { [key: string]: string } = {}, additionalHeaders: { [key: string]: string } = {},
signal?: AbortSignal,
): Promise<Response> { ): Promise<Response> {
const response = await this.makeRawRequest( const response = await this.makeRawRequest(
ReleasesApiConsumer.apiBase + apiPath, ReleasesApiConsumer.apiBase + apiPath,
Object.assign({}, this.defaultHeaders, additionalHeaders), Object.assign({}, this.defaultHeaders, additionalHeaders),
signal,
); );
if (!response.ok) { if (!response.ok) {
@@ -129,11 +136,13 @@ export class ReleasesApiConsumer {
private async makeRawRequest( private async makeRawRequest(
requestUrl: string, requestUrl: string,
headers: { [key: string]: string }, headers: { [key: string]: string },
signal?: AbortSignal,
redirectCount = 0, redirectCount = 0,
): Promise<Response> { ): Promise<Response> {
const response = await fetch(requestUrl, { const response = await fetch(requestUrl, {
headers, headers,
redirect: "manual", redirect: "manual",
signal,
}); });
const redirectUrl = response.headers.get("location"); const redirectUrl = response.headers.get("location");
@@ -153,7 +162,12 @@ export class ReleasesApiConsumer {
// mechanism is provided. // mechanism is provided.
delete headers["authorization"]; delete headers["authorization"];
} }
return await this.makeRawRequest(redirectUrl, headers, redirectCount + 1); return await this.makeRawRequest(
redirectUrl,
headers,
signal,
redirectCount + 1,
);
} }
return response; return response;

View File

@@ -0,0 +1,36 @@
import { clearTimeout } from "node:timers";
export function createTimeoutSignal(timeoutSeconds: number): {
signal: AbortSignal;
onData: () => void;
dispose: () => void;
} {
const timeout = timeoutSeconds * 1000;
const abortController = new AbortController();
let timeoutId: NodeJS.Timeout;
// If we don't get any data within the timeout, abort the download
timeoutId = setTimeout(() => {
abortController.abort();
}, timeout);
// If we receive any data within the timeout, reset the timeout
const onData = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
abortController.abort();
}, timeout);
};
const dispose = () => {
clearTimeout(timeoutId);
};
return {
signal: abortController.signal,
onData,
dispose,
};
}

View File

@@ -93,6 +93,10 @@ const PERSONAL_ACCESS_TOKEN_SETTING = new Setting(
"personalAccessToken", "personalAccessToken",
DISTRIBUTION_SETTING, DISTRIBUTION_SETTING,
); );
const CLI_DOWNLOAD_TIMEOUT_SETTING = new Setting(
"downloadTimeout",
DISTRIBUTION_SETTING,
);
const CLI_CHANNEL_SETTING = new Setting("channel", DISTRIBUTION_SETTING); const CLI_CHANNEL_SETTING = new Setting("channel", DISTRIBUTION_SETTING);
// Query History configuration // Query History configuration
@@ -118,6 +122,7 @@ export interface DistributionConfig {
updateCustomCodeQlPath: (newPath: string | undefined) => Promise<void>; updateCustomCodeQlPath: (newPath: string | undefined) => Promise<void>;
includePrerelease: boolean; includePrerelease: boolean;
personalAccessToken?: string; personalAccessToken?: string;
downloadTimeout: number;
channel: CLIChannel; channel: CLIChannel;
onDidChangeConfiguration?: Event<void>; onDidChangeConfiguration?: Event<void>;
} }
@@ -272,6 +277,10 @@ export class DistributionConfigListener
return PERSONAL_ACCESS_TOKEN_SETTING.getValue() || undefined; return PERSONAL_ACCESS_TOKEN_SETTING.getValue() || undefined;
} }
public get downloadTimeout(): number {
return CLI_DOWNLOAD_TIMEOUT_SETTING.getValue() || 10;
}
public async updateCustomCodeQlPath(newPath: string | undefined) { public async updateCustomCodeQlPath(newPath: string | undefined) {
await CUSTOM_CODEQL_PATH_SETTING.updateValue( await CUSTOM_CODEQL_PATH_SETTING.updateValue(
newPath, newPath,
@@ -644,8 +653,16 @@ const DEPRECATED_ALLOW_HTTP_SETTING = new Setting(
const ADDING_DATABASES_SETTING = new Setting("addingDatabases", ROOT_SETTING); const ADDING_DATABASES_SETTING = new Setting("addingDatabases", ROOT_SETTING);
const DOWNLOAD_TIMEOUT_SETTING = new Setting(
"downloadTimeout",
ADDING_DATABASES_SETTING,
);
const ALLOW_HTTP_SETTING = new Setting("allowHttp", ADDING_DATABASES_SETTING); const ALLOW_HTTP_SETTING = new Setting("allowHttp", ADDING_DATABASES_SETTING);
export function downloadTimeout(): number {
return DOWNLOAD_TIMEOUT_SETTING.getValue<number>() || 10;
}
export function allowHttp(): boolean { export function allowHttp(): boolean {
return ( return (
ALLOW_HTTP_SETTING.getValue<boolean>() || ALLOW_HTTP_SETTING.getValue<boolean>() ||

View File

@@ -1,5 +1,5 @@
import type { Response } from "node-fetch"; import type { Response } from "node-fetch";
import fetch from "node-fetch"; import fetch, { AbortError } from "node-fetch";
import { zip } from "zip-a-folder"; import { zip } from "zip-a-folder";
import type { InputBoxOptions } from "vscode"; import type { InputBoxOptions } from "vscode";
import { Uri, window } from "vscode"; import { Uri, window } from "vscode";
@@ -28,11 +28,16 @@ import {
} from "../common/github-url-identifier-helper"; } from "../common/github-url-identifier-helper";
import type { Credentials } from "../common/authentication"; import type { Credentials } from "../common/authentication";
import type { AppCommandManager } from "../common/commands"; import type { AppCommandManager } from "../common/commands";
import { addDatabaseSourceToWorkspace, allowHttp } from "../config"; import {
addDatabaseSourceToWorkspace,
allowHttp,
downloadTimeout,
} from "../config";
import { showAndLogInformationMessage } from "../common/logging"; import { showAndLogInformationMessage } from "../common/logging";
import { AppOctokit } from "../common/octokit"; import { AppOctokit } from "../common/octokit";
import { getLanguageDisplayName } from "../common/query-language"; import { getLanguageDisplayName } from "../common/query-language";
import type { DatabaseOrigin } from "./local-databases/database-origin"; import type { DatabaseOrigin } from "./local-databases/database-origin";
import { createTimeoutSignal } from "../common/fetch-stream";
/** /**
* Prompts a user to fetch a database from a remote location. Database is assumed to be an archive file. * Prompts a user to fetch a database from a remote location. Database is assumed to be an archive file.
@@ -478,10 +483,33 @@ async function fetchAndUnzip(
step: 1, step: 1,
}); });
const response = await checkForFailingResponse( const {
await fetch(databaseUrl, { headers: requestHeaders }), signal,
"Error downloading database", onData,
); dispose: disposeTimeout,
} = createTimeoutSignal(downloadTimeout());
let response: Response;
try {
response = await checkForFailingResponse(
await fetch(databaseUrl, {
headers: requestHeaders,
signal,
}),
"Error downloading database",
);
} catch (e) {
disposeTimeout();
if (e instanceof AbortError) {
const thrownError = new AbortError("The request timed out.");
thrownError.stack = e.stack;
throw thrownError;
}
throw e;
}
const archiveFileStream = createWriteStream(archivePath); const archiveFileStream = createWriteStream(archivePath);
const contentLength = response.headers.get("content-length"); const contentLength = response.headers.get("content-length");
@@ -493,12 +521,34 @@ async function fetchAndUnzip(
progress, progress,
); );
await new Promise((resolve, reject) => response.body.on("data", onData);
response.body
.pipe(archiveFileStream) try {
.on("finish", resolve) await new Promise((resolve, reject) => {
.on("error", reject), response.body
); .pipe(archiveFileStream)
.on("finish", resolve)
.on("error", reject);
// If an error occurs on the body, we also want to reject the promise (e.g. during a timeout error).
response.body.on("error", reject);
});
} catch (e) {
// Close and remove the file if an error occurs
archiveFileStream.close(() => {
void remove(archivePath);
});
if (e instanceof AbortError) {
const thrownError = new AbortError("The download timed out.");
thrownError.stack = e.stack;
throw thrownError;
}
throw e;
} finally {
disposeTimeout();
}
await readAndUnzip( await readAndUnzip(
Uri.file(archivePath).toString(true), Uri.file(archivePath).toString(true),