Skip to content

Add jsg::Cell and jsg::RefCell for traced fields - #7982

Merged
piscisaureus merged 1 commit into
mainfrom
piscisaureus/rust-jsg-rc-take
Nov 3, 2026
Merged

piscisaureus merged 1 commit into
mainfrom
piscisaureus/rust-jsg-rc-take

Conversation

@piscisaureus

@piscisaureus piscisaureus commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

When GC visits a jsg::Rc or a TracedGlobal in a field, it stops
being strong and relies on the field's owner to trace it. Moving it out
of the field (Cell::take, popping a Vec, ...) leaves it neither
strong nor traced, so the next GC can collect its wrapper or value
while Rust still uses it (#7976). C++ jsg::Ref avoids this in its
move constructor, but Rust has no hook on moves.

In this patch:

  • jsg::Cell and jsg::RefCell for traced fields that change after
    construction. take, replace and borrow_mut take the lock and
    first detach the contents from their owner with GcVisitor::detach,
    as a typed promise's reaction does with its value (Rust JSG: typed promises #8153): every
    jsg::Rc and TracedGlobal in them is strong again and forgets its
    parent. This is the same rule as C++ jsg::TracedOwn (Add jsg::TracedOwn so visited handles survive their owner moving #8103). A
    detaching visit checks that each TracedGlobal belongs to the
    lock's isolate.
  • std::cell::Cell/RefCell and std::rc::Rc are Traced only for
    types that hold no GC references (jsg::HoldsNoGcReferences), so a
    jsg::Rc or TracedGlobal in a std cell, or shared by two owners,
    doesn't compile. This replaces the debug-build abort when tracing a
    std RefCell that can hold GC references.
  • Fields that held one in a std cell, including the data of promise
    reactions and the value of an opaque wrapper, now use
    jsg::Cell/RefCell.

Take a task out of a cell after a GC has made it weak, then GC again
(jsg-test/tests/cell.rs):

std cell (main) jsg::Cell
task's wrapper replaced kept
task's TracedGlobal panics on a cleared handle kept

@piscisaureus
piscisaureus force-pushed the piscisaureus/rust-jsg-rc-take branch from ef1e43e to d2e3d8a Compare October 25, 2026 16:41
@piscisaureus piscisaureus changed the title RFC: jsg cells that make what leaves them strong (#7976) RFC: jsg::Cell and jsg::RefCell for traced fields (#7976) Oct 25, 2026
@codecov-commenter

codecov-commenter commented Oct 25, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 28.04878% with 59 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.83%. Comparing base (1336872) to head (515070e).

Files with missing lines Patch % Lines
src/rust/jsg/cell.rs 30.50% 41 Missing ⚠️
src/workerd/io/io-context/trace.rs 0.00% 6 Missing ⚠️
src/rust/jsg/v8.rs 37.50% 5 Missing ⚠️
src/rust/jsg/wrappable.rs 0.00% 3 Missing ⚠️
src/workerd/io/io-context/lib.rs 0.00% 3 Missing ⚠️
src/rust/jsg/opaque.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7982      +/-   ##
==========================================
- Coverage   68.84%   68.83%   -0.02%     
==========================================
  Files         737      738       +1     
  Lines      193921   193988      +67     
  Branches    30088    30088              
==========================================
+ Hits       133513   133532      +19     
- Misses      42756    42805      +49     
+ Partials    17652    17651       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@guybedford

Copy link
Copy Markdown
Contributor

This overall direction makes a lot of sense to me in aligning with our C++ approach, which is typically the goal. It's an important correctness fix. Appreciated for the clear justification and comparison to alternatives.

For defense-in-depth it could be worthwhile to maintain the boxing as a runtime protection, alongside the HoldsNoGcReferences convention.

One suggestion from the AI would be to make T: HoldsNoGcReferences in impl Traced for std::rc::Rc<T> - a type-level route to two tracing parents. The C++ one-parent assert aborts for jsg::Rc, and for a TracedGlobal the last visit wins, so the value can go while the other owner still uses it. Rule 2 asks reviewers to catch this by hand, which could instead be caught by the compiler with this bound.

Should we make trace and make_strong required impls at this point to ensure the correctness properties aren't missed in implementation? Or do you think the AI reviews will be good enough to catch something like this at this point?

littledivy added a commit that referenced this pull request Oct 30, 2026
A visited handle is traced: only its owner's next visit keeps the
target alive. Moving a jsg::Data makes the destination strong again,
but moving the kj::Own<T> of an object that holds handles moves only
the pointer. The handles stay traced with nothing visiting them, and
the GC can free their targets while the object still uses them. Code
that hit this either left the handles unvisited, which leaks, or worked
around it by hand. The stream queue did the first: it never visited the
resolvers of pending reads, so a ReadableStream whose read never
settles was never collected, along with its reader and everything they
held. WritableStreamInternalController::drain() does the second (see
687b3a6).

In this patch:

- GcVisitor::makeStrong() makes every handle reachable through a value
  strong: jsg::Data, the Rust Global/Traced pair, and Refs, whose
  parent it clears so another owner can visit them. It matches
  make_strong in Rust JSG's jsg::Cell (#7982), so both languages follow
  one rule: whatever leaves a traced owner is strong.
- jsg::TracedOwn<T> is an owning pointer that records the isolate of
  its last visit and strengthens the object when it moves or is
  released. MemoryTracker accepts it like kj::Own.
- The stream queue holds pending reads in TracedOwn and visits them
  from the consumer and from the queue. The queue's visit keeps a
  pending read alive while its controller is reachable, as in the spec,
  where the controller holds its stream. cancel() drains the ring one
  element at a time instead of moving it.
- The visits are behind a new autogate, gc-complete-tracing. With
  it disabled the resolvers stay unvisited strong roots, as before, so
  the rollout can be compared gated against ungated and turned off if
  code turns out to rely on such a stream staying alive.
- A new clang-tidy check, jsg-traced-own, flags a visitForGc() that
  visits through a kj::Own, and kj::mv() of a field that is a container
  of TracedOwn. Every site it found now holds a TracedOwn: stream
  readables and their consumers, the internal stream's pipe events,
  EventTarget listeners, CryptoKey, WebSocket and the trace Request
  detail. None of them moves its Own today; the check keeps it that
  way.
- The jsg README, AGENTS.md and the GcVisitor comment describe the
  rule.

Shared ownership is not covered: queue buffer entries are kj::Rc
shared between consumers, so a buffered chunk that refers to its own
stream still keeps the stream alive.

Tests:

- tracing-test: a value and a Ref taken out of a traced queue survive
  the queue being collected, and a cycle through a queued item is
  collected. With strengthening disabled the first case fails ("a
  value taken out of a traced queue was collected").
- streams-pending-gc-test, with the autogate enabled: streams, readers
  and BYOB streams with a pending read are collected; a pending read
  still settles, including when only the controller is reachable. With
  the autogate disabled, the three collection cases fail, as on main.
- traced-own-test covers the clang-tidy check.

1,000 streams with a never-settling read, after GC: 0 collected with
the autogate disabled, 999 with it enabled.

RSS of `workerd serve` for a worker whose every request leaves one
such stream behind (macOS, fastbuild):

  requests    disabled   enabled
  20,000      246 MB     246 MB
  60,000      482 MB     261 MB
  100,000     801 MB     261 MB

With the autogate disabled, RSS grows like on main; with it enabled, it
levels off.
littledivy added a commit that referenced this pull request Oct 30, 2026
A visited handle is traced: only its owner's next visit keeps the
target alive. Moving a jsg::Data makes the destination strong again,
but moving the kj::Own<T> of an object that holds handles moves only
the pointer. The handles stay traced with nothing visiting them, and
the GC can free their targets while the object still uses them. Code
that hit this either left the handles unvisited, which leaks, or worked
around it by hand. The stream queue did the first: it never visited the
resolvers of pending reads, so a ReadableStream whose read never
settles was never collected, along with its reader and everything they
held. WritableStreamInternalController::drain() does the second (see
687b3a6).

In this patch:

- GcVisitor::makeStrong() makes every handle reachable through a value
  strong: jsg::Data, the Rust Global/Traced pair, and Refs, whose
  parent it clears so another owner can visit them. It matches
  make_strong in Rust JSG's jsg::Cell (#7982), so both languages follow
  one rule: whatever leaves a traced owner is strong.
- jsg::TracedOwn<T> is an owning pointer that records the isolate of
  its last visit and strengthens the object when it moves or is
  released. MemoryTracker accepts it like kj::Own.
- The stream queue holds pending reads in TracedOwn and visits them
  from the consumer and from the queue, along with a BYOB read's view. The queue's visit keeps a
  pending read alive while its controller is reachable, as in the spec,
  where the controller holds its stream. cancel() drains the ring one
  element at a time instead of moving it.
- The visits, and TracedOwn making handles strong, are behind a new
  autogate, gc-complete-tracing. With it disabled the resolvers stay
  unvisited strong roots and TracedOwn behaves like kj::Own, as before, so
  the rollout can be compared gated against ungated and turned off if
  code turns out to rely on such a stream staying alive.
- A new clang-tidy check, jsg-traced-own, flags a visitForGc(), or a
  helper handed the visitor, that visits through a kj::Own, and kj::mv()
  of a container field whose elements hold a TracedOwn, directly or in
  a struct, kj::Maybe or kj::OneOf. Every site it found now holds a
  TracedOwn: stream readables and their consumers, the internal
  stream's pipe events, EventTarget listeners, CryptoKey and WebSocket.
  None of them moves its Own today; the check keeps it that way. The
  trace Request's Detail is shared through kj::addRef, so it keeps its
  kj::Own and the site is suppressed.
- The jsg README, AGENTS.md and the GcVisitor comment describe the
  rule.

Shared ownership is not covered: queue buffer entries are kj::Rc
shared between consumers, so a buffered chunk that refers to its own
stream still keeps the stream alive.

Tests:

- tracing-test: a value and a Ref taken out of a traced queue survive
  the queue being collected, and a cycle through a queued item is
  collected. With strengthening disabled the first case fails ("a
  value taken out of a traced queue was collected").
- streams-pending-gc-test, with the autogate enabled: streams, readers
  and BYOB streams with a pending read are collected; a pending read
  still settles, including when only the controller is reachable, and a
  cycle through a BYOB read's view is collected.
  streams-pending-gc-gate-off-test runs the same cases with the autogate
  disabled and checks that none of these streams is collected, as on
  main.
- traced-own-test covers the clang-tidy check.

1,000 streams with a never-settling read, after GC: 0 collected with
the autogate disabled, 999 with it enabled.

RSS of `workerd serve` for a worker whose every request leaves one
such stream behind (macOS, fastbuild):

  requests    disabled   enabled
  20,000      246 MB     246 MB
  60,000      482 MB     261 MB
  100,000     801 MB     261 MB

With the autogate disabled, RSS grows like on main; with it enabled, it
levels off.
littledivy added a commit that referenced this pull request Oct 30, 2026
A visited handle is traced: only its owner's next visit keeps the
target alive. Moving a jsg::Data makes the destination strong again,
but moving the kj::Own<T> of an object that holds handles moves only
the pointer. The handles stay traced with nothing visiting them, and
the GC can free their targets while the object still uses them. Code
that hit this either left the handles unvisited, which leaks, or worked
around it by hand. The stream queue did the first: it never visited the
resolvers of pending reads, so a ReadableStream whose read never
settles was never collected, along with its reader and everything they
held. WritableStreamInternalController::drain() does the second (see
687b3a6).

In this patch:

- GcVisitor::makeStrong() makes every handle reachable through a value
  strong: jsg::Data, the Rust Global/Traced pair, and Refs, whose
  parent it clears so another owner can visit them. It matches
  make_strong in Rust JSG's jsg::Cell (#7982), so both languages follow
  one rule: whatever leaves a traced owner is strong.
- jsg::TracedOwn<T> is an owning pointer that records the isolate of
  its last visit and strengthens the object when it moves or is
  released. MemoryTracker accepts it like kj::Own.
- The stream queue holds pending reads in TracedOwn and visits them
  from the consumer and from the queue, along with a BYOB read's view. The queue's visit keeps a
  pending read alive while its controller is reachable, as in the spec,
  where the controller holds its stream. cancel() drains the ring one
  element at a time instead of moving it.
- The visits, and TracedOwn making handles strong, are behind a new
  autogate, gc-complete-tracing. With it disabled the resolvers stay
  unvisited strong roots and TracedOwn behaves like kj::Own, as before, so
  the rollout can be compared gated against ungated and turned off if
  code turns out to rely on such a stream staying alive.
- A new clang-tidy check, jsg-traced-own, flags a visitForGc(), or a
  helper handed the visitor, that visits through a kj::Own, and kj::mv()
  of a container field whose elements hold a TracedOwn, directly or in
  a struct, kj::Maybe or kj::OneOf. Every site it found now holds a
  TracedOwn: stream readables and their consumers, the internal
  stream's pipe events, EventTarget listeners, CryptoKey and WebSocket.
  None of them moves its Own today; the check keeps it that way. The
  trace Request's Detail is shared through kj::addRef, so it keeps its
  kj::Own and the site is suppressed.
- The jsg README, AGENTS.md and the GcVisitor comment describe the
  rule.

Shared ownership is not covered: queue buffer entries are kj::Rc
shared between consumers, so a buffered chunk that refers to its own
stream still keeps the stream alive.

Tests:

- tracing-test: a value and a Ref taken out of a traced queue survive
  the queue being collected, and a cycle through a queued item is
  collected. With strengthening disabled the first case fails ("a
  value taken out of a traced queue was collected").
- streams-pending-gc-test, with the autogate enabled: streams, readers
  and BYOB streams with a pending read are collected; a pending read
  still settles, including when only the controller is reachable.
  streams-pending-gc-gate-off-test runs the same cases with the autogate
  disabled and checks that none of these streams is collected, as on
  main.
- traced-own-test covers the clang-tidy check.

1,000 streams with a never-settling read, after GC: 0 collected with
the autogate disabled, 999 with it enabled.

RSS of `workerd serve` for a worker whose every request leaves one
such stream behind (macOS, fastbuild):

  requests    disabled   enabled
  20,000      246 MB     246 MB
  60,000      482 MB     261 MB
  100,000     801 MB     261 MB

With the autogate disabled, RSS grows like on main; with it enabled, it
levels off.
littledivy added a commit that referenced this pull request Oct 30, 2026
A visited handle is traced: only its owner's next visit keeps the
target alive. Moving a jsg::Data makes the destination strong again,
but moving the kj::Own<T> of an object that holds handles moves only
the pointer. The handles stay traced with nothing visiting them, and
the GC can free their targets while the object still uses them. Code
that hit this either left the handles unvisited, which leaks, or worked
around it by hand. The stream queue did the first: it never visited the
resolvers of pending reads, so a ReadableStream whose read never
settles was never collected, along with its reader and everything they
held. WritableStreamInternalController::drain() does the second (see
687b3a6).

In this patch:

- GcVisitor::makeStrong() makes every handle reachable through a value
  strong: jsg::Data, the Rust Global/Traced pair, and Refs, whose
  parent it clears so another owner can visit them. It matches
  make_strong in Rust JSG's jsg::Cell (#7982), so both languages follow
  one rule: whatever leaves a traced owner is strong.
- jsg::TracedOwn<T> is an owning pointer that records the isolate of
  its last visit and strengthens the object when it moves or is
  released. MemoryTracker accepts it like kj::Own.
- The stream queue holds pending reads in TracedOwn and visits them
  from the consumer and from the queue, along with a BYOB read's view. The queue's visit keeps a
  pending read alive while its controller is reachable, as in the spec,
  where the controller holds its stream. cancel() drains the ring one
  element at a time instead of moving it.
- The visits, and TracedOwn making handles strong, are behind a new
  autogate, gc-complete-tracing. With it disabled the resolvers stay
  unvisited strong roots and TracedOwn behaves like kj::Own, as before, so
  the rollout can be compared gated against ungated and turned off if
  code turns out to rely on such a stream staying alive.
- A new clang-tidy check, jsg-traced-own, flags a visitForGc(), or a
  helper handed the visitor, that visits through a kj::Own, and kj::mv()
  of a container field whose elements hold a TracedOwn, directly or in
  a struct, kj::Maybe or kj::OneOf. Every site it found now holds a
  TracedOwn: stream readables and their consumers, the internal
  stream's pipe events, EventTarget listeners, CryptoKey and WebSocket.
  None of them moves its Own today; the check keeps it that way. The
  trace Request's Detail is shared through kj::addRef, so it keeps its
  kj::Own and the site is suppressed.
- The jsg README, AGENTS.md and the GcVisitor comment describe the
  rule.

Shared ownership is not covered: queue buffer entries are kj::Rc
shared between consumers, so a buffered chunk that refers to its own
stream still keeps the stream alive.

Tests:

- tracing-test: a value and a Ref taken out of a traced queue survive
  the queue being collected, and a cycle through a queued item is
  collected. With strengthening disabled the first case fails ("a
  value taken out of a traced queue was collected").
- streams-pending-gc-test, with the autogate enabled: streams, readers
  and BYOB streams with a pending read are collected; a pending read
  still settles, including when only the controller is reachable.
  streams-pending-gc-gate-off-test runs the same cases with the autogate
  disabled and checks that none of these streams is collected, as on
  main.
- traced-own-test covers the clang-tidy check, memory-test the
  MemoryTracker overload, and standard-test detaching a readable stream
  now held in a TracedOwn.

1,000 streams with a never-settling read, after GC: 0 collected with
the autogate disabled, 999 with it enabled.

RSS of `workerd serve` for a worker whose every request leaves one
such stream behind (macOS, fastbuild):

  requests    disabled   enabled
  20,000      246 MB     246 MB
  60,000      482 MB     261 MB
  100,000     801 MB     261 MB

With the autogate disabled, RSS grows like on main; with it enabled, it
levels off.
littledivy added a commit that referenced this pull request Oct 30, 2026
A visited handle is traced: only its owner's next visit keeps the
target alive. Moving a jsg::Data makes the destination strong again,
but moving the kj::Own<T> of an object that holds handles moves only
the pointer. The handles stay traced with nothing visiting them, and
the GC can free their targets while the object still uses them. Code
that hit this either left the handles unvisited, which leaks, or worked
around it by hand. The stream queue did the first: it never visited the
resolvers of pending reads, so a ReadableStream whose read never
settles was never collected, along with its reader and everything they
held. WritableStreamInternalController::drain() does the second (see
687b3a6).

In this patch:

- GcVisitor::makeStrong() makes every handle reachable through a value
  strong: jsg::Data, the Rust Global/Traced pair, and Refs, whose
  parent it clears so another owner can visit them. It matches
  make_strong in Rust JSG's jsg::Cell (#7982), so both languages follow
  one rule: whatever leaves a traced owner is strong.
- jsg::TracedOwn<T> is an owning pointer that records the isolate of
  its last visit and strengthens the object when it moves or is
  released. MemoryTracker accepts it like kj::Own.
- The stream queue holds pending reads in TracedOwn and visits them,
  along with a BYOB read's view, from the queue alone: a handle visited
  from two parents is strong or traced depending on which visited last.
  Every stream holds its controller, so the queue's visit keeps a
  pending read alive while its stream or its controller is reachable,
  as in the spec, where the controller holds its stream. close() and
  error() make the reads strong before the queue stops being visited,
  and cancel() drains the ring one element at a time instead of moving
  it.
- The visits, and TracedOwn making handles strong, are behind a new
  autogate, gc-complete-tracing. With it disabled the resolvers stay
  unvisited strong roots and TracedOwn behaves like kj::Own, as before, so
  the rollout can be compared gated against ungated and turned off if
  code turns out to rely on such a stream staying alive.
- A new clang-tidy check, jsg-traced-own, flags a visitForGc(), or a
  helper handed the visitor, that visits through a kj::Own, and kj::mv()
  of a container field whose elements hold a TracedOwn, directly or in
  a struct, kj::Maybe or kj::OneOf. Every site it found now holds a
  TracedOwn: stream readables and their consumers, the internal
  stream's pipe events, EventTarget listeners, CryptoKey and WebSocket.
  None of them moves its Own today; the check keeps it that way. The
  trace Request's Detail is shared through kj::addRef, so it keeps its
  kj::Own and the site is suppressed.
- The jsg README, AGENTS.md and the GcVisitor comment describe the
  rule.

Shared ownership is not covered: queue buffer entries are kj::Rc
shared between consumers, so a buffered chunk that refers to its own
stream still keeps the stream alive.

Tests:

- tracing-test: a value and a Ref taken out of a traced queue survive
  the queue being collected, and a cycle through a queued item is
  collected. With strengthening disabled the first case fails ("a
  value taken out of a traced queue was collected").
- streams-pending-gc-test, with the autogate enabled: streams, readers
  and BYOB streams with a pending read are collected; a pending read
  still settles, including when only the controller is reachable and
  on both branches of a tee when the stream closes.
  streams-pending-gc-gate-off-test runs the same cases with the autogate
  disabled and checks that none of these streams is collected, as on
  main.
- traced-own-test covers the clang-tidy check, memory-test the
  MemoryTracker overload, and standard-test detaching a readable stream
  now held in a TracedOwn.

1,000 streams with a never-settling read, after GC: 0 collected with
the autogate disabled, 999 with it enabled.

RSS of `workerd serve` for a worker whose every request leaves one
such stream behind (macOS, fastbuild):

  requests    disabled   enabled
  20,000      246 MB     246 MB
  60,000      482 MB     261 MB
  100,000     801 MB     261 MB

With the autogate disabled, RSS grows like on main; with it enabled, it
levels off.
littledivy added a commit that referenced this pull request Oct 30, 2026
A visited handle is traced: only its owner's next visit keeps the
target alive. Moving a jsg::Data makes the destination strong again,
but moving the kj::Own<T> of an object that holds handles moves only
the pointer. The handles stay traced with nothing visiting them, and
the GC can free their targets while the object still uses them. Code
that hit this either left the handles unvisited, which leaks, or worked
around it by hand. The stream queue did the first: it never visited the
resolvers of pending reads, so a ReadableStream whose read never
settles was never collected, along with its reader and everything they
held. WritableStreamInternalController::drain() does the second (see
687b3a6).

In this patch:

- GcVisitor::makeStrong() makes every handle reachable through a value
  strong: jsg::Data, the Rust Global/Traced pair, and Refs, whose
  parent it clears so another owner can visit them. It matches
  make_strong in Rust JSG's jsg::Cell (#7982), so both languages follow
  one rule: whatever leaves a traced owner is strong.
- jsg::TracedOwn<T> is an owning pointer that records the isolate of
  its last visit and strengthens the object when it moves or is
  released. MemoryTracker accepts it like kj::Own.
- The stream queue holds pending reads in TracedOwn and visits them,
  along with a BYOB read's view, from the queue alone: a handle visited
  from two parents is strong or traced depending on which visited last.
  Every stream holds its controller, so the queue's visit keeps a
  pending read alive while its stream or its controller is reachable,
  as in the spec, where the controller holds its stream. close() and
  error() make the reads strong before the queue stops being visited,
  and cancel() drains the ring one element at a time instead of moving
  it.
- The visits, and TracedOwn making handles strong, are behind a new
  autogate, gc-complete-tracing. With it disabled the resolvers stay
  unvisited strong roots and TracedOwn behaves like kj::Own, as before, so
  the rollout can be compared gated against ungated and turned off if
  code turns out to rely on such a stream staying alive.
- A new clang-tidy check, jsg-traced-own, flags a visitForGc(), or a
  helper handed the visitor, that visits through a kj::Own, and kj::mv()
  of a container field whose elements hold a TracedOwn, directly or in
  a struct, kj::Maybe or kj::OneOf. Every site it found now holds a
  TracedOwn: stream readables and their consumers, the internal
  stream's pipe events, EventTarget listeners, CryptoKey and WebSocket.
  None of them moves its Own today; the check keeps it that way. The
  trace Request's Detail is shared through kj::addRef, so it keeps its
  kj::Own and the site is suppressed.
- The jsg README, AGENTS.md and the GcVisitor comment describe the
  rule.

Shared ownership is not covered: queue buffer entries are kj::Rc
shared between consumers, so a buffered chunk that refers to its own
stream still keeps the stream alive.

Tests:

- tracing-test: a value and a Ref taken out of a traced queue survive
  the queue being collected, and a cycle through a queued item is
  collected. With strengthening disabled the first case fails ("a
  value taken out of a traced queue was collected").
- streams-pending-gc-test, with the autogate enabled: streams, readers
  and BYOB streams with a pending read are collected; a pending read
  still settles, including when only the controller is reachable and
  on both branches of a tee when the stream closes.
  streams-pending-gc-gate-off-test runs the same cases with the autogate
  disabled and checks that none of these streams is collected, as on
  main.
- traced-own-test covers the clang-tidy check, memory-test the
  MemoryTracker overload, and standard-test detaching a readable stream
  now held in a TracedOwn.

1,000 streams with a never-settling read, after GC: 0 collected with
the autogate disabled, 999 with it enabled.

RSS of `workerd serve` for a worker whose every request leaves one
such stream behind (macOS, fastbuild):

  requests    disabled   enabled
  20,000      246 MB     246 MB
  60,000      482 MB     261 MB
  100,000     801 MB     261 MB

With the autogate disabled, RSS grows like on main; with it enabled, it
levels off.
littledivy added a commit that referenced this pull request Nov 3, 2026
Main added Traced impls and std cells holding GC references after this
branch was written. Each Traced impl now has make_strong, and each
field that holds a jsg::Rc or a TracedGlobal and changes after
construction is a jsg::Cell or jsg::RefCell:

- make_strong for TracedAsyncContextFrame, promise reaction data,
  Function::new data, #[jsg_struct], #[jsg_oneof], NullOrUndefined,
  Dict, InspectorConnection and the io-context types.
- jsg_cpp::Ref::make_strong makes its jsg::Ref strong as moving it does
  in C++.
- Promise reaction data is a jsg::RefCell.
- std::rc::Rc<T> is Traced only for T: HoldsNoGcReferences, so two
  owners can't trace the same reference, with a compile test.
@littledivy littledivy changed the title RFC: jsg::Cell and jsg::RefCell for traced fields (#7976) Add jsg::Cell and jsg::RefCell for traced fields Nov 3, 2026
@littledivy
littledivy marked this pull request as ready for review November 3, 2026 05:59
@littledivy
littledivy requested review from a team as code owners November 3, 2026 05:59

@clanky-the-clanker clanky-the-clanker Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superseded by the review of bd96796e.

This review, of af4d1527

💬 Approved with comments — A sound correctness fix with good tests. The two WARNINGs are documentation errors (a false claim about #[jsg_struct] and a README example that no longer compiles), plus two INFO notes. None of them affects runtime behaviour.

Findings: 2 warnings · 2 info (3 are inline comments on the diff)

Not opened by the correctness review: 1 changed file it was meant to read
  • types/rtti-fixture/main.rs

The decision is advisory: this review is a comment and never approves, blocks or requests changes on the pull request.

🔧 Fix with your agent

Paste the following into your coding agent to investigate and fix these issues.

Fix the following code review issues found in pull request #7982.

Before making changes, review each issue and present a brief summary table:
- For each finding, state whether you agree, disagree, or need clarification
- If you disagree (e.g., the fix would require disproportionate changes
  for minimal ROI, or the finding is factually incorrect), explain why
- If you need to clarify something before deciding, ask those questions
- Then share your plan for which issues to tackle and in what order

## WARNING (2)

### [Code Quality] The `HoldsNoGcReferences` doc says `#[jsg_struct]` implements it, but the `jsg_struct` and oneof macros emit only `Traced`.
- **File:** src/rust/jsg/wrappable.rs:181

### [Documentation] The `DynamicResource` `custom_trace` example implements only `trace`. `make_strong` is now required, so the example doesn't compile.
- **File:** src/rust/jsg-macros/README.md:598

## INFO (2)

### [AGENTS.md] Clarify which `RefCell` the GC-related `borrow_mut()` restriction applies to.
- **File:** src/rust/AGENTS.md:38

### [Performance] `jsg::RefCell::borrow_mut` runs `make_strong` over the entire contents on every call, which is O(n) for collections.
- **File:** src/rust/jsg/cell.rs:145

📖 AGENTS.md: 💬 1 info

The RefCell GC caveat in src/rust/AGENTS.md reads as applying to jsg::RefCell too.

Recommendation: 💬 Optional Nits

  • [INFO] Clarify which RefCell the GC-related borrow_mut() restriction applies to. (src/rust/AGENTS.md:38)
View full review
  • src/rust/AGENTS.md:38: the restriction that a borrow_mut() of a cell holding GC references must not cross an allocating call ("since tracing skips a mutably borrowed RefCell") reads as covering jsg::RefCell too. jsg::RefCell::borrow_mut makes the contents strong first, so skipping it during tracing is safe. Std cells can no longer hold GC references at all.

📋 Code Quality: ⚠️ 1 warning

No correctness bugs found. The HoldsNoGcReferences doc says #[jsg_struct] implements it, but no macro emits that impl.

Recommendation: 📝 Minor Comments

  • [WARNING] The HoldsNoGcReferences doc says #[jsg_struct] implements it, but the jsg_struct and oneof macros emit only Traced. (src/rust/jsg/wrappable.rs:181)
View full review
  • src/rust/jsg/wrappable.rs:181: the doc says jsg_traced! and #[jsg_struct] implement HoldsNoGcReferences. In jsg-macros, the jsg_struct and oneof macros emit only Traced, never HoldsNoGcReferences. So a plain-data #[jsg_struct] doesn't fit in a std Cell/RefCell/Rc without a hand-written impl, which is what jsg-test/tests/gc.rs already writes for NativeState.

Things checked that look correct:

  • CppRef::makeStrong matches the C++ jsg::Ref move semantics.
  • Rc::make_strong resets parent.
  • jsg::RefCell::trace skipping a mutably borrowed cell is safe.
  • Every in-tree Traced impl now provides make_strong.

⚡ Performance: 💬 1 info

jsg::RefCell::borrow_mut walks the whole contents on every call.

Recommendation: 💬 Optional Nits

  • [INFO] jsg::RefCell::borrow_mut runs make_strong over the entire contents on every call, which is O(n) for collections. (src/rust/jsg/cell.rs:145)
View full review
  • src/rust/jsg/cell.rs: borrow_mut runs make_strong over the whole contents. For a RefCell<Vec<jsg::Rc<_>>> that's O(n) per borrow, so pushing elements one borrow at a time is O(n²). Each element's check is cheap, so this only matters for large collections that are mutated often.

📝 Documentation: ⚠️ 1 warning

The custom_trace README example omits the now-required make_strong and no longer compiles.

Recommendation: 📝 Minor Comments

  • [WARNING] The DynamicResource custom_trace example implements only trace. make_strong is now required, so the example doesn't compile. (src/rust/jsg-macros/README.md:598)
View full review
  • src/rust/jsg-macros/README.md:598-606: the DynamicResource example implements only trace. Traced::make_strong is now required with no default (src/rust/jsg/wrappable.rs:164), so copying the example gives a compile error. The EventHandlers example earlier in the same file was updated, but this one was missed.

✅ All other checks passed

🔒 Security: ✅ LGTM

No security issues found.


Reviewed commit: af4d1527

Comment thread src/rust/jsg/wrappable.rs Resolved
Comment thread src/rust/AGENTS.md Resolved
Comment thread src/rust/jsg/cell.rs Resolved

@clanky-the-clanker clanky-the-clanker Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superseded by the review of 5ac69e4f.

This review, of bd96796e

💬 Approved with comments — A sound GC correctness fix with good tests. Both WARNINGs are documentation errors (a false claim that #[jsg_struct] implements HoldsNoGcReferences, and a README example that won't compile), and neither changes runtime behaviour.

Findings: 2 warnings · 2 info

Not opened by the correctness review: 1 changed file it was meant to read
  • types/rtti-fixture/main.rs

The decision is advisory: this review is a comment and never approves, blocks or requests changes on the pull request.

🔧 Fix with your agent

Paste the following into your coding agent to investigate and fix these issues.

Fix the following code review issues found in pull request #7982.

Before making changes, review each issue and present a brief summary table:
- For each finding, state whether you agree, disagree, or need clarification
- If you disagree (e.g., the fix would require disproportionate changes
  for minimal ROI, or the finding is factually incorrect), explain why
- If you need to clarify something before deciding, ask those questions
- Then share your plan for which issues to tackle and in what order

## WARNING (2)

### [Code Quality] The `HoldsNoGcReferences` doc says `#[jsg_struct]` implements it, but the `jsg_struct` and oneof macros emit only `Traced`.
- **File:** src/rust/jsg/wrappable.rs:182

### [Documentation] The `DynamicResource` `custom_trace` example implements only `trace`. `make_strong` is now required, so the example doesn't compile.
- **File:** src/rust/jsg-macros/README.md:598

## INFO (2)

### [AGENTS.md] Say which `RefCell` the GC-related `borrow_mut()` restriction applies to.
- **File:** src/rust/AGENTS.md:38

### [Performance] `jsg::RefCell::borrow_mut` runs `make_strong` over the whole contents on every call, which is O(n) for collections.
- **File:** src/rust/jsg/cell.rs:147

📖 AGENTS.md: 💬 1 info

The GC-related borrow_mut() restriction doesn't say which RefCell it means.

Recommendation: 💬 Optional Nits

  • [INFO] Say which RefCell the GC-related borrow_mut() restriction applies to. (src/rust/AGENTS.md:38)
View full review
  • src/rust/AGENTS.md:38: the rule against holding a borrow_mut() across an allocating call, "since tracing skips a mutably borrowed RefCell", reads as if it covers jsg::RefCell. jsg::RefCell::borrow_mut makes the contents strong first, and std cells can no longer hold GC references, so the reason given no longer matches either type.

📋 Code Quality: ⚠️ 1 warning

The HoldsNoGcReferences doc says #[jsg_struct] implements it, but the macro doesn't. The isolates.rs change is correct.

Recommendation: 📝 Minor Comments

  • [WARNING] The HoldsNoGcReferences doc says #[jsg_struct] implements it, but the jsg_struct and oneof macros emit only Traced. (src/rust/jsg/wrappable.rs:182)
View full review
  • src/rust/jsg/wrappable.rs:181-182: the doc says jsg_traced! and #[jsg_struct] implement HoldsNoGcReferences. The jsg_struct and oneof macros in jsg-macros emit only impl jsg::Traced. So a plain-data #[jsg_struct] can't go in a std Cell, RefCell or Rc without a hand-written impl, which is what jsg-test/tests/gc.rs already writes for NativeState.
  • src/rust/jsg-test/tests/isolates.rs: the move to jsg::Cell and take(lock) is correct.

⚡ Performance: 💬 1 info

jsg::RefCell::borrow_mut takes O(n) time per call for collections.

Recommendation: 💬 Optional Nits

  • [INFO] jsg::RefCell::borrow_mut runs make_strong over the whole contents on every call, which is O(n) for collections. (src/rust/jsg/cell.rs:147)
View full review
  • src/rust/jsg/cell.rs:145-149: borrow_mut and try_borrow_mut run make_strong over the whole contents on every call. This only matters for large collections that are mutated often.

📝 Documentation: ⚠️ 1 warning

The DynamicResource custom_trace example has no make_strong, which is now required.

Recommendation: 📝 Minor Comments

  • [WARNING] The DynamicResource custom_trace example implements only trace. make_strong is now required, so the example doesn't compile. (src/rust/jsg-macros/README.md:598)
View full review
  • src/rust/jsg-macros/README.md:598-606: the DynamicResource example implements only trace. Traced::make_strong is now required, so copying the example gives a compile error. The EventHandlers example earlier in the same file was updated.

✅ All other checks passed

🔒 Security: ✅ LGTM

No security issues found.


Reviewed commit: bd96796e

@clanky-the-clanker clanky-the-clanker Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superseded by the review of ddce94a0.

This review, of 5ac69e4f

✅ Approved — Both documentation warnings from the previous review are fixed, and nothing new came up in the code changed since. The GC correctness fix and its tests look sound.

Not opened by the correctness review: 1 changed file it was meant to read
  • types/rtti-fixture/main.rs

The decision is advisory: this review is a comment and never approves, blocks or requests changes on the pull request.


✅ All checks passed

🔒 Security: ✅ LGTM

No security issues found.


📖 AGENTS.md: ✅ LGTM

The guidance in src/rust/AGENTS.md matches how the new cells behave.


📋 Code Quality: ✅ LGTM

The HoldsNoGcReferences doc in src/rust/jsg/wrappable.rs now matches the macros. Every make_strong impl passes the call on to each element, and jsg::Cell and jsg::RefCell make values strong before they leave the cell. CppRef::makeStrong uses the C++ move constructor to make the reference strong. The RTTI fixture's move to jsg::Cell is correct.


⚡ Performance: ✅ LGTM

take and replace each make a single make_strong call. The collection make_strong impls visit each element once, which costs the same as tracing.


📝 Documentation: ✅ LGTM

The DynamicResource example in src/rust/jsg-macros/README.md now implements make_strong, so it compiles.


Reviewed commit: 5ac69e4f

@piscisaureus
piscisaureus force-pushed the piscisaureus/rust-jsg-rc-take branch from 5ac69e4 to ddce94a Compare November 3, 2026 17:19
@piscisaureus

Copy link
Copy Markdown
Contributor Author

Rebased onto main and squashed (Divy's follow-ups are folded in, with a Co-authored-by).

One design change while rebasing: #8153 added GcVisitor::detach, a detaching visit through trace that makes every jsg::Rc strong and forgets its parent and lets every TracedGlobal go of its traced node, which is what this PR's Traced::make_strong did. Rather than keep two mechanisms for the same rule, the cells now use detach and make_strong is gone:

  • take/replace/borrow_mut/try_borrow_mut take &mut Lock (all callers already passed lock) and detach what leaves the cell. replace moves the old value out before detaching it, so a visit that reaches the cell again through a wrapperless jsg::Rc doesn't find it there still weak.
  • No per-type make_strong impls; hand-written Traced impls get the behaviour from trace, which now documents that it changes nothing other than through the visitor. The C++ jsg::Ref case uses main's cpp_ref_detach, which rebuilds the jsg::Ref; makeStrong() move-assigned it, which keeps its old parent.
  • A detaching visit carries the lock's isolate and panics on a TracedGlobal of another isolate, as make_strong did.
  • Main's opaque wrapper and the typed promise tests move to the jsg cells.

Not included: an isolate check for a jsg::Rc in a detaching visit. jsg::Rc::clone already reaches addStrongRef without one, so a check there alone wouldn't close the gap.

@guybedford could you take another look?

@clanky-the-clanker clanky-the-clanker Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superseded by the review of 515070ea.

This review, of ddce94a0

✅ Approved — No issues found. The detach-based jsg::Cell/jsg::RefCell and the HoldsNoGcReferences bounds look correct and come with tests.

The decision is advisory: this review is a comment and never approves, blocks or requests changes on the pull request.


✅ All checks passed

🔒 Security: ✅ LGTM

The HoldsNoGcReferences bounds tighten the memory-safety boundary: a GC reference held in a std cell or a std Rc no longer compiles.


📖 AGENTS.md: ✅ LGTM

The guidance in src/rust/AGENTS.md matches how the cells behave.


📋 Code Quality: ✅ LGTM

take and replace move the old value out before detaching it, and borrow_mut detaches while the borrow is held. The isolate check runs after the early return for a closed isolate. The call sites, including types/rtti-fixture/main.rs, are updated correctly, and jsg-test/tests/cell.rs and the compile tests cover the new behaviour.


⚡ Performance: ✅ LGTM

Each take, replace or borrow_mut runs one detaching visit, which costs about the same as a trace. The marker traits add no runtime cost.


📝 Documentation: ✅ LGTM

The documentation matches the detach-based design, and nothing refers to make_strong any more.


Reviewed commit: ddce94a0

@piscisaureus
piscisaureus force-pushed the piscisaureus/rust-jsg-rc-take branch from ddce94a to 4ef0fd8 Compare November 3, 2026 17:30
When GC visits a jsg::Rc or a TracedGlobal in a field, it stops being
strong and relies on the field's owner to trace it. Moving it out of
the field (Cell::take, popping a Vec, ...) leaves it neither strong
nor traced, so the next GC can collect its wrapper or value while Rust
still uses it (#7976). C++ jsg::Ref avoids this in its move
constructor, but Rust has no hook on moves.

* jsg::Cell and jsg::RefCell hold traced state that changes after
  construction. take, replace and borrow_mut take the lock and first
  detach the contents from their owner with GcVisitor::detach, as a
  typed promise's reaction does with its value: every jsg::Rc and
  TracedGlobal in them is strong again and forgets its parent. A
  detaching visit now checks that each TracedGlobal belongs to the
  lock's isolate, as other TracedGlobal operations do.
* std::cell::Cell, std::cell::RefCell and std::rc::Rc are Traced only
  for types that hold no GC references (jsg::HoldsNoGcReferences), so
  a jsg::Rc or TracedGlobal in a std cell, or shared by two owners,
  doesn't compile. jsg_traced! and the built-in wrappers and
  collections implement it. This replaces the debug-build abort when
  tracing a std RefCell that can hold GC references.
* Fields that held a GC reference in a std cell, including the data of
  promise reactions and the value of an opaque wrapper, now use
  jsg::Cell or jsg::RefCell.
* Compile tests for a GC reference in a std cell or a std Rc, and
  tests in jsg-test/tests/cell.rs that take a task out of a cell after
  a GC has made it weak, then GC again.

Co-authored-by: Divy Srivastava <divy@cloudflare.com>
@piscisaureus
piscisaureus force-pushed the piscisaureus/rust-jsg-rc-take branch from 4ef0fd8 to 515070e Compare November 3, 2026 17:30

@clanky-the-clanker clanky-the-clanker Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved — No issues found. The only change since the last review is the isolate check in v8.rs. It is correct, it keeps the early return for a closed isolate, and a test covers it.

The decision is advisory: this review is a comment and never approves, blocks or requests changes on the pull request.


✅ All checks passed

🔒 Security: ✅ LGTM

No security concerns. The new isolate check makes the detaching path stricter.


📖 AGENTS.md: ✅ LGTM

The guidance in src/rust/AGENTS.md still matches how the cells behave.


📋 Code Quality: ✅ LGTM

VisitMode::Detach now carries the lock's isolate pointer, and visit_traced_global asserts global.state.belongs_to(isolate) after the is_closed() early return. detach now takes &Lock, and its callers in cell.rs are updated to match. isolates.rs tests the panic and its message.


⚡ Performance: ✅ LGTM

The new check adds one pointer comparison per detaching visit, which costs almost nothing.


📝 Documentation: ✅ LGTM

The doc comments on detach and visit_traced_global match what the code does.


Reviewed commit: 515070ea

@piscisaureus
piscisaureus merged commit bc02571 into main Nov 3, 2026
23 checks passed
@piscisaureus
piscisaureus deleted the piscisaureus/rust-jsg-rc-take branch November 3, 2026 19:36
bartlomieju added a commit that referenced this pull request Nov 3, 2026
Once a GC has visited a resource that keeps a #[jsg_struct] with a
TracedGlobal field, the handle is weak and relies on the resource's
trace. Taken out of the field with a plain move, nothing traces it and
the next GC can collect the value, after which to_js panics with "C++
passed an empty v8::Local". With jsg::RefCell (#7982), borrow_mut
detaches the struct before it can leave, so it is strong again.

* A test takes such a struct out of a resource's jsg::RefCell after a
  GC, runs another GC and converts it back. Without the detach in
  borrow_mut, it fails with that panic.
* struct_js_value_field_outlives_its_scope runs a GC between its two
  scopes, and holds objects rather than a string literal, so it checks
  that the struct's handles keep their values.
* The #[jsg_struct] docs say that a resource changing such a struct
  after construction keeps it in a jsg:: cell.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants