From 88e0afa46b0dd06c3c5b125cc0de4dcc5011aeb0 Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 2 Sep 2026 17:24:14 +0300 Subject: [PATCH] Fix: exiting Play mode from the editor's own Stop button crashed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real bug, caught by hand (not by any automated test): clicking Stop in the Lingua Editor panel threw InvalidOperationException — "A system structurally changed 'QuadRenderer' without declaring Writes()". Root cause: GameWorld.Restore rebuilds the world via GameObject. AddComponent, which SystemAccessScope checks. That's fine when Restore is called from unconstrained code — every existing WorldSnapshotTests test calls it directly, outside any system, which is exactly why none of them caught this. But PlayModeController.ExitPlay is called from inside EditorPlugin's own Stage.Render system (the button click handler runs as part of DrawUi), which correctly declares no Reads/Writes at all — it has no compile-time knowledge of QuadRenderer or any other game's component types. So the ambient SystemAccessScope was still active when Restore tried to rebuild them. SystemAccessScope's own doc comment already said "editor code... [is] unconstrained by design" — true only when the call happened to originate outside a system's scope, not actually true in general. Fixed with SystemAccessScope.Suspend(), which GameWorld.Restore now wraps its entire rebuild in: Restore is a bulk, whole-world reset regardless of who calls it, the same category of operation Destroy already is (which never went through the checked RemoveComponent path to begin with). New regression test reproduces the exact shape: a system with no declared access calling Restore, run through the real Schedule — not calling SystemAccessScope directly, and not calling Restore outside a system either, which is why this slipped through the first time. Passed immediately after the fix; would have failed loudly before it. Full suite: 87 tests. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N --- .../Scheduling/SystemAccessScope.cs | 23 ++++++++++++++++ src/Engine.Kernel/World/GameWorld.cs | 6 +++++ .../Engine.Kernel.Tests/WorldSnapshotTests.cs | 27 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/src/Engine.Kernel/Scheduling/SystemAccessScope.cs b/src/Engine.Kernel/Scheduling/SystemAccessScope.cs index 6007a27..8fe0e84 100644 --- a/src/Engine.Kernel/Scheduling/SystemAccessScope.cs +++ b/src/Engine.Kernel/Scheduling/SystemAccessScope.cs @@ -27,6 +27,29 @@ internal static class SystemAccessScope return new Restore(previous); } + /// + /// Clears any active scope for the duration of a bulk, whole-world + /// operation — GameWorld.Restore is the one real caller. Restore + /// rebuilds arbitrary component types from a snapshot via + /// GameObject.AddComponent, and it can be invoked from inside a + /// running system (a Play/Stop button click handled during Stage. + /// Render, say) as easily as from unconstrained editor/host code. This + /// class's own doc comment already says editor code and scene + /// construction are unconstrained by design; without this, that's only + /// true when the call happens to originate outside any system's own + /// scope, not because Restore is actually exempt — a real bug, not a + /// hypothetical one: entering then exiting Play mode from the editor's + /// own Stop button throws, because ExitPlay runs inside EditorPlugin's + /// Stage.Render system, which (correctly) never declares Writes<T>() + /// for component types it has no compile-time knowledge of. + /// + public static IDisposable Suspend() + { + var previous = Current.Value; + Current.Value = null; + return new Restore(previous); + } + /// Querying or fetching a component counts as a read — either /// Reads<T>() or Writes<T>() satisfies it. public static void CheckRead(Type componentType) diff --git a/src/Engine.Kernel/World/GameWorld.cs b/src/Engine.Kernel/World/GameWorld.cs index c54058d..bcdeacb 100644 --- a/src/Engine.Kernel/World/GameWorld.cs +++ b/src/Engine.Kernel/World/GameWorld.cs @@ -113,6 +113,12 @@ public sealed class GameWorld : IWorld public void Restore(string snapshot) { + // Suspended for the whole rebuild, not just around AddComponent: + // Restore is a bulk, whole-world reset regardless of which system + // (if any) happened to call it — see SystemAccessScope.Suspend's + // own doc comment for why this is a real, not hypothetical, fix. + using var _ = SystemAccessScope.Suspend(); + foreach (var root in Roots.ToArray()) Destroy(root); diff --git a/tests/Engine.Kernel.Tests/WorldSnapshotTests.cs b/tests/Engine.Kernel.Tests/WorldSnapshotTests.cs index c14dbd3..411de14 100644 --- a/tests/Engine.Kernel.Tests/WorldSnapshotTests.cs +++ b/tests/Engine.Kernel.Tests/WorldSnapshotTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using Engine.Kernel.Scheduling; using Engine.Kernel.World; namespace Engine.Kernel.Tests; @@ -103,4 +104,30 @@ public class WorldSnapshotTests stopwatch.ElapsedMilliseconds < 100, $"Snapshot + Restore of 300 GameObjects took {stopwatch.ElapsedMilliseconds} ms, expected < 100 ms."); } + + // A real bug, caught by hand: entering then exiting Play mode via + // engine.editor's own Stop button threw InvalidOperationException, + // because ExitPlay runs Restore from inside EditorPlugin's Stage. + // Render system — which correctly declares no Reads/Writes at all, it + // has no compile-time knowledge of QuadRenderer or any other game's + // component types — and Restore rebuilds those very types via + // AddComponent. Every other test in this file calls Restore directly, + // outside any system's scope, which is exactly why none of them caught + // this. This one reproduces the real call shape: a system with no + // declared access, calling Restore, run through the real Schedule. + [Fact] + public void Restore_Called_From_A_System_With_No_Declared_Access_Does_Not_Throw() + { + var world = new GameWorld(); + var go = world.CreateGameObject("Hero"); + go.AddComponent().Value = 100; + var snapshot = world.Snapshot(); + + var schedule = new Schedule(); + schedule.Add(Stage.Update, (IWorld w) => w.Restore(snapshot)); // no Reads/Writes — like EditorPlugin's DrawUi + + var exception = Record.Exception(() => schedule.RunStage(Stage.Update, world)); + + Assert.Null(exception); + } }