Skip to content

Send queues: keep 4096 bytes of room behind a payload of 1 MiB or more - #46415

Open
robobun wants to merge 1 commit into
mainfrom
robobun/42a4301c/send-queue-growth-rule
Open

robobun wants to merge 1 commit into
mainfrom
robobun/42a4301c/send-queue-growth-rule

Conversation

@robobun

@robobun robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A short append behind a large unsent payload copies it. Bun.spawn stdin: write("x") behind write(64 MiB) blocks for 27 to 74 ms (v1.3.14: under 0.05 ms). No user reported it.
  • Vec fits a request for more than twice its capacity exactly. The next byte then doubles the block: mi_malloc(66889600), then mi_realloc(p, 133779200). StreamBuffer::write (src/io/PipeWriter.rs:1954) and OffsetByteList::write (src/collections/vec_ext.rs:544) append that way since the Rust port (Rewrite Bun in Rust #30412).

Fix

  • bun_core::vec::reserve_for_append asks for 4096 bytes more where Vec::reserve would fit 1 MiB or more exactly. Other growths keep Vec::reserve.
  • StreamBuffer, OffsetByteList, the node:http socket buffer, the string appends, ArrayBufferSink and the fetch body grow through it.
  • Verified: 22 new tests in 8 files. Each fails on main.
  • Self-reviewed: 71 concerns raised, 63 addressed. Not done (see Notes): exact Latin-1 sizing, a MySQL test, the lone-surrogate estimate, 4 test details.

Background

Downsides

  • Follow-ups over 4096 bytes, and queued remainders under 1 MiB, still copy once.
  • On one connection, a later payload of 1 to 2 times the largest so far holds two blocks. Postgres, 64 then 120 MiB: 184 MiB in total (main: 134).
  • Each growth runs 28 to 45 instructions more (growing FileSink write: 747 to 792).
Notes

What the numbers count. "main" and "PR" are release builds on Linux x64. The main binary is a build of this checkout with src/ at e53cd3d994. v1.3.14 and v1.4.0 are the release binaries. A peak is the growth of VmHWM while the writes run, over the payload size. The peak does not include the payload: it is zero pages, or it is resident before the measurement starts. On main the second block lives while the copy runs and until the allocator purges it.

Reach. Three rows need nothing unusual:

  • a large Postgres parameter
  • a large write and a newline to a child in one tick
  • a large string with an accented character

The other rows need a peer that does not read, or an option that is not the default.

Peak per entry point (5 interleaved runs). A cell is the ratio of the peak to the payload, or MiB where the row says so. The payload is 64 MiB unless the row gives a size.

Entry point, and what follows the payload v1.3.14 v1.4.0 main PR
Bun.spawn stdin, one write, nothing behind it 1.01 to 1.03 1.03 to 1.04 1.07 1.07
Bun.spawn stdin, then write("x") 1.01 to 1.04 2.04 2.07 1.06 to 1.07
child_process stdin, then write("x") 1.01 to 1.02 2.04 to 2.05 2.04 to 2.05 1.05 to 1.06
process.stdout to a pipe, then write("x") 1.06 2.08 2.10 1.10
stdin, one ASCII string, then "x" 1.01 to 1.02 1.09 to 2.03 2.02 to 2.03 1.02 to 1.04
stdin, one Latin-1 string that ends in é, then "x" 1.01 to 1.02 1.08 to 1.35 2.03 1.02 to 1.03
stdin, one UTF-16 string, then "x" 1.01 to 1.03 0.93 to 1.04 2.02 to 2.03 1.02
Postgres, bytea parameter 1.00 to 1.02 2.07 to 2.09 2.09 1.03
Redis SET, string value, enableAutoPipelining: false 1.00 to 1.01 2.09 2.09 to 2.10 1.03
Redis SET, Uint8Array value, enableAutoPipelining: false 1.01 to 1.02 2.08 to 2.09 3.10 2.04
Redis SET, Uint8Array value, default options 2.01 to 2.02 2.02 to 2.03 2.04 2.03 to 2.04
MySQL, blob parameter of 16,777,000 bytes (one packet) 1.05 to 1.10 1.01 to 1.10 1.03 to 1.08 1.03 to 1.08
MySQL, blob parameter of 16,777,197 bytes (two packets) no result in 120 s throws 2.09 1.03 to 1.08
MySQL, blob parameter of 64 MiB wrong result throws 2.09 1.03
IPC, send("x") behind a queued 32 MiB message: MiB added 0 12 to 70 37.3 0
fetch(), direct stream, chunked, 32 MiB 2.04 to 2.05 2.22 to 2.23 2.18 to 2.19 1.05 to 1.06
fetch(), direct stream, Content-Length, 32 MiB then "x" 2.04 to 2.06 2.10 to 2.11 2.05 to 2.06 1.05
s3file.writer(), queueSize: 2, 32 MiB then "x" 1.35 to 1.44 2.39 2.37 1.37 to 1.39
node:http upgraded socket, a 400 reply behind 32 MiB: MiB added 0 to 1 61 to 62 61 30 to 32
Bun.spawn stdin, then 6 MiB 1.01 to 1.02 1.94 1.93 1.93
  • Redis with a Uint8Array reads one payload more on main than on v1.4.0. Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021 copies a buffer argument before it serializes the command. This PR does not change that copy.
  • Redis with default options reads 2.0 on every build. The client serializes the command once and copies it into the write buffer. The buffer itself copies only when a command goes behind an unsent one. The new test covers that case.
  • MySQL parameters of 16 MiB or more work on main only, so the older releases give no baseline there.
  • v1.3.14 adds nothing on the upgraded socket row. I did not look into why one copy remains against it.
  • v1.4.0 varies on three string rows and on the IPC row. I did not look into why.
  • Node 26.3 reads 0.01 on the child_process row. It queues the write by reference.
  • Time of write("x") behind 64 MiB, 5 more runs: v1.3.14 0.03 to 0.04 ms, main 27 to 32 ms, PR 0.02 to 0.03 ms.

One follow-up write behind 64 MiB in Bun.spawn stdin (3 runs). A cell is the peak over the total, then the time of the second write. This probe resets VmHWM before the first write, so it reads 0.05 lower than the table above.

Follow-up v1.3.14 main PR
none 1.01 1.02 1.02 to 1.04
1 byte 1.01, 0.02 to 0.03 ms 2.02 to 2.04, 27 to 74 ms 1.02, 0.02 ms
4,096 bytes 1.00 to 1.01, 0.02 ms 2.02, 26 to 34 ms 1.02, 0.02 ms
4,097 bytes 1.01, 0.02 to 0.03 ms 2.02, 26 to 27 ms 2.02 to 2.04, 27 to 38 ms
64 KiB 1.01 to 1.02, 0.03 to 0.05 ms 2.02, 27 to 28 ms 2.02, 26 to 31 ms
6 MiB 1.01, 3 ms 1.93, 29 to 30 ms 1.93, 29 to 32 ms

Queue capacity after write(64 MiB), write("x"), write(7 MiB):

  • v1.3.14: 73,622,000 / 73,622,000 / 110,433,128
  • main: 66,889,600 / 133,779,200 / 133,779,200
  • PR: 66,893,696 / 66,893,696 / 133,787,392

The same behind a smaller first write (1 run). A cell is the queue capacity before and after the second write. On this machine the pipe takes the first 219,264 bytes, so the queue holds that much less than the write.

First write, follow-up main PR
2 MiB, 1 byte 1,877,888 / 3,755,776 1,881,984 / 1,881,984
2 MiB, 4,096 bytes 1,877,888 / 3,755,776 1,881,984 / 1,881,984
1 MiB, 1 byte 829,312 / 1,658,624 829,312 / 1,658,624
1 MiB, 4,096 bytes 829,312 / 1,658,624 829,312 / 1,658,624
512 KiB, 1 byte 305,024 / 610,048 305,024 / 610,048
512 KiB, 4,096 bytes 305,024 / 610,048 305,024 / 610,048

Traces without a short follow-up

Trace v1.3.14 main PR
1024 writes of 64 KiB to a stalled stdin: allocations, bytes moved, final capacity 18, 147,049,207, 73,622,000 12, 96,731,136, 96,796,672 12, 96,731,136, 96,796,672
32 MiB, then 16 MiB in 4 KiB writes, one tick: peak MiB 96.1 to 97.1 81.1 to 82.4 81.0 to 82.1
the same, spread over time: peak MiB 98.3 to 99.3 66.1 66.0
FileSink to /dev/null, 41 writes of 100 bytes per flush: ns per flush (minimum, then medians) n/a 1027, 1079 to 1354 1043, 1061 to 1327
the same with 40 writes of 100 bytes and one of 4192 n/a 1043, 1062 to 1141 1043, 1068 to 1108
the same with 40 writes of 100 bytes and one of 4193 n/a 1117, 1131 to 1448 1100, 1125 to 1149
Postgres, one 8 MiB row read: allocator calls, bytes moved (3 runs) n/a 11, 9,043,384 or 9,233,848 11, 9,233,848

ArrayBufferSink capacity after each write (distinct values)

Writes v1.3.14 main PR
Latin-1 string of 8 MiB with 1 non-ASCII character, then "x" 9,694,856 16,777,216 8,392,704
the same with 4095 non-ASCII characters, then "x" 9,694,856 16,777,216 8,392,704
the same with 4097 non-ASCII characters 9,694,856 16,777,216 16,785,408
Latin-1 string of 512 KiB with 1 non-ASCII character, then "x" 567,177 1,048,576 1,048,576
ASCII string of 8 MiB, then "x" 9,694,856 8,388,608 → 16,777,216 8,392,704
UTF-16 string of 8 MiB, then "x" 9,694,856 8,388,608 → 16,777,216 8,392,704
8 MiB of bytes, then "x" 9,694,856 8,388,608 → 16,777,216 8,392,704
1 MiB less 1 byte, then "x" 1,276,467 1,048,575 → 2,097,150 1,048,575 → 2,097,150
1 MiB of bytes, then "x" 1,276,467 1,048,576 → 2,097,152 1,052,672
50 bytes 128 50 50
255 writes of 16 bytes 128 → … → 4,118 (7 sizes) 16 → … → 4,096 (9 sizes) 16 → … → 4,096 (9 sizes)
64 writes of 1 byte 128 8 → … → 64 (4 sizes) 8 → … → 64 (4 sizes)
4096 writes of 16 KiB 21 distinct, last 73,622,000 16,384 → … → 67,108,864 (13 sizes) 16,384 → … → 67,108,864 (13 sizes)
128 writes of 512 KiB 567,177 → … → 73,622,000 (12 sizes) 524,288 → … → 67,108,864 (8 sizes) 524,288 → … → 67,108,864 (8 sizes)
16 writes of 4 MiB 4,308,683 → … → 73,622,000 (7 sizes) 4,194,304 → … → 67,108,864 (5 sizes) 4,198,400 → … → 67,174,400 (5 sizes)

A 64 MiB payload, then a larger one on the same connection (1 to 3 runs). A cell is the peak MiB of the first call, then the MiB that the second call adds. The probe resets VmHWM between the calls.

Calls v1.3.14 main PR
Postgres, bytea parameters of 64 then 80 MiB 65 / 80 134 / 12 65 to 66 / 80
Postgres, 64 then 120 MiB 64 to 65 / 120 134 / 52 66 / 120
Postgres, 64 then 136 MiB not run 134 / 136 66 / 136
Redis SET, Uint8Array, enableAutoPipelining: false, 64 then 80 MiB 65 to 66 / 80 198 / 92 130 / 160

The second call starts with the first block still held (64 MiB). So Postgres with 64 then 120 MiB peaks at 184 MiB in total on this branch and on v1.3.14. Main peaks at 134 MiB, in the first call: its doubled block already holds 120 MiB. By the same sum the Redis row is about 224 MiB in total on this branch and 198 on main. Above 2 times, main grows too and the totals are equal. mi_realloc copies the old block, used or not. OffsetByteList does not release its block when it drains. A release on drain would remove this case, and it is not in this PR.

Instructions (gdb stepi from entry to return). A cell is the total, then the count inside the function itself.

Call main PR
PosixStreamingWriter<FileSink>::write, 16 bytes into the queue 281, 76 281, 76
FileSink__write, the same write from its JS entry 547, 99 547, 99
OffsetByteList::write, Postgres small query 47, 28 45, 28
ArrayBufferSink__write, 16 bytes 233, 111 233, 111
ArrayBufferSink__write, 16-character ASCII string 477, 137 477, 137
ArrayBufferSink__write, first write of a new sink (the buffer grows) 342, 121 370, 125
FileSink__write, the write that takes the queue past 4096 bytes (the queue grows) 747, 99 792, 99

The first five rows are appends that fit. The last two rows grow: grow_for_append runs in front of Vec::reserve, and grow_and_append in front of the copy.

Size. size of the release binary: text 107,380,868 on main and 107,379,332 on this branch (1,536 bytes less). OffsetByteList::write goes from 108 to 85 bytes and StreamBuffer::write from 226 to 198. New: grow_for_append (94 bytes) and grow_and_append (114 bytes). No struct changes size.

Benchmarks. Release builds, interleaved rounds, ns per call. A cell is the minimum, then the median of the p50 of each round. main2 is a second run of the main binary, to show the noise. bench/ needs mitata, which this machine does not have, so a small sampling loop replaces bench/runner.mjs.

bench/snippets/arraybuffersink.mjs (5 rounds):

bench (ns, min / median of p50) v1.3.14 v1.4.0 main main2 PR
12 ascii 43.6 / 45.8 25.0 / 26.1 27.9 / 28.8 27.7 / 29.0 27.0 / 27.9
12 utf8 47.0 / 48.6 39.7 / 41.7 47.5 / 50.4 47.8 / 50.5 46.7 / 48.2
12288 ascii 375.5 / 398.1 165.4 / 179.6 168.8 / 180.5 162.7 / 178.4 160.1 / 175.4
18432 utf8 7510.6 / 7872.2 7444.5 / 7821.8 5874.3 / 6733.6 5758.9 / 5930.6 5817.3 / 5944.7

The same sink with bytes and Latin-1 strings (5 rounds, not in bench/). A "reuse" row writes to one sink and resets it. A "one-shot" row makes a sink, writes once and ends it, so every call grows the buffer once.

bench (ns, min / median of p50) v1.3.14 v1.4.0 main main2 PR
reuse: 12 bytes 43.4 / 46.6 15.1 / 16.0 17.8 / 18.6 17.2 / 18.9 17.6 / 18.8
reuse: 12288 bytes 97.7 / 104.5 68.4 / 72.1 70.2 / 72.6 71.5 / 77.8 69.8 / 76.0
reuse: 12 latin1 non-ascii 44.7 / 47.3 29.7 / 31.0 32.9 / 34.4 33.0 / 35.0 32.9 / 34.2
reuse: 12288 latin1 non-ascii 8385.7 / 8752.7 14159.8 / 14862.2 14136.1 / 14769.1 14238.3 / 14804.3 14060.2 / 14854.7
one-shot: 12 ascii 260.9 / 307.9 142.4 / 391.8 120.9 / 199.6 112.5 / 223.4 124.8 / 166.8
one-shot: 12 bytes 275.8 / 306.6 133.8 / 218.7 101.3 / 349.2 98.4 / 361.1 103.2 / 149.0
one-shot: 12 latin1 non-ascii 283.2 / 315.4 179.8 / 412.9 214.8 / 268.2 194.5 / 284.5 189.8 / 260.3
one-shot: 18 utf16 315.0 / 372.9 167.8 / 454.4 146.1 / 284.5 142.6 / 249.8 138.2 / 354.8
one-shot: 150 bytes 308.9 / 367.5 157.9 / 741.2 127.1 / 625.2 117.6 / 625.7 112.2 / 665.7
one-shot: 12288 ascii 1634.4 / 2333.4 908.9 / 15326.4 770.0 / 9853.3 788.4 / 1475.3 887.6 / 1434.6
one-shot: 12288 bytes 1453.8 / 2229.4 392.8 / 1396.8 896.3 / 1401.4 634.6 / 1390.6 657.8 / 1476.6
one-shot: 1 MiB bytes then 1 byte 82335.0 / 302164.4 72840.0 / 1224987.5 73147.5 / 920177.5 54965.0 / 135510.0 42780.0 / 86030.0
one-shot: 1 MiB bytes then 512 KiB in 4 KiB writes 172025.0 / 211065.0 100005.0 / 1789452.3 98760.0 / 253760.0 110715.0 / 894900.0 70895.0 / 679670.0

bench/snippets/ipc-json.mjs (3 rounds of 5 samples, ms per run):

bench (ms, min / median of p50) v1.3.14 v1.4.0 main main2 PR
ipc json - small messages (1000 roundtrips) 22.2 / 55.1 11.0 / 42.9 12.0 / 19.3 11.5 / 17.2 13.7 / 25.9
ipc json - 10MB messages (10 roundtrips) 1043.0 / 1886.6 1038.7 / 1337.5 901.3 / 1498.1 1019.5 / 1559.1 1044.6 / 1708.8

Reruns with main and this branch twice each, in the order main, PR, main, PR:

bench (ns, min / median of p50) main PR main2 PR2
one-shot: 12 ascii 111.9 / 353.4 120.0 / 150.6 110.4 / 139.6 122.9 / 210.7
one-shot: 12 bytes 100.8 / 364.3 101.7 / 200.0 97.3 / 134.4 103.7 / 365.8
one-shot: 12 latin1 non-ascii 218.2 / 353.2 172.9 / 342.7 149.6 / 266.4 217.7 / 291.0
one-shot: 12288 ascii 782.3 / 8554.9 673.7 / 1594.4 488.5 / 8636.5 648.3 / 10285.9
one-shot: 12288 bytes 435.1 / 9922.9 512.7 / 1674.2 425.5 / 1381.9 936.9 / 1630.7
ipc json - small messages (1000 roundtrips) 6.2 / 17.8 8.7 / 21.1 9.6 / 20.1 9.4 / 20.3
ipc json - 10MB messages (10 roundtrips) 559.6 / 1450.0 830.1 / 1379.0 817.1 / 1560.4 585.0 / 1252.9

In the reruns the two runs of one binary differ as much as main differs from this branch. The last two one-shot rows of the second table are the case this PR is for.

Why this rule. I built three rules. The first two ran on release builds of an earlier base (91e7e3b140).

Rule Result
The growth of v1.3.14 (steps of 1.5, room of 0 to 50%) where Vec fits exactly The same as the v1.3.14 column above: 32 MiB then 16 MiB of 4 KiB writes peaks at 96 to 99 MiB, against 66 to 82 on main. The copy comes later, with more bytes queued.
4096 bytes of room on every growth Short writes to a FileSink get about 13% slower per flush (about 1010 ns on main, 1140 with that rule). The queue leaves its cycle of 4096 and 8192 bytes, which mimalloc serves in place.
4096 bytes of room where Vec fits 1 MiB or more exactly (this PR) Equal to main on every trace above that has no single reserve of more than 512 KiB.

The rule applies only to one reserve of more than 512 KiB: the list has to need 1 MiB, and more than twice its capacity. That is one append, or one Redis pipeline flush, which reserves the total of its batch. The room doubles when the block doubles later: 16 writes of 4 MiB end 65,536 bytes above main. The room stays under 0.4% of the block. Below the limit the copy is short: 829,312 bytes take 0.3 to 0.7 ms.

Also changed. The fetch arm for a body that is not chunked now reserves the UTF-8 length before it appends. A Latin-1 chunk under 1 MiB now fits exactly there. On main that arm reserved the Latin-1 length and doubled.

Not in this PR.

Shared helpers. ByteVecExt::write_utf16 and the Latin-1 append (allocate_latin1_into_utf8_with_list) serve more than the queues. They also take string writes to the Bun.serve direct stream sink and to HTMLRewriter. The shell output buffer and EncodedSlice::to_owned_slice_z use them too. There, a string of 1 MiB or more gets the same 4096 bytes.

Related work.

No MySQL test. The MySQL rows go through OffsetByteList::write, which the Redis and Postgres tests cover. The stub-server tests in test/js/sql say that they are for fault injection only. They say that a case a real server can produce belongs in describeWithContainer. The MariaDB on my build machine refuses the user of those tests. So I could not run a test there and did not add one. The MySQL rows come from the same probes as the other rows. The server is a local MariaDB 11.8 with max_allowed_packet at 1 GiB.

Self-review: what is not done.

  • Exact Latin-1 sizing (two concerns). See "Not in this PR".
  • A MySQL test. See "No MySQL test".
  • The lone-surrogate estimate of write_utf16. It is wrong on main in the same way. strings: size the UTF-16 to UTF-8 append output in the conversion, mark copy_utf16_into_utf8_with_utf8_len unsafe #38971 (open) fixes it.
  • The Redis test row with auto-pipelining cannot fail on Windows. There the socket can take the whole SET, so the next command finds the buffer empty on main too.
  • Five of the six peak fixtures read VmHWM without a reset first. A reset needs a write to /proc/self/clear_refs, which can fail. A test without it can miss a regression, and it cannot fail on a fixed build.
  • The six fixtures repeat one line that reads VmHWM. A helper for it would touch harness.ts.
  • I did not run the new tests on a debug build of main. The peak tests depend on the allocator, and a debug build uses the system allocator.

Tests. Each new test fails on a release build of main and passes on a debug build of this branch.

  • test/js/bun/spawn/spawn-stdin-destroy.test.ts: 7 tests. Six check that the queue of proc.stdin does not grow. They write one byte or 4096 bytes behind 8 MiB of bytes, UTF-16 or Latin-1. One reads the peak of a child.
  • test/js/bun/util/arraybuffersink.test.ts: 6 tests, the same rows on ArrayBufferSink.
  • test/js/bun/spawn/spawn.ipc.test.ts: a short message behind a queued 16 MiB message, in both serializations.
  • test/js/sql/postgres-bytea-bind.test.ts: a 32 MiB bytea parameter.
  • test/js/valkey/reliability/connection-failures.test.ts: SET of 32 MiB then GET, with and without auto-pipelining.
  • test/js/bun/s3/s3-networksink-leak.test.ts: one byte behind 32 MiB in s3file.writer().
  • test/js/web/fetch/fetch-args.test.ts: a direct stream upload of 32 MiB, chunked and with Content-Length.
  • test/js/node/http/node-http-pinned-write.test.ts: a 400 reply behind 32 MiB on an upgraded socket.

Linux runs all 22. The 7 peak tests read VmHWM, so macOS runs 15. Windows runs 11: its writer keeps the first write in a buffer of its own, so 4 more stdin rows do not apply. I cannot run Windows or macOS.

The stdin tests are not in spawn-stdin-pipe-fd-leak.test.ts, and the S3 test is not in s3-upload-memory.test.ts. On a debug build, older tests in those files take up to 4.6 s of their 5 s. One in each file timed out once.

Suites run. On a debug build of this branch, the 8 files above pass. They also pass with the environment of the CI ASAN lane (detect_leaks=1, BUN_DESTRUCT_VM_ON_EXIT=1), except one older test. That test (write() copies a WebAssembly.Memory's bytes) passed its 30 s limit there. It takes 3.4 s without that environment. On a release build of main, the same 8 files fail in the 22 new tests only.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/fetch/fetch-args.test.ts, test/js/valkey/reliability/connection-failures.test.ts, test/js/sql/postgres-bytea-bind.test.ts, test/js/node/http/node-http-pinned-write.test.ts, test/js/bun/spawn/spawn.ipc.test.ts, test/js/bun/spawn/spawn-stdin-destroy.test.ts

`Vec::reserve` fits a request for more than twice the capacity exactly.
A byte queue that takes a large payload and then a few more bytes (a
protocol trailer, a newline, a short second write) doubles the block
for those bytes and copies the payload.

`bun_core::vec::reserve_for_append` asks `Vec::reserve` for 4096 bytes
more where that exact fit is 1 MiB or more. Every other growth is
`Vec::reserve` as before. `StreamBuffer`, `OffsetByteList`, the
node:http socket buffer, `ByteVecExt::write_utf16`, the Latin-1 to
UTF-8 append, `ArrayBufferSink` and the fetch request body grow
through it.
@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced on a release build of main e53cd3d994, and on the v1.3.14 and v1.4.0 release binaries. Each number is the growth of peak RSS over the payload size.

  • Bun.spawn stdin, write(64 MiB) then write("x") to a child that does not read: 1.01 to 1.04 on v1.3.14, 2.04 on v1.4.0, 2.07 on main. The one-byte write takes 27 to 74 ms on main.
  • A 64 MiB Postgres bytea parameter: 1.00 to 1.02 on v1.3.14, 2.09 on main.
  • Redis SET of a 64 MiB string with enableAutoPipelining: false: 1.00 to 1.01 on v1.3.14, 2.09 to 2.10 on main.
  • send("x") behind a queued 32 MiB IPC message: 37.3 MiB more on main, 0 on v1.3.14.

With this PR the four rows read 1.06 to 1.07, 1.03, 1.03 and 0 MiB. The 22 new tests fail on main and pass here.

PR: #46415

@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:05 PM PT - Oct 21st, 2026

❌ @robobun, your commit 17c0037 has some failures in Build #130489 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 46415

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

bun-46415 --bun

@coderabbitai

coderabbitai Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 1 minute.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 4f45c94f-9039-4f2a-9667-8afef7b8f1a2
📥 Commits

Reviewing files that changed from the base of the PR and between 0793250 and 17c0037.

📒 Files selected for processing: 15
  • src/bun_core/lib.rs
  • src/collections/vec_ext.rs
  • src/io/PipeWriter.rs
  • src/runtime/socket/uws_jsc.rs
  • src/runtime/valkey_jsc/valkey.rs
  • src/runtime/webcore/ArrayBufferSink.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • test/js/bun/s3/s3-networksink-leak.test.ts
  • test/js/bun/spawn/spawn-stdin-destroy.test.ts
  • test/js/bun/spawn/spawn.ipc.test.ts
  • test/js/bun/util/arraybuffersink.test.ts
  • test/js/node/http/node-http-pinned-write.test.ts
  • test/js/sql/postgres-bytea-bind.test.ts
  • test/js/valkey/reliability/connection-failures.test.ts
  • test/js/web/fetch/fetch-args.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/bun_core/lib.rs
Comment on lines +465 to +471
let needed = list.len().saturating_add(additional);
let exact_fit = needed > list.capacity().saturating_mul(2);
let room = if exact_fit && needed >= LARGE {
ROOM
} else {
0
};

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.

🟡 (optional) A payload of exactly twice the queue's capacity still forces a full copy on the next short append, same as on the base branch. grow_for_append at src/bun_core/lib.rs:466 adds room only when needed > capacity * 2, but Vec::reserve computes max(cap * 2, needed), so needed == capacity * 2 is also an exact fit with zero room. Fix: add ROOM whenever needed >= LARGE and capacity * 2 < needed + ROOM (or just reserve additional + ROOM for every needed >= LARGE, since Vec already takes the max), so the band just under a doubling keeps 4096 bytes too. Example: ArrayBufferSink.start({ highWaterMark: 1 MiB }), write(2 MiB), write("x") copies 2 MiB. [also at: src/bun_core/lib.rs:465 - nit: a payload that is exactly twice the current capacity still gets an exact fit, so the next short append copies the whole payload. grow_for_append at src/bun_core/lib.rs:465 tests needed > list.capacity().saturating_mul(2), but Vec::reserve allocates max(cap2, needed), which is also an exact fit when needed == cap2. Fix: treat needed >= cap*2 as the exact-fit case so every size Vec::reserve would fit exactly gets the 4096 bytes of room.; src/bun_core/lib.rs:466 - nit: a payload that needs exactly twice the current capacity is still fitted exactly, so the next short append copies it, same as on the base branch.]

Why this was flagged

Trigger: a list whose len + additional is exactly 2 * capacity (or within 4096 bytes below it) and at least 1 MiB, e.g. ArrayBufferSink::start at src/runtime/webcore/ArrayBufferSink.rs:31 does try_reserve_exact(1 MiB) and the first write at src/runtime/webcore/ArrayBufferSink.rs:82 is 2 MiB. In grow_for_append (src/bun_core/lib.rs:466) exact_fit is needed > list.capacity().saturating_mul(2), which is false at equality, so room is 0 and list.reserve(additional) is called. Rust's RawVec::grow_amortized sets cap = max(cap * 2, required_cap), so at equality the new capacity is exactly needed with no spare byte. The following write("x") then doubles the block and copies the whole payload, the behaviour this PR exists to remove; the base branch does the same copy. The documented rule ('a short append behind a large one fits') therefore has a hole at the doubling boundary; the fix is to request the room whenever cap * 2 < needed + ROOM and needed >= LARGE.

Verification: Triggered by a list whose len + additional lands exactly on 2 * capacity with that total >= 1 MiB, followed by a short append. At src/bun_core/lib.rs:466 needed > list.capacity().saturating_mul(2) is false at equality, so room is 0 and the call is plain list.reserve(additional). RawVec::grow_amortized picks max(cap * 2, required_cap), so the next 1-byte append copies the whole payload, as on main.

Bun.gc(true);
// The two sends left a higher peak behind. Writing 5 to clear_refs
// sets VmHWM back to what is resident now.
fs.writeFileSync("/proc/self/clear_refs", "5");

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.

🟡 (optional) CI runs on a kernel or sandbox that refuses writes to /proc/self/clear_refs fail the new IPC test, where the base suite had no such dependency. The fixture at test/js/bun/spawn/spawn.ipc.test.ts:218 calls fs.writeFileSync("/proc/self/clear_refs", "5") with no guard. A throw there prints to stderr and exits nonzero, and expectRssDeltaBelow (test/harness.ts:485) asserts stderr is empty. The test is gated only on isLinux. Fix: skip or tolerate when the clear_refs write fails (try/catch and emit a skip marker, or measure in a fresh process so VmHWM needs no reset), so the test cannot fail for a platform reason on gVisor, CONFIG_PROC_PAGE_MONITOR=n, or hardened runners.

Why this was flagged

Trigger: the test runs on Linux where /proc/self/clear_refs is absent or EACCES/EINVAL (gVisor, CONFIG_PROC_PAGE_MONITOR disabled, some hardened containers). Entry: spawn.ipc.test.ts:200 it.skipIf(!isLinux) spawns the fixture through expectRssDeltaBelow at test/harness.ts:474. The fixture writes "5" at spawn.ipc.test.ts:218 inside try/finally with no catch, so the error is printed to stderr and the process exits 1. test/harness.ts:485 expect(stderr.trim()).toBe("") fails and test/harness.ts:488 exit code assertion fails. No other test in the tree touches clear_refs (grep), so the base suite is unaffected on those runners. The isLinux gate does not cover this: it checks the OS, not kernel config or sandbox policy. Remedy: catch the write failure and skip, or restructure the fixture so no VmHWM reset is needed.

Verification: nit — triggers when the host has no writable /proc/self/clear_refs. The fixture in test/js/bun/spawn/spawn.ipc.test.ts:218 does fs.writeFileSync("/proc/self/clear_refs", "5"); with no catch, so a throw prints to stderr and exits nonzero. The test is gated only by it.skipIf(!isLinux) and harness.ts:485 asserts expect(stderr.trim()).toBe(""). The base suite had no such dependency.

Comment thread src/bun_core/lib.rs
) -> Vec<u8> {
list.truncate(offset_into_list);
list.reserve(latin1.len());
crate::vec::reserve_for_append(&mut list, latin1.len());

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.

🟣 pre-existing, not blocking: pre-existing nit: a Latin-1 string of 1 MiB or more with more than 4096 non-ASCII characters still doubles the queue and copies the payload, the copy this PR sets out to remove. src/bun_core/lib.rs:1660 reserves only latin1.len(), so the 4096 bytes of room absorb at most 4096 one-byte expansions before list.reserve(2) at src/bun_core/lib.rs:1675 doubles the block. Fix: size the reservation by the UTF-8 length, element_length_latin1_into_utf8(latin1) from src/bun_core/lib.rs:1814, which covers every caller of the helper: StreamBuffer::write_latin1, ByteVecExt::write_latin1 and to_utf8_from_latin1.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A JS string that is 8-bit in JSC and holds ordinary French, German or Spanish text of a few MiB (thousands of accented characters) is written to Bun.spawn stdin, a FileSink, an ArrayBufferSink or a fetch() request body. Those reach StreamBuffer::write_latin1 at src/io/PipeWriter.rs:2021 or ByteVecExt::write_latin1 at src/collections/vec_ext.rs:500, both of which call allocate_latin1_into_utf8_with_list. src/bun_core/lib.rs:1660 asks reserve_for_append for latin1.len() bytes, which for a fresh or small list yields an exact fit plus 4096 bytes of room. Each non-ASCII byte becomes two bytes, so after 4096 such characters the room is gone and list.reserve(2) at src/bun_core/lib.rs:1675 doubles the block, allocating 2x the payload and copying all of it, exactly what the base branch does on the first accented character. The PR's test and table only cover a string whose single non-ASCII character is the last one, so this path is not exercised.

Verification: src/bun_core/lib.rs line 1660 crate::vec::reserve_for_append(&mut list, latin1.len()); sizes the reservation by the Latin-1 byte count, not the UTF-8 output length. Each non-ASCII byte expands to two bytes, so after 4096 such characters list.reserve(2) at line 1675 doubles the block and memcpys the whole payload. On the base branch the first non-ASCII character already doubled the block.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant