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).
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 manuallydisconnect()/removeController()before dropping the widget.Repro
node --expose-gc repro.cjs(run in a dir whererequire('node-gtk')resolves):Observed output
WeakRefsurvivors 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:
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
d98c035(v2.2.0), Node v22.22.3Likely the underlying cause of #215 ("Memory leak: events"); distinct from #446 (which is about
Type.new()static returns — this reproduces with thenew Type()constructor form).