Fix callback/timer GC: prevent premature destruction of Python callbacks

closes #251

Two related bugs where Python garbage collection destroyed callbacks
that were still needed by live C++ objects:

1. **Drawable callbacks (all 8 types)**: tp_dealloc unconditionally called
   click_unregister() etc., destroying callbacks even when the C++ object
   was still alive in a parent's children vector. Fixed by guarding with
   shared_ptr::use_count() <= 1 — only unregister when the Python wrapper
   is the last owner.

2. **Timer GC prevention**: Active timers now hold a Py_INCREF'd reference
   to their Python wrapper (Timer::py_wrapper), preventing GC while the
   timer is registered in the engine. Released on stop(), one-shot fire,
   or destruction. mcrfpy.Timer("name", cb, 100) now works without storing
   the return value.

Also includes audio synth demo UI fixes: button click handling (don't set
on_click on Caption children), single-column slider layout, improved
Animalese contrast.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-02-19 20:53:50 -05:00
commit 9718153709
15 changed files with 740 additions and 231 deletions

View file

@ -415,7 +415,8 @@ inline PyTypeObject PyViewport3DType = {
if (obj->weakreflist != NULL) {
PyObject_ClearWeakRefs(self);
}
if (obj->data) {
// Only unregister callbacks if we're the last owner (#251)
if (obj->data && obj->data.use_count() <= 1) {
obj->data->click_unregister();
obj->data->on_enter_unregister();
obj->data->on_exit_unregister();

View file

@ -101,6 +101,8 @@ int PyTimer::init(PyTimerObject* self, PyObject* args, PyObject* kwds) {
it->second->stop();
}
Resources::game->timers[self->name] = self->data;
// Prevent Python GC while timer is active (#251)
self->data->retainPyWrapper((PyObject*)self);
}
return 0;
@ -147,6 +149,8 @@ PyObject* PyTimer::start(PyTimerObject* self, PyObject* Py_UNUSED(ignored)) {
}
self->data->start(current_time);
// Prevent Python GC while timer is active (#251)
self->data->retainPyWrapper((PyObject*)self);
Py_RETURN_NONE;
}
@ -218,6 +222,8 @@ PyObject* PyTimer::restart(PyTimerObject* self, PyObject* Py_UNUSED(ignored)) {
}
self->data->restart(current_time);
// Prevent Python GC while timer is active (#251)
self->data->retainPyWrapper((PyObject*)self);
Py_RETURN_NONE;
}
@ -316,6 +322,8 @@ int PyTimer::set_active(PyTimerObject* self, PyObject* value, void* closure) {
Resources::game->timers[self->name] = self->data;
}
self->data->start(current_time);
// Prevent Python GC while timer is active (#251)
self->data->retainPyWrapper((PyObject*)self);
} else if (self->data->isPaused()) {
// Resume from pause
self->data->resume(current_time);

View file

@ -15,11 +15,24 @@ Timer::Timer()
{}
Timer::~Timer() {
releasePyWrapper();
if (serial_number != 0) {
PythonObjectCache::getInstance().remove(serial_number);
}
}
void Timer::retainPyWrapper(PyObject* wrapper) {
if (py_wrapper == wrapper) return; // Already held
Py_XDECREF(py_wrapper);
py_wrapper = wrapper;
Py_XINCREF(py_wrapper);
}
void Timer::releasePyWrapper() {
Py_XDECREF(py_wrapper);
py_wrapper = nullptr;
}
bool Timer::hasElapsed(int now) const
{
if (paused || stopped) return false;
@ -71,6 +84,7 @@ bool Timer::test(int now)
// Handle one-shot timers: stop but preserve callback for potential restart
if (once) {
stopped = true; // Will be removed from map by testTimers(), but callback preserved
releasePyWrapper(); // Allow Python GC now (#251)
}
return true;
@ -123,6 +137,7 @@ void Timer::stop()
paused = false;
pause_start_time = 0;
total_paused_time = 0;
releasePyWrapper(); // Allow Python GC now that timer is inactive (#251)
}
bool Timer::isActive() const

View file

@ -29,6 +29,11 @@ public:
uint64_t serial_number = 0; // For Python object cache
std::string name; // Store name for creating Python wrappers (#180)
// Strong reference to Python wrapper prevents GC while timer is active (#251)
PyObject* py_wrapper = nullptr;
void retainPyWrapper(PyObject* wrapper);
void releasePyWrapper();
Timer(); // for map to build
Timer(PyObject* target, int interval, int now, bool once = false, bool start = true, const std::string& name = "");
~Timer();

View file

@ -122,7 +122,8 @@ namespace mcrfpydef {
if (obj->weakreflist != NULL) {
PyObject_ClearWeakRefs(self);
}
if (obj->data) {
// Only unregister callbacks if we're the last owner (#251)
if (obj->data && obj->data.use_count() <= 1) {
obj->data->click_unregister();
obj->data->on_enter_unregister();
obj->data->on_exit_unregister();

View file

@ -65,8 +65,8 @@ namespace mcrfpydef {
if (obj->weakreflist != NULL) {
PyObject_ClearWeakRefs(self);
}
// Clear Python references to break cycles
if (obj->data) {
// Only unregister callbacks if we're the last owner (#251)
if (obj->data && obj->data.use_count() <= 1) {
obj->data->click_unregister();
obj->data->on_enter_unregister();
obj->data->on_exit_unregister();

View file

@ -111,7 +111,8 @@ namespace mcrfpydef {
if (obj->weakreflist != NULL) {
PyObject_ClearWeakRefs(self);
}
if (obj->data) {
// Only unregister callbacks if we're the last owner (#251)
if (obj->data && obj->data.use_count() <= 1) {
obj->data->click_unregister();
obj->data->on_enter_unregister();
obj->data->on_exit_unregister();

View file

@ -90,8 +90,10 @@ namespace mcrfpydef {
if (obj->weakreflist != NULL) {
PyObject_ClearWeakRefs(self);
}
// Clear Python references to break cycles
if (obj->data) {
// Only unregister callbacks if we're the last owner of the
// C++ object. If other shared_ptr owners exist (e.g. parent's
// children vector), the callbacks must survive. (#251)
if (obj->data && obj->data.use_count() <= 1) {
obj->data->click_unregister();
obj->data->on_enter_unregister();
obj->data->on_exit_unregister();

View file

@ -269,8 +269,8 @@ namespace mcrfpydef {
if (obj->weakreflist != NULL) {
PyObject_ClearWeakRefs(self);
}
// Clear Python references to break cycles
if (obj->data) {
// Only unregister callbacks if we're the last owner (#251)
if (obj->data && obj->data.use_count() <= 1) {
obj->data->click_unregister();
obj->data->on_enter_unregister();
obj->data->on_exit_unregister();

View file

@ -108,7 +108,8 @@ namespace mcrfpydef {
if (obj->weakreflist != NULL) {
PyObject_ClearWeakRefs(self);
}
if (obj->data) {
// Only unregister callbacks if we're the last owner (#251)
if (obj->data && obj->data.use_count() <= 1) {
obj->data->click_unregister();
obj->data->on_enter_unregister();
obj->data->on_exit_unregister();

View file

@ -97,8 +97,8 @@ namespace mcrfpydef {
if (obj->weakreflist != NULL) {
PyObject_ClearWeakRefs(self);
}
// Clear Python references to break cycles
if (obj->data) {
// Only unregister callbacks if we're the last owner (#251)
if (obj->data && obj->data.use_count() <= 1) {
obj->data->click_unregister();
obj->data->on_enter_unregister();
obj->data->on_exit_unregister();