Skip to content

Commit c6ad934

Browse files
authored
Merge branch 'csprance:main' into main
2 parents fac1406 + 41dcae7 commit c6ad934

File tree

3 files changed

+126
-8
lines changed

3 files changed

+126
-8
lines changed

addons/gecs/ecs/world.gd

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -397,15 +397,10 @@ func remove_entity(entity: Entity) -> void:
397397

398398
for processor in ECS.entity_postprocessors:
399399
processor.call(entity)
400-
entity_removed.emit(entity)
401-
_worldLogger.debug("remove_entity Removing Entity: ", entity)
402-
var erase_idx = entities.find(entity)
403-
if erase_idx >= 0:
404-
entities.remove_at(erase_idx)
405-
else:
406-
_worldLogger.warning("remove_entity: entity not found in entities array: ", entity)
407400

408-
# Only disconnect signals if they're actually connected
401+
# Disconnect entity signals before notifying observers to prevent re-entrancy:
402+
# if an observer's on_component_removed calls entity.remove_component() as a side effect,
403+
# the signal must not be connected or it will double-notify observers watching that component.
409404
if entity.component_added.is_connected(_on_entity_component_added):
410405
entity.component_added.disconnect(_on_entity_component_added)
411406
if entity.component_removed.is_connected(_on_entity_component_removed):
@@ -415,6 +410,20 @@ func remove_entity(entity: Entity) -> void:
415410
if entity.relationship_removed.is_connected(_on_entity_relationship_removed):
416411
entity.relationship_removed.disconnect(_on_entity_relationship_removed)
417412

413+
# Emit component_removed for each component before teardown
414+
# so observers learn about removal when an entity is destroyed
415+
for comp in entity.components.values():
416+
component_removed.emit(entity, comp)
417+
_handle_observer_component_removed(entity, comp)
418+
419+
entity_removed.emit(entity)
420+
_worldLogger.debug("remove_entity Removing Entity: ", entity)
421+
var erase_idx = entities.find(entity)
422+
if erase_idx >= 0:
423+
entities.remove_at(erase_idx)
424+
else:
425+
_worldLogger.warning("remove_entity: entity not found in entities array: ", entity)
426+
418427
# Remove from ID registry
419428
var entity_id = entity.id
420429
if (

addons/gecs/tests/core/test_observers.gd

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,3 +390,97 @@ func test_observer_with_multiple_entities():
390390

391391
# Should have detected all 5 changes
392392
assert_int(observer.changed_count).is_equal(5)
393+
394+
395+
## Test that removing an entity via world.remove_entity triggers on_component_removed
396+
func test_observer_on_remove_entity_triggers_component_removed():
397+
var observer = O_ObserverTest.new()
398+
world.add_observer(observer)
399+
400+
# Create entity with the watched component
401+
var entity = Entity.new()
402+
var component = C_ObserverTest.new(42, "test")
403+
entity.add_component(component)
404+
world.add_entity(entity)
405+
406+
assert_int(observer.added_count).is_equal(1)
407+
observer.reset()
408+
409+
# Remove the entire entity from the world
410+
world.remove_entity(entity)
411+
412+
# Observer should have been notified about the component removal
413+
assert_int(observer.removed_count).is_equal(1)
414+
assert_object(observer.last_removed_entity).is_equal(entity)
415+
416+
417+
## Test that removing an entity with multiple components notifies all relevant observers
418+
func test_observer_on_remove_entity_multiple_components():
419+
var observer = O_ObserverTest.new()
420+
var health_observer = O_HealthObserver.new()
421+
world.add_observer(observer)
422+
world.add_observer(health_observer)
423+
424+
# Create entity with both watched components
425+
var entity = Entity.new()
426+
entity.add_component(C_ObserverTest.new())
427+
entity.add_component(C_ObserverHealth.new(100))
428+
world.add_entity(entity)
429+
430+
assert_int(observer.added_count).is_equal(1)
431+
assert_int(health_observer.health_added_count).is_equal(1)
432+
observer.reset()
433+
health_observer.reset()
434+
435+
# Remove the entire entity
436+
world.remove_entity(entity)
437+
438+
# Both observers should have been notified
439+
assert_int(observer.removed_count).is_equal(1)
440+
assert_int(health_observer.health_removed_count).is_equal(1)
441+
442+
443+
## Test that removing multiple entities notifies observer for each
444+
func test_observer_on_remove_multiple_entities():
445+
var observer = O_ObserverTest.new()
446+
world.add_observer(observer)
447+
448+
# Create and add 3 entities
449+
var entities_list: Array[Entity] = []
450+
for i in range(3):
451+
var entity = Entity.new()
452+
entity.add_component(C_ObserverTest.new(i))
453+
world.add_entity(entity)
454+
entities_list.append(entity)
455+
456+
assert_int(observer.added_count).is_equal(3)
457+
observer.reset()
458+
459+
# Remove all entities
460+
for entity in entities_list:
461+
world.remove_entity(entity)
462+
463+
# Observer should have been notified for each removal
464+
assert_int(observer.removed_count).is_equal(3)
465+
466+
467+
## Regression test: observer side-effects during remove_entity must not cause double notification.
468+
## O_TestCleanupSideEffect removes C_ObserverHealth when it sees C_ObserverTest removed.
469+
## If entity signals are still connected during the loop, health_observer fires twice (bug).
470+
## If signals are disconnected before the loop, health_observer fires exactly once (correct).
471+
func test_observer_no_double_notification_on_remove_entity():
472+
var cleanup_observer = O_TestCleanupSideEffectObserver.new()
473+
var health_observer = O_HealthObserver.new()
474+
world.add_observer(cleanup_observer)
475+
world.add_observer(health_observer)
476+
477+
var entity = Entity.new()
478+
entity.add_component(C_ObserverTest.new())
479+
entity.add_component(C_ObserverHealth.new(100))
480+
world.add_entity(entity)
481+
482+
health_observer.reset()
483+
484+
world.remove_entity(entity)
485+
486+
assert_int(health_observer.health_removed_count).is_equal(1)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
## Observer that removes C_ObserverHealth as a side effect when C_ObserverTest is removed.
2+
## Used to test re-entrancy safety in remove_entity: if signals remain connected during
3+
## the observer notification loop, health_observer will be notified twice.
4+
class_name O_TestCleanupSideEffectObserver
5+
extends Observer
6+
7+
func watch() -> Resource:
8+
return C_ObserverTest
9+
10+
func match() -> QueryBuilder:
11+
return q.with_all([C_ObserverTest])
12+
13+
func on_component_removed(entity: Entity, component: Resource) -> void:
14+
if entity.has_component(C_ObserverHealth):
15+
entity.remove_component(C_ObserverHealth)

0 commit comments

Comments
 (0)