Throw instead of silently corrupting data on a same-archetype move (completes #216)#308
Open
penspanic wants to merge 1 commit into
Open
Throw instead of silently corrupting data on a same-archetype move (completes #216)#308penspanic wants to merge 1 commit into
penspanic wants to merge 1 commit into
Conversation
Adding a component an entity already has, or removing one it does not, produces a destination archetype identical to the source. World.Move then appended the entity to a second slot of the same archetype and the following source-side removal overwrote a sibling entity's component data. A Debug.Assert guarded this in debug builds but is compiled out of the shipped Release package, so the corruption was completely silent. All structural entry points funnel through Move (Add/Remove, the non-generic and *Range overloads, and CommandBuffer playback), so all were affected. Turn that debug-only assert into a real InvalidOperationException. Empty AddRange / RemoveRange are now guarded as legitimate no-ops so they no longer trip the check. Adds regression tests for the direct, non-generic and deferred paths, and reworks the AddRemove benchmark to measure the success-path cost of the guard. Completes genaray#216. Fixes genaray#224, genaray#253. Addresses genaray#305. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
penspanic
force-pushed
the
pr/same-archetype-move-throw
branch
from
July 14, 2026 04:40
d52ca5b to
66d6410
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A structural operation that does not actually change an entity's component set
silently corrupts other entities' data in Release builds:
Remove<T>/Remove(type)where the entity does not have the componentAdd<T>/Add(object)where the entity already has it*Rangeoverloads of bothCommandBufferand applied viaPlaybackWhen two such operations hit entities sharing an archetype, the affected
entities end up sharing the last one's data — no exception, no log. This
has been reported repeatedly: #305 (closed as "check before you remove"), #224
(CommandBuffer), and #253 (empty
RemoveRange).Minimal repro (Release)
Root cause
GetOrCreateArchetypeBy{Add,Remove}Edgereturn the same archetype when thecomponent set does not change, so every structural path calls
World.Move(entity, source, destination)withsource == destination.Movewas only guarded by
Debug.Assert(source != destination, …), which is compiledout of Release. Past the assert,
Movedoesdestination.Add(entity, …)— asecond slot for the same entity in the same archetype — then
source.Remove(ref slot, …), which relocates the archetype's last entity intothe freed slot and tangles the bookkeeping, overwriting a sibling's data.
Fix
Turn the compiled-out assertion into a real runtime check. This is exactly the
direction @genaray endorsed in #216 ("it's time to finally introduce safety
checks"); that PR stalled only on a requested benchmark, which this PR includes.
Move(direct, non-generic,
*Range, andCommandBufferplayback).Moveis alreadywell past the JIT inlining threshold, so a separate
ThrowHelperbuys nothinghere (unlike Add component existence check #216, which checked inside the small
Add/Remove/Get/Set).AddRange/RemoveRangeare guarded as legitimate no-ops, so they nolonger reach the check — this is the correct fix for the empty-span half of
Calling RemoveRange(Entity entity, Span<ComponentType> types) can break entity slot #253 (a no-op, not an error).
Debug.Assertis intentionally replaced rather than kept: it never protectedRelease, which is where the corruption actually happened.
Tests (net8.0, Debug + Release × Default / Events / PureECS — all green)
Throws, covering every structural entry point:
RemoveNonExistentComponentThrows(Remove<T>)AddExistingComponentThrows(Add<T>)RemoveNonExistentComponentNonGenericThrows(Remove(entity, type))AddExistingComponentNonGenericThrows(Add(entity, object))AddExistingComponentsGeneratedThrows(source-generatedAdd<T0,T1>)RemoveNonExistentComponentsGeneratedThrows(source-generatedRemove<T0,T1>)AddRangeExistingComponentsThrows(AddRange)CommandBufferRemoveNonExistentComponentThrows/CommandBufferAddExistingComponentThrows(deferred)No-op guards (must NOT throw):
RemoveRangeEmptyIsNoOp— pins Calling RemoveRange(Entity entity, Span<ComponentType> types) can break entity slot #253's empty-span no-opAddRangeEmptyIsNoOp— bothSpan<object>andSpan<ComponentType>overloadsEach corruption test fails before the fix (in Debug via the assert; in Release
via the actual sibling-data overwrite — confirmed:
a.Xread2instead of1) and passes after.Movethrows before any mutation, so a caught throwleaves the world untouched (the tests assert siblings stay intact).
Benchmark
Reworked the existing
AddRemoveBenchmarkinto a valid add+remove round-trip —the success path the guard adds a single
source == destinationreferencecomparison to, on archetypes already computed by the existing edge lookup.
The check adds no allocation and its cost sits far below short-run
measurement noise (ShortRun margins were 20–170%). The comparison is one
reference equality on values already in hand, so no measurable regression is
expected on the structural-change path. Run a full job to confirm precisely:
(enable
BenchmarkSwitcherinBenchmark.csfirst — the repo ships it commented out).Scope & known limitations
Focused on the data-corruption path (structural Add/Remove). Semantics:
InvalidOperationException, matching #216 and your stated preference forexceptions on public-API misuse.
Deliberately left as pre-existing / follow-up (out of scope here):
*Rangeat element granularity.RemoveRange([present, absent])changesthe archetype, so it does not hit the guard and the absent element is still
silently ignored (and the
#if EVENTSloops fire an event per element). Theguard only catches the whole-operation no-op. A per-element check would be a
separate change.
Get/Setof an absent component (garbage read/write, not siblingcorruption) — the other half of Add component existence check #216. Happy to fold in or leave as follow-up.
Concretely: under an
EVENTSbuild, the variadicRemove<T0, T1>firesOnComponentRemoved<T>(entity)beforeMove, and that hook reads theremoved value via
Get<T>(entity)unconditionally — remove-of-absent therecrashes with an AccessViolation on master today, before this PR's guard
is ever reached. The corresponding regression test is therefore excluded
under
EVENTS(#if !EVENTS, with a comment) rather than papered over.CommandBuffer.Playbackis not atomic. A throw mid-playback leaves earlierphases applied; documented in the
PlaybackXML doc ("do not reuse a bufferwhose playback threw"). Under an
EVENTSbuild,OnComponentRemovedfiresbefore the guard throws, so "world untouched" is not fully honored there.
Completes #216. Fixes #224, #253. Addresses #305.