Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ See the [releases page](https://github.com/github/codeql-action/releases) for th

## [UNRELEASED]

No user facing changes.
- Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the `init` Action instead of falling back to downloading the bundle before extracting it. [#4061](https://github.com/github/codeql-action/pull/4061)

## 4.37.4 - 29 Jul 2026

Expand Down
360 changes: 186 additions & 174 deletions lib/entry-points.js

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions src/tar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as path from "path";
import * as stream from "stream";

import test from "ava";

import { getRunnerLogger } from "./logging";
import { extractTarZst } from "./tar";
import { setupTests } from "./testing-utils";
import { withTmpDir } from "./util";

setupTests(test);

test("extractTarZst rejects if the input stream errors", async (t) => {
await withTmpDir(async (tmpDir) => {
const archive = new stream.PassThrough();
const promise = extractTarZst(
archive,
path.join(tmpDir, "dest"),
{ type: "gnu", version: "1.34" },
getRunnerLogger(true),
);

archive.destroy(
Object.assign(new Error("socket hang up"), {
code: "ECONNRESET",
}),
);

await t.throwsAsync(promise, {
message: /Error while downloading and extracting tar/,
});
});
});
13 changes: 9 additions & 4 deletions src/tar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,15 @@ export async function extractTarZst(
});

if (tar instanceof stream.Readable) {
tar.pipe(tarProcess.stdin).on("error", (err) => {
reject(
new Error(`Error while downloading and extracting tar: ${err}`),
);
// Use `pipeline` rather than `pipe` so that an error on either stream is reported here
// rather than being emitted as an unhandled `error` event, and so that `tar`'s standard
// input is closed if the download fails partway through.
stream.pipeline(tar, tarProcess.stdin, (err) => {
if (err) {
reject(
new Error(`Error while downloading and extracting tar: ${err}`),
);
}
});
}

Expand Down
37 changes: 37 additions & 0 deletions src/tools-download.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,43 @@ test.serial(
},
);

test.serial(
"downloadAndExtract falls back to downloading before extracting if streaming fails",
async (t) => {
await withTmpDir(async (tmpDir) => {
sinon.stub(process, "platform").value("linux");
const archivePath = path.join(tmpDir, "codeql-bundle.tar.zst");
const destination = path.join(tmpDir, "codeql");
const downloadTool = sinon
.stub(toolcache, "downloadTool")
.resolves(archivePath);
const extract = sinon.stub(tar, "extract").resolves(destination);
const extractTarZst = sinon.stub(tar, "extractTarZst").resolves();
const request = nock("https://example.com")
.get("/codeql-bundle.tar.zst")
.replyWithError(
Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }),
);

const statusReport = await downloadAndExtract(
"https://example.com/codeql-bundle.tar.zst",
"zstd",
destination,
undefined,
{},
{ type: "gnu", version: "1.34" },
getRunnerLogger(true),
);

t.assert(Number.isInteger(statusReport.downloadDurationMs));
t.true(request.isDone());
t.false(extractTarZst.called);
t.true(downloadTool.calledOnce);
t.true(extract.calledOnce);
});
},
);

test.serial(
"downloadAndExtract omits the download duration when streaming extraction",
async (t) => {
Expand Down
28 changes: 24 additions & 4 deletions src/tools-download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ import { cleanUpPath, getErrorMessage, getRequiredEnvParam } from "./util";
*/
const STREAMING_HIGH_WATERMARK_BYTES = 4 * 1024 * 1024; // 4 MiB

/**
* How long the streaming download of the CodeQL tools may stall for before we abort it. This
* applies both to establishing the connection and to gaps between chunks of the response body.
*/
const STREAMING_STALL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes

/**
* The name of the tool cache directory for the CodeQL tools.
*/
Expand Down Expand Up @@ -137,8 +143,8 @@ async function downloadAndExtractZstdWithStreaming(
authorization ? { authorization } : {},
headers,
);
const response = await new Promise<IncomingMessage>((resolve) =>
https.get(
const response = await new Promise<IncomingMessage>((resolve, reject) => {
const request = https.get(
codeqlURL,
{
headers,
Expand All @@ -148,10 +154,24 @@ async function downloadAndExtractZstdWithStreaming(
agent,
} as unknown as RequestOptions,
(r) => resolve(r),
),
);
);
// Without this listener, connection failures such as `ECONNRESET` are emitted as unhandled
// `error` events, which terminate the process instead of letting us fall back to downloading
// the bundle before extracting it. This listener stays attached after the response arrives, so
// it also handles errors that occur while the response is being streamed.
request.on("error", reject);
request.setTimeout(STREAMING_STALL_TIMEOUT_MS, () => {
request.destroy(
new Error(
`No data received for ${formatDuration(STREAMING_STALL_TIMEOUT_MS)}.`,
),
);
});
});

if (response.statusCode !== 200) {
// Discard the response body so that the connection can be released.
response.resume();
throw new Error(
`Failed to download CodeQL bundle from ${codeqlURL}. HTTP status code: ${response.statusCode}.`,
);
Expand Down
Loading