Skip to content

Throw instead of silently corrupting data on a same-archetype move (completes #216)#308

Open
penspanic wants to merge 1 commit into
genaray:masterfrom
penspanic:pr/same-archetype-move-throw
Open

Throw instead of silently corrupting data on a same-archetype move (completes #216)#308
penspanic wants to merge 1 commit into
genaray:masterfrom
penspanic:pr/same-archetype-move-throw

Conversation

@penspanic

@penspanic penspanic commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

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 component
  • Add<T> / Add(object) where the entity already has it
  • the *Range overloads of both
  • the same operations recorded on a CommandBuffer and applied via Playback

When 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)

var world = World.Create();
var a = world.Create(new Name { Value = "a" });
var b = world.Create(new Name { Value = "b" });
a.Remove<Absent>();     // neither entity has Absent
b.Remove<Absent>();
// a.Get<Name>().Value is now "b" — silently overwritten.

Root cause

GetOrCreateArchetypeBy{Add,Remove}Edge return the same archetype when the
component set does not change, so every structural path calls
World.Move(entity, source, destination) with source == destination. Move
was only guarded by Debug.Assert(source != destination, …), which is compiled
out of Release. Past the assert, Move does destination.Add(entity, …) — a
second slot for the same entity in the same archetype — then
source.Remove(ref slot, …), which relocates the archetype's last entity into
the 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.

// World.Move
if (source == destination)
{
    throw new InvalidOperationException(
        "The operation would move the entity within the same archetype, ...");
}
  • One change covers every entry point because they all funnel through Move
    (direct, non-generic, *Range, and CommandBuffer playback).
  • Thrown inline, matching the existing throws in the codebase. Move is already
    well past the JIT inlining threshold, so a separate ThrowHelper buys nothing
    here (unlike Add component existence check #216, which checked inside the small Add/Remove/Get/Set).
  • Empty AddRange / RemoveRange are guarded as legitimate no-ops, so they no
    longer 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.Assert is intentionally replaced rather than kept: it never protected
Release, 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-generated Add<T0,T1>)
  • RemoveNonExistentComponentsGeneratedThrows (source-generated Remove<T0,T1>)
  • AddRangeExistingComponentsThrows (AddRange)
  • CommandBufferRemoveNonExistentComponentThrows / CommandBufferAddExistingComponentThrows (deferred)

No-op guards (must NOT throw):

Each corruption test fails before the fix (in Debug via the assert; in Release
via the actual sibling-data overwrite — confirmed: a.X read 2 instead of
1) and passes after. Move throws before any mutation, so a caught throw
leaves the world untouched (the tests assert siblings stay intact).

Benchmark

Reworked the existing AddRemoveBenchmark into a valid add+remove round-trip —
the success path the guard adds a single source == destination reference
comparison 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:

dotnet run -c Release --project src/Arch.Benchmarks -- --filter *AddRemove*

(enable BenchmarkSwitcher in Benchmark.cs first — 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 for
exceptions on public-API misuse.

Deliberately left as pre-existing / follow-up (out of scope here):

  • *Range at element granularity. RemoveRange([present, absent]) changes
    the archetype, so it does not hit the guard and the absent element is still
    silently ignored (and the #if EVENTS loops fire an event per element). The
    guard only catches the whole-operation no-op. A per-element check would be a
    separate change.
  • Get/Set of an absent component (garbage read/write, not sibling
    corruption) — the other half of Add component existence check #216. Happy to fold in or leave as follow-up.
    Concretely: under an EVENTS build, the variadic Remove<T0, T1> fires
    OnComponentRemoved<T>(entity) before Move, and that hook reads the
    removed value via Get<T>(entity) unconditionally — remove-of-absent there
    crashes 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.Playback is not atomic. A throw mid-playback leaves earlier
    phases applied; documented in the Playback XML doc ("do not reuse a buffer
    whose playback threw"). Under an EVENTS build, OnComponentRemoved fires
    before the guard throws, so "world untouched" is not fully honored there.

Completes #216. Fixes #224, #253. Addresses #305.

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
penspanic force-pushed the pr/same-archetype-move-throw branch from d52ca5b to 66d6410 Compare July 14, 2026 04:40
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.

Command Buffer "Remove" leads to unexpected behaviour

1 participant