Skip to content

Entity index / world:entity(desired id) / recycling bug cluster: silent entity kills, double-issued ids, phantom entities, leaked slots (7 verified bugs) #325

Description

@Pyseph

Through nothing but public API, the entity index can be corrupted in several related ways: claiming the next generation of a live id silently kills the live entity and hands its components to the claimer; world:entity() can issue the same id to two different logical entities (with one's component data bleeding into the other); world:entity() can return an id that world:contains() rejects and world:add() silently ignores; deleting one entity can make a never-created id fully alive; and a desired-id claim can permanently destroy a recycle-queue entry. Two smaller invariant bugs (an exported allocator returning ids from the wrong range, and unbounded map growth in cached queries) round out the cluster.

Affects: current main (3087601). Found during a systematic audit (context: #317); reproduces identically with and without that PR's changes.

Most of these share one theme: the desired-id branch of world_entity (src/jecs.luau:3366-3413) makes no liveness or occupancy checks — it never asks whether the slot's current generation is still alive, whether the dense cell it appends into is already occupied by a parked id, or whether the claimed id itself is sitting parked in the dead zone. They can likely be fixed together, so they are grouped here for triage. The critical bugs from the same audit are filed separately.

All repros: save as repro.luau in the repo root, run luau repro.luau. Output shown is verbatim from real runs against main and is deterministic across runs.


1. Claiming gen+1 of a live slot silently kills the live entity and steals its components (HIGH)

Root cause. In world_entity's desired-id branch (src/jecs.luau:3383-3391), when sparse_array[index] exists and dense_array[r.dense] holds a different id, the code unconditionally appends the claimed id at alive_count + 1 and repoints r.dense there — without checking whether the current occupant is alive (dense <= alive_count). The slot's single record object is reused as-is (archetype/row preserved), so the claimed id inherits every component of the live entity. The old id is left behind in dense_array inside the alive region and in its archetype's entities row, so queries still yield it — while entity_index_try_get (line 511, backing world:contains) now rejects it because dense_array[r.dense] ~= entity.

Repro
local jecs = require("@jecs")

local world = jecs.world()
local C = world:component()

local e = world:entity() -- alive
world:set(e, C, "old-data")
print("[1] e alive before claim:", world:contains(e))

-- claim generation+1 of the SAME, still-alive slot
local claimed = world:entity(jecs.ECS_GENERATION_INC(e))

print("[2] old live entity still contained (expect true, or claim rejected):", world:contains(e))
print("[3] claimed is contained:", world:contains(claimed))
print("[4] claimed inherits old entity's component (expect nil):", world:get(claimed, C))

for ent, v in world:query(C) do
	print("[5] query yields:", ent, v, "== old e:", ent == e, "contains(yielded):", world:contains(ent))
end

Observed:

[1] e alive before claim:	true
[2] old live entity still contained (expect true, or claim rejected):	false
[3] claimed is contained:	true
[4] claimed inherits old entity's component (expect nil):	old-data
[5] query yields:	272	old-data	== old e:	true	contains(yielded):	false

Expected: claiming a higher generation of a slot whose current generation is still alive should be rejected (id conflict) — or at minimum must not corrupt the index. Instead the live entity is silently de-listed, the claimed id takes over its record and components, and queries yield an id that contains() declares dead — a direct query/liveness invariant violation, with the dead id stuck in the alive dense region permanently.


2. Claiming the parked next-generation id is a silent no-op; the allocator then double-issues the id (HIGH)

Root cause. After world:delete(e), the slot's next-generation id is parked at dense_array[i_swap] with record.dense = i_swap > alive_count (world_delete tail, src/jecs.luau:3693-3705). When that exact id is then claimed, world_entity's desired-id branch finds dense_array[r.dense] == entity, so the existing_entity ~= entity promote path (3385) is skipped and the function falls through to return entity at line 3393 — without checking r.dense > alive_count and without promoting the id into the alive region. The claim is a no-op: contains() stays false, but set/get go through the non-strict entity_index_try_get_unsafe (2860, alive-count check commented out), so the user can attach data to the still-parked record. The next plain world:entity() (3418-3423) then pops dense_array[alive_count + 1] — the very same id — and returns it as a fresh entity sharing the same record, pre-loaded with the earlier data.

Found independently via two flows with identical results: the parked id obtained as jecs.ECS_GENERATION_INC(e) after a local delete (below), and as jecs.ECS_COMBINE(jecs.ECS_ID(e), 1) mirroring an id replicated from an authority world that already recycled the slot.

Repro
local jecs = require("@jecs")

local world = jecs.world()
local C = world:component()

local e = world:entity()
world:delete(e)

-- the next-generation id of e's slot: exactly what world:delete parked for recycling,
-- and exactly what a mirrored authority world would replicate after delete + respawn
local parked = jecs.ECS_GENERATION_INC(e)

local claimed = world:entity(parked)
print("[1] claim returned the requested id:", claimed == parked)
print("[2] contains(claimed) right after claiming it (expect true):", world:contains(claimed))

world:set(claimed, C, "payload")
print("[3] set+get on claimed id works:", world:get(claimed, C))

local fresh = world:entity()
print("[4] next world:entity() returns the SAME id (expect false):", fresh == claimed)
print("[5] 'fresh' entity already has data (expect nil):", world:get(fresh, C))

Observed:

[1] claim returned the requested id:	true
[2] contains(claimed) right after claiming it (expect true):	false
[3] set+get on claimed id works:	payload
[4] next world:entity() returns the SAME id (expect false):	true
[5] 'fresh' entity already has data (expect nil):	payload

Expected: world:entity(id) with a desired id claims it — afterwards contains(id) is true and no future world:entity() call returns the same id for a different logical entity. Instead one id is handed to two logical entities, and components set through the first handle silently appear on the second.


3. Reviving a deep-parked stale handle corrupts the recycle queue: the next world:entity() returns an unusable id (HIGH)

Root cause. The promote branch of world_entity (src/jecs.luau:3383-3391) (a) never clears the claimer's old dense cell — which still holds the claimer slot's parked next-generation id with no sparse backpointer — and (b) blindly overwrites dense_array[alive_count], which when the revived slot was parked deeper than position alive_count + 1 is the parked dead id of a different slot; that slot's record now aliases a dense entry owned by the revived entity. The subsequent plain world:entity() recycle path (3418-3423) returns the orphaned dense entry — gen 1 of the revived slot — without verifying that sparse_array[ECS_ID(id)].dense points back at it. The returned id fails the dense identity check in both entity_index_try_get (511) and entity_index_try_get_unsafe (2860), so the world has just issued an id that contains() rejects and add/set silently drop.

Repro
local jecs = require("@jecs")

local world = jecs.world()
local T = world:component()

local a = world:entity()
local b = world:entity()

-- delete in creation order: 'a' parks deep in the dead zone (below 'b')
world:delete(a)
world:delete(b)

-- revive the stale handle (documented desired-id revive flow)
local revived = world:entity(a)
print("[1] revived == a:", revived == a, " contains:", world:contains(revived))

-- now create a brand-new entity the normal way
local f = world:entity()
print("[2] f is gen1 of a's slot:", jecs.ECS_ID(f) == jecs.ECS_ID(a), jecs.ECS_GENERATION(f) == 1)
print("[3] contains(f) (freshly returned by world:entity()):", world:contains(f))

world:add(f, T)
print("[4] has(f, T) after world:add(f, T):", world:has(f, T))

local n = 0
for _ in world:query(T) do
	n += 1
end
print("[5] entities matching T in query:", n)

Observed:

[1] revived == a:	true	 contains:	true
[2] f is gen1 of a's slot:	true	true
[3] contains(f) (freshly returned by world:entity()):	false
[4] has(f, T) after world:add(f, T):	false
[5] entities matching T in query:	0

Expected: after reviving a stale handle, a plain world:entity() returns a distinct, contained, usable entity. Instead it returns gen 1 of the just-revived slot (two "alive" dense entries for one slot), contains() is false on an id the world just handed out, add is a silent no-op, and the entity never appears in queries. Slot b is also bricked: its sparse record aliases the revived entity's dense entry.


4. Deleting a pre-range entity makes a never-created range placeholder fully alive (HIGH)

Root cause. world_range (src/jecs.luau:1046-1069) prefills dense_array[i] = i and sparse_array[i] = { dense = 0, ... } for i = max_id + 1 .. range_begin and sets alive_count = range_begin — so placeholder ids sit inside the alive range while their sparse records say dense == 0 ("not alive"). world_delete's swap (3693-3705) picks i_swap = alive_count, reads e_swap = dense_array[i_swap] (a placeholder id, e.g. 1000), fetches its record via the world-local entity_index_try_get_any closure (2772-2775) — which, unlike the top-level variant at 498, does not reject dense == 0 — then assigns r_swap.dense = record.dense and dense_array[record.dense] = e_swap. The placeholder record now has a valid dense slot inside the alive range pointing at its own id: the never-created entity is alive.

Repro
local jecs = require("@jecs")

local world = jecs.world()
local A = world:component()

local e = world:entity()
world:set(e, A, "real")

world:range(1000, 2000)

local phantom = 1000 -- never created by anyone
print("[1] contains(1000) before delete:", world:contains(phantom))

world:delete(e) -- delete an unrelated pre-range entity

print("[2] contains(1000) after delete:", world:contains(phantom))

-- the phantom is fully usable now
world:set(phantom, A, "ghost")
print("[3] get(1000, A):", world:get(phantom, A))

local claimed = world:entity(phantom)
print("[4] world:entity(1000) returns:", claimed)

local n = 0
for _ in world:query(A) do
	n += 1
end
print("[5] entities with A in query:", n)

Observed:

[1] contains(1000) before delete:	false
[2] contains(1000) after delete:	true
[3] get(1000, A):	ghost
[4] world:entity(1000) returns:	1000
[5] entities with A in query:	1

Expected: deleting entity e affects only e; id 1000 stays non-contained until someone actually claims it via world:entity(1000). Instead contains(1000) flips true as a side effect of deleting an unrelated entity, and the phantom accepts set/get and appears in queries. The same swap would also corrupt a real claimed range entity whenever it happens to occupy the alive_count cell; the placeholder case fires deterministically.


5. Claiming a brand-new desired id overwrites a parked recycle entry — the deleted slot leaks permanently (MEDIUM)

Root cause. world_entity's desired-id else-branch for an unseen slot (src/jecs.luau:3394-3411) does alive_count += 1; dense_array[alive_count] = entity (3404-3406) without first relocating whatever occupies that cell. After a delete, dense_array[alive_count + 1] is exactly where world_delete parked the next-generation id (3702-3705), so the claim clobbers it. Because the recycle queue is purely positional (dense cells above alive_count, read at 3418-3423), the deleted slot can never be recycled again, and its sparse record is left pointing into a dense cell owned by the claimed id. The no-arg fresh path only consumes a dense cell when it is non-nil; the desired-id path has no such guard.

Repro
local jecs = require("@jecs")

local world = jecs.world()

local a = world:entity()
world:delete(a)
local parked = jecs.ECS_GENERATION_INC(a) -- what the recycle queue now holds

-- claim an unrelated brand-new id (e.g. mirroring a foreign/high id)
local claimed = world:entity(5000)
print("[1] claimed 5000:", claimed == 5000, " contains:", world:contains(claimed))

local nxt = world:entity()
print("[2] next world:entity() (expect recycled", parked, "):", nxt)
print("    recycled? (expect true):", nxt == parked)

-- the deleted slot is now unreachable through normal recycling:
local seen = false
for _ = 1, 100 do
	if world:entity() == parked then
		seen = true
	end
end
print("[3] parked id reissued within next 100 spawns (expect true):", seen)

Observed:

[1] claimed 5000:	true	 contains:	true
[2] next world:entity() (expect recycled	16777488	):	5001
    recycled? (expect true):	false
[3] parked id reissued within next 100 spawns (expect true):	false

Expected: the existing "Recycling" test pins that after a delete, the next world:entity() returns the recycled generation of the deleted slot; an unrelated desired-id claim should not destroy the recycle queue. Instead the parked entry is overwritten, the next spawn allocates a fresh id, and the deleted slot leaks permanently with its sparse record dangling into a dense cell owned by another entity.


6. jecs.new_low_id() can never return a low id and desyncs world.max_component_id (LOW)

Root cause. new_low_id (src/jecs.luau:4011-4030) probes for a free low id with entity_index_try_get_any(entity_index, e) ~= nil, but world construction unconditionally calls ENTITY_INDEX_NEW_ID for i = 1 .. EcsRest (3883-3885), creating sparse records for every slot up to 271 — so the probe sees every id in 1..HI_COMPONENT_ID as taken on every world, even though only the ids actually used by world:component() are. The while-loop walks world.max_component_id up to 257, the e >= HI_COMPONENT_ID fallback returns an ordinary high entity id, and entity_index_ensure (3951, the only other consumer) is unreachable. world:component() (3761-3771) uses a closure-local counter, so the two counters silently diverge.

Repro
local jecs = require("@jecs")

local world = jecs.world()

print("[1] first component id:", world:component()) -- low ids 2..256 are all free

local low = jecs.new_low_id(world)
print("[2] new_low_id returned:", low, "(expected: a free low id < 257)")
print("[3] world.max_component_id after new_low_id:", world.max_component_id)
print("[4] next world:component():", world:component())

Observed:

[1] first component id:	1
[2] new_low_id returned:	272	(expected: a free low id < 257)
[3] world.max_component_id after new_low_id:	257
[4] next world:component():	2

Expected: new_low_id returns the next free id in the low component range (here 2..256 are entirely free), mirroring world:component() for addon use. Instead it returns a fresh high entity id (without EcsComponent) on every world, and leaves world.max_component_id at 257 while world:component() continues handing out 2, 3, ... from its own counter. Exported but undocumented API, hence low severity.


7. Cached query archetypes_map re-inserts destroyed archetype ids — unbounded growth under churn (LOW)

Root cause. on_delete_callback in query_cached (src/jecs.luau:1932-1943) swap-removes the destroyed archetype: archetypes[i] = lastarchetype; archetypes[n] = nil; archetypes_map[archetypeid] = nil; archetypes_map[lastarchetype.id] = i. When the destroyed archetype is the last element (i == n — the common case for a query matching one archetype), lastarchetype.id == archetypeid, so the final line re-inserts the dead archetype id into the map immediately after removing it. Archetype ids are never reused (max_archetype_id is monotonic), so the entry can never be cleaned up — one permanently dead map entry per archetype create/destroy cycle.

Repro
local jecs = require("@jecs")

local world = jecs.world()
local Foo = world:component()

local q = world:query(Foo):cached()

-- churn: create an entity with Foo (creates the archetype), delete it,
-- cleanup (destroys the now-empty archetype). Repeat.
for i = 1, 100 do
	local e = world:entity()
	world:set(e, Foo, i)
	world:delete(e)
	world:cleanup()
end

local n = 0
for _ in q.archetypes_map do
	n += 1
end
print("live archetypes in cached query list:", #q.compatible_archetypes)
print("entries in archetypes_map:", n)
print("(expected: map entries == live archetypes; actual grows by 1 per churn cycle)")

Observed:

live archetypes in cached query list:	0
entries in archetypes_map:	100
(expected: map entries == live archetypes; actual grows by 1 per churn cycle)

Expected: archetypes_map mirrors the live compatible-archetypes list. No wrong query results today (ids are never recycled, so stale entries can't be hit), but in a long-running server with cached queries and archetype churn this is unbounded memory growth.


Impact

The four HIGH entries break core identity invariants through documented public API, in exactly the flows the desired-id API exists for (replication mirrors applying authority-assigned ids, stale-handle revival, partitioned ranges): a live entity silently dying with its components stolen, one id serving two logical entities with data bleed, never-created ids coming alive, and world:entity() returning ids the world itself then refuses. At game runtime these surface as despawned-but-still-rendered mirrors, components appearing on the wrong entity, and adds/sets that silently do nothing — corruption that persists in the index and is extremely hard to trace back. The MEDIUM/LOW entries are permanent slot/memory leaks and a wrong-range allocator, relevant for long-running servers and addons.


These bugs were found, diagnosed, and reproduced by Claude (Fable 5, via Claude Code) under my direction, as part of the same audited setup as #317. The output above is from a real run against main.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions