Skip to content

node:zlib: reject invalid brotli flush values at validation - #31505

Open
alii wants to merge 14 commits into
mainfrom
ali/brotli-flush-no-abort
Open

alii wants to merge 14 commits into
mainfrom
ali/brotli-flush-no-abort

Conversation

@alii

@alii alii commented May 28, 2026 •

Copy link
Copy Markdown
Member

Brotli streams aborted the whole process (an uncatchable unreachable!() panic, in release too) when .flush() was called with a zlib flush value the brotli encoder doesn't define, like Z_FINISH (4) or Z_BLOCK (5). The shared write path validated flush against the full zlib range for every codec, but brotli only defines BrotliEncoderOperation 0..=3, so these values reached brotli's set_flush and trapped. Node had the same codec-blind gap and spun at 100% CPU on these instead; they fixed it in nodejs/node#63746 for nodejs/node#63701.

Fix (matches Node's landed behavior):

  • ZlibBase.prototype.flush(kind) validates kind against the codec's FLUSH_BOUND via checkRangesOrGetDefault before queuing the fake flush chunk, so an out-of-range number throws ERR_OUT_OF_RANGE (RangeError) synchronously, a non-number throws ERR_INVALID_ARG_TYPE (TypeError), and undefined/NaN fall through to the default flush op. _flushBoundIdx is stored on the instance to make that lookup possible. This is exactly what zlib: validate flush kind for brotli streams nodejs/node#63746 does, and the vendored upstream test passes.
  • Native validation is type-safe per codec as defense-in-depth: each compression context declares a typed FlushOp (brotli BrotliEncoderOperation, zlib FlushValue, zstd's raw c_int) with a fallible flush_op_from_u32 as the single native validation point, so an invalid flush value is unrepresentable past the write boundary and set_flush is total (no catch-all, no unreachable!). processChunk routes a synchronous native rejection into the stream's error path so the _processChunk() backwards-compat entry never lets one escape into the stream machinery either.

Tests (test/js/node/zlib/zlib.test.js + vendored Node parallel test):

  • test/parallel/test-zlib-brotli-flush-invalid-kind.js vendored verbatim: gzip/brotli/zstd × valid/out-of-range/non-number/undefined/NaN/callback-only flush kinds
  • brotli compress + decompress .flush(Z_FINISH) / .flush(Z_BLOCK) → sync ERR_OUT_OF_RANGE RangeError, run in a subprocess so a regression (abort or spin) fails the test instead of killing the runner
  • brotli .flush() (implicit) and .flush(BROTLI_OPERATION_FLUSH) still round-trip
  • createDeflate().flush(Z_FINISH) still round-trips

Verification: bun bd test test/js/node/zlib/zlib.test.js → 385 pass / 0 fail; all 57 test/parallel/test-zlib-*.js pass; the four subprocess rejection tests and the vendored Node test both fail on the unfixed canary (abort / wrong error type). bun run rust:check-all → 10/10 targets ok.


[review] gate passed · iteration 12 · 7 files touched

fails on main (without fix)
ASAN without fix: 4 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 (980eaa010)

test/js/node/zlib/zlib.test.js:
(pass) prototype and name and constructor > Gzip > Gzip.prototype should be instanceof Gzip.__proto__ [2.45ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.constructor should be Gzip [1.15ms]
(pass) prototype and name and constructor > Gzip > Gzip.name should be Gzip [1.53ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.__proto__.constructor.name should be Zlib [1.98ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype should be instanceof Gunzip.__proto__ [0.82ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.constructor should be Gunzip [0.30ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.name should be Gunzip [0.26ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.__proto__.constructor.name should be Zlib [0.31ms]
(pass) prototype and name and constructor > Deflate > Deflate.prototype should be instanceof Deflate.__prot
... (truncated)

release without fix: 4 failed, 2 skipped
bun test v1.4.0-canary.1 (9008ae7ab)

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 (980eaa010)

test/js/node/zlib/zlib.test.js:
(pass) prototype and name and constructor > Gzip > Gzip.prototype should be instanceof Gzip.__proto__ [2.22ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.constructor should be Gzip [1.14ms]
(pass) prototype and name and constructor > Gzip > Gzip.name should be Gzip [1.38ms]
(pass) prototype and name and constructor > Gzip > Gzip.prototype.__proto__.constructor.name should be Zlib [1.69ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype should be instanceof Gunzip.__proto__ [0.44ms]
(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.26ms]
(pass) prototype and name and constructor > Gunzip > Gunzip.prototype.__proto__.constructor.name should be Zlib [0.30ms]
(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)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     980eaa0103
  features     baseline

22 deps, 107 codegen, 1176 objects in 968ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] install /workspace/bun
bun install v1.4.0-canary.1 (9008ae7ab)

Checked 107 installs across 153 packages (no changes) [25.00ms]
[2/1238] gen ErrorCode+*.h
[3/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (9008ae7ab)

Checked 1 install across 2 packages (no changes) [1.00ms]
[4/1238] fetch tinycc
[tinycc] up to date
[5/1237] gen bindgenv2
[6/1237] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[7/1237] fetch picohttpparser
[picohttpparser] up to date
[8/1237] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (9008ae7ab)

Checked 129 installs across 147 packages (no changes) [7.00ms]
[9/1237] fetch zlib
[zlib] up to date
[10/1237] subst deps/zlib/zlib.h
[11/1237] subst deps/libjpeg-turbo/jconfig.h
[12/1237] gen .bind.ts → GeneratedBindings.cpp
[13/1237] fetch zstd
[zstd] up to date
[14/1206] s
... (truncated)
diff hotspot
src/js/node/zlib.ts                                | 40 +++++++---
 src/runtime/node/node_zlib_binding.rs              | 49 +++++++++---
 src/runtime/node/zlib/NativeBrotli.rs              | 34 ++++----
 src/runtime/node/zlib/NativeZlib.rs                | 35 ++++----
 src/runtime/node/zlib/NativeZstd.rs                | 20 +++--
 .../test-zlib-brotli-flush-invalid-kind.js         | 92 ++++++++++++++++++++++
 test/js/node/zlib/zlib.test.js                     | 88 ++++++++++++++++++++-
 7 files changed, 301 insertions(+), 57 deletions(-)

gate history · 1 passed · 0 rejected · iteration 12

evidence per changed file
file                                                      reads  edits  tests
src/js/node/zlib.ts                                           4      3     45
src/runtime/node/node_zlib_binding.rs                        14     21     45
src/runtime/node/zlib/NativeBrotli.rs                         8     10     44
src/runtime/node/zlib/NativeZlib.rs                           3      6     44
src/runtime/node/zlib/NativeZstd.rs                           2      4     44
…de/test/parallel/test-zlib-brotli-flush-invalid-kind.js      0      1     44
test/js/node/zlib/zlib.test.js                               10     11     44

@alii

alii commented May 28, 2026

Copy link
Copy Markdown
Member Author

@robobun adopt

@robobun

robobun commented May 28, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 10:07 PM PT - Jul 7th, 2026

❌ @robobun, your commit 611d473 has 2 failures in Build #70185 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31505

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

bun-31505 --bun

@robobun

robobun commented May 28, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Ready for review. Reproduced: brotli .flush(Z_FINISH)/.flush(Z_BLOCK) aborted the whole process with an uncatchable unreachable!() panic on the release this PR was opened against. On current main (after #36165) the same call no longer aborts, but the rejection is thrown from inside the stream's write path, so it surfaces as an uncaughtException instead of an error the caller can catch.

Fix: matches Node's landed nodejs/node#63746. .flush(kind) validates kind against the codec's FLUSH_BOUND via checkRangesOrGetDefault, so out-of-range throws ERR_OUT_OF_RANGE synchronously, non-numbers throw ERR_INVALID_ARG_TYPE, and undefined/NaN use the default. Type-safe native FlushOp validation stays as defense-in-depth for direct _handle.write() / _processChunk() callers. Vendored Node's upstream test-zlib-brotli-flush-invalid-kind.js.

State: merged main in at 980eaa0103 (the branch conflicted with #36165, which had added a simpler per-codec flush_value_is_valid; resolution details in the comment below). Verified on the merged head with a debug build: test/js/node/zlib/zlib.test.js 394 pass / 0 fail, all 61 test/parallel/test-zlib-*.js pass, zlib-handle-bounds-check.test.ts and zlib-reset-race.test.ts from #36165 pass unchanged. The four subprocess tests and the vendored Node test fail on the unfixed build.

@coderabbitai

coderabbitai Bot commented May 28, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds codec-specific MAX_FLUSH and write-time validation, coerces out-of-range Brotli flush to Op::process, wraps native write in JS to forward sync errors to callbacks, updates macro invocations, and expands Brotli/Zstd tests and timeouts.

Changes

Compression flush validation and tests

Layer / File(s) Summary
Binding trait and macro + zlib/zstd macro wiring
src/runtime/node/node_zlib_binding.rs, src/runtime/node/zlib/NativeZlib.rs, src/runtime/node/zlib/NativeZstd.rs
Adds CompressionStreamImpl::MAX_FLUSH, updates __impl_compression_stream! to accept $max_flush, uses T::MAX_FLUSH for write/writeSync validation, and passes explicit max flush values for NativeZlib and NativeZstd.
Brotli native coercion
src/runtime/node/zlib/NativeBrotli.rs
Context::set_flush now coerces out-of-range flush values to Op::process instead of panicking; macro invocation for Brotli passes explicit max flush 3 and comment documents Brotli op range.
JS defensive write and expanded tests
src/js/node/zlib.ts, test/js/node/zlib/zlib.test.js
processChunk() wraps handle.write(...) in try/catch to clear buffers and route sync native errors to the callback. Tests: harness import expanded; streaming timeouts extended for Debug/ASAN; new subprocess-based Brotli .flush(kind) rejection tests plus follow-up valid-flush round-trip and Zstd timeout adjustments.
🚥 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 identifies the main change: codec-aware rejection of invalid Brotli flush values.
Description check ✅ Passed The description explains the problem, implementation, tests, verification results, and expected error behavior in sufficient detail.

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 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 `@test/js/node/zlib/zlib.test.js`:
- Around line 356-359: The test currently asserts stderr is exactly empty
(expect(stderr).toBe("")) which fails on ASAN shards; update the assertion to
first remove known ASAN startup warnings by splitting stderr into lines,
filtering out lines that match the ASAN startup pattern (e.g. lines starting
with "WARNING: ASAN interferes" or the full ASAN warning regex), then rejoining
the remaining lines and asserting that the filtered stderr is empty; apply this
change to the assertion around the first use of proc.stderr.text() (the block
using proc, stdout, stderr, exitCode and zlib.brotliDecompressSync) and the
similar assertion later in the file so both expect(stderr).toBe("") checks use
the filtered-stderr check instead.
🪄 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: 512a0522-4b4c-44b4-96de-7cc22eedff1a

📥 Commits

Reviewing files that changed from the base of the PR and between b69085e and a781833.

📒 Files selected for processing (2)
  • src/runtime/node/zlib/NativeBrotli.rs
  • test/js/node/zlib/zlib.test.js

Comment thread test/js/node/zlib/zlib.test.js 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 — replaces a process-aborting unreachable!() with a safe fallback to BROTLI_OPERATION_PROCESS, matching Node and prior Bun behavior, with subprocess regression tests.

Extended reasoning...

Overview

This PR changes a single match arm in src/runtime/node/zlib/NativeBrotli.rs's Context::set_flush: the catch-all arm for flush values outside 0..=3 now maps to Op::process instead of panicking with unreachable!(). The accompanying comment is updated to explain that the shared zlib write path validates flush against the full zlib range (0..=6), so Z_FINISH (4) and Z_BLOCK (5) can legitimately reach this function, and that both Node's static_cast and the brotli C encoder treat unknown ops with PROCESS semantics. Four regression tests are added in test/js/node/zlib/zlib.test.js that spawn a subprocess, call .flush(Z_FINISH) / .flush(Z_BLOCK) on brotli compress/decompress streams, and assert clean exit plus a correct round-trip.

Security risks

None. The change replaces an uncatchable process abort with a defined, benign operation (PROCESS — continue encoding without flushing/finishing). No new inputs are accepted, no buffer math changes, and the decoder already ignores flush entirely. The fallback is strictly less dangerous than the prior behavior.

Level of scrutiny

Low. The functional change is one line, the rationale is well-documented and matches Node.js semantics (which pass the raw int through via static_cast), and the prior behavior was a hard crash — so any regression risk is bounded by "behaves like PROCESS instead of aborting." The tests run in a subprocess specifically to verify the abort no longer occurs and that compressed output still decompresses correctly.

Other factors

No CODEOWNERS cover these files. No prior reviewer comments to address. The bug-hunting system found no issues. The test additions follow existing patterns in the file (bunExe/bunEnv/Bun.spawn with await using). The added harness imports (bunEnv, bunExe) are standard across the test suite.

@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 — replaces a process-aborting unreachable!() with a safe Op::process fallback that matches Node and the brotli C encoder, with subprocess round-trip tests covering both flush values.

Extended reasoning...

Overview

This PR touches two files: a one-arm change in src/runtime/node/zlib/NativeBrotli.rs:set_flush (replacing n => unreachable!(...) with _ => Op::process) and four new subprocess tests in test/js/node/zlib/zlib.test.js. The source change stops brotli streams from aborting the entire process when .flush() is called with a zlib flush constant (Z_FINISH=4, Z_BLOCK=5, Z_TREES=6) that the shared write path validates as in-range but the brotli encoder doesn't define. Out-of-range values are now coerced to BROTLI_OPERATION_PROCESS, which the PR description verifies is exactly what the brotli C encoder does with an unknown op (is_last=false/force_flush=false), and what Node and Bun 1.3.14 already do.

Security risks

None. This is compression-stream flush handling with no auth, crypto, permissions, or untrusted-input parsing involved. The change is strictly a hardening: it removes an uncatchable runtime panic reachable from user JS and replaces it with a benign no-op-equivalent operation. The decoder ignores flush entirely, so the decompress path is unaffected semantically.

Level of scrutiny

Low-to-moderate. The behavioral change is a single match arm in a small, well-understood function, and it converts a hard abort into the same fallback Node uses. The updated comment thoroughly documents why (shared write path validates 0..=6, brotli only defines 0..=3, C encoder treats unknown ops as PROCESS). The tests are subprocess-isolated so a regression would surface as a non-zero exit code rather than crashing the test runner.

Other factors

  • Bug hunter found no issues.
  • The one CodeRabbit review comment (filter ASAN stderr noise before asserting empty) was addressed in 73198ac and confirmed resolved; the tests now use the repo-standard .filter(l => l && !l.startsWith("WARNING: ASAN interferes")) pattern.
  • Tests follow repo conventions: bunEnv/bunExe, await using for the spawned process, exit code asserted last after stdout/stderr checks.
  • No CODEOWNERS cover these paths.
  • robobun validated the gate both ways (revert → panic, fix → green).

@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Actionable comments posted: 0

1 similar comment
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Actionable comments posted: 0

@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 — replacing the unreachable!() with Op::process is a minimal, well-justified fix for the abort, and the round-trip tests cover it.

Extended reasoning...

Overview

This PR touches two files: a one-line behavioral change in src/runtime/node/zlib/NativeBrotli.rs (replacing unreachable!() with Op::process in the catch-all arm of set_flush), and test additions/adjustments in test/js/node/zlib/zlib.test.js (new subprocess-based regression tests for .flush(Z_FINISH)/.flush(Z_BLOCK) on brotli streams, plus extended timeouts for two existing 50MB streaming tests under debug/ASAN).

Security risks

None. The change makes the flush-value match arm more permissive by mapping out-of-range zlib flush constants (4/5/6) to BROTLI_OPERATION_PROCESS instead of aborting the process. No user-controlled data reaches new code paths, no auth/crypto/permissions involved, and the fallback semantics match what the brotli C encoder already does with an unrecognized op (is_last=false/force_flush=false).

Level of scrutiny

Low-to-medium. While this is native runtime code, the change is strictly a panic→fallback conversion that cannot regress previously-working inputs (values 0–3 are unchanged). The fallback choice is well-justified in the comment and PR description (matches Node's static_cast pass-through and the prior Zig @enumFromInt behavior). The fix restores parity with Bun 1.3.14 and Node.

Other factors

  • The bug-hunting system found no issues.
  • The single CodeRabbit review comment (filter ASAN stderr noise) was addressed in 73198ac and confirmed resolved.
  • New tests run in subprocesses (so a regression to the abort would be caught as a non-zero exit rather than killing the test runner), follow repo conventions (bunEnv/bunExe, ASAN stderr filtering, exit-code asserted last), and verify full round-trip correctness.
  • The timeout bumps (isDebug || isASAN ? 60_000 : 15_000) are reasonable test-infra adjustments with no production impact.
  • CI shows build-rust failures on an earlier commit (2fc2459), but the actual Rust diff is a trivial match-arm change that cannot itself cause a compile error; the latest commit is an autofix.ci formatting pass.

@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 — minimal fix replacing a process-aborting panic with PROCESS semantics, matching Node and the brotli C encoder's own fallback; well covered by the new round-trip tests.

Extended reasoning...

Overview

Two files touched: a one-line behavioral change in src/runtime/node/zlib/NativeBrotli.rs (the catch-all arm of set_flush now maps to Op::process instead of unreachable!()), and additions to test/js/node/zlib/zlib.test.js (four new subprocess-based regression tests for .flush(Z_FINISH)/.flush(Z_BLOCK) on brotli compress/decompress, plus Debug/ASAN timeout bumps on two existing streaming tests). The large-looking test diff is mostly reindentation from moving the timeout argument.

Security risks

None. No user input parsing, auth, crypto, or filesystem changes. The fix narrows behavior from "abort the entire process" to "treat as PROCESS", which is strictly safer for callers. The flush value is already validated upstream against 0..=6, so this is just handling 4/5/6 gracefully rather than trapping. The new tests spawn bunExe() with fixed inline scripts and bunEnv, following the established harness pattern — no injection surface.

Level of scrutiny

Low. The native change is a single match-arm substitution with a clear, well-documented rationale: the shared write path validates against the zlib flush range (0..=6), brotli only defines 0..=3, and the brotli C encoder treats unknown ops as PROCESS (is_last=false/force_flush=false). The PR description confirms this was verified against vendor/brotli/c/enc/encode.c, and that Node and Bun 1.3.14 already behave this way. The decoder ignores flush entirely, so the decompress side is unaffected functionally.

Other factors

  • robobun gate-verified both directions (revert → panic, fix → green).
  • The one review comment (CodeRabbit's ASAN stderr filtering) was addressed in 73198ac and confirmed resolved; the final diff shows the filter applied in both test blocks per repo convention.
  • Exit code is asserted last, after stderr/stdout assertions, per repo test guidelines.
  • No CODEOWNERS entries match these paths.
  • No bugs found by the bug-hunting system.

@alii

alii commented Jun 1, 2026

Copy link
Copy Markdown
Member Author

@robobun please replace the current fix with a cleaner one that eliminates the bug at the validation layer instead of papering over it in set_flush.

Root cause: flush validation is codec-blind. flush_value_is_valid(n) = n <= 6 (src/runtime/node/node_zlib_binding.rs:88) is the zlib flush range, but it gates the write path for every codec (called at ~:319 and ~:600). So a brotli stream accepts flush values 4/5/6 (Z_FINISH/Z_BLOCK/Z_TREES) that have no brotli operation, and they reach set_flush, where the current PR maps them to PROCESS as a fallback. (Node has the same root cause and hangs forever on these — filed upstream at nodejs/node#63701.)

The change:

  1. Make flush validation codec-aware. Add a method to the codec/stream trait (the one with set_flush, ~node_zlib_binding.rs:208) e.g. fn flush_is_valid(&self, flush: u32) -> bool, with a default returning flush <= 6 (zlib/gzip). Override it for brotli (src/runtime/node/zlib/NativeBrotli.rs) to return flush <= 3 (PROCESS/FLUSH/FINISH/EMIT_METADATA — the only real BrotliEncoderOperations). Leave zstd on whatever its valid set is (don't change its behavior).
  2. Replace the two flush_value_is_valid(flush) call sites (~:319, ~:600) with the codec-aware self.flush_is_valid(flush). They already throw ERR_INVALID_ARG_VALUE "Invalid flush value" on failure — keep that. So brotli .flush(Z_FINISH) now throws a clean error at the boundary instead of reaching the encoder.
  3. In NativeBrotli.rs set_flush, remove the _ => Op::process fallback. The match now only ever sees 0..=3, so it's exhaustive over the real ops — set_flush no longer needs to defend against out-of-range input (validation guarantees it). Keep the four arms (0→process, 1→flush, 2→finish, 3→emit_metadata).
  4. Mirror the brotli flush_is_valid in the .zig sibling for parity (reference only).

Behavior change (intended, maintainer-approved): brotli .flush(Z_FINISH)/.flush(Z_BLOCK) now throw ERR_INVALID_ARG_VALUE instead of being silently accepted. That matches how .flush(Z_TREES) (6) already behaves, and it's strictly better than Node (which hangs). zlib/gzip/zstd flush behavior must be unchanged.

Tests (replace the current "does not abort" brotli flush tests in test/js/node/zlib/zlib.test.js):

  • createBrotliCompress().flush(zlib.constants.Z_FINISH) and .flush(Z_BLOCK) throw ERR_INVALID_ARG_VALUE (assert .code), and do not crash or hang. Run in a subprocess so a regression (abort/hang) shows as a non-zero exit / timeout, not a pass.
  • Sanity: brotli .flush() with no arg, and .flush(zlib.constants.BROTLI_OPERATION_FLUSH) (1), still work and round-trip.
  • A zlib stream (createDeflate) .flush(Z_FINISH) (4) still works unchanged — proves the validation narrowing is brotli-only.
  • Verify: fails appropriately on pre-change main (abort), passes after; bun bd test test/js/node/zlib/zlib.test.js green; bun run rust:check-all green (trait change is cross-platform).

When it's pushed, update the PR title/body to reflect the new approach: "reject invalid brotli flush values at validation" rather than "map out-of-range flush to PROCESS", and reference nodejs/node#63701 as the corresponding upstream bug.

@alii

alii commented Jun 1, 2026

Copy link
Copy Markdown
Member Author

@robobun here's the concrete implementation of the codec-aware-validation approach — please apply this exact diff, then build/test/verify and push to this branch. I wrote it but couldn't build-verify it locally (env constraints), so the key thing is to confirm it compiles, run the tests, and pin down how the rejection surfaces (see the test note).

It eliminates the bug class instead of patching the crash site: flush validation becomes codec-aware via a MAX_FLUSH associated const (brotli 3, zlib/zstd 6), so brotli rejects Z_FINISH/Z_BLOCK at the write boundary with ERR_INVALID_ARG_VALUE before they reach the encoder. The old codec-blind flush_value_is_valid (0..=6 for everything) is removed. NativeBrotli::set_flush is intentionally left as-is (its catch-all arm is still required for the match and is now unreachable defense-in-depth).

diff --git a/src/runtime/node/node_zlib_binding.rs b/src/runtime/node/node_zlib_binding.rs
@@ fn jsv_to_u32
-/// Local `std.meta.intToEnum(FlushValue, n)` shim — `bun_zlib::FlushValue` has
-/// no `TryFrom<u32>` impl upstream.
-#[inline]
-fn flush_value_is_valid(n: u32) -> bool {
-    // FlushValue is `#[repr(C)]` with discriminants 0..=6.
-    n <= 6
-}
-
@@ pub(crate) trait CompressionStreamImpl: Sized + Taskable + 'static {
     type Stream: CompressionContext;
 
+    /// Largest accepted `flush` value for this codec. zlib/gzip and zstd take
+    /// the full zlib flush range (0..=6); brotli only the four
+    /// `BrotliEncoderOperation` values (0..=3), so a zlib-only mode like
+    /// Z_FINISH or Z_BLOCK is rejected at the write boundary instead of
+    /// reaching the encoder (where it would spin — see nodejs/node#63701).
+    const MAX_FLUSH: u32;
+
@@ both validation sites (in `write` and `write_sync`, ~lines 318 and 599)
-        if !flush_value_is_valid(flush) {
+        if flush > T::MAX_FLUSH {
@@ macro_rules! __impl_compression_stream
-    ($native:ident, $ctx:ty, $type_name:literal) => {
+    ($native:ident, $ctx:ty, $type_name:literal, $max_flush:expr) => {
@@ impl CompressionStreamImpl for $native {
         type Stream = $ctx;
+        const MAX_FLUSH: u32 = $max_flush;

And add the $max_flush argument to the three macro invocations:

  • src/runtime/node/zlib/NativeBrotli.rs: __impl_compression_stream!(NativeBrotli, Context, "NativeBrotli", 3);
  • src/runtime/node/zlib/NativeZlib.rs: __impl_compression_stream!(NativeZlib, super::Context, "NativeZlib", 6);
  • src/runtime/node/zlib/NativeZstd.rs: __impl_compression_stream!(NativeZstd, Context, "NativeZstd", 6);

Tests — replace the existing "flush(%s) does not abort the process" brotli tests in test/js/node/zlib/zlib.test.js (they asserted a round-trip, which no longer applies now that the flush is rejected):

  • brotli createBrotliCompress().flush(zlib.constants.Z_FINISH) and .flush(Z_BLOCK) now reject with ERR_INVALID_ARG_VALUE, and must NOT crash or hang. Pin down how it surfaces — likely an 'error' event on the stream (async) rather than a synchronous throw, since .flush() queues a write; assert against whichever it actually is. Run in a subprocess so a regression (abort or the zlib: BrotliCompress.flush() with Z_FINISH or Z_BLOCK hangs at 100% CPU nodejs/node#63701-style hang) shows up as a non-zero exit / timeout rather than a pass.
  • brotli .flush() (no arg) and .flush(BROTLI_OPERATION_FLUSH) (1) still work and round-trip — proves valid flushes are unaffected.
  • A zlib stream createDeflate().flush(Z_FINISH) (4) still works unchanged — proves the narrowing is brotli-only.

Verify: bun bd test test/js/node/zlib/zlib.test.js green; bun run rust:check-all green (the trait/macro change is cross-platform). Then update the PR title/body to "reject invalid brotli flush values at validation" and keep the nodejs/node#63701 reference.

@alii

alii commented Jun 1, 2026

Copy link
Copy Markdown
Member Author

@robobun one correction on the error type for the change above: throw ERR_INVALID_ARG_TYPE (a TypeError), not ERR_INVALID_ARG_VALUE.

Verified against Node v26: an out-of-range brotli flush (flush(6)) and an out-of-range zlib flush (createDeflate().flush(99)) both throw TypeError with code ERR_INVALID_ARG_TYPE. So:

  • At both validation sites (write and write_sync), change the thrown error from ErrorCode::INVALID_ARG_VALUE to ErrorCode::INVALID_ARG_TYPE.
  • This also changes the zlib path's code (flush > 6) from ERR_INVALID_ARG_VALUE → ERR_INVALID_ARG_TYPE, which is correct — it matches Node. Update any existing test that asserted the old ERR_INVALID_ARG_VALUE code.
  • Use a clear, flush-specific message (e.g. about the flush argument). Do not copy Node's literal text (The "chunk" argument must be of type…) — that's an incidental artifact of how Node mishandles the value internally, not a real flush message.
  • Tests should assert err.code === "ERR_INVALID_ARG_TYPE" (and that it's a TypeError), not the message.

Everything else in the patch (the MAX_FLUSH const = 3 for brotli / 6 for zlib+zstd, the two flush > T::MAX_FLUSH checks, removing flush_value_is_valid) stays as specified.

@robobun robobun changed the title node:zlib: don't abort on brotli .flush() with a zlib flush value node:zlib: reject invalid brotli flush values at validation Jun 1, 2026

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/node/zlib/NativeBrotli.rs (1)

420-431: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid silently coercing unexpected Brotli flush values

The Node zlib binding rejects flush > T::MAX_FLUSH with INVALID_ARG_VALUE before calling NativeBrotli::set_flush, so the _ => Op::process arm should remain unreachable; keeping it as a silent fallback can mask future boundary/parity regressions. Prefer failing fast by making the _ case unreachable!.

Suggested change
             self.flush = match flush {
                 0 => Op::process,
                 1 => Op::flush,
                 2 => Op::finish,
                 3 => Op::emit_metadata,
-                _ => Op::process,
+                _ => unreachable!("invalid brotli flush value: {flush}"),
             };
🤖 Prompt for 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.

In `@src/runtime/node/zlib/NativeBrotli.rs` around lines 420 - 431, The match
fallback in NativeBrotli::set_flush currently maps unexpected flush values to
Op::process, which can silently hide boundary/parity regressions; change the `_
=> Op::process` arm to call unreachable!() (e.g., unreachable!("invalid Brotli
flush value in set_flush")) so the code fails fast if an out-of-range flush
reaches set_flush, keeping the four explicit arms (0..3) intact and preserving
the defensive comment.
🤖 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.

Outside diff comments:
In `@src/runtime/node/zlib/NativeBrotli.rs`:
- Around line 420-431: The match fallback in NativeBrotli::set_flush currently
maps unexpected flush values to Op::process, which can silently hide
boundary/parity regressions; change the `_ => Op::process` arm to call
unreachable!() (e.g., unreachable!("invalid Brotli flush value in set_flush"))
so the code fails fast if an out-of-range flush reaches set_flush, keeping the
four explicit arms (0..3) intact and preserving the defensive comment.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b8356000-f2c8-410e-9535-7f2632c933ed

📥 Commits

Reviewing files that changed from the base of the PR and between 4a963f7 and bd76c6f.

📒 Files selected for processing (6)
  • src/js/node/zlib.ts
  • src/runtime/node/node_zlib_binding.rs
  • src/runtime/node/zlib/NativeBrotli.rs
  • src/runtime/node/zlib/NativeZlib.rs
  • src/runtime/node/zlib/NativeZstd.rs
  • test/js/node/zlib/zlib.test.js

@robobun
robobun force-pushed the ali/brotli-flush-no-abort branch from 18c5c95 to e3d5cb0 Compare June 1, 2026 20:16
Comment thread src/runtime/node/node_zlib_binding.rs Outdated
alii and others added 7 commits June 1, 2026 20:37
The two 'streaming encode doesn't wait for entire input' tests push 50MB
through the stream and pass comfortably on release but exceed the 15s
timeout under the much slower debug/ASAN build. Bump the timeout to 60s
on debug/ASAN, matching the existing convention used elsewhere.
Make flush validation codec-aware: each compression stream declares
MAX_FLUSH (brotli 3, zlib/gzip/zstd 6) and the shared write path rejects
anything above it with ERR_INVALID_ARG_VALUE, so a zlib-only flush value
like Z_FINISH/Z_BLOCK never reaches the brotli encoder. processChunk now
routes a synchronous rejection from the native write into the stream's
error path (error event + errored write callback) instead of letting it
escape into the stream machinery.

Replaces the previous approach of coercing out-of-range brotli flush
values to BROTLI_OPERATION_PROCESS.
Node v26 throws a TypeError with code ERR_INVALID_ARG_TYPE for an
out-of-range flush value (both the zlib range check and brotli's), so
match the code at both write-boundary validation sites. The message
stays flush-specific rather than copying Node's incidental chunk-typed
text.
@robobun
robobun force-pushed the ali/brotli-flush-no-abort branch from e3d5cb0 to 062591b Compare June 1, 2026 20:47
@alii
alii enabled auto-merge (squash) June 1, 2026 21:10
@alii
alii disabled auto-merge June 1, 2026 21:25
@alii

alii commented Jun 1, 2026

Copy link
Copy Markdown
Member Author

@robobun let's make this type-safe instead of validating with a MAX_FLUSH const + a catch-all in set_flush. The goal is API design: set_flush should take a typed flush op, not a raw c_int, so an invalid flush value is unrepresentable past the validation boundary and the _ => Op::process catch-all disappears entirely. (It's only reached by 2 callers today, but the point is the pattern — make invalid states unconstructable.)

This replaces the MAX_FLUSH approach from the previous commits — remove const MAX_FLUSH and the flush > T::MAX_FLUSH checks; the fallible constructor below becomes the validator.

1. CompressionContext trait (node_zlib_binding.rs)

Replace fn set_flush(&mut self, flush: i32); with:

/// The codec's flush-operation enum (brotli `BrotliEncoderOperation`, zlib
/// `FlushValue`, …). Holding a value of this type is proof the flush mode is
/// valid for this codec — invalid ints can't be converted, so they can't reach
/// the encoder.
type FlushOp: Copy;
/// Convert a raw flush int (from JS) into this codec's flush op, or `None` if
/// it isn't a valid op for this codec. This is the single validation point.
fn flush_op_from_u32(flush: u32) -> Option<Self::FlushOp>;
fn set_flush(&mut self, op: Self::FlushOp);

Remove const MAX_FLUSH: u32; from CompressionStreamImpl.

2. The macro __impl_compression_stream!

Change the 4th param from $max_flush:expr to $flush_op:ty. In the impl CompressionContext for $ctx block:

type FlushOp = $flush_op;
#[inline] fn flush_op_from_u32(flush: u32) -> Option<$flush_op> { Self::flush_op_from_u32(flush) }
#[inline] fn set_flush(&mut self, op: $flush_op) { Self::set_flush(self, op) }

(Drop the const MAX_FLUSH = $max_flush; line.)

3. Each codec's inherent methods

Replace each set_flush(&mut self, flush: c_int) with a fallible constructor + a total set_flush. The constructor's accepted set must equal what that codec's current set_flush accepts — read each one first; only brotli's range is the behavior change (it narrows to its 4 real ops). Brotli:

fn flush_op_from_u32(flush: u32) -> Option<Op> {
    match flush {
        0 => Some(Op::process), 1 => Some(Op::flush),
        2 => Some(Op::finish),  3 => Some(Op::emit_metadata),
        _ => None,   // Z_FINISH(4)/Z_BLOCK(5)/etc. are not brotli ops
    }
}
fn set_flush(&mut self, op: Op) { self.flush = op; }   // total — no catch-all, no unreachable

zlib: type FlushOp = the type of its flush field (c::FlushValue); flush_op_from_u32 accepts 0..=6 (its current range) → Some(matching variant), else None; set_flush(op) just stores it. zstd: same shape using its flush field's type and its current accepted set. Don't change zlib/zstd accepted ranges — replicate exactly what their current set_flush maps.

4. The validation boundary (write and write_sync)

Replace the flush > T::MAX_FLUSH check with the fallible conversion, and hold the typed op until the later set_flush call:

let flush: u32 = jsv_to_u32(arguments[0]);
let Some(flush_op) = <T::Stream as CompressionContext>::flush_op_from_u32(flush) else {
    return Err(global_this.err(ErrorCode::INVALID_ARG_TYPE, format_args!(/* clear flush message, NOT Node's "chunk" text */)).throw());
};
// … later, where it currently calls set_flush(i32::try_from(flush)…):
s.set_flush(flush_op);

(FlushOp: Copy lets you capture flush_op into the with_mut closure.)

5. Macro invocations — 4th arg is now the op type

  • __impl_compression_stream!(NativeBrotli, Context, "NativeBrotli", Op)
  • __impl_compression_stream!(NativeZlib, super::Context, "NativeZlib", <zlib flush-field type, e.g. c::FlushValue>)
  • __impl_compression_stream!(NativeZstd, Context, "NativeZstd", <zstd flush-field type>)

Behavior + tests

Observable behavior is identical to the current MAX_FLUSH version: brotli .flush(Z_FINISH)/.flush(Z_BLOCK)/anything > 3 throws ERR_INVALID_ARG_TYPE; valid brotli flushes work; zlib .flush(Z_FINISH) (4) still works (≤6). So the existing tests stay as-is — keep them green. Verify bun bd test test/js/node/zlib/zlib.test.js and bun run rust:check-all are both green. If FlushOp (e.g. BrotliEncoderOperation) isn't Copy, either derive it or move flush_op once instead of capturing.

Replace the MAX_FLUSH bound with a typed flush op: each codec declares
FlushOp (brotli BrotliEncoderOperation, zlib FlushValue, zstd's raw
c_int) and a fallible flush_op_from_u32 that is the single validation
point. set_flush now takes the typed op, so an invalid flush value is
unrepresentable past the write boundary and the catch-all arms in
set_flush disappear. Accepted ranges are unchanged: brotli 0..=3,
zlib/zstd 0..=6.
Comment thread src/runtime/node/node_zlib_binding.rs
@alii
alii enabled auto-merge (squash) June 1, 2026 22:39
robobun added a commit that referenced this pull request Jun 2, 2026
Instead of skipping the brotli/zstd 'streaming encode doesn't wait for
entire input' tests under the sanitizer-instrumented build, give them a
longer timeout like #31505 (with extra headroom: 50 MB through ASAN
brotli measures ~64s on a slow runner, so 180s instead of 60s).

Also note on the two bomb-cap edge tests that the cap-ordering is
manually-verified-only: they allocate multiple GiB so they skip on CI,
and the 1 GiB cap is hardcoded with no injection point.
…bort

# Conflicts:
#	src/runtime/node/node_zlib_binding.rs
#	src/runtime/node/zlib/NativeBrotli.rs
#	src/runtime/node/zlib/NativeZlib.rs
#	src/runtime/node/zlib/NativeZstd.rs
@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Hit this independently while triaging a report of a JS-reachable process abort, and landed on the same root cause: the shared write entry validates flush against the zlib range (0..=6) for every codec, but NativeBrotli::set_flush only maps 0..=3. Re-confirmed on current main (eba370b) that createBrotliCompress().flush(Z_FINISH), .flush(Z_BLOCK), and the same two on createBrotliDecompress() all kill the process:

panic: internal error: entered unreachable code: invalid BrotliEncoderOperation 4

Two things from that triage that may be useful here:

  • There is a third reachable value beyond the two in the test matrix. stream._processChunk(chunk, 6, cb) hands the raw flush flag to the handle without going through kFlushBuffers, so 6 (Z_TREES, which .flush() itself cannot produce since kFlushBuffers has no entry for it) reaches the same trap and aborts on main. The single flush_op_from_u32 validation point here already rejects it; it just is not exercised by the tests.
  • On the red CI for build 67270: the only error annotations are test/js/bun/util/v8-heap-snapshot.test.ts (SIGKILL) and test/js/node/test/parallel/test-net-connect-memleak.js, both of which are failing on unrelated PRs from the same window, and the second already has a quarantine PR open (Quarantine test-net-connect-memleak on linux-x64-musl #33045). Nothing zlib related is red, so the aggregate failure looks like flake rather than this diff.

@alii

alii commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

@robobun nodejs/node#63746 merged, so lets match its behaviour

@robobun

robobun commented Jul 7, 2026 •

Copy link
Copy Markdown
Collaborator

Aligned with nodejs/node#63746 in d1a67dc114:

  • ZlibBase.prototype.flush(kind) now runs kind through checkRangesOrGetDefault against the codec's FLUSH_BOUND before queuing the fake flush chunk (and stores _flushBoundIdx on the instance to make that lookup possible), so an out-of-range kind throws ERR_OUT_OF_RANGE synchronously (RangeError), a non-number throws ERR_INVALID_ARG_TYPE (TypeError), and undefined/NaN fall through to the default — exactly Node's semantics.
  • Vendored Node's upstream test test/parallel/test-zlib-brotli-flush-invalid-kind.js verbatim; it passes. Updated the subprocess regression tests to assert the sync ERR_OUT_OF_RANGE throw.
  • Kept the typed-FlushOp native validation as defense-in-depth (it's what actually prevents the abort for any path that reaches the native write without going through .flush()), and kept the processChunk catch that routes a native rejection into the stream error path for the _processChunk() backwards-compat entry.
  • Also merged current main (took main's faster streaming-test rewrite, so this branch no longer needs the ASAN timeout bump).

Verified: bun bd test test/js/node/zlib/zlib.test.js 385/0; all 57 test/parallel/test-zlib-*.js pass; both the vendored Node test and the subprocess tests fail on the unfixed canary.

robobun added 2 commits July 7, 2026 23:08
…bort

# Conflicts:
#	test/js/node/zlib/zlib.test.js
…in JS

Node landed nodejs/node#63746 for nodejs/node#63701 (the upstream issue
this PR opened): ZlibBase.prototype.flush(kind) now runs kind through
checkRangesOrGetDefault against the codec's FLUSH_BOUND before queuing
the fake flush chunk, so an out-of-range kind throws ERR_OUT_OF_RANGE
synchronously (RangeError), a non-number throws ERR_INVALID_ARG_TYPE,
and undefined/NaN fall through to the default. Apply the same here and
store _flushBoundIdx on the instance to make FLUSH_BOUND available in
.flush(). Vendor Node's test-zlib-brotli-flush-invalid-kind.js; update
the subprocess regression test to assert the synchronous ERR_OUT_OF_RANGE
throw. The typed FlushOp native validation stays as defense-in-depth.
Comment thread src/runtime/node/node_zlib_binding.rs Outdated
The comments at the native write-boundary validation sites (and the
flush_op_from_u32 doc comments) still cited the pre-nodejs/node#63746
rationale of matching Node's incidental ERR_INVALID_ARG_TYPE. Reword
them to describe this check as what it is now: defense-in-depth for
callers that bypass .flush()'s FLUSH_BOUND validation in zlib.ts (which
throws ERR_OUT_OF_RANGE per nodejs/node#63746).
Comment thread test/js/node/zlib/zlib.test.js Outdated
robobun added 2 commits July 7, 2026 23:57
The comment said the narrowed validation is brotli-only, which was true
for the earlier native-only iteration but is stale after matching
nodejs/node#63746: FLUSH_BOUND validation now applies to every codec
(zlib [0, Z_BLOCK], brotli [0, 3], zstd [0, 2]).
…bort

# Conflicts:
#	src/runtime/node/node_zlib_binding.rs
#	src/runtime/node/zlib/NativeBrotli.rs
#	src/runtime/node/zlib/NativeZlib.rs
#	src/runtime/node/zlib/NativeZstd.rs
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Merged main into this branch (980eaa0103). The conflicts were all against #36165, which landed a per-codec flush_value_is_valid(u32) -> bool next to the old set_flush(i32) (brotli <= 3, zstd <= 2, zlib <= 6, with the unreachable!() arm still in set_flush) plus tests in test/js/node/zlib/zlib-handle-bounds-check.test.ts that drive the native handle directly. How I resolved them:

Net diff against main is 7 files: zlib.ts, the four native files, the vendored Node test, and zlib.test.js. On the merged head (debug build): zlib.test.js 394 pass, all 61 test/parallel/test-zlib-*.js pass, and the #36165 handle tests pass unchanged. The new tests still fail on the unfixed build (.flush(Z_FINISH) after a write() returns normally and the rejection arrives later as an uncaught exception; the vendored test gets ERR_INVALID_ARG_TYPE instead of ERR_OUT_OF_RANGE).

One observation for the record: Node 26.3 (before its fix) does not accept these values silently; createBrotliCompress().flush(Z_FINISH) spins a threadpool thread at 100% and the flush callback never fires, so there is no working behaviour to preserve for the values this PR starts rejecting.

Comment thread src/runtime/node/node_zlib_binding.rs

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants