diff --git a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs index bee6840bba..330b861b86 100644 --- a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs +++ b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs @@ -887,7 +887,8 @@ public void SetupWizardStartup_WhenLoaded_SchedulesVersionCheckInsteadOfReadingS // A cold-start session that hits Unity's native "Scripts have compiler errors" dialog // never flushes EditorApplication.delayCall again for the rest of that process's // lifetime, so this startup check rides on a self-unsubscribing EditorApplication.update - // tick instead of delayCall. + // tick instead of delayCall, and the flow then shows the window directly from its + // main-thread continuation instead of through delayCall. string setupWizardSource = ReadProductionSource( "Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs"); string setupWizardStartupFlowSource = ReadProductionSource( @@ -898,7 +899,7 @@ public void SetupWizardStartup_WhenLoaded_SchedulesVersionCheckInsteadOfReadingS Does.Contain("EditorApplication.update += RunStartupCheckOnFirstUpdateTick;")); Assert.That( setupWizardStartupFlowSource, - Does.Contain("EditorApplication.delayCall += () => _showWindowOnVersionChange();")); + Does.Not.Contain("EditorApplication.delayCall +=")); Assert.That(setupWizardSource, Does.Not.Contain("\n startupFlow.TryShowOnVersionChange();")); } diff --git a/Assets/Tests/Editor/SetupWizardStartupFlowVersionChangeTests.cs b/Assets/Tests/Editor/SetupWizardStartupFlowVersionChangeTests.cs index e47fd69640..44282b600a 100644 --- a/Assets/Tests/Editor/SetupWizardStartupFlowVersionChangeTests.cs +++ b/Assets/Tests/Editor/SetupWizardStartupFlowVersionChangeTests.cs @@ -15,8 +15,8 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor { /// /// Verifies the setup wizard's version-change evaluation for already-seen package versions: - /// when it records the last-seen state, when it refreshes the CLI, and when it does nothing. Also covers the - /// migration auto-scan poll actions and the fallback full scan with recording ports. + /// when it shows the window, when it records the last-seen state, when it refreshes the CLI, and when it does + /// nothing. Also covers the migration auto-scan poll actions and the fallback full scan with recording ports. /// public sealed class SetupWizardStartupFlowVersionChangeTests { @@ -203,6 +203,51 @@ public void TryShowOnVersionChange_WhenThePackageChangedAndTheSkillsAreCurrent_S Assert.That(_showWindowCount, Is.EqualTo(0)); } + /// + /// Verifies a new package version with a dispatcher older than the minimum shows the window synchronously + /// through the flow, without depending on delayCall. + /// + [Test] + public void TryShowOnVersionChange_WhenThePackageChangedAndTheCliNeedsUpdate_ShowsTheWindow() + { + const string PreviousVersion = "3.0.0-previous.1"; + Assume.That(UnityCliLoopConstants.PackageInfo.version, Is.Not.EqualTo(PreviousVersion)); + _editorSettingsPort.Settings = new UnityCliLoopEditorSettingsData + { + lastSeenSetupWizardVersion = PreviousVersion, + lastSeenSetupWizardMinimumDispatcherVersion = MinimumDispatcherVersion + }; + _cliDetector.CliVersion = "1.0.0"; + _cliDetector.IsDispatcher = true; + + _flow.TryShowOnVersionChange(); + + Assert.That(_showWindowCount, Is.EqualTo(1)); + } + + /// + /// Verifies a new package version with outdated installed skills shows the window synchronously through the + /// flow, without depending on delayCall. + /// + [Test] + public void TryShowOnVersionChange_WhenThePackageChangedAndTheSkillsAreOutdated_ShowsTheWindow() + { + const string PreviousVersion = "3.0.0-previous.1"; + Assume.That(UnityCliLoopConstants.PackageInfo.version, Is.Not.EqualTo(PreviousVersion)); + _editorSettingsPort.Settings = new UnityCliLoopEditorSettingsData + { + lastSeenSetupWizardVersion = PreviousVersion, + lastSeenSetupWizardMinimumDispatcherVersion = MinimumDispatcherVersion + }; + _cliDetector.CliVersion = "3.1.0"; + _cliDetector.IsDispatcher = true; + _skillSetupPort.ReturnOutdatedTarget = true; + + _flow.TryShowOnVersionChange(); + + Assert.That(_showWindowCount, Is.EqualTo(1)); + } + /// /// Verifies a compile-error detection that finds legacy files stores them as seeds, flags the auto-scan for /// this session, and opens the migration window once. @@ -542,6 +587,7 @@ private sealed class RecordingSkillSetupPort : ISkillSetupPort { internal List DetectProjectRoots { get; } = new List(); internal List DetectGroupFlags { get; } = new List(); + internal bool ReturnOutdatedTarget { get; set; } public void RemoveSkillFiles(string toolName) => throw new NotSupportedException(); public bool IsSkillInstalled(string toolName) => throw new NotSupportedException(); @@ -561,7 +607,7 @@ public List DetectSkillTargetsForLayoutAtProjectRoot( hasSkillsDirectory: true, hasExistingSkills: true, hasDifferentLayoutSkills: false, - SkillInstallState.Installed) + ReturnOutdatedTarget ? SkillInstallState.Outdated : SkillInstallState.Installed) }; } diff --git a/Assets/Tests/Editor/UnityCliLoopEditorSettingsRecoverySchedulerTests.cs b/Assets/Tests/Editor/UnityCliLoopEditorSettingsRecoverySchedulerTests.cs new file mode 100644 index 0000000000..7d8c24a377 --- /dev/null +++ b/Assets/Tests/Editor/UnityCliLoopEditorSettingsRecoverySchedulerTests.cs @@ -0,0 +1,93 @@ +using System; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.Infrastructure; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies when the settings file recovery scheduled at Editor startup runs. + /// + public sealed class UnityCliLoopEditorSettingsRecoverySchedulerTests + { + /// + /// Verifies the startup recovery does not run inline but waits on the main-thread dispatcher, and runs + /// once when the dispatcher drains its queue. + /// + [Test] + public void ScheduleForEditorStartup_QueuesTheRecoveryOnTheDispatcher_AndRunsItOnce() + { + CountingEditorSettingsPort editorSettingsPort = new CountingEditorSettingsPort(); + QueueingDispatcher dispatcher = new QueueingDispatcher(); + MainThreadSwitcher.RegisterService(dispatcher); + + try + { + UnityCliLoopEditorSettingsRecoveryScheduler.ScheduleForEditorStartup(editorSettingsPort); + + Assert.That(editorSettingsPort.RecoverCount, Is.EqualTo(0)); + + dispatcher.RunQueued(); + + Assert.That(editorSettingsPort.RecoverCount, Is.EqualTo(1)); + } + finally + { + EditorMainThreadDispatcherRestorer.Restore(); + } + } + + private sealed class CountingEditorSettingsPort : IUnityCliLoopEditorSettingsPort + { + internal int RecoverCount { get; private set; } + + public void RecoverSettingsFileIfNeeded() + { + RecoverCount++; + } + + public UnityCliLoopEditorSettingsData GetSettings() + { + throw new NotSupportedException(); + } + + public void SaveSettings(UnityCliLoopEditorSettingsData settings) + { + throw new NotSupportedException(); + } + + public void UpdateSettings(Func transform) + { + throw new NotSupportedException(); + } + + public string GetLastSeenSetupWizardVersion() + { + throw new NotSupportedException(); + } + + public bool GetSuppressSetupWizardAutoShow() + { + throw new NotSupportedException(); + } + + public void SetSuppressSetupWizardAutoShow(bool suppressAutoShow) + { + throw new NotSupportedException(); + } + + public void SetShowToolSettings(bool showToolSettings) + { + throw new NotSupportedException(); + } + + public void SetInstallSkillsFlat(bool installSkillsFlat) + { + throw new NotSupportedException(); + } + } + } +} diff --git a/Assets/Tests/Editor/UnityCliLoopEditorSettingsRecoverySchedulerTests.cs.meta b/Assets/Tests/Editor/UnityCliLoopEditorSettingsRecoverySchedulerTests.cs.meta new file mode 100644 index 0000000000..75d32b8be2 --- /dev/null +++ b/Assets/Tests/Editor/UnityCliLoopEditorSettingsRecoverySchedulerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b5abdccaf3906414baefef7f18f8060b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs b/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs index f1ffc60f94..d89dccf953 100644 --- a/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs @@ -157,7 +157,7 @@ public async Task ScheduleTrackedRecovery_WhenRecoveryAlreadyRunning_ReturnsCurr [Test] public async Task ScheduleStartupRecovery_WhenTrackedRecoveryIsRunning_ReturnsCurrentTaskWithoutSchedulingStartupRecovery() { - // Tests that startup recovery joins an active tracked recovery without registering a delay call. + // Tests that startup recovery joins an active tracked recovery without scheduling the startup recovery action. int scheduledActionCount = 0; TaskCompletionSource trackedRecoveryCompletionSource = new(); UnityCliLoopServerRecoveryTrackingService service = CreateRecoveryTrackingService(); @@ -319,7 +319,7 @@ public async Task StartRecoveryIfNeededAsync_WhenEditorIsBusy_ShouldDelayReadine { // Tests that recovery does not spend readiness timeout while Unity is still compiling or updating. bool editorIsBusy = true; - int delayCallCount = 0; + int readinessRetryWaitCount = 0; TestServerInstanceFactory serverInstanceFactory = new(); UnityCliLoopServerLifecycleRegistryService lifecycleRegistry = new UnityCliLoopServerLifecycleRegistryService(); @@ -333,7 +333,7 @@ public async Task StartRecoveryIfNeededAsync_WhenEditorIsBusy_ShouldDelayReadine isReadinessProbeBlocked: () => editorIsBusy, waitBeforeReadinessRetryAsync: (delayMilliseconds, ct) => { - delayCallCount++; + readinessRetryWaitCount++; Assert.That(readinessProbe.CallCount, Is.EqualTo(0)); editorIsBusy = false; return Task.CompletedTask; @@ -341,7 +341,7 @@ public async Task StartRecoveryIfNeededAsync_WhenEditorIsBusy_ShouldDelayReadine await service.StartRecoveryIfNeededAsync(isAfterCompile: false, CancellationToken.None); - Assert.That(delayCallCount, Is.EqualTo(1)); + Assert.That(readinessRetryWaitCount, Is.EqualTo(1)); Assert.That(readinessProbe.CallCount, Is.EqualTo(1)); Assert.That(serverStartedCount, Is.EqualTo(1)); } @@ -373,7 +373,7 @@ public void StartRecoveryIfNeededAsync_WhenPartiallyCreatedServerDisposeFails_Sh public void StartRecoveryIfNeededAsync_WhenEditorNeverBecomesIdle_ShouldFailWithoutReadinessProbe() { // Tests that recovery does not hang forever when Unity never leaves compile or update state. - int delayCallCount = 0; + int readinessRetryWaitCount = 0; TestServerInstanceFactory serverInstanceFactory = new(); UnityCliLoopServerLifecycleRegistryService lifecycleRegistry = new UnityCliLoopServerLifecycleRegistryService(); @@ -387,7 +387,7 @@ public void StartRecoveryIfNeededAsync_WhenEditorNeverBecomesIdle_ShouldFailWith isReadinessProbeBlocked: () => true, waitBeforeReadinessRetryAsync: (delayMilliseconds, ct) => { - delayCallCount++; + readinessRetryWaitCount++; return Task.CompletedTask; }, readinessIdleTimeoutMilliseconds: 1); @@ -399,7 +399,7 @@ public void StartRecoveryIfNeededAsync_WhenEditorNeverBecomesIdle_ShouldFailWith CancellationToken.None)); Assert.That(exception.Message, Does.Contain("Unity editor idle")); - Assert.That(delayCallCount, Is.EqualTo(1)); + Assert.That(readinessRetryWaitCount, Is.EqualTo(1)); Assert.That(readinessProbe.CallCount, Is.EqualTo(0)); Assert.That(serverStartedCount, Is.EqualTo(0)); } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleRecoveryCoordinator.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleRecoveryCoordinator.cs index ec5c0638ab..9eb2c98585 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleRecoveryCoordinator.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleRecoveryCoordinator.cs @@ -165,7 +165,9 @@ private void HandleCompileLifecycleWatchdogFault( Debug.LogException(exception); } - EditorApplication.delayCall += () => AbortCompileAfterWatchdogFault(compileTask); + // The fault continuation runs on the thread pool; the dispatcher hands the abort to the main + // thread and wakes the Editor with SignalTick, even in sessions where delayCall stops flushing. + MainThreadSwitcher.AddContinuation(() => AbortCompileAfterWatchdogFault(compileTask)); } /// diff --git a/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs index e4fe040528..12a32dfb84 100644 --- a/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs +++ b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs @@ -5,7 +5,6 @@ using io.github.hatayama.UnityCliLoop.Application; using io.github.hatayama.UnityCliLoop.Domain; -using io.github.hatayama.UnityCliLoop.InternalAPIBridge; using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.Infrastructure @@ -146,9 +145,10 @@ public void InitializeForEditorStartup() _serverLifecycleRegistry.ServerLoopExited += OnServerLoopUnexpectedlyExited; // Recovery binds the project IPC endpoint and may touch config files, so keep it off the - // synchronous Editor startup path while preserving automatic startup. + // synchronous Editor startup path while preserving automatic startup. The dispatcher drains + // on update and tick, so this runs even in a session where delayCall stops flushing. _recoveryTrackingService.ScheduleStartupRecovery( - action => EditorApplication.delayCall += () => action(), + MainThreadSwitcher.AddContinuation, RestoreServerStateIfNeeded); } @@ -344,12 +344,14 @@ private void OnEditorQuitting() /// /// OnServerLoopExited fires from the thread pool, but Unity APIs (EditorSettings, /// VibeLogger with SerializedObject, etc.) are main-thread-only. - /// EditorApplication.delayCall marshals the recovery to the next editor tick. + /// The main-thread dispatcher marshals the recovery to the next editor tick (update or tick), + /// which keeps working in sessions where delayCall stops flushing. Enqueueing also signals a + /// tick, so an unfocused idle Editor still runs the recovery. /// private void OnServerLoopUnexpectedlyExited() { // OnServerLoopExited fires from thread pool — marshal to main thread for Unity API safety - EditorApplication.delayCall += () => + MainThreadSwitcher.AddContinuation(() => { // The server just crashed — startup protection blocks recovery if the crash happens // within the 5-second protection window after a successful start @@ -364,12 +366,7 @@ private void OnServerLoopUnexpectedlyExited() // Resources already cleaned up by CleanupAfterUnexpectedLoopExit — just clear the reference _bridgeServer = null; _recoveryTrackingService.ScheduleTrackedRecovery(() => StartRecoveryIfNeededAsync(false, CancellationToken.None)); - }; - - // delayCall only runs on the next editor tick, and a backgrounded idle editor may never - // tick again on its own — the recovery would then wait forever. Signal one tick so the - // scheduled recovery actually executes even while the editor is unfocused. - EditorApplicationTickBridge.SignalTick(); + }); } /// diff --git a/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerRecoveryTrackingService.cs b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerRecoveryTrackingService.cs index 90828214f8..3efccdb075 100644 --- a/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerRecoveryTrackingService.cs +++ b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerRecoveryTrackingService.cs @@ -35,10 +35,10 @@ internal UnityCliLoopServerRecoveryTrackingService( internal Task RecoveryTask => _currentRecoveryTask; internal Task ScheduleStartupRecovery( - Action scheduleDelayCall, + Action scheduleOnEditorTick, Func restoreServerState) { - Debug.Assert(scheduleDelayCall != null, "scheduleDelayCall must not be null"); + Debug.Assert(scheduleOnEditorTick != null, "scheduleOnEditorTick must not be null"); Debug.Assert(restoreServerState != null, "restoreServerState must not be null"); TaskCompletionSource scheduledRecoveryCompletionSource = null; @@ -52,7 +52,7 @@ internal Task ScheduleStartupRecovery( return scheduledRecoveryTask; } - scheduleDelayCall(() => + scheduleOnEditorTick(() => { Task restoreTask; try diff --git a/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopEditorSettingsRecoveryScheduler.cs b/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopEditorSettingsRecoveryScheduler.cs index 4376fd4dca..302bee2a2e 100644 --- a/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopEditorSettingsRecoveryScheduler.cs +++ b/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopEditorSettingsRecoveryScheduler.cs @@ -1,12 +1,15 @@ using UnityEditor; using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.Infrastructure { // Infrastructure scheduler for delayed settings file recovery during Editor startup. /// /// Schedules Unity CLI Loop Editor Settings Recovery work at the point the owning workflow expects. + /// The recovery runs on the next Editor tick through the main-thread dispatcher, which keeps working in + /// sessions where delayCall stops flushing. /// internal static class UnityCliLoopEditorSettingsRecoveryScheduler { @@ -19,7 +22,7 @@ internal static void ScheduleForEditorStartup(IUnityCliLoopEditorSettingsPort ed return; } - EditorApplication.delayCall += editorSettingsPort.RecoverSettingsFileIfNeeded; + MainThreadSwitcher.AddContinuation(editorSettingsPort.RecoverSettingsFileIfNeeded); } } } diff --git a/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs index c533f34ee5..e5d9857804 100644 --- a/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs +++ b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs @@ -228,7 +228,7 @@ private static Task[] BuildShutdownWaitTasks(Task serverTask, Task[] clientTasks /// StopServer() guards on _isRunning==true, but by the time this runs _isRunning may already /// be false or the normal shutdown path may race with the finally block. /// A separate cleanup path that skips the _isRunning guard is needed. - /// Lifecycle events are deferred to OnServerLoopExited → EditorApplication.delayCall + /// Lifecycle events are deferred to OnServerLoopExited → the main-thread dispatcher /// because this runs on the thread pool where Unity APIs are unsafe. /// private void CleanupAfterUnexpectedLoopExit() diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlow.cs b/Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlow.cs index 1e9995a0f5..0f427897cb 100644 --- a/Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlow.cs +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardStartupFlow.cs @@ -315,7 +315,9 @@ private async Task EvaluateVersionChange(CancellationToken ct) return; } - EditorApplication.delayCall += () => _showWindowOnVersionChange(); + // Every await above resumes on Unity's synchronization context, so this already runs on the main + // thread; delayCall would only add a hop that some sessions never flush. + _showWindowOnVersionChange(); } private async Task NeedsCliUpdateForSetupWizardAsync(CancellationToken ct) diff --git a/Packages/src/Editor/Presentation/UnityCliLoopSettingsWindow.cs b/Packages/src/Editor/Presentation/UnityCliLoopSettingsWindow.cs index 3da43fbfc3..2ee04ff0f1 100644 --- a/Packages/src/Editor/Presentation/UnityCliLoopSettingsWindow.cs +++ b/Packages/src/Editor/Presentation/UnityCliLoopSettingsWindow.cs @@ -357,7 +357,7 @@ private void HandleToolToggled(string toolName, bool enabled) _view?.UpdateSingleToolToggle(toolName, enabled); // Skill synchronization can touch many files, so defer it to keep UI input responsive. - EditorApplication.delayCall += () => _skillsPresenter.ApplyToolToggleSideEffects(toolName, enabled).Forget(); + MainThreadSwitcher.AddContinuation(() => _skillsPresenter.ApplyToolToggleSideEffects(toolName, enabled).Forget()); } private void UpdateShowConfiguration(bool show) diff --git a/Packages/src/Editor/ToolContracts/AssemblyInfo.cs b/Packages/src/Editor/ToolContracts/AssemblyInfo.cs index bc84b2d45c..8284c7e479 100644 --- a/Packages/src/Editor/ToolContracts/AssemblyInfo.cs +++ b/Packages/src/Editor/ToolContracts/AssemblyInfo.cs @@ -2,6 +2,9 @@ [assembly: InternalsVisibleTo("UnityCLILoop.CompositionRoot.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Application")] +[assembly: InternalsVisibleTo("UnityCLILoop.Infrastructure")] +[assembly: InternalsVisibleTo("UnityCLILoop.Presentation")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.Compile.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.Screenshot.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.PausePoint.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")]