Skip to content

Commit bf023fb

Browse files
committed
Guard lvpy_release_callback_user_data against interpreter finalization
lv.deinit() called from an atexit hook (as pydevices' display_driver does) triggers lv_deinit()'s object-tree teardown, which fires LV_EVENT_DELETE per object during Py_FinalizeEx(). That path reaches lvpy_release_callback_user_data(), which called PyDict_Check() and PyObject_TypeCheck() on the stored callback user_data -- probing PyObject internals that are unsafe to touch once the interpreter has started finalizing, and crashing with SIGSEGV inside the type check. Guard the release path with Py_IsFinalizing() (3.13+) / _Py_IsFinalizing() (3.10-3.12, via PY_VERSION_HEX) and skip the DECREF once finalization has begun. Leaking that reference is correct at that point: the process is exiting and the memory is reclaimed by the OS regardless. Adds a subprocess-based regression test that registers an atexit hook calling lv.deinit() on a display with widgets carrying several per-registration callbacks, asserting a clean exit.
1 parent f7adf1d commit bf023fb

2 files changed

Lines changed: 101 additions & 0 deletions

File tree

src/lvpy_runtime.c

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,9 +704,24 @@ PyObject *get_callback_dict_from_user_data(void *user_data)
704704
return mp_get_callbacks(obj);
705705
}
706706

707+
/* Py_IsFinalizing() is 3.13+; _Py_IsFinalizing() covers 3.10-3.12. Both
708+
* report whether the interpreter is inside Py_FinalizeEx(). */
709+
#if PY_VERSION_HEX >= 0x030d0000
710+
#define LVPY_IS_FINALIZING() Py_IsFinalizing()
711+
#else
712+
#define LVPY_IS_FINALIZING() _Py_IsFinalizing()
713+
#endif
714+
707715
int lvpy_is_per_registration_callback_dict(void *user_data)
708716
{
709717
if (!user_data) return 0;
718+
/* During Py_FinalizeEx(), lv_deinit()'s object-tree teardown fires
719+
* LV_EVENT_DELETE per object, which reaches here through
720+
* lvpy_release_callback_user_data(). At that point PyObject internals
721+
* (the type object graph PyDict_Check/PyObject_TypeCheck walk) may
722+
* already be torn down, so probing them is unsafe. Treat user_data as
723+
* opaque once finalization has started. */
724+
if (LVPY_IS_FINALIZING()) return 0;
710725
PyObject *obj = (PyObject *)user_data;
711726
if (!PyDict_Check(obj)) return 0;
712727
PyTypeObject *base = py_get_base_obj_type();
@@ -716,6 +731,11 @@ int lvpy_is_per_registration_callback_dict(void *user_data)
716731

717732
void lvpy_release_callback_user_data(void *user_data)
718733
{
734+
/* Same finalization hazard as above: Py_DECREF touches the object's
735+
* type/refcount machinery, which is unsafe once Py_FinalizeEx() has
736+
* started tearing down the interpreter. Skip the release and leak —
737+
* the process is exiting, so this memory is reclaimed by the OS. */
738+
if (LVPY_IS_FINALIZING()) return;
719739
if (lvpy_is_per_registration_callback_dict(user_data)) {
720740
Py_DECREF((PyObject *)user_data);
721741
}

tests/test_lvgl_init.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,69 @@
11
# SPDX-License-Identifier: MIT
22
"""Unit tests for the native lvgl CPython extension."""
33

4+
import os
5+
import subprocess
6+
import sys
47
import unittest
58
from pathlib import Path
69

710

11+
_ATEXIT_DEINIT_SCRIPT = """
12+
import atexit
13+
14+
import lvgl as lv
15+
16+
lv.init()
17+
disp = lv.display_create(64, 64)
18+
disp.set_color_format(lv.COLOR_FORMAT.RGB565)
19+
buf = lv.draw_buf_create(64, 64, lv.COLOR_FORMAT.RGB565, 0)
20+
disp.set_draw_buffers(buf, None)
21+
disp.set_render_mode(lv.DISPLAY_RENDER_MODE.PARTIAL)
22+
scr = lv.screen_active()
23+
24+
widgets = []
25+
26+
27+
class Row:
28+
def __init__(self, parent, i):
29+
self.btn = lv.button(parent)
30+
self.label = lv.label(self.btn)
31+
self.label.set_text("r%d" % i)
32+
# Bound-method callbacks registered for several event types so the
33+
# per-object event dsc list has several per-registration callback
34+
# dicts by the time lv_deinit() walks it.
35+
self.btn.add_event_cb(self.on_click, lv.EVENT.CLICKED, None)
36+
self.btn.add_event_cb(self.on_value_changed, lv.EVENT.VALUE_CHANGED, None)
37+
self.btn.add_event_cb(self.on_delete, lv.EVENT.DELETE, None)
38+
39+
def on_click(self, event):
40+
self.label.set_text("clicked")
41+
42+
def on_value_changed(self, event):
43+
pass
44+
45+
def on_delete(self, event):
46+
pass
47+
48+
49+
for i in range(40):
50+
widgets.append(Row(scr, i))
51+
kept = widgets
52+
53+
54+
def _deinit_at_exit():
55+
lv.deinit()
56+
57+
58+
# pydevices' display_driver registers its LVGL teardown the same way: an
59+
# atexit hook that calls lv.deinit() during interpreter shutdown. lv_deinit()
60+
# fires LV_EVENT_DELETE per object, which used to crash inside
61+
# lvpy_release_callback_user_data (PyDict_Check / PyObject_TypeCheck on a
62+
# stored callback user_data pointer) once Python began tearing itself down.
63+
atexit.register(_deinit_at_exit)
64+
"""
65+
66+
867
class LvglInitTests(unittest.TestCase):
968
def test_exact_bindings_source_is_recorded(self):
1069
root = Path(__file__).resolve().parents[1]
@@ -54,6 +113,28 @@ def test_label_on_active_screen(self):
54113
finally:
55114
lv.deinit()
56115

116+
def test_atexit_deinit_does_not_crash(self):
117+
# Regression test for the use-after-free in
118+
# lvpy_release_callback_user_data() during interpreter finalization
119+
# (fixed by guarding the release path with Py_IsFinalizing() /
120+
# _Py_IsFinalizing()). Runs in a subprocess because the crash only
121+
# reproduces on real interpreter shutdown, not inside a running test.
122+
env = dict(os.environ)
123+
env.setdefault("SDL_VIDEODRIVER", "dummy")
124+
result = subprocess.run(
125+
[sys.executable, "-c", _ATEXIT_DEINIT_SCRIPT],
126+
env=env,
127+
capture_output=True,
128+
text=True,
129+
timeout=30,
130+
)
131+
self.assertEqual(
132+
result.returncode,
133+
0,
134+
"atexit-triggered lv.deinit() crashed (rc=%r); stdout=%r stderr=%r"
135+
% (result.returncode, result.stdout, result.stderr),
136+
)
137+
57138

58139
if __name__ == "__main__":
59140
unittest.main()

0 commit comments

Comments
 (0)