Skip to content

stream: Readable setting destroyed=true before push(null) drops final chunk through createInflateRaw (regression in 24.16) #63992

@mja00

Description

@mja00

Version

v24.16.0 (also reproduces on v26.1.0; last good v24.15.0)

Platform

Linux matt-desktop-cachyos 7.0.12-1-cachyos #1 SMP PREEMPT_DYNAMIC Sat, 13 Jun 2026 07:50:52 +0000 x86_64 GNU/Linux

Subsystem

stream and zlib

What steps will reproduce the bug?

Save the follow as repro.cjs then node repro.cjs:

const { Readable } = require('node:stream');
const zlib = require('node:zlib');

const raw = Buffer.allocUnsafe(384 * 1024);
let x = 0x9e3779b9 >>> 0;
for (let i = 0; i < raw.length; i++) {
  x ^= x << 13; x ^= x >>> 17; x ^= x << 5; x >>>= 0;
  raw[i] = x % 6 === 0 ? (x & 0xff) : ((i >> 4) & 0x3f);
}
const compressed = zlib.deflateRawSync(raw, { level: 6 });

class FdSliceLikeReadable extends Readable {
  constructor(buf) { super(); this.buf = buf; this.pos = 0; this.ended = false; }
  _read(n) {
    if (this.ended) return;
    const toRead = Math.min(this._readableState.highWaterMark, n, this.buf.length - this.pos);
    if (toRead <= 0) {
      this.destroyed = true;   // remove this line and 'end' fires normally
      this.ended = true;
      this.push(null);
      return;
    }
    const start = this.pos; this.pos += toRead;
    setImmediate(() => this.push(this.buf.subarray(start, start + toRead)));
  }
}

let bytes = 0;
const inflate = new FdSliceLikeReadable(compressed).pipe(zlib.createInflateRaw());
inflate.on('data', (c) => { bytes += c.length; });
inflate.on('end', () => console.log(`OK end fired: ${bytes}/${raw.length} bytes`));
process.on('exit', () => {
  if (bytes !== raw.length) console.log(`BUG: no 'end'; delivered ${bytes}/${raw.length}`);
});

How often does it reproduce? Is there a required condition?

Happens every run deterministically.

What is the expected behavior? Why is that the expected behavior?

I expect end to fire and receive a OK end fired: 393216/393216 bytes in the repro snippet.

What do you see instead?

On 24.16.0/26.1.0 inflate stalls partway and end never fires; the process exits with the read unfinished: BUG: no 'end'; delivered 295677/393216. Real apps awaiting end see "Detected unsettled top-level await."

Additional information

Appears to be a regression between 24.15.0 to 24.16.0. I haven't had time to bisect the versions to find a likely commit but it's most likely around anything stream related.

Removing the this.destroyed = true assignment, or replacing createInflateRaw with a PassThrough, both make the hang disappear, so the trigger is a Readable mutating the managed destroyed flag before push(null) while feeding a zlib transform. This is the root cause of the yauzl/extract-zip hangs in #63487 (their fd-slicer dependency uses this exact _read pattern).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions