node:zlib: don't abort when the native handle is driven outside the zlib.ts lifecycle - #32762
Conversation
…lib.ts lifecycle The zlib.ts wrapper drives NativeZlib/NativeBrotli/NativeZstd as constructor -> init() -> write*() -> close(), caches the onerror and writeCallback JS functions in init(), and nulls `_handle` on close. The native binding assumed that protocol; a handle driven outside it (reachable from JS through `_handle` and `_handle.constructor`) aborted the whole process with a Rust panic, or handed a null state pointer straight into brotli/zstd. - CompressionStream::throw_if_closed rejects write()/writeSync()/init() on a closed handle (the Context is in NodeMode::NONE, which the mode matches in do_work()/Context::init() treat as unreachable). reset() already had this check. - Brotli/Zstd Context::do_work() returns early when the native state was never created: BrotliEncoderCompressStream / ZSTD_compressStream2 dereference the state pointer unconditionally. - emit_error() and the async write-completion path skip the JS callback when none is cached instead of panicking. The pending reset/close handling still runs. - NativeZlib::init() rejects a dictionary longer than u32::MAX before copying it; deflateSetDictionary takes a uInt length. Brotli/Zstd Context::close() idempotency is #32382 and brotli set_flush with zlib flush constants is #32358; both are intentionally not touched here.
|
Updated 9:52 AM PT - Jun 26th, 2026
❌ @robobun, your commit fa7abd9 has 3 failures in
🧪 To try this PR locally: bunx bun-pr 32762That installs a local version of the PR into your bun-32762 --bun |
WalkthroughThe PR adds closed-state checks to zlib, brotli, and zstd init paths, guards Brotli and zstd work loops against missing state, makes zlib callback dispatch optional when callbacks are absent, validates zlib dictionary length, and adds subprocess-based tests. ChangesZlib lifecycle and bounds checks
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/node/node_zlib_binding.rs`:
- Around line 282-296: The guard logic in throw_if_closed is fine, but the doc
comment above it contains a broken trailing clause that does not read correctly.
Clean up the documentation text in that comment by removing or rephrasing the
malformed “which none of its `mode` matches handle” fragment so the explanation
of closed Context state is grammatically correct and concise.
In `@test/js/node/zlib/zlib-handle-bounds-check.test.ts`:
- Around line 160-168: The run helper in zlib-handle-bounds-check.test.ts is
leaving Bun.spawn’s stderr pipe unread, which can deadlock when the child emits
large diagnostics. Update the run function to drain stderr concurrently with
stdout and exited, using the existing Bun.spawn proc object in run so all pipes
are consumed before returning. Keep the current stdout/exitCode result shape and
the existing assertions unchanged, but make sure stderr is actively read rather
than ignored.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: b08cfbc9-4c5c-4577-9ab2-7de6a8f8284a
📒 Files selected for processing (5)
src/runtime/node/node_zlib_binding.rssrc/runtime/node/zlib/NativeBrotli.rssrc/runtime/node/zlib/NativeZlib.rssrc/runtime/node/zlib/NativeZstd.rstest/js/node/zlib/zlib-handle-bounds-check.test.ts
…never-initialized one The ASAN CI lane tears the VM down at exit, running every GC finalizer. Once write/reset/init stop aborting early, a handle whose native state was never created reaches the finalizer, which had its own aborts: - Brotli/Zstd Context::close() on a never-created `state`: brotli's deinit_state unwraps the `None`; zstd hands the null CCtx straight to ZSTD_CCtx_reset. Both now early-return. This also makes close() idempotent (the user-called double-close #32382 fixes). - NativeZlib Context::close() asserted that deflateEnd accepted the stream; on a z_stream whose init never ran it returns Z_STREAM_ERROR with nothing to free, so the assertion allows it. Two more gaps in the same surface from review: - init() (all three classes) and NativeZlib::params() only rejected a *closed* handle, so calling them while an async write() still held the Context on a worker thread mutated the same native stream from two threads. `throw_if_closed` becomes `throw_unless_idle` (write_in_progress / pending_close / closed); write()/writeSync() use it too instead of carrying their own copies of the first two checks. - A brotli/zstd init() whose parameter setup fails tears the Context down without marking the handle closed, so the next writeSync()/init() still aborted. That path now sets `closed`. The test children run with BUN_DESTRUCT_VM_ON_EXIT=1 so every finalizer runs deterministically, not only on the lanes that destroy the VM.
…cts init()
`Context::init` leaves `mode` at `NONE` and returns silently when the
zlib init call fails (e.g. `windowBits` outside 8..=15), so nothing set
the wrapper's `closed` flag: the same `{ closed: false, mode: NONE }`
state the brotli/zstd parameter-failure branches were just taught to
avoid. The next `init()` then passed `throw_unless_idle` and reached
`Context::init`'s `NONE => unreachable!()` arm, aborting the process.
|
CI status note for reviewers: the real failure CI ever found on this PR was its own doing, and it is fixed. The x64-asan lane destroys the VM at exit, which ran the GC finalizer for the never-initialized handles the new tests create and exposed the finalizer aborts described above; those are fixed and the test children now set Since that fix, |
What
The
zlib.tswrapper drives theNativeZlib/NativeBrotli/NativeZstdhandles as constructor ->init()->write*()->close(), cachesonerrorand the write callback ininit(), and nulls_handleon close. The native binding assumed that protocol. Driving a handle outside it, which only takes reaching_handle(and itsconstructor), aborts the whole process. Eleven distinct ways, all reproduced on the release binary; two are null-pointer segfaults and one needs nothing butnew:Plus two that are not aborts but are worse:
init()orparams()called while an asyncwrite()is still running on the thread pool mutates the same nativez_stream/ encoder from two threads (the worker holds&mut Contextinsidedeflate()while the JS thread runsdeflateInit2_on it), and a brotli/zstdinit()whose parameter setup fails tears theContextdown without marking the handle closed, so the next call aborts anyway.Why
close(), or after a failedinit(), theContextis inNodeMode::NONEwith its native state freed;Context::{do_work,init}and brotli'sget_error_infohave no arm forNONEand hitunreachable!().reset()already guarded onclosed;write,writeSyncandinitdid not, and nothing guarded against an in-flight write.init()can fail three different ways without anything recording it: zlib'sdeflateInit2_/inflateInit2_rejecting the arguments, and brotli/zstd's per-parameter setup failing after the state was created. All three leave{ closed: false, mode: NONE }.init(), brotli/zstd have no encoder/decoder state at all.do_work()passes the null pointer intoBrotliEncoderCompressStream/ZSTD_compressStream2, and the GC finalizer does the same throughContext::close()(deinit_stateunwraps theNone; zstd hands the nullCCtxtoZSTD_CCtx_reset). So a handle that is merely constructed and collected already aborts.NativeZlib::Context::close()asserted thatdeflateEnd/inflateEndreturnedZ_OK/Z_DATA_ERROR; on az_streamwhose init never ran they returnZ_STREAM_ERROR(with nothing to free), which fired the assertion in debug/ASAN builds.emit_error()and the async write-completion pathunwrap()JS callbacks that onlyinit()caches.NativeZlib::init()copied the dictionary into aVecand only then didu32::try_from(dict.len()).expect("int cast"); JSC allows a 2^32-byteUint8ArrayanddeflateSetDictionarytakes auIntlength.Fix
throw_if_closedis generalized intoCompressionStream::throw_unless_idle(write_in_progress/pending_close/closed, in the shared mixin so all three stream types at once).write(),writeSync(), each class'sinit(), andNativeZlib::params()call it;write()/writeSync()previously carried their own copies of the first two checks.init()failure path now marks the handleclosed:NativeZlib::init()checks whetherContext::initended inNodeMode::NONE, and the brotli/zstd parameter-failure branches set it after tearing theContextdown.Context::close()early-return when the native state was never created. This also makesclose()idempotent, which overlaps the user-called double-close()case that zlib: make brotli/zstd native handle close() idempotent #32382 fixes in the same function; it became required here because the GC finalizer goes through the sameclose().NativeZlib::Context::close()'s debug assertion also acceptsZ_STREAM_ERROR.Context::do_work()return early when the state was never created.NativeZlibalready tolerates this (zlib's owndeflateStateChecknull-checks the stream).emit_error()and the write-completion path skip the JS callback when none is cached instead of panicking; the pending reset/close handling still runs. Node behaves the same way.NativeZlib::init()rejects a dictionary longer thanu32::MAXbefore copying it:RangeError [ERR_OUT_OF_RANGE]: The value of "dictionary.byteLength" is out of range. It must be <= 4294967295. Received 4294967296. With the check before the copy, a 2^32-byteUint8Arrayargument is never read, so the test stays cheap.Intentionally not touched here:
set_flushreceiving zlib flush constants4..=6is zlib: accept zlib flush constants in brotli set_flush instead of panicking #32358 (whose fix, mapping them like Node does, is the right one).init()on an already-initialized, idle handle leaks the first encoder/internal_state. That is a bounded leak rather than an abort, Node has the same gap, and it needs its own leak-regression test; left as a follow-up.NativeZlib::params()afterclose()is already safe (Context::set_paramshas a_ => {}arm).Test
test/js/node/zlib/zlib-handle-bounds-check.test.tsgains one subprocess test per case (21 new). The children run withBUN_DESTRUCT_VM_ON_EXIT=1so the GC finalizers run deterministically on every lane, not only the ASAN lanes that destroy the VM at exit (which is how CI caught the finalizer class in the first place).The 14
test-zlib-{write-after-close,close-after-*,flush*,brotli*,params,dictionary*,reset-before-write,...}node-upstream tests still pass. The twostreaming encode doesn't wait for entire inputfailures inzlib.test.jsare pre-existing on debug+ASAN runners: the test's own byte-fill setup loop takes 42s against a 15s limit before any compression happens.