Skip to content

openReadStream() stalls on Node.js 26 for entries larger than 64 KiB #3

Description

@fengmk2

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions