Conversation
…s OutOfMemory instead of aborting rust-argon2 allocates the block matrix infallibly, so a memoryCost the system cannot satisfy (hash options, or the m= field of a hash given to verify) aborted the process. Reserve the same amount fallibly first and report it as OutOfMemory, which Bun.password surfaces as the PASSWORD_OUT_OF_MEMORY error it produced before the Rust port.
|
Warning Review limit reached
Next review available in: 29 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Status: fixed, waiting on CI. Split out of #38941.
|
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. The probe-then-allocate approach is sound and strictly additive (can only turn aborts into errors, never the reverse), but since it's a deliberate interim design choice in Bun.password — chosen over patching rust-argon2 or switching crates — a maintainer should confirm they're happy landing it this way.
What was reviewed
check_memory_is_allocatable:u32 → usizewidening +checked_mul(1024)beforetry_reserve_exact;?onbun_alloc::AllocErrorreachescrate::Error::Allocvia#[from], andError::name()returns"OutOfMemory"→PASSWORD_OUT_OF_MEMORYinpassword_error_instance.- Verify path:
m=recorded inside the existing prescan; the ulimit test'sm=4194304is exactlyMAX_VERIFY_MEMORY_COST(1 << 22), so it passes theWeakParameterscap and reaches the probe as intended. - Probe uses the global allocator, same as rust-argon2's
vec[Block; n], so ASAN'smax_allocation_size_mband mimalloc underulimit -vsee the same failure the real allocation would.
Extended reasoning...
Overview
The PR adds a fallible allocation probe (check_memory_is_allocatable) in src/runtime/crypto/pwhash.rs that reserves memoryCost * 1024 bytes via Vec::try_reserve_exact before rust-argon2's infallible vec[Block::zero(); n] runs. It is called from str_hash (using config.mem_cost) and from str_verify (using the m= value already parsed by the existing cost-cap prescan). On failure it returns Error::Alloc(AllocError), which Error::name() reports as "OutOfMemory" and password_error_instance formats as PASSWORD_OUT_OF_MEMORY — the same error the pre-port Zig implementation produced. Two new subprocess tests cover all four entry points (hash/hashSync/verify/verifySync) under ASAN's max_allocation_size_mb cap and under a Linux ulimit -v address-space limit.
Security risks
None introduced. This does not touch the argon2/bcrypt primitives, salts, or comparison logic; it only front-runs an allocation that would otherwise abort the process. If anything it hardens verify slightly against a hostile PHC string whose m= is sized to abort the process — that now becomes a catchable error. The probe cannot cause a previously-successful hash to fail: if the probe reservation succeeds it is immediately dropped, and the subsequent real allocation sees the same allocator state modulo a TOCTOU window the PR description explicitly acknowledges.
Level of scrutiny
Moderate-to-high. The Rust change is ~20 lines and mechanically simple, but it lives in Bun.password and encodes a design choice: a best-effort probe rather than patching rust-argon2 (blocked on #33153) or switching to RustCrypto's argon2 (which would change PHC codec/verify semantics). The PR description makes the case for this tradeoff thoroughly, and it also flags interactions with three other open PRs (#33865 memoryCost ceiling, #37015 node:crypto argon2, #32314 same prescan loop). That's exactly the kind of context where a maintainer should sign off on the direction, even though the implementation itself is straightforward and correct.
Other factors
I verified the error-plumbing chain end to end: bun_alloc::AllocError → crate::Error::Alloc (via #[from] at src/runtime/error.rs:419) → name() == "OutOfMemory" (line 782) → password_error_instance builds PASSWORD_OUT_OF_MEMORY. The verify test's m=4194304 sits exactly at MAX_VERIFY_MEMORY_COST so it clears the existing > limit check and reaches the new probe. The checked_mul guards 32-bit overflow. The tests follow harness conventions (bunEnv spread, concurrent pipe drain, exit-code asserted last, skipIf with stated reasons) and each has a plausible CI lane where it runs (ASAN debug for the first, Linux release for the second). No bugs found by the multi-agent hunt or by this pass; deferring purely so a human confirms the probe approach is the interim fix they want.
…nk's output buffer cannot be allocated (#38941) ### Problem - A `TextEncoderStream` chunk whose encoded output cannot be allocated aborts the process: `memory allocation of N bytes failed`, then `panic(main thread): abort() called` and a "Bun has crashed" report. The output buffer is 1x to 3x the chunk, so for a large chunk this is where the API runs out of memory. - `encode_latin1_into` (`src/runtime/webcore/TextEncoderStreamEncoder.rs`) reserves infallibly at three places (the up-front reservation, the `reserve(2)` retry when a two-byte char does not fit, the grow-to-fit reservation) and returns nothing. `encode_utf16_into` returns a `Result`, but its main reservation is infallible too, and the replacement encoder it falls back to for invalid UTF-16, `to_utf8_list_with_type_bun` (`src/bun_core/string/immutable/unicode.rs`), returns `Result<_, AllocError>` while growing with `reserve_exact`, so its `Err` was unreachable. - The Zig version (`TextEncoderStreamEncoder.zig` at the parent of 23427db, lines 68 to 169) returned `throwOutOfMemoryValue()` at each of these sites; the port lost that. #33014 fixed the same regression in the decoding direction (`to_utf16_alloc*`, `TextDecoder`). ### Fix - Every reservation in `encode_latin1_into`, `encode_utf16_into` and `to_utf8_list_with_type_bun` is a `try_reserve` / `try_reserve_exact` mapped to `AllocError`. The amounts are unchanged: `ensure_total_capacity(len + remain + 1)` is `try_reserve(remain + 1)`, and the old `reserve_exact((i + count + len + extra) - len)` is `try_reserve_exact(i + count + extra)`. - `encode_latin1_into` returns `Result` like its UTF-16 sibling, and the two callers that did not handle an error yet (`encode_latin1`, and the Latin-1 arm of `TextEncoderStreamEncoder__encodeIntoSink`) throw out-of-memory the way `encode_utf16` and the UTF-16 arm already did. `to_utf8_list_with_type_bun` has no other caller. - Why this is the right behavior: the buffer's size is chosen by the caller's data, and a failure to get it was part of the JS contract before the port. The C++ transform step (`encodeAndEnqueue` in `JSTextEncoderStream.cpp`) already turns a pending exception into a rejected transform promise, so the `write()` rejects and the stream errors with `RangeError: Out of memory`; nothing else needed to change. This does not touch the abort-on-OOM policy for internal allocations; it is the same shape as #33014 and the `try_reserve` sites in `encoding.rs`, `PBKDF2.rs` and `TextDecoder`. - Verified with `test/js/web/encoding/text-encoder-stream.test.ts`, two ASAN-only tests (the gate and the ASAN lanes) that run a child under `allocator_may_return_null=1:max_allocation_size_mb=4`. JSC allocates the input strings outside that cap, so each of five inputs makes a different encoder reservation fail: the Latin-1 up-front reservation, the Latin-1 grow-to-fit reservation, the Latin-1 `reserve(2)` retry (an ASCII byte plus an odd number of two-byte chars leaves one spare byte), the UTF-16 simdutf-sized reservation, and the replacement encoder's first reservation (a lone surrogate ahead of N ASCII units: simdutf's N + 2 fits, 1.2N + 3 does not). One test feeds the chunks to a JS reader (write and read both reject with `RangeError: Out of memory`, and encoding still works afterwards); the other serves them through Bun.serve's native response sink, each behind a dangling lead surrogate so the output is assembled in the encoder's own buffer (the arm that stays the encoder's job if #36877 hands plain chunks to the sink directly), which also covers the prepend variant of every reservation; the errored transform cancels the source with the error and the server keeps serving. Without the fix both children abort on the first input (`memory allocation of 8388608 bytes failed`); the per-site sizes are in the details below. - Also run: `bun bd test test/js/web/encoding/` (577 pass), `test/js/web/streams/streams.test.js`, `test/regression/issue/29225.test.ts`, `test/js/node/test/parallel/test-whatwg-webstreams-encoding.js`, `cargo fmt --check`. - Scope: this PR originally also contained the `Bun.password` argon2 change; that is an unrelated allocation with its own design question and now lives in #39021. The same port regression exists in other user-sized buffers that are not touched here and have been handed off separately: the `Bun.gzipSync` / `gunzipSync` / `deflateSync` / `inflateSync` output buffers (`BunObject.rs`, including the one sized from the gzip trailer's ISIZE) and `Bun.zstdCompressSync` plus zstd decompression (`BunObject.rs`, `zstd/lib.rs`). The sinks and SQL buffers are covered by #38888's follow-up plan. ### Background - `Vec::try_reserve` returns `Err` when the allocator returns null, where `reserve` calls `handle_alloc_error` and aborts. Under ASAN the allocator is libc's, and `ASAN_OPTIONS=allocator_may_return_null=1:max_allocation_size_mb=N` makes it return null for any single allocation above N MiB, which is what makes these failures reproducible; JSC allocates JS strings through its own allocator, which the cap does not apply to. - `AllocError` is the unit error type the crates' `Error` enums wrap as `Alloc(..)`; `JSGlobalObject::throw_out_of_memory_value` sets JSC's out-of-memory `RangeError` as the pending exception. - A TextEncoderStream transform that throws rejects the transform promise, which errors both halves of the stream and, through `pipeThrough`, cancels the stream being piped in with the same error; that cancel reason is what the native-sink test observes. - Native-sink path: when the stream's readable is consumed by a native sink such as an HTTP response, `TextEncoderStreamEncoder__encodeIntoSink` encodes into a reusable buffer and hands it to the sink instead of enqueueing a `Uint8Array` per chunk. A lead surrogate left over from the previous chunk is encoded as a prefix of the next chunk's output, so that case always needs the encoder's buffer. <details> <summary>Per-site aborts without the fix (debug ASAN build), each size identifying the reservation that failed</summary> 48 MiB cap, JS path: ``` latin1, 64 MiB ASCII memory allocation of 67108864 bytes failed (up-front reservation) latin1, 40 MiB of 0xE9 memory allocation of 83886080 bytes failed (grow-to-fit, amortized from 40 MiB) latin1, "a" + odd run of 0xE9 memory allocation of 83886084 bytes failed (reserve(2) retry, amortized from 40 MiB + 2) utf16, 20M x U+65E5 memory allocation of 62914560 bytes failed (simdutf-sized reservation, 3 bytes/unit) utf16, U+D800 + 44 MiB of "a" memory allocation of 55364815 bytes failed (replacement encoder: 3 + 1.2 * 46137344) ``` 4 MiB cap (the sizes the tests use), with the fix, ASAN's refused allocation per case; JS path then native-sink path behind a carried lead surrogate (+3 bytes of replacement prefix in each reservation): ``` latin1 8388608 8388611 latin1Grow 6291456 6291462 latin1Stuck 6291460 6291466 utf16 6291456 6291459 utf16Invalid 4718595 4718598 ``` Every case reports `{"name":"RangeError","message":"Out of memory"}` and the child exits 0. </details> <!-- robobun:evidence:begin --> --- **[review]** gate passed · iteration 0 · 3 files touched <details><summary>fails on main (without fix)</summary> ```console ASAN without fix: BUILD FAILED (no junit output) $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/encoding/text-encoder-stream.test.ts ninja: Entering directory `/workspace/bun/build/debug' [1/85] gen BunProcess.lut.h Generating /workspace/bun/build/debug/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp [2/85] gen generated_host_exports.rs generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 240 extern-C blocks audited [3/85] gen cpp.rs (cppbind) [4/85] gen JS modules (bundle-modules) Preprocess modules (11911ms) Bundle modules (45ms) Postprocesss modules (181ms) Bundle Functions (696ms) Generate Code (13ms) [12.87s] Bundled "src/js" for development 2826 kb 197 internal modules 13 native modules 91 internal functions across 17 files [4/80] 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) [76/80] cxx obj/codegen/ZigGeneratedClasses.cpp.o FAILED: rust-target/x86_64-unknown-linux-gnu/debug/libbun_rust.a /workspace/bun/build/release/bun /work ... (truncated) release without fix: 2 skipped bun test v1.4.0-canary.1 (eabb96d) test/js/web/encoding/text-encoder-stream.test.ts: (pass) encoding one string of UTF-8 should give one complete chunk [0.61ms] (pass) a character split between chunks should be correctly encoded [0.06ms] (pass) a character following one split between chunks should be correctly encoded [0.04ms] (pass) two consecutive astral characters each split down the middle should be correctly reassembled [0.03ms] (pass) two consecutive astral characters each split down the middle with an invalid surrogate in the middle should be correctly encoded [0.06ms] (pass) a stream ending in a leading surrogate should emit a replacement character as a final chunk [0.02ms] (pass) an unmatched surrogate at the end of a chunk followed by an astral character in the next chunk should be replaced with the replacement character at the start of the next output chunk [0.03ms] (pass) an unmatched surrogate at the end of a chunk followed by an ascii character in the next chunk should be replaced with the replacement character at the start of the next output chunk [0.02ms] (pass) an unmatched surrogate at the end of a chunk followed by a plane 1 character split int ... (truncated) ``` </details> <details><summary>passes on PR (with fix)</summary> ```console ASAN with fix: all passed $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/encoding/text-encoder-stream.test.ts bun test v1.4.0 (3250eb4) test/js/web/encoding/text-encoder-stream.test.ts: (pass) encoding one string of UTF-8 should give one complete chunk [41.59ms] (pass) a character split between chunks should be correctly encoded [8.27ms] (pass) a character following one split between chunks should be correctly encoded [6.15ms] (pass) two consecutive astral characters each split down the middle should be correctly reassembled [6.95ms] (pass) two consecutive astral characters each split down the middle with an invalid surrogate in the middle should be correctly encoded [9.03ms] (pass) a stream ending in a leading surrogate should emit a replacement character as a final chunk [4.68ms] (pass) an unmatched surrogate at the end of a chunk followed by an astral character in the next chunk should be replaced with the replacement character at the start of the next output chunk [5.68ms] (pass) an unmatched surrogate at the end of a chunk followed by an ascii character in the next chunk should be replac ... (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 3250eb4 features baseline 22 deps, 123 codegen, 1176 objects in 1165ms ninja: Entering directory `/workspace/bun/build/release' [1/1238] gen ErrorCode+*.h [2/1238] gen bindgenv2 [3/1238] install /workspace/bun bun install v1.4.0-canary.1 (eabb96d) Checked 107 installs across 153 packages (no changes) [74.00ms] [4/1238] install /workspace/bun/packages/bun-error bun install v1.4.0-canary.1 (eabb96d) Checked 1 install across 2 packages (no changes) [4.00ms] [5/1238] fetch zlib [zlib] up to date [6/1238] gen .bind.ts → GeneratedBindings.cpp [7/1238] fetch tinycc [tinycc] up to date [8/1237] fetch libjpeg-turbo [libjpeg-turbo] up to date [9/1237] install /workspace/bun/src/node-fallbacks bun install v1.4.0-canary.1 (eabb96d) Checked 129 installs across 147 packages (no changes) [55.00ms] [10/1237] subst deps/zlib/zconf.h [11/1237] subst deps/zlib/zlib.h [12/1188] gen JSEvent.lut.h Generating /workspace/bun/build/release/codegen/JSEvent.lut.h from /workspace/bun/src/jsc/ ... (truncated) ``` </details> <details><summary>diff hotspot</summary> ``` src/bun_core/string/immutable/unicode.rs | 17 +-- src/runtime/webcore/TextEncoderStreamEncoder.rs | 49 ++++--- test/js/web/encoding/text-encoder-stream.test.ts | 163 ++++++++++++++++++++++- 3 files changed, 195 insertions(+), 34 deletions(-) ``` </details> **gate history** · 1 passed · 0 rejected · iteration 0 <details><summary>evidence per changed file</summary> ``` file reads edits tests src/bun_core/string/immutable/unicode.rs 5 5 0 src/runtime/webcore/TextEncoderStreamEncoder.rs 4 11 0 test/js/web/encoding/text-encoder-stream.test.ts 3 7 0 ``` </details> <!-- robobun:evidence:end -->
Problem
Bun.password.hash/hashSyncacceptmemoryCostup to 4294967295 KiB (4 TiB), andverify/verifySynctakem=from the encoded hash (the existing prescan caps it at 4 GiB).pwhash::argon2::str_hash/str_verify(src/runtime/crypto/pwhash.rs) hand the cost to rust-argon2, whoseMemory::newallocates the block matrix withvecfor both hashing and verifying). When that allocation fails the process aborts:memory allocation of N bytes failed,panic(main thread): abort() called, and a "Bun has crashed" report, for a value the caller passed in (or, for verify, for a hash string somebody else produced).sh -c 'ulimit -v 4194304; exec bun -e "Bun.password.hashSync(\"pw\", { algorithm: \"argon2id\", memoryCost: 4194304 })"'aborts withmemory allocation of 4294967296 bytes failed; a 512 MiB cost under a 1 GB limit aborts the same way, and so does the 4 TiB maximum on a machine with Linux's default overcommit setting (the request exceeds RAM plus swap, sommaprefuses it).std.crypto.pwhash.argon2took an allocator and returnederror.OutOfMemory, whichPasswordObject.zigreported like every other hash failure:Password hashing failed with error "OutOfMemory"withcode: "PASSWORD_OUT_OF_MEMORY".Fix
check_memory_is_allocatable(m)reservesm * 1024bytes withtry_reserve_exact, drops the probe, and returnsError::Allocon failure.str_hashcalls it right beforehash_encoded;str_verifyrecordsmin the prescan it already runs over the cost fields and calls it right beforeverify_encoded(the decoder only accepts strings with exactly onem=, so this is the cost it would allocate for; anything else fails to decode before allocating).Error::Allocnames itselfOutOfMemory, so the unchangedpassword_error_instanceinPasswordObject.rsproduces exactly the pre-port error, on both the sync path and the work-pool path:Password hashing failed with error "OutOfMemory"/Password verification failed with error "OutOfMemory",code: "PASSWORD_OUT_OF_MEMORY".m=of a hash string an attacker controls), and any address-space or commit limit. It deliberately changes nothing when the allocation succeeds and the machine later runs out of memory while argon2 touches the blocks (overcommit_memory=1, cgroup limits); that is the OOM killer's domain, as before.argon2, which changes the PHC codec and the verify semantics) are much larger changes than this needs. The probe is best effort by construction (another thread can take the memory between the probe and the crate's allocation, and the crate rounds the cost down to a multiple of 4 blocks), which is acceptable: it cannot make anything that works today fail, and it converts every deterministic failure into the error. It costs one untouched reservation and release per call, microseconds next to the hash itself. The function ispub(crate)so thenode:cryptoargon2 binding proposed in node:crypto: implement argon2 and argon2Sync #37015, which drives the same crate, can use the same check.memoryCostceiling forhash. Bun.password: reject argon2 cost options that verify would refuse #33865 proposes capping it at verify's 4 GiB; that is compatible with this and does not replace it, since the costs that fail to allocate in practice (64 MiB default, 4 GiB maximum) are well inside any plausible ceiling. crypto: accept argon2 PHC params in any order in Bun.password.verify #32314 edits the same prescan loop; whichever lands second has a three-line conflict.test/js/bun/util/password.test.ts: the same child script runshashSync,hash,verifySyncandverifyagainst an unallocatable cost and checks an allocatable one still works afterwards, in two variants. Under ASAN (test.skipIf(!isASAN)) withallocator_may_return_null=1:max_allocation_size_mb=32and the default 64 MiB cost: fails on main withmemory allocation of 67108864 bytes failed, passes here. On Linux release builds (test.skipIf(isASAN || !isLinux); ASAN builds cannot start under an address-space limit) withulimit -v 4194304and a 4 GiB cost, which can never fit while bun itself starts in about 0.35 GB: fails against the current release build withmemory allocation of 4294967296 bytes failed(output below); its passing side runs on CI's Linux release lanes, since the local debug build is ASAN. The rest of the file: 72 pass, 9 debug-only skips.TextEncoderStreamhalf of the same port regression), which shares no code with it.Background
mis in KiB: the algorithm fills a matrix ofmone-kibibyte blocks, allocated up front in one piece, and reads it back while hashing, so it is the dominant allocation of a hash or verify call.verifygets it from the PHC string it is given ($argon2id$v=19$m=65536,t=2,p=1$salt$hash), so it is input, not configuration.Vec::try_reserve_exactreturnsErrwhen the allocator returns null, where the crate'svec!callshandle_alloc_errorand aborts. Reserving without writing touches no pages, so the probe costs the allocator a reservation and a release and nothing else.Error::Alloc(AllocError)is the runtime crate's out-of-memory variant;Error::name()reports it asOutOfMemory, andBun.passwordbuilds both the message and thePASSWORD_*code from that name, which is also how the Zig implementation formattederror.OutOfMemory.ASAN_OPTIONS=allocator_may_return_null=1:max_allocation_size_mb=Nmakes any single allocation above N MiB return null;ulimit -v(RLIMIT_AS) makesmmapfail once the process's address space would exceed the limit, which is how mimalloc ends up returning null in the release variant.Release build, without the fix, address-space variant of the test
With the fix, all four entry points report
{"name":"Error","code":"PASSWORD_OUT_OF_MEMORY","message":"Password hashing failed with error \"OutOfMemory\""}(
verificationfor the verify pair) and a hash withmemoryCost: 8still round-trips afterwards.