Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/bun_core/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -723,7 +723,7 @@ macro_rules! from_field_ptr {
/// bun_core::impl_field_parent! { Assets => DevServer.assets; pub fn owner; fn owner_mut; }
///
/// // (2) ref-only (&self -> &P)
/// bun_core::impl_field_parent! { SubscriptionCtx => JSValkeyClient._subscription_ctx; fn parent; }
/// bun_core::impl_field_parent! { ValkeyClient => JSValkeyClient.client; fn parent; }
///
/// // (3) mut-only (&mut self -> *mut P)
/// bun_core::impl_field_parent! { DirectoryWatchStore => DevServer.directory_watchers; fn mut owner; }
Expand All @@ -745,6 +745,14 @@ macro_rules! from_field_ptr {
/// `$Parent.$field` for its entire lifetime. If `$Child` can exist
/// standalone, the generated accessors are unsound; keep a hand-rolled
/// `pub unsafe fn` instead.
///
/// The ref-only form derives `&$Parent` from `core::ptr::from_ref(self)`, so
/// its provenance is that of `&$Child`. Use it for **reads only**: if `$Child`
/// is `Freeze` the argument is `noalias readonly` and a write to any parent
/// field through the result is UB the optimizer will exploit. When the parent
/// must be written through, store an explicit `BackRef<$Parent>` on the child
/// (constructed from the allocation's raw pointer) instead of using this
/// macro.
Comment thread
robobun marked this conversation as resolved.
Outdated
#[macro_export]
macro_rules! impl_field_parent {
// ref + raw-mut pair
Expand Down
18 changes: 17 additions & 1 deletion src/ptr/ref_count.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,23 @@ impl<T: RefCounted> RefCount<T> {
dump_stack_hook(None, return_address());
}
count.assert_single_threaded();
count.raw_count.set(count.raw_count.get() + 1);
let next = count.raw_count.get() + 1;
count.raw_count.set(next);
// Tripwire for readonly-provenance stores: if `self_` was derived from
// a `&T` whose provenance does not cover `ref_count` (e.g. container_of
// from a Freeze child field), LLVM may elide this store at O2+ while
// the paired `deref` on the opaque side survives, silently dropping a
// ref. Re-reading via a volatile load defeats forwarding so the assert
// observes the actual cell contents.
Comment thread
robobun marked this conversation as resolved.
Outdated
#[cfg(debug_assertions)]
debug_assert_eq!(
// SAFETY: `count` is live per the caller contract; `Cell<u32>` is
// `repr(transparent)` over `UnsafeCell<u32>`, so reading the
// underlying `u32` at this address is valid.
unsafe { core::ptr::read_volatile(count.raw_count.as_ptr()) },
next,
"RefCount::ref_ store elided (pointer derived from readonly provenance?)",
);
}

/// # Safety
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/api/BunObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1958,7 +1958,7 @@ pub(crate) fn get_valkey_default_client(global_this: &JSGlobalObject, _: &JSObje
// hold the only reference for field init below.
let valkey_ref = unsafe { &*valkey };
valkey_ref.this_value.set(jsc::JsRef::init_weak(as_js));
match SubscriptionCtx::init(valkey_ref) {
match SubscriptionCtx::init(valkey) {
Ok(ctx) => valkey_ref._subscription_ctx.set(ctx),
Err(jsc::JsError::Thrown) | Err(jsc::JsError::Terminated) => return JSValue::ZERO,
Err(err) => {
Expand Down
87 changes: 47 additions & 40 deletions src/runtime/valkey_jsc/js_valkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@

#[derive(Default)]
pub struct SubscriptionCtx {
/// Back-reference to the owning client. Constructed from the Box's raw
/// `*mut JSValkeyClient` so reads through it carry the allocation's full
/// provenance; the previous `container_of` recovery from `&self` (three
/// `bool`s, a Freeze type) produced a pointer LLVM may mark
/// `noalias readonly`, making the refcount write in
/// `on_new_subscription_callback_insert` a dead store at O2+ while the
/// paired `ScopedRef` drop survived as an opaque call. `None` only between
/// `JSValkeyClient::new` and `SubscriptionCtx::init`.
Comment thread
robobun marked this conversation as resolved.
Outdated
parent: Option<BackRef<JSValkeyClient>>,
pub is_subscriber: bool,
pub original_enable_offline_queue: bool,
pub original_enable_auto_pipelining: bool,
Expand All @@ -76,36 +85,41 @@
/// free-fns plus `to_js`/`from_js`. Re-exported here as `Js`.
pub use crate::generated_classes::js_RedisClient as Js;

// SAFETY: `SubscriptionCtx` lives at `JSValkeyClient._subscription_ctx`
// (intrusive backref). `JsCell<SubscriptionCtx>` is `#[repr(transparent)]`.
bun_core::impl_field_parent! { SubscriptionCtx => JSValkeyClient._subscription_ctx; fn parent; }

impl SubscriptionCtx {
pub fn init(valkey_parent: &JSValkeyClient) -> JsResult<Self> {
let callback_map = JSMap::create(&valkey_parent.global_object);
let parent_this = valkey_parent
.this_value
.get()
.try_get()
.expect("unreachable");

Js::subscription_callback_map_set_cached(
parent_this,
&valkey_parent.global_object,
callback_map,
);
/// `valkey_parent` must be the `heap::into_raw` pointer for the owning
/// `JSValkeyClient` (full-allocation provenance), not a pointer derived
/// from a narrower borrow.
pub fn init(valkey_parent: *mut JSValkeyClient) -> JsResult<Self> {
// SAFETY: freshly heap-allocated by `JSValkeyClient::new`; caller
// owns the +1 and has not yet handed it to any path that could free
// it.
let parent = unsafe { BackRef::from_raw(valkey_parent) };

Check failure on line 96 in src/runtime/valkey_jsc/js_valkey.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

this public function might dereference a raw pointer but is not marked `unsafe`
let callback_map = JSMap::create(&parent.global_object);
let parent_this = parent.this_value.get().try_get().expect("unreachable");

Js::subscription_callback_map_set_cached(parent_this, &parent.global_object, callback_map);

Ok(SubscriptionCtx {
original_enable_offline_queue: valkey_parent.client.get().flags.enable_offline_queue,
original_enable_auto_pipelining: valkey_parent
.client
.get()
.flags
.enable_auto_pipelining,
parent: Some(parent),
original_enable_offline_queue: parent.client.get().flags.enable_offline_queue,
original_enable_auto_pipelining: parent.client.get().flags.enable_auto_pipelining,
is_subscriber: false,
})
}

#[inline]
fn parent(&self) -> &JSValkeyClient {
self.parent
.as_ref()
.expect("SubscriptionCtx used before init()")
.get()
}

#[inline]
fn parent_backref(&self) -> BackRef<JSValkeyClient> {
self.parent.expect("SubscriptionCtx used before init()")
}

fn subscription_callback_map(&self) -> &mut JSMap {
let parent_this = self
.parent()
Expand Down Expand Up @@ -202,10 +216,7 @@
channel_name: JSValue,
callback: JSValue,
) -> JsResult<()> {
// `BackRef` (Copy + Deref) detaches the borrow so the guard closure is
// safe even though intervening JS may re-enter `&self`.
let parent_br = BackRef::new(self.parent());
let _guard = scopeguard::guard(parent_br, |p| {
let _guard = scopeguard::guard(self.parent_backref(), |p| {
p.on_new_subscription_callback_insert();
});
let map = self.subscription_callback_map();
Expand Down Expand Up @@ -283,10 +294,7 @@

// After we go through every single callback, we will have to update the poll ref.
// The user may, for example, unsubscribe in the callbacks, or even stop the client.
// `BackRef` (Copy + Deref) detaches the borrow so the guard closure is
// safe even though intervening JS may re-enter `&self`.
let parent_br = BackRef::new(self.parent());
let _update = scopeguard::guard(parent_br, |p| p.update_poll_ref());
let _update = scopeguard::guard(self.parent_backref(), |p| p.update_poll_ref());

// If callbacks is an array, iterate and call each one
let mut iter = callbacks.array_iterator(global_object)?;
Expand Down Expand Up @@ -835,7 +843,7 @@
// Need to associate the subscription context, after the JS ref has been populated.
new_client
._subscription_ctx
.set(SubscriptionCtx::init(new_client)?);
.set(SubscriptionCtx::init(new_client_ptr)?);

Ok(new_client_ptr)
}
Expand Down Expand Up @@ -1437,9 +1445,8 @@
self.ref_();
// socket close can potentially call JS so we need to enqueue the deinit
struct Holder {
// BACKREF — JSValkeyClient is intrusively ref-counted (RefCount + @fieldParentPtr
// recovery in SubscriptionCtx::parent). The `self.ref_()` above / `(*ctx).deref()`
// in run() keep it alive across the task hop.
// The `self.ref_()` above / `(*ctx).deref()` in run() keep the
// intrusively ref-counted client alive across the task hop.
Comment thread
robobun marked this conversation as resolved.
ctx: *const JSValkeyClient,
task: jsc::AnyTask::AnyTask,
}
Expand Down Expand Up @@ -1490,11 +1497,11 @@
this.this_value.with_mut(|t| t.finalize());
this.client_mut().flags.finalized = true;
this.close_socket_next_tick();
// `_subscription_ctx` is three inline bools (no allocation, no GC
// ref); `is_subscriber` can legitimately still be set here if the
// server never confirmed UNSUBSCRIBE before disconnect, since
// `update_poll_ref()` gates on the JS handler map, not this flag.
// Nothing to release.
// `_subscription_ctx` owns no allocation and no GC ref (the backref is
// non-owning, the rest are inline bools); `is_subscriber` can
// legitimately still be set here if the server never confirmed
// UNSUBSCRIBE before disconnect, since `update_poll_ref()` gates on
// the JS handler map, not this flag. Nothing to release.
Comment thread
robobun marked this conversation as resolved.
}

pub fn stop_timers(&self) {
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/valkey_jsc/js_valkey_functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1912,7 +1912,7 @@ impl JSValkeyClient {
new_client.this_value.set(JsRef::init_weak(new_client_js));
new_client
._subscription_ctx
.set(SubscriptionCtx::init(new_client)?);
.set(SubscriptionCtx::init(new_client_ptr)?);
// If the original client is already connected and not manually closed, start connecting the new client.
if this.client.get().status == valkey::Status::Connected
&& !this.client.get().flags.is_manually_closed
Expand Down
5 changes: 4 additions & 1 deletion src/runtime/webcore/ByteBlobLoader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,10 @@ impl ByteBlobLoader {
blob.content_type.set(ct);
}

self.parent_const().is_closed.set(true);
// SAFETY: `impl_field_parent!` contract; `is_closed` is disjoint from
// `context`. Reached via the `&mut self` accessor (not `parent_const`)
// so the store is not routed through a Freeze-`&self` readonly pointer.
unsafe { (*self.parent()).is_closed.set(true) };
Some(blob::Any::Blob(blob))
}

Expand Down
5 changes: 3 additions & 2 deletions src/runtime/webcore/ReadableStream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -665,8 +665,9 @@ pub struct NewSource<C: SourceContext> {
/// `Finalized` so [`Self::on_js_close`] reads `None` instead of a
/// dead-but-unswept cell.
pub this_jsvalue: jsc::JsRef,
/// R-2: written by `&self` context methods (`ByteStream::to_any_blob`,
/// `ByteBlobLoader::to_any_blob`) via `parent_const()`, so interior-mutable.
/// R-2: written by context methods (`ByteStream::to_any_blob`,
/// `ByteBlobLoader::to_any_blob`) via `impl_field_parent!` recovery, so
/// interior-mutable.
Comment thread
robobun marked this conversation as resolved.
pub is_closed: Cell<bool>,
}

Expand Down
87 changes: 87 additions & 0 deletions test/js/valkey/valkey-gc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,96 @@

// Fuzzer found the same over-release reachable from subscribe() when the
// socket dies mid-call: upsert_receive_handler's exit guard re-enters
// on_writable/update_poll_ref before send() takes its own ref, so a
// connect/close fault path inside could free the client under the live
// `&self`. This variant races a server-side RST against subscribe()+close().
// subscribe() used to release one more intrusive ref than it took:
// SubscriptionCtx.parent() recovered &JSValkeyClient via container_of on
// &SubscriptionCtx (three plain bools, a Freeze type). The shared-ref argument
// is lowered as noalias readonly, so at O2+ LLVM may drop the refcount .set()
// reached through it while the paired ScopedRef drop (an opaque call)
// survives. After close() releases the socket ref, only the connection-timeout
// timer holds the allocation; when it fires the count reaches zero and deinit
// frees the Box under the still-live JS wrapper. The next GC finalize (or any
// property read) is a heap-use-after-free.
//
// Debug cargo builds are opt-level=0 and do not perform this elision, so this
// test exercises the fault only on optimized ASAN builds. It still asserts the
// healthy-server subscribe/close/GC path stays balanced everywhere.
Comment thread
robobun marked this conversation as resolved.
test.concurrent(
"RedisClient survives subscribe() + close() against a healthy server across connection-timeout + GC",
async () => {

Check warning on line 145 in test/js/valkey/valkey-gc.test.ts

View check run for this annotation

Claude / Claude Code Review

New test inserted between an existing comment and its test, orphaning the comment

The new healthy-server test and its comment block are inserted immediately after line 129 ("This variant races a server-side RST against subscribe()+close().") with no separating blank line, so the pre-existing 5-line RST comment now reads as part of the healthy-server test's header — while the actual RST test at ~line 217 is left with no adjacent descriptive comment. Move the new test (and its comment) to *before* the "Fuzzer found the same over-release reachable from subscribe()..." block, or
Comment thread
robobun marked this conversation as resolved.
Outdated
const src = `
const CRLF = "\\r\\n";
const blk = s => "$" + s.length + CRLF + s + CRLF;
const server = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: {
open(s) { s.data = { buf: "" }; },
data(s, d) {
s.data.buf += d.toString("latin1");
if (s.data.buf.includes("HELLO")) {
s.write("%1" + CRLF + blk("proto") + ":3" + CRLF);
s.data.buf = "";
}
},
close() {},
},
});
const url = "redis://127.0.0.1:" + server.port;
const timeoutMs = 1000;
const clients = [];
for (let i = 0; i < 40; i++) {
const c = new Bun.RedisClient(url, {
connectionTimeout: timeoutMs,
idleTimeout: 0,
autoReconnect: false,
});
c.onconnect = () => {}; c.onclose = () => {};
await c.connect();
// upsert_receive_handler runs synchronously here; the server never
// replies to SUBSCRIBE so the returned promise is discarded.
try { c.subscribe("ch" + i, () => {}).catch(() => {}); } catch {}
c.close();
clients.push(c);
}
// Let every client's connection-timeout timer (armed at connect()) fire.
// This is the condition under test, not an arbitrary wait.
const { promise, resolve } = Promise.withResolvers();
setTimeout(resolve, timeoutMs + 200);
await promise;
Bun.gc(true);
for (const c of clients) {
// Any native read on the wrapper is a heap-UAF once the backing Box is
// freed under it.
if (typeof c.bufferedAmount !== "number") throw new Error("bufferedAmount");
if (c.connected !== false) throw new Error("connected");
}
clients.length = 0;
server.stop(true);
Bun.gc(true);
await 1;
Bun.gc(true);
console.log("OK");
process.exit(0);
`;

await using proc = Bun.spawn({
cmd: [bunExe(), "-e", src],
env: bunEnv,
stdout: "pipe",
stderr: "inherit",
});

const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);

expect(stdout.trim()).toBe("OK");
expect(proc.signalCode).toBeNull();
expect(exitCode).toBe(0);
},
);

test.concurrent("RedisClient survives subscribe() + close() against a server that resets the connection", async () => {
const src = `
const CRLF = "\\r\\n";
Expand Down
Loading