Native C modules have no teardown hook that runs while their JSContext is still usable — module cleanup is deferred to JS_FreeRuntime (or never runs), leaking addon C++ state
Environment
- quickjs-ng
master (2026-08): 5cbbc67 docs: add Vayu to projects (verified on v0.13.0 too)
- Verified on Windows/clang-cl; behavior is platform-independent
- Local measurement:
JS_NewContext returns a context with reference count 551
Problem / current state
A native C module created with JS_NewCModule owns context-scoped resources that must be released at a well-defined point while the context is still usable — e.g. a C++ binding layer (jspp, Rust/nim bindings, plugin systems) that keeps class constructors, Global-style JSValues and callbacks bound to a per-context engine.
Today there is no such point:
- Attaching a
JSClassFinalizer object to the module's private_value only runs during JS_FreeRuntime's GC — after the embedder has already called JS_FreeContext; the context is no longer usable there (its global object is unreachable), so context JS APIs cannot be called safely.
- Worse, if any JSValue is still referenced from C++ (a stored callback, a registered class ctor/proto, a
Global<T>-equivalent), that finalizer never runs at all: debug builds end in assert(list_empty(&rt->gc_obj_list)), release builds silently leak every engine/bound JSValue for the host process lifetime.
Minimal observation (see repro below):
case-1: plain C module
after host JS_FreeContext : module-finalizer=0 <-- the point where cleanup SHOULD run
after JS_FreeRuntime : module-finalizer=1 <-- actually runs here (context gone)
case-2: module + one JSValue pinned from C++
after host JS_FreeContext : module-finalizer=0
after JS_FreeRuntime : module-finalizer=0 <-- never runs
Essence of the problem
Module teardown is gated on actual JSContext destruction, but a context created with JS_NewContext is effectively never destroyed by the embedder's JS_FreeContext:
- Every C function object holds
JS_DupContext(ctx) as its realm — see p->u.cfunc.realm = JS_DupContext(ctx) in js_new_c_function. JS_NewContext therefore returns with a reference count of ~550 (measured 551), i.e. 1 (embedder) + all builtin C functions.
- A host's single
JS_FreeContext only decrements (551 → 550) and early-returns; js_free_modules() is not reached, so module finalizers do not run.
- The context finally reaches 0 references only inside
JS_FreeRuntime's GC as the builtin realm references are released — at which point the context (its global object, atoms, …) is already being torn down.
- With any C++-held JSValue the GC cannot collect that object, the context never reaches 0 references, and module teardown (and the leak check) never completes.
Because every C function stores a raw reference to the context, and JSValue after JS_FreeContext is a dangling pointer (unlike V8 handles there is no safe Reset), the only safe cleanup point is while the context is still alive — which currently never exists.
What was attempted
- Addon-side hook via
private_value class finalizer (no quickjs change) — too late (runtime GC) or never (pins); validated by repro.
- Addon-side: stop C++ from pinning its own class objects (keep class ctor/proto owned by JS-side module refs; C++ keeps raw pointers) — fixes the engine-owned objects, verified, but provides no release point for user-held
Global<T>/callback JSValues, and leaks between commits in a long-lived runtime.
JS_SetModuleFinalizer(m, fn) fired from js_free_module_def — the right hook shape (context-parameter finalizer, like Node's napi_add_env_cleanup_hook), no regression on api-test/lre-test/unit JS, ASan-clean, but its trigger is module-def destruction, which per the essence above is gated on real context destruction → still fires too late or never for the pinned case.
- Eagerly running module teardown from
JS_FreeContext when the embedder drops its reference — internal realm releases also call JS_FreeContext (hundreds per context), so module teardown would over-trigger and break normal evaluation (verified: api-test fails). Distinguishing the embedder call requires routing internal realm-release sites away from the public teardown path, which changes internal accounting semantics.
Difficulty / open design questions
- The embedder's
JS_FreeContext is indistinguishable from an internal realm release by reference count, because realms inflate it. So "fire module cleanup when the host drops its last own reference" is not expressible with the current refcount alone.
- QuickJS deliberately lets functions live beyond the context via realms; contexts therefore intentionally survive the embedder's
JS_FreeContext. Any change to realm accounting (e.g. not dup-ing the context into every C function realm) must preserve function-outlives-context semantics, or it will break real embedders.
- A clean fix should give native modules a "context teardown" notification that runs while the context is still usable, independent of how many internal realm references happen to be outstanding.
Impact scope
- Affected: every embedder that hosts native C modules (
JS_NewCModule) whose modules keep context-scoped resources — JS binding layers (jspp, …), plugin systems, multi-context servers.
- Long-lived runtimes: every context that imports a native module leaks the module's C++ state for the runtime's lifetime (no finalizer ever runs). Debug builds assert; release builds leak silently; ASan does not report it (it is a never-free C++ leak, not an invalid access).
- Benign case: the
qjs CLI and other short-lived processes — process exit reclaims everything, so the leak is harmless there. This is why the issue has gone unnoticed.
Proposed direction
Give native modules a deterministic teardown callback with a usable context, fired when the embedder releases the context — e.g.:
typedef int JSModuleFinalizerFunc(JSContext *ctx, JSModuleDef *m);
void JS_SetModuleFinalizer(JSModuleDef *m, JSModuleFinalizerFunc *finalizer);
with the open question of exactly when it fires, given that the context object legitimately survives the embedder's JS_FreeContext because of realm references. Candidate triggers:
- Current: fire when the module
def is destroyed (real context destruction) — minimal, but fires late/never per this report.
- Fire when the embedder calls
JS_FreeContext (the context may still be usable) — matches Node semantics; requires distinguishing the embedder call from internal realm releases.
- Fix the realm accounting so a context is actually destroyed by the embedder's
JS_FreeContext — the largest semantic change.
Related prior art: Node napi_add_env_cleanup_hook / napi_add_env_cleanup_hook runs the addon cleanup while the environment is still alive.
Native C modules have no teardown hook that runs while their JSContext is still usable — module cleanup is deferred to
JS_FreeRuntime(or never runs), leaking addon C++ stateEnvironment
master(2026-08):5cbbc67 docs: add Vayu to projects(verified on v0.13.0 too)JS_NewContextreturns a context with reference count 551Problem / current state
A native C module created with
JS_NewCModuleowns context-scoped resources that must be released at a well-defined point while the context is still usable — e.g. a C++ binding layer (jspp, Rust/nim bindings, plugin systems) that keeps class constructors,Global-style JSValues and callbacks bound to a per-context engine.Today there is no such point:
JSClassFinalizerobject to the module'sprivate_valueonly runs duringJS_FreeRuntime's GC — after the embedder has already calledJS_FreeContext; the context is no longer usable there (its global object is unreachable), so context JS APIs cannot be called safely.Global<T>-equivalent), that finalizer never runs at all: debug builds end inassert(list_empty(&rt->gc_obj_list)), release builds silently leak every engine/bound JSValue for the host process lifetime.Minimal observation (see repro below):
Essence of the problem
Module teardown is gated on actual JSContext destruction, but a context created with
JS_NewContextis effectively never destroyed by the embedder'sJS_FreeContext:JS_DupContext(ctx)as its realm — seep->u.cfunc.realm = JS_DupContext(ctx)injs_new_c_function.JS_NewContexttherefore returns with a reference count of ~550 (measured 551), i.e. 1 (embedder) + all builtin C functions.JS_FreeContextonly decrements (551 → 550) and early-returns;js_free_modules()is not reached, so module finalizers do not run.JS_FreeRuntime's GC as the builtin realm references are released — at which point the context (its global object, atoms, …) is already being torn down.Because every C function stores a raw reference to the context, and
JSValueafterJS_FreeContextis a dangling pointer (unlike V8 handles there is no safeReset), the only safe cleanup point is while the context is still alive — which currently never exists.What was attempted
private_valueclass finalizer (no quickjs change) — too late (runtime GC) or never (pins); validated by repro.Global<T>/callback JSValues, and leaks between commits in a long-lived runtime.JS_SetModuleFinalizer(m, fn)fired fromjs_free_module_def— the right hook shape (context-parameter finalizer, like Node'snapi_add_env_cleanup_hook), no regression onapi-test/lre-test/unit JS, ASan-clean, but its trigger is module-defdestruction, which per the essence above is gated on real context destruction → still fires too late or never for the pinned case.JS_FreeContextwhen the embedder drops its reference — internal realm releases also callJS_FreeContext(hundreds per context), so module teardown would over-trigger and break normal evaluation (verified:api-testfails). Distinguishing the embedder call requires routing internal realm-release sites away from the public teardown path, which changes internal accounting semantics.Difficulty / open design questions
JS_FreeContextis indistinguishable from an internal realm release by reference count, because realms inflate it. So "fire module cleanup when the host drops its last own reference" is not expressible with the current refcount alone.JS_FreeContext. Any change to realm accounting (e.g. not dup-ing the context into every C function realm) must preserve function-outlives-context semantics, or it will break real embedders.Impact scope
JS_NewCModule) whose modules keep context-scoped resources — JS binding layers (jspp, …), plugin systems, multi-context servers.qjsCLI and other short-lived processes — process exit reclaims everything, so the leak is harmless there. This is why the issue has gone unnoticed.Proposed direction
Give native modules a deterministic teardown callback with a usable context, fired when the embedder releases the context — e.g.:
with the open question of exactly when it fires, given that the context object legitimately survives the embedder's
JS_FreeContextbecause of realm references. Candidate triggers:defis destroyed (real context destruction) — minimal, but fires late/never per this report.JS_FreeContext(the context may still be usable) — matches Node semantics; requires distinguishing the embedder call from internal realm releases.JS_FreeContext— the largest semantic change.Related prior art: Node
napi_add_env_cleanup_hook/napi_add_env_cleanup_hookruns the addon cleanup while the environment is still alive.