Skip to content

[core] Fix resource leak in table hooks for types without move_ctor#2047

Open
Indra-db wants to merge 2 commits intoSanderMertens:masterfrom
Indra-db:indradb/rust
Open

[core] Fix resource leak in table hooks for types without move_ctor#2047
Indra-db wants to merge 2 commits intoSanderMertens:masterfrom
Indra-db:indradb/rust

Conversation

@Indra-db
Copy link
Copy Markdown
Contributor

@Indra-db Indra-db commented Apr 2, 2026

Problem

Types that register move_dtor and ctor_move_dtor but not move_ctor leak resources during table compaction and component removal. This affects:

  • Rust binding types (explicitly set dtor, move_dtor, ctor_move_dtor, leave move and move_ctor NULL)
  • C++ types with trivial move constructors but non-trivial destructors (lifecycle_traits.hpp returns nullptr for move_ctor when is_trivially_move_constructible, but still registers ctor_move_dtor because the dtor is non-trivial)

Root cause: ctor_move_dtor treats dst as uninitialized (placement new), while move_dtor drops dst before overwriting. When dst holds live data, ctor_move_dtor skips the drop and leaks.

Changes

flecs_table_delete: added !destruct guard to the ctor_move_dtor fallback. When destruct=true (entity deletion), dst holds live data, so move_dtor must be used. The fallback to ctor_move_dtor is only correct when destruct=false (table compaction after a move, where dst was already moved away).

flecs_table_move (interleaved and trailing column loops): force dtor=true when !move_ctor && ctor_move_dtor for removed components. Previously, use_move_dtor=false (non-last entity) caused the dtor to be skipped entirely, deferring cleanup to table compaction. For types without move_ctor, compaction does not properly clean up the removed data.

Which types are affected

Type move_ctor ctor_move_dtor Condition matches Affected
C++ non-trivial move set set no no
C++ fully trivial (POD) NULL NULL no no
C++ trivial move, non-trivial dtor NULL set yes yes (latent bug)
Rust (destructive move) NULL set yes yes

Hook selection design

For types where move_ctor=NULL and ctor_move_dtor!=NULL:

Operation Correct hook Why
flecs_table_delete, non-last, destruct=true move_dtor dst holds live data, must drop before overwriting
flecs_table_delete, non-last, destruct=false ctor_move_dtor dst was moved away, uninitialized
flecs_table_move, matching columns ctor_move_dtor (existing logic) dst is new slot in dst table, uninitialized
flecs_table_move, removed column, last row dtor via invoke_remove_hooks(dtor=true) no compaction needed
flecs_table_move, removed column, not last row dtor must fire immediately cannot defer to compaction for these types

Edge cases

All scenarios assume move_ctor=NULL, ctor_move_dtor!=NULL, move_dtor!=NULL.

Fixed by this PR

ecs_delete on non-last entity (destruct=true)
ecs_delete -> flecs_table_delete(world, table, row, true). Last entity moves into gap. Before fix: !move_ctor && ctor_move_dtor always true, ctor_move_dtor used, dst leaked. After fix: !destruct is false, falls through to move_dtor, dst dropped first. Correct.

ecs_add/ecs_remove table move, entity is NOT last in source, removed columns
use_move_dtor=false. Before fix: dtor=false, dtor skipped, data abandoned. Compaction (flecs_table_delete(destruct=false)) used ctor_move_dtor on the slot, which treats it as uninitialized, removed component data leaked. After fix: dtor = false || (!move_ctor && ctor_move_dtor) = true, dtor fires immediately. Correct. Matching columns and compaction behavior unchanged (both already correct).

Not affected by this PR

ecs_delete on last entity - hits row == count branch, calls invoke_remove_hooks(dtor=true), no move hooks involved.

ecs_add/ecs_remove table move, entity is last in source - use_move_dtor=true already handled all paths correctly.

ecs_add with no components removed - removed-column loops don't execute.

Types with move_ctor set - !move_ctor is false, none of the new conditions trigger.

Fully trivial types (no hooks) - fast path (memcpy), no hook logic reached.

ctor_move_dtor=NULL and move_ctor=NULL - condition evaluates to true && false = false, falls through to move_dtor or memcpy.

Mixed hook configurations on same entity - each component processed independently per column.

Deferred operations - same call chain (flecs_move_entity -> flecs_table_move -> flecs_table_delete), same fix applies.

move_dtor=NULL but ctor_move_dtor set - flecs_type_info_move_dtor falls back to move + dtor or memcpy. Not a realistic configuration: both Rust and C++ bindings always set move_dtor alongside ctor_move_dtor.

Tests

Eight new tests in ComponentLifecycle covering three configurations.

Tests that fail without the fix:

Test Type
delete_nonlast_with_destruct_no_move_ctor Rust-like
remove_component_nonlast_no_move_ctor_calls_dtor_trailing Rust-like
remove_component_nonlast_no_move_ctor_calls_dtor_interleaved Rust-like
delete_nonlast_trivial_move_with_dtor_move_dtor_called C++ trivial-move
remove_component_nonlast_trivial_move_with_dtor_dtor_called C++ trivial-move

Tests that pass both with and without the fix:

Test Type
delete_last_no_move_ctor Rust-like, non-bug path
delete_nonlast_nontrivial_move_ctor_move_dtor_not_affected C++ non-trivial move
remove_component_nonlast_nontrivial_move_ctor_not_affected C++ non-trivial move

Also validated against a few hundred lifecycle test cases on the Flecs-Rust side.

Indra-db added 2 commits April 2, 2026 13:29
  for types without move_ctor

When a language binding (e.g. Rust) registers move_dtor and ctor_move_dtor but not move_ctor, three code paths in table management could leak resources:

flecs_table_delete: when deleting a non-last entity (destruct=true), the fallback condition (!move_ctor && ctor_move_dtor) always selected ctor_move_dtor, which treats dst as uninitialized and skips dropping the deleted entity's data. Fixed by gating the fallback on !destruct, so move_dtor is used when dst holds live data.

flecs_table_move (interleaved and trailing column loops): when removing a component from a non-last entity, use_move_dtor=false caused the dtor to be skipped entirely for the removed component. Fixed by forcing dtor=true when move_ctor is not set but ctor_move_dtor is, ensuring immediate cleanup rather than deferring to table compaction.

Added three tests to ComponentLifecycle that validate each code path and fail if the corresponding fix is reverted.
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.

1 participant