Repository navigation
Conversation
… output reset() on a Gzip stream dropped the deflate state of a member whose header was already in the output. The next member followed that fragment, and gunzip failed on the whole output with Z_DATA_ERROR. Node.js v26.11.0 and later refuse that reset (nodejs/node#66179). The native handle reports ERR_ZLIB_INCOMPLETE_FRAME through onerror, and the stream is destroyed. The zlib Context now records that the open gzip member has given output, and Context::reset returns the same error before it calls deflateReset. ZlibBase.prototype.reset throws ERR_INTERNAL_ASSERTION for a closed stream, as Node.js does.
|
Updated 9:39 AM PT - Oct 29th, 2026
✅ @robobun, your commit cb6b302fbd5b9e1c487411a6e41777e45018a31e passed in 🧪 To try this PR locally: bunx bun-pr 47364That installs a local version of the PR into your bun-47364 --bun |
StatusHow the problem reproduces, on main (da867c6) and on Bun 1.4.3: import zlib from "node:zlib";
const z = zlib.createGzip(), out = [];
z.on("error", e => console.log("error", e.code));
z.on("data", d => out.push(d));
await new Promise(r => z.write("hello", r));
await new Promise(r => z.flush(r));
z.reset();
if (!z.destroyed) {
z.end("world");
await new Promise(r => z.on("end", r));
console.log(zlib.gunzipSync(Buffer.concat(out)).toString());
}
The fix is this pull request, #47364. The zstd half is the draft #47376, on top of #47079. CI on cb6b302 (build 135173) passes. |
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Native output tracking and reset rules src/runtime/node/zlib/NativeZlib.rs |
The native context tracks output from GZIP members. Reset returns ERR_ZLIB_INCOMPLETE_FRAME when an incomplete GZIP member has emitted output. The UNZIP multi-member loop uses the underlying reset helper. |
Stream API and regression coverage src/js/node/zlib.ts, test/js/node/test/parallel/test-zlib-reset-incomplete-output.js, test/js/node/zlib/zlib-reset-incomplete-frame.test.ts |
The JavaScript reset method throws ERR_INTERNAL_ASSERTION when the handle is absent. Tests cover GZIP reset outcomes, other stream types, native-handle behavior, and reset after destruction. |
Suggested reviewers: jarred-sumner
Priority:
Merge Risk: 
Minimal · up to ccdc6
-
The change rejects gzip resets that would discard incomplete output and adds regression coverage. No concrete merge-blocking issue remains; mergeable subject to normal checks.
Pre-merge checks: 
4
-
Full details: Title check
-
Explanation
The title names the main change: gzip reset() is refused when a member is open and has produced output. It matches the raw summary, which describes Context tracking gzip output and reset() returning ERR_ZLIB_INCOMPLETE_FRAME. It is specific, concise, and free of file lists or vague terms. It is 76 characters, which is slightly long but still readable.
Full details: Description check
-
Explanation
The description is complete and relevant. It explains the problem, implementation, scope, limitations, and verification results, including untested platforms. It does not use the template headings verbatim, but it provides equivalent information for both required sections.
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Comment @coderabbitai help to get the list of available commands.
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline finding, I also checked: the new ERR_ZLIB_INCOMPLETE_FRAME reaches JS through the existing emit_error → onerror → zlibOnError path (node_zlib_binding.rs reset, zlib.ts zlibOnError), so no ErrorCode.ts entry is needed and the stream is destroyed with the exact message/errno/code; output_emitted is only read by reset() after the write_in_progress guard and only written by do_work_gzip, so there is no cross-thread race; the gunzip multi-member loop and the UNZIP→GUNZIP transition never route through do_work_gzip, so decompressors are unaffected.
Extended reasoning...
The change adds a single output_emitted flag to the zlib Context, splits GZIP out of the deflate arm of do_work, makes Context::reset refuse with Node's error when a gzip member has emitted output, and replaces a debug-only $assert in ZlibBase.prototype.reset with an ERR_INTERNAL_ASSERTION throw; it touches no security-sensitive surface. The inline finding (double init() leaking z_stream state) is pre-existing but is now exercised directly by the new test, and a second verified finding was not posted, so approval is not appropriate; the note records the concrete concerns examined and ruled out.
…ION, as in Node.js Node.js v26.12.1 gives the same answer for each receiver of zlib.Deflate.prototype.reset: ERR_INTERNAL_ASSERTION for an object, a number or a string, and a TypeError for undefined and null.
Problem
reset()on a Gzip stream that has given part of a member returns with no error, and the output fails to decode (Z_DATA_ERROR). A fuzz comparison with Node.js found it, not a user.ERR_ZLIB_INCOMPLETE_FRAMEand destroy the stream (zlib: reject reset after gzip emitted incomplete output nodejs/node#66179).Context::reset(src/runtime/node/zlib/NativeZlib.rs:366) callsdeflateResetwith no check.Fix
Contextrecords that the open gzip member has given output.Context::resetreturns Node's error beforedeflateReset. The existingonerrorpath destroys the stream.reset()on a closed stream throws Node'sERR_INTERNAL_ASSERTION. It was aTypeError.test/js/node/zlib/zlib-reset-incomplete-frame.test.ts(main fails 21 of 32, Node.js v26.12.1 passes all) and Node'stest-zlib-reset-incomplete-output.js, unmodified. Also 63 vendoredtest-zlib*.jsfiles.init()(see Notes).Background
deflate()call puts out the gzip header. Afterreset()the end of that member never follows.Contextis the codec state behind the native handle (_handle) of one stream.do_workruns once per native write.ZlibBase.prototype.reset: it misses_handle.reset()and a sync_processChunk(), and adds work to every write completion.Downsides
reset()per segment on one Gzip stream now destroys the stream. With no'error'listener the process exits. Node.js before v26.11.0 accepts it.do_work(16 → 27 or 28). Other modes are unchanged.Notes
What each runtime does
"main" is da867c6. Bun 1.4.3 gives the same results as main.
hellois written first, andworldis written withend()if the stream is still open.createGzip()reset()Z_DATA_ERROR'error'ERR_ZLIB_INCOMPLETE_FRAME, destroyedcreateGzip()reset()Z_DATA_ERRORERR_ZLIB_INCOMPLETE_FRAMEcreateGzip()params(),reset()ERR_ZLIB_INCOMPLETE_FRAMEcreateGzip()Z_FINISH),reset()helloworldhelloworldhelloworldcreateDeflate()reset()Z_DATA_ERRORcreateDeflateRaw()reset()helloworldcreateBrotliCompress()reset()reset(), all of the input_handlewriteSync(flush),reset()onerror(message, -2, "ERR_ZLIB_INCOMPLETE_FRAME")destroy(),reset()TypeError(release build)ERR_INTERNAL_ASSERTION"zlib binding closed"createZstdCompress()reset()ZSTD_error_corruption_detectedERR_ZLIB_INCOMPLETE_FRAMEProbe scripts, one line per row, compared with Node.js v26.12.1 on release builds:
The error
Error, codeERR_ZLIB_INCOMPLETE_FRAME, errno -2 (Z_STREAM_ERROR), message "Cannot reset a gzip stream with an incomplete member; end the stream or start a new gzip compressor".onerror, andzlibOnErrorinzlib.tsmakes theError. Node.js does the same, and the code is not in itslib/internal/errors.js. So there is noErrorCode.tsentry.reset()returnsundefined. The stream is already destroyed when it returns, and'error'follows on the next tick.reset()does not change the codec. The member can still be finished, and it decodes.Sources:
ZlibContext::ResetStreamand nodejs/node#66179. The Node.js documentation ofzlib.reset()states the refusal, and states that zlib-wrapped deflate and raw deflate stay resettable.One flag where Node.js has two
Node.js keeps
stream_complete_andoutput_emitted_, and refuses if!stream_complete_ && output_emitted_. The code setsstream_complete_only where it clearsoutput_emitted_, and adeflate()call that fails puts out no bytes. So the two are never both true, and the test is the same asoutput_emitted_. A second flag could never change the result, and no test could cover it.Only the GZIP arm of
do_workwrites the flag. Node.js writes its flags for Deflate and DeflateRaw too and reads them only for GZIP.The gunzip loop over the members of one input calls
reset_z_stream(), which isreset()without the check. So that loop runs the same instructions as on main.ZstdCompress is not in this PR
Contextcalls. node:zlib: reset a zstd stream in place, keep its dictionary and parameters #44261, node:zlib: zstd pledgedSrcSize and dictionary follow Node v26.12.1 #47027, node:zlib: zstd decoder follows Node v26.10.0 across writes and at the end of the input #33413 and node:zlib: call ZSTD_compressStream2 until a write's input is taken #46891 edit the same lines ofNativeZstd.rs.test-zlib-zstd-reset-incomplete-frame.js.Measurements
Release builds of main da867c6 and of this PR, linux x64, one build directory.
Context::do_work(gdb single-step count, callees not counted):Z_FINISH). The count includes the newdo_work_gzip.llvm-objdump): brotlido_work, zstddo_work, the threecheck_error, the threewrite, the threewrite_sync, brotlireset, zstdreset. 13 compared, 0 differ.do_work_inflateandset_dictionarydiffer only in the byte offset ofmode(0x28 → 0x29).writeSync, end to end (perf_event_open, slope of 1e6 and 2e6 calls, median of 7 interleaved runs): gzip 1,037 → 1,047, deflate 912 → 911, inflate 964 → 963, zstd 841 → 842, brotli 919 → 922. The runs of one build spread over 5 to 43 instructions.do_work_gzipcalls onlydeflate.heapStats().objectCountover 1e6writeSynccalls does not grow on main or with this PR.Context48 → 48 B. The flag takes a padding byte. NativeZlib 152 → 152 B (the argument ofmi_must_mallocin the constructor).do_work154 → 159 B anddo_work_gzip66 B.CompressionStreamImpl::resetof NativeZlib 744 → 773 B. Release.text+256 B and.bun_builtins+205 B.dataandbssare the same. No host function and no bindgen entry is new.reset()itself: the JS function has 6 → 24 bytecodes, and 5 → 8 run for an open stream. The native part of an accepted gzip reset runs 58 → 61 instructions.valgrind,perfandbloatyare not on this machine. The numbers are fromgdb,llvm-objdump,nm -S,llvm-sizeand aperf_event_opencounter.Tests
test/js/node/zlib/zlib-reset-incomplete-frame.test.tsusesnode:testandnode:assert. The same file passes under Node.js v26.12.1 (node --test, 32 pass). Node.js v26.3.0 fails 10 of 32, because it resets in silence. A debug build and a release build of this PR: 32 pass. A release build of main: 21 fail, 11 pass.test-zlib-reset-incomplete-output.jsintest/js/node/test/parallel/is the file of Node.js v26.12.1, byte for byte. main fails 2 of 7. This PR passes 7.members-of-prototype-objects-for-any-receiver.test.tschanges forreset()on a receiver with no_handle:ERR_INTERNAL_ASSERTIONfor an object, a number or a string, where main throws aTypeError. Node.js v26.12.1 gives the same answer for each receiver.detect_leaks=1,BUN_DESTRUCT_VM_ON_EXIT=1,BUN_JSC_validateExceptionChecks=1).Z_STREAM_END(2),Z_STREAM_ENDhandled as the other results (4), no check inreset()(12), the flag assigned on each call (2), the flag set with no comparison ofavail_out(2). The throw inzlib.tsis covered by 11 tests that fail on main. The clear ininithas no test (see the next section).test-zlib*.js, 6test-stream-iter-transform*.jsand 3test-webstreams-*compression*.jsvendored files,zlib.test.js(473 pass),zlib-reset-race.test.ts,zlib-onerror-reentrancy.test.ts,zlib-handle-bounds-check.test.ts,bytesWritten.test.ts,deflate-streaming.test.ts,zlib-estimated-size-gc.test.ts,finished-call-memory.test.ts,zlib.kMaxLength.global.test.js,test/regression/brotli-reset-leak.test.ts,test/js/web/streams/compression.test.ts(73 pass) andtest/js/bun/util/zstd.test.ts(99 pass).A second
init()leaks on mainContext::initclears the flag, as Node'sZlibContext::Initclears its two. Soinit()thenreset()is accepted, as in Node.js.init()on a live zlib handle callsdeflateInit2over the first state and leaks it. This is on main. LeakSanitizer reports 350,336 bytes for one such call on the debug ASAN build, so a test of this path aborts the ASAN lane.init()one time. Only code that drives_handleitself can reach a second one.initfor all 7 zlib modes, and Node.js does a different thing there (it keeps the first state). It is not in this PR.Rows that still differ from Node.js
params()on a destroyed Gzip stream, also after a refusedreset(). Node.js throws an uncaughtERR_INTERNAL_ASSERTIONfromparamsAfterFlushCallback. Bun returns and does not call the callback. Hardening follow-up: fix regressions from #46021 and more crashes #46270 made that choice._handle.close(),_handle.reset(). Bun returns for each closed handle (node_zlib_binding.rs:597). Node.js aborts for Gzip ("Unreachable code reached" inInitZlib). The stream API cannot reach this state: it drops the handle when it closes it.The
reset()test files of Node.js v26.12.1test-zlib-dictionary.jstest-zlib-reset-before-write.jstest-zlib-reset-during-write.jstest-zlib-reset-incomplete-output.jstest-zlib-zstd-reset-incomplete-frame.jstest-zlib-zstd-reset.jstest-zlib-brotli-reset.jsreset()Other pull requests
git merge-treeof this branch with the heads of #47079, #47073, #47140, #47027, #33413 and #46891 reports no conflict. #44261 conflicts with main in two files that this PR does not touch.