Skip to content

js: Keep a live memo's value when its object is collected - #1014

Merged
huacnlee merged 1 commit into
longbridge:mainfrom
sunfuze:js/live-memo-gc-mark
Nov 6, 2026
Merged

huacnlee merged 1 commit into
longbridge:mainfrom
sunfuze:js/live-memo-gc-mark

Conversation

@sunfuze

@sunfuze sunfuze commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Summary

An app that keeps a computed alive under an owner could crash its engine
some time later. One of two things happened: QuickJS's assertion
gc_decref_child: JS_REF_COUNT(p) > 0 failed in a later collection, or the
engine segfaulted in free_gc_object when it stopped (Host::stop_work →
state::uninstall → reactive::free_all). Which one depended on what had
reused the freed memory. In Pal's desktop app, built on NSH, nsh test
crashed intermittently on CI. The crash showed up after an app test file
created a chat (a few computeds over a store), then waited or allocated
long enough for a collection.

Root cause

The Signal / Memo classes' 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_handle frees a signal or a disposed memo
that 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_handle returns None),
so the graph now holds a dangling value. The graph frees that value again
when the memo runs again, or in free_all at 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 of
computed(() => new Map(projects().map((p) => [p.id, p]))). Inside
JS_RunGC the collector had freed that Map as part of a cycle. The same
address was then reused by a lazily built web class's method. At teardown,
free_all released that method through the stale pointer.

Fix

Graph::held_by_handle(id) gives drop_handle's rule a name: a signal or
a disposed computation. gc_mark now marks the value and the custom
equals only when the rule holds. A cycle through a live memo is kept
until the owner disposes the memo, like anything else the owner holds.

How to Test

  • New 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 under
    a root, puts the memo and its value in a cycle, runs a collection and
    checks that a WeakRef to the value still derefs.
    • Without the fix it fails ("the live memo's value was collected"), then
      aborts in JS_FreeRuntime.
    • With the fix it passes.
  • cargo test -p nsh-core -p nsh-js passes, as do
    cargo clippy -p nsh-core -p nsh-js --all-targets and
    cargo +nightly fmt --all --check.
  • Pal's app suite (374 tests, nsh test) ran on a debug build of this
    change. 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

  • I have read the CONTRIBUTING document and followed the guidelines.

🤖 Generated with Claude Code

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

sunfuze commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

The web check's one failure, examples/gallery/tests/context-menu-press.test.tsx › Shift+F10 on the focused trigger opens the menu at its first item, fails the same way on main (run 42072952615, e3dd20e). It comes from #978's test, not from this change. Every other check passed.

@huacnlee huacnlee left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

✅ 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-targets and cargo build -p nsh-cli: passed. Counter nsh test --trace interactions passed (+1/−1, 7/8 mount items).
  • cargo +nightly fmt --all --check reports 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.

🤖 Noop (reviewer) · AI agent acting for @huacnlee

@huacnlee
huacnlee merged commit 0d0338a into longbridge:main Nov 6, 2026
8 of 9 checks passed
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.

2 participants