Skip to content

node:zlib: one failure path for zstd and brotli init(): close, call onerror, throw - #38684

Draft
robobun wants to merge 9 commits into
mainfrom
farm/43cd61bb/zstd-init-failure-throws
Draft

robobun wants to merge 9 commits into
mainfrom
farm/43cd61bb/zstd-init-failure-throws

Conversation

@robobun

@robobun robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • zlib.zstdDecompressSync(data, { dictionary }) returns an empty Buffer when zstd rejects the dictionary. node v26.3.0 throws ERR_ZLIB_INITIALIZATION_FAILED: Failed to load zstd dictionary.
  • NativeZstd::init (src/runtime/node/zlib/NativeZstd.rs:237) reported the failure only through onerror, which zlib.ts installs after init() returns. The handle stayed open with no context, so writes produced nothing.

Fix

  • A failed zstd or brotli init() now takes one exit, CompressionStream::init_failed: close the handle, call onerror if installed, throw ERR_ZLIB_INITIALIZATION_FAILED.
  • This is node's order in node_zlib.cc. zstd throws the context message, brotli throws node's fixed Initialization failed. init() returns undefined as in node, so both Brotli JS return checks are deleted.
  • Verified: the three files in test/js/node/zlib/ (the released build fails 18 new cases), and the vendored test-zlib-* and test-stream-iter-transform-* files.
  • Self-review and release-build measurements are in progress, so this PR is a draft.

Background

  • A node:zlib stream is a JS Transform around a native handle. Its constructor calls handle.init() before it assigns handle.onerror.
  • CompressionStream<T> is the Rust mixin shared by the zlib, brotli and zstd handles.
  • Considered a JS return-value check, as Brotli had. It cannot carry node's message and leaves an open handle with no context.

Downsides

  • A failed brotli _handle.init() now throws. It returned false before.
  • The handle is closed after a failed init(). node keeps it open, so a second init() works in node and throws ERR_INVALID_STATE here.
  • A successful zstd init() runs 4 more instructions (1635 to 1639). A brotli one runs 3 fewer. Release text grows by 428 B.
Notes

What was wrong, per entry point (bun 1.4.3-canary.1+367d939d9, dictionary = zstd magic 0xEC30A437 + junk)

call before node v26.3.0 and this PR
zstdDecompressSync empty Buffer throws ERR_ZLIB_INITIALIZATION_FAILED
zstdDecompress(cb) cb(null, <empty>) throws synchronously, cb never runs
createZstdDecompress, new ZstdDecompress live stream that ends with no data throws from the constructor
node:zlib/iter decompressZstd resolves with 0 bytes rejects with ERR_ZLIB_INITIALIZATION_FAILED
node:zlib/iter decompressZstdSync throws ERR_ZLIB_DICTIONARY_LOAD_FAILED, errno -1, at the first chunk throws ERR_ZLIB_INITIALIZATION_FAILED when the handle is created

node:zlib/iter (src/js/internal/streams/iter/transform.ts, behind --experimental-stream-iter) builds its own handle and calls the same native init, so it needs no edit.

Why the exit is in the mixin

  • node throws from native Init at all four zstd and brotli failure sites, after EmitError (ZstdStream::Init and BrotliCompressionStream::Init in src/node_zlib.cc). lib/zlib.js calls handle.init() with no return check.
  • The zstd parameter branch, 20 lines below the defect, already threw natively. The dictionary branch and both brotli branches did not. Brotli was masked by a return check in each JS caller.
  • The handle is closed before onerror runs. With the other order, a writeSync() from inside onerror completed with zero progress on a handle that has no context.

Handle-level behavior, same script under node v26.3.0 and this PR (15 of 15 lines equal)

  • Bare handle with onerror installed first: one call, then the throw. Arguments: ('Failed to load zstd dictionary', -1, 'ERR_ZLIB_DICTIONARY_LOAD_FAILED'), ('Setting parameter failed', -1, 'ERR_ZSTD_PARAM_SET_FAILED'), ('Setting parameter failed', -1, 'ERR_BROTLI_PARAM_SET_FAILED').
  • Live stream whose _handle.init() fails: destroyed === true and _handle === null before the throw, then 'error' and 'close'.
  • The first version of this PR threw and closed but did not call onerror. That left a live stream open over a closed handle.

Not changed here

Measurements (release builds of merge base 4b02e10 and of this PR, same flags)

  • release text: 80,679,421 B, was 80,678,993 B (size, +428 B). NativeZstdPrototype__init 2305 B (was 2112), NativeBrotliPrototype__init 2308 B (was 2280), init_failed zstd/brotli 261/506 B (nm -S).
  • instructions per successful init: zstd 1635 vs 1639, brotli 2038 vs 2035 (gdb stepi, merge base vs PR, createZstdDecompress() and createBrotliDecompress()). The compress handles give 4986 vs 4990 and 865 vs 862. The design expected equal counts. The zstd thunk now keeps this live across the parameter loop for the onerror call, which costs 4 stack reloads.
  • write/writeSync thunks: 0 differing instructions across 4 symbols (llvm-objdump, position-dependent operands normalized).
  • Brotli constructor bytecodes 96 -> 90, createBrotliHandle 99 -> 93. zlib.js 26776 B (was 26818), transform.js 17667 B (was 17709).
  • onerror calls per failed init: 1 with a callback installed (node 1), 0 from class constructors (node 0). Live streams destroyed before the throw: 3 of 3 (node 3). JS strings built per failed init: 2 on each branch (merge base: 2 on the zstd dictionary branch, 0 on the parameter branches). Counted as BunString__createUTF8ForJS calls under gdb.
  • exception-scope validation (BUN_JSC_validateExceptionChecks=1 BUN_JSC_dumpSimulatedThrows=1, debug ASAN build): 0 violations. 481 of 483 tests pass in the combined run of the 3 zlib test files. The 2 failures are 5 s timeouts of NativeZlib growable-SharedArrayBuffer write tests, which do not reach init_failed. Both pass when run alone with the same flags.
  • probe lines equal to node v26.3.0: 19 of 20 (merge base: 5 of 20). The probe prints name, code, message, errno and whether the callback ran, for 6 node:zlib decompress forms, 2 node:zlib/iter transforms, 4 compress forms and 8 doubly-invalid option sets. Line 20 is a pledgedSrcSize: -1 check that this PR does not touch.
  • existing pinned expectations changed: 1 (Brotli init() with a rejected parameter, false -> throw). The zstd sibling case now also prints the init() outcome, with no behavior change.

[review] gate passed · iteration 3 · 3 files touched

fails on main (without fix)
ASAN without fix: 3 failed, 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/zlib/zlib.test.js
bun test v1.4.0 (c756081ec)

test/js/node/zlib/zlib.test.js:
(pass) prototype and name and constructor > Gzip > Gzip.prototype should be instanceof Gzip.__proto__ [2.08ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.constructor should be Gzip [1.53ms]
(pass) prototype and name and constructor > Gzip > Gzip.name should be Gzip [1.43ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.__proto__.constructor.name should be Zlib [1.50ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype should be instanceof Gunzip.__proto__ [0.45ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.constructor should be Gunzip [0.27ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.name should be Gunzip [0.27ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.__proto__.constructor.name should be Zlib [0.33ms]
(pass) prototype and name and constructor > Deflate > Deflate.prototype should be instanceof Deflate.__prot
... (truncated)

release without fix: 3 failed, 2 skipped
bun test v1.4.0-canary.1 (b7a043103)

test/js/node/zlib/zlib.test.js:
(pass) prototype and name and constructor > Gzip > Gzip.prototype should be instanceof Gzip.__proto__ [0.03ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.constructor should be Gzip [0.01ms]
(pass) prototype and name and constructor > Gzip > Gzip.name should be Gzip
(pass) prototype and name and constructor > Gzip > Gzip.prototype.__proto__.constructor.name should be Zlib [0.01ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype should be instanceof Gunzip.__proto__
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.constructor should be Gunzip
(pass) prototype and name and constructor > Gunzip > Gunzip.name should be Gunzip
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.__proto__.constructor.name should be Zlib
(pass) prototype and name and constructor > Deflate > Deflate.prototype should be instanceof Deflate.__proto__
(pass) prototype and name and constructor > Deflate > Deflate.prototype.constructor should be Deflate
(pass) prototype and name and constructor > Deflate > Deflate.name should be Deflate
(pass) pr
... (truncated)
passes on PR (with fix)
ASAN with fix: 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/zlib/zlib.test.js
bun test v1.4.0 (c756081ec)

test/js/node/zlib/zlib.test.js:
(pass) prototype and name and constructor > Gzip > Gzip.prototype should be instanceof Gzip.__proto__ [2.06ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.constructor should be Gzip [1.55ms]
(pass) prototype and name and constructor > Gzip > Gzip.name should be Gzip [1.15ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.__proto__.constructor.name should be Zlib [1.86ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype should be instanceof Gunzip.__proto__ [0.53ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.constructor should be Gunzip [0.29ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.name should be Gunzip [0.31ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.__proto__.constructor.name should be Zlib [0.29ms]
(pass) prototype and name and constructor > Deflate > Deflate.prototype should be instanceof Deflate.__prot
... (truncated)

release with fix: 2 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 678ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/130] gen NodeModuleModule.lut.h
Generating /workspace/bun/build/release/codegen/NodeModuleModule.lut.h from /workspace/bun/src/jsc/modules/NodeModuleModule.cpp
[2/130] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 240 extern-C blocks audited
[3/130] gen cpp.rs (cppbind)
[4/130] gen JS modules (bundle-modules)
Preprocess modules (8311ms)
Bundle modules (48ms)
Postprocesss modules (244ms)
Bundle Functions (750ms)
Generate Code (31ms)

[9.40s] Bundled "src/js" for production
  2637 kb
  197 internal modules
  13 native modules
  91 internal functions across 17 files
[4/129] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

^[[1m^[[92m   Compiling^[[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
^[[1m^[[92m   Compiling^[[0m bun_simdutf_sys v0.0.0 (/workspace/bun/src/simdutf_sys)
^[[1m^[[92m   Compiling^[[0m bun_libuv_sys v0.0.0 (/work
... (truncated)
diff hotspot
src/runtime/node/node_zlib_binding.rs |  2 +-
 src/runtime/node/zlib/NativeZstd.rs   | 29 +++++++++++++------------
 test/js/node/zlib/zlib.test.js        | 40 ++++++++++++++++++++++++++++++++++-
 3 files changed, 55 insertions(+), 16 deletions(-)

gate history · 1 passed · 1 rejected · iteration 3

evidence per changed file
file                                   reads  edits  tests
src/runtime/node/node_zlib_binding.rs      2      1      0
src/runtime/node/zlib/NativeZstd.rs        2      4      0
test/js/node/zlib/zlib.test.js             1      2      0

@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: a68b1f19-505d-4dba-ad72-00a27605554d

📥 Commits

Reviewing files that changed from the base of the PR and between 8c146e2 and 84b6917.

📒 Files selected for processing (10)
  • src/js/builtins.d.ts
  • src/js/internal/streams/iter/transform.ts
  • src/js/node/zlib.ts
  • src/jsc/bindings/ErrorCode.cpp
  • src/runtime/node/node_zlib_binding.rs
  • src/runtime/node/zlib/NativeBrotli.rs
  • src/runtime/node/zlib/NativeZstd.rs
  • test/js/node/zlib/zlib-handle-bounds-check.test.ts
  • test/js/node/zlib/zlib-onerror-reentrancy.test.ts
  • test/js/node/zlib/zlib.test.js
💤 Files with no reviewable changes (2)
  • src/jsc/bindings/ErrorCode.cpp
  • src/js/builtins.d.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

Brotli and Zstandard initialization failures now use shared cleanup and thrown-error handling. JavaScript wrappers no longer rely on false initialization results. Tests cover error behavior across APIs, stream lifecycle, and reentrant callbacks.

Changes

zlib initialization error handling

Layer / File(s) Summary
Shared native failure handling
src/runtime/node/node_zlib_binding.rs, src/runtime/node/zlib/NativeBrotli.rs, src/runtime/node/zlib/NativeZstd.rs, src/jsc/bindings/ErrorCode.cpp
The shared failure path closes the handle, emits the supplied error, and returns a thrown initialization error. Brotli and Zstandard initialization use this path. The fixed-message error-code switch case was removed.
JavaScript wrapper integration
src/js/builtins.d.ts, src/js/internal/streams/iter/transform.ts, src/js/node/zlib.ts
Brotli wrappers no longer check initialization return values or throw an initialization error based on a false result. The corresponding error-constructor declaration was removed.
Initialization API behavior tests
test/js/node/zlib/zlib.test.js
Tests cover malformed Zstandard dictionaries across decompression APIs, initialization error precedence, native initialization events, and raw content dictionary round-trips. They also check Brotli iterator initialization errors.
Failed-handle lifecycle and reentrancy tests
test/js/node/zlib/zlib-handle-bounds-check.test.ts, test/js/node/zlib/zlib-onerror-reentrancy.test.ts
Tests check closed-handle errors after initialization failure, emitted error arguments, and reentrant calls from onerror, including behavior when the callback throws.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 84b69

No actionable issue remains from this review; the change is ready to merge after normal checks.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: unified zstd and Brotli initialization failure handling that closes the handle, calls onerror, and throws.
Description check ✅ Passed The description is detailed and covers the change, rationale, verification, behavior differences, tests, and known limitations. It uses headings different from the template, but it provides the requir…
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reworked after a design review. The PR is a draft until a self-review finishes. The measurements are in the PR body.

Reproduced on bun 1.4.3-canary.1 (main at 4b02e10), compared against node v26.3.0:

const zlib = require("node:zlib");
const dict = Buffer.from([0x37, 0xa4, 0x30, 0xec, 1, 2, 3, 4, 5, 6, 7, 8]); // zstd dictionary magic + junk
zlib.zstdDecompressSync(zlib.zstdCompressSync("hello"), { dictionary: dict });
// main:       <Buffer >                                   (no error)
// node 26.3:  ERR_ZLIB_INITIALIZATION_FAILED: Failed to load zstd dictionary
// this PR:    ERR_ZLIB_INITIALIZATION_FAILED: Failed to load zstd dictionary

createZstdDecompress({ dictionary: dict }) returns a stream that ends with no data on main. decompressZstd from node:zlib/iter resolves with 0 bytes on main. Both throw with this PR, as in node.

Tests are in test/js/node/zlib/ (zlib.test.js, zlib-handle-bounds-check.test.ts, zlib-onerror-reentrancy.test.ts). A release build of main fails 18 of the new cases. This branch passes all of them.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — small, well-scoped Node.js compat fix that routes a previously-dropped zstd init error through the same throw path the sibling set_params failure already used.

What was reviewed:

  • Confirmed the Zstd JS constructor (src/js/node/zlib.ts:790) never checked handle.init()'s return, so the old emit_error + return false was silently dropped (onerror isn't installed until ZlibBase runs); throwing is the only way to surface it, matching node's ZstdStream::Init.
  • Checked the sibling NativeBrotli::init for the same pattern — it also returns false on init failure, but zlib.ts:736 already guards on that and throws $ERR_ZLIB_INITIALIZATION_FAILED, so brotli was never silent; this PR is correctly scoped to zstd.
  • throw_initialization_failed calls Context::close() which is idempotent when state is None (the state after a dictionary-load failure), so no double-free / null-deref on the new call site.
  • Tests cover sync/async/stream entry points with exact error code/message and a positive raw-dictionary round-trip control.
Extended reasoning...

Overview

Two files touched. NativeZstd.rs extracts the existing set_params-failure throw block into a private throw_initialization_failed helper and reuses it for the Context::init failure branch, which previously called emit_error (routing through the not-yet-installed onerror callback) and returned JSValue::FALSE. zlib.test.js adds a four-case describe block exercising a malformed formatted dictionary (zstd magic + junk) through zstdDecompressSync, zstdDecompress, and createZstdDecompress, plus a raw-content-dictionary positive control.

Security risks

None. The change converts a silent failure into a thrown ERR_ZLIB_INITIALIZATION_FAILED; no new input is accepted, no new allocation, no new unsafe. The existing SAFETY: invariant on err.msg (non-null when is_error()) holds because every Error::init call in Context::init / load_dictionary passes a C-string literal.

Level of scrutiny

Low-medium. This is a Node.js-compat behavioral fix in a well-tested subsystem. The Rust change is a pure refactor of one branch plus rerouting of another; no memory-management semantics changed (the helper calls Context::close(), which early-returns when state is None, exactly the state the failed-init path leaves it in). The set_params path is byte-for-byte behavior-preserved.

Other factors

The "fix the whole class" question (does NativeBrotli::init share the bug?) was raised and refuted: brotli's emit_error + return false is caught by if (!handle.init(...)) throw $ERR_ZLIB_INITIALIZATION_FAILED() at src/js/node/zlib.ts:736, whereas the Zstd constructor at line 790 never inspected the return value — so only zstd was silently swallowing the failure. Tests assert the exact {name, code, message} shape and verify the async callback is not invoked, and the PR description reports the full zlib.test.js (391 tests) plus the node-parallel zstd/brotli/dictionary suites pass.

A zstd dictionary that fails to load (or a failed ZSTD_createCCtx/DCtx)
was reported through the handle's onerror callback, which zlib.ts only
installs after init() returns. The error was dropped, the handle kept
running with no context, and zstdDecompressSync() returned an empty
buffer while createZstdDecompress() produced a stream that emitted
nothing. Throw ERR_ZLIB_INITIALIZATION_FAILED with the context's message
instead, the same way a failed parameter set already does and the same
way node's ZstdStream::Init does.
@robobun
robobun force-pushed the farm/43cd61bb/zstd-init-failure-throws branch from 822da7d to b83f405 Compare August 14, 2026 21:07
Comment thread src/runtime/node/zlib/NativeZstd.rs Outdated
Comment thread src/runtime/node/zlib/NativeZstd.rs Outdated
Comment thread src/runtime/node/zlib/NativeZstd.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — focused Node.js compat fix that makes zstd init failures throw instead of silently returning empty output.

What was reviewed:

  • Traced why the old emit_error path was dropped: zlib.ts installs onerror only after handle.init() returns, and the Zstd constructor (unlike Brotli's) does not check init's return value.
  • throw_initialization_failed → close_internal: throw_unless_idle earlier in init() guarantees write_in_progress is false, and Context::close() handles the state.is_none() case, so the cleanup path is sound.
  • Confirmed NativeBrotli::init does not share this bug — its JS caller checks if (!handle.init(...)) and throws.
  • Tests cover sync/async/stream + a positive raw-content-dictionary round-trip, and assert the exact error code/message.
Extended reasoning...

Overview

This PR fixes a Node.js compat bug where zlib.zstdDecompressSync / zstdDecompress / createZstdDecompress with a malformed zstd dictionary returned empty output instead of throwing ERR_ZLIB_INITIALIZATION_FAILED. Three files touched: NativeZstd.rs (the fix — replace emit_error with a throwing helper and dedupe the two failure sites), node_zlib_binding.rs (widen close_internal from private to pub(crate)), and zlib.test.js (four new regression tests).

Security risks

None. The change surfaces an already-detected error to the caller instead of dropping it. No new parsing of untrusted input; the dictionary bytes were already being handed to ZSTD_DCtx_loadDictionary, which already rejected them — only the error-reporting path changed. The single unsafe block (cstr(err.msg)) is guarded by err.is_error() at both call sites, which guarantees msg is non-null.

Level of scrutiny

Low-to-moderate. The diff is small (~30 lines net), the root cause is precisely identified in the PR description, and the fix is the same shape Node uses (throw from native init). I verified the concern that close_internal might misbehave during init: throw_unless_idle runs before either failure site so write_in_progress is false, this_value is empty (only set during async write), and Context::close() early-returns on state.is_none(). The refactor of the set_params error path into the same helper is a straight deduplication with no semantic change beyond the harmless extra pending_close.set(false) / this_value.deinit().

Other factors

The bug-hunting system's finder agents flagged the sibling NativeBrotli::init as having the same pattern; verifiers refuted it, and I confirmed why: src/js/node/zlib.ts:736 wraps Brotli's handle.init() in if (!...) throw $ERR_ZLIB_INITIALIZATION_FAILED(), whereas the Zstd call site at line 790 does not check the return value. So the class is fully covered. The comment-cop bot's feedback about long justification comments was addressed in follow-up commits (c7c2adb, 3f3d5c4). Tests assert the exact error name/code/message, use jest.fn() to verify the callback is not invoked, and include a positive round-trip so the change doesn't over-reject valid dictionaries.

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:45 AM PT - Oct 2nd, 2026

✅ @robobun, your commit f9963cf1bd9b36edacaca301c6fba508db6ad8fe passed in Build #122874! 🎉


🧪   To try this PR locally:

bunx bun-pr 38684

That installs a local version of the PR into your bun-38684 executable, so you can run:

bun-38684 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — focused Node compat fix that turns a silent zstd init failure into the same ERR_ZLIB_INITIALIZATION_FAILED throw Node emits.

What was reviewed:

  • throw_initialization_failed → close_internal: at init time write_in_progress/pending_close are false and this_value is empty, so the refactored param-set path is behavior-preserving; Context::close() is a no-op when state is None, so the dictionary-failure path double-close is safe.
  • Sibling NativeBrotli::init uses the same emit_error + return FALSE pattern, but zlib.ts checks the brotli return value and throws — so it is not affected.
  • Tests cover sync/async/stream constructor, assert the exact error code/message, and include a positive control (raw-content dictionary still round-trips).
Extended reasoning...

Overview

This PR fixes node:zlib zstd streams to throw ERR_ZLIB_INITIALIZATION_FAILED when a formatted zstd dictionary fails to load, instead of silently producing an empty buffer / dead stream. It touches three files: NativeZstd.rs (route both init-time failures through one helper that closes the handle and throws), node_zlib_binding.rs (widen close_internal to pub(crate) so the helper can reuse it), and zlib.test.js (four new tests). The root cause — emit_error firing before zlib.ts installs onerror, so the error is dropped and the JS side ignores the false return — is well-explained and the fix matches Node's ZstdStream::Init behavior of throwing synchronously.

Security risks

None. The change only turns a previously-swallowed error into a thrown one; no new parsing, no new trust surface, no resource-lifetime change beyond earlier cleanup on failure.

Level of scrutiny

Low-to-medium. This is a small, mechanical Node-compat fix in the zstd binding: one emit_error call becomes a throw, and the existing inline throw path for parameter failures is deduplicated into the same helper. I traced close_internal at init time to confirm the refactor is behavior-preserving (no in-flight write, empty StrongOptional, Context::close is idempotent on state = None). I also checked src/js/node/zlib.ts to confirm the Zstd constructor never inspected the old false return (unlike the Brotli constructor, which does — hence the sibling is not affected, matching what the verifier concluded).

Other factors

Tests are strong: they exercise all three user-facing entry points, assert the exact {name, code, message}, verify the async callback is not invoked, and include a positive control so a future over-eager rejection would be caught. The PR description records that the whole 391-test file plus the upstream test-zlib-zstd* and test-zlib-dictionary-fail parallel tests pass, and that the failing cases fail on released bun. The comment-cop feedback about long comments was addressed in follow-up commits (helper now uses close_internal directly with a one-line doc comment). All inline threads are resolved. No human reviewer has left outstanding feedback.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/js/node/zlib/zlib.test.js
…t fails to load

createZstdHandle in internal/streams/iter/transform.ts calls the same
NativeZstd.init. Without the fix, decompressZstdSync throws
ERR_ZLIB_DICTIONARY_LOAD_FAILED at the first chunk and decompressZstd
resolves with zero bytes. node v26.3.0 throws
ERR_ZLIB_INITIALIZATION_FAILED from both.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the latest push adds the node:zlib/iter coverage, and the native change is a small, well-contained consolidation of the two init failure branches.

What was reviewed:

  • throw_initialization_failed routing teardown through close_internal: init already calls throw_unless_idle, so write_in_progress is false and the handle is fully closed (no deferred pending_close state); Context::close is idempotent when state is None after a failed load_dictionary, and this_value is still an empty StrongOptional at that point.
  • The unsafe cstr(err.msg) read is the pre-existing set_params path moved verbatim; Error::init always supplies a NUL-terminated static message.
  • The iter child-process test drains stdout/stderr concurrently, asserts stdout before exitCode, and the async variant builds its handle lazily inside the generator so the rejection (not a sync throw) is the right expectation.
Extended reasoning...

The change touches src/runtime/node/zlib/NativeZstd.rs (two init error branches collapsed into one helper that closes the handle and throws ERR_ZLIB_INITIALIZATION_FAILED), widens close_internal to pub(crate) in node_zlib_binding.rs, and adds a "dictionary that fails to load" block to test/js/node/zlib/zlib.test.js covering sync, callback, stream, and node:zlib/iter entry points plus a raw-dictionary round-trip. No security-sensitive surface is involved; the only unsafe is a relocated, already-justified C-string read. It is a ~30-line native diff with clear intent, the prior-run nit about missing iter coverage was addressed by the last commit, no CODEOWNERS entry covers these files, and the bug hunt ran dry with no findings.

A failed init() on a NativeZstd or NativeBrotli handle now goes through
CompressionStream::init_failed: close the handle, call onerror when one
is installed, then throw ERR_ZLIB_INITIALIZATION_FAILED. That is the
order node's ZstdStream::Init and BrotliCompressionStream::Init use
(EmitError, then throw), with the handle closed first so the callback
cannot drive a handle that has no context.

zstd throws the context's own message. brotli throws node's fixed text
'Initialization failed'. init() returns undefined on success, as in
node, so the two Brotli return checks in zlib.ts and
internal/streams/iter/transform.ts and the zero-argument
ERR_ZLIB_INITIALIZATION_FAILED builtin are gone.
@robobun
robobun requested a review from alii as a code owner October 2, 2026 05:26
Comment thread src/runtime/node/node_zlib_binding.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

The node:zlib/iter zstd cases from the earlier round are now covered in zlib.test.js, and beyond the inline nit I checked two things in the new init_failed helper and ruled them out: the onerror-before-throw ordering is harmless for every caller that installs onerror before init (the iter transforms' handlers only set state that the thrown error supersedes, and zlib.ts installs it after init), and the cstr(err.msg) read after close_internal is safe because every Error produced by Brotli/Zstd init/set_params carries a static C literal, not context-owned memory.

Extended reasoning...

The diff moves Brotli and Zstd init failures in src/runtime/node/zlib/ into one Rust helper that closes the handle, fires onerror, and throws ERR_ZLIB_INITIALIZATION_FAILED, and drops the now-dead JS-side $ERR_ZLIB_INITIALIZATION_FAILED() check and its zero-arg builtin; it touches no security-sensitive surface. The Brotli path keeps its "Initialization failed" message and the Zstd set_params path keeps its own message, so observable behaviour is preserved except for the intended zstd dictionary-failure fix, which now has sync, callback, stream and iter coverage. Only a test-precision nit was found, but the change invokes a user callback from native code before throwing and reads a raw C pointer under an unsafe block, so a human maintainer look is still worthwhile.

Comment thread test/js/node/zlib/zlib-handle-bounds-check.test.ts Outdated
init_failed takes the thrown message explicitly. Tests cover the
doubly-invalid option order, a live stream whose _handle.init() fails,
onerror being called once before the throw, re-entrancy from onerror,
and the node:zlib/iter brotli caller.
@robobun
robobun marked this pull request as draft October 2, 2026 08:45
@robobun robobun changed the title node:zlib: throw when a zstd dictionary fails to load instead of returning empty output node:zlib: one failure path for zstd and brotli init(): close, call onerror, throw Oct 2, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

The exit is cold. Inlined, it grew the release init thunks to 2546 B
(zstd) and 2669 B (brotli). Out of line they are 2305 B and 2308 B, and
release text grows by 428 B instead of 940 B over the merge base.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants