Skip to content

Commit 52672e6

Browse files
authored
Merge pull request #4455 from github/shati-patel/fix-cli-extraction
Refactor unzip functionality to limit concurrency and improve error handling in `copyStream`
2 parents 05d73c2 + c593313 commit 52672e6

5 files changed

Lines changed: 195 additions & 44 deletions

File tree

extensions/ql-vscode/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## [UNRELEASED]
44

5+
- Fix a bug where installing or updating the CodeQL CLI could hang indefinitely while extracting the downloaded archive. Extraction now reports an error if a file cannot be written, and aborts with a clear message if no progress is made within the download timeout (for example due to slow or networked storage, or security software). [#4455](https://github.com/github/vscode-codeql/pull/4455)
56
- Remove support for CodeQL CLI versions older than 2.23.9. [#4448](https://github.com/github/vscode-codeql/pull/4448)
67
- Added support for selection-based result filtering via a checkbox in the result viewer. When enabled, only results from the currently-viewed file are shown. Additionally, if the editor selection is non-empty, only results within the selection range are shown. [#4362](https://github.com/github/vscode-codeql/pull/4362)
78
- Added a new "CodeQL: Go to File in Selected Database" command that allows you to open a file from the source archive of the currently selected database. [#4390](https://github.com/github/vscode-codeql/pull/4390)

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: availableParallelism(),
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: 77 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,23 @@ import type { Entry as ZipEntry, Options as ZipOptions, ZipFile } from "yauzl";
22
import { open } from "yauzl";
33
import type { Readable } from "stream";
44
import { Transform } from "stream";
5+
import { pipeline } from "stream/promises";
56
import { dirname, join } from "path";
67
import type { WriteStream } from "fs";
78
import { createWriteStream, ensureDir } from "fs-extra";
89
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;
922

1023
// We can't use promisify because it picks up the wrong overload.
1124
export function openZip(
@@ -88,31 +101,23 @@ export async function openZipBuffer(
88101
});
89102
}
90103

91-
async function copyStream(
104+
export async function copyStream(
92105
readable: Readable,
93106
writeStream: WriteStream,
94107
bytesExtractedCallback?: (bytesExtracted: number) => void,
108+
signal?: AbortSignal,
95109
): Promise<void> {
96-
return new Promise((resolve, reject) => {
97-
readable.on("error", (err) => {
98-
reject(err);
99-
});
100-
readable.on("end", () => {
101-
resolve();
102-
});
103-
104-
readable
105-
.pipe(
106-
new Transform({
107-
transform(chunk, _encoding, callback) {
108-
bytesExtractedCallback?.(chunk.length);
109-
this.push(chunk);
110-
callback();
111-
},
112-
}),
113-
)
114-
.pipe(writeStream);
115-
});
110+
await pipeline(
111+
readable,
112+
new Transform({
113+
transform(chunk, _encoding, callback) {
114+
bytesExtractedCallback?.(chunk.length);
115+
callback(null, chunk);
116+
},
117+
}),
118+
writeStream,
119+
{ signal },
120+
);
116121
}
117122

118123
type UnzipProgress = {
@@ -139,6 +144,7 @@ async function unzipFile(
139144
entry: ZipEntry,
140145
rootDestinationPath: string,
141146
bytesExtractedCallback?: (bytesExtracted: number) => void,
147+
signal?: AbortSignal,
142148
): Promise<number> {
143149
const path = join(rootDestinationPath, entry.fileName);
144150

@@ -164,7 +170,7 @@ async function unzipFile(
164170
mode,
165171
});
166172

167-
await copyStream(readable, writeStream, bytesExtractedCallback);
173+
await copyStream(readable, writeStream, bytesExtractedCallback, signal);
168174

169175
return entry.uncompressedSize;
170176
}
@@ -185,6 +191,7 @@ export async function unzipToDirectory(
185191
destinationPath: string,
186192
progress: UnzipProgressCallback | undefined,
187193
taskRunner: (tasks: Array<() => Promise<void>>) => Promise<void>,
194+
timeoutSeconds: number = DEFAULT_UNZIP_IDLE_TIMEOUT_SECONDS,
188195
): Promise<void> {
189196
const zipFile = await openZip(archivePath, {
190197
autoClose: false,
@@ -211,28 +218,53 @@ export async function unzipToDirectory(
211218

212219
reportProgress();
213220

214-
await taskRunner(
215-
entries.map((entry) => async () => {
216-
let entryBytesExtracted = 0;
217-
218-
const totalEntryBytesExtracted = await unzipFile(
219-
zipFile,
220-
entry,
221-
destinationPath,
222-
(thisBytesExtracted) => {
223-
entryBytesExtracted += thisBytesExtracted;
224-
bytesExtracted += thisBytesExtracted;
225-
reportProgress();
226-
},
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.",
227262
);
228-
229-
// Should be 0, but just in case.
230-
bytesExtracted += -entryBytesExtracted + totalEntryBytesExtracted;
231-
232-
filesExtracted++;
233-
reportProgress();
234-
}),
235-
);
263+
}
264+
throw e;
265+
} finally {
266+
dispose();
267+
}
236268
} finally {
237269
zipFile.close();
238270
}
@@ -251,6 +283,7 @@ export async function unzipToDirectorySequentially(
251283
archivePath: string,
252284
destinationPath: string,
253285
progress?: UnzipProgressCallback,
286+
timeoutSeconds?: number,
254287
): Promise<void> {
255288
return unzipToDirectory(
256289
archivePath,
@@ -261,5 +294,6 @@ export async function unzipToDirectorySequentially(
261294
await task();
262295
}
263296
},
297+
timeoutSeconds,
264298
);
265299
}

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

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { createHash } from "crypto";
22
import { open } from "fs/promises";
33
import { join, relative, resolve, sep } from "path";
4+
import { Readable } from "stream";
5+
import { createWriteStream } from "fs";
46
import { chmod, pathExists, readdir } from "fs-extra";
57
import type { DirectoryResult } from "tmp-promise";
68
import { dir } from "tmp-promise";
79
import {
10+
copyStream,
811
excludeDirectories,
912
openZip,
1013
openZipBuffer,
@@ -13,6 +16,7 @@ import {
1316
} from "../../../src/common/unzip";
1417
import { walkDirectory } from "../../../src/common/files";
1518
import { unzipToDirectoryConcurrently } from "../../../src/common/unzip-concurrently";
19+
import { createTimeoutSignal } from "../../../src/common/fetch-stream";
1620

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

@@ -276,3 +280,104 @@ async function computeHash(contents: Buffer) {
276280

277281
return hash.digest("hex");
278282
}
283+
284+
describe("copyStream error handling", () => {
285+
let tmpDir: DirectoryResult;
286+
287+
beforeEach(async () => {
288+
tmpDir = await dir({
289+
unsafeCleanup: true,
290+
});
291+
});
292+
293+
afterEach(async () => {
294+
await tmpDir?.cleanup();
295+
});
296+
297+
it("rejects when the write stream errors mid-extraction", async () => {
298+
// Use a real zip to trigger unzip, but make the destination read-only
299+
// so the write stream fails. This verifies the promise rejects rather
300+
// than hanging indefinitely.
301+
const destPath = join(tmpDir.path, "output");
302+
303+
// Extract once to create the directory structure
304+
await unzipToDirectorySequentially(zipPath, destPath);
305+
306+
// Make a file read-only so re-extraction will fail on write
307+
const targetFile = join(destPath, "directory", "file.txt");
308+
await chmod(targetFile, 0o000);
309+
310+
// On Windows, chmod doesn't prevent writes, so skip assertion there
311+
if (process.platform === "win32") {
312+
await chmod(targetFile, 0o644);
313+
return;
314+
}
315+
316+
// Re-extract — should reject with a write error, not hang
317+
await expect(
318+
unzipToDirectorySequentially(zipPath, destPath),
319+
).rejects.toThrow();
320+
321+
// Restore permissions for cleanup
322+
await chmod(targetFile, 0o644);
323+
});
324+
325+
it("rejects when the write stream is destroyed mid-copy", async () => {
326+
// A destroyed write stream should cause `copyStream` to reject, not hang.
327+
const destFile = join(tmpDir.path, "output.bin");
328+
const writeStream = createWriteStream(destFile);
329+
330+
// A readable that emits a chunk and then destroys the write stream, to
331+
// simulate a mid-copy write failure.
332+
let pushed = false;
333+
const readable = new Readable({
334+
read() {
335+
if (pushed) {
336+
return;
337+
}
338+
pushed = true;
339+
this.push(Buffer.alloc(1024, "x"));
340+
setImmediate(() => {
341+
writeStream.destroy(new Error("simulated write failure"));
342+
});
343+
},
344+
});
345+
346+
await expect(copyStream(readable, writeStream)).rejects.toThrow(
347+
"simulated write failure",
348+
);
349+
});
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+
});
383+
});

0 commit comments

Comments
 (0)