Add fuzz_grid_entity target for EntityCollection bug family, addresses #283

Targets #258-#263 (gridstate overflow on entity transfer between
differently-sized grids), #273 (entity.die during iteration), #274
(spatial hash on set_grid). Dispatches 13 operations driven by
ByteStream from fuzz_common.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-04-10 11:13:16 -04:00
commit 0d433c1410
6 changed files with 361 additions and 11 deletions

View file

@ -1,12 +1,27 @@
"""fuzz_grid_entity - stub. Wave 2 agent W4 will implement.
"""Fuzz target: EntityCollection operations across multiple differently-sized grids.
Target bugs: #258-#263 (gridstate overflow on entity transfer between
differently-sized grids), #273 (entity.die during iteration), #274
(set_grid spatial hash). See /home/john/.claude/plans/abundant-gliding-hummingbird.md.
Hunts the bug family from issues #258-#263, #273, #274:
Contract: define fuzz_one_input(data: bytes) -> None. The C++ harness
(tests/fuzz/fuzz_common.cpp) calls this for every libFuzzer iteration.
Use ByteStream to consume bytes. Wrap work in try/except EXPECTED_EXCEPTIONS.
- #258-#263: UIEntity::gridstate heap overflows when an entity transfers between
grids of different sizes. The bug showed up in every mutation path
(append/extend/insert/setitem/slice assignment/set_grid).
- #273: entity.die() during iteration over grid.entities invalidated the C++
list iterator. The fix now raises RuntimeError; we still want the fuzzer to
exercise the raise path and any neighbour that could miss the guard.
- #274: set_grid(None) and set_grid(other_grid) needed to update the spatial
hash; a missing remove/insert causes use-after-free.
The fuzz loop keeps a small pool of grids of varying sizes and a pool of
entities, then dispatches a sequence of byte-driven operations against them:
create/destroy, append/insert/extend, remove/pop, __setitem__, slice
assignment, del, direct grid reassignment, and iteration with mid-loop
mutation. libFuzzer's coverage feedback drives the state exploration in
UIEntityCollection.cpp and UIEntity::set_grid.
The C++ harness (tests/fuzz/fuzz_common.cpp) invokes fuzz_one_input(data) for
every libFuzzer iteration and calls fuzz_common.safe_reset() before each call.
We still catch expected Python exceptions so one bad op does not abort the
whole iteration, which would hide later coverage from libFuzzer.
"""
import mcrfpy
@ -14,10 +29,345 @@ import mcrfpy
from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS
def fuzz_one_input(data):
stream = ByteStream(data)
# Caps chosen to keep each iteration fast and avoid address-space exhaustion
# under ASan. Grids up to 32x32 = 1024 cells means gridstate vectors stay
# small; 16 entities is enough to exercise slice-assignment boundaries.
MAX_GRIDS = 4
MAX_ENTITIES = 16
MAX_OPS = 48
MAX_GRID_DIM = 32
def _pick_grid(stream, grids):
"""Pick a random grid from the pool, or None if pool empty."""
if not grids:
return None
return grids[stream.int_in_range(0, len(grids) - 1)]
def _pick_entity(stream, entities):
"""Pick a random entity from the pool, or None if pool empty."""
if not entities:
return None
return entities[stream.int_in_range(0, len(entities) - 1)]
def _make_entity(stream, grids, attach=True):
"""Create a fresh entity, optionally attached to a random grid."""
x = stream.int_in_range(0, MAX_GRID_DIM - 1)
y = stream.int_in_range(0, MAX_GRID_DIM - 1)
grid = _pick_grid(stream, grids) if attach else None
if grid is not None:
return mcrfpy.Entity(grid_pos=(x, y), grid=grid)
return mcrfpy.Entity(grid_pos=(x, y))
def _make_grid(stream):
"""Create a grid with a random small size."""
w = stream.int_in_range(1, MAX_GRID_DIM)
h = stream.int_in_range(1, MAX_GRID_DIM)
return mcrfpy.Grid(grid_size=(w, h))
def _safe_index(stream, collection_len):
"""Pick an index in [0, collection_len-1], or 0 when empty."""
if collection_len <= 0:
return 0
return stream.int_in_range(0, collection_len - 1)
def _op_new_grid(stream, grids, entities):
if len(grids) >= MAX_GRIDS:
# Drop a random grid first so we exercise grid destruction paths too.
idx = stream.int_in_range(0, len(grids) - 1)
grids.pop(idx)
grids.append(_make_grid(stream))
def _op_new_entity(stream, grids, entities):
if len(entities) >= MAX_ENTITIES:
idx = stream.int_in_range(0, len(entities) - 1)
entities.pop(idx)
entities.append(_make_entity(stream, grids, attach=bool(stream.int_in_range(0, 1))))
def _op_append(stream, grids, entities):
grid = _pick_grid(stream, grids)
if grid is None:
return
# Half the time append a fresh entity, half the time append an existing one
# from the pool to exercise the cross-grid transfer path in append().
if entities and stream.int_in_range(0, 1):
ent = _pick_entity(stream, entities)
else:
ent = _make_entity(stream, grids, attach=False)
if len(entities) < MAX_ENTITIES:
entities.append(ent)
grid.entities.append(ent)
def _op_insert(stream, grids, entities):
grid = _pick_grid(stream, grids)
if grid is None:
return
idx = stream.int_in_range(0, max(0, len(grid.entities)))
if entities and stream.int_in_range(0, 1):
ent = _pick_entity(stream, entities)
else:
ent = _make_entity(stream, grids, attach=False)
if len(entities) < MAX_ENTITIES:
entities.append(ent)
grid.entities.insert(idx, ent)
def _op_extend(stream, grids, entities):
grid = _pick_grid(stream, grids)
if grid is None:
return
count = stream.int_in_range(0, 3)
batch = []
for _ in range(count):
if entities and stream.int_in_range(0, 1):
batch.append(_pick_entity(stream, entities))
else:
ent = _make_entity(stream, grids, attach=False)
if len(entities) < MAX_ENTITIES:
entities.append(ent)
batch.append(ent)
grid.entities.extend(batch)
def _op_remove(stream, grids, entities):
grid = _pick_grid(stream, grids)
if grid is None or len(grid.entities) == 0:
return
idx = _safe_index(stream, len(grid.entities))
target = grid.entities[idx]
grid.entities.remove(target)
def _op_pop(stream, grids, entities):
grid = _pick_grid(stream, grids)
if grid is None or len(grid.entities) == 0:
return
if stream.int_in_range(0, 1):
grid.entities.pop()
else:
idx = _safe_index(stream, len(grid.entities))
grid.entities.pop(idx)
def _op_setitem(stream, grids, entities):
"""Replace entities[i] with an entity possibly from a different grid.
This is the critical path for gridstate resize (#258-#263): when the
replacement entity was previously attached to a differently-sized grid,
its gridstate must be resized to match the new grid.
"""
grid = _pick_grid(stream, grids)
if grid is None or len(grid.entities) == 0:
return
idx = _safe_index(stream, len(grid.entities))
if entities and stream.int_in_range(0, 1):
ent = _pick_entity(stream, entities)
else:
ent = _make_entity(stream, grids, attach=True)
if len(entities) < MAX_ENTITIES:
entities.append(ent)
grid.entities[idx] = ent
def _op_slice_assign(stream, grids, entities):
"""Slice assignment - covers both contiguous and extended slices."""
grid = _pick_grid(stream, grids)
if grid is None:
return
n = len(grid.entities)
a = stream.int_in_range(0, max(0, n))
b = stream.int_in_range(a, max(a, n))
extended = bool(stream.int_in_range(0, 1)) and a < b
count = stream.int_in_range(0, 3)
batch = []
for _ in range(count):
if entities and stream.int_in_range(0, 1):
batch.append(_pick_entity(stream, entities))
else:
ent = _make_entity(stream, grids, attach=False)
if len(entities) < MAX_ENTITIES:
entities.append(ent)
batch.append(ent)
if extended:
step = stream.int_in_range(1, 3)
# Extended slice assignment requires matched lengths
target_len = max(0, (b - a + step - 1) // step)
if target_len == len(batch):
grid.entities[a:b:step] = batch
else:
grid.entities[a:b] = batch
def _op_del_index(stream, grids, entities):
"""del grid.entities[i] or del grid.entities[a:b]."""
grid = _pick_grid(stream, grids)
if grid is None or len(grid.entities) == 0:
return
if stream.int_in_range(0, 1):
idx = _safe_index(stream, len(grid.entities))
del grid.entities[idx]
else:
n = len(grid.entities)
a = stream.int_in_range(0, n)
b = stream.int_in_range(a, n)
del grid.entities[a:b]
def _op_transfer(stream, grids, entities):
"""e.grid = other_grid - THE critical gridstate-overflow path (#258-#263).
Prefers picking a source and target grid of *different* sizes so every
transfer stresses ensureGridstate's resize logic.
"""
if len(grids) < 2 or not entities:
return
ent = _pick_entity(stream, entities)
# Try to pick a grid whose size differs from entity's current grid
target = _pick_grid(stream, grids)
if target is None:
return
try:
# Minimal smoke: create one grid so the harness verifies end to end.
mcrfpy.Grid(grid_size=(4, 4))
ent.grid = target
except EXPECTED_EXCEPTIONS:
pass
def _op_set_grid_none(stream, grids, entities):
"""Detach an entity from its grid - exercises #274 spatial hash removal."""
ent = _pick_entity(stream, entities)
if ent is None:
return
try:
ent.grid = None
except EXPECTED_EXCEPTIONS:
pass
def _op_die(stream, grids, entities):
"""Call e.die() outside iteration - plain lifecycle path."""
ent = _pick_entity(stream, entities)
if ent is None:
return
try:
ent.die()
except EXPECTED_EXCEPTIONS:
pass
# Don't remove from entities list - let the pool hold a dead reference;
# the next op that touches it should hit defensive paths.
def _op_iterate_and_mutate(stream, grids, entities):
"""Iterate grid.entities and mid-loop call die() or reassign grid.
Targets #273 directly: the iterator must raise RuntimeError rather than
UB. We swallow the RuntimeError because it's the correct behaviour.
We also exercise a safe pattern (collect-then-die) in the other branch.
"""
grid = _pick_grid(stream, grids)
if grid is None or len(grid.entities) == 0:
return
mode = stream.int_in_range(0, 2)
if mode == 0:
# Unsafe: mutate mid-iter. Should raise RuntimeError.
try:
for ent in grid.entities:
if stream.remaining < 1:
break
if stream.int_in_range(0, 1):
ent.die()
else:
# Reassign to a different grid mid-iter.
other = _pick_grid(stream, grids)
if other is not None and other is not grid:
ent.grid = other
except EXPECTED_EXCEPTIONS:
pass
elif mode == 1:
# Safe: collect then mutate.
snapshot = list(grid.entities)
for ent in snapshot:
if stream.remaining < 1:
break
if stream.int_in_range(0, 3) == 0:
try:
ent.die()
except EXPECTED_EXCEPTIONS:
pass
else:
# Read-only iteration with incidental queries
total = 0
for ent in grid.entities:
total += 1
if total > 64:
break
# Dispatch table - each op is (min_bytes, callable)
_OPS = [
_op_new_grid, # 0
_op_new_entity, # 1
_op_append, # 2
_op_insert, # 3
_op_extend, # 4
_op_remove, # 5
_op_pop, # 6
_op_setitem, # 7
_op_slice_assign, # 8
_op_del_index, # 9
_op_transfer, # 10
_op_set_grid_none, # 11
_op_die, # 12
_op_iterate_and_mutate, # 13
]
def fuzz_one_input(data):
"""libFuzzer entry point.
Called by the C++ harness for every iteration. fuzz_common.safe_reset() is
invoked BEFORE this function, so mcrfpy.current_scene is clear. We rebuild
our local state from scratch each call so inputs are independent and
reproducible from a crashing seed.
"""
stream = ByteStream(data)
if stream.remaining < 2:
return
try:
# Seed the pool: one grid, zero entities. Ops will grow both pools.
initial_grids = stream.int_in_range(1, MAX_GRIDS)
grids = []
for _ in range(initial_grids):
try:
grids.append(_make_grid(stream))
except EXPECTED_EXCEPTIONS:
pass
entities = []
n_ops = stream.int_in_range(1, MAX_OPS)
for _ in range(n_ops):
if stream.remaining < 2:
break
op_idx = stream.int_in_range(0, len(_OPS) - 1)
try:
_OPS[op_idx](stream, grids, entities)
except EXPECTED_EXCEPTIONS:
# One failing op must not abort the rest of the iteration.
# libFuzzer needs to see coverage from subsequent ops.
pass
except EXPECTED_EXCEPTIONS:
pass
finally:
# Drop local references so the next safe_reset() can fully clean up.
# Entities retained in `entities` keep their C++ UIEntity alive, which
# is fine - safe_reset only clears current_scene/timers.
grids = None
entities = None

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.