Skip to content

fix: make Query !Send/!Sync, add QueryHandle for multithreaded access - #300

Merged
Indra-db merged 3 commits into
mainfrom
fix/query-send-sync-soundness
Jul 18, 2026
Merged

Indra-db merged 3 commits into
mainfrom
fix/query-send-sync-soundness

Conversation

@Indra-db

@Indra-db Indra-db commented Jul 17, 2026 •

Copy link
Copy Markdown
Owner

Summary

Query<T> had unsafe impl Send/Sync gated only on its component tuple being Send. That bound is insufficient: iterating a query reads world storage (tables, query cache) with no synchronization, while the !Send World mutates on its owning thread lock-free. Safe code could therefore move a query to another thread and iterate it while the main thread mutated the world, a data race that crashes (STATUS_FAIL_FAST_EXCEPTION on Windows). This also answered an inconsistency from review: EntityView was already correctly !Send, while Query, with the same world pointer, was not.

Changes

Query is now !Send/!Sync

Like World. The original repro is now a compile error, locked in by trybuild tests.

New: QueryHandle: the sound path to queries in multithreaded systems

The legitimate pattern that relied on the old impls (capturing a query in par_each/par_run callbacks and iterating via iter_stage, mirroring upstream flecs) is preserved through a new type:

let q = world.new_query::<&Velocity>().handle();
world.system::<&mut Position>().par_each_entity(move |e, p| {
    q.iter_stage(e.world()).each(|v| { p.x += v.x; });
});

QueryHandle is Send/Sync only when the component tuple is Send, keeps the query alive like a clone, and cannot be iterated on its own; every iteration requires a world/stage handle already valid on the current thread. Off the owning thread such a handle only exists inside flecs-managed staged execution, where the world is read-only and writes are deferred. Its Drop/Clone synchronize with world teardown through a shared lock, so off-thread handle drops can never interleave with ecs_fini.

Additional soundness fixes found during review

  • iter_stage() (on Query and QueryHandle) now asserts the stage belongs to the query's world. The C side does not validate this; a mismatched pair walks foreign tables with the wrong query plan. This was reachable in safe code before this PR.
  • Entity creation (world.entity(), named/scoped variants, bulk builder) now panics during the multithreaded execution phase. The equivalent C-side ecs_assert in flecs_new_id is compiled out in release builds (NDEBUG), leaving a silent race on the non-atomic entity index; the Rust guard is always on. Measured: no detectable cost on create_delete benches (p > 0.05).
  • Fixed flecs_force_enable_ecs_asserts: build.rs defined FLECS_KEEP_ASSERTS (plural) but flecs checks FLECS_KEEP_ASSERT, so the feature silently did nothing. Verified the fixed define keeps asserts in release C builds.
  • Query::handle() panics if the flecs OS API has no threading functions (poly refcounts fall back to non-atomic without them).
  • QueryHandle re-exported from core/prelude (initially usable but not nameable).

Changelog user facing

Breaking

  • Query<T> is now !Send/!Sync (previously Send/Sync when its component tuple was Send). The old impls were unsound: iterating from another thread raced world mutation and could crash. To use a query inside multithreaded (par_*) system callbacks, capture a QueryHandle via Query::handle() and iterate with handle.iter_stage(stage); existing iter_stage call sites inside callbacks need no changes, only the capture.

Added

  • QueryHandle: thread-shareable handle to a query for par_* system callbacks. Send/Sync when the component tuple is Send; keeps the query alive; Clone supported.

Fixed

  • iter_stage() now panics when passed a world/stage from a different world than the query (previously undefined behavior).
  • Creating entities during the multithreaded execution phase now panics in all build profiles (previously a debug-only C assert; release builds raced silently).
  • flecs_force_enable_ecs_asserts feature now actually keeps flecs C asserts in release builds (macro name typo).

Changelog internals

  • WorldCtx gains world_dead: Arc<Mutex<bool>>; World::drop marks it under lock before ecs_fini, and QueryHandle::drop/clone hold it across refcount operations so off-thread handle teardown cannot interleave with world destruction. Owning-thread drops during ecs_fini (handles in system closures) still run cleanup; post-teardown drops are detected via is_panicking.
  • WorldCtx::is_panicking changed Cell<bool> → AtomicBool (read off-thread by handle drops).
  • Off-thread last-ref drop of an anonymous query skips ecs_query_fini (would mutate world allocator state); memory is reclaimed at world fini.
  • Query::handle() requires ecs_os_has_threading() (atomic poly refcounts); asserted at construction.
  • Thread-affinity comment in world_ctx.rs updated: par systems are guarded by TupleType: Send bounds on registration; Query itself is !Send.
  • Compile-fail stderr expectations regenerated (Query errors now via auto-trait NonNull fields instead of unsatisfied bounds).

@Indra-db
Indra-db merged commit aa282ce into main Jul 18, 2026
11 checks passed
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