Skip to content

Commit fb0d6cb

Browse files
shati-pateljosefs
authored andcommitted
Refactor unzip functionality to limit concurrency and improve error handling in stream copying
1 parent 05d73c2 commit fb0d6cb

3 files changed

Lines changed: 84 additions & 22 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export async function unzipToDirectoryConcurrently(
99
progress?: UnzipProgressCallback,
1010
): Promise<void> {
1111
const queue = new PQueue({
12-
concurrency: availableParallelism(),
12+
concurrency: Math.min(availableParallelism(), 4),
1313
});
1414

1515
return unzipToDirectory(

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

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ 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";
@@ -88,31 +89,21 @@ export async function openZipBuffer(
8889
});
8990
}
9091

91-
async function copyStream(
92+
export async function copyStream(
9293
readable: Readable,
9394
writeStream: WriteStream,
9495
bytesExtractedCallback?: (bytesExtracted: number) => void,
9596
): 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-
});
97+
await pipeline(
98+
readable,
99+
new Transform({
100+
transform(chunk, _encoding, callback) {
101+
bytesExtractedCallback?.(chunk.length);
102+
callback(null, chunk);
103+
},
104+
}),
105+
writeStream,
106+
);
116107
}
117108

118109
type UnzipProgress = {

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

Lines changed: 71 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,
@@ -276,3 +279,71 @@ async function computeHash(contents: Buffer) {
276279

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

0 commit comments

Comments
 (0)