Skip to content

Commit ad55a71

Browse files
committed
Abort CodeQL CLI zip archive extraction if no progress within the timeout
1 parent fb0d6cb commit ad55a71

4 files changed

Lines changed: 111 additions & 23 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,7 @@ class ExtensionSpecificDistributionManager {
560560
progressCallback,
561561
)
562562
: undefined,
563+
this.config.downloadTimeout,
563564
);
564565
} catch (e) {
565566
if (e instanceof DOMException && e.name === "AbortError") {

extensions/ql-vscode/src/common/unzip-concurrently.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,22 @@ import type { UnzipProgressCallback } from "./unzip";
33
import { unzipToDirectory } from "./unzip";
44
import PQueue from "p-queue";
55

6+
/**
7+
* Maximum number of files to extract concurrently. We cap this rather than
8+
* using the full core count so that we don't open an excessive number of
9+
* simultaneous file writes, which can overwhelm slower or contended storage
10+
* during extraction.
11+
*/
12+
const MAX_UNZIP_CONCURRENCY = 4;
13+
614
export async function unzipToDirectoryConcurrently(
715
archivePath: string,
816
destinationPath: string,
917
progress?: UnzipProgressCallback,
18+
timeoutSeconds?: number,
1019
): Promise<void> {
1120
const queue = new PQueue({
12-
concurrency: Math.min(availableParallelism(), 4),
21+
concurrency: Math.min(availableParallelism(), MAX_UNZIP_CONCURRENCY),
1322
});
1423

1524
return unzipToDirectory(
@@ -19,5 +28,6 @@ export async function unzipToDirectoryConcurrently(
1928
async (tasks) => {
2029
await queue.addAll(tasks);
2130
},
31+
timeoutSeconds,
2232
);
2333
}

extensions/ql-vscode/src/common/unzip.ts

Lines changed: 65 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ import { dirname, join } from "path";
77
import type { WriteStream } from "fs";
88
import { createWriteStream, ensureDir } from "fs-extra";
99
import { asError } from "./helpers-pure";
10+
import { createTimeoutSignal } from "./fetch-stream";
11+
12+
/**
13+
* Default idle timeout (in seconds) used when a caller does not specify one. If
14+
* no bytes are extracted and no files complete within this window, the
15+
* extraction is aborted. This is a safety net that guards against a single
16+
* stalled write hanging the whole operation forever. 30 seconds of zero
17+
* extraction progress is well beyond any healthy pause, so this won't trigger
18+
* false positives. (The CLI downloader passes the configurable
19+
* `codeQL.cli.downloadTimeout` instead of relying on this default.)
20+
*/
21+
const DEFAULT_UNZIP_IDLE_TIMEOUT_SECONDS = 30;
1022

1123
// We can't use promisify because it picks up the wrong overload.
1224
export function openZip(
@@ -93,6 +105,7 @@ export async function copyStream(
93105
readable: Readable,
94106
writeStream: WriteStream,
95107
bytesExtractedCallback?: (bytesExtracted: number) => void,
108+
signal?: AbortSignal,
96109
): Promise<void> {
97110
await pipeline(
98111
readable,
@@ -103,6 +116,7 @@ export async function copyStream(
103116
},
104117
}),
105118
writeStream,
119+
{ signal },
106120
);
107121
}
108122

@@ -130,6 +144,7 @@ async function unzipFile(
130144
entry: ZipEntry,
131145
rootDestinationPath: string,
132146
bytesExtractedCallback?: (bytesExtracted: number) => void,
147+
signal?: AbortSignal,
133148
): Promise<number> {
134149
const path = join(rootDestinationPath, entry.fileName);
135150

@@ -155,7 +170,7 @@ async function unzipFile(
155170
mode,
156171
});
157172

158-
await copyStream(readable, writeStream, bytesExtractedCallback);
173+
await copyStream(readable, writeStream, bytesExtractedCallback, signal);
159174

160175
return entry.uncompressedSize;
161176
}
@@ -176,6 +191,7 @@ export async function unzipToDirectory(
176191
destinationPath: string,
177192
progress: UnzipProgressCallback | undefined,
178193
taskRunner: (tasks: Array<() => Promise<void>>) => Promise<void>,
194+
timeoutSeconds: number = DEFAULT_UNZIP_IDLE_TIMEOUT_SECONDS,
179195
): Promise<void> {
180196
const zipFile = await openZip(archivePath, {
181197
autoClose: false,
@@ -202,28 +218,53 @@ export async function unzipToDirectory(
202218

203219
reportProgress();
204220

205-
await taskRunner(
206-
entries.map((entry) => async () => {
207-
let entryBytesExtracted = 0;
208-
209-
const totalEntryBytesExtracted = await unzipFile(
210-
zipFile,
211-
entry,
212-
destinationPath,
213-
(thisBytesExtracted) => {
214-
entryBytesExtracted += thisBytesExtracted;
215-
bytesExtracted += thisBytesExtracted;
216-
reportProgress();
217-
},
221+
// Abort extraction if no progress is made for `timeoutSeconds`. `pipeline`
222+
// only rejects on a stream error, so without this a single write that
223+
// blocks without erroring (e.g. slow/networked storage or security
224+
// software holding a file) would hang the extraction indefinitely.
225+
const { signal, onData, dispose } = createTimeoutSignal(timeoutSeconds);
226+
227+
try {
228+
await taskRunner(
229+
entries.map((entry) => async () => {
230+
let entryBytesExtracted = 0;
231+
232+
const totalEntryBytesExtracted = await unzipFile(
233+
zipFile,
234+
entry,
235+
destinationPath,
236+
(thisBytesExtracted) => {
237+
// Reset the idle timeout: we are making progress.
238+
onData();
239+
entryBytesExtracted += thisBytesExtracted;
240+
bytesExtracted += thisBytesExtracted;
241+
reportProgress();
242+
},
243+
signal,
244+
);
245+
246+
// Should be 0, but just in case.
247+
bytesExtracted += -entryBytesExtracted + totalEntryBytesExtracted;
248+
249+
// Reset the idle timeout on completion too, so extracting many empty
250+
// files or directories doesn't trip the timeout.
251+
onData();
252+
filesExtracted++;
253+
reportProgress();
254+
}),
255+
);
256+
} catch (e) {
257+
if (signal.aborted) {
258+
throw new Error(
259+
`Timed out while extracting archive: no progress was made for ${timeoutSeconds} seconds. ` +
260+
"This can happen if a file cannot be written, for example because of slow or networked storage, " +
261+
"or security software holding a file open.",
218262
);
219-
220-
// Should be 0, but just in case.
221-
bytesExtracted += -entryBytesExtracted + totalEntryBytesExtracted;
222-
223-
filesExtracted++;
224-
reportProgress();
225-
}),
226-
);
263+
}
264+
throw e;
265+
} finally {
266+
dispose();
267+
}
227268
} finally {
228269
zipFile.close();
229270
}
@@ -242,6 +283,7 @@ export async function unzipToDirectorySequentially(
242283
archivePath: string,
243284
destinationPath: string,
244285
progress?: UnzipProgressCallback,
286+
timeoutSeconds?: number,
245287
): Promise<void> {
246288
return unzipToDirectory(
247289
archivePath,
@@ -252,5 +294,6 @@ export async function unzipToDirectorySequentially(
252294
await task();
253295
}
254296
},
297+
timeoutSeconds,
255298
);
256299
}

extensions/ql-vscode/test/unit-tests/common/unzip.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from "../../../src/common/unzip";
1717
import { walkDirectory } from "../../../src/common/files";
1818
import { unzipToDirectoryConcurrently } from "../../../src/common/unzip-concurrently";
19+
import { createTimeoutSignal } from "../../../src/common/fetch-stream";
1920

2021
const zipPath = resolve(__dirname, "../data/unzip/test-zip.zip");
2122

@@ -346,4 +347,37 @@ describe("copyStream error handling", () => {
346347
"simulated write failure",
347348
);
348349
});
350+
351+
it("rejects (rather than hanging) when the idle timeout aborts a stalled copy", async () => {
352+
// A readable that emits one chunk and then stalls forever: it never pushes
353+
// more data, never ends, and never errors. Without the abort signal this
354+
// would hang indefinitely (the original bug). `copyStream` must honour the
355+
// signal and reject once the idle timeout fires.
356+
let pushed = false;
357+
const stalled = new Readable({
358+
read() {
359+
if (!pushed) {
360+
pushed = true;
361+
this.push(Buffer.alloc(1024, "x"));
362+
}
363+
// Then never push again and never call push(null) -> stalled.
364+
},
365+
});
366+
367+
const destFile = join(tmpDir.path, "stalled-output.bin");
368+
const writeStream = createWriteStream(destFile);
369+
370+
// Short idle timeout so the test is fast; well within Jest's default limit.
371+
const { signal, dispose } = createTimeoutSignal(0.05);
372+
373+
try {
374+
await expect(
375+
copyStream(stalled, writeStream, undefined, signal),
376+
).rejects.toThrow();
377+
expect(signal.aborted).toBe(true);
378+
} finally {
379+
dispose();
380+
stalled.destroy();
381+
}
382+
});
349383
});

0 commit comments

Comments
 (0)