Skip to content

Commit a98aabf

Browse files
quickjs napi: track napi_refs in env and release at Detach
During teardown the QuickJS implementation of Napi::Detach would leave outstanding strong napi_refs dangling. Those refs pin JS values from outside the gc_obj_list graph, so the cycle collector in JS_FreeRuntime cannot break them and the list_empty(gc_obj_list) assertion fires. Mirror the V8 impl (napi_env__::DeleteMe): track every RefInfo created by napi_create_reference in a per-env list and release their JS side in Detach. The RefInfo allocations themselves are left for their owning C++ holders to destroy (typically later, via wrapper finalizers); those subsequent napi_delete_reference calls take a detached-env path that is a no-op. Also implement gc_mark on NapiCallback so the strong newTarget ref it holds participates in cycle collection, fix a leak of the callbackData ref in create_function_internal, and run JS_RunGC twice at the end of Detach so wrapper finalizers (which release embedded Napi::Reference instances) execute while the env is still valid. Tests: 136/136 passing. Reduces teardown leak count significantly; a small residual set of externally-pinned objects still prevents the gc_obj_list assertion from passing in all cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f9e10bb commit a98aabf

3 files changed

Lines changed: 105 additions & 17 deletions

File tree

Core/Node-API/Source/env_quickjs.cc

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,32 @@ namespace Napi
4949
napi_env env_ptr{env};
5050
if (env_ptr)
5151
{
52+
// Release every strong napi_ref still outstanding. This mirrors
53+
// the V8 impl (napi_env__::DeleteMe) and is essential on QuickJS:
54+
// any surviving strong ref pins a JS value from outside the GC
55+
// graph, which prevents the teardown cascade in JS_FreeContext
56+
// from running and triggers list_empty(gc_obj_list) assert in
57+
// JS_FreeRuntime.
58+
//
59+
// We only release the JS side here. The RefInfo allocations
60+
// themselves are owned by their C++ holders (e.g. an
61+
// Napi::FunctionReference embedded in a polyfill object) and
62+
// will be freed later when those holders are destroyed. We
63+
// zero-out count/value so the subsequent napi_delete_reference
64+
// is a no-op and does not touch the already freed context.
65+
for (void* p : env_ptr->refs_list)
66+
{
67+
auto* info = reinterpret_cast<RefInfo*>(p);
68+
if (info->count > 0)
69+
{
70+
JS_FreeValue(env_ptr->context, info->value);
71+
}
72+
info->count = 0;
73+
info->value = JS_UNDEFINED;
74+
}
75+
env_ptr->refs_list.clear();
76+
env_ptr->detached = true;
77+
5278
if (!JS_IsUndefined(env_ptr->has_own_property_function))
5379
{
5480
JS_FreeValue(env_ptr->context, env_ptr->has_own_property_function);
@@ -62,7 +88,23 @@ namespace Napi
6288
}
6389
env_ptr->handle_scope_stack.clear();
6490

65-
delete env_ptr;
91+
// Run the cycle collector so napi_wrap finalizers (which
92+
// destroy C++ wrapper objects and release any embedded
93+
// napi_refs) get a chance to execute while the env is still
94+
// valid. A second pass picks up anything unpinned by the
95+
// first pass's finalizers.
96+
JSRuntime* rt = JS_GetRuntime(env_ptr->context);
97+
JS_RunGC(rt);
98+
JS_RunGC(rt);
99+
100+
// NOTE: we intentionally do NOT `delete env_ptr` here. Some
101+
// native objects (e.g. ObjectWrap instances) have their
102+
// destructors run as a side effect of JS_FreeContext's
103+
// teardown cascade (via napi_wrap finalizers) which happens
104+
// *after* Detach returns. Those destructors reach
105+
// napi_delete_reference on an env pointer that must remain
106+
// valid. The env is leaked, but this is bounded (one per
107+
// AppRuntime environment tier).
66108
}
67109
}
68110

Core/Node-API/Source/js_native_api_quickjs.cc

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -176,12 +176,23 @@ static JSValue Callback(JSContext *ctx, JSValueConst this_val, int argc, JSValue
176176
void* opaque = JS_GetOpaque(val, js_callback_class_id);
177177
ExternalCallback* externalCallback = reinterpret_cast<ExternalCallback*>(opaque);
178178
if (externalCallback != nullptr) {
179-
// newTarget is NOT a strong reference (we do not dup when assigning),
180-
// so do NOT JS_FreeValueRT it here. See create_function_internal.
179+
JS_FreeValueRT(rt, externalCallback->newTarget);
181180
delete externalCallback;
182181
}
183182
}
184183

184+
// Expose newTarget (a strong JSValue held by this class) to QuickJS's cycle
185+
// collector. This is what allows the cycle
186+
// func(cid=15) -> func_data[callbackData(cid=66)] -> opaque -> newTarget -> func
187+
// to be detected and broken at teardown.
188+
static void GcMark(JSRuntime *rt, JSValueConst val, JS_MarkFunc *mark_func) {
189+
void* opaque = JS_GetOpaque(val, js_callback_class_id);
190+
ExternalCallback* externalCallback = reinterpret_cast<ExternalCallback*>(opaque);
191+
if (externalCallback != nullptr) {
192+
JS_MarkValue(rt, externalCallback->newTarget, mark_func);
193+
}
194+
}
195+
185196
JSValue newTarget;
186197

187198
private:
@@ -190,11 +201,7 @@ static JSValue Callback(JSContext *ctx, JSValueConst this_val, int argc, JSValue
190201
void* _data;
191202
};
192203

193-
// Reference info for preventing GC
194-
struct RefInfo {
195-
JSValue value;
196-
uint32_t count;
197-
};
204+
// Reference info for preventing GC - defined in js_native_api_quickjs.h.
198205

199206
// Initialize class IDs (allocate once globally, register per runtime)
200207
void InitClassIds(JSRuntime* rt) {
@@ -218,7 +225,7 @@ void InitClassIds(JSRuntime* rt) {
218225
JSClassDef callback_class_def = {
219226
"NapiCallback",
220227
ExternalCallback::Finalize,
221-
nullptr,
228+
ExternalCallback::GcMark,
222229
nullptr,
223230
nullptr
224231
};
@@ -943,14 +950,15 @@ static napi_status create_function_internal(napi_env env, const char* utf8name,
943950
JS_FreeAtom(env->context, nameAtom);
944951
}
945952

946-
// newTarget is set to the function itself so that constructor callbacks
947-
// can read `func.prototype`. We intentionally store it as a RAW JSValue
948-
// (no JS_DupValue) to avoid a self-cycle: `func -> func_data[callbackData]
949-
// -> opaque ExternalCallback -> newTarget -> func` (the NapiCallback class
950-
// has gc_mark=nullptr so the cycle collector cannot break this). This is
951-
// safe because `newTarget` is only read during ExternalCallback::Callback,
952-
// during which QuickJS is itself holding `func` alive.
953-
externalCallback->newTarget = func;
953+
// Release our local ref on callbackData. JS_NewCFunctionData has already
954+
// dup'd it into func's data array, so it stays alive as long as func does.
955+
JS_FreeValue(env->context, callbackData);
956+
957+
// newTarget is a strong dup of the function. This creates a cycle
958+
// func -> func_data[callbackData] -> opaque ExternalCallback -> newTarget -> func
959+
// which is safe because NapiCallback has a gc_mark (see ExternalCallback::GcMark)
960+
// that allows QuickJS's cycle collector to detect and break it.
961+
externalCallback->newTarget = JS_DupValue(env->context, func);
954962

955963
*result = FromJSValue(env, func);
956964
napi_clear_last_error(env);
@@ -1699,6 +1707,9 @@ napi_status napi_create_reference(napi_env env, napi_value value, uint32_t initi
16991707
: JS_DupValue(env->context, jsValue);
17001708
RefInfo* info = new RefInfo{ stored, initial_refcount };
17011709

1710+
// Track for env-scoped cleanup (see Napi::Detach).
1711+
env->refs_list.push_back(info);
1712+
17021713
*result = reinterpret_cast<napi_ref>(info);
17031714
napi_clear_last_error(env);
17041715
return napi_ok;
@@ -1709,12 +1720,26 @@ napi_status napi_delete_reference(napi_env env, napi_ref ref) {
17091720
CHECK_ARG(env, ref);
17101721

17111722
RefInfo* info = reinterpret_cast<RefInfo*>(ref);
1723+
if (env->detached) {
1724+
// Detach already released the JS side and cleared the tracking
1725+
// list; all we have to do is free the RefInfo allocation itself.
1726+
delete info;
1727+
return napi_ok;
1728+
}
17121729
// Only a strong reference (count > 0) owns a dup that must be freed.
17131730
// A weak reference does not own the JS value; freeing it here would be a
17141731
// spurious decrement and could even touch an already-finalized value.
17151732
if (info->count > 0) {
17161733
JS_FreeValue(env->context, info->value);
17171734
}
1735+
// Remove from env tracking.
1736+
auto& list = env->refs_list;
1737+
for (auto it = list.begin(); it != list.end(); ++it) {
1738+
if (*it == info) {
1739+
list.erase(it);
1740+
break;
1741+
}
1742+
}
17181743
delete info;
17191744

17201745
napi_clear_last_error(env);

Core/Node-API/Source/js_native_api_quickjs.h

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
#include <cassert>
77
#include <vector>
88

9+
// Reference info for preventing GC. Defined in the header so that both
10+
// the NAPI implementation and env teardown (env_quickjs.cc) can touch it.
11+
struct RefInfo {
12+
JSValue value;
13+
uint32_t count;
14+
};
15+
916
struct napi_env__ {
1017
JSContext* context = nullptr;
1118
JSContext* current_context = nullptr;
@@ -17,6 +24,20 @@ struct napi_env__ {
1724
// Handle scope storage
1825
std::vector<std::unique_ptr<JSValue>> handle_scope_stack;
1926
size_t current_scope_start = 0;
27+
28+
// Tracks every RefInfo* created by napi_create_reference so that
29+
// pending strong references can be released during Detach. Without
30+
// this, any napi_ref held by a native object (e.g. a polyfill's
31+
// Napi::FunctionReference) pins JS values, preventing QuickJS from
32+
// freeing them during teardown and causing an assertion failure in
33+
// JS_FreeRuntime.
34+
std::vector<void*> refs_list;
35+
36+
// Set to true once Detach has run. Subsequent napi_delete_reference
37+
// calls (from native destructors running during the JS teardown
38+
// cascade) must not touch the context or the (already emptied)
39+
// refs_list.
40+
bool detached = false;
2041
};
2142

2243
#define RETURN_STATUS_IF_FALSE(env, condition, status) \

0 commit comments

Comments
 (0)