Skip to content

🐛 core: fix query hash collisions from the shared sort buffer - #281

Merged
krispya merged 3 commits into
pmndrs:canaryfrom
MaxFreedomPollard:fix-nested-query-hash-buffer
Sep 8, 2026
Merged

krispya merged 3 commits into
pmndrs:canaryfrom
MaxFreedomPollard:fix-nested-query-hash-buffer

Conversation

@MaxFreedomPollard

Copy link
Copy Markdown

Two queries that differ only in the traits listed before a relation filter end up with the same hash, so the second one reuses the first one's cached query instance and hands back the wrong entities.

The problem

const ChildOf = relation()

const parent = world.spawn(IsPlayer)
const positioned = world.spawn(Position, ChildOf(parent))
const named = world.spawn(Name, ChildOf(parent))

world.query(Position, ChildOf(IsPlayer)) // [positioned], correct
world.query(Name, ChildOf(IsPlayer))     // [positioned], should be [named]

Both of those hash to 3,49000001, where 3 is IsPlayer's trait id. Nothing throws, the second query just quietly returns the first one's results.

The cause

createQueryHash in packages/core/src/query/utils/create-query-hash.ts fills a module level Float64Array called sortBuf and starts every call at index 0. When a relation filter is given as inline parameters, line 43 recurses into createQueryHash to hash the target query. That nested call restarts at index 0, so it writes IsPlayer's id over the Position or Name id its caller had already put there, and sorts that slice on the way out. The caller then carries on from its own cursor and joins a buffer whose leading entries belong to the target query rather than to itself.

The hash is the cache key for ctx.queriesHashMap in packages/core/src/world/world.ts and for universe.cachedQueries in packages/core/src/query/query.ts, so a collision returns a query instance that was built from different parameters.

Passing a cached query instead, as in ChildOf(createQuery(IsPlayer)), was never affected, because that branch reads .hash off the query rather than recursing. That is also why the existing relation filter tests pass: they only ever pass the filter as the sole parameter, or pass a cached query.

The fix

A nested call now claims the slice after its caller's entries through a sortBufBase offset, and every call joins sortBuf.subarray(base, cursor) rather than subarray(0, cursor). Nothing extra is allocated, and a query with no inline relation filter never reads or writes the offset, so the hot path is unchanged.

Verification

Node 24.11.0, pnpm 11.9.0.

pnpm -F @koota/core test run with the new test and without the source change fails on expected [ 2 ] to deeply equal [ 3 ]. With the source change the core suite is 136 passed, 1 expected fail, 8 files, and the whole pnpm test run is green across @koota/collections (23), @koota/core (136) and @koota/react (36).

I also ran the pnpm test:build path, building the bundle and running the generated tests against dist: 172 passed, 2 expected fail, 13 files. Reverting only create-query-hash.ts and rebuilding makes the same generated test fail, so the fix holds through the inline function transform too.

@krispya
krispya changed the base branch from main to canary September 7, 2026 19:03
@krispya
krispya changed the base branch from canary to main September 7, 2026 19:04
@krispya

krispya commented Sep 7, 2026

Copy link
Copy Markdown
Member

Thanks this a helpful bug you found! I think there might be a simpler solution 🤔. I'm going to ponder it for a day.

MaxFreedomPollard and others added 3 commits September 8, 2026 10:18
createQueryHash in packages/core/src/query/utils/create-query-hash.ts writes into
a module-level Float64Array named sortBuf and starts every call at index 0. When a
relation filter is passed as inline parameters, the targetQuery branch recurses into
createQueryHash to hash the target query. That nested call restarts at index 0 and
overwrites the entries the outer call has already written.

The result is that world.query(Position, ChildOf(IsPlayer)) and
world.query(Name, ChildOf(IsPlayer)) both hash to "3,49000001", because the leading
trait id is replaced by IsPlayer's id in both. That hash is the key for
ctx.queriesHashMap in packages/core/src/world/world.ts, so the second query reuses
the first query's cached instance and silently returns the wrong entities.

A nested call now claims the slice after its caller's entries through a sortBufBase
offset, and each call joins sortBuf.subarray(base, cursor) rather than
subarray(0, cursor). Nothing is allocated, and a query with no inline relation
filter never touches the offset.
@krispya
krispya force-pushed the fix-nested-query-hash-buffer branch from 80b84f7 to c02bd83 Compare September 8, 2026 08:22
@krispya
krispya changed the base branch from main to canary September 8, 2026 08:24
@krispya

krispya commented Sep 8, 2026

Copy link
Copy Markdown
Member

I did some slight tweaks and moved this to canary which will soon become the new main. Thank you!

@krispya
krispya merged commit 67cfb0d into pmndrs:canary Sep 8, 2026
krispya added a commit that referenced this pull request Sep 16, 2026
* 🐛 core: fix query hash collisions from the shared sort buffer

createQueryHash in packages/core/src/query/utils/create-query-hash.ts writes into
a module-level Float64Array named sortBuf and starts every call at index 0. When a
relation filter is passed as inline parameters, the targetQuery branch recurses into
createQueryHash to hash the target query. That nested call restarts at index 0 and
overwrites the entries the outer call has already written.

The result is that world.query(Position, ChildOf(IsPlayer)) and
world.query(Name, ChildOf(IsPlayer)) both hash to "3,49000001", because the leading
trait id is replaced by IsPlayer's id in both. That hash is the key for
ctx.queriesHashMap in packages/core/src/world/world.ts, so the second query reuses
the first query's cached instance and silently returns the wrong entities.

A nested call now claims the slice after its caller's entries through a sortBufBase
offset, and each call joins sortBuf.subarray(base, cursor) rather than
subarray(0, cursor). Nothing is allocated, and a query with no inline relation
filter never touches the offset.

* ♻️ core: simplify recursive query hash offsets

* 🧪 benches: cover createQuery and inline query performance

---------

Co-authored-by: Kris Baumgartner <kjbaumgartner@gmail.com>
krispya added a commit that referenced this pull request Sep 16, 2026
* 🐛 core: fix query hash collisions from the shared sort buffer

createQueryHash in packages/core/src/query/utils/create-query-hash.ts writes into
a module-level Float64Array named sortBuf and starts every call at index 0. When a
relation filter is passed as inline parameters, the targetQuery branch recurses into
createQueryHash to hash the target query. That nested call restarts at index 0 and
overwrites the entries the outer call has already written.

The result is that world.query(Position, ChildOf(IsPlayer)) and
world.query(Name, ChildOf(IsPlayer)) both hash to "3,49000001", because the leading
trait id is replaced by IsPlayer's id in both. That hash is the key for
ctx.queriesHashMap in packages/core/src/world/world.ts, so the second query reuses
the first query's cached instance and silently returns the wrong entities.

A nested call now claims the slice after its caller's entries through a sortBufBase
offset, and each call joins sortBuf.subarray(base, cursor) rather than
subarray(0, cursor). Nothing is allocated, and a query with no inline relation
filter never touches the offset.

* ♻️ core: simplify recursive query hash offsets

* 🧪 benches: cover createQuery and inline query performance

---------

Co-authored-by: Kris Baumgartner <kjbaumgartner@gmail.com>
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.

2 participants