From 14bfabeef6f9ecb95dd6dee8b600a8cd4270c86c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:17:10 +0000 Subject: [PATCH 1/5] zlib: accept zlib flush constants in brotli set_flush instead of panicking The shared CompressionStream write()/writeSync() validation accepts flush values in the zlib range 0..=6 for all of NativeZlib/NativeBrotli/NativeZstd, so Z_FINISH (4) and Z_BLOCK (5) can reach the brotli Context::set_flush. The Rust port mapped only 0..=3 and hit unreachable!() on the rest, aborting the process after write_in_progress/ref_/buffer-pinning state had already been set up. Node stores the raw int: the brotli decoder never reads the flush value at all (BrotliDecoderDecompressStream takes no op argument) and get_error_info only compares it against BROTLI_OPERATION_FINISH, while the encoder treats any unknown op as a non-flushing, non-terminal step. Map values outside the brotli operation set to BROTLI_OPERATION_PROCESS so both paths match Node's observable behavior instead of crashing. --- src/runtime/node/zlib/NativeBrotli.rs | 14 +++--- test/js/node/zlib/zlib.test.js | 63 ++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/runtime/node/zlib/NativeBrotli.rs b/src/runtime/node/zlib/NativeBrotli.rs index 9aee4a90d5dd..648eea62faa0 100644 --- a/src/runtime/node/zlib/NativeBrotli.rs +++ b/src/runtime/node/zlib/NativeBrotli.rs @@ -415,16 +415,20 @@ mod _impl { } pub fn set_flush(&mut self, flush: c_int) { - // Caller passes a valid BrotliEncoderOperation discriminant (Node - // zlib constants 0..=3). Exhaustive match — `Op` is `#[repr(u32)]` - // so the prior `c_int` bit-cast was a width hazard anyway. Out-of- - // range traps. + // The shared `write`/`writeSync` validation accepts the zlib + // flush range (0..=6), so `Z_FINISH` (4) / `Z_BLOCK` (5) / + // `Z_TREES` (6) can reach here. Node stores the raw int; the + // decoder never consumes it and `get_error_info` only compares + // against `Op::finish`, while the brotli encoder treats any + // unknown op as a non-flushing, non-terminal step. Map anything + // outside the brotli operation set to `process` so both paths + // match Node instead of panicking. self.flush = match flush { 0 => Op::process, 1 => Op::flush, 2 => Op::finish, 3 => Op::emit_metadata, - n => unreachable!("invalid BrotliEncoderOperation {n}"), + _ => Op::process, }; } diff --git a/test/js/node/zlib/zlib.test.js b/test/js/node/zlib/zlib.test.js index 51f0a51a3ea7..72a7067336c3 100644 --- a/test/js/node/zlib/zlib.test.js +++ b/test/js/node/zlib/zlib.test.js @@ -1,6 +1,6 @@ import { deflateSync, gunzipSync, gzipSync, inflateSync } from "bun"; import { describe, expect, it } from "bun:test"; -import { tmpdirSync } from "harness"; +import { bunEnv, bunExe, tmpdirSync } from "harness"; import * as buffer from "node:buffer"; import * as fs from "node:fs"; import { resolve } from "node:path"; @@ -323,6 +323,67 @@ describe("zlib.brotli", () => { expect(compressed.toString()).toEqual(Buffer.from(compressedString3, "base64").toString()); } }); + + // Node validates the native write()/writeSync() flush argument against the + // zlib range (0..=6), so passing a zlib flush constant like Z_FINISH (4) or + // Z_BLOCK (5) to a brotli stream reaches the native set_flush. The Rust + // port mapped only 0..=3 and panicked on the rest; these should instead + // complete the flush and let end() surface the usual Z_BUF_ERROR. + it.each([ + ["Z_FINISH", zlib.constants.Z_FINISH], + ["Z_BLOCK", zlib.constants.Z_BLOCK], + ])("BrotliDecompress.flush(%s) completes instead of aborting", async (_, kind) => { + const script = ` + const z = require("zlib"); + const d = z.createBrotliDecompress(); + const events = []; + d.on("error", e => { + events.push("err:" + e.code); + console.log(JSON.stringify(events)); + }); + d.flush(${kind}, () => events.push("flushed")); + d.end(); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), exitCode }).toEqual({ + stdout: JSON.stringify(["flushed", "err:Z_BUF_ERROR"]), + exitCode: 0, + }); + void stderr; + }); + + it("BrotliDecompress.flush(Z_FINISH) still decodes valid input", async () => { + const script = ` + const z = require("zlib"); + const d = z.createBrotliDecompress(); + const buf = z.brotliCompressSync(Buffer.from("hello world")); + const out = []; + d.on("data", b => out.push(b)); + d.on("error", e => { console.log("err:" + e.code); process.exit(1); }); + d.on("end", () => console.log("data:" + Buffer.concat(out).toString())); + d.write(buf); + d.flush(z.constants.Z_FINISH, () => console.log("flushed")); + d.end(); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim().split("\n"), exitCode }).toEqual({ + stdout: ["flushed", "data:hello world"], + exitCode: 0, + }); + void stderr; + }); }); it.each([ From 5328345478ec4a704eaa7e2e491345bb7c138bab Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:23:48 +0000 Subject: [PATCH 2/5] test: assert stdout before exitCode in brotli flush tests --- test/js/node/zlib/zlib.test.js | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/test/js/node/zlib/zlib.test.js b/test/js/node/zlib/zlib.test.js index 72a7067336c3..e37043f8cca2 100644 --- a/test/js/node/zlib/zlib.test.js +++ b/test/js/node/zlib/zlib.test.js @@ -351,11 +351,9 @@ describe("zlib.brotli", () => { stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout: stdout.trim(), exitCode }).toEqual({ - stdout: JSON.stringify(["flushed", "err:Z_BUF_ERROR"]), - exitCode: 0, - }); void stderr; + expect(stdout.trim()).toBe(JSON.stringify(["flushed", "err:Z_BUF_ERROR"])); + expect(exitCode).toBe(0); }); it("BrotliDecompress.flush(Z_FINISH) still decodes valid input", async () => { @@ -378,11 +376,9 @@ describe("zlib.brotli", () => { stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout: stdout.trim().split("\n"), exitCode }).toEqual({ - stdout: ["flushed", "data:hello world"], - exitCode: 0, - }); void stderr; + expect(stdout.trim().split("\n")).toEqual(["flushed", "data:hello world"]); + expect(exitCode).toBe(0); }); }); From e22acc8114b9b47aaeba0439558bbf874b9caf20 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:35:27 +0000 Subject: [PATCH 3/5] test: cover BrotliCompress.flush(Z_FINISH) encoder path --- test/js/node/zlib/zlib.test.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/js/node/zlib/zlib.test.js b/test/js/node/zlib/zlib.test.js index e37043f8cca2..c7a490679e7b 100644 --- a/test/js/node/zlib/zlib.test.js +++ b/test/js/node/zlib/zlib.test.js @@ -380,6 +380,36 @@ describe("zlib.brotli", () => { expect(stdout.trim().split("\n")).toEqual(["flushed", "data:hello world"]); expect(exitCode).toBe(0); }); + + it("BrotliCompress.flush(Z_FINISH) still produces decodable output", async () => { + // set_flush is shared between encode and decode; on the encode path the + // stored flush op is passed directly to BrotliEncoderCompressStream, so + // the out-of-range mapping must not break the finish sequence. + const script = ` + const z = require("zlib"); + const c = z.createBrotliCompress(); + const chunks = []; + c.on("data", b => chunks.push(b)); + c.on("error", e => { console.log("err:" + e.code); process.exit(1); }); + c.on("end", () => { + const out = z.brotliDecompressSync(Buffer.concat(chunks)); + console.log("roundtrip:" + out.toString()); + }); + c.write(Buffer.from("hello world")); + c.flush(z.constants.Z_FINISH, () => console.log("flushed")); + c.end(); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + void stderr; + expect(stdout.trim().split("\n")).toEqual(["flushed", "roundtrip:hello world"]); + expect(exitCode).toBe(0); + }); }); it.each([ From b836da556eca07a3f3d59993934933760e20b0ba Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:08:28 +0000 Subject: [PATCH 4/5] test: run brotli flush subprocess tests concurrently --- test/js/node/zlib/zlib.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/js/node/zlib/zlib.test.js b/test/js/node/zlib/zlib.test.js index c7a490679e7b..3bb2d42d48d3 100644 --- a/test/js/node/zlib/zlib.test.js +++ b/test/js/node/zlib/zlib.test.js @@ -329,7 +329,7 @@ describe("zlib.brotli", () => { // Z_BLOCK (5) to a brotli stream reaches the native set_flush. The Rust // port mapped only 0..=3 and panicked on the rest; these should instead // complete the flush and let end() surface the usual Z_BUF_ERROR. - it.each([ + it.concurrent.each([ ["Z_FINISH", zlib.constants.Z_FINISH], ["Z_BLOCK", zlib.constants.Z_BLOCK], ])("BrotliDecompress.flush(%s) completes instead of aborting", async (_, kind) => { @@ -356,7 +356,7 @@ describe("zlib.brotli", () => { expect(exitCode).toBe(0); }); - it("BrotliDecompress.flush(Z_FINISH) still decodes valid input", async () => { + it.concurrent("BrotliDecompress.flush(Z_FINISH) still decodes valid input", async () => { const script = ` const z = require("zlib"); const d = z.createBrotliDecompress(); @@ -381,7 +381,7 @@ describe("zlib.brotli", () => { expect(exitCode).toBe(0); }); - it("BrotliCompress.flush(Z_FINISH) still produces decodable output", async () => { + it.concurrent("BrotliCompress.flush(Z_FINISH) still produces decodable output", async () => { // set_flush is shared between encode and decode; on the encode path the // stored flush op is passed directly to BrotliEncoderCompressStream, so // the out-of-range mapping must not break the finish sequence. From aec93712db8f29345c5ddc98dbe3e81c7b70fe47 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:41:01 +0000 Subject: [PATCH 5/5] ci: retrigger