From b3869f7a40d574c4fe5a79418bfe9ad79118ac6e Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 5 Aug 2026 21:10:05 +0800 Subject: [PATCH 1/3] fix: replace @eggjs/yauzl with upstream yauzl 3.4.0 The fork depends on fd-slicer2, whose ReadStream loses data when piped on Node.js 26: any zip entry over 64 KiB delivers roughly the first chunk and then stalls with no end, no error, and no close. That is why zip.uncompress() hangs until the test timeout on Node 26 while passing on 18 through 24. Upstream yauzl 3.4.0 dropped fd-slicer entirely, its only dependency now being pend, and does not have the bug. Verified identical behaviour on the contain-absolute-path.zip fixture that motivated the fork in the first place: 31 entries, Buffer fileNames under decodeStrings:false, externalFileAttributes intact, and the leading "/" entry still read. Suite is 171 passing on both Node 24 and Node 26. The only visible difference is that yauzl 3.x capitalises the "end of central directory record signature not found" message, so that assertion is now case-insensitive. Reported upstream at node-modules/yauzl#3. --- lib/zip/uncompress_stream.js | 2 +- package.json | 2 +- test/zip/uncompress_stream.test.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/zip/uncompress_stream.js b/lib/zip/uncompress_stream.js index eb6f195..2184aaf 100644 --- a/lib/zip/uncompress_stream.js +++ b/lib/zip/uncompress_stream.js @@ -3,7 +3,7 @@ // https://github.com/thejoshwolfe/yauzl#no-streaming-unzip-api const debug = require('util').debuglog('compressing/zip/uncompress_stream'); -const yauzl = require('@eggjs/yauzl'); +const yauzl = require('yauzl'); const stream = require('stream'); const UncompressBaseStream = require('../base_write_stream'); const utils = require('../utils'); diff --git a/package.json b/package.json index 09900d3..7c7ab57 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ }, "homepage": "https://github.com/node-modules/compressing#readme", "dependencies": { - "@eggjs/yauzl": "^2.11.0", + "yauzl": "^3.4.0", "flushwritable": "^1.0.0", "get-ready": "^1.0.0", "iconv-lite": "^0.7.0", diff --git a/test/zip/uncompress_stream.test.js b/test/zip/uncompress_stream.test.js index 8a8445a..cf2ef56 100644 --- a/test/zip/uncompress_stream.test.js +++ b/test/zip/uncompress_stream.test.js @@ -52,7 +52,7 @@ describe('test/zip/uncompress_stream.test.js', () => { const uncompressStream = new compressing.zip.UncompressStream(); await assert.rejects(async () => { await pipelinePromise(fs.createReadStream(sourceFile), uncompressStream); - }, /end of central directory record signature not found/); + }, /end of central directory record signature not found/i); }); it('should uncompress according to file path', done => { From 35ab10caee762af8fc7c4150d6a5a2190a8d935a Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 5 Aug 2026 21:17:43 +0800 Subject: [PATCH 2/3] fix: update yazl to 3.3.1 and finalize the archive a tick later yazl 3 turns "add entries after calling end()" from a tolerated no-op into a thrown error. compressing hits it because _onEntryFinish() finalizes as soon as the queue is momentarily empty, and for zip the finish callback runs synchronously, so a caller adding entries back to back closed the archive after the first one. yazl 2 accepted the later entries anyway and produced a correct archive, which is why this never surfaced. Finalize on the next tick instead, and skip it if an entry arrived in the meantime. Verified the produced archive still contains every entry. --- lib/tar/stream.js | 10 ++++++++-- package.json | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/tar/stream.js b/lib/tar/stream.js index eb3dbc7..6e64625 100644 --- a/lib/tar/stream.js +++ b/lib/tar/stream.js @@ -127,9 +127,15 @@ class TarStream extends BaseStream { const waitingEntry = this._waitingEntries.shift(); if (waitingEntry) { this.addEntry.apply(this, waitingEntry); - } else { - this._finalize(); + return; } + // A caller adding entries back to back queues the later ones only after this + // returns, and for zip the finish callback runs synchronously, so finalizing + // here would close the archive after the first entry. Give the caller a tick. + setImmediate(() => { + if (this._processing || this._waitingEntries.length > 0) return; + this._finalize(); + }); } _finalize() { diff --git a/package.json b/package.json index 7c7ab57..a793b80 100644 --- a/package.json +++ b/package.json @@ -39,13 +39,13 @@ }, "homepage": "https://github.com/node-modules/compressing#readme", "dependencies": { - "yauzl": "^3.4.0", "flushwritable": "^1.0.0", "get-ready": "^1.0.0", "iconv-lite": "^0.7.0", "streamifier": "^0.1.1", "tar-stream": "^1.5.2", - "yazl": "^2.4.2" + "yauzl": "^3.4.0", + "yazl": "^3.3.1" }, "devDependencies": { "@types/mocha": "10", From 63ecd7cbde0ae272c48bb1bc7087106abe20ca5e Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 5 Aug 2026 21:25:04 +0800 Subject: [PATCH 3/3] fix: end a zip directory entry through flow control, not a timer The directory placeholder emitted 'end' from a setImmediate, so the event fired whether or not the consumer had finished with the entry. A listener that creates the directory asynchronously would therefore be handed the next entry, a file inside that directory, before the directory existed, and the write failed with ENOENT. That is the intermittent "ENOENT ... /xxx/bar.txt" seen in test/zip/uncompress_stream.test.js on loaded CI runners. Reproduces every time by delaying the mkdir in the entry handler. Push EOF instead, so 'end' arrives only once the consumer reads or resumes the entry and the ordering no longer depends on timing. --- lib/zip/uncompress_stream.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/zip/uncompress_stream.js b/lib/zip/uncompress_stream.js index 2184aaf..b3ad0dc 100644 --- a/lib/zip/uncompress_stream.js +++ b/lib/zip/uncompress_stream.js @@ -125,7 +125,11 @@ class ZipUncompressStream extends UncompressBaseStream { const placeholder = new stream.Readable({ read() {} }); debug('directory, header: %j', header); this.emit('entry', header, placeholder, next); - setImmediate(() => placeholder.emit('end')); + // Push EOF rather than emitting 'end' on a timer: a fabricated event fires + // whether or not the consumer has finished with the entry, so a listener + // that creates the directory asynchronously would see the next entry, a + // file inside that directory, arrive before the directory exists. + placeholder.push(null); } }) .on('end', () => this._finalCallback())