Skip to content

Finish the destroy cascade when a subscriber destroys an entity - #284

Merged
ospira merged 1 commit into
pmndrs:canaryfrom
MaxFreedomPollard:fix/reentrant-destroy
Oct 28, 2026
Merged

ospira merged 1 commit into
pmndrs:canaryfrom
MaxFreedomPollard:fix/reentrant-destroy

Conversation

@MaxFreedomPollard

Copy link
Copy Markdown

Destroying an entity whose onEntityDestroy or onRemove subscriber destroys something else can stop the cascade halfway. With one parent and fifty children under a relation with autoDestroy: 'orphan', all fifty children stay alive (the repro below uses two for brevity). Their relation pair is already gone by then, so they are neither parented nor cleaned up, and nothing throws or logs. A subscriber that destroys the entity currently being destroyed is louder: it recurses until the stack overflows.

The problem

const world = createWorld();
const ChildOf = relation({ autoDestroy: 'orphan' });

const parent = world.spawn();
const childA = world.spawn(ChildOf(parent));
const childB = world.spawn(ChildOf(parent));
const unrelated = world.spawn();

world.onEntityDestroy((entity) => {
  if (entity === parent && world.has(unrelated)) unrelated.destroy();
});

parent.destroy();

world.has(childA); // true, should be false
world.has(childB); // true, should be false
childA.targetsFor(ChildOf); // [], the pair is already stripped

The result is the same when the nested destroy comes from an onRemove callback, since trait removal also happens inside the cascade. Destroying the entity that is already being destroyed never terminates at all:

world.onEntityDestroy((entity) => entity.destroy());
world.spawn().destroy(); // RangeError: Maximum call stack size exceeded

The cause

destroyEntity in packages/core/src/entity/entity.ts drives the cascade from one queue and one set held at module scope, and empties both on entry. Subscribers fire inside that loop, so unrelated.destroy() re-enters destroyEntity, runs entityQueue.length = 0 and processedEntities.clear(), and throws away the two children the outer call had already queued. When the nested call returns, the outer while sees an empty queue and exits. The self destroy is the same clear seen from the other side: the entity is wiped back out of the processed set, so the loop treats it as new, fires the subscriber again, and goes around forever.

No existing test re-enters destroyEntity at all. The two that do destroy from a subscriber, registered at packages/core/tests/lifecycle.test.ts:324 and :348, fire from onChange during set(), which is outside any destroy cascade.

The fix

A nested call now takes its own queue, so it cannot clear the cascade the outer call is still walking. It keeps sharing the processed set, and that is what stops the two calls from destroying the same entity twice: an entity the outer loop has already claimed is skipped by the nested call, and an entity the nested call destroyed is skipped when the outer loop pops it. The shared set is also what ends the self destroy, since the entity is already in it when the nested call looks.

The in-flight flag stays at module scope on purpose. Re-entry arrives through a user callback, not through koota's own call chain, so there is no call site to thread a depth argument from. A finally restores it, so a subscriber that throws does not wedge later destroys.

The common path allocates nothing new and measures the same: destroying 20k entities with two traits takes a median 14.68 ms with the change against 14.75 ms without, over 7 rounds. Only a destroy called from inside another destroy pays for one array. The source diff is about fifteen real lines; the rest is the existing while loop shifted one level right by the try, so the hide whitespace view is the quickest read.

Verification

Node 24.16.0, vitest 4.1.11. pnpm -F @koota/core test run before the change: 170 passed, 1 expected fail, 10 files. After, with five new tests in packages/core/tests/lifecycle.test.ts: 175 passed, 1 expected fail, 10 files. Reverting only packages/core/src/entity/entity.ts fails four of the five, with expected true to be false, expected [ 1, 2 ] to have a length of 3 and RangeError: Maximum call stack size exceeded; the fifth passes either way and guards the new finally. A 20-deep nested chain destroys all 60 entities with exactly 60 subscriber calls. oxlint and prettier are clean on both changed files and tsc --noEmit passes. I did not run pnpm test:build locally; the change is inside destroyEntity and touches nothing the inline-function transform rewrites.

@krispya
krispya force-pushed the canary branch 2 times, most recently from 0520a74 to 63c1187 Compare September 16, 2026 12:23
@krispya krispya changed the title 🐛 core: finish the destroy cascade when a subscriber destroys an entity Finish the destroy cascade when a subscriber destroys an entity Oct 4, 2026
destroyEntity walks its cascade with a module level queue and processed set that
every call shares, and it empties both on entry. A subscriber that calls destroy
runs inside the loop, so the nested call wiped the pending work and the outer
loop exited early, leaving entities alive that the cascade was about to destroy.
Destroying the entity that is already being destroyed cleared it back out of the
processed set instead, so the loop reprocessed it and recursed until the stack
overflowed. A nested call now takes its own queue and keeps sharing the processed
set, so it can never clear the outer cascade and neither call destroys the same
entity twice. The flag that marks a cascade as in flight is restored in a finally
block so a subscriber that throws does not wedge later destroys.
@MaxFreedomPollard

Copy link
Copy Markdown
Author

Rebased onto the current canary, since the branch was still on the history from before canary was rebased. The fix itself is unchanged. destroyEntity on canary still clears the shared queue when it is re-entered, and the new tests fail there without this change. CI is green.

@ospira

ospira commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Rebased onto the current canary, since the branch was still on the history from before canary was rebased. The fix itself is unchanged. destroyEntity on canary still clears the shared queue when it is re-entered, and the new tests fail there without this change. CI is green.

Hey great I had just done this actually as I was reviewing it and didn't want you to have to deal with the rebase, appreciate you going ahead and updating it yourself. Confirmed it looked good on my end too. I also ran a bench against some other potential solutions and confirmed your approach was best.

Thanks again. Will merge soon.

Btw if you're not there already (didn't find you earlier) I encourage you to join the Discord!

@ospira
ospira merged commit 8d21007 into pmndrs:canary Oct 28, 2026
1 check passed
krispya pushed a commit that referenced this pull request Oct 28, 2026
🐛 core: finish the destroy cascade when a subscriber destroys an entity

destroyEntity walks its cascade with a module level queue and processed set that
every call shares, and it empties both on entry. A subscriber that calls destroy
runs inside the loop, so the nested call wiped the pending work and the outer
loop exited early, leaving entities alive that the cascade was about to destroy.
Destroying the entity that is already being destroyed cleared it back out of the
processed set instead, so the loop reprocessed it and recursed until the stack
overflowed. A nested call now takes its own queue and keeps sharing the processed
set, so it can never clear the outer cascade and neither call destroys the same
entity twice. The flag that marks a cascade as in flight is restored in a finally
block so a subscriber that throws does not wedge later destroys.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants