@@ -71,7 +71,7 @@ struct CallbackInfo {
7171class ExternalCallback {
7272 public:
7373 ExternalCallback (napi_env env, napi_callback cb, void * data)
74- : _env(env), _cb(cb), _data(data), newTarget(JS_UNDEFINED) {}
74+ : _env(env), _cb(cb), _data(data) {}
7575
7676static JSValue Callback (JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv, int magic, JSValue *func_data) {
7777 ExternalCallback* externalCallback = reinterpret_cast <ExternalCallback*>(JS_GetOpaque (func_data[0 ], js_callback_class_id));
@@ -94,19 +94,22 @@ static JSValue Callback(JSContext *ctx, JSValueConst this_val, int argc, JSValue
9494
9595 JSValue actualThis = JS_UNDEFINED;
9696 bool isConstructCall = (magic == 1 );
97-
98- // Handle constructor call
97+
98+ // For JS_CLASS_C_FUNCTION_DATA constructor calls, QuickJS passes new_target
99+ // as this_val. Use it directly instead of storing the function as a raw
100+ // JSValue on ExternalCallback — doing so created a self-referencing C-level
101+ // ref that QuickJS GC could not see, leaking the function permanently.
99102 if (isConstructCall) {
100- JSValue prototypeProperty = JS_GetPropertyStr (ctx, externalCallback-> newTarget , " prototype" );
101-
103+ JSValue prototypeProperty = JS_GetPropertyStr (ctx, this_val , " prototype" );
104+
102105 if (JS_IsException (prototypeProperty)) {
103106 externalCallback->_env ->current_context = savedCtx; // RESTORE
104107 return JS_EXCEPTION;
105108 }
106-
109+
107110 actualThis = JS_NewObjectProto (ctx, prototypeProperty);
108111 JS_FreeValue (ctx, prototypeProperty);
109-
112+
110113 if (JS_IsException (actualThis)) {
111114 externalCallback->_env ->current_context = savedCtx; // RESTORE
112115 return JS_EXCEPTION;
@@ -117,7 +120,12 @@ static JSValue Callback(JSContext *ctx, JSValueConst this_val, int argc, JSValue
117120
118121 CallbackInfo cbInfo;
119122 cbInfo.thisArg = reinterpret_cast <napi_value>(&actualThis);
120- cbInfo.newTarget = reinterpret_cast <napi_value>(&externalCallback->newTarget );
123+ // For constructor calls, this_val IS new_target (the constructor function
124+ // itself). Cast away const for the napi_value contract; the callee must not
125+ // mutate it during the call.
126+ cbInfo.newTarget = isConstructCall
127+ ? reinterpret_cast <napi_value>(const_cast <JSValue*>(&this_val))
128+ : nullptr ;
121129 cbInfo.isConstructCall = isConstructCall;
122130 cbInfo.argc = argc;
123131 cbInfo.argv = args.empty () ? nullptr : args.data ();
@@ -176,25 +184,16 @@ static JSValue Callback(JSContext *ctx, JSValueConst this_val, int argc, JSValue
176184 void * opaque = JS_GetOpaque (val, js_callback_class_id);
177185 ExternalCallback* externalCallback = reinterpret_cast <ExternalCallback*>(opaque);
178186 if (externalCallback != nullptr ) {
179- JS_FreeValueRT (rt, externalCallback->newTarget );
180187 delete externalCallback;
181188 }
182189 }
183190
184- JSValue newTarget;
185-
186191 private:
187192 napi_env _env;
188193 napi_callback _cb;
189194 void * _data;
190195};
191196
192- // Reference info for preventing GC
193- struct RefInfo {
194- JSValue value;
195- uint32_t count;
196- };
197-
198197// Initialize class IDs (allocate once globally, register per runtime)
199198void InitClassIds (JSRuntime* rt) {
200199 if (!class_ids_allocated) {
@@ -942,9 +941,11 @@ static napi_status create_function_internal(napi_env env, const char* utf8name,
942941 JS_FreeAtom (env->context , nameAtom);
943942 }
944943
945- externalCallback->newTarget = JS_DupValue (env->context , func);
946-
947944 *result = FromJSValue (env, func);
945+ // Release our reference on the opaque callback object: the cfunction
946+ // retains it via func_data (which dups), so it stays alive as long as the
947+ // cfunction does. Without this the opaque object leaks (ref_count stuck at 2).
948+ JS_FreeValue (env->context , callbackData);
948949 napi_clear_last_error (env);
949950 return napi_ok;
950951}
@@ -1682,8 +1683,29 @@ napi_status napi_create_reference(napi_env env, napi_value value, uint32_t initi
16821683 CHECK_ARG (env, result);
16831684
16841685 JSValue jsValue = ToJSValue (value);
1685- RefInfo* info = new RefInfo{ JS_DupValue (env->context , jsValue), initial_refcount };
1686-
1686+
1687+ // initial_refcount == 0 means a WEAK reference per NAPI semantics. Holding
1688+ // a duplicated JSValue in that case would create an invisible-to-GC C-level
1689+ // root and prevent QuickJS from ever collecting wrapped objects (which is
1690+ // the root cause of the assert(list_empty(&rt->gc_obj_list)) failure in
1691+ // JS_FreeRuntime, since napi_wrap calls napi_create_reference with
1692+ // initial_refcount == 0).
1693+ RefInfo* info;
1694+ if (initial_refcount > 0 ) {
1695+ info = new RefInfo{ JS_DupValue (env->context , jsValue), initial_refcount };
1696+ } else {
1697+ // Weak: don't dup. The stored JSValue is non-owning and must not be
1698+ // dereferenced once count is back to 0 unless it has been re-strengthened.
1699+ info = new RefInfo{ jsValue, 0 };
1700+ }
1701+
1702+ // Track so Detach() can release any strong holds before JS_FreeRuntime runs
1703+ // its terminal GC. Skip tracking once shutting down — env state is being
1704+ // torn down and we must not mutate it from finalizer-driven calls.
1705+ if (!env->is_shutting_down ) {
1706+ env->refs .insert (info);
1707+ }
1708+
16871709 *result = reinterpret_cast <napi_ref>(info);
16881710 napi_clear_last_error (env);
16891711 return napi_ok;
@@ -1694,7 +1716,21 @@ napi_status napi_delete_reference(napi_env env, napi_ref ref) {
16941716 CHECK_ARG (env, ref);
16951717
16961718 RefInfo* info = reinterpret_cast <RefInfo*>(ref);
1697- JS_FreeValue (env->context , info->value );
1719+
1720+ // Even during shutdown we MUST drop any strong JS hold this reference still
1721+ // owns. Detach() drains env->refs but cannot reach RefInfo objects that are
1722+ // created after, or that the drain skipped because they were processed by
1723+ // a finalizer racing the loop. Leaving info->count > 0 with a live value
1724+ // keeps that JSValue alive past JS_FreeContext and fails the
1725+ // assert(list_empty(&rt->gc_obj_list)) inside JS_FreeRuntime.
1726+ if (!env->is_shutting_down ) {
1727+ env->refs .erase (info);
1728+ }
1729+ if (info->count > 0 ) {
1730+ JS_FreeValue (env->context , info->value );
1731+ info->value = JS_UNDEFINED;
1732+ info->count = 0 ;
1733+ }
16981734 delete info;
16991735
17001736 napi_clear_last_error (env);
@@ -1704,31 +1740,43 @@ napi_status napi_delete_reference(napi_env env, napi_ref ref) {
17041740napi_status napi_reference_ref (napi_env env, napi_ref ref, uint32_t * result) {
17051741 CHECK_ENV (env);
17061742 CHECK_ARG (env, ref);
1707-
1743+
17081744 RefInfo* info = reinterpret_cast <RefInfo*>(ref);
1745+ // Going 0 -> 1 must promote a weak ref to a strong one by duping the value
1746+ // so the JS object can no longer be collected from under us.
1747+ if (info->count == 0 && !env->is_shutting_down ) {
1748+ info->value = JS_DupValue (env->context , info->value );
1749+ }
17091750 info->count ++;
1710-
1751+
17111752 if (result != nullptr ) {
17121753 *result = info->count ;
17131754 }
1714-
1755+
17151756 napi_clear_last_error (env);
17161757 return napi_ok;
17171758}
17181759
17191760napi_status napi_reference_unref (napi_env env, napi_ref ref, uint32_t * result) {
17201761 CHECK_ENV (env);
17211762 CHECK_ARG (env, ref);
1722-
1763+
17231764 RefInfo* info = reinterpret_cast <RefInfo*>(ref);
17241765 if (info->count > 0 ) {
17251766 info->count --;
1767+ // Going 1 -> 0 demotes back to weak: release the strong JS hold. We must
1768+ // do this even during shutdown to avoid pinning JSValues past
1769+ // JS_FreeContext / JS_FreeRuntime (gc_obj_list assertion).
1770+ if (info->count == 0 ) {
1771+ JS_FreeValue (env->context , info->value );
1772+ info->value = JS_UNDEFINED;
1773+ }
17261774 }
1727-
1775+
17281776 if (result != nullptr ) {
17291777 *result = info->count ;
17301778 }
1731-
1779+
17321780 napi_clear_last_error (env);
17331781 return napi_ok;
17341782}
@@ -2184,8 +2232,12 @@ napi_status napi_create_promise(napi_env env, napi_deferred* deferred, napi_valu
21842232 JS_SetPropertyStr (env->context , container, " reject" , resolving_funcs[1 ]);
21852233
21862234 // Create reference directly from container WITHOUT going through FromJSValue
2187- // to avoid double-ownership (handle scope + reference)
2235+ // to avoid double-ownership (handle scope + reference). Track in env->refs
2236+ // so Detach() can release the strong hold if the promise is never settled.
21882237 RefInfo* refInfo = new RefInfo{ container, 1 }; // Use refcount 1 for immediate access
2238+ if (!env->is_shutting_down ) {
2239+ env->refs .insert (refInfo);
2240+ }
21892241 napi_ref ref = reinterpret_cast <napi_ref>(refInfo);
21902242
21912243 *deferred = reinterpret_cast <napi_deferred>(ref);
0 commit comments