redis: encapsulate EventLoopTimer keep-alive refs in RefCountedTimer
Follow-up to #34714, which fixed the TLS-context over-release path but left the timer path with an unconditional ScopedRef::adopt.
Problem
The fuzzer still produces heap-use-after-free on 99fc2f892c23: on_connection_timeout's ScopedRef::adopt drops as the final deref, freeing the Box<JSValkeyClient> while the JS wrapper and the other armed EventLoopTimer still point at it; GC finalize then reaches stop_timers on freed memory.
freed by:
ScopedRef::<JSValkeyClient>::drop
JSValkeyClient::on_connection_timeout js_valkey.rs:1195
...
accessed by:
<State as PartialEq>::eq
JSValkeyClient::stop_timers
JSValkeyClient::finalize
The fire callbacks adopt what they assume is add_timer's +1 unconditionally. When the close/reconnect/fail paths re-enter each other and the timer's ref has already been released (or was never taken for that arm), the adopt spends a phantom ref and the count reaches zero under live references.
Fix
Wrap each timer in a RefCountedTimer that tracks whether its keep-alive ref is currently held:
arm()inserts into the timer heap andref_()s the owner iffref_heldwas false.disarm()unlinks andderef()s iffref_heldwas true.take_fire_ref()marks the timerFIREDand returnsSome(ScopedRef::adopt(..))iffref_heldwas true, otherwiseNone.
So a fire (or stop) path can never release a ref that was not taken, regardless of how the connect/close/timeout callbacks interleave. deinit() asserts both flags are clear at count==0. The wrapper is #[repr(C)] with the EventLoopTimer first so the from_field_ptr! container-of recovery in dispatch.rs is unchanged (offset_of const-asserted).
This also folds away the open-coded state == ACTIVE checks and the separate remove-then-add at every call site, so the net diff is -115/+188 with most of the addition being the wrapper itself.
Verification
$ bun bd test test/js/valkey/valkey-gc.test.ts
8 pass
0 fail
The new test ("connection-timeout + reconnect churn against an under-replying server") exercises the fire/disarm/re-arm interleaving under ASAN but does not reproduce the fuzzer crash deterministically; I could not construct a local sequence that over-releases on the parent commit. The fix is structural: the flag makes phantom release impossible by construction, so fuzzer re-verification against this branch is the intended proof.
[review] gate passed · iteration 0 · 2 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
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (cddda957a)
test/js/valkey/valkey-gc.test.ts:
(pass) RedisClient survives a failed custom-TLS context without freeing the live client [522.76ms]
(pass) custom setter with a foreign receiver throws instead of corrupting the heap [493.89ms]
(pass) RedisClient survives GC after a command throws during argument validation [640.52ms]
(pass) rejects a RESP simple-string reply whose line terminator never arrives [497.23ms]
(pass) RedisClient survives GC across many short-lived instances [738.72ms]
(pass) RedisClient survives connection-timeout + reconnect churn against an under-replying server [1406.40ms]
(pass) RedisClient survives subscribe() + close() against a server that resets the connection [2589.52ms]
(pass) getBuffer replies surviv
... (truncated)
release without fix: 1 FAILED
bun test v1.4.0-canary.1 (1498d7b77)
test/js/valkey/valkey-gc.test.ts:
(pass) RedisClient survives a failed custom-TLS context without freeing the live client [16.40ms]
352 | });
353 | try {
354 | await client.send("PING", []);
355 | expect.unreachable();
356 | } catch (error: any) {
357 | 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:357:28)
(fail) rejects a RESP simple-string reply whose line terminator never arrives [12.75ms]
(pass) RedisClient survives GC after a command throws during argument validation [15.55ms]
(pass) custom setter with a foreign receiver throws instead of corrupting the heap [14.95ms]
(pass) RedisClient survives GC across many short-lived instances [14.96ms]
(pass) getBuffer replies survive GC with adopted backing stores intact [53.11ms]
(pass) RedisClient survives subscribe() + close() against a server that resets the connection [436.48ms]
(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
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (cddda957a)
test/js/valkey/valkey-gc.test.ts:
(pass) RedisClient survives a failed custom-TLS context without freeing the live client [516.07ms]
(pass) custom setter with a foreign receiver throws instead of corrupting the heap [500.60ms]
(pass) RedisClient survives GC after a command throws during argument validation [645.94ms]
(pass) rejects a RESP simple-string reply whose line terminator never arrives [483.82ms]
(pass) RedisClient survives GC across many short-lived instances [735.06ms]
(pass) RedisClient survives connection-timeout + reconnect churn against an under-replying server [1383.79ms]
(pass) RedisClient survives subscribe() + close() against a server that resets the connection [2606.69ms]
(pass) getBuffer replies surviv
... (truncated)
release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 711ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[1/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date
nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)
info: checking for self-update (current version: 1.29.0)
[1m[92m Blocking[0m waiting for file lock on build directory
[1m[92m Compiling[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
[1m[
... (truncated)
diff hotspot
src/runtime/valkey_jsc/js_valkey.rs | 222 ++++++++++++++++++------------------
test/js/valkey/valkey-gc.test.ts | 82 ++++++++++++-
2 files changed, 189 insertions(+), 115 deletions(-)
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file
file reads edits tests
src/runtime/valkey_jsc/js_valkey.rs 14 15 0
test/js/valkey/valkey-gc.test.ts 1 2 0