Skip to content

valkey: accept a plus sign in big number replies and never throw on oversize ones - #39702

Open
robobun wants to merge 1 commit into
mainfrom
farm/dc2fcec8/valkey-big-number-plus-sign-oversize
Open

valkey: accept a plus sign in big number replies and never throw on oversize ones#39702
robobun wants to merge 1 commit into
mainfrom
farm/dc2fcec8/valkey-big-number-plus-sign-oversize

Conversation

@robobun

@robobun robobun commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A RESP3 big number reply with a plus sign, (+42, resolves as the string "+42". The spec shape is ([+|-]<digits>, and the : integer path already accepts +. big_int_from_decimal (src/jsc/JSValue.rs:676) stripped only -.
  • A ( reply with more digits than a BigInt can hold (about 313,600) rejects the command with an uncoded RangeError: Out of memory: BigInt generated from this operation is too big. The binding (src/jsc/bindings/bindings.cpp:4492) called JSBigInt::stringToBigInt with a global object, so the size check threw. valkey: fix null array, null CRLF, big number and blob error replies #39544 documents the ( arm as "integer literal becomes a BigInt, anything else stays a string, the decoder never throws". Both replies fall outside that.
  • Only a module, a Lua script or a non-Redis peer can send these. The connection stays usable either way.

Fix

  • big_int_from_decimal strips + or -, checks the digits, and passes the digits and a sign flag to the binding. It returns Option<JSValue> and cannot throw.
  • The binding, now JSC__JSValue__bigIntFromDecimalDigits, calls JSBigInt::parseInt with a null global object. In that mode JSC returns the empty value for a value that does not fit instead of an exception. This is the same call JSC's own bytecode generator uses for BigInt literals. The ( arm in protocol_jsc.rs then takes the existing string fallback.
  • The docs and the send() JSDoc name the oversize case.
  • Verified: test/js/valkey/valkey-incremental-scan.test.ts. On main, (+42 (both send modes) and the 400,000 digit reply fail, 43 other cases pass. With the fix all 46 pass. Also test/integration/bun-types/bun-types.test.ts.

Background

  • RESP3 is the Redis wire protocol. A reply line that starts with ( is a big number: an integer too large for the : 64-bit integer type. Redis core emits it only for values above i64::MAX. Modules and Lua can put any text after (.
  • JSBigInt::parseInt takes a "global object for OOM" argument. With a global object it throws a RangeError when the result would exceed JSC's BigInt limit of 2^20 bits. With nullptr it returns the empty JSValue instead. stringToBigInt always passes the global object.
  • The test file drives the client against an in-process mock server. Every entry in FRAMES is sent whole and one byte per read. The mock now finishes a reply on drain, because a 400 KB reply does not fit in one socket write on every platform.
Notes
  • The limit: JSBigInt::maxLengthBits is 1024 * 1024. computeLength budgets 107/32 bits per decimal digit, so a literal with more than about 313,593 significant digits fails. The RESP line cap is 512 MB, so the gap is reachable. A 300,000 digit reply still becomes a BigInt.
  • parseInt with a null global asserts on an empty digit span in debug builds. The Rust side returns None for an empty span before the call ((, (+ and (- stay strings), and the binding asserts len > 0.
  • Extra cases probed against the debug build with BUN_JSC_validateExceptionChecks=1, no reports: (+0 and (-0 give 0n, (007 gives 7n, (+-5, ( 5 and (0x10 stay strings, a negative oversize literal stays a string, an oversize literal nested in an array stays a string next to a BigInt sibling, and PING works after each.
  • The rest of the valkey suite needs the docker fixture and did not run locally.

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

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/valkey/valkey-incremental-scan.test.ts
bun test v1.4.0 (4c689909e)

test/js/valkey/valkey-incremental-scan.test.ts:
(pass) Valkey reply decoding, frame sent whole > RESP3 null with trailing bytes (_junk) [89.81ms]
(pass) Valkey reply decoding, frame sent whole > RESP2 null array (*-1) [141.53ms]
(pass) Valkey reply decoding, frame sent whole > RESP2 null array nested in an array [116.55ms]
(pass) Valkey reply decoding, frame sent whole > RESP2 null bulk string ($-1) [116.05ms]
(pass) Valkey reply decoding, frame sent whole > RESP3 null (_) [115.77ms]
173 |       const outcome = await client.get("k").then(
174 |         value => ({ value }),
175 |         error => ({ error }),
176 |       );
177 |       if ("value" in expected) {
178 |         expect(outcome).toEqual({ value: expected.value });
                              ^
error: expect(received).toEqual(expected)

  {
-   "value": 42n,
+   "value": "+42",
  }

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/valkey/valkey-incremental-scan.test.ts
... (truncated)

release without fix: 27 FAILED
bun test v1.4.0-canary.1 (4c689909e)

test/js/valkey/valkey-incremental-scan.test.ts:
173 |       const outcome = await client.get("k").then(
174 |         value => ({ value }),
175 |         error => ({ error }),
176 |       );
177 |       if ("value" in expected) {
178 |         expect(outcome).toEqual({ value: expected.value });
                              ^
error: expect(received).toEqual(expected)

  {
-   "value": null,
+   "value": [],
  }

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/valkey/valkey-incremental-scan.test.ts:178:25)
      at async withClient (/workspace/bun/test/js/valkey/valkey-incremental-scan.test.ts:127:18)
      at async <anonymous> (/workspace/bun/test/js/valkey/valkey-incremental-scan.test.ts:172:11)
173 |       const outcome = await client.get("k").then(
174 |         value => ({ value }),
175 |         error => ({ error }),
176 |       );
177 |       if ("value" in expected) {
178 |         expect(outcome).toEqual({ value: expected.value });
                              ^
error: expect(received).toEqual(expected)

  {
    "value": [
-     null,
+     [],
      "abc",
    ],
  }

- Expected  - 1
+ Rec
... (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/mechgate.xml" test/js/valkey/valkey-incremental-scan.test.ts
bun test v1.4.0 (4c689909e)

test/js/valkey/valkey-incremental-scan.test.ts:
(pass) Valkey reply decoding, frame sent whole > RESP3 null with trailing bytes (_junk) [47.71ms]
(pass) Valkey reply decoding, frame sent whole > RESP2 null array (*-1) [81.82ms]
(pass) Valkey reply decoding, frame sent whole > RESP2 null array nested in an array [66.62ms]
(pass) Valkey reply decoding, frame sent whole > RESP2 null bulk string ($-1) [66.41ms]
(pass) Valkey reply decoding, frame sent whole > RESP3 null (_) [66.41ms]
(pass) Valkey reply decoding, frame sent whole > big number above 2^53 [41.26ms]
(pass) Valkey reply decoding, frame sent whole > negative big number [40.84ms]
(pass) Valkey reply decoding, frame sent whole > big number with an explicit plus sign [40.20ms]
(pass) Valkey reply decoding, frame sent whole > big number above 2^64 [40.04ms]
(pass) Valkey reply decoding, frame sent whole > big number with a non-integer payload [40.89ms]
(pass) Valkey reply decoding, frame sent whole > big nu
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 717ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/22] cxx obj/unified/UnifiedSource-src_jsc_bindings_node-0.cpp.o
[2/22] cxx obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o
[3/22] cxx obj/unified/UnifiedSource-src_jsc_bindings-3.cpp.o
[4/22] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-2.cpp.o
[5/22] cxx obj/unified/UnifiedSource-src_jsc_modules-0.cpp.o
[6/22] cxx obj/unified/UnifiedSource-src_jsc_bindings-0.cpp.o
[7/22] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[8/22] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-3.cpp.o
[9/22] cxx obj/src/jsc/bindings/BunProcess.cpp.o
[10/22] cxx obj/unified/UnifiedSource-src_jsc_bindings-2.cpp.o
[11/22] cxx obj/src/jsc/bindings/BunObject.cpp.o
[12/22] cxx obj/src/jsc/bindings/bindings.cpp.o
[13/22] cxx obj/codegen/ZigGeneratedClasses.cpp.o
[14/22] cxx obj/src/jsc/bindings/ZigGlobalObject.cpp.o
[15/22] cxx obj/src/jsc/bindings/napi.cpp.o
[16/22] cxx obj/codegen/JSSink.cpp.o
[17/22] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust
... (truncated)
diff hotspot
docs/runtime/redis.mdx                         |  2 +-
 packages/bun-types/redis.d.ts                  |  2 +-
 src/jsc/JSValue.rs                             | 37 ++++++++++++------------
 src/jsc/bindings/bindings.cpp                  | 14 +++++----
 src/jsc/bindings/headers.h                     |  2 +-
 src/runtime/valkey_jsc/protocol_jsc.rs         |  7 +++--
 test/js/valkey/valkey-incremental-scan.test.ts | 40 ++++++++++++++++++++++----
 7 files changed, 70 insertions(+), 34 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                            reads  edits  tests
docs/runtime/redis.mdx                              1      1      0
packages/bun-types/redis.d.ts                       1      1      0
src/jsc/JSValue.rs                                  2      2      0
src/jsc/bindings/bindings.cpp                       1      1      0
src/jsc/bindings/headers.h                          1      2      0
src/runtime/valkey_jsc/protocol_jsc.rs              1      1      0
test/js/valkey/valkey-incremental-scan.test.ts      2      5      0

…versize ones

A RESP3 big number reply is `([+|-]<digits>`. The decoder only stripped
`-`, so `(+42` resolved as the string "+42". It also built the BigInt
with JSBigInt::stringToBigInt, which throws a RangeError when the
literal has more digits than a BigInt can hold (about 313,600). That
exception rejected the command.

The binding now takes the digits and a sign flag and calls
JSBigInt::parseInt with a null global object, so a value that does not
fit comes back as the empty value instead of an exception. The decoder
then falls back to the string, as it does for any other payload that is
not an integer literal.

The mock server in the test file now finishes a reply on drain, so a
reply larger than one socket write reaches the client.
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Your included review limit has been reached.

You’re in a promotional period — use the checkbox below to run this review for free:

  • Run review for free

On-demand reviews are free for the next 30 days. After that, they cost $0.25 per reviewed file.

How can I continue?

Run this review now using the option above, or comment @coderabbitai review --use-credits.

You can also wait for the limit to reset (next review available in 2 minutes), then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c737cdf4-887c-424e-8d03-57840cd655f6

📥 Commits

Reviewing files that changed from the base of the PR and between cfa9f8e and 5a93099.

📒 Files selected for processing (7)
  • docs/runtime/redis.mdx
  • packages/bun-types/redis.d.ts
  • src/jsc/JSValue.rs
  • src/jsc/bindings/bindings.cpp
  • src/jsc/bindings/headers.h
  • src/runtime/valkey_jsc/protocol_jsc.rs
  • test/js/valkey/valkey-incremental-scan.test.ts

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

@robobun

robobun commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:29 PM PT - Aug 19th, 2026

@robobun, your commit 5a930991fd709d4d723b5e0226b13c6e7465a4a8 passed in Build #101452! 🎉


🧪   To try this PR locally:

bunx bun-pr 39702

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

bun-39702 --bun

@robobun

robobun commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

Status: the diff is complete and CI is running.

Reproduced on a debug build of main (cfa9f8e) with the mock server in test/js/valkey/valkey-incremental-scan.test.ts:

  • (+42\r\n resolves the string "+42".
  • ( followed by 400,000 digits rejects the command with RangeError: Out of memory: BigInt generated from this operation is too big.

With this branch both cases follow the documented rule: 42n for the first, the digits as a string for the second. The connection answers PING after each. The file runs 46 cases. On main the 3 new ones fail and the other 43 pass. With the fix all 46 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.

I reviewed this PR and found no bugs. Because it changes a C++/Rust FFI binding signature and leans on JSBigInt::parseInt's null-globalObject behavior, a human look would still be worthwhile.

What was reviewed:

  • Verified against JSC's JSBigInt::parseInt: ParseIntSign::Signed negates the result (so passing sign-stripped digits + flag is correct), and the null-global path returns JSValue() for oversize instead of throwing.
  • Confirmed the empty-digits debug ASSERT(nullOrGlobalObjectForOOM) in JSC is guarded by the Rust-side digits.is_empty() check before the FFI call.
  • Confirmed big_int_from_decimal's only caller is the valkey ( arm, so the JsResult<Option<_>>Option<_> signature change is contained.
Extended reasoning...

Overview

This PR fixes two edge cases in the Valkey client's RESP3 big-number (() reply decoding: a leading + sign now parses to a BigInt (matching the RESP3 spec and the : integer path), and a literal with more digits than JSC's BigInt limit (~313,600) now falls back to a string instead of rejecting the command with an uncoded RangeError. It touches src/jsc/JSValue.rs (big_int_from_decimal), the C++ binding in src/jsc/bindings/bindings.cpp + headers.h, the single caller in src/runtime/valkey_jsc/protocol_jsc.rs, docs/JSDoc, and adds three test cases plus a mock-server drain handler in test/js/valkey/valkey-incremental-scan.test.ts.

Security risks

None identified. The input is server-side wire data that was already being length-validated by the RESP parser; this change only affects how a validated line is converted to a JS value. The oversize path now degrades to a string instead of throwing, which is strictly less disruptive.

Level of scrutiny

Medium. The change is small and self-contained, but it renames and re-signatures an extern "C" binding and relies on a specific JSC internal contract (parseInt with a null globalObjectForOOM returns empty instead of throwing). I verified that contract against the vendored JSBigInt.cpp: the too-large branch checks if (nullOrGlobalObjectForOOM) before throwing and otherwise returns JSValue(), and ParseIntSign::Signed is applied as maybeResult *= -1 / tryCreateFromImpl(..., sign == Signed, ...), so stripping the sign in Rust and passing a flag is correct. I also confirmed the empty-string ASSERT at the top of parseInt cannot fire because the Rust side returns None for empty digits (and the binding ASSERT(len > 0)).

Other factors

  • big_int_from_decimal has exactly one caller (grep-verified), so the JsResult<Option<JSValue>>Option<JSValue> change and dropping from_js_host_call is contained. The [[ZIG_EXPORT(nothrow)]] marker matches the pattern used elsewhere (e.g. BunString.cpp) and is consistent with the Rust side no longer wrapping the call for exception propagation.
  • Test coverage is thorough: (+42 in both whole and byte-per-read modes, (+ (sign with no digits → string), and a 400,000-digit reply → string with a follow-up PING to prove the connection survives. The mock-server drain handler is a necessary addition since a 400 KB write can hit socket backpressure.
  • The PR description documents empirical verification with BUN_JSC_validateExceptionChecks=1 across additional edge cases ((+0, (-0, (007, (+-5, nested arrays).
  • Deferring rather than approving because the FFI signature/semantics change, while correct as far as I can verify, is the kind of JSC-boundary work a maintainer should sign off on.

@robobun

robobun commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

The review above asks for a human look at the null global object contract. The relevant lines in the vendored JavaScriptCore, for a quick check:

  • JSBigInt::parseInt (runtime/JSBigInt.cpp, the computeLength branch): when the digit count exceeds the limit it throws only if (nullOrGlobalObjectForOOM), otherwise it returns JSValue().
  • JSBigInt::createWithLength (same file) has the same guard for the final allocation, and tryCreateFromImpl returns null through it, which parseInt returns as the empty value.
  • BytecodeGenerator::addBigIntConstant (bytecompiler/BytecodeGenerator.cpp) calls parseInt(nullptr, vm, ...) the same way for BigInt literals in source code.

The two SyntaxError paths in parseInt are not reachable from this binding: the Rust side passes only a non-empty run of ASCII digits, and the binding uses IgnoreExceptions.

CI build 101452 is still running, no failures so far.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants