Skip to content

bun_ptr: test that RefPtr clones cross threads and the last drop destroys - #40522

Open
robobun wants to merge 5 commits into
mainfrom
farm/b2d8107d/refptr-cross-thread-test
Open

robobun wants to merge 5 commits into
mainfrom
farm/b2d8107d/refptr-cross-thread-test

Conversation

@robobun

@robobun robobun commented Aug 26, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • Add ref_ptr_clones_cross_threads_and_the_last_one_destroys. Four scoped threads clone through a shared &RefPtr<Shared> (Sync) and hand the clone back (Send). All five refs are then released on their own threads with no join in between, so only the count's atomics order the destructor after the other threads' reads. A Relaxed fetch_sub in ThreadSafeRefCount::deref makes Miri report a data race. A shape that joins the workers first passes with Relaxed.
  • Add extern crate self as bun_ptr; to src/ptr/lib.rs, as bun_jsc, bun_install, and bun_css do. The test host Shared now uses #[derive(ThreadSafeRefCounted)] instead of a hand-written copy of the derive output.
  • Add test/internal/rust-ref-ptr-miri.test.ts. It runs that unit test under Miri from bun test, with the flags bun run rust:miri uses, and checks that it ran and passed. It skips where miri or the cargo workspace is missing, like linear-fifo.test.ts.
  • Verified: bun run rust:miri -p bun_ptr (23 pass). The wrapper test passes, and fails with main's src/ptr files. cargo clippy -p bun_ptr is clean. No runtime code changes.

Background

  • RefPtr<T> is the intrusive Arc<T>: the count lives inside T.
  • The bun_ptr unit tests run under Miri only: the native test binary does not link the OutputSink symbols. Miri tracks happens-before with vector clocks, so a racing read and free is reported in any schedule.
  • std::thread::spawn is what this test module already uses. bun_threading is not a dependency of bun_ptr.
Notes

[review] gate passed · iteration 1 · 3 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/internal/rust-ref-ptr-miri.test.ts
bun test v1.4.1 (adc354d99)

test/internal/rust-ref-ptr-miri.test.ts:
42 |     if (exitCode !== 0) {
43 |       // Surface miri's diagnostic so the gate/CI log shows the actual UB.
44 |       console.error(stderr || stdout);
45 |     }
46 |     expect(stderr).not.toContain("Undefined Behavior");
47 |     expect(stdout).toContain("test ref_count::tests::ref_ptr_clones_cross_threads_and_the_last_one_destroys ... ok");
                        ^
error: expect(received).toContain(expected)

Expected to contain: "test ref_count::tests::ref_ptr_clones_cross_threads_and_the_last_one_destroys ... ok"
Received: "\nrunning 0 tests\n\ntest result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 22 filtered out; finished in 0.11s\n\n\nrunning 0 tests\n\ntest result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s\n\nall doctests ran in 0.01s; merged doctests compilation took 0.01s\n"

      at <anonymous> (/workspace/bun/test/internal/rust-ref-ptr-miri.test.ts:47:20)
(fail) Ref
... (truncated)

release without fix: 1 FAILED
bun test v1.4.1-canary.1 (adc354d99)

test/internal/rust-ref-ptr-miri.test.ts:
42 |     if (exitCode !== 0) {
43 |       // Surface miri's diagnostic so the gate/CI log shows the actual UB.
44 |       console.error(stderr || stdout);
45 |     }
46 |     expect(stderr).not.toContain("Undefined Behavior");
47 |     expect(stdout).toContain("test ref_count::tests::ref_ptr_clones_cross_threads_and_the_last_one_destroys ... ok");
                        ^
error: expect(received).toContain(expected)

Expected to contain: "test ref_count::tests::ref_ptr_clones_cross_threads_and_the_last_one_destroys ... ok"
Received: "\nrunning 0 tests\n\ntest result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 22 filtered out; finished in 0.11s\n\n\nrunning 0 tests\n\ntest result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s\n\nall doctests ran in 0.01s; merged doctests compilation took 0.01s\n"

      at <anonymous> (/workspace/bun/test/internal/rust-ref-ptr-miri.test.ts:47:20)
(fail) RefPtr cross-thread clone and drop is clean under miri's data-race detector [1338.39ms]

 0 pass
 1 fail
 2 expect() calls
Ran 1 test across 1 file. [1466.00ms]
__F:1:S:0
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/internal/rust-ref-ptr-miri.test.ts
bun test v1.4.1 (adc354d99)

test/internal/rust-ref-ptr-miri.test.ts:
(pass) RefPtr cross-thread clone and drop is clean under miri's data-race detector [2055.74ms]

 1 pass
 0 fail
 3 expect() calls
Ran 1 test across 1 file. [4.17s]
__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     ecb981f449
  features     baseline

23 deps, 129 codegen, 1172 objects in 632ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] gen ErrorCode+*.h
[2/1244] install /workspace/bun
bun install v1.4.1-canary.1 (adc354d99)

Checked 26 installs across 63 packages (no changes) [7.00ms]
[3/1244] gen bindgenv2
[4/1244] install /workspace/bun/packages/bun-error
bun install v1.4.1-canary.1 (adc354d99)

Checked 1 install across 2 packages (no changes) [4.00ms]
[5/1244] fetch zlib
[zlib] up to date
[6/1244] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[7/1217] gen .bind.ts → GeneratedBindings.cpp
[8/1217] fetch tinycc
[tinycc] up to date
[9/1216] install /workspace/bun/src/node-fallbacks
bun install v1.4.1-canary.1 (adc354d99)

Checked 111 installs across 104 packages (no changes) [6.00ms]
[10/1216] gen node-fallbacks/react-refresh.js
Bundled 1 module in 9ms

  react-refresh.js  4.81 KB  (entry point)

[11/1216] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.
... (truncated)
diff hotspot
src/ptr/lib.rs                          |  3 ++
 src/ptr/ref_count.rs                    | 45 +++++++++++++++++++++++++----
 test/internal/rust-ref-ptr-miri.test.ts | 51 +++++++++++++++++++++++++++++++++
 3 files changed, 93 insertions(+), 6 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                     reads  edits  tests
src/ptr/lib.rs                               1      3      0
src/ptr/ref_count.rs                         3      7      0
test/internal/rust-ref-ptr-miri.test.ts      0      1      0

…roys

RefPtr<T> is Send + Sync when T is, and Drop releases the ref. No unit
test exercised the two together. This one clones a RefPtr<Shared> into
four threads, drops each clone there, then drops the last ref on yet
another thread and checks that the destructor ran exactly once. It runs
under Miri with the rest of the crate (bun run rust:miri -p bun_ptr).
@coderabbitai

coderabbitai Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 25 days. After that, they cost $0.25 per reviewed file.

Or wait 2 minutes for your next included review.

View limit details

Limit details: You’ve used all 5 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3f82aee9-80ad-4bf8-91f0-02de3842123c

📥 Commits

Reviewing files that changed from the base of the PR and between 06820dc and ecb981f.

📒 Files selected for processing (3)
  • src/ptr/lib.rs
  • src/ptr/ref_count.rs
  • test/internal/rust-ref-ptr-miri.test.ts

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

@robobun

robobun commented Aug 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: test-only PR plus a one-line crate self-alias, verified locally.

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

LGTM — test-only addition that mirrors the neighboring thread_safe_ref_count_cross_thread_destroy structure and exercises the RefPtr<T>: Send + Sync bounds directly.

What was reviewed:

  • Hand-rolled AnyRefCounted impl for Shared matches the trait's four methods and delegates to ThreadSafeRefCount as the derive would.
  • unsafe impl Send/Sync for Shared is sound: atomic count, Box<u32> payload only read across threads; SAFETY comments present on every unsafe.
  • Test uses serial()/drops() snapshot pattern correctly, .collect()s clones before joining so the has_one_ref() assertion is deterministic, and .join().unwrap() is on a test-invariant path.
Extended reasoning...

Overview

This PR adds a single unit test plus supporting trait impls to the #[cfg(test)] module of src/ptr/ref_count.rs. The new test ref_ptr_clones_cross_threads_and_the_last_one_destroys clones a RefPtr<Shared> into four spawned threads, joins them, verifies no destruction has happened and the main ref is now sole owner, then moves the last ref to another thread to drop and asserts exactly one destruction. To make RefPtr<Shared>: Send + Sync hold, it hand-writes the AnyRefCounted impl (the derive expands to ::bun_ptr:: paths that don't resolve inside the defining crate) and adds unsafe impl Send/Sync for Shared. No runtime code is touched.

Security risks

None. The change is entirely inside a #[cfg(test)] module in a Rust crate that is exercised under Miri, not linked into the shipped binary. The unsafe impl Send/Sync are on a test-local type whose fields (ThreadSafeRefCount<Shared> + Box<u32>) are genuinely thread-safe for the read-only access pattern used, and the SAFETY comments accurately state the justification.

Level of scrutiny

Low. This is a small, self-contained test addition that copies the exact shape of the adjacent thread_safe_ref_count_cross_thread_destroy test (four threads, payload 5, serial() guard + drops() snapshot, std::thread::spawn, .join().unwrap()). The AnyRefCounted impl is a mechanical spelling-out of what the derive emits, and each unsafe block/impl carries a SAFETY comment as REVIEW.md requires. The .unwrap() calls are on JoinHandle::join in a test — a provable-invariant use per src/CLAUDE.md's .unwrap() rule.

Other factors

No CODEOWNERS entry covers src/ptr/ref_count.rs. The PR timeline shows no outstanding third-party objections. The bug hunt exited on dry_streak with no findings. The test is deterministic: all clones are taken and .collect()ed before any join begins, and every worker is joined before the has_one_ref() assertion, so there is no ordering flakiness. The serial() mutex prevents interference with sibling tests that share the DROPS counter. Given the change is test-only, follows local conventions precisely, and fills an explicitly-noted coverage gap for the RefPtr<T>: Send + Sync blanket impls, approval is appropriate.

…n the count's ordering

extern crate self as bun_ptr lets the ref-count derives resolve inside the
crate, so the test host Shared uses #[derive(ThreadSafeRefCounted)] instead
of a hand-written copy of the derive output.

The test now clones through a shared &RefPtr from four scoped threads, then
releases all five refs on their own threads with no join in between. The
destructor is only ordered after the other threads' reads by the refcount's
atomics. A Relaxed fetch_sub in ThreadSafeRefCount::deref makes Miri report
a data race in this test. The previous shape joined every worker before the
last release, so join() supplied that ordering and a Relaxed count passed.
Comment thread src/ptr/lib.rs Outdated
Comment thread src/ptr/ref_count.rs Outdated
Comment thread src/ptr/ref_count.rs Outdated
Comment thread src/ptr/ref_count.rs Outdated
The bun_ptr test binary does not link natively, so the crate's unit tests
run under Miri only. This wrapper runs the cross-thread RefPtr test with the
same Tree Borrows flags as bun run rust:miri and checks that it ran and
passed. Skipped where miri is not installed or the cargo workspace is not
resolvable, like linear-fifo.test.ts.

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

LGTM — test-only additions, no runtime code touched.

Checked that swapping the hand-written impl ThreadSafeRefCounted for the derive is equivalent (the extern crate self as bun_ptr alias is what lets the derive's ::bun_ptr:: paths resolve in-crate; same idiom already used in bun_jsc/bun_install/bun_css). The new cross-thread test correctly races five drops with no join between them so only the refcount's release/acquire orders the destructor after the payload reads — the shape Miri needs to catch a weakened ordering. The unsafe impl Send/Sync for Shared is confined to #[cfg(test)] and justified (atomic count, read-only Box<u32> payload). The Miri wrapper test is a near-verbatim copy of the established test/internal/linear-fifo.test.ts pattern, including the skip gate, pipe draining, and 120s timeout, and it asserts the specific ... ok line so a filter miss (0 tests) fails rather than passing silently.

Extended reasoning...

Overview

Three files: src/ptr/lib.rs gains a one-line extern crate self as bun_ptr; so the ref-count derive macros' emitted ::bun_ptr:: paths resolve inside the crate's own #[cfg(test)] module. src/ptr/ref_count.rs changes only the test module — the Shared test host swaps a hand-written ThreadSafeRefCounted impl for #[derive(crate::ThreadSafeRefCounted)], gains unsafe impl Send + Sync, and a new unit test exercises RefPtr<T>: Send + Sync by cloning through &RefPtr on scoped threads and then dropping all five refs on concurrent unjoined threads. test/internal/rust-ref-ptr-miri.test.ts runs that one unit test under cargo miri test with Tree Borrows so Miri's data-race detector validates the refcount's release/acquire ordering.

Security risks

None. No user-facing API surface, no parsing, no I/O beyond spawning cargo miri in a test that skips when the toolchain or vendored workspace is absent. The unsafe impl Send/Sync is on a #[cfg(test)]-only struct whose fields (ThreadSafeRefCount + Box<u32>) are genuinely thread-safe for the read-only usage the test performs; the SAFETY comments state this accurately.

Level of scrutiny

Low. Every change is either inside #[cfg(test)] or is the self-alias line (a compile-time-only name resolution aid with well-established precedent in this repo). No production code paths are altered. The exit reason was dry_streak, so the bug hunt ran to completion without findings.

Other factors

Since the earlier review on the first commit, four follow-up commits landed: the test was reworked so the concurrent releases are not joined before the destructor assertion (making the test actually depend on the refcount's ordering rather than the join's happens-before — the PR description confirms a Relaxed fetch_sub now trips Miri), comments were shortened per bot feedback, and the Miri wrapper test was added. The wrapper is a near-verbatim clone of the accepted test/internal/linear-fifo.test.ts (same skip gate, same env/flags approach, same 120s timeout, same concurrent pipe drain and stderr-before-exitCode ordering), and it additionally asserts the specific ... ok line so a filter miss fails loudly. The github-actions inline threads were self-resolved by the author, but the intervening commit messages (derive the host, shorten comments, one-line comment) plausibly correspond to and address them.

This branch has not been deployed

No deployments
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.

1 participant