zlib: one-shot gzip()/deflate() results accumulate in arrayBuffers until OOM — GC never prompted (regression in v24.15.0)
Chưa có ai nhận issue này.
- Ngôn ngữ chính
- JavaScript
- Star
- 122k
- Fork
- 37.3k
- Merge trung bình
- 4 ngày 2 giờ
- Pull request đã merge (30 ngày)
- 283
Mô tả
Version
v24.15.0 through v24.20.0 (first bad release: v24.15.0; still present on v24.19.0 and v24.20.0, the latest 24.x at time of filing). Confirmed clean: v22.12.0, v22.22.0, v24.0.0, v24.13.0, v24.14.0.
Platform
Darwin 25.5.0 arm64
Also reproduced in production on Linux x64 (containerized, cgroup v2) — that is where we hit OOMKills.
Subsystem
zlib
What steps will reproduce the bug?
Call the one-shot convenience API (zlib.gzip() — zlib.deflate() behaves identically) in a loop and watch process.memoryUsage().arrayBuffers. No dependencies — save as repro.js:
"use strict";
// One-shot zlib.gzip() in a loop; watch process.memoryUsage().arrayBuffers.
// Run: node repro.js
// With forced GC: FORCE_GC=1 node --expose-gc repro.js
const zlib = require("node:zlib");
const N = 2000;
// ~120 KB compressible payload (repeating structured JSON)
const chunk = Buffer.from(
JSON.stringify({ resourceSpans: [{ scopeSpans: [{ spans: [{ name: "POST /x", attrs: { a: 1 } }] }] }] }),
);
const payload = Buffer.concat(Array(Math.ceil((120 * 1024) / chunk.length)).fill(chunk));
const mb = (x) => (x / 1048576).toFixed(1);
function sample(label) {
if (process.env.FORCE_GC === "1" && global.gc) global.gc();
const m = process.memoryUsage();
console.log(
`${label}\theapUsed=${mb(m.heapUsed)}\texternal=${mb(m.external)}\tarrayBuffers=${mb(m.arrayBuffers)}`,
);
}
console.log(`node=${process.version} force_gc=${process.env.FORCE_GC === "1"}`);
sample("start");
let i = 0;
(function loop() {
if (i >= N) return setTimeout(() => sample("end"), 200);
i++;
if (i % 500 === 0) sample(`i=${i}`);
zlib.gzip(payload, (err) => {
if (err) throw err;
loop();
});
})();
How often does it reproduce? Is there a required condition?
Every run, immediately. Two conditions:
- The one-shot API (
zlib.gzip,zlib.deflate, …). The stream classes are NOT affected — a loop creating acreateGunzip()stream per message stays flat, presumably becauseClose()frees native state deterministically. - A JS heap comfortable enough that V8 does not run major GCs on its own. That is exactly the production steady state of a typical server: our pods sat at ~150 MiB heapUsed against a 432 MiB old-space limit, so nothing ever collected the dead results.
What is the expected behavior? Why is that the expected behavior?
The memory retained by completed one-shot calls should count as GC pressure, so V8 collects the dead result buffers before they accumulate meaningfully. That is what every release up to and including v24.14.0 does — the same loop is flat (sawtooths back down without any forced GC):
=== v24.14.0 ===
node=v24.14.0 force_gc=false
start heapUsed=3.9 external=1.5 arrayBuffers=0.1
i=500 heapUsed=4.2 external=1.7 arrayBuffers=0.4
i=1000 heapUsed=4.4 external=2.1 arrayBuffers=0.7
i=1500 heapUsed=4.5 external=2.2 arrayBuffers=0.8
i=2000 heapUsed=4.2 external=1.7 arrayBuffers=0.3
end heapUsed=4.3 external=1.7 arrayBuffers=0.3
What do you see instead?
From v24.15.0 on, arrayBuffers grows linearly with the number of calls and never comes back down:
=== v24.20.0 ===
node=v24.20.0 force_gc=false
start heapUsed=4.2 external=1.9 arrayBuffers=0.3
i=500 heapUsed=5.2 external=12.2 arrayBuffers=10.6
i=1000 heapUsed=6.9 external=22.6 arrayBuffers=21.0
i=1500 heapUsed=7.2 external=33.1 arrayBuffers=31.4
i=2000 heapUsed=8.5 external=43.5 arrayBuffers=41.9
end heapUsed=8.5 external=43.5 arrayBuffers=41.9
End-state arrayBuffers across versions (same script, 2000 iterations):
| version | end arrayBuffers |
|---|---|
| v22.12.0 | 2.7 MiB ✅ |
| v24.14.0 | 0.3 MiB ✅ |
| v24.15.0 | 31.5 MiB ❌ |
| v24.19.0 | 41.9 MiB ❌ |
| v24.20.0 | 41.9 MiB ❌ |
The buffers are not unreclaimable: with FORCE_GC=1 node --expose-gc repro.js every affected version ends at ~0.3 MiB. The memory is ordinary garbage that nothing ever prompts V8 to collect — which is why the growth is unbounded in a long-running server whose heap is otherwise comfortable.
Growth granularity is the compressed output rounded up to the 16 KB default chunkSize: this compressible 120 KB payload leaks ~16 KB/call; a 16 KB random (incompressible) payload leaks ~31 KB/call; 64 KB random leaks ~94 KB/call.
Additional information
Release-level bisect points at v24.15.0, and the only zlib change in that release is #61717 ("src: refactor compression allocation tracking, enable for zstd"), i.e. commits bef661f182, 3c8f700fd7, 94dbb36d4d, e8079a8297. The behavior is consistent with the one-shot path's retained allocations no longer being reported to V8's external-memory GC-pressure accounting after that refactor.
Real-world impact: @grpc/grpc-js runs zlib.gzip(message, cb) once per outgoing message when compression is enabled (compression-filter.js), so any service exporting OpenTelemetry traces over OTLP/gRPC with CompressionAlgorithm.GZIP leaks per export. After upgrading a production service from Node 22.12 to 24.19 with no dependency changes, its pods accumulated arrayBuffers at ~3 MiB/min and were OOMKilled roughly every 90 minutes; heapUsed stayed flat the whole time. Disabling exporter compression fully mitigates it.
Searched for existing reports before filing: the closest hits are the Blob.stream()/CompressionStream RSS leaks (#64105, #63574, #63708), but those leak RSS rather than arrayBuffers, begin at v24.16 or v26, and are not reclaimed the same way — this one starts exactly at v24.15.0, is fully reclaimed by forced GC, and needs only plain zlib.gzip().
Hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Hướng nghiên cứu
Bắt đầu bằng cách chạy repro.js với các phiên bản Node.js bị ảnh hưởng và sạch, sau đó kiểm tra đường dẫn zlib one-shot và bốn commit từ #61717 được liệt kê trong issue. Theo dõi cách các allocation đã hoàn tất được báo cáo cho V8 và xác minh rằng fix khôi phục áp lực GC mà không ảnh hưởng đến các lớp stream; bản tái hiện sẽ ngừng tích lũy arrayBuffers mà không cần GC cưỡng bức.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Đánh giá
- Công nghệ
- javascript, node.js
- Lĩnh vực
- backend, performance
- Loại issue
- Lỗi
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức độ hoạt động
- Sôi nổi
- Độ rõ ràng
- Khá rõ ràng
- Mức phù hợp với người mới
- 58/100