Remove entity self-reference cycle

UIEntity::init() stored self->data->self = (PyObject*)self with
Py_INCREF(self), creating a reference cycle that prevented entities
from ever being freed. The matching Py_DECREF never existed.

Fix: Remove the `self` field from UIEntity entirely. Replace all
read sites (iter next, getitem, get_perspective, entities_in_radius)
with PythonObjectCache lookups using serial_number, which uses weak
references and doesn't prevent garbage collection.

Also adds tp_dealloc to PyUIEntityType to properly clean up the
shared_ptr and weak references when the Python wrapper is freed.

Closes #266, closes #275

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-03-07 23:22:58 -05:00
commit a12e035a71
5 changed files with 123 additions and 30 deletions

View file

@ -0,0 +1,100 @@
"""Regression test: entity self-reference cycle (#266, #275).
Bug: UIEntity::init() stored a reference to the Python wrapper via
self->data->self = (PyObject*)self and Py_INCREF(self). This created
a reference cycle that prevented the entity from ever being freed,
even after removing it from all grids and dropping all Python references.
Fix: Remove the self field entirely. Use PythonObjectCache (weak refs)
for Python object identity preservation instead.
"""
import mcrfpy
import gc
import sys
def test_entity_accessible_via_grid():
"""Entities remain accessible through grid.entities after Python ref dropped"""
grid = mcrfpy.Grid(grid_size=(10, 10))
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=grid)
entity_id = id(entity)
# Drop the Python reference
del entity
gc.collect()
# Entity should still be accessible via grid
assert len(grid.entities) == 1, f"Expected 1 entity, got {len(grid.entities)}"
retrieved = grid.entities[0]
assert retrieved.grid_x == 5.0, f"Expected x=5.0, got {retrieved.grid_x}"
print(" PASS: entity_accessible_via_grid")
def test_entity_removed_from_grid():
"""Entity can be removed from grid cleanly"""
grid = mcrfpy.Grid(grid_size=(10, 10))
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=grid)
assert len(grid.entities) == 1
grid.entities.remove(entity)
assert len(grid.entities) == 0
print(" PASS: entity_removed_from_grid")
def test_multiple_entities():
"""Multiple entities can be created, accessed, and removed"""
grid = mcrfpy.Grid(grid_size=(20, 20))
entities = []
for i in range(10):
e = mcrfpy.Entity(grid_pos=(i, i), grid=grid)
entities.append(e)
assert len(grid.entities) == 10
# Drop all Python references
del entities
gc.collect()
# All should still be in grid
assert len(grid.entities) == 10
# Access each one
for i in range(10):
e = grid.entities[i]
assert e.grid_x == float(i), f"Entity {i} x={e.grid_x}, expected {float(i)}"
print(" PASS: multiple_entities")
def test_entity_transfer_preserves_identity():
"""Entity identity preserved when transferring between grids"""
grid1 = mcrfpy.Grid(grid_size=(10, 10))
grid2 = mcrfpy.Grid(grid_size=(20, 20))
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=grid1)
entity.grid = grid2
assert len(grid1.entities) == 0
assert len(grid2.entities) == 1
retrieved = grid2.entities[0]
assert retrieved.grid_x == 5.0
print(" PASS: entity_transfer_preserves_identity")
def test_iteration_after_gc():
"""Iterating grid.entities works after GC of Python wrappers"""
grid = mcrfpy.Grid(grid_size=(10, 10))
for i in range(5):
mcrfpy.Entity(grid_pos=(i, 0), grid=grid)
gc.collect()
count = 0
for e in grid.entities:
count += 1
assert count == 5, f"Expected 5 entities in iteration, got {count}"
print(" PASS: iteration_after_gc")
print("Testing entity lifecycle (self-reference removal)...")
test_entity_accessible_via_grid()
test_entity_removed_from_grid()
test_multiple_entities()
test_entity_transfer_preserves_identity()
test_iteration_after_gc()
print("PASS: all entity lifecycle tests passed")
sys.exit(0)