Skip to content

Commit 397e9e9

Browse files
committed
Update to bitjs 1.2.5 to fix race condition bug in Zipper
1 parent af5ed5d commit 397e9e9

File tree

1 file changed

+105
-31
lines changed

1 file changed

+105
-31
lines changed

code/bitjs/archive/compress.js

Lines changed: 105 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import { ZipCompressionMethod, getConnectedPort } from './common.js';
2020
* @property {Uint8Array} fileData The bytes of the file.
2121
*/
2222

23+
/** The number of milliseconds to periodically send any pending files to the Worker. */
24+
const FLUSH_TIMER_MS = 50;
25+
2326
/**
2427
* Data elements are packed into bytes in order of increasing bit number within the byte,
2528
* i.e., starting with the least-significant bit of the byte.
@@ -46,15 +49,28 @@ export const CompressStatus = {
4649
};
4750

4851
// TODO: Extend EventTarget and introduce subscribe methods (onProgress, onInsert, onFinish, etc).
49-
// TODO: I think appendFiles() is still a good idea so that all files do not have to live in memory
50-
// at once, but the API is wonky here... re-think it. Maybe something more like a builder?
5152

5253
/**
5354
* A thing that zips files.
5455
* NOTE: THIS IS A WORK-IN-PROGRESS! THE API IS NOT FROZEN! USE AT YOUR OWN RISK!
5556
* TODO(2.0): Add semantic onXXX methods for an event-driven API.
5657
*/
5758
export class Zipper {
59+
/**
60+
* @type {Uint8Array}
61+
* @private
62+
*/
63+
byteArray = new Uint8Array(0);
64+
65+
/**
66+
* The overall state of the Zipper.
67+
* @type {CompressStatus}
68+
* @private
69+
*/
70+
compressStatus_ = CompressStatus.NOT_STARTED;
71+
// Naming of this property preserved for compatibility with 1.2.4-.
72+
get compressState() { return this.compressStatus_; }
73+
5874
/**
5975
* The client-side port that sends messages to, and receives messages from the
6076
* decompressor implementation.
@@ -70,6 +86,28 @@ export class Zipper {
7086
*/
7187
disconnectFn_;
7288

89+
/**
90+
* A timer that periodically flushes pending files to the Worker. Set upon start() and stopped
91+
* upon the last file being compressed by the Worker.
92+
* @type {Number}
93+
* @private
94+
*/
95+
flushTimer_ = 0;
96+
97+
/**
98+
* Whether the last files have been added by the client.
99+
* @type {boolean}
100+
* @private
101+
*/
102+
lastFilesReceived_ = false;
103+
104+
/**
105+
* The pending files to be sent to the Worker.
106+
* @type {FileInfo[]}
107+
* @private
108+
*/
109+
pendingFilesToSend_ = [];
110+
73111
/**
74112
* @param {CompressorOptions} options
75113
*/
@@ -93,46 +131,49 @@ export class Zipper {
93131
throw `CompressionStream with deflate-raw not supported by JS runtime: ${err}`;
94132
}
95133
}
96-
97-
/**
98-
* @type {CompressStatus}
99-
* @private
100-
*/
101-
this.compressState = CompressStatus.NOT_STARTED;
102-
103-
/**
104-
* @type {Uint8Array}
105-
* @private
106-
*/
107-
this.byteArray = new Uint8Array(0);
108134
}
109135

110136
/**
111137
* Must only be called on a Zipper that has been started. See start().
112138
* @param {FileInfo[]} files
113139
* @param {boolean} isLastFile
114140
*/
115-
appendFiles(files, isLastFile) {
116-
if (!this.port_) {
117-
throw `Port not initialized. Did you forget to call start() ?`;
118-
}
119-
if (![CompressStatus.READY, CompressStatus.WORKING].includes(this.compressState)) {
120-
throw `Zipper not in the right state: ${this.compressState}`;
141+
appendFiles(files, isLastFile = false) {
142+
if (this.compressStatus_ === CompressStatus.NOT_STARTED) {
143+
throw `appendFiles() called, but Zipper not started.`;
121144
}
145+
if (this.lastFilesReceived_) throw `appendFiles() called, but last file already received.`;
122146

123-
this.port_.postMessage({ files, isLastFile });
147+
this.lastFilesReceived_ = isLastFile;
148+
this.pendingFilesToSend_.push(...files);
124149
}
125150

126151
/**
127-
* Send in a set of files to be compressed. Set isLastFile to true if no more files are to added
128-
* at some future state. The Promise will not resolve until isLastFile is set to true either in
129-
* this method or in appendFiles().
152+
* Send in a set of files to be compressed. Set isLastFile to true if no more files are to be
153+
* added in the future. The return Promise will not resolve until isLastFile is set to true either
154+
* in this method or in an appendFiles() call.
130155
* @param {FileInfo[]} files
131156
* @param {boolean} isLastFile
132-
* @returns {Promise<Uint8Array>} A Promise that will contain the entire zipped archive as an array
133-
* of bytes.
157+
* @returns {Promise<Uint8Array>} A Promise that will resolve once the final file has been sent.
158+
* The Promise resolves to an array of bytes of the entire zipped archive.
134159
*/
135-
async start(files, isLastFile) {
160+
async start(files = [], isLastFile = false) {
161+
if (this.compressStatus_ !== CompressStatus.NOT_STARTED) {
162+
throw `start() called, but Zipper already started.`;
163+
}
164+
165+
// We optimize for the case where isLastFile=true in a start() call by posting to the Worker
166+
// immediately upon async resolving below. Otherwise, we push these files into the pending set
167+
// and rely on the flush timer to send them into the Worker.
168+
if (!isLastFile) {
169+
this.pendingFilesToSend_.push(...files);
170+
this.flushTimer_ = setInterval(() => this.flushAnyPendingFiles_(), FLUSH_TIMER_MS);
171+
}
172+
this.compressStatus_ = CompressStatus.READY;
173+
this.lastFilesReceived_ = isLastFile;
174+
175+
// After this point, the function goes async, so appendFiles() may run before anything else in
176+
// this function.
136177
const impl = await getConnectedPort('./zip.js');
137178
this.port_ = impl.hostPort;
138179
this.disconnectFn_ = impl.disconnectFn;
@@ -148,26 +189,36 @@ export class Zipper {
148189
console.log(evt.data);
149190
} else {
150191
switch (evt.data.type) {
192+
// Message sent back upon the first message the Worker receives, which may or may not
193+
// have sent any files for compression, e.g. start([]).
151194
case 'start':
152-
this.compressState = CompressStatus.WORKING;
195+
this.compressStatus_ = CompressStatus.WORKING;
153196
break;
197+
// Message sent back when the last file has been compressed by the Worker.
154198
case 'finish':
155-
this.compressState = CompressStatus.COMPLETE;
199+
if (this.flushTimer_) {
200+
clearInterval(this.flushTimer_);
201+
this.flushTimer_ = 0;
202+
}
203+
this.compressStatus_ = CompressStatus.COMPLETE;
156204
this.port_.close();
157205
this.disconnectFn_();
158206
this.port_ = null;
159207
this.disconnectFn_ = null;
160208
resolve(this.byteArray);
161209
break;
210+
// Message sent back when the Worker has written some bytes to the zip file.
162211
case 'compress':
163212
this.addBytes_(evt.data.bytes);
164213
break;
165214
}
166215
}
167216
};
168217

169-
this.compressState = CompressStatus.READY;
170-
this.port_.postMessage({ files, isLastFile, compressionMethod: this.zipCompressionMethod});
218+
// See note above about optimizing for the start(files, true) case.
219+
if (isLastFile) {
220+
this.port_.postMessage({ files, isLastFile, compressionMethod: this.zipCompressionMethod });
221+
}
171222
});
172223
}
173224

@@ -182,4 +233,27 @@ export class Zipper {
182233
this.byteArray.set(oldArray);
183234
this.byteArray.set(newBytes, oldArray.byteLength);
184235
}
236+
237+
/**
238+
* Called internally by the async machinery to send any pending files to the Worker. This method
239+
* sends at most one message to the Worker.
240+
* @private
241+
*/
242+
flushAnyPendingFiles_() {
243+
if (this.compressStatus_ === CompressStatus.NOT_STARTED) {
244+
throw `flushAppendFiles_() called but Zipper not started.`;
245+
}
246+
// If the port is not initialized or we have no pending files, just return immediately and
247+
// try again on the next flush.
248+
if (!this.port_ || this.pendingFilesToSend_.length === 0) return;
249+
250+
// Send all files to the worker. If we have received the last file, then let the Worker know.
251+
this.port_.postMessage({
252+
files: this.pendingFilesToSend_,
253+
isLastFile: this.lastFilesReceived_,
254+
compressionMethod: this.zipCompressionMethod,
255+
});
256+
// Release the memory from the browser's main thread.
257+
this.pendingFilesToSend_ = [];
258+
}
185259
}

0 commit comments

Comments
 (0)