Skip to content
Draft
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 extensions/ql-vscode/src/common/unzip-concurrently.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export async function unzipToDirectoryConcurrently(
progress?: UnzipProgressCallback,
): Promise<void> {
const queue = new PQueue({
concurrency: availableParallelism(),
concurrency: Math.min(availableParallelism(), 4),
});

return unzipToDirectory(
Expand Down
31 changes: 11 additions & 20 deletions extensions/ql-vscode/src/common/unzip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Entry as ZipEntry, Options as ZipOptions, ZipFile } from "yauzl";
import { open } from "yauzl";
import type { Readable } from "stream";
import { Transform } from "stream";
import { pipeline } from "stream/promises";
import { dirname, join } from "path";
import type { WriteStream } from "fs";
import { createWriteStream, ensureDir } from "fs-extra";
Expand Down Expand Up @@ -93,26 +94,16 @@ async function copyStream(
writeStream: WriteStream,
bytesExtractedCallback?: (bytesExtracted: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
readable.on("error", (err) => {
reject(err);
});
readable.on("end", () => {
resolve();
});

readable
.pipe(
new Transform({
transform(chunk, _encoding, callback) {
bytesExtractedCallback?.(chunk.length);
this.push(chunk);
callback();
},
}),
)
.pipe(writeStream);
});
await pipeline(
readable,
new Transform({
transform(chunk, _encoding, callback) {
bytesExtractedCallback?.(chunk.length);
callback(null, chunk);
},
}),
writeStream,
);
}

type UnzipProgress = {
Expand Down
78 changes: 78 additions & 0 deletions extensions/ql-vscode/test/unit-tests/common/unzip.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createHash } from "crypto";
import { open } from "fs/promises";
import { join, relative, resolve, sep } from "path";
import { Readable } from "stream";
import { createWriteStream } from "fs";
import { chmod, pathExists, readdir } from "fs-extra";
import type { DirectoryResult } from "tmp-promise";
import { dir } from "tmp-promise";
Expand Down Expand Up @@ -276,3 +278,79 @@ async function computeHash(contents: Buffer) {

return hash.digest("hex");
}

describe("copyStream error handling", () => {
let tmpDir: DirectoryResult;

beforeEach(async () => {
tmpDir = await dir({
unsafeCleanup: true,
});
});

afterEach(async () => {
await tmpDir?.cleanup();
});

it("rejects when the write stream errors mid-extraction", async () => {
// Use a real zip to trigger unzip, but make the destination read-only
// so the write stream fails. This verifies the promise rejects rather
// than hanging indefinitely.
const destPath = join(tmpDir.path, "output");

// Extract once to create the directory structure
await unzipToDirectorySequentially(zipPath, destPath);

// Make a file read-only so re-extraction will fail on write
const targetFile = join(destPath, "directory", "file.txt");
await chmod(targetFile, 0o000);

// On Windows, chmod doesn't prevent writes, so skip assertion there
if (process.platform === "win32") {
await chmod(targetFile, 0o644);
return;
}

// Re-extract — should reject with a write error, not hang
await expect(
unzipToDirectorySequentially(zipPath, destPath),
).rejects.toThrow();

// Restore permissions for cleanup
await chmod(targetFile, 0o644);
});

it("rejects when the write stream is destroyed mid-copy", async () => {
// Directly test the pipeline behavior: a destroyed write stream should
// cause rejection, not a hang.
const destFile = join(tmpDir.path, "output.bin");
const writeStream = createWriteStream(destFile);

// Create a readable that emits data then waits
const readable = new Readable({
read() {
this.push(Buffer.alloc(1024, "x"));
// Destroy the write stream after the first chunk to simulate an error
setImmediate(() => {
writeStream.destroy(new Error("simulated write failure"));
});
},
});

// Import pipeline to test the same pattern as copyStream
const { pipeline } = await import("stream/promises");
const { Transform } = await import("stream");

await expect(
pipeline(
readable,
new Transform({
transform(chunk, _encoding, callback) {
callback(null, chunk);
},
}),
writeStream,
),
).rejects.toThrow("simulated write failure");
});
});