-
-
Notifications
You must be signed in to change notification settings - Fork 23
Fix callback lifetime management using PyCapsule-based cleanup #87
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
This commit resolves memory management issues with Python callbacks, including segfaults, premature callback destruction, and resource leaks. Callbacks had multiple lifetime management issues: 1. Callbacks were freed prematurely when JS PyObject wrappers were GC'd, even though Python still held references (causing segfaults) 2. The same Python object could have multiple PyObject wrappers, each registering with refregistry and causing duplicate Py_DecRef calls 3. Resource leaks occurred when callbacks were never properly cleaned up 4. Auto-created callbacks (from JS functions) had no cleanup mechanism Implement PyCapsule-based callback cleanup: 1. **PyCapsule destructor**: When creating a callback, we: - Create a PyCapsule containing the callback's handle - Use the capsule as the m_self parameter of PyCFunction_NewEx - Register a destructor that Python calls when freeing the PyCFunction - In the destructor, remove the callback from callbackRegistry and destroy it 2. **Separate lifetime tracking**: Callbacks use callbackRegistry instead of refregistry. They are NOT registered with refregistry because: - The initial reference from PyCFunction_NewEx is sufficient - Cleanup happens via the capsule destructor, not FinalizationRegistry - This prevents artificial refcount inflation 3. **Handle-based deduplication**: Track registered handles in a Set (not PyObject instances) to prevent duplicate registration when multiple PyObject wrappers exist for the same Python object 4. **Auto-created callback cleanup**: callbackCleanupRegistry handles callbacks created from raw JS functions that are never passed to Python - src/python.ts: Implement PyCapsule-based callback cleanup - src/symbols.ts: Add PyCapsule_New and PyCapsule_GetPointer symbols - test/test_with_gc.ts: Add proper cleanup to tests All existing tests pass with resource sanitization on
…tration Callbacks are now only added to callbackRegistry when .owned is called (i.e., when actually passed to Python), not during creation. This allows callbackCleanupRegistry to properly clean up callbacks that are created but never passed to Python. Changes: - Store Callback reference in PyObject.#callback during creation - Register with callbackRegistry in .owned getter when callback is passed to Python - Add test case for auto-created callbacks that are never passed to Python
|
This is a mini version of the stress test: ( an important part is spamming GC) import {
kw,
NamedArgument,
type PyObject,
python,
} from "jsr:@denosaurs/python@0.4.6";
import {
type Adw1_ as Adw_,
DenoGLibEventLoop,
type Gtk4_ as Gtk_,
} from "jsr:@sigma/gtk-py@0.7.0";
const gi = python.import("gi");
gi.require_version("Gtk", "4.0");
gi.require_version("Adw", "1");
const Gtk: Gtk_.Gtk = python.import("gi.repository.Gtk");
const Adw: Adw_.Adw = python.import("gi.repository.Adw");
const GLib = python.import("gi.repository.GLib");
const gcp = python.import("gc");
const el = new DenoGLibEventLoop(GLib); // this is important so setInterval works (by unblockig deno async event loop)
const gcInterval = setInterval(() => {
gcp.collect();
gc();
}, 100);
class MainWindow extends Gtk.ApplicationWindow {
#state = false;
#f?: PyObject;
constructor(kwArg: NamedArgument) {
// deno-lint-ignore no-explicit-any
super(kwArg as any);
this.set_default_size(300, 150);
this.set_title("Awaker");
this.connect("close-request", () => {
el.stop();
clearInterval(gcInterval);
return false;
});
const button = Gtk.ToggleButton(
new NamedArgument("label", "OFF"),
);
const f = python.callback(this.onClick);
button.connect("clicked", f);
const vbox = Gtk.Box(
new NamedArgument("orientation", Gtk.Orientation.VERTICAL),
);
vbox.append(button);
this.set_child(vbox);
}
// deno-lint-ignore no-explicit-any
onClick = (_: any, button: Gtk_.ToggleButton) => {
this.#state = !this.#state;
(this.#state) ? button.set_label("ON") : button.set_label("OFF");
};
}
class App extends Adw.Application {
#win: MainWindow | undefined;
constructor(kwArg: NamedArgument) {
super(kwArg);
this.connect("activate", this.onActivate);
}
onActivate = python.callback((_kwarg, app: Gtk_.Application) => {
new MainWindow(new NamedArgument("application", app)).present();
});
}
const app = new App(kw`application_id=${"com.example.com"}`);
app.register();
app.activate();
el.start(); |
|
The above example is what motivated this: I couldn't make this test more minimal or make it a standalone test with no gtk involved, but the point being in a relativey complex project, JS might not see that some callbacks are still needed by python So to fix this I see 2 solutions:
The pr seems to work well so far |
eliassjogreen
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wow thanks! I looked through the code and can't see anything that's obviously wrong, but it's as you say not the simplest issue nor piece of code... Well documented and easy to follow too! <3
|
Thanks I added a small cleanup, turns out bun tests runs with each commit that's pretty cool, I think its ready to merge |
|
ok unfortunately I tested more complex stuff and I get a segfault when misusing some python libraries both in the latest version of this library and in this PR, here is a breakdown in case someone-else want to tackle this in the future to reproduce the segfault:
it segfaults when calling eq after a while (reproducible 99% of the time) by commenting out these 2 lines Line 868 in 4a64342
so what happened , the gist seems to be that z3 internal bindings to c++ and python make this complexity 2 folds, anyhow here is my discussion with claude https://claude.ai/share/09c47928-4fcb-497a-9a48-44c18f4d51d3 this is the last summery of it So I'm going to open a pr that makes auto-callbacks just leak always , so we can just document that its a convenience function and if the user want to control memory they should use python.callback |
|
closed in favor of #89 |

This PR is a +10 hour debugging with + 200 requests to claude, The motivation is I had a "stress" program that discovered many issues, and so this pr:
This commit resolves memory management issues with Python callbacks, including segfaults, premature callback destruction, and resource leaks.
Callbacks had multiple lifetime management issues:
Implement PyCapsule-based callback cleanup:
PyCapsule destructor: When creating a callback, we:
Separate lifetime tracking: Callbacks use callbackRegistry instead of refregistry. They are NOT registered with refregistry because:
Handle-based deduplication: Track registered handles in a Set (not PyObject instances) to prevent duplicate registration when multiple PyObject wrappers exist for the same Python object
Auto-created callback cleanup: callbackCleanupRegistry handles callbacks created from raw JS functions that are never passed to Python
All existing tests pass with resource sanitization on