Skip to content

Report a dead target as empty from napi_get_reference_value - #67

Merged
syrusakbary merged 1 commit into
mainfrom
fix/reference-value-dead-target
Sep 8, 2026
Merged

Report a dead target as empty from napi_get_reference_value#67
syrusakbary merged 1 commit into
mainfrom
fix/reference-value-dead-target

Conversation

@Arshia001

Copy link
Copy Markdown
Member

Problem

QuickJS frees reference cycles in two passes, so a finalizer can still see other members of its own cycle as zombies — that is exactly what JS_IsLiveObject exists for. A weak reference slot pointing at such an object has not been cleared yet, because reset_weak_ref only runs from that object's own free_object.

napi_get_reference_value therefore calls dup_inner() on a zombie and hands the caller a fresh reference to memory the collector is about to release. Whichever scope owns the resulting napi_value frees it again on close, and the process trips a heap-use-after-free.

Found while fixing a TLS memory leak in edgejs (WAX-609). That leak had the side effect of keeping TLSWrap wrappers permanently alive, which is what kept this latent — as soon as the wrappers could actually be collected, TlsWrapFinalize reached back through napi_get_reference_value and resurrected a zombie:

JS_RunGC → napi_external__::finalizer → TlsWrapFinalize
   → EdgeStreamBaseGetWrapper → napi_get_reference_value → dup_inner()   ← resurrects
...later...
napi_scope__::close() → ~napi_value__ → JS_FreeValue   ← heap-use-after-free

Fix

Report such a target as empty, which is what callers already handle for a collected weak reference.

Note on the JS_IsObject guard

The check is deliberately restricted to objects. JS_IsLiveObject returns false for every non-object — strings, symbols, bigints — so applying it unconditionally makes every reference to one read back as empty. I hit exactly that: the first version of this patch failed 51 node:crypto tests because every string reference vanished.

Verification

Built against edgejs (EDGE_NAPI_PROVIDER=quickjs):

  • node:tls + node:https + node:crypto433/433, matching baseline.
  • ASAN (-fsanitize=address) clean on the four tests that previously aborted with corrupted double-linked list.

This is defence-in-depth and is independent of the edgejs-side fix: edgejs is green and ASAN-clean with or without it. It guards any addon whose finalizer reaches back into the object graph.

🤖 Generated with Claude Code

QuickJS frees reference cycles in two passes, so a finalizer can still see
other members of its own cycle as zombies -- that is what JS_IsLiveObject
exists for. A weak reference slot pointing at such an object has not been
cleared yet, because reset_weak_ref only runs from that object's own
free_object. napi_get_reference_value would therefore dup_inner() a zombie,
handing the caller a fresh reference to memory the collector is about to
release; whichever scope owns the resulting napi_value then frees it again
and the process trips a heap-use-after-free.

Report such a target as empty instead, which is what callers already handle
for a collected weak reference.

The check is deliberately restricted to objects. JS_IsLiveObject reports
every non-object -- strings, symbols, bigints -- as not live, so applying it
unconditionally makes every reference to one read back as empty (51
node:crypto tests fail that way).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@syrusakbary
syrusakbary merged commit 57feb2d into main Sep 8, 2026
8 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