From 433ca1de80bed930120c6710be99ad558fc59d4c Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 1 Jun 2026 01:11:38 +0900 Subject: [PATCH 1/4] Restore interrupted domain reload settings Save the original Enter Play Mode settings in a delete-on-restore UserSettings marker before disabling domain reload. This prevents abandoned PlayMode test runs from leaving project settings dirty and keeps nested scopes active until the final scope exits. --- .../Editor/DomainReloadDisableScopeTests.cs | 168 ++++++++++++++++++ .../DomainReloadDisableScopeTests.cs.meta | 11 ++ .../Common/EditorUtility/AssemblyInfo.cs | 3 + .../Common/EditorUtility/AssemblyInfo.cs.meta | 11 ++ .../EditorUtility/DomainReloadDisableScope.cs | 35 +++- .../DomainReloadDisableScopeRecovery.cs | 156 ++++++++++++++++ .../DomainReloadDisableScopeRecovery.cs.meta | 11 ++ 7 files changed, 388 insertions(+), 7 deletions(-) create mode 100644 Assets/Tests/Editor/DomainReloadDisableScopeTests.cs create mode 100644 Assets/Tests/Editor/DomainReloadDisableScopeTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Common/EditorUtility/AssemblyInfo.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Common/EditorUtility/AssemblyInfo.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs.meta diff --git a/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs b/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs new file mode 100644 index 0000000000..60c45781a3 --- /dev/null +++ b/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs @@ -0,0 +1,168 @@ +using System.IO; + +using NUnit.Framework; +using UnityEditor; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies Enter Play Mode settings recovery for DomainReloadDisableScope. + /// + public sealed class DomainReloadDisableScopeTests + { + private static readonly string MarkerFilePath = DomainReloadDisableScopeRecovery.MarkerFilePathForTests; + private static readonly string TempFilePath = DomainReloadDisableScopeRecovery.TempFilePathForTests; + + private bool _originalEnabled; + private EnterPlayModeOptions _originalOptions; + private bool _markerFileExisted; + private string _markerFileContent; + private bool _tempFileExisted; + private string _tempFileContent; + + [SetUp] + public void SetUp() + { + _originalEnabled = EditorSettings.enterPlayModeOptionsEnabled; + _originalOptions = EditorSettings.enterPlayModeOptions; + _markerFileExisted = File.Exists(MarkerFilePath); + _markerFileContent = _markerFileExisted ? File.ReadAllText(MarkerFilePath) : string.Empty; + _tempFileExisted = File.Exists(TempFilePath); + _tempFileContent = _tempFileExisted ? File.ReadAllText(TempFilePath) : string.Empty; + + DomainReloadDisableScope.ResetActiveScopeCountForTests(); + DomainReloadDisableScopeRecovery.ClearPendingRestoreForTests(); + } + + [TearDown] + public void TearDown() + { + EditorSettings.enterPlayModeOptionsEnabled = _originalEnabled; + EditorSettings.enterPlayModeOptions = _originalOptions; + DomainReloadDisableScope.ResetActiveScopeCountForTests(); + DomainReloadDisableScopeRecovery.ClearPendingRestoreForTests(); + RestoreFile(MarkerFilePath, _markerFileExisted, _markerFileContent); + RestoreFile(TempFilePath, _tempFileExisted, _tempFileContent); + } + + [Test] + public void Dispose_RestoresOriginalSettingsAndDeletesMarkerFile() + { + // Verifies that a completed scope restores settings and removes its recovery marker. + SetEnterPlayModeSettings(false, EnterPlayModeOptions.None); + + using (DomainReloadDisableScope scope = new DomainReloadDisableScope()) + { + Assert.That(EditorSettings.enterPlayModeOptionsEnabled, Is.True); + Assert.That(EditorSettings.enterPlayModeOptions, Is.EqualTo(EnterPlayModeOptions.DisableDomainReload)); + Assert.That(DomainReloadDisableScopeRecovery.HasPendingRestoreForTests(), Is.True); + } + + Assert.That(EditorSettings.enterPlayModeOptionsEnabled, Is.False); + Assert.That(EditorSettings.enterPlayModeOptions, Is.EqualTo(EnterPlayModeOptions.None)); + Assert.That(DomainReloadDisableScopeRecovery.HasPendingRestoreForTests(), Is.False); + } + + [Test] + public void Dispose_KeepsDomainReloadDisabled_UntilLastNestedScopeDisposes() + { + // Verifies that nested scopes restore settings only after the final scope exits. + SetEnterPlayModeSettings(false, EnterPlayModeOptions.None); + + DomainReloadDisableScope outerScope = new DomainReloadDisableScope(); + DomainReloadDisableScope innerScope = new DomainReloadDisableScope(); + + innerScope.Dispose(); + + Assert.That(EditorSettings.enterPlayModeOptionsEnabled, Is.True); + Assert.That(EditorSettings.enterPlayModeOptions, Is.EqualTo(EnterPlayModeOptions.DisableDomainReload)); + Assert.That(DomainReloadDisableScopeRecovery.HasPendingRestoreForTests(), Is.True); + + outerScope.Dispose(); + + Assert.That(EditorSettings.enterPlayModeOptionsEnabled, Is.False); + Assert.That(EditorSettings.enterPlayModeOptions, Is.EqualTo(EnterPlayModeOptions.None)); + Assert.That(DomainReloadDisableScopeRecovery.HasPendingRestoreForTests(), Is.False); + } + + [Test] + public void RestoreIfPending_RestoresOriginalSettings_WhenScopeWasAbandoned() + { + // Verifies that a marker left by an abandoned scope can restore the original settings. + SetEnterPlayModeSettings(false, EnterPlayModeOptions.None); + + DomainReloadDisableScope scope = new DomainReloadDisableScope(); + Assert.That(EditorSettings.enterPlayModeOptionsEnabled, Is.True); + Assert.That(EditorSettings.enterPlayModeOptions, Is.EqualTo(EnterPlayModeOptions.DisableDomainReload)); + + DomainReloadDisableScopeRecovery.RestoreIfPending(); + + Assert.That(EditorSettings.enterPlayModeOptionsEnabled, Is.False); + Assert.That(EditorSettings.enterPlayModeOptions, Is.EqualTo(EnterPlayModeOptions.None)); + Assert.That(DomainReloadDisableScopeRecovery.HasPendingRestoreForTests(), Is.False); + System.GC.KeepAlive(scope); + } + + [Test] + public void Constructor_RestoresStaleMarker_BeforeSavingNewRunMarker() + { + // Verifies that stale recovery state is consumed before the next run records its baseline. + SetEnterPlayModeSettings(false, EnterPlayModeOptions.None); + + DomainReloadDisableScope abandonedScope = new DomainReloadDisableScope(); + Assert.That(DomainReloadDisableScopeRecovery.HasPendingRestoreForTests(), Is.True); + DomainReloadDisableScope.ResetActiveScopeCountForTests(); + + DomainReloadDisableScope nextScope = new DomainReloadDisableScope(); + DomainReloadDisableScopeRecoveryData markerData = DomainReloadDisableScopeRecovery.ReadMarkerDataForTests(); + + Assert.That(markerData.originalOptionsEnabled, Is.False); + Assert.That(markerData.originalOptions, Is.EqualTo((int)EnterPlayModeOptions.None)); + + nextScope.Dispose(); + + Assert.That(EditorSettings.enterPlayModeOptionsEnabled, Is.False); + Assert.That(EditorSettings.enterPlayModeOptions, Is.EqualTo(EnterPlayModeOptions.None)); + Assert.That(DomainReloadDisableScopeRecovery.HasPendingRestoreForTests(), Is.False); + System.GC.KeepAlive(abandonedScope); + } + + [Test] + public void RestoreIfPending_LeavesNoMarkerFile_AfterSuccessfulRestore() + { + // Verifies that successful recovery deletes the marker instead of saving a cleared state. + SetEnterPlayModeSettings(false, EnterPlayModeOptions.None); + DomainReloadDisableScope scope = new DomainReloadDisableScope(); + + DomainReloadDisableScopeRecovery.RestoreIfPending(); + + Assert.That(File.Exists(MarkerFilePath), Is.False); + Assert.That(File.Exists(TempFilePath), Is.False); + System.GC.KeepAlive(scope); + } + + private static void SetEnterPlayModeSettings(bool enabled, EnterPlayModeOptions options) + { + EditorSettings.enterPlayModeOptionsEnabled = enabled; + EditorSettings.enterPlayModeOptions = options; + } + + private static void RestoreFile(string filePath, bool fileExisted, string fileContent) + { + if (!fileExisted) + { + return; + } + + string directoryPath = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + File.WriteAllText(filePath, fileContent); + } + } +} diff --git a/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs.meta b/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs.meta new file mode 100644 index 0000000000..2d9a8dbd09 --- /dev/null +++ b/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 252ce50bd4844006a19e039419a86783 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/AssemblyInfo.cs new file mode 100644 index 0000000000..dfa2c7f965 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")] diff --git a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/AssemblyInfo.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/AssemblyInfo.cs.meta new file mode 100644 index 0000000000..d8c60f08a1 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b4e094b1aa114af78472170716ef9a4a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScope.cs b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScope.cs index 1528e70c7e..664379c1fa 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScope.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScope.cs @@ -8,13 +8,18 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// public class DomainReloadDisableScope : IDisposable { - private readonly bool _originalEnabled; - private readonly EnterPlayModeOptions _originalOptions; + private static int _activeScopeCount; + private bool _disposed; public DomainReloadDisableScope() { - _originalEnabled = EditorSettings.enterPlayModeOptionsEnabled; - _originalOptions = EditorSettings.enterPlayModeOptions; + if (_activeScopeCount == 0) + { + DomainReloadDisableScopeRecovery.RestoreIfPending(); + DomainReloadDisableScopeRecovery.SaveCurrentSettings(); + } + + _activeScopeCount++; EditorSettings.enterPlayModeOptionsEnabled = true; EditorSettings.enterPlayModeOptions = EnterPlayModeOptions.DisableDomainReload; @@ -22,8 +27,24 @@ public DomainReloadDisableScope() public void Dispose() { - EditorSettings.enterPlayModeOptionsEnabled = _originalEnabled; - EditorSettings.enterPlayModeOptions = _originalOptions; + if (_disposed) + { + return; + } + + _disposed = true; + System.Diagnostics.Debug.Assert(_activeScopeCount > 0, "active scope count must be positive before dispose"); + _activeScopeCount--; + + if (_activeScopeCount == 0) + { + DomainReloadDisableScopeRecovery.RestoreIfPending(); + } + } + + internal static void ResetActiveScopeCountForTests() + { + _activeScopeCount = 0; } } -} \ No newline at end of file +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs new file mode 100644 index 0000000000..3d50bbc00d --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs @@ -0,0 +1,156 @@ +using System; +using System.IO; + +using UnityEditor; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Restores Enter Play Mode settings when DomainReloadDisableScope was interrupted. + /// + internal static class DomainReloadDisableScopeRecovery + { + internal static string MarkerFilePathForTests => DomainReloadDisableScopeRecoveryConstants.MarkerFilePath; + internal static string TempFilePathForTests => DomainReloadDisableScopeRecoveryConstants.TempFilePath; + + [InitializeOnLoadMethod] + private static void RestorePendingSettingsOnEditorLoad() + { + if (AssetDatabase.IsAssetImportWorkerProcess()) + { + return; + } + + RestoreIfPending(); + } + + /// + /// Saves the current Enter Play Mode settings before DomainReloadDisableScope changes them. + /// + internal static void SaveCurrentSettings() + { + Debug.Assert( + !File.Exists(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath), + "recovery marker must be restored before saving a new marker"); + + DomainReloadDisableScopeRecoveryData markerData = new DomainReloadDisableScopeRecoveryData + { + originalOptionsEnabled = EditorSettings.enterPlayModeOptionsEnabled, + originalOptions = (int)EditorSettings.enterPlayModeOptions + }; + + SaveMarkerData(markerData); + } + + /// + /// Restores Enter Play Mode settings saved before DomainReloadDisableScope changed them. + /// + internal static void RestoreIfPending() + { + DeleteTempFileIfExists(); + if (!File.Exists(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath)) + { + return; + } + + DomainReloadDisableScopeRecoveryData markerData = ReadMarkerData(); + EditorSettings.enterPlayModeOptionsEnabled = markerData.originalOptionsEnabled; + EditorSettings.enterPlayModeOptions = (EnterPlayModeOptions)markerData.originalOptions; + DeleteMarkerFileIfExists(); + } + + /// + /// Deletes any saved Enter Play Mode settings pending restore. + /// + internal static void ClearPendingRestoreForTests() + { + DeleteTempFileIfExists(); + DeleteMarkerFileIfExists(); + } + + internal static bool HasPendingRestoreForTests() + { + return File.Exists(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath); + } + + internal static DomainReloadDisableScopeRecoveryData ReadMarkerDataForTests() + { + return ReadMarkerData(); + } + + private static void SaveMarkerData(DomainReloadDisableScopeRecoveryData markerData) + { + Debug.Assert(markerData != null, "markerData must not be null"); + + string directoryPath = DomainReloadDisableScopeRecoveryConstants.DirectoryPath; + if (!Directory.Exists(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + DeleteTempFileIfExists(); + string json = JsonUtility.ToJson(markerData, true); + File.WriteAllText(DomainReloadDisableScopeRecoveryConstants.TempFilePath, json); + DeleteMarkerFileIfExists(); + File.Move( + DomainReloadDisableScopeRecoveryConstants.TempFilePath, + DomainReloadDisableScopeRecoveryConstants.MarkerFilePath); + + Debug.Assert( + File.Exists(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath), + "marker file must exist after save"); + } + + private static DomainReloadDisableScopeRecoveryData ReadMarkerData() + { + string json = File.ReadAllText(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath); + DomainReloadDisableScopeRecoveryData markerData = + JsonUtility.FromJson(json); + if (markerData == null) + { + throw new InvalidOperationException("Domain reload recovery marker must contain valid JSON."); + } + + return markerData; + } + + private static void DeleteMarkerFileIfExists() + { + if (File.Exists(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath)) + { + File.Delete(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath); + } + } + + private static void DeleteTempFileIfExists() + { + if (File.Exists(DomainReloadDisableScopeRecoveryConstants.TempFilePath)) + { + File.Delete(DomainReloadDisableScopeRecoveryConstants.TempFilePath); + } + } + } + + /// + /// Stores the original Enter Play Mode settings needed for interrupted scope recovery. + /// + [Serializable] + internal sealed class DomainReloadDisableScopeRecoveryData + { + public bool originalOptionsEnabled; + public int originalOptions; + } + + /// + /// Centralizes DomainReloadDisableScope recovery marker paths. + /// + internal static class DomainReloadDisableScopeRecoveryConstants + { + internal const string DirectoryPath = "UserSettings"; + internal const string MarkerFileName = "uloop-domain-reload-recovery.json"; + internal const string TempFileName = MarkerFileName + ".tmp"; + internal static readonly string MarkerFilePath = Path.Combine(DirectoryPath, MarkerFileName); + internal static readonly string TempFilePath = Path.Combine(DirectoryPath, TempFileName); + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs.meta new file mode 100644 index 0000000000..d6416a461b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1a8678771b1d45eb84fe6ff1f5188f87 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 3a616a62f602c36d405b5fd07d66751867b232bd Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 1 Jun 2026 19:38:39 +0900 Subject: [PATCH 2/4] Handle abandoned domain reload scopes before new runs Reset stale scope state when a new PlayMode test run starts so pending recovery markers are consumed in the same Editor session. Validate recovered Enter Play Mode option bits before applying marker data, and align EditorUtility internal visibility with sibling assemblies. --- .../Editor/DomainReloadDisableScopeTests.cs | 19 ++++++++++++++- .../Common/EditorUtility/AssemblyInfo.cs | 2 ++ .../EditorUtility/DomainReloadDisableScope.cs | 18 ++++++++++++++ .../DomainReloadDisableScopeRecovery.cs | 24 +++++++++++++++++++ .../TestFramework/PlayModeTestExecuter.cs | 1 + 5 files changed, 63 insertions(+), 1 deletion(-) diff --git a/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs b/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs index 60c45781a3..ecc041e007 100644 --- a/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs +++ b/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using NUnit.Framework; @@ -113,7 +114,7 @@ public void Constructor_RestoresStaleMarker_BeforeSavingNewRunMarker() DomainReloadDisableScope abandonedScope = new DomainReloadDisableScope(); Assert.That(DomainReloadDisableScopeRecovery.HasPendingRestoreForTests(), Is.True); - DomainReloadDisableScope.ResetActiveScopeCountForTests(); + DomainReloadDisableScope.RecoverAbandonedScopeBeforeNewRun(); DomainReloadDisableScope nextScope = new DomainReloadDisableScope(); DomainReloadDisableScopeRecoveryData markerData = DomainReloadDisableScopeRecovery.ReadMarkerDataForTests(); @@ -143,6 +144,22 @@ public void RestoreIfPending_LeavesNoMarkerFile_AfterSuccessfulRestore() System.GC.KeepAlive(scope); } + [Test] + public void RestoreIfPending_RejectsUnsupportedEnterPlayModeOptionBits() + { + // Verifies that corrupted markers cannot write unsupported option bits into EditorSettings. + SetEnterPlayModeSettings(true, EnterPlayModeOptions.DisableDomainReload); + string json = "{ \"originalOptionsEnabled\": false, \"originalOptions\": 1024 }"; + File.WriteAllText(MarkerFilePath, json); + + InvalidOperationException exception = + Assert.Throws(() => DomainReloadDisableScopeRecovery.RestoreIfPending()); + + Assert.That(exception.Message, Does.Contain("supported Enter Play Mode option bits")); + Assert.That(EditorSettings.enterPlayModeOptionsEnabled, Is.True); + Assert.That(EditorSettings.enterPlayModeOptions, Is.EqualTo(EnterPlayModeOptions.DisableDomainReload)); + } + private static void SetEnterPlayModeSettings(bool enabled, EnterPlayModeOptions options) { EditorSettings.enterPlayModeOptionsEnabled = enabled; diff --git a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/AssemblyInfo.cs index dfa2c7f965..8710f4b962 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/AssemblyInfo.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/AssemblyInfo.cs @@ -1,3 +1,5 @@ using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.RunTests.TestFramework.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")] diff --git a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScope.cs b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScope.cs index 664379c1fa..ead86df2e0 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScope.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScope.cs @@ -42,6 +42,24 @@ public void Dispose() } } + /// + /// Resets stale scope state before a new PlayMode test run starts. + /// + internal static void RecoverAbandonedScopeBeforeNewRun() + { + if (_activeScopeCount == 0) + { + return; + } + + if (!DomainReloadDisableScopeRecovery.HasPendingRestore()) + { + return; + } + + _activeScopeCount = 0; + } + internal static void ResetActiveScopeCountForTests() { _activeScopeCount = 0; diff --git a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs index 3d50bbc00d..c200534fcb 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs @@ -11,6 +11,13 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class DomainReloadDisableScopeRecovery { + // Unity can persist this deprecated bit, but referencing the enum member raises CS0618. + private const int DisableSceneBackupUnlessDirtyOptionBit = 4; + private const int ValidEnterPlayModeOptionsMask = + (int)(EnterPlayModeOptions.DisableDomainReload + | EnterPlayModeOptions.DisableSceneReload) + | DisableSceneBackupUnlessDirtyOptionBit; + internal static string MarkerFilePathForTests => DomainReloadDisableScopeRecoveryConstants.MarkerFilePath; internal static string TempFilePathForTests => DomainReloadDisableScopeRecoveryConstants.TempFilePath; @@ -70,6 +77,11 @@ internal static void ClearPendingRestoreForTests() } internal static bool HasPendingRestoreForTests() + { + return HasPendingRestore(); + } + + internal static bool HasPendingRestore() { return File.Exists(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath); } @@ -112,9 +124,21 @@ private static DomainReloadDisableScopeRecoveryData ReadMarkerData() throw new InvalidOperationException("Domain reload recovery marker must contain valid JSON."); } + ValidateOriginalOptions(markerData.originalOptions); return markerData; } + private static void ValidateOriginalOptions(int originalOptions) + { + if ((originalOptions & ~ValidEnterPlayModeOptionsMask) == 0) + { + return; + } + + throw new InvalidOperationException( + "Domain reload recovery marker must contain supported Enter Play Mode option bits."); + } + private static void DeleteMarkerFileIfExists() { if (File.Exists(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath)) diff --git a/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/PlayModeTestExecuter.cs b/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/PlayModeTestExecuter.cs index 25ba913383..4f940a4a51 100644 --- a/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/PlayModeTestExecuter.cs +++ b/Packages/src/Editor/FirstPartyTools/RunTests/TestFramework/PlayModeTestExecuter.cs @@ -41,6 +41,7 @@ public static async Task ExecutePlayModeTest( CancellationToken ct) { ct.ThrowIfCancellationRequested(); + DomainReloadDisableScope.RecoverAbandonedScopeBeforeNewRun(); using DomainReloadDisableScope scope = new DomainReloadDisableScope(); return await ExecuteTestWithEventNotification(TestMode.PlayMode, filter, ct); } From ae8036655124454c245f1a9a871cb1953c3e0180 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 1 Jun 2026 20:22:03 +0900 Subject: [PATCH 3/4] Delete invalid domain reload recovery markers Remove corrupted recovery markers before failing fast so invalid marker data cannot trap the Editor in the same recovery exception on every load. Keep the existing validation strict and cover marker cleanup in the focused EditMode test. --- Assets/Tests/Editor/DomainReloadDisableScopeTests.cs | 1 + .../EditorUtility/DomainReloadDisableScopeRecovery.cs | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs b/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs index ecc041e007..29fffe3cb3 100644 --- a/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs +++ b/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs @@ -158,6 +158,7 @@ public void RestoreIfPending_RejectsUnsupportedEnterPlayModeOptionBits() Assert.That(exception.Message, Does.Contain("supported Enter Play Mode option bits")); Assert.That(EditorSettings.enterPlayModeOptionsEnabled, Is.True); Assert.That(EditorSettings.enterPlayModeOptions, Is.EqualTo(EnterPlayModeOptions.DisableDomainReload)); + Assert.That(File.Exists(MarkerFilePath), Is.False); } private static void SetEnterPlayModeSettings(bool enabled, EnterPlayModeOptions options) diff --git a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs index c200534fcb..a60a14cc35 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs @@ -121,7 +121,7 @@ private static DomainReloadDisableScopeRecoveryData ReadMarkerData() JsonUtility.FromJson(json); if (markerData == null) { - throw new InvalidOperationException("Domain reload recovery marker must contain valid JSON."); + ThrowInvalidMarker("Domain reload recovery marker must contain valid JSON."); } ValidateOriginalOptions(markerData.originalOptions); @@ -135,10 +135,16 @@ private static void ValidateOriginalOptions(int originalOptions) return; } - throw new InvalidOperationException( + ThrowInvalidMarker( "Domain reload recovery marker must contain supported Enter Play Mode option bits."); } + private static void ThrowInvalidMarker(string message) + { + DeleteMarkerFileIfExists(); + throw new InvalidOperationException(message); + } + private static void DeleteMarkerFileIfExists() { if (File.Exists(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath)) From 6a58b170bd1f57ae87e80cf1109281d05de047c8 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 1 Jun 2026 20:36:07 +0900 Subject: [PATCH 4/4] Reject unrecovered domain reload recovery markers Fail before writing a temp marker when an existing recovery marker is still present, so marker saves cannot create a window where no recovery marker exists. Cover the precondition with focused EditMode coverage. --- .../Editor/DomainReloadDisableScopeTests.cs | 24 +++++++++++++++++++ .../DomainReloadDisableScopeRecovery.cs | 9 +++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs b/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs index 29fffe3cb3..c2fe04bd93 100644 --- a/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs +++ b/Assets/Tests/Editor/DomainReloadDisableScopeTests.cs @@ -3,6 +3,8 @@ using NUnit.Framework; using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; using io.github.hatayama.UnityCliLoop.FirstPartyTools; @@ -161,6 +163,28 @@ public void RestoreIfPending_RejectsUnsupportedEnterPlayModeOptionBits() Assert.That(File.Exists(MarkerFilePath), Is.False); } + [Test] + public void SaveCurrentSettings_ThrowsAndKeepsExistingMarker_WhenMarkerAlreadyExists() + { + // Verifies that saving refuses to replace an unrecovered marker. + string directoryPath = Path.GetDirectoryName(MarkerFilePath); + if (!string.IsNullOrEmpty(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + string existingJson = "{ \"originalOptionsEnabled\": true, \"originalOptions\": 1 }"; + File.WriteAllText(MarkerFilePath, existingJson); + + LogAssert.Expect(LogType.Assert, "recovery marker must be restored before saving a new marker"); + InvalidOperationException exception = + Assert.Throws(() => DomainReloadDisableScopeRecovery.SaveCurrentSettings()); + + Assert.That(exception.Message, Does.Contain("must be restored before saving")); + Assert.That(File.ReadAllText(MarkerFilePath), Is.EqualTo(existingJson)); + Assert.That(File.Exists(TempFilePath), Is.False); + } + private static void SetEnterPlayModeSettings(bool enabled, EnterPlayModeOptions options) { EditorSettings.enterPlayModeOptionsEnabled = enabled; diff --git a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs index a60a14cc35..e5aa325a1b 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/EditorUtility/DomainReloadDisableScopeRecovery.cs @@ -37,9 +37,15 @@ private static void RestorePendingSettingsOnEditorLoad() /// internal static void SaveCurrentSettings() { + bool markerExists = File.Exists(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath); Debug.Assert( - !File.Exists(DomainReloadDisableScopeRecoveryConstants.MarkerFilePath), + !markerExists, "recovery marker must be restored before saving a new marker"); + if (markerExists) + { + throw new InvalidOperationException( + "Domain reload recovery marker must be restored before saving a new marker."); + } DomainReloadDisableScopeRecoveryData markerData = new DomainReloadDisableScopeRecoveryData { @@ -104,7 +110,6 @@ private static void SaveMarkerData(DomainReloadDisableScopeRecoveryData markerDa DeleteTempFileIfExists(); string json = JsonUtility.ToJson(markerData, true); File.WriteAllText(DomainReloadDisableScopeRecoveryConstants.TempFilePath, json); - DeleteMarkerFileIfExists(); File.Move( DomainReloadDisableScopeRecoveryConstants.TempFilePath, DomainReloadDisableScopeRecoveryConstants.MarkerFilePath);