Skip to content

Zlib inflate() holds and reuses buffer until program exit, is never freed limiting memory for other uses. #45303

Open
@nitram-work

Description

@nitram-work

Version

v16.17.1

Platform

Linux 5.15.0-52-generic #58~20.04.1-Ubuntu SMP Thu Oct 13 13:09:46 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux

Subsystem

zlib

What steps will reproduce the bug?

Code snippet (run with --expose-gc)

const { exit } = require('process');
const { deflateSync, inflate } = require('zlib');

const MEGABYTES = 1024 * 1024;

if (!global.gc) {
  console.error('Please run with `node --expose-gc`');
  exit(1);
}

const main = async () => {
  process.on('exit', () => gcAndLogMem('at exit handler'));

  createAndFreeBuffer(2000 * MEGABYTES);

  const compressed = compressBuffer(1000 * MEGABYTES);

  // Decompressing or compressing-freeing several times reuses zlib reserved buf
  for (let i = 0; i < 3; i++) {
    gcAndLogMem('before decompress');
    await decompressBuffer(compressed);
    gcAndLogMem('after decompress');
  }

  createAndFreeBuffer(2000 * MEGABYTES);
  gcAndLogMem('before exit');
};

// Create a very compressible pattern buffer to make sure it is allocated.
const createFilledBuffer = size => {
  const buf = Buffer.alloc(size);
  for (let i = 0; i < size; i++) buf[i] = Math.floor(i / 1000) & 0xff;
  return buf;
};

const createAndFreeBuffer = size => {
  console.log('create buffer {');
  let buf = createFilledBuffer(size);
  gcAndLogMem('  created buffer');
  buf = null;
  gcAndLogMem('  freed buffer');
  console.log('} create buffer');
};

const compressBuffer = size => {
  console.log('compress {');
  let input = createFilledBuffer(size);
  gcAndLogMem('  fill input');
  const compressed = deflateSync(input);
  gcAndLogMem('  deflated');
  input = null;
  gcAndLogMem('  freed input');
  console.log('} compress');
  return compressed;
};

const decompressBuffer = async compressed => {
  console.log('decompress {');
  let output = null;
  await new Promise(resolve => {
    inflate(compressed, (err, decompressed) => {
      output = decompressed;
      gcAndLogMem('after inflate');
      resolve();
    });
  });
  gcAndLogMem('  after promise');
  output = null;
  gcAndLogMem('  free output');
  console.log('} decompress');
};

const gcAndLogMem = m => {
  global.gc();
  global.gc();
  console.log(
    `${m.padEnd(16, '.')} GC CALL/EXT MEM= ${(
      process.memoryUsage().external / MEGABYTES
    ).toFixed(1)}MB`,
  );
};

main();

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

Always

What is the expected behavior?

Memory should lower back to near zero after finishing each decompression routine/scope.

What do you see instead?

Memory never lowers back to "zero" until the exit handler, not allowing it's reuse for something else.
It's not a cumulative memory leak, but keeps it reserved forever.
In a system with 2.5 GB memory limit it would crash due to OOM, even if we could fit 2GB easily if that memory held by zlib was released. Try it: (ulimit -Sd 2500000; node --expose-gc ./script.js )

Script output unbounded
$ node --expose-gc script.js

create buffer {
  created buffer GC CALL/EXT MEM= 2000.3MB
  freed buffer.. GC CALL/EXT MEM= 0.3MB
} create buffer
compress {
  fill input.... GC CALL/EXT MEM= 1000.3MB
  deflated...... GC CALL/EXT MEM= 1003.3MB
  freed input... GC CALL/EXT MEM= 3.3MB
} compress
before decompress GC CALL/EXT MEM= 3.3MB
decompress {
after inflate... GC CALL/EXT MEM= 2003.4MB
  after promise. GC CALL/EXT MEM= 2003.4MB
  free output... GC CALL/EXT MEM= 1003.4MB <--------- this internal buffer is never released
} decompress
after decompress GC CALL/EXT MEM= 1003.4MB
before decompress GC CALL/EXT MEM= 1003.4MB
decompress {
after inflate... GC CALL/EXT MEM= 2003.4MB <--------- but it looks like it's reused by zlib
  after promise. GC CALL/EXT MEM= 2003.4MB
  free output... GC CALL/EXT MEM= 1003.4MB
} decompress
after decompress GC CALL/EXT MEM= 1003.4MB
before decompress GC CALL/EXT MEM= 1003.4MB
decompress {
after inflate... GC CALL/EXT MEM= 2003.4MB
  after promise. GC CALL/EXT MEM= 2003.4MB
  free output... GC CALL/EXT MEM= 1003.4MB
} decompress
after decompress GC CALL/EXT MEM= 1003.4MB
create buffer {
  created buffer GC CALL/EXT MEM= 3003.4MB  <------ here crashes with limited memory 
  freed buffer.. GC CALL/EXT MEM= 1003.4MB
} create buffer
before exit..... GC CALL/EXT MEM= 1003.4MB
at exit handler. GC CALL/EXT MEM= 0.3MB

Script output boudned to 2500 MB (in Ubuntu, YMMV)
( ulimit -Sd 2500000 ; node --expose-gc script.js )

create buffer {
  created buffer GC CALL/EXT MEM= 2000.3MB
  freed buffer.. GC CALL/EXT MEM= 0.3MB
} create buffer
compress {
  fill input.... GC CALL/EXT MEM= 1000.3MB
  deflated...... GC CALL/EXT MEM= 1003.3MB
  freed input... GC CALL/EXT MEM= 3.3MB
} compress
before decompress GC CALL/EXT MEM= 3.3MB
decompress {
after inflate... GC CALL/EXT MEM= 2003.4MB
  after promise. GC CALL/EXT MEM= 2003.4MB
  free output... GC CALL/EXT MEM= 1003.4MB
} decompress
after decompress GC CALL/EXT MEM= 1003.4MB
before decompress GC CALL/EXT MEM= 1003.4MB
decompress {
after inflate... GC CALL/EXT MEM= 2003.4MB
  after promise. GC CALL/EXT MEM= 2003.4MB
  free output... GC CALL/EXT MEM= 1003.4MB
} decompress
after decompress GC CALL/EXT MEM= 1003.4MB
before decompress GC CALL/EXT MEM= 1003.4MB
decompress {
after inflate... GC CALL/EXT MEM= 2003.4MB
  after promise. GC CALL/EXT MEM= 2003.4MB
  free output... GC CALL/EXT MEM= 1003.4MB
} decompress
after decompress GC CALL/EXT MEM= 1003.4MB
create buffer {
at exit handler. GC CALL/EXT MEM= 1003.4MB
node:internal/buffer:959
    super(bufferOrLength, byteOffset, length);
    ^

RangeError: Array buffer allocation failed
    at new ArrayBuffer (<anonymous>)
    at new Uint8Array (<anonymous>)
    at new FastBuffer (node:internal/buffer:959:5)
    at Function.alloc (node:buffer:371:10)
    at createFilledBuffer (/home/xxx/src/script.js:32:22)
    at createAndFreeBuffer (/home/xxx/src/script.js:39:13)
    at main (/home/xxx/src/script.js:26:3)
    at runMicrotasks (<anonymous>)
    at processTicksAndRejections (node:internal/process/task_queues:96:5)

Additional information

This was a related issue I previously raised and closed myself because it wasn't a leak and I was misguided by understanding how zlib worked #44750
But even now understanding the peak memory use would be 2x the inflated buffer (the chunks must be unified in another buffer) it still doesn't explain why it is not released.

I thought to open a new issue because the original title would be misleading. It's not a memory leak per se as it doesn't accumulate, but it nevers releases those buffers that might be needed by other parts of the program.

Metadata

Metadata

Assignees

No one assigned

    Labels

    zlibIssues and PRs related to the zlib subsystem.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions