Skip to content

Commit a8dd368

Browse files
authored
Merge pull request #3157 from github/koesie10/yauzl-progress
Add progress reporting for unzipping files
2 parents fbaf3d1 + 4e4345f commit a8dd368

File tree

8 files changed

+252
-49
lines changed

8 files changed

+252
-49
lines changed

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
showAndLogWarningMessage,
2727
} from "../common/logging";
2828
import { unzipToDirectoryConcurrently } from "../common/unzip-concurrently";
29+
import { reportUnzipProgress } from "../common/vscode/unzip-progress";
2930

3031
/**
3132
* distribution.ts
@@ -423,6 +424,12 @@ class ExtensionSpecificDistributionManager {
423424
await unzipToDirectoryConcurrently(
424425
archivePath,
425426
this.getDistributionStoragePath(),
427+
progressCallback
428+
? reportUnzipProgress(
429+
`Extracting CodeQL CLI ${release.name}…`,
430+
progressCallback,
431+
)
432+
: undefined,
426433
);
427434
} finally {
428435
await remove(tmpDirectory);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function readableBytesMb(numBytes: number): string {
2+
return `${(numBytes / (1024 * 1024)).toFixed(1)} MB`;
3+
}
Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
import { availableParallelism } from "os";
2-
import { unzipToDirectory } from "./unzip";
2+
import { UnzipProgressCallback, unzipToDirectory } from "./unzip";
33
import PQueue from "p-queue";
44

55
export async function unzipToDirectoryConcurrently(
66
archivePath: string,
77
destinationPath: string,
8+
progress?: UnzipProgressCallback,
89
): Promise<void> {
910
const queue = new PQueue({
1011
concurrency: availableParallelism(),
1112
});
1213

13-
return unzipToDirectory(archivePath, destinationPath, async (tasks) => {
14-
await queue.addAll(tasks);
15-
});
14+
return unzipToDirectory(
15+
archivePath,
16+
destinationPath,
17+
progress,
18+
async (tasks) => {
19+
await queue.addAll(tasks);
20+
},
21+
);
1622
}

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

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Entry as ZipEntry, open, Options as ZipOptions, ZipFile } from "yauzl";
2-
import { Readable } from "stream";
2+
import { Readable, Transform } from "stream";
33
import { dirname, join } from "path";
44
import { WriteStream } from "fs";
55
import { createWriteStream, ensureDir } from "fs-extra";
@@ -25,6 +25,10 @@ export function excludeDirectories(entries: ZipEntry[]): ZipEntry[] {
2525
return entries.filter((entry) => !/\/$/.test(entry.fileName));
2626
}
2727

28+
function calculateTotalUncompressedByteSize(entries: ZipEntry[]): number {
29+
return entries.reduce((total, entry) => total + entry.uncompressedSize, 0);
30+
}
31+
2832
export function readZipEntries(zipFile: ZipFile): Promise<ZipEntry[]> {
2933
return new Promise((resolve, reject) => {
3034
const files: ZipEntry[] = [];
@@ -84,6 +88,7 @@ export async function openZipBuffer(
8488
async function copyStream(
8589
readable: Readable,
8690
writeStream: WriteStream,
91+
bytesExtractedCallback?: (bytesExtracted: number) => void,
8792
): Promise<void> {
8893
return new Promise((resolve, reject) => {
8994
readable.on("error", (err) => {
@@ -93,28 +98,53 @@ async function copyStream(
9398
resolve();
9499
});
95100

96-
readable.pipe(writeStream);
101+
readable
102+
.pipe(
103+
new Transform({
104+
transform(chunk, _encoding, callback) {
105+
bytesExtractedCallback?.(chunk.length);
106+
this.push(chunk);
107+
callback();
108+
},
109+
}),
110+
)
111+
.pipe(writeStream);
97112
});
98113
}
99114

115+
type UnzipProgress = {
116+
filesExtracted: number;
117+
totalFiles: number;
118+
119+
bytesExtracted: number;
120+
totalBytes: number;
121+
};
122+
123+
export type UnzipProgressCallback = (progress: UnzipProgress) => void;
124+
100125
/**
101126
* Unzips a single file from a zip archive.
102127
*
103128
* @param zipFile
104129
* @param entry
105130
* @param rootDestinationPath
131+
* @param bytesExtractedCallback Called when bytes are extracted.
132+
* @return The number of bytes extracted.
106133
*/
107134
async function unzipFile(
108135
zipFile: ZipFile,
109136
entry: ZipEntry,
110137
rootDestinationPath: string,
111-
): Promise<void> {
138+
bytesExtractedCallback?: (bytesExtracted: number) => void,
139+
): Promise<number> {
112140
const path = join(rootDestinationPath, entry.fileName);
113141

114142
if (/\/$/.test(entry.fileName)) {
115143
// Directory file names end with '/'
116144

117145
await ensureDir(path);
146+
147+
return 0;
118148
} else {
119149
// Ensure the directory exists
120150
await ensureDir(dirname(path));
@@ -131,7 +161,9 @@ async function unzipFile(
131161
mode,
132162
});
133163

134-
await copyStream(readable, writeStream);
164+
await copyStream(readable, writeStream, bytesExtractedCallback);
165+
166+
return entry.uncompressedSize;
135167
}
136168
}
137169

@@ -143,10 +175,12 @@ async function unzipFile(
143175
* @param archivePath
144176
* @param destinationPath
145177
* @param taskRunner A function that runs the tasks (either sequentially or concurrently).
178+
* @param progress
146179
*/
147180
export async function unzipToDirectory(
148181
archivePath: string,
149182
destinationPath: string,
183+
progress: UnzipProgressCallback | undefined,
150184
taskRunner: (tasks: Array<() => Promise<void>>) => Promise<void>,
151185
): Promise<void> {
152186
const zipFile = await openZip(archivePath, {
@@ -158,8 +192,43 @@ export async function unzipToDirectory(
158192
try {
159193
const entries = await readZipEntries(zipFile);
160194

195+
let filesExtracted = 0;
196+
const totalFiles = entries.length;
197+
let bytesExtracted = 0;
198+
const totalBytes = calculateTotalUncompressedByteSize(entries);
199+
200+
const reportProgress = () => {
201+
progress?.({
202+
filesExtracted,
203+
totalFiles,
204+
bytesExtracted,
205+
totalBytes,
206+
});
207+
};
208+
209+
reportProgress();
210+
161211
await taskRunner(
162-
entries.map((entry) => () => unzipFile(zipFile, entry, destinationPath)),
212+
entries.map((entry) => async () => {
213+
let entryBytesExtracted = 0;
214+
215+
const totalEntryBytesExtracted = await unzipFile(
216+
zipFile,
217+
entry,
218+
destinationPath,
219+
(thisBytesExtracted) => {
220+
entryBytesExtracted += thisBytesExtracted;
221+
bytesExtracted += thisBytesExtracted;
222+
reportProgress();
223+
},
224+
);
225+
226+
// Should be 0, but just in case.
227+
bytesExtracted += -entryBytesExtracted + totalEntryBytesExtracted;
228+
229+
filesExtracted++;
230+
reportProgress();
231+
}),
163232
);
164233
} finally {
165234
zipFile.close();
@@ -173,14 +242,21 @@ export async function unzipToDirectory(
173242
*
174243
* @param archivePath
175244
* @param destinationPath
245+
* @param progress
176246
*/
177247
export async function unzipToDirectorySequentially(
178248
archivePath: string,
179249
destinationPath: string,
250+
progress?: UnzipProgressCallback,
180251
): Promise<void> {
181-
return unzipToDirectory(archivePath, destinationPath, async (tasks) => {
182-
for (const task of tasks) {
183-
await task();
184-
}
185-
});
252+
return unzipToDirectory(
253+
archivePath,
254+
destinationPath,
255+
progress,
256+
async (tasks) => {
257+
for (const task of tasks) {
258+
await task();
259+
}
260+
},
261+
);
186262
}

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ProgressOptions as VSCodeProgressOptions,
55
window as Window,
66
} from "vscode";
7+
import { readableBytesMb } from "../bytes";
78

89
export class UserCancellationException extends Error {
910
/**
@@ -125,15 +126,13 @@ export function reportStreamProgress(
125126
) {
126127
if (progress && totalNumBytes) {
127128
let numBytesDownloaded = 0;
128-
const bytesToDisplayMB = (numBytes: number): string =>
129-
`${(numBytes / (1024 * 1024)).toFixed(1)} MB`;
130129
const updateProgress = () => {
131130
progress({
132131
step: numBytesDownloaded,
133132
maxStep: totalNumBytes,
134-
message: `${messagePrefix} [${bytesToDisplayMB(
133+
message: `${messagePrefix} [${readableBytesMb(
135134
numBytesDownloaded,
136-
)} of ${bytesToDisplayMB(totalNumBytes)}]`,
135+
)} of ${readableBytesMb(totalNumBytes)}]`,
137136
});
138137
};
139138

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { readableBytesMb } from "../bytes";
2+
import { UnzipProgressCallback } from "../unzip";
3+
import { ProgressCallback } from "./progress";
4+
5+
export function reportUnzipProgress(
6+
messagePrefix: string,
7+
progress: ProgressCallback,
8+
): UnzipProgressCallback {
9+
return ({ bytesExtracted, totalBytes }) => {
10+
progress({
11+
step: bytesExtracted,
12+
maxStep: totalBytes,
13+
message: `${messagePrefix} [${readableBytesMb(
14+
bytesExtracted,
15+
)} of ${readableBytesMb(totalBytes)}]`,
16+
});
17+
};
18+
}

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,75 @@ describe.each([
164164
expect(await pathExists(join(tmpDir.path, "empty-directory"))).toBe(true);
165165
expect(await readdir(join(tmpDir.path, "empty-directory"))).toEqual([]);
166166
});
167+
168+
describe("with reported progress", () => {
169+
const progressCallback = jest.fn();
170+
171+
beforeEach(async () => {
172+
progressCallback.mockReset();
173+
174+
await unzipToDirectory(zipPath, tmpDir.path, progressCallback);
175+
});
176+
177+
it("has at least as many progress callbacks as files", () => {
178+
expect(progressCallback.mock.calls.length).toBeGreaterThanOrEqual(11);
179+
});
180+
181+
it("has an incrementing files extracted value", () => {
182+
let previousValue = 0;
183+
for (const call of progressCallback.mock.calls.values()) {
184+
const [{ filesExtracted }] = call;
185+
expect(filesExtracted).toBeGreaterThanOrEqual(previousValue);
186+
previousValue = filesExtracted;
187+
}
188+
});
189+
190+
it("has an incrementing bytes extracted value", () => {
191+
let previousValue = 0;
192+
for (const call of progressCallback.mock.calls.values()) {
193+
const [{ bytesExtracted }] = call;
194+
expect(bytesExtracted).toBeGreaterThanOrEqual(previousValue);
195+
previousValue = bytesExtracted;
196+
}
197+
});
198+
199+
it("always increments either bytes or files extracted", () => {
200+
let previousBytesExtracted = 0;
201+
let previousFilesExtracted = 0;
202+
203+
for (const [index, call] of progressCallback.mock.calls.entries()) {
204+
if (index === 0) {
205+
// The first call is always 0, 0
206+
continue;
207+
}
208+
209+
const [{ bytesExtracted, filesExtracted }] = call;
210+
expect(bytesExtracted + filesExtracted).toBeGreaterThan(
211+
previousBytesExtracted + previousFilesExtracted,
212+
);
213+
previousBytesExtracted = bytesExtracted;
214+
previousFilesExtracted = filesExtracted;
215+
}
216+
});
217+
218+
it("has a first call with the correct values", () => {
219+
expect(progressCallback).toHaveBeenNthCalledWith(1, {
220+
bytesExtracted: 0,
221+
totalBytes: 87,
222+
filesExtracted: 0,
223+
totalFiles: 11,
224+
});
225+
});
226+
227+
it("has a last call with the correct values", () => {
228+
expect(progressCallback).toHaveBeenLastCalledWith({
229+
bytesExtracted: 87,
230+
totalBytes: 87,
231+
filesExtracted: 11,
232+
totalFiles: 11,
233+
});
234+
});
235+
});
167236
});
168237

169238
async function expectFile(

0 commit comments

Comments
 (0)