check cascaded deletion for structural changes within onremove hooks

This commit is contained in:
Ukendio 2025-12-28 11:47:45 +01:00
parent 5208aa7749
commit e4b12f4a28
3 changed files with 62 additions and 32 deletions

View file

@ -83,6 +83,9 @@ local function observers_new(
local tgt = jecs.ECS_PAIR_SECOND(term) local tgt = jecs.ECS_PAIR_SECOND(term)
local wc = tgt == jecs.w local wc = tgt == jecs.w
local onremoved = world:removed(rel, function(entity, id, delete: boolean?) local onremoved = world:removed(rel, function(entity, id, delete: boolean?)
if delete then
return
end
if not wc and id ~= term then if not wc and id ~= term then
return return
end end
@ -98,7 +101,10 @@ local function observers_new(
table.insert(cleanup, onremoved) table.insert(cleanup, onremoved)
else else
local onremoved = world:removed(term, function(entity, id) local onremoved = world:removed(term, function(entity, id, delete: boolean?)
if delete then
return
end
local r = jecs.record(world, entity) local r = jecs.record(world, entity)
local archetype = r.archetype local archetype = r.archetype
if archetype then if archetype then

View file

@ -1693,7 +1693,7 @@ local function query_iter_init(query: QueryInner): () -> (number, ...any)
local row = i_u local row = i_u
i_u -= 1 i_u -= 1
return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row], captured_g[row] return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row], col6[row]
end end
elseif not id8 then elseif not id8 then
function world_query_iter_next(): any function world_query_iter_next(): any
@ -2288,7 +2288,7 @@ local function query_cached(query: QueryInner)
local row = i_u local row = i_u
i_u -= 1 i_u -= 1
return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row], captured_g[row] return e, col0[row], col1[row], col2[row], col3[row], col4[row], col5[row], col6[row]
end end
elseif not id8 then elseif not id8 then
function world_query_iter_next(): any function world_query_iter_next(): any
@ -3393,6 +3393,7 @@ local function world_new(DEBUG: boolean?)
end end
local function world_delete(world: world, entity: i53) local function world_delete(world: world, entity: i53)
DEBUG_DELETING_ENTITY = entity
local record = entity_index_try_get_unsafe(entity) local record = entity_index_try_get_unsafe(entity)
if not record then if not record then
return return
@ -3525,6 +3526,7 @@ local function world_new(DEBUG: boolean?)
else else
local on_remove = id_record.on_remove local on_remove = id_record.on_remove
local to = archetype_traverse_remove(world, id, idr_t_archetype)
for i = #entities, 1, -1 do for i = #entities, 1, -1 do
local child = entities[i] local child = entities[i]
if on_remove then if on_remove then
@ -3532,20 +3534,14 @@ local function world_new(DEBUG: boolean?)
end end
local r = entity_index_try_get_unsafe(child) :: record local r = entity_index_try_get_unsafe(child) :: record
local to = archetype_traverse_remove(world, id, r.archetype)
inner_entity_move(child, r, to) inner_entity_move(child, r, to)
end end
end end
end end
end
for archetype_id in archetype_ids do
local idr_t_archetype = archetypes[archetype_id]
if idr_t_archetype then
archetype_destroy(world, idr_t_archetype) archetype_destroy(world, idr_t_archetype)
end end
end end
end
if idr_r then if idr_r then
local archetype_ids = idr_r.records local archetype_ids = idr_r.records
@ -3707,16 +3703,33 @@ local function world_new(DEBUG: boolean?)
-- NOTE(marcus): Make it easy to grep the debug functions and -- NOTE(marcus): Make it easy to grep the debug functions and
-- being able to read the specification, without having to look -- being able to read the specification, without having to look
-- at the implementation to understand invariants. -- at the implementation to understand invariants.
local function world_remove_checked(world: world, entity: i53, id: i53)
local DEBUG_DELETING_ENTITY
local function DEBUG_IS_DELETING_ENTITY(entity: i53)
if DEBUG_DELETING_ENTITY == entity then
error([[
Tried to make structural changes while the entity is in process
of being deleted. You called this function inside of the
OnRemove hook, but the entity is going to remove all of its
components making this operation moot.
]], 2)
end
end
local function DEBUG_IS_INVALID_ENTITY(entity: i53)
local entity_id = ECS_ID(entity) local entity_id = ECS_ID(entity)
local wrong_entity = eindex_sparse_array[entity_id] ~= entity_id local r = eindex_sparse_array[entity_id]
if wrong_entity then local canonical_entity = eindex_dense_array[r.dense]
if canonical_entity ~= entity then
error([[ error([[
This Entity handle has an outdated generation. You are This Entity handle has an outdated generation. You are
probably holding onto an entity that you got from outside the ECS probably holding onto an entity that you got from outside the ECS
]], 2) ]], 2)
end end
end
local function DEBUG_ID_IS_INVALID_PAIR(id: i53)
if ECS_ID_IS_WILDCARD(id) then if ECS_ID_IS_WILDCARD(id) then
error([[ error([[
You tried to pass in a wildcard pair. This is strictly You tried to pass in a wildcard pair. This is strictly
@ -3725,34 +3738,43 @@ local function world_new(DEBUG: boolean?)
targets to remove and use jecs.bulk_remove. targets to remove and use jecs.bulk_remove.
]], 2) ]], 2)
end end
end
-- NOTE(marcus): I have to save the old function and overriding the
-- upvalue in order to actually allow cascaded deletions to also be
-- checked by our program because we use the world_delete ptr internally.
local canonical_world_delete = world_delete
local function world_delete_checked(world: world, entity: i53)
DEBUG_DELETING_ENTITY = entity
DEBUG_IS_INVALID_ENTITY(entity)
canonical_world_delete(world, entity, id)
end
world_delete = world_delete_checked
local function world_remove_checked(world: world, entity: i53, id: i53)
DEBUG_IS_DELETING_ENTITY(entity)
DEBUG_IS_INVALID_ENTITY(entity)
DEBUG_ID_IS_INVALID_PAIR(id)
world_remove(world, entity, id) world_remove(world, entity, id)
end end
local function world_add_checked(world: world, entity: i53, id: i53) local function world_add_checked(world: world, entity: i53, id: i53)
local entity_id = ECS_ID(entity) DEBUG_IS_DELETING_ENTITY(entity)
local wrong_entity = eindex_sparse_array[entity_id] ~= entity_id DEBUG_IS_INVALID_ENTITY(entity)
if wrong_entity then DEBUG_ID_IS_INVALID_PAIR(id)
error([[
This Entity handle has an outdated generation. You are
probably holding onto an entity that you got from outside the ECS
]], 2)
end
world_add(world, entity, id) world_add(world, entity, id)
end end
local function world_set_checked(world: world, entity: i53, id: i53, value: any) local function world_set_checked(world: world, entity: i53, id: i53, value: any)
local entity_id = ECS_ID(entity) DEBUG_IS_DELETING_ENTITY(entity)
local wrong_entity = eindex_sparse_array[entity_id] ~= entity_id DEBUG_IS_INVALID_ENTITY(entity)
if wrong_entity then DEBUG_ID_IS_INVALID_PAIR(id)
error([[
This Entity handle has an outdated generation. You are
probably holding onto an entity that you got from outside the ECS
]], 2)
end
world_set(world, entity, id, value) world_set(world, entity, id, value)
end end
world.remove = world_remove_unchecked world.remove = world_remove_checked
world.add = world_add_checked world.add = world_add_checked
world.set = world_set_checked
end end
for i = 1, EcsRest do for i = 1, EcsRest do

View file

@ -25,7 +25,7 @@ local entity_visualiser = require("@modules/entity_visualiser")
local dwi = entity_visualiser.stringify local dwi = entity_visualiser.stringify
TEST("reproduce idr_t nil archetype bug", function() TEST("reproduce idr_t nil archetype bug", function()
local world = jecs.world() local world = jecs.world(true)
local cts = { local cts = {
Humanoid = world:component(), Humanoid = world:component(),
@ -46,7 +46,9 @@ TEST("reproduce idr_t nil archetype bug", function()
local src = r.archetype local src = r.archetype
--REMOVING THIS jecs.archetype_traverse_remove CALL STOPS IT FROM HAPPENING --REMOVING THIS jecs.archetype_traverse_remove CALL STOPS IT FROM HAPPENING
-- local dst = src and jecs.archetype_traverse_remove(world, id, src) CHECK_EXPECT_ERR(function()
world:remove(entity, cts.Humanoid)
end)
end) end)
local batchSize = 200 local batchSize = 200