fix: held accessor views fail loud in dev — making the docs' fail-loud claim true#53
Conversation
… is now true The object read()/write() returns is the pooled per-(archetype, component) singleton. A held view silently re-pointed when a later world.entity() resolve re-poked it at another entity, or when a swap-pop handed its row to a new tenant — core-concepts.md claimed this misuse 'fails loud' but only the EntityRef-level resolve was guarded. In dev mode (NODE_ENV !== 'production') each random-access resolve now returns a per-call Proxy membrane stamped with (handle, row); every property access re-validates both the singleton's binding and the row's current tenant, throwing an actionable error instead of reading another entity's data. Lenient (observer-window) resolves are exempt — their rows are expected to be re-tenanted mid-window. Production returns the bare singleton: zero allocation. Query iteration never passes through the guarded path. Also: load(bytes, mode) throws on an unknown mode instead of silently treating it as merge (a JS caller passing 'overwrite' fell through every replace branch).
Greptile SummaryThis PR closes two silent-failure gaps: it adds a dev-mode
Confidence Score: 4/5The production path is untouched; the change adds a dev-only Proxy guard and a serialisation mode check. Safe to merge with the noted dev-guard gap understood. The dev-mode guard's fresh() check correctly catches re-pointing and swap-pop re-tenanting, but silently passes when the entity being despawned or migrating is the last occupant of its archetype — arch.rows[row] is never cleared in that path, so both halves of the condition remain true. A held write view in that window can write into a freed row without throwing. packages/core/src/storage/storage.ts — specifically the fresh() predicate inside guardAccessorView and the corresponding last-entity despawn/migration path in removeRow. Important Files Changed
Prompt To Fix All With AIFix the following 1 code review issue. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 1
packages/core/src/storage/storage.ts:281-282
**Stale-guard miss: last-entity despawn/migration not detected**
`removeRow` only updates `arch.rows[row]` during a swap-pop (when `row !== last`). When the entity being removed IS the last occupant of its archetype, `arch.count` is simply decremented and `arch.rows[row]` is left holding the departing handle. Because neither condition in `fresh()` changes — `a.__eid` is not re-pointed, and `arch.rows[row]` still matches `expectedEid >>> 0` — the proxy appears fresh after the entity is gone. A held write view accessed in this window would silently write into a now-freed row, potentially corrupting data for the next entity allocated there. The test suite only covers the swap-pop path (entity is not last); a test with `world.despawn(only_entity)` would repro this miss.
Reviews (1): Last reviewed commit: "fix: held accessor views fail loud in de..." | Re-trigger Greptile |
| const fresh = (): boolean => | ||
| (a.__eid as number) === expectedEid && (arch === null || arch.rows[row] === (expectedEid >>> 0)) |
There was a problem hiding this comment.
Stale-guard miss: last-entity despawn/migration not detected
removeRow only updates arch.rows[row] during a swap-pop (when row !== last). When the entity being removed IS the last occupant of its archetype, arch.count is simply decremented and arch.rows[row] is left holding the departing handle. Because neither condition in fresh() changes — a.__eid is not re-pointed, and arch.rows[row] still matches expectedEid >>> 0 — the proxy appears fresh after the entity is gone. A held write view accessed in this window would silently write into a now-freed row, potentially corrupting data for the next entity allocated there. The test suite only covers the swap-pop path (entity is not last); a test with world.despawn(only_entity) would repro this miss.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/core/src/storage/storage.ts
Line: 281-282
Comment:
**Stale-guard miss: last-entity despawn/migration not detected**
`removeRow` only updates `arch.rows[row]` during a swap-pop (when `row !== last`). When the entity being removed IS the last occupant of its archetype, `arch.count` is simply decremented and `arch.rows[row]` is left holding the departing handle. Because neither condition in `fresh()` changes — `a.__eid` is not re-pointed, and `arch.rows[row]` still matches `expectedEid >>> 0` — the proxy appears fresh after the entity is gone. A held write view accessed in this window would silently write into a now-freed row, potentially corrupting data for the next entity allocated there. The test suite only covers the swap-pop path (entity is not last); a test with `world.despawn(only_entity)` would repro this miss.
How can I resolve this? If you propose a fix, please make it concise.
The gap (audit finding #4, 2026-06)
core-concepts.mdpromises pooled-ref misuse "fails loud" — but only theEntityRef-level resolve was guarded. The objectread()/write()returns is itself the pooled per-(archetype, component) singleton, and a held view silently re-pointed two ways:world.entity(b).read(C)re-pokes the singleton → the held view reads b's data__eidstill matches; the data underneath changed)The fix
Dev-mode (
IS_DEV) per-resolve Proxy membrane instorage.#resolve, stamped with(handle, boundRow). Every property get/set re-validates the singleton's binding and the row's current tenant (arch.rows[boundRow] === handle, read lazily — the rows view is re-published on growth), throwing an actionablestale accessor viewerror.__lenientthroughAccessorResolver.resolveRead/Write(…, lenient)— their rows are expected to be re-tenanted mid-window.targetas receiver so prototype getters/setters keep the singleton asthis— cached VecViews stay singleton-bound. (Known limit: a held vec element view bypasses the membrane; the.vecaccess itself is covered.)core-concepts.mdupdated to state the guarantee precisely (dev guards the view; fix what dev flags before shipping).Also in this PR
load(bytes, mode)now throws on an unknown mode — a JS caller passing e.g.'overwrite'previously fell through everymode === 'replace'branch and silently merged on a load-a-save path.Tests
__eid-matches case)load(bytes, 'overwrite')throwsunknown load modepnpm test1210 passed ·typecheck:testsclean ·docs:check40/40 · spec §6.5 (accessors.md) updated locally