Skip to content

Instantly share code, notes, and snippets.

@gphg
Last active September 3, 2026 16:40
Show Gist options
  • Select an option

  • Save gphg/522ecd98dd64bfbbb0ad733e557a4c48 to your computer and use it in GitHub Desktop.

Select an option

Save gphg/522ecd98dd64bfbbb0ad733e557a4c48 to your computer and use it in GitHub Desktop.

Copilot Chat Conversation Export: Reevaluating List.lua weaknesses in world.lua context

User: @gphg Thread URL: https://github.com/copilot/c/3a2e6888-0c3e-409a-8b99-203e2c16f507

@gphg

Evaluate this list.lua module and its weakness.

Copilot

I'll analyze this Lua List implementation for you.

Overall Assessment

This is a clever bidirectional index data structure designed for fast O(1) removal. However, it has several significant weaknesses:


Critical Weaknesses

1. Broken Swap-and-Pop Logic

The remove() function uses a swap-and-pop strategy, but it's fundamentally flawed:

if index == size then
   self[size] = nil
else
   local other = self[size]
   self[index] = other      -- Move last element to deleted position
   self[other] = index      -- Update index mapping
   self[size] = nil
end

Problem: After removal, the list is no longer sorted by index. Gaps appear:

  • Add: A, B, C (indices 1, 2, 3)
  • Remove B: List becomes [A, C, _, ...] but index 2 now contains C
  • Calling get(2) returns C (correct), but get(3) returns nil (wrong!)

Iteration via ipairs() will silently stop at the first nil, breaking any code that relies on contiguous indices.

2. No Protection Against Collision with Reserved Keys

The code claims objects cannot be 'size', 'onAdded', or 'onRemoved', but there's no validation:

function List:add(obj)
   -- No check if obj == 'size' or obj == 'onAdded' or obj == 'onRemoved'
   self[obj] = size  -- OVERWRITES self.size if obj is 'size'!

If you add the string 'size', you corrupt the list's size tracking.

3. Iterator Incompatibility

The list cannot be iterated safely with ipairs() or pairs() after removals:

local list = List.new()
list:add("a"):add("b"):add("c")
list:remove("b")

for i, v in ipairs(list) do
   print(i, v)  -- Prints: 1, "a" only! Stops at nil.
end

There's no custom iterator (__pairs, __ipairs), and the sparse table breaks standard iteration.

4. The Sort Function Breaks the Invariant

function List:sort(order)
   table.sort(self, order)  -- Compacts the table (removes nils)
   for key, obj in ipairs(self) do
      self[obj] = key
   end
end

After sort(), the list is contiguous and correct. But then you can remove() again, creating gaps. The invariant is inconsistent.

5. Memory Leak Risk

When you call remove() with an object, only self[obj] is set to nil. If the object itself holds references, the cycle self -> obj -> self may delay garbage collection (Lua's GC will handle it eventually, but it's inefficient).


Example Bug

local list = List.new()
list:add("x"):add("y"):add("z")

print(list:get(1))  -- "x"
print(list:get(2))  -- "y"
print(list:get(3))  -- "z"

list:remove("y")

print(list:get(1))  -- "x"
print(list:get(2))  -- "z" (correct position after swap)
print(list:get(3))  -- nil (BUG! Expected behavior unclear)

-- Iteration fails:
for i, v in ipairs(list) do print(i, v) end  -- Only prints: 1 "x"

Design vs. Implementation Mismatch

The intent is clear: provide O(1) removal by swapping the last element into the deleted slot (a common ECS pattern). But the execution doesn't account for:

  • The gap at index 3 after removal
  • Users expecting a contiguous array
  • The inability to iterate

Recommendations

To fix this, you need one of:

  1. Document it as sparse: Make it clear that after removal, indices are NOT contiguous, and users should use pairs() or a custom iterator.
  2. Use a linked list: Trade O(1) removal for O(n) lookup, but maintain iteration order.
  3. Add a __pairs iterator that only iterates valid indices (1 to size).
  4. Add validation in add() to reject reserved key names.

Would you like me to elaborate on any of these weaknesses or suggest a corrected implementation?

@gphg

@Keyslam-Group/Concord/files/concord/world.lua in practice, it used by world.lua. I want you to reevaluate based on this module.

Copilot

I'll re-evaluate the List weaknesses in the context of how world.lua actually uses it.Now I can reevaluate the List.lua weakness in the context of how it's actually used in world.lua. The picture becomes much worse.


Critical Re-evaluation: List.lua IS Fundamentally Broken in world.lua

The Core Problem: __flush() Depends on Contiguous Indices

In world.lua, the __flush() function iterates entities using numeric indices:

function World:__flush()
   -- Process added entities
   for i = 1, self.__backAdded.size do
      e = self.__backAdded[i]  -- Relies on index i being valid
      ...
   end

   -- Process removed entities
   for i = 1, self.__backRemoved.size do
      e = self.__backRemoved[i]  -- Relies on index i being valid
      ...
   end

   -- Process dirty entities
   for i = 1, self.__backDirty.size do
      e = self.__backDirty[i]  -- Relies on index i being valid
      ...
   end
end

This is safe for the temporary lists (__added, __removed, __dirty) because they only use add() and clear(), which maintain contiguity.

However, look at the main entity list:

function World:removeEntity(e)
   self.__removed:add(e)
end

-- Later in __flush():
self.__entities:remove(e)  -- Swap-and-pop removal!

After removing an entity from self.__entities, the list becomes sparse. But later:

function World:query(def, onMatch)
   for _, e in ipairs(self.__entities) do  -- ipairs stops at first nil!
      if Filter.match(e, filter) then
         ...
      end
   end
end

And this is called directly after a removal:

world:removeEntity(player)  -- Removes from __entities with swap-and-pop
world:query(...)            -- Tries to iterate __entities with ipairs

Real Bug Scenario

local world = World()

local e1 = world:newEntity()
local e2 = world:newEntity()
local e3 = world:newEntity()

world:__flush()
-- self.__entities now contains: [e1, e2, e3] with size=3

world:removeEntity(e2)
world:__flush()
-- After swap-and-pop:
--   self.__entities[1] = e1
--   self.__entities[2] = e3 (swapped from position 3)
--   self.__entities[3] = nil (deleted)
--   size = 2

local results = world:query(...)
-- ipairs iteration: only sees e1, STOPS at nil
-- e3 is silently SKIPPED!

Entity e3 is invisible to queries after ANY removal. This is a silent data loss bug.


Other Problems Exposed by world.lua

1. World:clear() Incorrectly Iterates After Removal

function World:clear()
   for i = 1, self.__entities.size do
      self:removeEntity(self.__entities[i])  -- First iteration works
                                              -- But self.__entities[i] changes after swap-and-pop!
   end
end
  • Iteration 1: Remove self.__entities[1] → swap-and-pop moves last to position 1
  • Iteration 2: self.__entities[2] might now be nil or wrong entity
  • Result: Some entities not removed, memory leak

2. World:serialize() Silently Loses Entities

function World:serialize(ignoreKeys)
   self:__flush()

   for i = 1, self.__entities.size do
      local entity = self.__entities[i]  -- Skips sparse indices!
      if entity.serializable then
         table.insert(data, entity:serialize(ignoreKeys))
      end
   end
end

After any removal, only the contiguous prefix is serialized. Entities after a removal are lost in save files.

3. System Evaluation on Dirty Entities

-- In tryAddSystem:
for j = 1, world.__entities.size do
   system:__evaluate(world.__entities[j])  -- Skips sparse entries!
end

New systems don't evaluate on "hidden" entities after removals.


Why This Bug Exists: The size Field Decoupling

The real issue is that self.size is not synchronized with the actual contiguous length of the table.

After swap-and-pop:

self.__entities.size = 2    -- Claims there are 2 entities
self.__entities[1] = e1
self.__entities[2] = e3
self.__entities[3] = nil     -- But position 3 still exists!

Iterating 1, size works in numeric loops but fails with ipairs() (which stops at the first nil).

The code inconsistently uses:

  • ipairs() in query()stops early
  • for i = 1, size do in __flush()works

The Real Fix Needed

Option A: Use Dense Table Semantics
Remove swap-and-pop. Instead:

function List:remove(obj)
   local index = self[obj]
   if not index then return end
   
   -- Shift all elements after the removed one
   for i = index, self.size - 1 do
      local next = self[i + 1]
      self[i] = next
      self[next] = i
   end
   
   self[self.size] = nil
   self[obj] = nil
   self.size = self.size - 1
   
   if self.onRemoved then self:onRemoved(obj) end
   return self
end

Cost: O(n) removal, but maintains contiguity.

Option B: Add Custom Iterator

function List:__pairs()
   local i = 1
   local size = self.size
   return function()
      while i <= size do
         local obj = self[i]
         if obj then
            local idx = i
            i = i + 1
            return idx, obj
         end
         i = i + 1
      end
   end
end

Cost: Requires systems to use pairs() instead of ipairs().

Option C: Validate Before Use
Concord could add assertions to catch this at runtime.


Summary

Issue Severity Impact
Sparse table after removal CRITICAL Entities silently disappear from queries, serialization, system evaluation
clear() corruption CRITICAL Incomplete entity cleanup, memory leaks
ipairs() vs loop semantic mismatch HIGH Inconsistent behavior across codebase
No validation MEDIUM No runtime warning when bugs occur

The List implementation is not just weak—it's dangerous in production use with Concord's ECS architecture. The swap-and-pop optimization trades correctness for speed, and Concord pays the price.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment