Skip to content

native C modules have no cleanup hook that runs while the JSContext is still usable #1703

Description

@engsr6982

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:

  1. 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.
  2. 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

  1. Addon-side hook via private_value class finalizer (no quickjs change) — too late (runtime GC) or never (pins); validated by repro.
  2. 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.
  3. 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.
  4. 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:

  1. Current: fire when the module def is destroyed (real context destruction) — minimal, but fires late/never per this report.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions