From 01c693d94c9acf6581be84879477fb13168ff7ac Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 30 Sep 2026 20:14:28 +0900 Subject: [PATCH 1/2] Run the external Scene preflight after every Play session With Auto Refresh enabled, a Scene file changed while no focus return followed (during Play Mode, or while the Editor stayed focused) was imported by Unity right after Play Mode ended. Unity then raised its "modified externally" dialog, which blocked control-play-mode --action Stop, and on Unity 2022.3 answering that dialog crashed the Editor twice. The preflight ran on Edit Mode return only when a focus return had been deferred and the Editor was focused. - Leaving Edit Mode now schedules the preflight, and Edit Mode return runs it whether or not the Editor is focused. Its import-then-reload order is what keeps the dialog away. - The flag already persisted in SessionState, and while it is set Initialize keeps the restored fingerprints. A domain reload on either side of Play therefore no longer re-baselines over the change it must detect. Verified on Unity 2022.3.62f3 with Auto Refresh enabled: after changing an open clean Scene on disk, then CLI Play and Stop, the dialog no longer appeared and Stop returned with the new content loaded. This held with domain reload on Play both disabled and enabled. --- .../ExternalSceneFocusReturnDeferralTests.cs | 28 +++++++++++++++++++ .../Compile/ExternalSceneChangeTracker.cs | 13 +++++++-- .../ExternalSceneFocusReturnDeferral.cs | 13 ++++++++- docs/focus-return-asset-handling.md | 14 ++++++++-- 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/Assets/Tests/Editor/ExternalSceneFocusReturnDeferralTests.cs b/Assets/Tests/Editor/ExternalSceneFocusReturnDeferralTests.cs index 92eaa90f90..fe826d08fb 100644 --- a/Assets/Tests/Editor/ExternalSceneFocusReturnDeferralTests.cs +++ b/Assets/Tests/Editor/ExternalSceneFocusReturnDeferralTests.cs @@ -58,6 +58,34 @@ public void ConsumeOnEnteredEditMode_WhenNothingDeferred_ReturnsFalse() Assert.That(deferral.ConsumeOnEnteredEditMode(), Is.False); } + [Test] + public void DeferUntilEditMode_WhenLeavingEditMode_ResolvesOnceOnEnteredEditMode() + { + // Verifies every Play session schedules one preflight for its end, even without a focus return, + // because a file changed during Play otherwise reaches Unity's reload dialog right after Stop. + ExternalSceneFocusReturnDeferral deferral = new ExternalSceneFocusReturnDeferral(isDeferred: false); + + deferral.DeferUntilEditMode(); + + Assert.That(deferral.ConsumeOnEnteredEditMode(), Is.True); + Assert.That(deferral.ConsumeOnEnteredEditMode(), Is.False); + } + + [Test] + public void DeferUntilEditMode_WhenPlayModeDomainReloadInitializes_KeepsTheRestoredFingerprints() + { + // Verifies a domain reload while entering or leaving Play Mode keeps the pre-Play fingerprints, + // so an external change made before or during Play is still detected when Edit Mode returns. + ExternalSceneFocusReturnDeferral deferral = new ExternalSceneFocusReturnDeferral(isDeferred: false); + + deferral.DeferUntilEditMode(); + bool shouldRecord = deferral.ShouldRecordBaselineOnInitialize( + isFocused: true, + restoredSceneSnapshots: true); + + Assert.That(shouldRecord, Is.False); + } + [Test] public void RemoveSnapshotsForScenesNotOpen_RemovesRuntimeLoadedScenesAndKeepsOpenOnes() { diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs index 72f29e73dc..820ddffd6d 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs @@ -102,6 +102,14 @@ private static void HandleFocusChanged(bool isFocused) private static void HandlePlayModeStateChanged(PlayModeStateChange state) { + if (state == PlayModeStateChange.ExitingEditMode) + { + _focusReturnDeferral.DeferUntilEditMode(); + // The deferral must outlive the domain reloads on both sides of Play Mode. + SessionState.SetBool(FocusReturnDeferredSessionStateKey, true); + return; + } + if (state != PlayModeStateChange.EnteredEditMode) { return; @@ -122,8 +130,9 @@ private static void HandlePlayModeStateChanged(PlayModeStateChange state) "Entered Edit Mode", new { prunedScenePaths, shouldResolve }, includeStackTrace: false); - // An unfocused Editor gets the same preflight from its next focus return. - if (shouldResolve && EditorApplication.isFocused) + // Why not wait for focus: Unity's own post-Play refresh can import a changed Scene before any focus + // return, and the preflight's import-then-reload is what keeps the reload dialog from appearing. + if (shouldResolve) { ResolveForFocusReturn(); } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneFocusReturnDeferral.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneFocusReturnDeferral.cs index ac9f257906..417d8b6864 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneFocusReturnDeferral.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneFocusReturnDeferral.cs @@ -1,7 +1,8 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Remembers that a focus-return Scene preflight was skipped during Play Mode so it can run once Edit Mode returns. + /// Remembers that the Scene preflight must run once Edit Mode returns: for a focus return skipped during + /// Play Mode, and for every Play session. /// internal sealed class ExternalSceneFocusReturnDeferral { @@ -28,6 +29,16 @@ public bool ShouldResolveOnFocusReturn(bool isPlayingOrWillChangePlaymode) return false; } + /// + /// Schedules one preflight for when Edit Mode returns. Called when the Editor leaves Edit Mode, because + /// a file changed during Play (or while the Editor stayed focused) is followed by no focus return, and + /// with Auto Refresh enabled Unity imports it right after Play Mode ends and raises its reload dialog. + /// + public void DeferUntilEditMode() + { + IsDeferred = true; + } + /// /// Decides whether Initialize may replace the restored fingerprints with the current disk state. /// A deferred focus return needs the pre-reload fingerprints to compare against, so re-recording diff --git a/docs/focus-return-asset-handling.md b/docs/focus-return-asset-handling.md index 6759cc0164..4dcb14624a 100644 --- a/docs/focus-return-asset-handling.md +++ b/docs/focus-return-asset-handling.md @@ -44,9 +44,17 @@ Scene kept the exception firing on every later focus return. On `PlayModeStateChange.EnteredEditMode` the tracker first drops fingerprints for Scenes that are no longer open in the Editor (runtime-loaded Scenes never get a lasting baseline), then -runs the preflight once if a focus return was deferred and the Editor is focused. An unfocused -Editor leaves it to the next focus return instead. The deferral flag lives in `SessionState` -because leaving Play Mode can trigger a domain reload. The decision is isolated in +runs the preflight once. It does so after every Play session, not only when a focus return +was deferred, and whether or not the Editor is focused: `ExitingEditMode` schedules it. A +Scene file changed during Play Mode, or while the Editor stayed focused, is followed by no +focus return. With Auto Refresh enabled, Unity imports it right after Play Mode ends and +raises the "modified externally" dialog, which also blocks `control-play-mode --action Stop`. +The preflight's import-then-reload keeps the dialog away. This was confirmed on Unity +2022.3.62f3 with domain reload on Play both disabled and enabled (#3047). + +The deferral flag lives in `SessionState` because entering and leaving Play Mode can trigger a +domain reload. While it is set, `Initialize` keeps the restored fingerprints instead of +recording the current disk state, so a change made before or during Play is still detected. The decision is isolated in `ExternalSceneFocusReturnDeferral` and `ExternalSceneSnapshotPruner` (`Assets/Tests/Editor/ExternalSceneFocusReturnDeferralTests.cs`). From ed1fafdf0912d196ba6c271b8f09c2ef9a06ad14 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 30 Sep 2026 20:27:12 +0900 Subject: [PATCH 2/2] Move the Play Mode transition decision into the tested deferral class The previous tests only checked that a method set a flag, so removing the ExitingEditMode wiring in the tracker or restoring the focus condition left every test green. ShouldResolveOnPlayModeStateChange now owns both rules: leaving Edit Mode schedules one preflight, and Edit Mode return resolves it regardless of focus. The tracker passes the focus state and resolves exactly when the method returns true, so the tests fail if either rule is dropped. --- .../ExternalSceneFocusReturnDeferralTests.cs | 70 +++++++++++++------ .../Compile/ExternalSceneChangeTracker.cs | 17 ++--- .../ExternalSceneFocusReturnDeferral.cs | 31 +++++--- 3 files changed, 74 insertions(+), 44 deletions(-) diff --git a/Assets/Tests/Editor/ExternalSceneFocusReturnDeferralTests.cs b/Assets/Tests/Editor/ExternalSceneFocusReturnDeferralTests.cs index fe826d08fb..90ec56ec4f 100644 --- a/Assets/Tests/Editor/ExternalSceneFocusReturnDeferralTests.cs +++ b/Assets/Tests/Editor/ExternalSceneFocusReturnDeferralTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using NUnit.Framework; +using UnityEditor; using io.github.hatayama.UnityCliLoop.FirstPartyTools; @@ -36,54 +37,77 @@ public void ShouldResolveOnFocusReturn_WhenPlaying_ReturnsFalseAndDefers() } [Test] - public void ConsumeOnEnteredEditMode_WhenDeferred_ReturnsTrueOnceAndClears() + public void ShouldResolveOnPlayModeStateChange_WhenDeferralRestoredAfterReload_ResolvesOnceAndClears() { - // Verifies a deferred focus return runs exactly once after Play Mode ends. + // Verifies a deferral restored from SessionState after a Play Mode domain reload runs exactly once + // when Edit Mode returns, even though this instance never saw the Editor leave Edit Mode. ExternalSceneFocusReturnDeferral deferral = new ExternalSceneFocusReturnDeferral(isDeferred: true); - bool firstConsume = deferral.ConsumeOnEnteredEditMode(); - bool secondConsume = deferral.ConsumeOnEnteredEditMode(); + bool firstResolve = deferral.ShouldResolveOnPlayModeStateChange( + PlayModeStateChange.EnteredEditMode, + isFocused: true); + bool secondResolve = deferral.ShouldResolveOnPlayModeStateChange( + PlayModeStateChange.EnteredEditMode, + isFocused: true); - Assert.That(firstConsume, Is.True); - Assert.That(secondConsume, Is.False); + Assert.That(firstResolve, Is.True); + Assert.That(secondResolve, Is.False); Assert.That(deferral.IsDeferred, Is.False); } [Test] - public void ConsumeOnEnteredEditMode_WhenNothingDeferred_ReturnsFalse() + public void ShouldResolveOnPlayModeStateChange_AfterAPlaySession_ResolvesOnceEvenWhenUnfocused() { - // Verifies leaving Play Mode without a skipped focus return does not trigger a resolve. + // Verifies every Play session runs one preflight when Edit Mode returns, without a focus return and + // while unfocused, because Unity's own post-Play import otherwise raises the reload dialog after Stop. ExternalSceneFocusReturnDeferral deferral = new ExternalSceneFocusReturnDeferral(isDeferred: false); - Assert.That(deferral.ConsumeOnEnteredEditMode(), Is.False); + bool resolvesOnExitingEditMode = deferral.ShouldResolveOnPlayModeStateChange( + PlayModeStateChange.ExitingEditMode, + isFocused: false); + bool resolvesOnEnteredPlayMode = deferral.ShouldResolveOnPlayModeStateChange( + PlayModeStateChange.EnteredPlayMode, + isFocused: false); + bool resolvesOnEnteredEditMode = deferral.ShouldResolveOnPlayModeStateChange( + PlayModeStateChange.EnteredEditMode, + isFocused: false); + bool resolvesOnSecondEnteredEditMode = deferral.ShouldResolveOnPlayModeStateChange( + PlayModeStateChange.EnteredEditMode, + isFocused: false); + + Assert.That(resolvesOnExitingEditMode, Is.False); + Assert.That(resolvesOnEnteredPlayMode, Is.False); + Assert.That(resolvesOnEnteredEditMode, Is.True); + Assert.That(resolvesOnSecondEnteredEditMode, Is.False); } [Test] - public void DeferUntilEditMode_WhenLeavingEditMode_ResolvesOnceOnEnteredEditMode() + public void ShouldResolveOnPlayModeStateChange_WhenLeavingEditMode_KeepsFingerprintsAcrossPlayReloads() { - // Verifies every Play session schedules one preflight for its end, even without a focus return, - // because a file changed during Play otherwise reaches Unity's reload dialog right after Stop. + // Verifies a domain reload while entering or leaving Play Mode keeps the pre-Play fingerprints, + // so an external change made before or during Play is still detected when Edit Mode returns. ExternalSceneFocusReturnDeferral deferral = new ExternalSceneFocusReturnDeferral(isDeferred: false); - deferral.DeferUntilEditMode(); + deferral.ShouldResolveOnPlayModeStateChange(PlayModeStateChange.ExitingEditMode, isFocused: true); + bool shouldRecord = deferral.ShouldRecordBaselineOnInitialize( + isFocused: true, + restoredSceneSnapshots: true); - Assert.That(deferral.ConsumeOnEnteredEditMode(), Is.True); - Assert.That(deferral.ConsumeOnEnteredEditMode(), Is.False); + Assert.That(shouldRecord, Is.False); + Assert.That(deferral.IsDeferred, Is.True); } [Test] - public void DeferUntilEditMode_WhenPlayModeDomainReloadInitializes_KeepsTheRestoredFingerprints() + public void ShouldResolveOnPlayModeStateChange_WhenEditModeReturnsWithoutAPlaySession_DoesNotResolve() { - // Verifies a domain reload while entering or leaving Play Mode keeps the pre-Play fingerprints, - // so an external change made before or during Play is still detected when Edit Mode returns. + // Verifies Edit Mode return resolves only for a scheduled preflight, not on every state change. ExternalSceneFocusReturnDeferral deferral = new ExternalSceneFocusReturnDeferral(isDeferred: false); - deferral.DeferUntilEditMode(); - bool shouldRecord = deferral.ShouldRecordBaselineOnInitialize( - isFocused: true, - restoredSceneSnapshots: true); + bool shouldResolve = deferral.ShouldResolveOnPlayModeStateChange( + PlayModeStateChange.EnteredEditMode, + isFocused: true); - Assert.That(shouldRecord, Is.False); + Assert.That(shouldResolve, Is.False); } [Test] diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs index 820ddffd6d..f4154bf86a 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs @@ -102,14 +102,11 @@ private static void HandleFocusChanged(bool isFocused) private static void HandlePlayModeStateChanged(PlayModeStateChange state) { - if (state == PlayModeStateChange.ExitingEditMode) - { - _focusReturnDeferral.DeferUntilEditMode(); - // The deferral must outlive the domain reloads on both sides of Play Mode. - SessionState.SetBool(FocusReturnDeferredSessionStateKey, true); - return; - } - + bool shouldResolve = _focusReturnDeferral.ShouldResolveOnPlayModeStateChange( + state, + EditorApplication.isFocused); + // The deferral must outlive the domain reloads on both sides of Play Mode. + SessionState.SetBool(FocusReturnDeferredSessionStateKey, _focusReturnDeferral.IsDeferred); if (state != PlayModeStateChange.EnteredEditMode) { return; @@ -123,15 +120,11 @@ private static void HandlePlayModeStateChanged(PlayModeStateChange state) SaveSceneSnapshotsToSessionState(); } - bool shouldResolve = _focusReturnDeferral.ConsumeOnEnteredEditMode(); - SessionState.SetBool(FocusReturnDeferredSessionStateKey, false); VibeLogger.LogInfo( "external_scene_entered_edit_mode", "Entered Edit Mode", new { prunedScenePaths, shouldResolve }, includeStackTrace: false); - // Why not wait for focus: Unity's own post-Play refresh can import a changed Scene before any focus - // return, and the preflight's import-then-reload is what keeps the reload dialog from appearing. if (shouldResolve) { ResolveForFocusReturn(); diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneFocusReturnDeferral.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneFocusReturnDeferral.cs index 417d8b6864..7edea2a9bb 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneFocusReturnDeferral.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneFocusReturnDeferral.cs @@ -1,3 +1,5 @@ +using UnityEditor; + namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// @@ -30,13 +32,27 @@ public bool ShouldResolveOnFocusReturn(bool isPlayingOrWillChangePlaymode) } /// - /// Schedules one preflight for when Edit Mode returns. Called when the Editor leaves Edit Mode, because - /// a file changed during Play (or while the Editor stayed focused) is followed by no focus return, and - /// with Auto Refresh enabled Unity imports it right after Play Mode ends and raises its reload dialog. + /// Decides whether the preflight must run for this Play Mode transition. Leaving Edit Mode schedules + /// one preflight for when Edit Mode returns, and that preflight runs whether or not the Editor is + /// focused: Unity's own post-Play refresh can import a changed Scene before any focus return, and only + /// the preflight's import-then-reload keeps the reload dialog from appearing. The caller passes the focus + /// state and resolves exactly when this returns true, so ignoring focus stays part of this tested decision + /// instead of a condition the caller could add back. /// - public void DeferUntilEditMode() + public bool ShouldResolveOnPlayModeStateChange(PlayModeStateChange state, bool isFocused) { - IsDeferred = true; + if (state == PlayModeStateChange.ExitingEditMode) + { + IsDeferred = true; + return false; + } + + if (state != PlayModeStateChange.EnteredEditMode) + { + return false; + } + + return ConsumeDeferral(); } /// @@ -49,10 +65,7 @@ public bool ShouldRecordBaselineOnInitialize(bool isFocused, bool restoredSceneS return !restoredSceneSnapshots || (isFocused && !IsDeferred); } - /// - /// Returns whether a deferred preflight must run now, and clears the deferral so it runs only once. - /// - public bool ConsumeOnEnteredEditMode() + private bool ConsumeDeferral() { bool wasDeferred = IsDeferred; IsDeferred = false;