Skip to content

Commit 5c83a91

Browse files
committed
Fix issue #52, a race condition with Zipper and release 1.2.5
1 parent 2aa0fde commit 5c83a91

File tree

4 files changed

+153
-32
lines changed

4 files changed

+153
-32
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [1.2.5] - 2025-10-05
6+
7+
### Fixed
8+
9+
- [Issue #52](https://github.com/codedread/bitjs/issues/47) Fixed a race condition in Zipper.
10+
511
## [1.2.4] - 2024-12-08
612

713
### Fixed

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
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@codedread/bitjs",
3-
"version": "1.2.3",
3+
"version": "1.2.5",
44
"description": "Binary Tools for JavaScript",
55
"homepage": "https://github.com/codedread/bitjs",
66
"author": "Jeff Schiller",

tests/archive-compress.spec.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ describe('bitjs.archive.compress', () => {
3939
});
4040

4141
it('zipper works for STORE', (done) => {
42+
let extractCalled = false;
4243
const files = new Map(inputFileInfos);
4344
const zipper = new Zipper({zipCompressionMethod: ZipCompressionMethod.STORE});
4445
zipper.start(Array.from(files.values()), true).then(byteArray => {
@@ -47,6 +48,7 @@ describe('bitjs.archive.compress', () => {
4748

4849
const unarchiver = getUnarchiver(byteArray.buffer);
4950
unarchiver.addEventListener('extract', evt => {
51+
extractCalled = true;
5052
const {filename, fileData} = evt.unarchivedFile;
5153
expect(files.has(filename)).equals(true);
5254
const inputFile = files.get(filename).fileData;
@@ -57,6 +59,7 @@ describe('bitjs.archive.compress', () => {
5759
});
5860
unarchiver.addEventListener('finish', evt => done());
5961
unarchiver.start();
62+
expect(extractCalled).equals(true);
6063
});
6164
});
6265

@@ -86,4 +89,42 @@ describe('bitjs.archive.compress', () => {
8689
} catch (err) {
8790
// Do nothing. This runtime did not support DEFLATE. (Node < 21.2.0)
8891
}
92+
93+
it('zipper.start([file1]) and appendFiles(otherFiles, true) works', (done) => {
94+
let extractCalled = false;
95+
const files = new Map(inputFileInfos);
96+
const zipper = new Zipper({zipCompressionMethod: ZipCompressionMethod.STORE});
97+
const fileArr = Array.from(files.values());
98+
zipper.start([fileArr.shift()]).then(byteArray => {
99+
expect(zipper.compressState).equals(CompressStatus.COMPLETE);
100+
expect(byteArray.byteLength > decompressedFileSize).equals(true);
101+
102+
const unarchiver = getUnarchiver(byteArray.buffer);
103+
unarchiver.addEventListener('extract', evt => {
104+
extractCalled = true;
105+
const {filename, fileData} = evt.unarchivedFile;
106+
expect(files.has(filename)).equals(true);
107+
const inputFile = files.get(filename).fileData;
108+
expect(inputFile.byteLength).equals(fileData.byteLength);
109+
for (let b = 0; b < inputFile.byteLength; ++b) {
110+
expect(inputFile[b]).equals(fileData[b]);
111+
}
112+
});
113+
unarchiver.addEventListener('finish', evt => {
114+
done();
115+
});
116+
unarchiver.start();
117+
expect(extractCalled).equals(true);
118+
});
119+
zipper.appendFiles(fileArr, true);
120+
});
121+
122+
it('appendFiles() throws an error if after last file', (done) => {
123+
const files = new Map(inputFileInfos);
124+
const zipper = new Zipper({zipCompressionMethod: ZipCompressionMethod.STORE});
125+
zipper.start(Array.from(files.values()), true);
126+
expect(() => zipper.appendFiles(Array.from(files.values()), true)).throws();
127+
done();
128+
});
129+
89130
});

0 commit comments

Comments
 (0)