Skip to content

Listener cycles in the JS binding

Generated from docs/listener-cycles.md when the site was built at 7aa23e2.

The core keeps each DOM object alive while something holds a handle to it: the document’s tree, another core object (an AbortController holds its signal, a parent node its children), or the JS binding. The binding holds a handle for each wrapper (host.ref) until the wrapper is collected, when a FinalizationRegistry callback lets go (host.unref). Wrappers of connected nodes are also held strongly, so their expandos last while the document owns them.

Callbacks go the other way. A listener, an on* handler, an abort algorithm or any other function the core keeps crosses the C API as an id into the glue’s callback table (cbs in bindings/js/glue/conversions.js), which holds the function until the core drops the id. That table is a GC root.

A listener or on* closure that reaches its own target is never collected. The closure is held by cbs for as long as the core holds the listener; the core holds the listener for as long as the target lives; and the target lives while the closure reaches its wrapper. Nothing in JS’s heap sees the core’s edges, so the GC cannot find the cycle. The closure can reach the target directly or through other objects:

  • t.addEventListener('x', () => t) on an EventTarget,
  • c.signal.onabort = () => c, through the controller that holds the signal,
  • AbortSignal.any([c.signal]).onabort = () => c, through the source signal that keeps the dependent alive under the DOM’s lifetime rule for dependent signals.

Each leaks its objects and wrappers. A closure that does not reach its target (() => log.push(1)) is collected with it. A browser collects all of these, because its GC traces through the DOM. The ignored test listener_cycles_are_collected in crates/niso-quickjs/tests/platform.rs measures the three cases above; the redesign below should make it pass.

The cycle becomes collectable once every edge in it is one the JS GC traces:

  1. Callbacks held from the target’s wrapper. A listener’s or handler’s function is kept on its target’s wrapper (in a hidden list), not in cbs. The core still names it by id, and invoke finds it through the wrapper. A closure that reaches its target is then a cycle within JS’s heap.
  2. The core’s edges mirrored into JS. Once a wrapper holds the callbacks, it must stay alive whenever its object can still fire them, even when no script holds it, or the listeners are lost with it. The edges the core keeps between objects become JS references between their wrappers: a controller’s wrapper holds its signal’s, a source signal’s holds its dependents’ (while the lifetime rule applies), and a parent node’s holds its children’s. The document’s wrapper and the global are roots. A wrapper reachable from a root through these edges stays; one that is not can go, and with it the core object it held.
  3. Strong wrappers only where the core has a root of its own. Objects the core keeps alive for reasons JS cannot see (a pending fetch’s signal, a timer, a running worker) hold their wrapper strongly only for as long as that reason lasts, as connected nodes do today.

The work touches every engine the glue runs on, since each must collect cycles across wrappers (Porffor’s FinalizationRegistry never calls back, so there nothing is collected either way), and every place the core adds or removes an edge between objects must tell the binding.