Skip to content

redis: give SubscriptionCtx an explicit BackRef to fix the subscribe() refcount over-release - #35787

Open
robobun wants to merge 5 commits into
mainfrom
claude/farm/b292a388/valkey-subscribe-uaf
Open

redis: give SubscriptionCtx an explicit BackRef to fix the subscribe() refcount over-release#35787
robobun wants to merge 5 commits into
mainfrom
claude/farm/b292a388/valkey-subscribe-uaf

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Root cause

RedisClient.subscribe() releases exactly one more intrusive ref than it takes, which frees the Box<JSValkeyClient> under its still-live JS wrapper once the connection-timeout timer (or any other last legitimate holder) lets go. The crash surfaces as whichever path next touches the allocation: RefCountedTimer::state/disarm from stop_timers in GC finalize, bufferedAmount, the !ref_held asserts, etc., which is why the face kept moving across #34714 and #34760.

The missing increment is a codegen artifact of writing through a Freeze-typed shared reference:

bun_core::impl_field_parent! { SubscriptionCtx => JSValkeyClient._subscription_ctx; fn parent; }

SubscriptionCtx was three plain bools, so &SubscriptionCtx is a Freeze shared ref that rustc lowers as noalias readonly. parent() derives &JSValkeyClient from core::ptr::from_ref(self), and upsert_receive_handler's exit guard reaches on_new_subscription_callback_insertref_scope()RefCount::ref_ through it. At O2+ LLVM is entitled to treat the raw_count.set() store as dead (it's a write through a pointer derived from a readonly argument) while the paired ScopedRef drop survives as an opaque call to deref_with_context. Disassembly of the optimized build shows both predecessor blocks of upsert_receive_handler executing assert_single_threaded → compute &raw_count → overflow-probe get()+1no storecall on_writable.

Numbered trace on a healthy server (no faults):

  1. create → count=1 (the ref finalize later adopts)
  2. connect → +socket keep-alive + connection-timeout timer → 3
  3. subscribe → elided +1, kept −1 → 2 (wrapper's own ref is spent while this_value is still attached)
  4. close → on_valkey_close releases the socket ref → 1 (only the armed timer)
  5. timer fires → 0 → deinit frees the Box under the live wrapper
  6. GC finalize (or any earlier native read) → heap-use-after-free

The free stack is always the last legitimate release; the defect is a missing increment, which appears in no stack trace.

Fix

SubscriptionCtx now stores an explicit Option<BackRef<JSValkeyClient>> constructed from the heap::into_raw pointer in SubscriptionCtx::init, and the impl_field_parent! invocation is removed. All five parent() consumers (subscription-map lookup, the upsert_receive_handler exit guard, the invoke_callbacks poll-ref guard, is_deletable, close) read through the stored back-reference, so writes to ref_count/poll_ref/client carry the allocation's full provenance instead of a readonly-derived one. init is called from all three construction paths (create, Bun.redis's default-client accessor, duplicate()).

Tripwire

RefCount::ref_ re-reads the count via ptr::read_volatile under debug_assertions and asserts it advanced. On optimized builds with assertions enabled this converts the class of bug from "heap-UAF at a distance" into an assertion at the dropped store itself.

impl_field_parent! audit

child arm Freeze? writes through ref accessor? status
SubscriptionCtx ref-only yes yes (ref_scope, update_poll_ref) fixed here
ByteBlobLoader ref+mut yes yes (is_closed.set) fixed here (switched to the &mut self accessor)
ValkeyClient ref-only no (AutoFlusher.registered: Cell<bool>) yes (ref_, add_subscription, …) OK today; load-bearing on one Cell
MySQLConnection ref+mut no (MySQLRequestQueue Cell/JsCell) yes (ref_guard, stop_timers, …) OK today; load-bearing on queue
Assets ref+mut yes no (reads only) OK
CapturedWriter ref+mut yes no (reads only) OK
ByteStream ref+mut no yes OK
Execution nonnull n/a n/a OK by construction
SourceMapStore mut-only n/a n/a OK by construction
FileReader raw n/a n/a OK by construction

Added a safety note to the macro's docs so the next refactor of ValkeyClient/MySQLConnection doesn't silently reintroduce this.

Verification

Healthy-server subscribe isolation (shape A): connect → subscribe()close() → wait for the connection-timeout timer → Bun.gc(true) → touch bufferedAmount/connected, 40 clients per process. Added to test/js/valkey/valkey-gc.test.ts.

The defect is an LLVM dead-store at O2+ on a Cell::set reached through a noalias readonly argument, so it is only observable on a build that both (a) compiles the Rust side at O2 or higher and (b) instruments the heap so the post-free read faults. Neither of the mechanical fail-before configurations has both:

build Rust opt heap check result without fix
--profile=debug (ASAN) 0 yes store is emitted, refcount stays balanced
--profile=release 3 + fat LTO no Box is freed early, but mimalloc does not poison and the property reads return the stale values
--profile=release-asan 3 yes heap-use-after-free

Disassembly of the optimized build shows both predecessor blocks of upsert_receive_handler reaching assert_single_threaded → compute &raw_count → overflow-probe get()+1 → no store → call on_writable; the same site in the dev build is an out-of-line call ref_ that executes the store. The added RefCount::ref_ volatile tripwire turns the elision into an assertion on any O2+debug-assertions build.

Locally against the debug build: 9/9 in valkey-gc.test.ts, body.test.ts 348/348, serve.test.ts unchanged from main.


[review] gate passed · iteration 0 · 8 files touched

fails on main (without fix)
ASAN without fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/valkey/valkey-gc.test.ts
bun test v1.4.0 (393298281)

test/js/valkey/valkey-gc.test.ts:
(pass) RedisClient survives a failed custom-TLS context without freeing the live client [398.38ms]
(pass) RedisClient survives GC after a command throws during argument validation [482.46ms]
(pass) custom setter with a foreign receiver throws instead of corrupting the heap [383.40ms]
(pass) RedisClient survives GC across many short-lived instances [717.57ms]
(pass) rejects a RESP simple-string reply whose line terminator never arrives [576.52ms]
(pass) RedisClient survives connection-timeout + reconnect churn against an under-replying server [1349.30ms]
(pass) RedisClient survives subscribe() + close() against a healthy server across connection-timeout + GC [1782.91ms]
(pass) RedisClient survives subscribe() + close() against a server that resets the connection [3039.70ms]
(pass) getBuffer replies survive GC with adopted backing stores intact [2310.95ms]

 9 pass
 0 fail
 26 expect() calls
Ran 9 tests across 1 file. [8.54s]
__F:0:S:0

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/valkey/valkey-gc.test.ts:
(pass) custom setter with a foreign receiver throws instead of corrupting the heap [44.69ms]
(pass) RedisClient survives GC across many short-lived instances [43.65ms]
(pass) RedisClient survives GC after a command throws during argument validation [45.35ms]
(pass) RedisClient survives a failed custom-TLS context without freeing the live client [50.67ms]
439 |       });
440 |       try {
441 |         await client.send("PING", []);
442 |         expect.unreachable();
443 |       } catch (error: any) {
444 |         expect(error.code).toBe("ERR_REDIS_CONNECTION_CLOSED");
                                 ^
error: expect(received).toBe(expected)

Expected: "ERR_REDIS_CONNECTION_CLOSED"
Received: "ERR_REDIS_INVALID_RESPONSE"

      at <anonymous> (/workspace/bun/test/js/valkey/valkey-gc.test.ts:444:28)
(fail) rejects a RESP simple-string reply whose line terminator never arrives [55.06ms]
(pass) getBuffer replies survive GC with adopted backing stores intact [61.48ms]
(pass) RedisClient survives subscribe() + close() against a server that resets the connection [466.50ms]
(pass) RedisClient survives 
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/valkey/valkey-gc.test.ts
bun test v1.4.0 (393298281)

test/js/valkey/valkey-gc.test.ts:
(pass) RedisClient survives a failed custom-TLS context without freeing the live client [428.74ms]
(pass) RedisClient survives GC after a command throws during argument validation [500.53ms]
(pass) custom setter with a foreign receiver throws instead of corrupting the heap [406.66ms]
(pass) RedisClient survives GC across many short-lived instances [687.95ms]
(pass) rejects a RESP simple-string reply whose line terminator never arrives [710.41ms]
(pass) RedisClient survives connection-timeout + reconnect churn against an under-replying server [1705.80ms]
(pass) RedisClient survives subscribe() + close() against a healthy server across connection-timeout + GC [1808.50ms]
(pass) RedisClient survives subscribe() + close() against a server that resets the connection [2676.81ms]
(pass) getBuffer replies survive GC with adopted backing stores intact [2445.49ms]

 9 pass
 0 fail
 26 expect() calls
Ran 9 tests across 1 file. [6.45s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     3932982819
  features     baseline

22 deps, 108 codegen, 1171 objects in 4608ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[2/1234] gen ErrorCode+*.h
[3/1234] gen bindgenv2
[4/1234] gen ProcessBindingBuffer.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingBuffer.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingBuffer.cpp
[5/1234] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[6/1234] fetch zlib
[zlib] up to date
[7/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[8/1234] fetch tinycc
[tinycc] up to date
[9/1234] fetch picohttpparser
[picohttpparser] up to date
[10/1234] gen .bind.ts → GeneratedBindings.cpp
[11/1234] gen ProcessBindingFs.lut.h
Generating /workspace/bun/bu
... (truncated)
diff hotspot
src/bun_core/lib.rs                           |  6 +-
 src/ptr/ref_count.rs                          | 12 +++-
 src/runtime/api/BunObject.rs                  |  4 +-
 src/runtime/valkey_jsc/js_valkey.rs           | 81 +++++++++++++------------
 src/runtime/valkey_jsc/js_valkey_functions.rs |  4 +-
 src/runtime/webcore/ByteBlobLoader.rs         |  5 +-
 src/runtime/webcore/ReadableStream.rs         |  5 +-
 test/js/valkey/valkey-gc.test.ts              | 87 +++++++++++++++++++++++++++
 8 files changed, 158 insertions(+), 46 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                           reads  edits  tests
src/bun_core/lib.rs                                2      2      0
src/ptr/ref_count.rs                               2      2      0
src/runtime/api/BunObject.rs                       2      2      0
src/runtime/valkey_jsc/js_valkey.rs                9     11      0
src/runtime/valkey_jsc/js_valkey_functions.rs      2      2      0
src/runtime/webcore/ByteBlobLoader.rs              2      1      0
src/runtime/webcore/ReadableStream.rs              1      1      0
test/js/valkey/valkey-gc.test.ts                   3      5      0

…ibe() refcount over-release

SubscriptionCtx previously recovered its owning JSValkeyClient via
impl_field_parent! on &self. SubscriptionCtx is three plain bools (a
Freeze type), so the &self argument is lowered as noalias readonly and
its provenance covers only those three bytes. The refcount .set() in
on_new_subscription_callback_insert (reached through that pointer) is a
store LLVM may discard at O2+, while the paired ScopedRef drop survives
as an opaque call. Net: each subscribe() releases one more ref than it
takes; after close() + connection-timeout the last legitimate release
frees the Box under the live JS wrapper.

SubscriptionCtx now holds an Option<BackRef<JSValkeyClient>> built from
the heap::into_raw pointer at init time, so every parent access carries
the allocation's full provenance.

Also:
- RefCount::ref_ re-reads the count via a volatile load under
  debug_assertions and asserts it advanced, so this bug class trips on
  release-assertions/release-asan lanes at the store site instead of
  surfacing as a heap-use-after-free on whichever releaser runs last.
- ByteBlobLoader::to_any_blob now reaches is_closed via the &mut-self
  accessor (same audit finding; ByteBlobLoader is Freeze).
- impl_field_parent! docs warn that the ref-only form is read-only when
  the child is Freeze.
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Status: diff ready; needs a maintainer merge on the strength of the analysis. All review threads resolved, clippy/miri/format/lint green. No valkey/refcount/stream failures on any lane.

Reproduced how: connect → subscribe()close() → connection-timeout fire → GC → property read. The defect is an O2+ dead-store on the raw_count.set() reached through noalias readonly &SubscriptionCtx, so it only faults on a build that is both optimized and heap-instrumented (release-asan). The mechanical fail-before check runs --profile=debug (opt-level 0, store emitted) and --profile=release (optimized but mimalloc does not poison), so neither observes it; the Verification section in the PR body has the build-config table and the disassembly evidence. The added RefCount::ref_ volatile tripwire converts the class of bug into an assertion on any O2+debug-assertions build.

CI: builds #81369 and #81477 both hit step-failed-outside-runner on multiple build-bun jobs whose sibling build-cpp job expired before an agent picked it up (darwin-aarch64/x64, linux-aarch64-musl/android, linux-x64-musl on #81477). All are marked pre-existing on main. The one test-level result is webview-chrome.test.ts flagged flaky (passed on retry). None of this touches the diff.

PR: #35787

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:21 PM PT - Jul 25th, 2026

@robobun, your commit 3767356 has 1 failures in Build #81477 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35787

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

bun-35787 --bun

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The PR replaces recovered subscription-context parent references with explicit BackRef<JSValkeyClient> storage, updates initialization call sites, documents pointer provenance and mutation rules, strengthens debug refcount checks, and adds a GC regression test for subscribe/close lifetimes.

Subscription context ownership

Layer / File(s) Summary
Pointer provenance and mutation contracts
src/bun_core/lib.rs, src/ptr/ref_count.rs, src/runtime/webcore/...
Macro safety documentation, refcount debug verification, and mutable parent access documentation are updated.
Stored subscription parent back-reference
src/runtime/valkey_jsc/js_valkey.rs
SubscriptionCtx stores a BackRef, initializes it from a raw client pointer, and uses it for callback scope guards.
Client wiring and lifetime regression coverage
src/runtime/api/BunObject.rs, src/runtime/valkey_jsc/..., test/js/valkey/valkey-gc.test.ts
Creation, default-client setup, and duplication pass raw pointers to SubscriptionCtx::init; lifecycle comments and GC coverage are updated.

Possibly related PRs

  • oven-sh/bun#34714: Updates related Valkey client refcount guards around connect, close, and subscribe paths.
  • oven-sh/bun#34829: Refactors SubscriptionCtx construction and initialization in the same runtime area.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main change: adding an explicit BackRef to fix the Redis subscribe() refcount issue.
Description check ✅ Passed It describes the bug, the fix, and how it was verified with tests and builds, covering the required PR template content.

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

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/valkey/valkey-gc.test.ts`:
- Around line 140-142: Update the regression tests around the healthy-server
subscribe/close/GC flow and the cases at the referenced sections so they run the
reproducer with an optimized ASAN binary rather than bunExe()’s debug build.
Replace timing-only waits with an awaited observable timeout or close condition,
and assert that condition so the test deterministically exercises the pre-fix
failure and verifies every required timeout callback completes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d41331c5-09b0-495e-aac3-fa0b335be0ea

📥 Commits

Reviewing files that changed from the base of the PR and between 04bb5c4 and 59e3841.

📒 Files selected for processing (8)
  • src/bun_core/lib.rs
  • src/ptr/ref_count.rs
  • src/runtime/api/BunObject.rs
  • src/runtime/valkey_jsc/js_valkey.rs
  • src/runtime/valkey_jsc/js_valkey_functions.rs
  • src/runtime/webcore/ByteBlobLoader.rs
  • src/runtime/webcore/ReadableStream.rs
  • test/js/valkey/valkey-gc.test.ts

Comment thread test/js/valkey/valkey-gc.test.ts
@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Segmentation fault at address 0x8 (After ~2 hours) #21002 - Segfault at address 0x8 after ~2 hours of Redis usage; crash occurs in JSC GC (WeakSet::forEachBlock, Heap::runCurrentPhase), consistent with GC sweeping a freed JSValkeyClient due to the refcount over-release
  2. node-redis Pub/Sub silently fails to reconnect in rare cases #21622 - node-redis Pub/Sub silently enters non-functional limbo state after network failure (Bun-only, never on Node.js); corrupted internal state from a use-after-free in the subscribe refcount path could explain the rare, non-deterministic silent failure

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #21002
Fixes #21622

🤖 Generated with Claude Code

Comment thread src/bun_core/lib.rs Outdated
Comment thread src/ptr/ref_count.rs Outdated
Comment thread src/runtime/valkey_jsc/js_valkey.rs Outdated
Comment thread src/runtime/valkey_jsc/js_valkey.rs
Comment thread src/runtime/valkey_jsc/js_valkey.rs
Comment thread src/runtime/valkey_jsc/js_valkey.rs
Comment thread src/runtime/webcore/ReadableStream.rs
Comment thread src/bun_core/lib.rs
Comment thread src/ptr/ref_count.rs
Comment thread src/runtime/valkey_jsc/js_valkey.rs
Comment thread test/js/valkey/valkey-gc.test.ts Outdated

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

Thanks for fixing the test-comment placement in 3932982. I didn't find any bugs, but this is a subtle memory-safety fix (readonly-provenance dead-store elimination in the intrusive refcount path) touching RefCount::ref_, the impl_field_parent! contract, Valkey client lifecycle, and ByteBlobLoader — worth a human look, especially on the audit table's "load-bearing on one Cell" entries for ValkeyClient/MySQLConnection.

What was reviewed:

  • All three SubscriptionCtx::init call sites (create, get_valkey_default_client, duplicate) pass the fresh heap::into_raw pointer; #[derive(Default)] still holds via Option<BackRef>.
  • parent_backref()/parent() cover all five former impl_field_parent! consumers; BackRef: Copy so the struct-literal move + subsequent reads in init are sound.
  • ByteBlobLoader::to_any_blob already had &mut self, so the switch to the raw-mut parent() accessor is available; ByteStream::to_any_blob (which takes &self) was left alone because ByteStream is non-Freeze.
  • The volatile tripwire in RefCount::ref_ is debug_assertions-gated and reads a live Cell pointer.
Extended reasoning...

Overview

Fixes a heap-UAF in RedisClient.subscribe() where SubscriptionCtx (three plain bools → Freeze) recovered &JSValkeyClient via impl_field_parent! container-of on a noalias readonly &self. At O2+ LLVM elides the raw_count.set() store reached through that pointer while the paired ScopedRef drop (opaque call) survives, netting −1 on the intrusive refcount per subscribe(). The fix stores an explicit BackRef<JSValkeyClient> on SubscriptionCtx constructed from the heap::into_raw pointer (full-allocation provenance), removes the macro invocation, and updates all three construction sites plus five consumers. A parallel fix in ByteBlobLoader::to_any_blob switches from parent_const() to the &mut self raw-mut accessor. A debug_assertions volatile-re-read tripwire is added to RefCount::ref_, and the macro docs gain a provenance note.

Security risks

None new. This closes a UAF reachable from user JS (subscribe()), which is a security-relevant improvement. No new attack surface.

Level of scrutiny

High. This is a pointer-provenance / optimizer-interaction bug in intrusive refcounting — the most-blocked category in this repo's review guidelines. The root-cause analysis (disassembly-verified dead-store, numbered refcount trace) is thorough, but the fix touches shared infrastructure (RefCount::ref_, impl_field_parent! docs) and the audit table explicitly flags two other users (ValkeyClient, MySQLConnection) as correct only because they happen to contain a Cell field today. A maintainer should decide whether that's acceptable or whether those need the same treatment now.

Other factors

  • The new test only reproduces on optimized-ASAN lanes (opt-level 0 doesn't elide the store); the PR description and test comment both state this, and the release-build evidence in the PR body shows a pre-fix failure (albeit on a different test in the file, which is a bit odd — the healthy-server test itself shows as passing on release-without-fix in the truncated output, though the description says it's the ASAN lane that catches it).
  • All comment-cop and CodeRabbit threads are resolved; my prior placement nit was fixed.
  • The unsafe fn init signature change with its # Safety doc is appropriate given clippy's not_unsafe_ptr_arg_deref.
  • ByteBlobLoader change is defensible independent of whether the specific store was actually being elided today — it removes a same-class hazard.

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

No bugs found, and the test-comment placement I flagged earlier is fixed in 3932982. Deferring to a human reviewer: this is a memory-safety fix in intrusive refcounting with a subtle optimizer-provenance root cause, and the audit table names two other impl_field_parent! users (ValkeyClient, MySQLConnection) that are one Cell-removal away from the same fault — worth a human deciding whether that's acceptable to leave.

What was reviewed:

  • SubscriptionCtx::init now takes the raw heap::into_raw pointer at all three construction sites (create, Bun.redis default, duplicate); BackRef is Copy so the struct-literal field ordering (parent: Some(parent) before later parent.client.get() reads) is fine.
  • The removed impl_field_parent! invocation had no other consumers; all five parent() uses now go through the stored back-ref.
  • ByteBlobLoader::to_any_blob switch to the &mut self accessor writes a disjoint field (is_closed), and ByteStream::to_any_blob (the other writer named in the is_closed doc comment) already goes through a non-Freeze child so is unaffected.
  • The RefCount::ref_ volatile re-read tripwire is #[cfg(debug_assertions)]-gated and reads through the same live count reference.
Extended reasoning...

Overview

Fixes a heap-use-after-free in RedisClient.subscribe() where the intrusive refcount's .set() store was elided at O2+ because it was reached through a pointer derived from a Freeze shared-ref (&SubscriptionCtx, three plain bools) lowered as noalias readonly. The fix stores an explicit BackRef<JSValkeyClient> on SubscriptionCtx (constructed from the full-provenance heap::into_raw pointer) instead of recovering the parent via container_of. Also: a debug-only volatile-re-read tripwire in RefCount::ref_, a doc warning on the impl_field_parent! macro, a same-class fix in ByteBlobLoader::to_any_blob, and a new GC regression test.

Security risks

None in the traditional sense — this is internal lifetime bookkeeping. The pre-fix state is a UAF (memory safety), which the PR closes on the SubscriptionCtx and ByteBlobLoader paths.

Level of scrutiny

High. This is exactly the "memory safety (the most-blocked category)" bucket in REVIEW.md: intrusive refcounts, pointer provenance, and an optimizer-dependent fault. The root-cause claim — that LLVM drops a Cell::set reached through a readonly-provenance pointer while the paired ScopedRef drop survives as an opaque call — is plausible and well-argued (with disassembly cited), but it's the kind of theory a maintainer should sign off on. The audit table also documents that ValkeyClient::parent() and MySQLConnection are "OK today; load-bearing on one Cell" — i.e., they avoid this bug only because an incidental interior-mutable field makes the child non-Freeze. A human should decide whether that latent risk is acceptable or whether those should also move to stored back-refs now.

Other factors

  • My earlier inline comment (RST-race test comment glued to the wrong test) was addressed in 3932982; verified the comment now sits above its own test at line 212.
  • All CodeRabbit and comment-cop threads are resolved; the remaining doc comments are legitimate (# Safety on unsafe fn, macro invariant docs).
  • The new test only reproduces on optimized-ASAN lanes (documented in the test header and PR body); this matches how the rest of valkey-gc.test.ts works and is a property of the bug, not the test.
  • SubscriptionCtx gained a field but finalize()'s "nothing to release" comment was correctly updated: BackRef is non-owning.
  • CI (#81369) is still building at the time of this review.

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.

2 participants