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