Repository navigation
Conversation
`write_or_end` reads the payload once, through the new `BlobOrStringOrBuffer::in_memory_view()`. The accessor returns `None` for a Blob whose bytes are not in memory. The call then throws before it sends or ends anything, and the socket stays open. The guard tested `needs_to_read_file()`, which is true for a file store only. An S3 store passed it, `slice()` was empty, `write` returned 0, and `end` returned 0 and sent FIN.
|
Status: reproduced on the Bun 1.4.4 release binary (38afee6) and on release and debug builds of main 6292c65. The script is in the Notes of the description. The fix and its tests are in this PR: #46833. Its base is main. #46522 leaves this change for a separate review here. If #46522 merges first, I rebase this PR (one line conflicts). |
|
Updated 8:36 PM PT - Oct 24th, 2026
✅ @robobun, your commit 5534f6ccee0ed320a409406354b40b7246375f54 passed in 🧪 To try this PR locally: bunx bun-pr 46833That installs a local version of the PR into your bun-46833 --bun |
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Expose in-memory payload bytes src/runtime/node/types.rs |
BlobOrStringOrBuffer now returns an optional in-memory byte view. Blob values use Blob::in_memory_view(); string and buffer values return their contained bytes. |
Use views for socket writes and ends src/runtime/socket/socket_body.rs, test/js/bun/net/socket.test.ts |
NewSocket::write_or_end rejects payloads without an in-memory view and uses the view for default lengths and byte-range slicing. TCP and TLS tests cover rejected file- or S3-backed blobs, successful in-memory payloads, and subsequent writes. |
Possibly related issues
- oven-sh/bun#43381 — This change covers socket behavior after
Socket.end()rejects an unsupported file- or S3-backed Blob.
Suggested reviewers: alii, cirospaciari, dylan-conway
Priority:
Merge Risk: 
Minimal · up to 5534f
-
The change can merge after normal checks; no concrete unresolved risk has been established.
Pre-merge checks: 
4
-
Full details: Description check
-
Explanation
The description clearly explains the problem, implementation, scope, behavioral changes, limitations, and verification results. It does not use the exact template headings, but it provides the required information, including detailed test coverage and known environment-specific failure.
-
- 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.
Comment @coderabbitai help to get the list of available commands.
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked the data slice borrowed at src/runtime/socket/socket_body.rs:2894 and held across the byteOffset/byteLength coercions — is_any_int and to_int64 are pure numeric checks that cannot run user JS, and buffer stays alive until the function returns, so the view cannot dangle. The default byteLength and the bytes source now reading data instead of buffer.slice() is identical for every in-memory input (both resolve to shared_view() / str.slice()), and the control test pins that for sliced and offset Blobs over both tcp and tls.
Extended reasoning...
The change replaces the File-only guard in the shared write/end body of NewSocket (both SSL variants, via the IS_END const generic) with a fallible BlobOrStringOrBuffer::in_memory_view() so S3-backed Blobs are refused with a thrown Error before any socket state is touched, and adds tcp/tls tests with a loopback Bun.serve as the S3 stand-in. It touches no auth, crypto, or injection surface. The refactor was verified behavior-preserving for in-memory inputs and the borrowed slice is sound, but the inline findings about sibling guards and the consumed Request/Response body are substantive enough that a human should weigh them before merging.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟣
src/runtime/node/types.rs— pre-existing: callers that pass an S3File where a Bun.file() is refused still get empty bytes written silently, exactly as on the base branch. The sibling guards still test only needs_to_read_file(), which is false for an S3 store, so the blob passes and slice() yields nothing. Fix: route every file-or-S3 refusal through the same predicate the socket now uses (blob.in_memory_view().is_none()), which covers the 2 sites listed. Same pattern at 2 sites (src/runtime/node/types.rs:153, src/runtime/socket/uws_jsc.rs:193). Bun.build({ files: { "a.ts": s3file } }) then builds an empty module instead of throwing "File blob cannot be used here".Why this was flagged
A user passes an S3File (client.file("key")) as a value in Bun.build's files option, which reaches BlobOrStringOrBuffer::from_js at src/runtime/api/JSBundler.rs:80 with FileBlobs::Reject. The guard at src/runtime/node/types.rs:153 checks only blob.needs_to_read_file(), which is false for an S3 store, so the S3File is accepted; JSBundler.rs:90 then copies blob_or_string.slice(), which is empty for an S3 store, and the virtual file is registered with zero bytes. A Bun.file(path) at the same spot throws "File blob cannot be used here". The node:http server socket write at src/runtime/socket/uws_jsc.rs:193 has the identical needs_to_read_file()-only guard and the identical empty-slice write at uws_jsc.rs:211. This PR fixes the third instance of the pattern in socket_body.rs:2894 with in_memory_view() but leaves these two unchanged; the description defers them to another issue. Behaviour is the same as on the base branch, so this is pre-existing, but the fix belongs in the shared helper so all three sites agree.
Verification: pre-existing. Triggering condition: a user passes an S3File to any caller that parses with
FileBlobs::Reject, e.g. a value in Bun.build'sfilesoption. src/runtime/node/types.rs:153blob.needs_to_read_file()is false forstore::Data::S3, so the S3 blob is accepted andslice()returns&[], so the virtual file is registered with zero bytes. The base branch fails the same way.
|
On the two sites outside the diff: both are excluded on purpose, and the description names them under "Not in it".
|
|
I said on #46522 that I'd fold this in, and I've decided against it. The change itself reads well; the reasons are about #46522.
It stands well on its own with base main. #46522 rewrites |
|
This PR stays on base main and waits for its own review.
|
Related to #43931
Problem
socket.write(s3file)returns 0 on aBun.connect/Bun.listensocket.socket.end(s3file)returns 0 and closes the connection. Nothing is sent, and nothing throws.NewSocket::write_or_end(src/runtime/socket/socket_body.rs:2894) testsneeds_to_read_file(), true for a file store only.buffer.slice()is empty for an S3 store.Fix
write_or_endreads the payload once:let Some(data) = buffer.in_memory_view() else { throw }.ErrorofWebSocket.send(WebSocket: real closing handshake, one failure path,wsrewritten on native transports (consolidates the open WebSocket PRs) #45223):Socket.write cannot send a file- or S3-backed Blob synchronously; await blob.bytes() first. It comes before the socket sends or ends.test/js/bun/net/socket.test.ts(2 of 4 new tests fail on main). Also the whole file (Notes).Background
Blobviews a store: bytes, a file, or an S3 key.BlobExt::in_memory_view()isNonefor the last two.write()is typedstring | BufferSource, but native code also takes a Blob.|| is_s3()in the guard: it keeps two reads behind a hand-written list of store kinds.Downsides
write(x, byteOffset, byteLength)of a string or buffer costs 8 more instructions (787 to 795).write(string)costs 18 fewer.Requestwith an S3 body still writes 0 (s3: leave the size of an S3 blob unknown inresolve_size#46653). A refusedRequestorResponseis spent.Notes
Origin. A fuzz run found it. #43931 described the class and was closed as not a user report. #46673 lists the other sites. #14516 wrote the guard before the S3 store existed (#15740). No S3 request is made before or after.
Repro. A loopback
Bun.servestands in for S3.What moves. The left column is the 1.4.4 release binary (38afee6) and main 6292c65: each row gave the same result on both. There,
write(Bun.file(path))throwsFile blob not supported yet in this function."Lazy" is anS3File, a slice of one,new Blob([s3file]),new Response([s3file]), or aRequestwith such a body.write(lazy)end(lazy)write(s3file, 0, 4)ERR_OUT_OF_RANGEwith a maximum of 0write(lazy)on a half-closed socketwrite(Bun.file(path)), and aRequestwith that bodywrite(s3file.slice(0, 0))Bun.file(path).slice(0, 0)RequestorResponsewrite(123)comes: before the socket state is read. A detached socket still returns -1.RequestorResponsewhose lazy body has a resolved size of 0 still writes 0, becauseto_blob()drops its store. That is a printed S3 body (s3: leave the size of an S3 blob unknown inresolve_size#46653), an empty slice of an S3 file, or an empty file whose size was read. For the last two, 0 bytes is the right result.new Response(s3file)(no array) has no buffered body. It throwsOnly buffered Request/Response bodies are supported for now.before and after.Writablerefuses a Blob chunk withERR_INVALID_ARG_TYPE, as Node does.Tests. Debug (ASAN) build of this branch, Linux x64.
writeandendfor six inputs and for an explicit range, aflush()and a later write on the same socket, and that the S3 stand-in saw no request before a sentinel read.Requestor aResponseas socket data.should not call drain before handshake, which connects towww.example.com. This machine has no DNS, and the test fails the same way on main.Cost. Release builds (
bun run build:release) of main 6292c65 with and without the commit, Linux x64.gdbstepifrom the first instruction ofNewSocket<false>::write_or_end::<false>to its return, callees included, 8 calls on one loopback connection. Every later call gave the same count. The counts leave out mimalloc, whose refill path adds 92 on some calls in both builds.write(x)write(x, 0, 1)Uint8ArrayBlobnew Response("ef")new Response(new Blob(["ef"]))write("x"): 1414 to 1396. A string over TLS: 2933 to 2915.end("x"), fromjsTCPSocketPrototypeFunction_end: 1442 to 1420.byteLengthis not given. The saving is that read.write("x")and 737 to 736 forwrite("x", 0, 1).write_or_end, before and after: a string 0, an in-memory Blob 1 malloc and 1 free,new Response("ef")3 and 3.write(s3file)and a refusedend(s3file)enter 0 syscalls (gdbcatch syscall). On main,end(s3file)enterssetsockopt,epoll_ctlandclose, and nosendto.Bun.file()refusal on main costs about 5,100 and 6: the message now has one formatted argument.llvm-size -A,llvm-nm -S):.text82,253,657 to 82,253,145.write_or_end::<false>1902 to 1749 bytes (TCP) and 1958 to 1809 (TLS).Other open PRs.
byteLengthline. If net, tls: consolidate the open node:net, node:tls, Bun.connect, Bun.listen and usockets pull requests #46522 merges first, I rebase this PR and the line becomesdata.len().saturating_sub(byte_offset). If this PR merges first, net, tls: consolidate the open node:net, node:tls, Bun.connect, Bun.listen and usockets pull requests #46522 gets that conflict.b.anyand.variadic#46789 rewrites howwrite()parses its data: one conflict insocket_body.rsand one intypes.rs. The read has to stay a fallible one there.Bun.buildfiles and in redis #46673 edits the sameuseline oftypes.rswith the same text: no conflict. s3: leave the size of an S3 blob unknown inresolve_size#46653, Bun.Image: treat an empty Blob or buffer as zero bytes #46117 and Read Bun.serve integer options, socket.write offsets and matcher counts by value, so -0 is 0 #46212: none added.in_memory_viewan inherent method. There is no textual conflict, but if it lands first,BlobExt as _must leave theuseline oftypes.rs(unused_importsis denied).git merge-treeagainst 8417dd1, 451c232, 23f74c5, c200a79, e8f1b97, 4a65696, 261fc5b and fd1fc8d.Not in it.
us_socket_buffered_js_write(src/runtime/socket/uws_jsc.rs:193) keeps the File-only guard. Only the private_writereaches it with a Blob, and http: consolidate the open fetch, Bun.serve, node:http, node:http2 and HTTP/3 pull requests #46528 deletes it.Bun.buildfilesand redis arguments. They go throughBlobOrStringOrBuffer::from_js(src/runtime/node/types.rs:153), which refuses a file store only. Refuse an S3-backed Blob at spawn stdin, in the shell, inBun.buildfiles and in redis #46673 fixes that parser.new Blob(["a", s3file])still sendsa(Blob constructor ignores File parts if more than one part is given #25851, Blob: read file-backed parts in multi-part new Blob([...]) #33600).new Response([blob]), thenwrite(blob)) still writes 0 (Blob: share a body part's store instead of moving it out of the caller's Blob #46732).FormDatabody with an S3-backed entry (FormData: throw when serializing an S3-backed entry instead of sending an empty part #38364).Bun.build()output written tooutdiris file-backed and has nobytes()yet (Add bytes() and image() to BuildArtifact, and take it as a Blob in FormData #45509).Self-review. It found that the first cut went too far. That cut also kept a refused
Requestbody unread and refused a printed S3Request. It made three empty file-backed bodies throw where 0 was correct, and it changed node:http's private_write. I removed it. The review also asked for the sentinel read, theflush(), the sliced control rows and this cost table. The one point I did not take: it asked for a PR based on the branch of #46522. I based it on main, so that it has CI against main and can merge on its own. Also considered: a throw in the argument parser. Every write would then pass the caller's name to it.