From 7fb7e1da6989a23b34acb2e7e8e3a1d0f031ac52 Mon Sep 17 00:00:00 2001 From: rbroderi Date: Fri, 11 Sep 2026 21:40:02 -0400 Subject: [PATCH 1/2] Add host userdata identity regression tests --- tests/test_host_userdata_identity.py | 83 ++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/test_host_userdata_identity.py diff --git a/tests/test_host_userdata_identity.py b/tests/test_host_userdata_identity.py new file mode 100644 index 0000000..d4d9dc1 --- /dev/null +++ b/tests/test_host_userdata_identity.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from luapyre import LuaRuntime + + +def test_host_userdata_identity_roundtrip_survives_temporary_tables_and_gc(): + """Regression guard for the failure mode reported in Lupa GH-294. + + Opaque host objects must round-trip through Lua by identity even while + temporary Lua objects are being created and collection cycles run. A host + reference must never be silently rebound to another live Python object. + """ + + lua = LuaRuntime() + entities = [object(), object(), object()] + + def entity_for(index): + return entities[index % len(entities)] + + def verify_entity(index, actual): + expected = entities[index % len(entities)] + assert actual is expected, (index, id(expected), id(actual)) + return True + + lua.expose("entity_for", entity_for) + lua.expose("verify_entity", verify_entity) + + result = lua.execute( + ''' +local function echo(entity, event) + return entity +end + +for i = 0, 19999 do + local expected = entity_for(i) + local actual = echo(expected, {name = "update"}) + if not verify_entity(i, actual) then + return false + end + if i % 97 == 0 then + collectgarbage("collect") + end +end + +return true +''' + ) + + assert result is True + + +def test_distinct_host_userdata_remain_distinct_as_table_keys_across_gc(): + """Identity-keyed Lua table entries must not alias after collection.""" + + lua = LuaRuntime() + entities = [object(), object(), object()] + + def entity_for(index): + return entities[index % len(entities)] + + lua.expose("entity_for", entity_for) + + result = lua.execute( + ''' +local keys = {entity_for(0), entity_for(1), entity_for(2)} +local seen = {} +for i = 1, 3 do + seen[keys[i]] = i +end + +for n = 1, 250 do + local temporary = {n = n} + if n % 17 == 0 then + collectgarbage("collect") + end +end + +return seen[keys[1]], seen[keys[2]], seen[keys[3]], + keys[1] ~= keys[2] and keys[2] ~= keys[3] and keys[1] ~= keys[3] +''' + ) + + assert result == (1, 2, 3, True) From b01e9570fff3e1e1a48b6574280e18ee75d5a66a Mon Sep 17 00:00:00 2001 From: rbroderi Date: Fri, 11 Sep 2026 21:40:18 -0400 Subject: [PATCH 2/2] Document direct host userdata identity invariant --- src/luapyre/runtime.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/luapyre/runtime.py b/src/luapyre/runtime.py index 190a6a3..f0dbae4 100644 --- a/src/luapyre/runtime.py +++ b/src/luapyre/runtime.py @@ -43,6 +43,13 @@ def _to_lua(self, value): for key, item in value.items(): t.rawset(self._to_lua(key), self._to_lua(item)) return t + + # Opaque host userdata is represented by the Python object itself. + # Do not replace this with a recyclable integer/ref-slot registry unless + # every reuse and finalization path validates the referent identity (or + # a generation token). A stale reusable wrapper can otherwise become + # rebound to a different live host object; see Lupa GH-294 for the class + # of bug this direct-reference invariant deliberately avoids. return value def expose(self, name: str, fn):