Skip to content

node:zlib: refuse reset() of a zstd frame that is open and has given output - #47376

Draft
robobun wants to merge 2 commits into
claude/zstd-rust-replacement-4qwmbqfrom
robobun/042207aa/zstd-reset-incomplete-frame
Draft

robobun wants to merge 2 commits into
claude/zstd-rust-replacement-4qwmbqfrom
robobun/042207aa/zstd-reset-incomplete-frame

Conversation

@robobun

@robobun robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Draft, on top of #47079: the zstd half of #47364. @dylan-conway: take this commit into #47079, or I retarget it to main after #47079 lands.

Problem

Fix

  • The zstd Context records that the open frame has given output. Context::reset returns Node's error before it makes a new context. The existing onerror path destroys the stream.
  • Verified on a debug build: test/js/node/zlib/zlib-zstd-reset-incomplete-frame.test.ts (main fails 8 of 14, Node.js v26.12.1 passes all) and Node's test-zlib-zstd-reset-incomplete-frame.js, unmodified. Also 8 test-zlib-zstd*.js files.
  • Self-reviewed with node:zlib: refuse reset() of a gzip member that is open and has given output #47364. That review asked for this separate PR.

Background

  • A zstd frame puts out its header at the first flush. A new context never writes its end.
  • Context is the codec state behind the native handle (_handle) of one stream. do_work runs once per native write.

Downsides

  • Write, flush, reset() per segment on one ZstdCompress stream now destroys the stream. With no 'error' listener the process exits.
  • A ZstdCompress native write runs two more compares and one store in do_work. Not measured on the port. On the C library: 29 → 34 or 35 instructions.
  • An accepted reset() still drops the parameters and the dictionary (node:zlib: reset a zstd stream in place, keep its dictionary and parameters #44261). A later reset() can then differ from Node.js (see Notes).
Notes

What each runtime does

hello is written first, and world is written with end() if the stream is still open. "main" is da867c6. Context::reset of #47079 has no check either.

stream steps main this PR Node.js v26.12.1
createZstdCompress() write, reset() world world world
createZstdCompress() write, flush, reset() no error, ZSTD_error_corruption_detected 'error' ERR_ZLIB_INCOMPLETE_FRAME, destroyed the same
createZstdCompress() 512 KiB write, reset() no error, ZSTD_error_corruption_detected ERR_ZLIB_INCOMPLETE_FRAME the same
createZstdCompress() write, flush(ZSTD_e_end), reset() helloworld helloworld helloworld
ZstdCompress _handle writeSync(flush), reset() returns onerror(message, 60, "ERR_ZLIB_INCOMPLETE_FRAME") the same
createZstdDecompress() half of the input, reset(), all of the input decodes the same the same

The error

  • Error, code ERR_ZLIB_INCOMPLETE_FRAME, errno 60 (ZSTD_error_stage_wrong), message "Cannot reset a zstd stream with an incomplete frame; end the frame or discard the output produced so far".
  • The native handle gives the three values to onerror, as Node.js does. The code is not in Node's lib/internal/errors.js, so there is no ErrorCode.ts entry.
  • A refused reset() does not change the codec. The frame can still be finished, and it decodes.

Source: ZstdCompressContext::ResetStream.

One flag where Node.js has two

Node.js keeps frame_complete_ and frame_output_emitted_, and refuses if !frame_complete_ && frame_output_emitted_. The code sets frame_complete_ only where it clears frame_output_emitted_. So the two are never both true, and the test is the same as frame_output_emitted_.

The flag is updated one time after the compress loop of do_work, from the position of the output buffer.

Tests

  • test/js/node/zlib/zlib-zstd-reset-incomplete-frame.test.ts uses node:test and node:assert. The same file passes under Node.js v26.12.1 (node --test, 14 pass). A debug build of this branch: 14 pass. A release build of main (da867c6): 8 fail, 6 pass.
  • test-zlib-zstd-reset-incomplete-frame.js in test/js/node/test/parallel/ is the file of Node.js v26.12.1, byte for byte. main fails 2 of 5. This branch passes 5.
  • Both files pass on the debug ASAN build with the environment of the ASAN lane (detect_leaks=1, BUN_DESTRUCT_VM_ON_EXIT=1, BUN_JSC_validateExceptionChecks=1).
  • Also run on the debug build of this branch: 63 test-zlib*.js, 6 test-stream-iter-transform*.js and 3 test-webstreams-*compression*.js vendored files, zlib-handle-bounds-check.test.ts (41 pass, with its zstd workers case), zlib-reset-race.test.ts, zlib-onerror-reentrancy.test.ts, zlib-estimated-size-gc.test.ts, zlib.test.js (473 pass), test/js/bun/util/zstd.test.ts (253 pass) and test/js/web/streams/compression.test.ts (74 pass).
  • Not done on the port: the check that each clause has a failing test, and the release measurements. Both were done for the same lines on the C library before node:zlib: refuse reset() of a gzip member that is open and has given output #47364 was split (each of 7 clause deletions failed a test, and do_work went from 29 to 34 or 35 instructions per write). I repeat them when this PR targets main.
  • Not run: Windows and macOS.

After an accepted reset()

Context::reset still makes a new context, as Node.js did before v26.10.0. So an accepted reset() drops the parameters and the dictionary. #44261 changes that. Until then, the check can give a different answer from Node.js for the frame after an accepted reset(). A probe of 7 handle sequences differs in 3:

stream options after one accepted reset() this PR Node.js v26.12.1
ZSTD_c_checksumFlag ZSTD_e_end into a buffer 4 bytes short of the frame, reset() accepted (no checksum any more, the frame fits) refused
ZSTD_c_windowLog 10 an 8 KiB write with no flush, reset() accepted (0 bytes out) refused (246 bytes out)
a dictionary ZSTD_e_end into a buffer of the size of the first frame, reset() refused (no dictionary any more, the frame is longer) accepted

The two control rows with no reset() before them agree with Node.js.

Rows that still differ from Node.js

  • The rows above.
  • A closed native handle: _handle.writeSync(flush), _handle.close(), _handle.reset(). Node.js calls onerror with errno 60, because its flags outlive Close(). Bun returns for each closed handle (node_zlib_binding.rs). The stream API cannot reach this state.
  • reset() of a destroyed stream. node:zlib: refuse reset() of a gzip member that is open and has given output #47364 changes it to ERR_INTERNAL_ASSERTION, as in Node.js. This PR does not.

[human-review] gate passed · iteration 1 · 3 files touched

fails on main (without fix)
ASAN without fix: 8 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/zlib/zlib-zstd-reset-incomplete-frame.test.ts
bun test v1.4.4 (367d939d9)

test/js/node/zlib/zlib-zstd-reset-incomplete-frame.test.ts:
76 |     stream.write("hello");
77 |     await flushed(stream);
78 |     assert.ok(Buffer.concat(chunks).length > 0);
79 | 
80 |     assert.strictEqual(stream.reset(), undefined);
81 |     assert.strictEqual(stream.destroyed, true);
                ^
AssertionError: Expected values to be strictly equal:

false !== true

 generatedMessage: true,
     actual: false,
   expected: true,
   operator: "strictEqual",
       diff: "simple",
       code: "ERR_ASSERTION"

      at /workspace/bun/test/js/node/zlib/zlib-zstd-reset-incomplete-frame.test.ts:81:12
      at node:test:1781:26
      at executeTestNode (node:test:1785:63)
      at processTicksAndRejections (native:35:14)
(fail) ZstdCompress: reset() in an open frame > after a flush, destroys the stream before it returns [243.34ms]
92 |     // This write stays in the compressor. The frame is still open, and part of it is out.
93 |     await wri
... (truncated)

release without fix: 8 FAILED
bun test v1.4.3-canary.1 (367d939d9)

test/js/node/zlib/zlib-zstd-reset-incomplete-frame.test.ts:
76 |     stream.write("hello");
77 |     await flushed(stream);
78 |     assert.ok(Buffer.concat(chunks).length > 0);
79 | 
80 |     assert.strictEqual(stream.reset(), undefined);
81 |     assert.strictEqual(stream.destroyed, true);
                ^
AssertionError: Expected values to be strictly equal:

false !== true

 generatedMessage: true,
     actual: false,
   expected: true,
   operator: "strictEqual",
       diff: "simple",
       code: "ERR_ASSERTION"

      at /workspace/bun/test/js/node/zlib/zlib-zstd-reset-incomplete-frame.test.ts:81:12
      at node:test:1445:26
      at executeTestNode (node:test:1448:63)
      at processTicksAndRejections (native:7:39)
(fail) ZstdCompress: reset() in an open frame > after a flush, destroys the stream before it returns [10.81ms]
92 |     // This write stays in the compressor. The frame is still open, and part of it is out.
93 |     await written(stream, "world");
94 |     assert.strictEqual(Buffer.concat(chunks).length, given);
95 | 
96 |     stream.reset();
97 |     assert.strictEqual(stream.destroyed, true);
           
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/zlib/zlib-zstd-reset-incomplete-frame.test.ts
bun test v1.4.4 (367d939d9)

test/js/node/zlib/zlib-zstd-reset-incomplete-frame.test.ts:
(pass) ZstdCompress: reset() in an open frame > after a flush, destroys the stream before it returns [289.10ms]
(pass) ZstdCompress: reset() in an open frame > after output and then a write that gives none [33.80ms]
(pass) ZstdCompress: reset() in an open frame > is allowed once a finish flush completed it, and the next one is guarded again [36.00ms]
(pass) ZstdCompress: reset() in an open frame > inside a 'data' listener [29.06ms]
(pass) ZstdCompress: reset() in an open frame > after a synchronous _processChunk() [15.65ms]
(pass) ZstdCompress: reset() in an open frame > on the native handle, reports to onerror and keeps what is open [13.84ms]
(pass) ZstdCompress: reset() in an open frame > on the native handle, after output and then a write that gives none [6.19ms]
(pass) ZstdCompress: reset() in an open frame > on the native handle, after a write() on the thread pool [10.83ms]
(pass) ZstdC
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 746ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/344] gen generated_host_exports.rs
generated_host_exports.rs: 111 exports (lazy=4, generic=107, rust=0); 263 extern-C blocks audited
[2/343] gen the table of built-ins
builtins table: operands without uses: none
builtins table: 2011 host entries have a result that is not described; a program in which a value may be anything holds what any host class needs: 0 modules, 15 functions
[3/256] cxx obj/src/jsc/bindings/webcore/streams/JSPullIntoDescriptor.cpp.o
[4/256] cxx obj/src/jsc/bindings/webcore/streams/JSCrossRealmTransformState.cpp.o
[5/256] cxx obj/src/jsc/bindings/webcore/streams/JSCompressionStream.cpp.o
[6/256] cxx obj/src/jsc/bindings/webcore/streams/JSDecompressionStream.cpp.o
[7/256] cxx obj/src/jsc/bindings/webcore/streams/JSCountQueuingStrategy.cpp.o
[8/256] cxx obj/src/jsc/bindings/webcore/streams/JSByteLengthQueuingStrategy.cpp.o
[9/256] cxx obj/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp.o
[10/256] cxx obj/src/jsc/bindings/webcore/streams/JSCompressionStreamShared.cpp.o
[1
... (truncated)
diff hotspot
src/runtime/node/zlib/NativeZstd.rs                |  19 +-
 .../test-zlib-zstd-reset-incomplete-frame.js       | 129 ++++++++++
 .../zlib/zlib-zstd-reset-incomplete-frame.test.ts  | 279 +++++++++++++++++++++
 3 files changed, 426 insertions(+), 1 deletion(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                                      reads  edits  tests
src/runtime/node/zlib/NativeZstd.rs                           2      3      8
…/test/parallel/test-zlib-zstd-reset-incomplete-frame.js      0      0     11
…t/js/node/zlib/zlib-zstd-reset-incomplete-frame.test.ts      0      1      5

…output

reset() on a ZstdCompress stream dropped the context of a frame whose
first bytes were already in the output. The next frame followed that
fragment, and the output failed to decode with
ZSTD_error_corruption_detected.

Node.js v26.11.0 and later refuse that reset (nodejs/node#66088). The
native handle reports ERR_ZLIB_INCOMPLETE_FRAME through onerror, and
the stream is destroyed.

The zstd Context now records that the open frame has given output, and
Context::reset returns the same error before it makes a new context.
@robobun

robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:33 PM PT - Oct 29th, 2026

❌ @robobun, your commit 4209187 has 2 failures in Build #135361 (All Failures):

  • test/aot/build-scaling/build-scaling-2.test.ts - code 1 on 🍎 any aarch64
  • Failed to create agent for 🐧 3.23 aarch64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.179fba3f-2659-4cd2-939f-f91e94fcd9eb
  • Failed to create agent for 🐧 3.23 aarch64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.36311fb5-da3e-429c-98be-6289af773e7d
  • Failed to create agent for 🐧 3.23 aarch64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.c49912e8-7b08-4b3e-9541-d216c99ad995
  • Failed to create agent for 🐧 3.23 aarch64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.259ee84d-200f-4627-b61d-365e046e5f1c
  • Failed to create agent for 🐧 3.23 aarch64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.225e25a4-d621-413d-80b1-dc7b79572acb
  • Failed to create agent for 🐧 25.04 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.51b8b657-5e5b-42ad-8989-3b572e52690c
  • Failed to create agent for 🐧 25.04 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.2fb8d7e0-436f-4081-a867-5c9edb912dd0
  • Failed to create agent for 🐧 25.04 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.5cee64ea-0540-4bf8-afa9-6e24792eb0ab
  • Failed to create agent for 🐧 13 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.43174e24-f0d9-4dfd-925c-2689f1f84b4c
  • Failed to create agent for 🐧 25.04 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.0ec4aa86-2fa3-47b5-9ef3-1942664471f6
  • Failed to create agent for 🐧 25.04 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.32ae2cba-5b58-4ddb-97c5-7dc2dddac3a0
  • Failed to create agent for 🐧 25.04 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.d811035f-5394-4113-ad9f-11054b97d9ee
  • Failed to create agent for 🐧 13 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.0d83313a-7717-4f45-95b1-12e09d49ad90
  • Failed to create agent for 🐧 13 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.9d1d943e-eec5-4b4a-a24b-dacb40ef30c1
  • Failed to create agent for 🐧 13 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.31310f44-decb-4e98-9ec9-8ad63de9adfc
  • Failed to create agent for 🐧 13 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.5a692a21-d9cb-481d-9e10-f3b6099619eb
  • Failed to create agent for 🐧 25.04 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.f29294fa-d5da-4931-809e-4eeaf97eb31c
  • Failed to create agent for 🐧 25.04 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.b582dfa7-fe04-48f8-a142-040a635d8409
  • Failed to create agent for 🐧 13 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.6bc036ac-a3a9-4cc8-b875-6e99638b9c3b
  • Failed to create agent for 🐧 25.04 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.532fa035-012d-4002-9427-3704d9a226dd
  • Failed to create agent for 🐧 25.04 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.b7837298-2b32-4523-a6f7-89b7ccb9c23b
  • Failed to create agent for 🐧 25.04 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.9ddd0afc-f54f-470a-8849-105b0c17ae6c
  • Failed to create agent for 🐧 13 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.86d8d61f-9624-4db9-af64-74dd07c68a7c
  • Failed to create agent for 🐧 25.04 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.621c32d2-98ad-4f96-9270-b9220af2a8d3
  • Failed to create agent for 🐧 13 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.4c6f63c3-6bfc-48f3-90a1-5e69fa17830f
  • Failed to create agent for 🐧 25.04 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.207ec54f-58d7-4b39-b28e-1541fc7161c5
  • Failed to create agent for 🐧 13 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.0eeda004-c236-44f6-bbf1-3d2d64e38707
  • Failed to create agent for 🐧 13 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.1658c3a2-8a8e-4176-b661-c3e41212b225
  • Failed to create agent for 🐧 25.04 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.8fa720d7-e1b2-4211-b811-97873f0267a6
  • Failed to create agent for 🐧 25.04 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.73fa0887-934a-4434-923d-99b8fce51087
  • Failed to create agent for 🐧 25.04 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.74293272-1bd8-40eb-bb33-1aa7557b143d
  • Failed to create agent for 🐧 13 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.fa26e2aa-09f3-4718-91e4-b6a918d67b3e
  • Failed to create agent for 🐧 13 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.8ea34329-adf7-443f-b43a-c1dd84a9e6ff
  • Failed to create agent for 🐧 25.04 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.9fe21cff-95cc-4fe8-ae71-039038a0fd6d
  • Failed to create agent for 🐧 13 x64 - cargo-test
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.7e4789c9-5494-4070-9172-798e87e6f93a
  • Failed to create agent for 🐧 13 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.48301cb0-5b3a-4295-97af-c2237cd277e8
  • Failed to create agent for 🐧 13 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.351d8acb-41e4-4706-808c-99501924640d
  • Failed to create agent for 🐧 13 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.82e56685-baf0-452a-b4ef-973a994672a6
  • Failed to create agent for 🐧 25.04 x64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.c580e287-54d7-471c-99b6-58ad35ff58db
  • Failed to create agent for 🐧 13 aarch64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.edad8791-40b6-490e-af1e-56d2b30f411b
  • Failed to create agent for 🐧 13 aarch64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.b077096c-233b-4391-b45b-c4cb2f845f04
  • Failed to create agent for 🐧 13 aarch64 - test-bun
  • CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.6a4ecbf7-5907-4147-8c4b-ea5b29f0c5e6

🧪   To try this PR locally:

bunx bun-pr 47376

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

bun-47376 --bun

@robobun

robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

How the problem reproduces, on main (da867c6) and on Bun 1.4.3:

import zlib from "node:zlib";
const z = zlib.createZstdCompress(), 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.zstdDecompressSync(Buffer.concat(out)).toString());
}
  • main: reset() reports nothing, and zstdDecompressSync then throws ZSTD_error_corruption_detected.
  • Node.js v26.12.1 and this PR: error ERR_ZLIB_INCOMPLETE_FRAME.

The fix is this pull request, #47376. It is a draft on top of #47079. The gzip half is #47364.

CI: no failure comes from this change. The two builds of this tree (135244, 135361) are red for two reasons:

In build 135244 every build job passes, and the full test suite passes on Alpine x64, Alpine aarch64, Debian x64 with ASAN, Windows x64 and Windows aarch64. The second commit is empty and only started the second build.

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.

1 participant