Skip to content

2026 08 17 explicit schema creation - #2

Open
Juliusz Kopczewski (julkiewicz) wants to merge 5 commits into
readycodeio:readym-mainfrom
julkiewicz:2026-08-17-explicit-schema-creation
Open

2026 08 17 explicit schema creation#2
Juliusz Kopczewski (julkiewicz) wants to merge 5 commits into
readycodeio:readym-mainfrom
julkiewicz:2026-08-17-explicit-schema-creation

Conversation

@julkiewicz

Copy link
Copy Markdown

No description provided.

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.
@julkiewicz
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.
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