Skip to content

Signal/controller handler leaks its GObject when the handler closure captures it (toggle-ref never downgrades) #455

Description

@romgrk

Summary

A connected signal handler (or event-controller handler) whose closure captures its own GObject keeps that GObject (and everything the closure transitively references) alive forever — even after every JS reference is dropped and a full GC runs with the GLib loop iterating. The toggle reference never downgrades, so the wrapper is never collected and the native object is never freed.

This is the single most common GTK pattern (widget.on('clicked', () => this.doThing()), controllers that act on the widget they're attached to), so it's an easy, silent, unbounded leak. The only way out is to manually disconnect() / removeController() before dropping the widget.

Repro

node --expose-gc repro.cjs (run in a dir where require('node-gtk') resolves):

const gi = require('node-gtk');
const Gtk = gi.require('Gtk', '4.0');
const GLib = gi.require('GLib', '2.0');
Gtk.init();
gi.startLoop();
const loop = GLib.MainLoop.new(null, false);

const persistentList = new Gtk.ListBox(); // a long-lived list, like in a real app

const variants = {
  'control: plain JS object': () => ({ x: 1 }),

  'Button + "clicked", closure captures NOTHING': () => {
    const b = new Gtk.Button();
    b.on('clicked', () => {});
    return b;
  },

  'Button + "clicked", closure CAPTURES the button': () => {
    const b = new Gtk.Button();
    b.on('clicked', () => { void b.getLabel(); });
    return b;
  },

  'Row + EventControllerKey, closure CAPTURES the row, controller left on': () => {
    const r = new Gtk.ListBoxRow();
    const c = new Gtk.EventControllerKey();
    c.on('key-pressed', () => { void r; return false; });
    r.addController(c);
    return r;
  },

  'Row + controller capturing row, removeController() before drop (workaround)': () => {
    const r = new Gtk.ListBoxRow();
    const c = new Gtk.EventControllerKey();
    c.on('key-pressed', () => { void r; return false; });
    r.addController(c);
    r.removeController(c);
    return r;
  },

  'Row append+remove on a live list, ctl closure captures row, controller left on': () => {
    const r = new Gtk.ListBoxRow();
    const c = new Gtk.EventControllerKey();
    c.on('key-pressed', () => { persistentList.unselectRow(r); return false; });
    r.addController(c);
    persistentList.append(r);
    persistentList.remove(r); // detached, but controller not removed
    return r;
  },
};

const N = 500;
const refs = Object.fromEntries(Object.keys(variants).map((k) => [k, []]));

let phase = 0;
GLib.timeoutAdd(GLib.PRIORITY_DEFAULT, 30, () => {
  if (phase === 0) {
    for (const [name, make] of Object.entries(variants))
      for (let i = 0; i < N; i++) refs[name].push(new WeakRef(make()));
    phase = 1;
    return true;
  }
  if (phase <= 6) { global.gc(); phase++; return true; } // GC + let the loop iterate
  console.log(`\nWeakRef survivors after ${N}x each, global.gc() + 6 loop iterations:\n`);
  for (const k of Object.keys(variants)) {
    let alive = 0;
    for (const w of refs[k]) if (w.deref()) alive++;
    console.log(`  ${String(alive).padStart(4)}/${N}  ${alive === 0 ? 'collected' : 'LEAK    '}  ${k}`);
  }
  loop.quit();
  return false;
});
loop.run();

Observed output

WeakRef survivors after 500x each, global.gc() + 6 loop iterations:
     0/500  collected  control: plain JS object
     0/500  collected  Button + "clicked", closure captures NOTHING
   500/500  LEAK       Button + "clicked", closure CAPTURES the button
   500/500  LEAK       Row + EventControllerKey, closure CAPTURES the row, controller left on
     0/500  collected  Row + controller capturing row, removeController() before drop (workaround)
   500/500  LEAK       Row append+remove on a live list, ctl closure captures row, controller left on

WeakRef survivors after GC == wrappers node-gtk is keeping strong-rooted == native objects never freed. The discriminator is whether the handler closure references the object, not whether the object is parented/detached.

Analysis

node-gtk keeps a strong persistent handle on the signal/closure for as long as the handler stays connected (so the closure can't be GC'd while it might still fire). When that closure captures the wrapper, the cycle is:

GObject  --(connected handler, strong global handle)-->  JS closure  --(captured var)-->  JS wrapper  --(toggle-ref)-->  GObject

The toggle reference only goes weak when the GObject's refcount drops to "JS holds the last ref", but the strong closure handle keeps the wrapper reachable, so that downgrade never happens → the object lives forever. GJS avoids this (its closures don't strongly root the emitter wrapper).

Impact

Any widget.on(signal, () => …captures widget…) or controller-acts-on-its-widget leaks the entire subtree/graph reachable from the captured reference unless explicitly disconnected. In a real app (a multibuffer/editor view rebuilt per search) this leaked one editor + its document/buffer/syntax-tag graph per rebuild and grew RSS from ~0.4 GB to ~3.3 GB while the V8 heap stayed flat — a textbook "flat JS heap, growing native RSS" signature; heap snapshots showed the leaked widgets rooted at depth 1 by (Global handles) with no widget-tree path.

Workaround

disconnect() the handler / removeController() the controller before dropping the widget (last variant above collects, 0/500). Tracking and severing every such handler by hand is error-prone; ideally node-gtk would root signal closures weakly via the toggle ref (or break the wrapper→closure→wrapper cycle) so the common self-referencing pattern doesn't leak.

Environment

  • node-gtk d98c035 (v2.2.0), Node v22.22.3
  • GTK 4.22.4, GLib 2.88.1
  • Linux 7.0.12-arch1-1 (Arch Linux)

Likely the underlying cause of #215 ("Memory leak: events"); distinct from #446 (which is about Type.new() static returns — this reproduces with the new Type() constructor form).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions