2026 08 17 explicit schema creation - #2
Open
Juliusz Kopczewski (julkiewicz) wants to merge 5 commits into
Open
Conversation
The schema was resolved lazily by the static readonly field EntityStoreBase.Static.EntitySchema, so the first code to touch any store decided the schema for the whole process. When that happened before NativeAOT.CreateSchema(), the store silently used a reflection-built schema (JIT) or an engine-types-only default schema (NativeAOT), and CreateSchema() afterwards built a second schema nothing read. Nothing detected this. Mod components cannot exist in either fallback schema, so their struct indices aliased unrelated real components, and reinterpreting one component's bytes as another corrupted memory. Schema creation is now explicit on every path: - EntitySchemaHolder is the single place the schema lives. Set() seals it once, reads throw a message naming the required order if it is not created yet. - Static.EntitySchema and Static.DefaultHeapMap become properties. A throwing class initializer would poison EntityStoreBase.Static permanently and bury the real message in a TypeInitializationException, so the check has to happen on the call that came too early, not in an initializer. - The derived static readonly caches in ComponentTypes, Tags and CommandBuffer become properties for the same reason. - SchemaBootstrap.CreateFromLoadedAssemblies() exposes the reflection scan as an explicit entry point, so a host that discovers components by scanning can still do so, after its mod assemblies are loaded. - SchemaUtils.RegisterSchemaTypes and NativeAOT.CreateDefaultSchema are now dead. Their bodies are kept for reference behind a throw.
Both ways of building a schema now go through SchemaBootstrap, so there is one place that documents how and when the schema comes into existence rather than two unrelated entry points. CreateFromRegisteredTypes(NativeAOT) forwards to NativeAOT.CreateSchema, which already seals through EntitySchemaHolder. NativeAOT.CreateSchema stays public because the fork's own test projects call it directly, and its doc comment now points at SchemaBootstrap.
Friflo supports exactly one schema per process, and the two ways of building it cannot be combined: a reflection scan builds its own SchemaTypes and ignores anything registered on a NativeAOT instance, and vice versa. A second creation attempt already threw, but the message did not say which mechanism had won, and the scan walked and force-loaded the whole reference graph before failing. The holder now records how the schema was created, as an EntitySchemaSource with an explicit NotCreated member, and refuses a second attempt naming both sides: EntitySchema already created from RegisteredTypes, and LoadedAssemblies tried to create it again. ... The two mechanisms are mutually exclusive: ... A duplicate call through the same mechanism says so instead, so the two cases are distinguishable. Set() now takes the lock before checking. Without it two threads racing to create could both pass the null check and the last writer would win silently, which is the class of bug this whole change exists to remove. SchemaBootstrap also checks up front so the reflection scan does not do its work only to throw at the end.
Juliusz Kopczewski (julkiewicz)
changed the base branch from
main
to
readym-main
August 17, 2026 20:02
Reworks the holder's threading and the entry points around what a caller can actually be held to. Synchronization. The read path now carries no volatile and no lock at all: the schema is written once and immutable, so there is nothing to protect, and the obligation that matters cannot live here anyway. Creating the schema must happen-before every read, and a holder that synchronized every read would still not help a caller that creates a world while another thread is still registering types - that caller gets NotCreated nondeterministically, not a stale schema. Static.EntitySchema is read on every archetype creation and inside the query enumerators, so a barrier there buys a correct caller nothing. The contract is written out in full on the fields. Initialize does the check, the build and the assignment under one lock, so every caller leaves with a happens-before edge to the writes whether it created the schema or found one already there. IsCreated is no longer public: a published flag only enables `if (!IsCreated) Create()`, whose loser never takes the lock and so has no edge with the writer. Rather than document the trap, the means to fall into it is gone. NativeAOT.SchemaCreated became internal for the same reason. Repeated initialization. A second initialization is a hard failure by default. A process that legitimately builds many containers over one schema opts in with AllowRepeatedInitializationForTests, which requires a sink so it cannot be enabled silently, and even then the repeat must describe the same schema: same registered .NET types, same mod components by index and size. A caller whose registrations differ is refused, because its component types are not the ones in the sealed schema. Mixing the two mechanisms is refused either way. The shape description deliberately does not compare the finished component and tag tables. Those are only populated by SchemaTypes.CreateSchemaTypes during real creation, so describing a candidate that way would mean building a second schema, and that mutates Friflo's process-global type state. The already-created guard also moved off the registration path. Registering into a NativeAOT instance that will not create the schema is harmless, it is a throwaway object, and it is what a process with more than one container does. CreateSchemaInternal, Initialize and Set are where creation is refused.
Whether a same-mechanism repeat throws depends on the process-wide flag, so anything asserting either behaviour has to be able to state which mode it expects instead of assuming the process it happens to run in. A mechanism conflict is checked before the flag and does not need this.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.