Repository navigation
Finish the destroy cascade when a subscriber destroys an entity - #284
Conversation
0520a74 to
63c1187
Compare
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.
80e36a9 to
065e7ee
Compare
|
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! |
🐛 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.
Destroying an entity whose
onEntityDestroyoronRemovesubscriber destroys something else can stop the cascade halfway. With one parent and fifty children under a relation withautoDestroy: '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
The result is the same when the nested destroy comes from an
onRemovecallback, since trait removal also happens inside the cascade. Destroying the entity that is already being destroyed never terminates at all:The cause
destroyEntityinpackages/core/src/entity/entity.tsdrives the cascade from one queue and one set held at module scope, and empties both on entry. Subscribers fire inside that loop, sounrelated.destroy()re-entersdestroyEntity, runsentityQueue.length = 0andprocessedEntities.clear(), and throws away the two children the outer call had already queued. When the nested call returns, the outerwhilesees 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
destroyEntityat all. The two that do destroy from a subscriber, registered atpackages/core/tests/lifecycle.test.ts:324and:348, fire fromonChangeduringset(), 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
finallyrestores 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
whileloop shifted one level right by thetry, so the hide whitespace view is the quickest read.Verification
Node 24.16.0, vitest 4.1.11.
pnpm -F @koota/core test runbefore the change: 170 passed, 1 expected fail, 10 files. After, with five new tests inpackages/core/tests/lifecycle.test.ts: 175 passed, 1 expected fail, 10 files. Reverting onlypackages/core/src/entity/entity.tsfails four of the five, withexpected true to be false,expected [ 1, 2 ] to have a length of 3andRangeError: Maximum call stack size exceeded; the fifth passes either way and guards the newfinally. A 20-deep nested chain destroys all 60 entities with exactly 60 subscriber calls. oxlint and prettier are clean on both changed files andtsc --noEmitpasses. I did not runpnpm test:buildlocally; the change is insidedestroyEntityand touches nothing the inline-function transform rewrites.