Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Assets/Tests/Editor/OnionAssemblyDependencyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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();"));
}

Expand Down
52 changes: 49 additions & 3 deletions Assets/Tests/Editor/SetupWizardStartupFlowVersionChangeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// 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.
/// </summary>
public sealed class SetupWizardStartupFlowVersionChangeTests
{
Expand Down Expand Up @@ -203,6 +203,51 @@ public void TryShowOnVersionChange_WhenThePackageChangedAndTheSkillsAreCurrent_S
Assert.That(_showWindowCount, Is.EqualTo(0));
}

/// <summary>
/// Verifies a new package version with a dispatcher older than the minimum shows the window synchronously
/// through the flow, without depending on delayCall.
/// </summary>
[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));
}

/// <summary>
/// Verifies a new package version with outdated installed skills shows the window synchronously through the
/// flow, without depending on delayCall.
/// </summary>
[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));
}

/// <summary>
/// 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.
Expand Down Expand Up @@ -542,6 +587,7 @@ private sealed class RecordingSkillSetupPort : ISkillSetupPort
{
internal List<string> DetectProjectRoots { get; } = new List<string>();
internal List<bool> DetectGroupFlags { get; } = new List<bool>();
internal bool ReturnOutdatedTarget { get; set; }

public void RemoveSkillFiles(string toolName) => throw new NotSupportedException();
public bool IsSkillInstalled(string toolName) => throw new NotSupportedException();
Expand All @@ -561,7 +607,7 @@ public List<SkillSetupTargetInfo> DetectSkillTargetsForLayoutAtProjectRoot(
hasSkillsDirectory: true,
hasExistingSkills: true,
hasDifferentLayoutSkills: false,
SkillInstallState.Installed)
ReturnOutdatedTarget ? SkillInstallState.Outdated : SkillInstallState.Installed)
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Verifies when the settings file recovery scheduled at Editor startup runs.
/// </summary>
public sealed class UnityCliLoopEditorSettingsRecoverySchedulerTests
{
/// <summary>
/// Verifies the startup recovery does not run inline but waits on the main-thread dispatcher, and runs
/// once when the dispatcher drains its queue.
/// </summary>
[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<UnityCliLoopEditorSettingsData, UnityCliLoopEditorSettingsData> 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();
}
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> trackedRecoveryCompletionSource = new();
UnityCliLoopServerRecoveryTrackingService service = CreateRecoveryTrackingService();
Expand Down Expand Up @@ -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();
Expand All @@ -333,15 +333,15 @@ 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;
});

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));
}
Expand Down Expand Up @@ -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();
Expand All @@ -387,7 +387,7 @@ public void StartRecoveryIfNeededAsync_WhenEditorNeverBecomesIdle_ShouldFailWith
isReadinessProbeBlocked: () => true,
waitBeforeReadinessRetryAsync: (delayMilliseconds, ct) =>
{
delayCallCount++;
readinessRetryWaitCount++;
return Task.CompletedTask;
},
readinessIdleTimeoutMilliseconds: 1);
Expand All @@ -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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -344,12 +344,14 @@ private void OnEditorQuitting()
/// <summary>
/// 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.
/// </summary>
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
Expand All @@ -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();
});
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ internal UnityCliLoopServerRecoveryTrackingService(
internal Task RecoveryTask => _currentRecoveryTask;

internal Task ScheduleStartupRecovery(
Action<Action> scheduleDelayCall,
Action<Action> scheduleOnEditorTick,
Func<Task> 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<bool> scheduledRecoveryCompletionSource = null;
Expand All @@ -52,7 +52,7 @@ internal Task ScheduleStartupRecovery(
return scheduledRecoveryTask;
}

scheduleDelayCall(() =>
scheduleOnEditorTick(() =>
{
Task restoreTask;
try
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
/// <summary>
/// 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.
/// </summary>
internal static class UnityCliLoopEditorSettingsRecoveryScheduler
{
Expand All @@ -19,7 +22,7 @@ internal static void ScheduleForEditorStartup(IUnityCliLoopEditorSettingsPort ed
return;
}

EditorApplication.delayCall += editorSettingsPort.RecoverSettingsFileIfNeeded;
MainThreadSwitcher.AddContinuation(editorSettingsPort.RecoverSettingsFileIfNeeded);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
private void CleanupAfterUnexpectedLoopExit()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> NeedsCliUpdateForSetupWizardAsync(CancellationToken ct)
Expand Down
Loading
Loading