Implement Scheduler: stage execution, conflict batching, debug enforcement
Schedule gains real execution on top of the registration bookkeeping
from the PluginHost pass: RunStage(stage, world) runs every system
registered for a stage, and Reads<>/Writes<>() declarations are
enforced live via SystemAccessScope — a system touching a component it
didn't declare throws immediately, with a message naming the
violation, from GameWorld.Query<T>() and GameObject.GetComponent<T>()/
AddComponent<T>()/RemoveComponent<T>(). Outside of a running system
(editor code, tests, scene construction) nothing is enforced.
Scope cut made deliberately, not by accident: systems are grouped into
conflict-free batches by declared access (ComputeBatches, tested
directly), but batches run sequentially rather than on real threads.
Actually parallelizing them needs GameWorld's structural changes
(Create/Destroy/AddComponent/RemoveComponent) deferred to a command
buffer first — without that, two systems with disjoint *declared*
types can still race on shared storage, since AddComponent<T>() on a
GameObject mutates that object's own component list regardless of T.
Building real concurrency on top of a known thread-safety hole would
be worse than not building it yet. Noted as a TODO on RunStage.
Two real bugs found and fixed while wiring this up, not designed in
from the start:
- ISchedule.Add took `Delegate`, and a lambda passed there doesn't
reliably compile down to `Action<IWorld>` at runtime — the
compiler's natural-type inference for lambdas (as opposed to method
groups, which do work this way) can synthesize a different, private
delegate type instead, so `is Action<IWorld>` silently failed for
every lambda-registered system. Changed Add's parameter type to
Action<IWorld> directly, which sidesteps the inference question
entirely — found by ScheduleTests actually using lambdas, which
EchoPlugin's method-group-based Tick had been masking.
- AlcUnloadTests started failing intermittently ("ALC survived unload
cycle 3") once PluginSystemTests existed alongside it — xUnit
parallelizes across test classes by default, and ALC-unload tests
are sensitive to any concurrent activity in the process. Added
[CollectionBehavior(DisableTestParallelization = true)] to the
harness assembly; stable across 5+ repeated runs since.
EchoPlugin.Tick is no longer a stub — it increments every Ping.Count
in World, which two new integration tests in
Engine.ConformanceHarness/PluginSystemTests.cs exercise end to end: a
plugin loaded from a real collectible ALC registers a system, Schedule
actually invokes that cross-ALC delegate, and it correctly mutates a
component owned by the Default-ALC World. This only works because
PluginLoadContext resolves Sandbox.Echo.Contracts to the copy this
test project references directly, rather than loading a second,
type-incompatible one — the harness's new normal ProjectReference to
Sandbox.Echo.Contracts.csproj makes that a live assertion, not just an
implementation detail no test would notice breaking.
27 tests total now (21 in Engine.Kernel.Tests, 6 in
Engine.ConformanceHarness), all green on a clean build, harness
verified stable across repeated runs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
namespace Engine.Kernel.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Ambient, per-thread record of which component types the currently
|
||||
/// running system declared via Reads<T>()/Writes<T>(). GameWorld
|
||||
/// and GameObject consult this — when one is active — to enforce
|
||||
/// docs/kernel-contract.md §7's rule that an undeclared access fails
|
||||
/// loudly instead of silently working by accident.
|
||||
///
|
||||
/// No scope is active outside of Schedule.RunStage's invocation of a
|
||||
/// system — editor code, tests, and initial scene construction are all
|
||||
/// unconstrained by design; enforcement exists for the frame loop, not for
|
||||
/// every touch of a GameObject anywhere in the process.
|
||||
///
|
||||
/// ThreadLocal rather than a plain static field: batches run sequentially
|
||||
/// today (see the TODO on Schedule.RunStage), but this is already correct
|
||||
/// for when a batch's systems run on separate threads instead.
|
||||
/// </summary>
|
||||
internal static class SystemAccessScope
|
||||
{
|
||||
private static readonly ThreadLocal<(IReadOnlySet<Type> Reads, IReadOnlySet<Type> Writes)?> Current = new();
|
||||
|
||||
public static IDisposable Enter(IReadOnlySet<Type> reads, IReadOnlySet<Type> writes)
|
||||
{
|
||||
var previous = Current.Value;
|
||||
Current.Value = (reads, writes);
|
||||
return new Restore(previous);
|
||||
}
|
||||
|
||||
/// <summary>Querying or fetching a component counts as a read — either
|
||||
/// Reads<T>() or Writes<T>() satisfies it.</summary>
|
||||
public static void CheckRead(Type componentType)
|
||||
{
|
||||
var scope = Current.Value;
|
||||
if (scope is null)
|
||||
return;
|
||||
|
||||
if (!scope.Value.Reads.Contains(componentType) && !scope.Value.Writes.Contains(componentType))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A system read '{componentType.Name}' without declaring Reads<{componentType.Name}>() " +
|
||||
$"or Writes<{componentType.Name}>() — see docs/kernel-contract.md §7.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Structurally changing a GameObject's components — adding or
|
||||
/// removing one — requires Writes<T>() specifically. Mutating a
|
||||
/// component's own fields after GetComponent<T>() isn't
|
||||
/// interceptable this way; see the note on GameObject.AddComponent.</summary>
|
||||
public static void CheckWrite(Type componentType)
|
||||
{
|
||||
var scope = Current.Value;
|
||||
if (scope is null)
|
||||
return;
|
||||
|
||||
if (!scope.Value.Writes.Contains(componentType))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A system structurally changed '{componentType.Name}' without declaring " +
|
||||
$"Writes<{componentType.Name}>() — see docs/kernel-contract.md §7.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Restore((IReadOnlySet<Type> Reads, IReadOnlySet<Type> Writes)? previous) : IDisposable
|
||||
{
|
||||
public void Dispose() => Current.Value = previous;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user