Skip to content

Commit 1cd4d01

Browse files
authored
Merge pull request #4061 from github/henrymercer/turbo-system
Handle network errors when streaming the CodeQL bundle download
2 parents d2bfc30 + 155e522 commit 1cd4d01

6 files changed

Lines changed: 290 additions & 183 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ See the [releases page](https://github.com/github/codeql-action/releases) for th
44

55
## [UNRELEASED]
66

7-
No user facing changes.
7+
- 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)
88

99
## 4.37.4 - 29 Jul 2026
1010

lib/entry-points.js

Lines changed: 186 additions & 174 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/tar.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import * as path from "path";
2+
import * as stream from "stream";
3+
4+
import test from "ava";
5+
6+
import { getRunnerLogger } from "./logging";
7+
import { extractTarZst } from "./tar";
8+
import { setupTests } from "./testing-utils";
9+
import { withTmpDir } from "./util";
10+
11+
setupTests(test);
12+
13+
test("extractTarZst rejects if the input stream errors", async (t) => {
14+
await withTmpDir(async (tmpDir) => {
15+
const archive = new stream.PassThrough();
16+
const promise = extractTarZst(
17+
archive,
18+
path.join(tmpDir, "dest"),
19+
{ type: "gnu", version: "1.34" },
20+
getRunnerLogger(true),
21+
);
22+
23+
archive.destroy(
24+
Object.assign(new Error("socket hang up"), {
25+
code: "ECONNRESET",
26+
}),
27+
);
28+
29+
await t.throwsAsync(promise, {
30+
message: /Error while downloading and extracting tar/,
31+
});
32+
});
33+
});

src/tar.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,15 @@ export async function extractTarZst(
194194
});
195195

196196
if (tar instanceof stream.Readable) {
197-
tar.pipe(tarProcess.stdin).on("error", (err) => {
198-
reject(
199-
new Error(`Error while downloading and extracting tar: ${err}`),
200-
);
197+
// Use `pipeline` rather than `pipe` so that an error on either stream is reported here
198+
// rather than being emitted as an unhandled `error` event, and so that `tar`'s standard
199+
// input is closed if the download fails partway through.
200+
stream.pipeline(tar, tarProcess.stdin, (err) => {
201+
if (err) {
202+
reject(
203+
new Error(`Error while downloading and extracting tar: ${err}`),
204+
);
205+
}
201206
});
202207
}
203208

src/tools-download.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,43 @@ test.serial(
3838
},
3939
);
4040

41+
test.serial(
42+
"downloadAndExtract falls back to downloading before extracting if streaming fails",
43+
async (t) => {
44+
await withTmpDir(async (tmpDir) => {
45+
sinon.stub(process, "platform").value("linux");
46+
const archivePath = path.join(tmpDir, "codeql-bundle.tar.zst");
47+
const destination = path.join(tmpDir, "codeql");
48+
const downloadTool = sinon
49+
.stub(toolcache, "downloadTool")
50+
.resolves(archivePath);
51+
const extract = sinon.stub(tar, "extract").resolves(destination);
52+
const extractTarZst = sinon.stub(tar, "extractTarZst").resolves();
53+
const request = nock("https://example.com")
54+
.get("/codeql-bundle.tar.zst")
55+
.replyWithError(
56+
Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }),
57+
);
58+
59+
const statusReport = await downloadAndExtract(
60+
"https://example.com/codeql-bundle.tar.zst",
61+
"zstd",
62+
destination,
63+
undefined,
64+
{},
65+
{ type: "gnu", version: "1.34" },
66+
getRunnerLogger(true),
67+
);
68+
69+
t.assert(Number.isInteger(statusReport.downloadDurationMs));
70+
t.true(request.isDone());
71+
t.false(extractTarZst.called);
72+
t.true(downloadTool.calledOnce);
73+
t.true(extract.calledOnce);
74+
});
75+
},
76+
);
77+
4178
test.serial(
4279
"downloadAndExtract omits the download duration when streaming extraction",
4380
async (t) => {

src/tools-download.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ import { cleanUpPath, getErrorMessage, getRequiredEnvParam } from "./util";
1919
*/
2020
const STREAMING_HIGH_WATERMARK_BYTES = 4 * 1024 * 1024; // 4 MiB
2121

22+
/**
23+
* How long the streaming download of the CodeQL tools may stall for before we abort it. This
24+
* applies both to establishing the connection and to gaps between chunks of the response body.
25+
*/
26+
const STREAMING_STALL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
27+
2228
/**
2329
* The name of the tool cache directory for the CodeQL tools.
2430
*/
@@ -137,8 +143,8 @@ async function downloadAndExtractZstdWithStreaming(
137143
authorization ? { authorization } : {},
138144
headers,
139145
);
140-
const response = await new Promise<IncomingMessage>((resolve) =>
141-
https.get(
146+
const response = await new Promise<IncomingMessage>((resolve, reject) => {
147+
const request = https.get(
142148
codeqlURL,
143149
{
144150
headers,
@@ -148,10 +154,24 @@ async function downloadAndExtractZstdWithStreaming(
148154
agent,
149155
} as unknown as RequestOptions,
150156
(r) => resolve(r),
151-
),
152-
);
157+
);
158+
// Without this listener, connection failures such as `ECONNRESET` are emitted as unhandled
159+
// `error` events, which terminate the process instead of letting us fall back to downloading
160+
// the bundle before extracting it. This listener stays attached after the response arrives, so
161+
// it also handles errors that occur while the response is being streamed.
162+
request.on("error", reject);
163+
request.setTimeout(STREAMING_STALL_TIMEOUT_MS, () => {
164+
request.destroy(
165+
new Error(
166+
`No data received for ${formatDuration(STREAMING_STALL_TIMEOUT_MS)}.`,
167+
),
168+
);
169+
});
170+
});
153171

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

0 commit comments

Comments
 (0)