Repository navigation
js: Keep a live memo's value when its object is collected - #1014
Conversation
A memo's JS object marked the memo's value as its child for QuickJS's cycle collector. That is right for a signal or a disposed memo, which are freed with their object, but a live memo belongs to its owner and outlives its object: when the object and the value were in a cycle (the value holding the memo, directly or not), the collector freed the value while the graph still held it. The graph freed it again when the memo ran again or the graph went, which tripped `gc_decref_child`'s assertion in a later collection or crashed in `free_gc_object` when the engine stopped, depending on what had reused the memory. The object now marks the value only when it owns it (`Graph::held_by_handle`, the rule `drop_handle` already followed). A cycle through a live memo is then kept until its owner disposes it, as anything else the owner holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
huacnlee
left a comment
There was a problem hiding this comment.
✅ This fixes the live-memo GC ownership mismatch; the approach and local regression checks look sound.
gc_mark now reports only references that drop_handle actually releases, while live memo values and comparators remain owned by the graph. I found no code blocker.
Validation at 7d4918a17858e9d8a785631c5c8b7b9726102ed1:
cargo test -p nsh-core -p nsh-js: passed (1,135 core and 199 JS unit tests, integration tests and doctests).- A local additional test with a genuinely disposable root and cyclic value/custom comparator passed: both survive collection while owned and are reclaimed after disposal. Removing only the new GC predicate makes the PR regression abort in QuickJS teardown (SIGABRT). Local probes were restored; nothing pushed.
cargo clippy -p nsh-core -p nsh-js --all-targetsandcargo build -p nsh-cli: passed. Counternsh test --traceinteractions passed (+1/−1, 7/8 mount items).cargo +nightly fmt --all --checkreports eight untouched files, all blob-identical to the parent; neither changed file is reported.
The remaining CI failure is the same Shift+F10 context-menu test on this PR and main e3dd20e6: 293/294 pass in both. CI is still red.
One documentation nit: crates/nsh-core/src/reactive.rs:433 adds the public Rust helper Graph::held_by_handle(&self, id: u32) -> bool. Please list it under ## Public API in the PR body, as the repository template requests. Its name and narrow scope fit its cross-crate purpose; there is no new JS API or dependency.
This has no visual/layout surface; verification used GC lifetimes directly. I did not rerun the private Pal suite or whole-workspace/platform builds.
Summary
An app that keeps a
computedalive under an owner could crash its enginesome time later. One of two things happened: QuickJS's assertion
gc_decref_child: JS_REF_COUNT(p) > 0failed in a later collection, or theengine segfaulted in
free_gc_objectwhen it stopped (Host::stop_work→state::uninstall→reactive::free_all). Which one depended on what hadreused the freed memory. In Pal's desktop app, built on NSH,
nsh testcrashed intermittently on CI. The crash showed up after an app test file
created a chat (a few
computeds over a store), then waited or allocatedlong enough for a collection.
Root cause
The
Signal/Memoclasses'gc_mark(crates/nsh-js/src/reactive.rs)marks the node's value as a child of the JS object. That is correct only
when the object owns the value, that is, when the node is freed together
with the object.
Graph::drop_handlefrees a signal or a disposed memothat way. A live memo belongs to its owner and outlives its object, and
its value is held by the graph node.
Suppose the object and the value are in a cycle, for example the value
holds the memo through a closure or a store. When the cycle is unreachable,
the collector counts the graph's reference as internal and frees the value.
The object's finalizer keeps the live node (
drop_handlereturnsNone),so the graph now holds a dangling value. The graph frees that value again
when the memo runs again, or in
free_allat teardown.To find this, I instrumented QuickJS. It recorded every freed object with
its backtrace, and on the failed assertion it dumped the parent and the
freed child. The child was a
Map, the value ofcomputed(() => new Map(projects().map((p) => [p.id, p]))). InsideJS_RunGCthe collector had freed thatMapas part of a cycle. The sameaddress was then reused by a lazily built web class's method. At teardown,
free_allreleased that method through the stale pointer.Fix
Graph::held_by_handle(id)givesdrop_handle's rule a name: a signal ora disposed computation.
gc_marknow marks the value and the customequalsonly when the rule holds. A cycle through a live memo is keptuntil the owner disposes the memo, like anything else the owner holds.
How to Test
reactive::tests::a_live_memo_keeps_its_value_when_its_object_is_collected(
cargo test -p nsh-js --lib a_live_memo_keeps). It creates a memo undera root, puts the memo and its value in a cycle, runs a collection and
checks that a
WeakRefto the value still derefs.aborts in
JS_FreeRuntime.cargo test -p nsh-core -p nsh-jspasses, as docargo clippy -p nsh-core -p nsh-js --all-targetsandcargo +nightly fmt --all --check.nsh test) ran on a debug build of thischange. The app's minimal reproduction (a chat created in one test, a
1 s wait in the next) crashed every time before the fix and passed every
time after it.
Checklist
🤖 Generated with Claude Code