node:http: release NodeHTTPResponse refs through its pointer instead of freeing under &self - #37875
node:http: release NodeHTTPResponse refs through its pointer instead of freeing under &self#37875robobun wants to merge 1 commit into
Conversation
…of &self NodeHTTPResponse::deref(&self) called deinit(&self), which ended in heap::take(self.as_ctx_ptr()): the allocation was freed through a pointer derived from a shared reference while that reference was still a live argument of both frames. Switch the type to #[derive(CellRefCounted)] with deinit(this: *mut Self) as the destroy target, so the release takes the allocation's pointer and the free goes through it after the last borrow ends. The hand-written AnyRefCounted impl and ref_/deref pair go away. Release sites now hand over the pointer they hold: the server-handler release in the dispatch tail (mod.rs) and in the promise reactions uses the out-param / m_ctx pointer, the auto-flush trampoline adopts the task's ref through the ctx pointer it is given, the keep-alive brackets in cork, writeHeadAndEnd and onDrainCorked become ScopedRef guards, and the remaining &self-only sites call Self::deref(self.as_ctx_ptr()) explicitly. finalize uses finalize_js_box for its pre-release work. The self-receiver-reclaim lint now treats self.as_ctx_ptr() as a spelling of the receiver, which reports the old deinit on main and nothing after this change.
|
Warning Review limit reached
Next review available in: 2 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 (3)
Comment |
|
Status Reproduced structurally: with this PR's test change applied to Fix: this PR (#37875). Self-review in progress; will update here if it turns anything up. |
There was a problem hiding this comment.
I reviewed this and didn't find any bugs — the refcount arithmetic looks balanced at every converted site and the patterns match PostgresSQLConnection / websocket_client. Because it reworks the intrusive-refcount release path across ~10 sites in node:http's server response lifecycle, a human pass on the ref/deref pairing would still be worthwhile.
What was reviewed:
ScopedRef::newvs::adoptsemantics against each converted site (cork,write_head_and_end,on_drain_corkedbump-and-release;on_auto_flush_trampolineadopts the task's existing ref) — counts match the removedref_()/deref()pairs._keep_aliveguards are named bindings (notlet _ =), so they drop at end of scope after the last use ofself/this.finalize_js_boxvs the oldset + finalize_js_box_noop— same ordering (before-closure runs, then one deref).deinitteardown steps and order unchanged; the shared borrow is scoped to end beforeheap::take(this).
Extended reasoning...
Overview
This PR converts NodeHTTPResponse from a hand-rolled ref_()/deref(&self) intrusive refcount to #[derive(bun_ptr::CellRefCounted)] with #[ref_count(destroy = Self::deinit)], changing deinit from &self to unsafe fn(this: *mut Self) so the allocation is freed through the raw pointer rather than under a live protected &self. Roughly ten release sites are updated: three keep-alive brackets become ScopedRef guards, the auto-flush trampoline adopts the task ref via ScopedRef::adopt, the promise resolve/reject callbacks and the sync dispatch tail in mod.rs release through the m_ctx / out-param pointer, and the remaining &self sites call Self::deref(self.as_ctx_ptr()) explicitly (the same pattern PostgresSQLConnection and websocket_client use). The source-lint test is extended to recognise self.as_ctx_ptr() as a receiver spelling.
Security risks
None in the traditional sense. The risk here is memory safety: an unbalanced ref at any converted site is a UAF or leak in the node:http server request path. I traced each site and the arithmetic matches the old code (each removed deref() has exactly one replacement release on the same paths; on_drain_corked's three-exit manual deref becomes one guard that drops on every exit). The only ordering change — the auto-flush ref now releases when the trampoline's guard drops rather than as on_auto_flush's last statement — is benign (the guard drops immediately after the call returns).
Level of scrutiny
High. This is native memory-safety code in a hot production path (node:http server response lifecycle), squarely in REVIEW.md's most-blocked category. The change is a mechanical sweep following an established derive pattern, and the PR description documents extensive test coverage across abort/timeout/drain/upgrade/pipelining paths on the ASAN build, but the consequence of a miscounted ref here is severe enough that a maintainer familiar with the NodeHTTPResponse lifecycle should confirm the pairing.
Other factors
- The pattern is well-established:
CellRefCounted+unsafe fn deinit(this: *mut Self)+Self::deref(self.as_ctx_ptr())at&selfsites appears identically inPostgresSQLConnection.rsandhttp_jsc/websocket_client.rs. - I verified
ScopedRef::newbumps on construction and derefs on drop, while::adoptonly derefs — the trampoline correctly usesadopt(the ref was taken inregister_auto_flush), and the three keep-alive brackets correctly usenew. finalizenow usesfinalize_js_box(self, |this| ...)in place of manual set +finalize_js_box_noop; checkedsrc/ptr/ref_count.rs:174— same net effect (run closure on&T, then onerc_deref).- The
_keep_aliveguards are named bindings, so they live to end of scope (a barelet _ =would drop immediately and defeat the purpose). - No behavioural test is added because the fix is for a latent Tree-Borrows/provenance violation, not an observable bug; the source-lint extension does catch the old shape on unmodified
main.
|
For whoever does the ref/deref pass, here is every count on a
|
|
Updated 2:05 PM PT - Aug 12th, 2026
❌ @robobun, your commit b434675 has some failures in 🧪 To try this PR locally: bunx bun-pr 37875That installs a local version of the PR into your bun-37875 --bun |
Problem
NodeHTTPResponsereleased its refcount through a&selfmethod, and on the last release that method freed the allocation through a pointer taken from&self.bun run rust:mirichecks), whether or not anything reads it afterwards. blob: delete Blob::deinit, which freed the allocation through &mut self #37672 has a standalone reduction of this shape.as_ctx_ptr(), whose own doc says it has shared provenance and exists to fill C-shaped ctx slots, so it is not a pointer the allocation can be freed through.Blob::deinit(blob: delete Blob::deinit, which freed the allocation through &mut self #37672) andReadBytesHandler::on_read_bytes(blob: hand the read handler's pointer to on_read_bytes instead of &mut self #37681); a tree-wide grep finds this as the only remaining site.Fix
CellRefCountedderive, likeCronJobandPostgresSQLConnection, so release isderef(*mut Self)anddeinittakes the raw pointer. Teardown runs inside a scoped borrow that ends before the free.ScopedRefguard, and sites with only&selfcallSelf::deref(self.as_ctx_ptr()), so that shortcut is visible at each site instead of hidden inside a safederef(&self).ref_()keeps the same single release on the same paths. The one ordering change is the auto-flush release, now made when the trampoline's guard drops, right afteron_auto_flushreturns.self.as_ctx_ptr()as a spelling of the receiver: onmainit reports exactly this one line, with the fix nothing. The node:http suites listed in the original pass on the debug ASAN build (one failure is pre-existing, dns: add the loopback family AI_ADDRCONFIG filters out of localhost lookups #37442).Background
#[derive(bun_ptr::CellRefCounted)]generatesref_(),unsafe fn deref(*mut Self)and the bridgeScopedRefandfinalize_js_boxneed, and calls the nameddestroyfunction at zero..classes.tsare a JS wrapper cell holding a raw pointer (m_ctx) to the Rust object; the wrapper owns one ref and gives it up infinalizewhen GC collects it.value.as_::<T>()returns that raw pointer,as_class_refa&Tto the same object.as_ctx_ptr()is a blanket helper returningself's address as*mut Self, meant for handing the object to C callbacks that take avoid*ctx.bun_ptr::ScopedRefis a guard holding one ref and releasing it on drop;newtakes a fresh ref,adopttakes over a ref someone else already took.&selfargument is protected for the whole call, so a free is only allowed from a frame that holds the object as a raw pointer.Original description
Problem
NodeHTTPResponse(src/runtime/server/NodeHTTPResponse.rs) hand-rolled its intrusive refcount asderef(&self), which on zero calleddeinit(&self), which ended inThat frees the allocation through a pointer derived from
&self, while&selfis still a live argument of both thedeinitand thederefframe. Two things are wrong with it, independent of whether anything readsselfafterwards (nothing does today, so this is latent):as_ctx_ptr()isbun_ptr::AsCtxPtr's "address ofselfas*mut" helper; its own doc says the result has shared provenance and exists to fill C-shaped ctx slots. Deallocating through it is not something a&self-derived pointer can do.bun run rust:miriuses). blob: delete Blob::deinit, which freed the allocation through &mut self #37672 has a standalone reduction of exactly this shape.The comment on the hand-written
AnyRefCountedimpl already described converting the callers to a pointer-takingderefas a separate sweep; this is that sweep. The same shape inBlob::deinitis #37672, andReadBytesHandler::on_read_byteswas #37681. The tree-wide grep for a reclaim throughas_ctx_ptr()finds only this site.Fix
#[derive(bun_ptr::CellRefCounted)]with#[ref_count(destroy = Self::deinit)], the same arrangement asCronJob,NativeZlib,PostgresSQLConnectionandJSMySQLConnection. The derive suppliesref_(),unsafe fn deref(this: *mut Self)and theAnyRefCountedbridge, so the hand-written bridge and theref_/derefpair are deleted.deinitnow takesthis: *mut Self: the teardown runs through a scoped shared borrow, andheap::take(this)runs after that borrow ends, through the pointer the count was released on.&selfmethod call:on_node_http_request*(src/runtime/server/mod.rs) releases the server-handler ref through the*mut NodeHTTPResponseout-param it already holds, andBun__NodeHTTPRequest__onResolve/onRejectthrough the wrapper's m_ctx pointer (as_::<NodeHTTPResponse>()instead ofas_class_ref);on_auto_flush_trampolineadopts the task's ref as aScopedRefon the ctx pointer the deferred-task queue hands it, so the release happens afteron_auto_flush(&self)has returned;on_auto_flushno longer releases anything itself;ref_()/deref()keep-alive brackets incork,write_head_and_endandon_drain_corkedbecome aScopedRefguard (the last of these had three exits, each with its ownderef());&self(mark_request_as_done,handle_abort_or_timeout,on_data_or_aborted,unregister_auto_flush) callSelf::deref(self.as_ctx_ptr())explicitly, asPostgresSQLConnectionandwebsocket_clientdo. Those methods are reached from&selfhost functions and uws callbacks, so they have no better pointer to offer; the change here is that the frame that frees isderef/deinitholding the raw pointer, not a&selfmethod, and the shortcut is visible at each site instead of hidden inside a safederef(&self).finalizeusesfinalize_js_boxfor its pre-release work (clearingarmed_this_value), as the other derive users do.Refcount arithmetic is unchanged at every site: each
ref_()still has the same single release on the same paths, and the order of the teardown steps indeinitis the same. The only ordering change is the auto-flush release, which now happens when the trampoline's guard drops, immediately afteron_auto_flushreturns, instead of as its last statement.Test
test/internal/source-lints/self-receiver-reclaim.test.ts (the lint from #37681) now treats
self.as_ctx_ptr()andas_ctx_ptr(self)as spellings of the receiver, with positive and negative examples (self.field.as_ctx_ptr(), handing the pointer toon_data,Self::deref(self.as_ctx_ptr())and aScopedRefover it stay allowed). With this test change andmain'ssrc/, the lint reports exactlyand with the fix the tree is at zero. Per-type
as_ptr(&self) -> *mut Selfhelpers (for exampleFileResponseStream's) are deliberately not added:heap::take(x.as_ptr())is also how smart-pointer newtypes free their pointee, and none of them reclaims the receiver today.There is no behavioural reproducer; the bug is the shape of the free, not something observable before the allocator happens to reuse the memory.
Verification
On the debug (ASAN) build: test/js/node/http/{node-http,node-http-uaf,node-http-server-abort-events,node-http-nested-cork,node-http-pinned-write,node-http-backpressure,node-http-backpressure-max,node-http-server-timeouts,node-http-ondata-reregister-leak,node-http-req-socket-pause,node-http-server-socket-end-drain,node-http-connect,node-http-with-ws,node-http-transfer-encoding,node-http-res-settimeout-unref,node-http.compress.leak,node-http-parser,node-http-maxHeaderSize,node-http-syscall-fault,client-timeout-error,early-hints-crlf-injection,numeric-header}.test.ts, test/js/bun/http/{node-http-halfclose-midupload,request-smuggling}.test.ts, and 44 of the ported Node suites under test/js/node/test/parallel (
test-http-abort*,test-http-flush*,test-http-pipeline*,test-http-response-{close,cork,readable},test-http-server-close*,test-http-server-request-timeout*,test-http-upgrade*,test-http-set-timeout*, and a few others) all pass. These cover the abort, timeout, flushHeaders (auto-flush), onwritable drain, sync and async handler, upgrade, CONNECT and pipelining release paths. The one failure seen,request via http proxy, issue#4295in node-http.test.ts, fails identically on the unmodified release build in this container (listen(0, "localhost")binds::1while the client connects to127.0.0.1; the class #37442 describes) and is unrelated. Several of the node:http child-process tests need more than the default 5 s timeout on this debug build because importingnode:httpalone takes about 2.7 s here; they pass with--timeout 60000.bun test test/internal/source-lints/passes;cargo clippy -p bun_runtime --no-depsandcargo fmt -p bun_runtime -- --checkare clean.