From 220edc60687b04ba0bab3f0ca3ed57c71d8925dd Mon Sep 17 00:00:00 2001 From: Stefan Behnel Date: Fri, 11 Sep 2026 16:54:07 +0200 Subject: [PATCH] GH#294: Fix a timing issue with the new garbage collector in Lua 5.5. Temporary objects passed into Lua could be overwritten without having been collected yet, thus letting the wrong Python object show up inside of Lua. --- CHANGES.rst | 9 +++++++++ lupa/_lupa.pyx | 8 +++++++- lupa/tests/test.py | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 21415617..115675e2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,15 @@ Lupa change log =============== +2.9 (2026-??-??) +---------------- + +* GH#294: In Lua 5.5, passing Python objects temporarily into Lua could make the wrong + object show up inside of Lua due to a timing issue between marking the object as unused + and the following garbage collection run that frees it. This issue surfaced due to + changes in the garbage collector of Lua 5.5. + + 2.8 (2026-04-15) ---------------- diff --git a/lupa/_lupa.pyx b/lupa/_lupa.pyx index 09a9b292..15e5c8c8 100644 --- a/lupa/_lupa.pyx +++ b/lupa/_lupa.pyx @@ -1697,9 +1697,11 @@ cdef bint py_to_lua_custom(LuaRuntime runtime, lua_State *L, object o, int type_ pyref = <_PyReference>runtime._pyrefs_in_lua[refkey] lua.lua_rawgeti(L, -1, pyref._ref) # tbl udata py_obj = lua.lua_touserdata(L, -1) - if py_obj: + if py_obj and py_obj.obj is o: lua.lua_remove(L, -2) # udata return 1 # values pushed + + # Object reference is no longer up to date. Create a new one. lua.lua_pop(L, 1) # tbl # create new wrapper for Python object @@ -2078,6 +2080,10 @@ cdef int py_object_gc_with_gil(py_object *py_obj, lua_State* L) noexcept with gi try: runtime.store_raised_exception(L, b'error while cleaning up a Python object') finally: return -1 else: + if py_obj.obj is not pyref._obj: + # Object reference has been reused already. Nothing to clean up. + return 0 + lua.lua_getfield(L, lua.LUA_REGISTRYINDEX, PYREFST) # tbl lua.luaL_unref(L, -1, pyref._ref) # tbl lua.lua_pop(L, 1) diff --git a/lupa/tests/test.py b/lupa/tests/test.py index 8735c796..54971d64 100644 --- a/lupa/tests/test.py +++ b/lupa/tests/test.py @@ -3111,6 +3111,55 @@ def test_functions(self): self.testmissingref({}, self.lupa.as_attrgetter) # attribute getter protocol +class TestSafeObjectRoundtrip(SetupLuaRuntimeMixin, LupaTestCase): + lua_runtime_kwargs = {'max_memory': 0} # Track memory allocations. + + def setUp(self): + try: + super().setUp() + except self.lupa.LuaError: + # LuaJIT 2.0 doesn't support 'max_memory' option. + self.lua_runtime_kwargs = {} + super().setUp() + + def test_python_object_identity_roundtrip_with_temporary_table_argument(self): + # Repeated object passing into Lua must not overwrite dead but uncollected objects. + # Make sure we don't leak memory when we handle that case. + # Issue reported in https://github.com/scoder/lupa/issues/294 + lua = self.lua + if 'luajit' not in lua.lua_implementation.lower(): + self.assertTrue(self.lua_runtime_kwargs, "Standard Lua should support 'max_memory' option.") + + echo = lua.eval('function(entity, event) return entity end') + mktable = lua.table + objects = [object(), object(), object()] + + def pass_objects_through_lua(chunk_size=100, obj_count=len(objects)): + for index in range(chunk_size): + expected = objects[index % obj_count] + actual = echo(expected, mktable(name="update")) + self.assertIs(actual, expected, (index, id(expected), id(actual))) + + lua.gccollect() + peak_memory_initial = lua.get_memory_used() + + pass_objects_through_lua() + + lua.gccollect() + if self.lua_runtime_kwargs: + peak_memory_initial = max(peak_memory_initial, lua.get_memory_used()) + + peak_memory = peak_memory_initial + for _ in range(200): + pass_objects_through_lua() + lua.gccollect() + if self.lua_runtime_kwargs: + peak_memory = max(peak_memory, lua.get_memory_used()) + + if self.lua_runtime_kwargs: + self.assertLessEqual(peak_memory, int(peak_memory_initial * 1.05)) # at most 5% memory growth + + ################################################################################ # test Lua object __str__ method