Gtk template signals cause a reference cycle that is not detected
System information
Fedora 38, GJS 6da182f4
Bug information
Steps to reproduce
- Run the following script with
GOBJECT_DEBUG=instance-count
. You must use a debug build of GLib for that to work; the GNOME flatpak SDK is probably easiest.
import GObject from 'gi://GObject?version=2.0';
import GLib from 'gi://GLib?version=2.0';
import Gtk from 'gi://Gtk?version=4.0';
const ByteArray = imports.byteArray;
if (Gtk.MAJOR_VERSION === 3) {
Gtk.init(null);
} else {
Gtk.init();
}
class MyWidget extends Gtk.Button {
test() {
console.log('Hello, world!');
}
}
GObject.registerClass(
{
Template: ByteArray.fromString(`
<interface>
<template class="Gjs_MyWidget">
<property name="visible">true</property>
<!-- <signal name="clicked" handler="test"/> -->
</template>
</interface>
`),
},
MyWidget
);
function run() {
const window = new Gtk.Window();
window.set_default_size(200, 200);
window.child = new MyWidget();
window.show();
}
run();
if (Gtk.MAJOR_VERSION === 3) {
Gtk.main();
} else {
const ml = new GLib.MainLoop(null, false);
ml.run();
}
- Click the button and see that "Hello, world!" is printed, as expected.
- Open the GTK Inspector, go to the Global tab, go to Statistics in the sidebar, and click the circle button to start refreshing. Scroll to "Gjs_MyWidget" and see that there is 1.
- Close the window. You should soon see the number of Gjs_MyWidget drop to 0, as expected.
- Uncomment the
<signal/>
line in the template and retry the steps above. This time the Gjs_MyWidget is never freed, even after the window is closed.
This is a memory leak. Even after the widget is no longer held by the window or referenced in JavaScript, it is not disposed.
The issue is that connecting the signal handler creates a reference cycle: MyWidget -> signal handlers -> Gjs::Closure -> m_callable -> test.bind(MyWidget) -> MyWidget. Because the MyWidget -> Gjs::Closure link of the reference cycle is in GObject, the garbage collector cannot detect it. The callable, test.bind(MyWidget), is rooted until the Gjs::Closure is disposed, but it never will be because test.bind(MyWidget) refers to MyWidget, which keeps the signal handlers and thus the closure alive.
Also, compare the implementation of GtkCBuilderScope. It uses g_cclosure_new_object
, which does not hold a ref on the object and invalidates the closure when the object is disposed. Currently in GJS it is the opposite: the closure, through JavaScript, holds a reference on the object, but has no way of even knowing which object its lifetime should be bound to.