Skip to content

[Bugfix] Close the data race between Scene.assign and a reader of the slot - #69

Closed
miogds wants to merge 1 commit into
developfrom
bugfix/component_publication_fork
Closed

miogds wants to merge 1 commit into
developfrom
bugfix/component_publication_fork

Conversation

@miogds

@miogds miogds commented Oct 4, 2026

Copy link
Copy Markdown
Member

What

The fix of untoldengine#1302 on the fork's develop. Scene.assign wrote a new component into its slot with a plain store and Scene.get read the slot with a plain load, outside the scene lock, so a reader whose copy of the scene says the slot is in use could load the new pointer with nothing ordering that load after the object's initialization. A component that is an object now goes into its slot with an atomic compare and exchange and is read with an acquire load. The cause, the hardware test, the measurements and the options that were weighed are in the upstream PR.

One commit

The commit of untoldengine#1302, cherry-picked with -x. The fork already releases components (#68, upstream untoldengine#1300), and the two changes meet in Scene.assign, Scene.get and ComponentPool. Resolved this way:

  • Scene.assign: the component the entity had goes to the quarantine first, as before, and the new one is then stored atomically through ComponentSlot.
  • ComponentPool: whether a slot holds one reference to an object is kept in the chunk list's header, beside the quarantine, so the pool stays one reference. slot(at:) hands out the slot's address with it, and component(at:as:) reads through ComponentSlot.
  • Scene.get: asks the pool for the slot and lets the pool go before it reads. A pool kept across the atomic load is retained and released on every call (4 ns); the scene the caller holds keeps the pool's storage, so what the quarantine holds back stays held back.

This is the form the upstream branch takes when it is rebased over untoldengine#1300, or untoldengine#1300 over it.

Verified on this branch

  • swift test --filter UntoldEngineTests: 1,546 run. The only failure is ExternalRenderExtensionPackageTests, which fails locally whenever the checkout folder is not named UntoldEngine; it passes from one that is. UntoldComponentKitTests: 59 passed.
  • Thread Sanitizer (3 runs) and Address Sanitizer: the 55 tests of the release of components, of this change and of the ECS are clean. The three threaded tests of this change keep a reader on one copy of the scene while its entities are given the component again, lose it and get it back, and are destroyed with their indices reused; on develop each reports the data race.
  • Zero warnings in the strict-concurrency build (scripts/strict-concurrency-guardrails.sh); no new SwiftFormat findings in the changed files.
  • The fork's UntoldEditor develop (aba6ce4) built against this branch: 1,071 tests passed.
  • The render suite: 1,166 run with four workers, all passed.
  • Reads cost what they did with [Bugfix] Release ECS components when they leave the scene (with upstream #1289) #68 alone: scene.get 57.2 ns against 58.2 ns (release build, medians of 7 alternating runs, measured on the same merge over upstream develop).

… slot

The scene is read without its lock: `scene` hands out a copy of the Scene
under the lock, and the copy is read after it. Scene.assign wrote a new
component into its slot with a plain store and Scene.get read the slot with
a plain load, so when a reader's copy said the slot was in use, nothing
ordered the read after the new object's initialization. A slot in use gets
a new component when an entity is given a component it already has (loaders
register the transform and scenegraph components twice), when a component
is removed and given again (a mesh that is replaced), and when the index of
a destroyed entity is taken by a new one.

Thread Sanitizer reports it in the three cases. It is not only a formal
race: the stores of one core reach another out of order on Apple silicon.
Two plain stores, a field and then the pointer to it, read from a second
thread that follows the pointer, showed the pointer without the field 136
times in 20 million tries on an M4 Max, and never once the pointer is
stored with a release store or a compare and exchange.

A component that is an object now goes into its slot with an atomic compare
and exchange, which comes after everything its initializer wrote, and is
read with an acquire load. A reader gets the component the slot had or the
new one, and either one whole. What assign does is unchanged: an entity
given a component again still gets a new one.

Reads cost what they did, within a nanosecond: the acquire load is one
instruction in place of another. Scene.get lets its copy of the pool go
before it reads the slot, because a pool kept across the atomic load is
retained and released on every call (4 ns).

A component that is a value is not one reference: it is written and read
in place, as before.

On the fork, which already releases components (#68): a component that
leaves its slot goes to the quarantine first, and the new one is then
stored atomically. Whether a slot holds one reference is kept in the chunk
list's header, beside the quarantine. Scene.get asks the pool for the slot
and reads it; the scene the caller holds keeps the pool's storage.

(cherry picked from commit 704cb96)
@miogds

miogds commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

Closed without merging: the fork's develop already has this change through the sync with upstream (#70), which brought untoldengine#1302.

@miogds miogds closed this Oct 5, 2026
@miogds
miogds deleted the bugfix/component_publication_fork branch October 5, 2026 20:26
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