On Node.js 26, openReadStream() delivers a partial entry and then goes quiet: no end, no error, no close. Entries smaller than the 64 KiB highWaterMark are unaffected, so the failure only shows up once a second read is needed.
Works on Node 24, fails on Node 26. Reproduced with @eggjs/yauzl@2.11.0 and fd-slicer2@1.2.0 (both current).
Reproduction
Self-contained, needs only yazl and @eggjs/yauzl. Random bytes are used so the entry stays incompressible and its compressed size stays above 64 KiB.
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
const yazl = require('yazl');
const yauzl = require('@eggjs/yauzl');
const SIZE = 200 * 1024;
const payload = crypto.randomBytes(SIZE);
const zipPath = path.join(os.tmpdir(), 'yauzl-node26-repro.zip');
const zf = new yazl.ZipFile();
zf.addBuffer(payload, 'big.bin');
zf.end();
zf.outputStream.pipe(fs.createWriteStream(zipPath)).on('close', read);
function read() {
yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => {
if (err) throw err;
zip.readEntry();
zip.on('entry', entry => {
zip.openReadStream(entry, (err, rs) => {
if (err) throw err;
let bytes = 0;
rs.on('data', d => { bytes += d.length; });
rs.on('end', () => done('end', bytes));
rs.on('error', e => done('error: ' + e.message, bytes));
setTimeout(() => done('STALL (no end event)', bytes), 5000);
});
});
});
}
let settled = false;
function done(tag, bytes) {
if (settled) return; settled = true;
console.log('node %s -> %s | bytes: %d / %d', process.version, tag, bytes, SIZE);
process.exit(0);
}
Output:
node v24.19.0 -> end | bytes: 204800 / 204800
node v26.6.0 -> STALL (no end event) | bytes: 196548 / 204800
Where the problem is
It is not zlib and not the chunk boundary. Holding everything else constant and reading the identical byte range of the same entry into the same zlib.createInflateRaw(), only the source stream differs:
| source stream |
Node 24 |
Node 26 |
fs.createReadStream({fd, start, end}) |
94904 bytes, ends |
94904 bytes, ends |
fd-slicer2 (what yauzl uses) |
94904 bytes, ends |
stalls at 66342 |
Ruled out along the way:
- zlib. Feeding
createInflateRaw() the exact captured deflate payload works on Node 26, whether as one chunk, split at 64 KiB, or in three chunks.
- Chunk sizes.
Readable.from() with the same splits works on Node 26.
- Async pushing. A hand-written
Readable that pushes those chunks from real async fs.read callbacks, imitating fd-slicer2, works on Node 26.
The odd part: fd-slicer2's own accounting is identical on both versions. Instrumenting its ReadStream shows 3 _read calls, all 93836 bytes pushed, push() returning true each time, and EOF pushed once, on Node 24 and Node 26 alike. It reports delivering everything while only about 64 KiB actually reaches the destination, so the loss is in how its stream interacts with pipe() on Node 26 rather than in what it reads.
Suggested fix
Replace fd-slicer2's hand-rolled ReadStream with fs.createReadStream({ fd, start, end, autoClose: false }), which the table above shows behaves correctly on Node 26. Node's implementation also handles backpressure, destroy(), and error propagation properly.
Worth noting separately: fd-slicer2's destroy() override always emits an error and does not follow Node's _destroy(err, cb) contract, which is the kind of divergence that tends to break against newer stream internals.
Impact
This affects any consumer reading zip entries larger than 64 KiB on Node 26. It surfaced in compressing, where zip.uncompress() hangs until the test timeout on Node 26 while passing on 18 through 24.
Environment
@eggjs/yauzl 2.11.0, fd-slicer2 1.2.0
- Node.js 26.6.0 fails, 24.19.0 works
- macOS arm64, also reproduced on ubuntu-latest and windows-latest in GitHub Actions
On Node.js 26,
openReadStream()delivers a partial entry and then goes quiet: noend, noerror, noclose. Entries smaller than the 64 KiBhighWaterMarkare unaffected, so the failure only shows up once a second read is needed.Works on Node 24, fails on Node 26. Reproduced with
@eggjs/yauzl@2.11.0andfd-slicer2@1.2.0(both current).Reproduction
Self-contained, needs only
yazland@eggjs/yauzl. Random bytes are used so the entry stays incompressible and its compressed size stays above 64 KiB.Output:
Where the problem is
It is not zlib and not the chunk boundary. Holding everything else constant and reading the identical byte range of the same entry into the same
zlib.createInflateRaw(), only the source stream differs:fs.createReadStream({fd, start, end})fd-slicer2(what yauzl uses)Ruled out along the way:
createInflateRaw()the exact captured deflate payload works on Node 26, whether as one chunk, split at 64 KiB, or in three chunks.Readable.from()with the same splits works on Node 26.Readablethat pushes those chunks from real asyncfs.readcallbacks, imitating fd-slicer2, works on Node 26.The odd part: fd-slicer2's own accounting is identical on both versions. Instrumenting its
ReadStreamshows 3_readcalls, all 93836 bytes pushed,push()returningtrueeach time, and EOF pushed once, on Node 24 and Node 26 alike. It reports delivering everything while only about 64 KiB actually reaches the destination, so the loss is in how its stream interacts withpipe()on Node 26 rather than in what it reads.Suggested fix
Replace
fd-slicer2's hand-rolledReadStreamwithfs.createReadStream({ fd, start, end, autoClose: false }), which the table above shows behaves correctly on Node 26. Node's implementation also handles backpressure,destroy(), and error propagation properly.Worth noting separately:
fd-slicer2'sdestroy()override always emits anerrorand does not follow Node's_destroy(err, cb)contract, which is the kind of divergence that tends to break against newer stream internals.Impact
This affects any consumer reading zip entries larger than 64 KiB on Node 26. It surfaced in
compressing, wherezip.uncompress()hangs until the test timeout on Node 26 while passing on 18 through 24.Environment
@eggjs/yauzl2.11.0,fd-slicer21.2.0