Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -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)
----------------

Expand Down
8 changes: 7 additions & 1 deletion lupa/_lupa.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <py_object*>lua.lua_touserdata(L, -1)
if py_obj:
if py_obj and py_obj.obj is <PyObject*> 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
Expand Down Expand Up @@ -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 <PyObject*> 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)
Expand Down
49 changes: 49 additions & 0 deletions lupa/tests/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading