From 226cd8cdbea9005b2042c0748d450506e9eb01c7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:17:16 +0000 Subject: [PATCH 1/2] Read Bun.serve integer options, socket.write offsets and matcher counts by value, so -0 is 0 These readers tested the argument with JSC's Int52 predicate (`is_any_int()` / `is_uint32_as_any_int()`). It is false for -0 and for whole numbers from 2^51, so a computed -0 was refused where 0 is accepted. - Bun.serve `idleTimeout` and the three `websocket` limits (`idleTimeout`, `maxPayloadLength`, `backpressureLimit`): test with `is_integer()`. Messages and limits are unchanged. - `socket.write()` / `socket.end()` `byteOffset` and `byteLength`: one int32-first reader. A whole double is decoded inline. Only the throw is out of line, and the range error reports the received value. - `toBeArrayOfSize`: test with `is_integer()`. - `toHaveBeenCalledTimes` / `toHaveReturnedTimes`: one reader with Jest's rule (a safe integer that is not below 0). `toHaveReturnedTimes` now compares as i64, so the message shows the count that was passed. --- src/runtime/server/ServerConfig.rs | 2 +- src/runtime/server/WebSocketServerContext.rs | 6 +- src/runtime/socket/socket_body.rs | 88 +++++++++++-------- src/runtime/test_runner/expect.rs | 28 ++++++ .../test_runner/expect/toBeArrayOfSize.rs | 2 +- .../expect/toHaveBeenCalledTimes.rs | 8 +- .../test_runner/expect/toHaveReturned.rs | 13 +-- test/js/bun/http/bun-serve-args.test.ts | 70 +++++++++++++++ test/js/bun/net/tcp-server.test.ts | 82 +++++++++++++++++ test/js/bun/test/jest-extended.test.js | 14 +++ test/js/bun/test/mock-fn.test.js | 39 ++++++++ 11 files changed, 294 insertions(+), 58 deletions(-) diff --git a/src/runtime/server/ServerConfig.rs b/src/runtime/server/ServerConfig.rs index 27575cf6cd4e..b17bcbc34759 100644 --- a/src/runtime/server/ServerConfig.rs +++ b/src/runtime/server/ServerConfig.rs @@ -972,7 +972,7 @@ impl ServerConfig { if let Some(value) = arg.get(global, "idleTimeout")? { if !value.is_undefined_or_null() { - if !value.is_any_int() { + if !value.is_integer() { return Err(global.throw_invalid_arguments(format_args!( "Bun.serve expects idleTimeout to be an integer", ))); diff --git a/src/runtime/server/WebSocketServerContext.rs b/src/runtime/server/WebSocketServerContext.rs index 90e432d9cc66..28c53901e3d9 100644 --- a/src/runtime/server/WebSocketServerContext.rs +++ b/src/runtime/server/WebSocketServerContext.rs @@ -301,7 +301,7 @@ pub(crate) fn on_create( if let Some(value) = object.get(global_object, "maxPayloadLength")? { if !value.is_undefined_or_null() { - if !value.is_any_int() { + if !value.is_integer() { return Err(global_object.throw_invalid_arguments(format_args!( "websocket expects maxPayloadLength to be an integer" ))); @@ -312,7 +312,7 @@ pub(crate) fn on_create( if let Some(value) = object.get(global_object, "idleTimeout")? { if !value.is_undefined_or_null() { - if !value.is_any_int() { + if !value.is_integer() { return Err(global_object.throw_invalid_arguments(format_args!( "websocket expects idleTimeout to be an integer" ))); @@ -336,7 +336,7 @@ pub(crate) fn on_create( } if let Some(value) = object.get(global_object, "backpressureLimit")? { if !value.is_undefined_or_null() { - if !value.is_any_int() { + if !value.is_integer() { return Err(global_object.throw_invalid_arguments(format_args!( "websocket expects backpressureLimit to be an integer" ))); diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 878351da0ca0..5855b26c48b1 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -3005,50 +3005,20 @@ impl NewSocket { if offset_value.is_undefined() { break 'brk 0; } - if !offset_value.is_any_int() { - let _ = global.throw_invalid_argument_type(label, "byteOffset", "integer"); + let Some(offset) = byte_count_arg(global, offset_value, label, "byteOffset") else { return WriteResult::Fail; - } - let i = offset_value.to_int64(); - if i < 0 { - let _ = global.throw_range_error( - i, - jsc::RangeErrorOptions { - field_name: b"byteOffset", - min: 0, - max: jsc::MAX_SAFE_INTEGER, - msg: b"", - }, - ); - return WriteResult::Fail; - } - usize::try_from(i).expect("int cast") + }; + offset }; let byte_length: usize = 'brk: { if length_value.is_undefined() { break 'brk buffer.slice().len(); } - if !length_value.is_any_int() { - let _ = global.throw_invalid_argument_type(label, "byteLength", "integer"); - return WriteResult::Fail; - } - - let l = length_value.to_int64(); - - if l < 0 { - let _ = global.throw_range_error( - l, - jsc::RangeErrorOptions { - field_name: b"byteLength", - min: 0, - max: jsc::MAX_SAFE_INTEGER, - msg: b"", - }, - ); + let Some(length) = byte_count_arg(global, length_value, label, "byteLength") else { return WriteResult::Fail; - } - usize::try_from(l).expect("int cast") + }; + length }; let mut bytes = buffer.slice(); @@ -4180,6 +4150,52 @@ enum WriteResult { Success { wrote: i32, total: usize }, } +/// `byteOffset` / `byteLength` of `write()` and `end()`, read by value; `None` means it threw. +#[inline] +fn byte_count_arg( + global: &JSGlobalObject, + value: JSValue, + label: &'static str, + field: &'static str, +) -> Option { + if value.is_int32() { + if let Ok(count) = usize::try_from(value.as_int32()) { + return Some(count); + } + } else if let Some(number) = value.get_number() { + if number >= 0.0 && number <= jsc::MAX_SAFE_INTEGER as f64 && number.trunc() == number { + return Some(number as usize); + } + } + throw_byte_count_arg(global, value, label, field); + None +} + +#[cold] +#[inline(never)] +fn throw_byte_count_arg( + global: &JSGlobalObject, + value: JSValue, + label: &'static str, + field: &'static str, +) { + let _ = match value + .get_number() + .filter(|n| n.is_finite() && n.trunc() == *n) + { + Some(number) => global.throw_range_error( + number, + jsc::RangeErrorOptions { + field_name: field.as_bytes(), + min: 0, + max: jsc::MAX_SAFE_INTEGER, + msg: b"", + }, + ), + None => global.throw_invalid_argument_type(label, field, "integer"), + }; +} + pub(crate) struct StoredVerifyError { pub code: Box<[u8]>, pub reason: Box<[u8]>, diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index e4241ea6de79..b3e6a6f56c45 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -3037,6 +3037,34 @@ pub(crate) mod mock { Ok(value.get(global_this, "value")?.unwrap_or(JSValue::UNDEFINED)) } + /// Jest's `ensureExpectedIsNonNegativeInteger` for `toHaveBeenCalledTimes` / `toHaveReturnedTimes`. + pub(crate) fn expected_count( + global: &JSGlobalObject, + arguments: &[JSValue], + matcher_name: &'static str, + ) -> JsResult { + if let Some(&expected) = arguments.first() { + if expected.is_int32() { + if expected.as_int32() >= 0 { + return Ok(i64::from(expected.as_int32())); + } + } else if let Some(count) = expected.get_number() { + if count >= 0.0 && count <= bun_jsc::MAX_SAFE_INTEGER as f64 && count.trunc() == count { + return Ok(count as i64); + } + } + } + Err(expected_count_error(global, matcher_name)) + } + + #[cold] + #[inline(never)] + fn expected_count_error(global: &JSGlobalObject, matcher_name: &'static str) -> JsError { + global.throw_invalid_arguments(format_args!( + "{matcher_name}() requires 1 non-negative integer argument" + )) + } + // split lifetimes — `&'a mut Formatter<'a>` is the invariant-borrow trap // (forces the &mut to live as long as the Formatter's own param, which outlives the // local). `'g` tracks the JSGlobalObject borrow inside Formatter; `'a` is the short diff --git a/src/runtime/test_runner/expect/toBeArrayOfSize.rs b/src/runtime/test_runner/expect/toBeArrayOfSize.rs index 56f3ddb9ae2d..f8a68a4e23e6 100644 --- a/src/runtime/test_runner/expect/toBeArrayOfSize.rs +++ b/src/runtime/test_runner/expect/toBeArrayOfSize.rs @@ -26,7 +26,7 @@ pub(crate) fn to_be_array_of_size( let size = arguments[0]; size.ensure_still_alive(); - if !size.is_any_int() { + if !size.is_integer() { return Err(global.throw(format_args!("toBeArrayOfSize() requires the first argument to be a number"))); } diff --git a/src/runtime/test_runner/expect/toHaveBeenCalledTimes.rs b/src/runtime/test_runner/expect/toHaveBeenCalledTimes.rs index aaf1fc509dbd..0254febd3e08 100644 --- a/src/runtime/test_runner/expect/toHaveBeenCalledTimes.rs +++ b/src/runtime/test_runner/expect/toHaveBeenCalledTimes.rs @@ -17,13 +17,7 @@ pub(crate) fn to_have_been_called_times( super::mock::MockKind::Calls, )?; - if arguments.len() < 1 || !arguments[0].is_uint32_as_any_int() { - return Err(global.throw_invalid_arguments(format_args!( - "toHaveBeenCalledTimes() requires 1 non-negative integer argument" - ))); - } - - let times = arguments[0].to_int64(); + let times = super::mock::expected_count(global, arguments, "toHaveBeenCalledTimes")?; let mut pass = calls.get_length(global)? as i64 == times; diff --git a/src/runtime/test_runner/expect/toHaveReturned.rs b/src/runtime/test_runner/expect/toHaveReturned.rs index c47c6d609789..a0ded299c386 100644 --- a/src/runtime/test_runner/expect/toHaveReturned.rs +++ b/src/runtime/test_runner/expect/toHaveReturned.rs @@ -39,7 +39,7 @@ fn to_have_returned_times_fn( )?; let mut returns = returns_arr.array_iterator(global)?; - let expected_success_count: i32 = if mode == Mode::ToHaveReturned { + let expected_success_count: i64 = if mode == Mode::ToHaveReturned { if arguments.len() > 0 && !arguments[0].is_undefined() { return Err(global.throw_invalid_arguments(format_args!( "{}() must not have an argument", @@ -48,19 +48,12 @@ fn to_have_returned_times_fn( } 1 } else { - if arguments.len() < 1 || !arguments[0].is_uint32_as_any_int() { - return Err(global.throw_invalid_arguments(format_args!( - "{}() requires 1 non-negative integer argument", - mode.tag_name() - ))); - } - - arguments[0].coerce::(global)? + mock::expected_count(global, arguments, mode.tag_name())? }; let mut pass; - let mut actual_success_count: i32 = 0; + let mut actual_success_count: i64 = 0; let mut total_call_count: i32 = 0; while let Some(item) = returns.next()? { match mock::jest_mock_return_object_type(global, item)? { diff --git a/test/js/bun/http/bun-serve-args.test.ts b/test/js/bun/http/bun-serve-args.test.ts index 22a1ccc01291..53b286e9c1ca 100644 --- a/test/js/bun/http/bun-serve-args.test.ts +++ b/test/js/bun/http/bun-serve-args.test.ts @@ -404,6 +404,76 @@ describe("Bun.serve websocket options", () => { ); }); +describe("Bun.serve integer options are read by value", () => { + // -0 is an integer that ordinary arithmetic produces, and it equals 0. + const negativeZero = Math.round(-0.2); + const fetch = () => new Response("ok"); + const websocket = (option: string) => (value: unknown) => ({ websocket: { message() {}, [option]: value } }); + + // `max` is the largest value the option takes. The two options without one clamp at 2^32 - 1. + const integerOptions: [name: string, expects: string, max: number | null, withValue: (value: unknown) => object][] = [ + ["idleTimeout", "Bun.serve expects idleTimeout", 255, idleTimeout => ({ idleTimeout })], + ["websocket.idleTimeout", "websocket expects idleTimeout", 960, websocket("idleTimeout")], + ["websocket.maxPayloadLength", "websocket expects maxPayloadLength", null, websocket("maxPayloadLength")], + ["websocket.backpressureLimit", "websocket expects backpressureLimit", null, websocket("backpressureLimit")], + ]; + + describe.each(integerOptions)("%s", (_name, expects, max, withValue) => { + const notAnInteger = { name: "TypeError", code: "ERR_INVALID_ARG_TYPE", message: `${expects} to be an integer` }; + const aboveMax = max === null ? "accepted" : { ...notAnInteger, message: `${expects} to be ${max} or less` }; + const limit = max ?? 2 ** 32 - 1; + + function outcome(value: unknown) { + try { + serve({ port: 0, fetch, ...withValue(value) }).stop(true); + return "accepted"; + } catch (e: any) { + return { name: e.name, code: e.code, message: e.message }; + } + } + + test("takes -0 and every other whole number, and refuses what is not one", () => { + const cases: [value: unknown, expected: unknown][] = [ + [0, "accepted"], + [negativeZero, "accepted"], + [limit, "accepted"], + // A negative value means 0, whatever its size. + [-1, "accepted"], + [-(2 ** 53), "accepted"], + [limit + 1, aboveMax], + [2 ** 51, aboveMax], + [2 ** 53, aboveMax], + [1e300, aboveMax], + [1.5, notAnInteger], + [NaN, notAnInteger], + [Infinity, notAnInteger], + [-Infinity, notAnInteger], + ["5", notAnInteger], + [true, notAnInteger], + [5n, notAnInteger], + [{}, notAnInteger], + ]; + expect(cases.map(([value]) => [value, outcome(value)])).toEqual(cases); + }); + }); + + test("-0 for every option in one call, and again in reload()", () => { + const all = () => ({ + idleTimeout: negativeZero, + websocket: { + message() {}, + idleTimeout: negativeZero, + maxPayloadLength: negativeZero, + backpressureLimit: negativeZero, + }, + fetch, + }); + using server = serve({ port: 0, ...all() }); + server.reload(all()); + expect(server.port).toBeGreaterThan(0); + }); +}); + describe("Bun.serve development options", () => { test("development mode", () => { using server = serve({ diff --git a/test/js/bun/net/tcp-server.test.ts b/test/js/bun/net/tcp-server.test.ts index 2202c2e742dd..413eb619d95c 100644 --- a/test/js/bun/net/tcp-server.test.ts +++ b/test/js/bun/net/tcp-server.test.ts @@ -186,6 +186,88 @@ it("echo server 1 on 1", async () => { })(); }); +it("write() and end() read byteOffset and byteLength by value", async () => { + const received: Buffer[] = []; + const { promise: closed, resolve, reject } = Promise.withResolvers(); + using listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data(_socket, chunk) { + received.push(chunk); + }, + close() { + resolve(); + }, + error(_socket, err) { + reject(err); + }, + }, + }); + const client = await Bun.connect({ + hostname: "127.0.0.1", + port: listener.port, + socket: { data() {} }, + }); + + const buf = Buffer.from("0123456789"); + // -0 is an integer that ordinary arithmetic produces, and it equals 0. + const negativeZero = Math.round(-0.2); + // JSC stores this whole number as a double, not as an int32. + const double = (n: number) => new Float64Array([n])[0]; + const notAnInteger = "TypeError [ERR_INVALID_ARG_TYPE]"; + const outOfRange = (name: string, max: number, received: string) => + `RangeError [ERR_OUT_OF_RANGE]: The value of "${name}" is out of range. It must be >= 0 and <= ${max}. Received ${received}`; + const outcome = (offset: unknown, length: unknown) => { + try { + return client.write(buf, offset as number, length as number); + } catch (e: any) { + return e.code === "ERR_OUT_OF_RANGE" ? `${e.name} [${e.code}]: ${e.message}` : `${e.name} [${e.code}]`; + } + }; + + // Each refused value for one argument, with the other argument valid. `end` is the + // largest value the buffer allows: 10 for byteOffset, and 6 for byteLength after offset 4. + const refused = (name: string, end: number): [value: unknown, expected: string][] => [ + [1.5, notAnInteger], + [NaN, notAnInteger], + [Infinity, notAnInteger], + [-Infinity, notAnInteger], + [true, notAnInteger], + [5n, notAnInteger], + [null, notAnInteger], + [{}, notAnInteger], + [-1, outOfRange(name, Number.MAX_SAFE_INTEGER, "-1")], + [-(2 ** 53), outOfRange(name, Number.MAX_SAFE_INTEGER, "-9007199254740992")], + [-1e300, outOfRange(name, Number.MAX_SAFE_INTEGER, "-1e+300")], + [end + 1, outOfRange(name, end, String(end + 1))], + [2 ** 32, outOfRange(name, end, "4294967296")], + [2 ** 51, outOfRange(name, end, "2251799813685248")], + [2 ** 53 - 1, outOfRange(name, end, "9007199254740991")], + [2 ** 53, outOfRange(name, Number.MAX_SAFE_INTEGER, "9007199254740992")], + [1e300, outOfRange(name, Number.MAX_SAFE_INTEGER, "1e+300")], + ]; + const offsets = refused("byteOffset", 10); + expect(offsets.map(([offset]) => [offset, outcome(offset, 0)])).toEqual(offsets); + const lengths = refused("byteLength", 6); + expect(lengths.map(([length]) => [length, outcome(4, length)])).toEqual(lengths); + + const wrote = { + offset: client.write(buf, negativeZero, 3), + length: client.write(buf, 3, negativeZero), + both: client.write(buf, negativeZero, negativeZero), + offsetAlone: client.write(buf, negativeZero), + doubles: client.write(buf, double(1), double(2)), + end: client.end(buf, negativeZero, 2), + }; + await closed; + + expect({ wrote, received: Buffer.concat(received).toString() }).toEqual({ + wrote: { offset: 3, length: 0, both: 0, offsetAlone: 10, doubles: 2, end: 2 }, + received: "012" + "0123456789" + "12" + "01", + }); +}); + describe("tcp socket binaryType", () => { const binaryType = ["arraybuffer", "uint8array", "buffer"] as const; for (const type of binaryType) { diff --git a/test/js/bun/test/jest-extended.test.js b/test/js/bun/test/jest-extended.test.js index 2bf5f791c03d..2b1e4662c024 100644 --- a/test/js/bun/test/jest-extended.test.js +++ b/test/js/bun/test/jest-extended.test.js @@ -204,6 +204,20 @@ describe("jest-extended", () => { expect(null).not.toBeArray(); }); + test("toBeArrayOfSize() compares the size by value", async () => { + // -0 is an integer that ordinary arithmetic produces, and `[].length === -0`. + const negativeZero = Math.round(-0.2); + expect([]).toBeArrayOfSize(negativeZero); + expect([1]).not.toBeArrayOfSize(negativeZero); + expect(() => expect([]).not.toBeArrayOfSize(negativeZero)).toThrow(); + await expect(Promise.resolve([])).resolves.toBeArrayOfSize(negativeZero); + // A whole number that no array length equals is a mismatch, not a usage error. + for (const size of [-1, 2 ** 51, 2 ** 53, 1e300, -1e300]) { + expect([]).not.toBeArrayOfSize(size); + expect(() => expect([]).toBeArrayOfSize(size)).toThrow(); + } + }); + test("toBeArrayOfSize()", () => { expect([]).toBeArrayOfSize(0); expect(new Array()).toBeArrayOfSize(0); diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index 8773618a30a1..cbec42d9b454 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -888,6 +888,45 @@ describe("mock()", () => { expect(() => expect(fn).toHaveBeenCalledTimes(5)).toThrow(); }); + test("toHaveBeenCalledTimes and toHaveReturnedTimes take any non-negative safe integer", () => { + const fn = jest.fn(); + // -0 is an integer that ordinary arithmetic produces, and it equals 0. + const negativeZero = Math.round(-0.2); + const usageError = (matcher, count) => { + try { + expect(fn).not[matcher](count); + } catch (e) { + return e; + } + }; + for (const [matcher, name] of [ + ["toHaveBeenCalledTimes", "toHaveBeenCalledTimes"], + ["toHaveReturnedTimes", "toHaveReturnedTimes"], + // Jest 30 removed this alias. + ...(isBun ? [["toBeCalledTimes", "toHaveBeenCalledTimes"]] : []), + ]) { + expect(fn)[matcher](negativeZero); + expect(() => expect(fn).not[matcher](negativeZero)).toThrow(); + // A count that the mock did not reach is a mismatch that names the count, not a usage error. + for (const count of [2 ** 32 - 1, 2 ** 32, 2 ** 53 - 1]) { + expect(fn).not[matcher](count); + expect(() => expect(fn)[matcher](count)).toThrow(String(count)); + } + // Every other value is a usage error, also under `.not`. + for (const count of [-1, 1.5, NaN, Infinity, 2 ** 53, "1", 1n, null, undefined]) { + expect(usageError(matcher, count)).toMatchObject( + isBun + ? { + name: "TypeError", + code: "ERR_INVALID_ARG_TYPE", + message: `${name}() requires 1 non-negative integer argument`, + } + : { message: expect.stringContaining("non-negative integer") }, + ); + } + } + }); + it("no segmentation fault when passing jest.fn into another jest.fn, issue#5900", () => { function foo() { return true; From fd1fc8d5dcef7695c53867b864eeca3752520e28 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:48:04 +0000 Subject: [PATCH 2/2] test: assert the mismatch text in the new matcher tests, not a bare toThrow() --- test/js/bun/test/jest-extended.test.js | 6 +++--- test/js/bun/test/mock-fn.test.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/js/bun/test/jest-extended.test.js b/test/js/bun/test/jest-extended.test.js index 2b1e4662c024..b99981abd79d 100644 --- a/test/js/bun/test/jest-extended.test.js +++ b/test/js/bun/test/jest-extended.test.js @@ -209,12 +209,12 @@ describe("jest-extended", () => { const negativeZero = Math.round(-0.2); expect([]).toBeArrayOfSize(negativeZero); expect([1]).not.toBeArrayOfSize(negativeZero); - expect(() => expect([]).not.toBeArrayOfSize(negativeZero)).toThrow(); + expect(() => expect([]).not.toBeArrayOfSize(negativeZero)).toThrow("Received:"); await expect(Promise.resolve([])).resolves.toBeArrayOfSize(negativeZero); - // A whole number that no array length equals is a mismatch, not a usage error. + // A whole number that no array length equals is a mismatch ("Received:"), not a usage error. for (const size of [-1, 2 ** 51, 2 ** 53, 1e300, -1e300]) { expect([]).not.toBeArrayOfSize(size); - expect(() => expect([]).toBeArrayOfSize(size)).toThrow(); + expect(() => expect([]).toBeArrayOfSize(size)).toThrow("Received:"); } }); diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index cbec42d9b454..e37f33f268ab 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -906,7 +906,7 @@ describe("mock()", () => { ...(isBun ? [["toBeCalledTimes", "toHaveBeenCalledTimes"]] : []), ]) { expect(fn)[matcher](negativeZero); - expect(() => expect(fn).not[matcher](negativeZero)).toThrow(); + expect(() => expect(fn).not[matcher](negativeZero)).toThrow("Expected number of"); // A count that the mock did not reach is a mismatch that names the count, not a usage error. for (const count of [2 ** 32 - 1, 2 ** 32, 2 ** 53 - 1]) { expect(fn).not[matcher](count);