Skip to content
This repository was archived by the owner on Oct 30, 2026. It is now read-only.
This repository was archived by the owner on Oct 30, 2026. It is now read-only.

CPython: should add_event_cb callbacks outlive their deleted widget? #33

Closed
PyDevices/lvgl-python
#30

Description

@bdbarnett

On CPython, a callback passed to add_event_cb stays alive after its widget is deleted and the widget's Python wrapper is dropped. MicroPython and CircuitPython free it. Is that intended?

The CPython runtime keeps a widget's callbacks in a dict on its Python wrapper, and LVGL's side only points at the wrapper. lvgl-python's guide describes the contract as: keep a Python reference to any widget that owns callbacks. From that, you'd expect a callback to go when the wrapper does. It doesn't, apparently because mp_lv_callback in src/lvpy_runtime.c adds one more reference to the callback after storing it in that dict:

PyDict_SetItemString(callbacks, callback_name, py_callback);
Py_INCREF(py_callback);

PyDict_SetItemString already holds its own reference, and nothing releases the extra one. The line has been there since the runtime was first added, with no comment, and no test checks callback lifetime. It could be deliberate, keeping a callback alive for code that drops its widget reference, or it could be a leftover. The per-registration dict path a few lines up (PyDict_New followed by Py_INCREF) raises the same question.

To reproduce, with pydevices-lvgl 9.5.50 on CPython 3.12:

import gc, weakref
import display_driver
import lvgl as lv

class F:
    def __call__(self, e):
        pass

scr = lv.obj(None)
lv.screen_load(scr)
b = lv.button(scr)
f = F()
ref = weakref.ref(f)
b.add_event_cb(f, lv.EVENT.CLICKED, None)
f = None
lv.screen_load(lv.obj(None))
scr.delete()
b = scr = None
gc.collect()
print("callback alive:", ref() is not None)   # True

Where it shows up: an app that builds a screen, deletes it and builds another keeps every callback it ever registered, and everything those callbacks reference. On MicroPython and CircuitPython, the same cycle run 50 times, with each callback holding a 20 KB buffer, loses under 2 KB of heap.

Activity

  1. changed the title [-]CPython: add_event_cb callbacks outlive their deleted widget[/-] [+]CPython: should a deleted widget release its event callbacks?[/+] on Oct 12, 2026
  2. changed the title [-]CPython: should a deleted widget release its event callbacks?[/-] [+]CPython: should add_event_cb callbacks outlive their deleted widget?[/+] on Oct 12, 2026
  3. added a commit that references this issue on Oct 12, 2026
    b4ed744
  4. bdbarnett commented on Oct 12, 2026

    @bdbarnett
    ContributorAuthor

    Not intended: it was a leak, and it's fixed in lvgl-python#30, now on main. It reaches PyPI with the next pydevices-lvgl release.

    What was going on: with user_data=None, add_event_cb stores the handler in a small dict of its own (not on the widget's wrapper) and hands LVGL a pointer to that dict. The runtime releases one reference to the dict when the event is removed or the object deleted, which is the right design, but mp_lv_callback created the dict with two references and took a third one on the handler. Nothing matched those, so the dict, the handler and whatever the handler held outlived the widget. Your reproduction prints callback alive: True on 9.5.50 and False with the fix; 50 build-and-delete cycles with 20 KB per handler grew the heap by 10.4 MB before and by under 10 KB after.

    Just deleting the extra increfs isn't enough, which may be why the line survived. LVGL sends LV_EVENT_DELETE to the runtime's own hook before a DELETE callback you registered on the same object, so an immediate release freed the dict that your callback was about to read (a segfault under MALLOC_PERTURB_). The reference is now dropped once no LVGL call and no Python callback is in flight.

    The contract, as the README now says: a callback lives as long as its registration. It is released when the widget is deleted or the event removed. Dropping your Python reference to the widget while it exists neither stops the callback nor leaks it, and a handler may delete its own widget. tests/test_callback_lifetime.py in lvgl-python pins each of those cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions