From b23b243b07545bc41e121018466fb9fea0a851eb Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 10:23:39 +0900 Subject: [PATCH 1/5] Run the settings file recovery through the main-thread dispatcher instead of delayCall Some Editor sessions stop flushing EditorApplication.delayCall while EditorApplication.update keeps running, so the settings file recovery queued at Editor startup never ran there. The main-thread dispatcher drains its queue on both update and tick, so the recovery now rides on it. ToolContracts now exposes its internals to Infrastructure, Presentation, and the Compile tool, so the remaining delayCall sites in those assemblies can hand their work to the same dispatcher. --- ...oopEditorSettingsRecoverySchedulerTests.cs | 93 +++++++++++++++++++ ...itorSettingsRecoverySchedulerTests.cs.meta | 11 +++ ...yCliLoopEditorSettingsRecoveryScheduler.cs | 5 +- .../src/Editor/ToolContracts/AssemblyInfo.cs | 3 + 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 Assets/Tests/Editor/UnityCliLoopEditorSettingsRecoverySchedulerTests.cs create mode 100644 Assets/Tests/Editor/UnityCliLoopEditorSettingsRecoverySchedulerTests.cs.meta 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/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/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")] From 8c68aec48e6ce125d0bb92aa9839eae682dc6ed3 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 10:25:54 +0900 Subject: [PATCH 2/5] Schedule server recovery through the main-thread dispatcher instead of delayCall The startup recovery and the recovery after an unexpected server loop exit were queued on EditorApplication.delayCall, which some Editor sessions stop flushing while update keeps running. There the server would not come back on its own. The dispatcher drains on update and tick and signals a tick when work is enqueued, so the manual SignalTick after the old delayCall registration is no longer needed. The startup scheduler parameter is renamed to describe what it does now, and a test counter that counted readiness retry waits, not delayCall registrations, gets a name that says so. --- ...CliLoopServerControllerStartupLockTests.cs | 14 +++++++------- .../Server/UnityCliLoopServerController.cs | 19 ++++++++----------- ...ityCliLoopServerRecoveryTrackingService.cs | 6 +++--- .../UnityCliLoopBridgeServer.cs | 2 +- 4 files changed, 19 insertions(+), 22 deletions(-) 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/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/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() From 66e6b95fd939fa777f272e16339acbc5a620fe22 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 10:26:32 +0900 Subject: [PATCH 3/5] Hand the watchdog abort to the main-thread dispatcher instead of delayCall When the compile watchdog faults, its continuation runs on the thread pool and queued the abort on EditorApplication.delayCall. In an Editor session that stops flushing delayCall, the abort never ran, so a compile request that only the watchdog could end might stay unanswered. The dispatcher drains on update and tick, so the abort now reaches the main thread there too. --- .../Compile/CompileLifecycleRecoveryCoordinator.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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)); } /// From c6c52a40d8d0f73d6460b43e53398fd6224709b8 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 10:31:03 +0900 Subject: [PATCH 4/5] Show the setup wizard directly instead of through delayCall The version check that decides whether to show the setup wizard queued the window on EditorApplication.delayCall. In an Editor session that does not flush delayCall, the wizard never appeared, even when the CLI needed an update or the installed skills were outdated. The check already resumes on Unity's synchronization context after each await, so it is on the main thread and can show the window itself. New tests pin that a package change with an outdated CLI or outdated skills shows the window synchronously, and the source pin now checks that the flow registers nothing on delayCall instead of requiring it. --- .../Editor/OnionAssemblyDependencyTests.cs | 5 +- ...etupWizardStartupFlowVersionChangeTests.cs | 52 +++++++++++++++++-- .../Setup/SetupWizardStartupFlow.cs | 4 +- 3 files changed, 55 insertions(+), 6 deletions(-) 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/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) From 08f255c09cc8672a4ca5215b1af0fa71c208e3b8 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 10:34:13 +0900 Subject: [PATCH 5/5] Defer the tool toggle side effects through the main-thread dispatcher instead of delayCall Toggling a tool in the settings window deferred the skill file sync to EditorApplication.delayCall to keep the UI responsive. In an Editor session that does not flush delayCall, the skill files never followed the toggle. The dispatcher drains on the next update or tick, so the work is still deferred but now runs. --- Packages/src/Editor/Presentation/UnityCliLoopSettingsWindow.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)