|
| 1 | +# SPDX-License-Identifier: MIT |
| 2 | +"""References a callback trampoline holds while LVGL calls into Python. |
| 3 | +
|
| 4 | +- A callback that replaces its own registration while it runs |
| 5 | + (``timer.set_cb(other)`` from inside the timer callback) stays alive until |
| 6 | + it returns. Before, the only reference was the callback dict's, so the |
| 7 | + replacement freed the callback mid-call. |
| 8 | +- Arguments passed to a callback are released after the call. Before, every |
| 9 | + call leaked one reference to each argument, so a widget handed to a |
| 10 | + ``tree_walk`` callback was never freed. |
| 11 | +
|
| 12 | +The checks run in one subprocess under MALLOC_PERTURB_ so a use-after-free |
| 13 | +shows up as a crash rather than luck. |
| 14 | +""" |
| 15 | + |
| 16 | +import json |
| 17 | +import os |
| 18 | +import subprocess |
| 19 | +import sys |
| 20 | +import unittest |
| 21 | + |
| 22 | + |
| 23 | +_SCRIPT = r""" |
| 24 | +import gc |
| 25 | +import json |
| 26 | +import sys |
| 27 | +import weakref |
| 28 | +
|
| 29 | +import lvgl as lv |
| 30 | +
|
| 31 | +lv.init() |
| 32 | +disp = lv.display_create(64, 64) |
| 33 | +disp.set_color_format(lv.COLOR_FORMAT.RGB565) |
| 34 | +buf = lv.draw_buf_create(64, 64, lv.COLOR_FORMAT.RGB565, 0) |
| 35 | +disp.set_draw_buffers(buf, None) |
| 36 | +disp.set_render_mode(lv.DISPLAY_RENDER_MODE.PARTIAL) |
| 37 | +scr = lv.screen_active() |
| 38 | +out = {} |
| 39 | +
|
| 40 | +
|
| 41 | +def pump(ms): |
| 42 | + for _ in range(ms // 5): |
| 43 | + lv.tick_inc(5) |
| 44 | + lv.timer_handler() |
| 45 | +
|
| 46 | +
|
| 47 | +# A callback that replaces itself stays alive until it returns. |
| 48 | +timer = lv.timer_create_basic() |
| 49 | +timer.set_period(10) |
| 50 | +
|
| 51 | +
|
| 52 | +class Replaces: |
| 53 | + def __call__(self, t): |
| 54 | + ref = weakref.ref(self) |
| 55 | + del self |
| 56 | + timer.set_cb(lambda t: None) |
| 57 | + gc.collect() |
| 58 | + out["alive_after_replacing_itself"] = ref() is not None |
| 59 | +
|
| 60 | +
|
| 61 | +timer.set_cb(Replaces()) |
| 62 | +pump(50) |
| 63 | +timer.delete() |
| 64 | +
|
| 65 | +
|
| 66 | +# Callback arguments are released after the call. |
| 67 | +child = lv.button(scr) |
| 68 | +
|
| 69 | +def walk(obj, user_data): |
| 70 | + return lv.obj.TREE_WALK.NEXT |
| 71 | +
|
| 72 | +before = sys.getrefcount(child) |
| 73 | +for _ in range(10): |
| 74 | + scr.tree_walk(walk, None) |
| 75 | +out["tree_walk_arg_refcount_delta"] = sys.getrefcount(child) - before |
| 76 | +
|
| 77 | +child.delete() |
| 78 | +lv.deinit() |
| 79 | +print("RESULT " + json.dumps(out)) |
| 80 | +""" |
| 81 | + |
| 82 | + |
| 83 | +def _run(): |
| 84 | + env = dict(os.environ) |
| 85 | + env["MALLOC_PERTURB_"] = "165" |
| 86 | + proc = subprocess.run( |
| 87 | + [sys.executable, "-c", _SCRIPT], |
| 88 | + capture_output=True, |
| 89 | + text=True, |
| 90 | + timeout=120, |
| 91 | + env=env, |
| 92 | + ) |
| 93 | + for line in proc.stdout.splitlines(): |
| 94 | + if line.startswith("RESULT "): |
| 95 | + return proc, json.loads(line[len("RESULT "):]) |
| 96 | + return proc, None |
| 97 | + |
| 98 | + |
| 99 | +class CallbackReferenceTest(unittest.TestCase): |
| 100 | + @classmethod |
| 101 | + def setUpClass(cls): |
| 102 | + cls.proc, cls.out = _run() |
| 103 | + |
| 104 | + def setUp(self): |
| 105 | + if self.out is None: |
| 106 | + self.fail( |
| 107 | + "script did not finish (exit %s)\nstdout:\n%s\nstderr:\n%s" |
| 108 | + % (self.proc.returncode, self.proc.stdout, self.proc.stderr) |
| 109 | + ) |
| 110 | + |
| 111 | + def test_callback_outlives_replacing_itself(self): |
| 112 | + self.assertIs(self.out.get("alive_after_replacing_itself"), True) |
| 113 | + |
| 114 | + def test_callback_arguments_are_released(self): |
| 115 | + self.assertEqual(self.out["tree_walk_arg_refcount_delta"], 0) |
| 116 | + |
| 117 | + |
| 118 | +if __name__ == "__main__": |
| 119 | + unittest.main() |
0 commit comments