diff --git a/Assets/Editor/EditorDelayManualTests.cs b/Assets/Editor/EditorDelayManualTests.cs index 4c8d043933..cb296e3aa1 100644 --- a/Assets/Editor/EditorDelayManualTests.cs +++ b/Assets/Editor/EditorDelayManualTests.cs @@ -9,19 +9,19 @@ namespace io.github.hatayama.UnityCliLoop.Dev { /// - /// Manual testing class for EditorDelay + /// Manual testing class for EditorFrameWaiter. /// Real-time test execution from Unity Editor menu /// - public static class EditorDelayManualTests + public static class EditorFrameWaiterManualTests { private static int testFrameStart; private static int testCounter = 0; - [MenuItem("UnityCliLoop/Debug/EditorDelay Tests/Basic Delay Tests")] + [MenuItem("UnityCliLoop/Debug/EditorFrameWaiter Tests/Basic Frame Wait Tests")] public static void TestBasicDelays() { Debug.Log("========================================"); - Debug.Log("=== EditorDelay Basic Tests Started ==="); + Debug.Log("=== EditorFrameWaiter Basic Tests Started ==="); Debug.Log("========================================"); testFrameStart = Time.frameCount; @@ -37,7 +37,7 @@ private static async Task TestZeroFrameDelayAsync() int currentFrame = Time.frameCount; Debug.Log($"[Test {++testCounter}] Zero Frame Delay - Start (Frame: {currentFrame})"); - await EditorDelay.DelayFrame(0); + await EditorFrameWaiter.WaitFramesAsync(0); int completionFrame = Time.frameCount; Debug.Log($"[Test {testCounter}] Zero Frame Delay - Complete (Frame: {completionFrame}) - Immediate: {currentFrame == completionFrame}"); @@ -48,7 +48,7 @@ private static async Task TestSingleFrameDelayAsync() int currentFrame = Time.frameCount; Debug.Log($"[Test {++testCounter}] Single Frame Delay - Start (Frame: {currentFrame})"); - await EditorDelay.DelayFrame(1); + await EditorFrameWaiter.WaitFramesAsync(1); int completionFrame = Time.frameCount; int framesDiff = completionFrame - currentFrame; @@ -61,14 +61,14 @@ private static async Task TestMultipleFrameDelayAsync() int currentFrame = Time.frameCount; Debug.Log($"[Test {++testCounter}] Multiple Frame Delay ({delayFrames}) - Start (Frame: {currentFrame})"); - await EditorDelay.DelayFrame(delayFrames); + await EditorFrameWaiter.WaitFramesAsync(delayFrames); int completionFrame = Time.frameCount; int framesDiff = completionFrame - currentFrame; Debug.Log($"[Test {testCounter}] Multiple Frame Delay - Complete (Frame: {completionFrame}) - Frames elapsed: {framesDiff}"); } - [MenuItem("UnityCliLoop/Debug/EditorDelay Tests/Concurrent Execution Test")] + [MenuItem("UnityCliLoop/Debug/EditorFrameWaiter Tests/Concurrent Execution Test")] public static void TestConcurrentExecution() { Debug.Log("========================================="); @@ -92,14 +92,14 @@ private static async Task ConcurrentTaskAsync(string taskName, int frames) int startFrame = Time.frameCount; Debug.Log($"Task {taskName}: Start (Frame: {startFrame}, Delay: {frames} frames)"); - await EditorDelay.DelayFrame(frames); + await EditorFrameWaiter.WaitFramesAsync(frames); int endFrame = Time.frameCount; int elapsed = endFrame - testFrameStart; Debug.Log($"Task {taskName}: Complete (Frame: {endFrame}, Total elapsed: {elapsed} frames)"); } - [MenuItem("UnityCliLoop/Debug/EditorDelay Tests/Stress Test (100 Tasks)")] + [MenuItem("UnityCliLoop/Debug/EditorFrameWaiter Tests/Stress Test (100 Tasks)")] public static void TestStressLoad() { Debug.Log("=============================="); @@ -110,7 +110,7 @@ public static void TestStressLoad() int completedCount = 0; Debug.Log($"Starting {taskCount} concurrent tasks..."); - Debug.Log($"Initial Pending Tasks: {EditorDelayManager.PendingTaskCount}"); + Debug.Log($"Initial Pending Waits: {EditorFrameWaiter.PendingWaitCount}"); for (int i = 0; i < taskCount; i++) { @@ -122,21 +122,21 @@ public static void TestStressLoad() { Debug.Log($"=== Stress Test Complete ==="); Debug.Log($"Completed Tasks: {completedCount}/{taskCount}"); - Debug.Log($"Remaining Pending Tasks: {EditorDelayManager.PendingTaskCount}"); + Debug.Log($"Remaining Pending Waits: {EditorFrameWaiter.PendingWaitCount}"); } }).Forget(); } - Debug.Log($"All tasks started. Pending Tasks: {EditorDelayManager.PendingTaskCount}"); + Debug.Log($"All tasks started. Pending Waits: {EditorFrameWaiter.PendingWaitCount}"); } private static async Task StressTaskAsync(int taskId, Action onComplete) { - await EditorDelay.DelayFrame(2); // All tasks execute after 2 frames + await EditorFrameWaiter.WaitFramesAsync(2); // All tasks execute after 2 frames onComplete?.Invoke(); } - [MenuItem("UnityCliLoop/Debug/EditorDelay Tests/Cancellation Test")] + [MenuItem("UnityCliLoop/Debug/EditorFrameWaiter Tests/Cancellation Test")] public static void TestCancellation() { Debug.Log("=============================="); @@ -160,7 +160,7 @@ private static async Task TestCancellableTaskAsync(CancellationToken cancellatio try { Debug.Log("Cancellable Task: Start (will be cancelled)"); - await EditorDelay.DelayFrame(10, cancellationToken); + await EditorFrameWaiter.WaitFramesAsync(10, cancellationToken); Debug.Log("Cancellable Task: Complete (should not reach here)"); } catch (OperationCanceledException) @@ -173,14 +173,14 @@ private static async Task TestCancellableTaskAsync(CancellationToken cancellatio } } - [MenuItem("UnityCliLoop/Debug/EditorDelay Tests/Integration Test")] + [MenuItem("UnityCliLoop/Debug/EditorFrameWaiter Tests/Integration Test")] public static void TestUnityCliLoopServerControllerIntegration() { Debug.Log("=========================================="); Debug.Log("=== UnityCliLoopServerController Integration Test ==="); Debug.Log("=========================================="); - Debug.Log("Testing EditorDelay integration with UnityCliLoopServerController..."); + Debug.Log("Testing EditorFrameWaiter integration with UnityCliLoopServerController..."); Debug.Log("This will simulate the actual usage in server restoration."); SimulateServerRestorationAsync().Forget(); @@ -191,42 +191,42 @@ private static async Task SimulateServerRestorationAsync() Debug.Log("Simulation: Starting server restoration sequence..."); // Test with the same pattern as UnityCliLoopServerController - await EditorDelay.DelayFrame(1); + await EditorFrameWaiter.WaitFramesAsync(1); Debug.Log("Simulation: Phase 1 - Port release wait completed"); - await EditorDelay.DelayFrame(1); + await EditorFrameWaiter.WaitFramesAsync(1); Debug.Log("Simulation: Phase 2 - Server startup completed"); - await EditorDelay.DelayFrame(1); + await EditorFrameWaiter.WaitFramesAsync(1); Debug.Log("Simulation: Phase 3 - Notification sent"); Debug.Log("Simulation: Server restoration sequence completed!"); } - [MenuItem("UnityCliLoop/Debug/EditorDelay Tests/Show Manager Status")] + [MenuItem("UnityCliLoop/Debug/EditorFrameWaiter Tests/Show Waiter Status")] public static void ShowDelayManagerStatus() { Debug.Log("=============================="); - Debug.Log("=== EditorDelayManager Status ==="); + Debug.Log("=== EditorFrameWaiter Status ==="); Debug.Log("=============================="); - Debug.Log($"Pending Tasks: {EditorDelayManager.PendingTaskCount}"); + Debug.Log($"Pending Waits: {EditorFrameWaiter.PendingWaitCount}"); Debug.Log($"Current Frame: {Time.frameCount}"); Debug.Log($"Time Since Startup: {EditorApplication.timeSinceStartup:F2}s"); } - [MenuItem("UnityCliLoop/Debug/EditorDelay Tests/Clear All Tasks")] + [MenuItem("UnityCliLoop/Debug/EditorFrameWaiter Tests/Clear All Waits")] public static void ClearAllTasks() { - int clearedCount = EditorDelayManager.PendingTaskCount; - EditorDelayManager.ClearAllTasks(); - Debug.Log($"Cleared {clearedCount} pending tasks from EditorDelayManager"); + int clearedCount = EditorFrameWaiter.PendingWaitCount; + EditorFrameWaiter.ClearAllForTests(); + Debug.Log($"Cleared {clearedCount} pending waits from EditorFrameWaiter"); } - [MenuItem("UnityCliLoop/Debug/EditorDelay Tests/Show Test Instructions")] + [MenuItem("UnityCliLoop/Debug/EditorFrameWaiter Tests/Show Test Instructions")] public static void ShowTestInstructions() { Debug.Log("======================================="); - Debug.Log("=== EditorDelay Test Instructions ==="); + Debug.Log("=== EditorFrameWaiter Test Instructions ==="); Debug.Log("======================================="); Debug.Log("1. Basic Delay Tests - Test zero, single, and multiple frame delays"); Debug.Log("2. Concurrent Execution Test - Test parallel task execution order"); @@ -241,4 +241,4 @@ public static void ShowTestInstructions() Debug.Log("Watch the Console for test results and frame timing!"); } } -} \ No newline at end of file +} diff --git a/Assets/Tests/Editor/EditorDelayTests.cs b/Assets/Tests/Editor/EditorDelayTests.cs deleted file mode 100644 index b7a3790f34..0000000000 --- a/Assets/Tests/Editor/EditorDelayTests.cs +++ /dev/null @@ -1,376 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using UnityEngine.TestTools; -using NUnit.Framework; - -using io.github.hatayama.UnityCliLoop.ToolContracts; - -namespace io.github.hatayama.UnityCliLoop.Tests.Editor -{ - /// - /// Unit tests for EditorDelay system - /// Automated tests using Unity Test Framework - /// - [TestFixture] - public class EditorDelayTests - { - private int frameCountAtStart; - private readonly List executionLog = new(); - - [SetUp] - public void SetUp() - { - // Ensure test isolation - EditorDelayManager.ClearAllTasks(); - EditorDelayManager.ResetFrameCount(); - - // Record frame count at test start - frameCountAtStart = EditorDelayManager.CurrentFrameCount; - executionLog.Clear(); - } - - [TearDown] - public void TearDown() - { - // Post-test cleanup - EditorDelayManager.ClearAllTasks(); - executionLog.Clear(); - } - - /// - /// Zero frame delay test (immediate execution) - /// Verify immediate execution using EditorDelayManager frame counter - /// - [Test] - public async Task DelayFrame_ZeroFrames_ExecutesImmediately() - { - // Arrange - Record starting frame number - int startFrame = EditorDelayManager.CurrentFrameCount; - bool executed = false; - - // Act - Execute zero frame delay - await EditorDelay.DelayFrame(0); - executed = true; - - // Assert - Verify execution completed within the same frame - int endFrame = EditorDelayManager.CurrentFrameCount; - Assert.IsTrue(executed, "Zero frame delay should execute the task"); - Assert.AreEqual(startFrame, endFrame, "Zero frame delay should execute within the same frame"); - Assert.AreEqual(0, EditorDelayManager.PendingTaskCount, "No tasks should be pending after zero frame delay"); - } - - /// - /// Basic frame delay test - /// Check exact frame count using CurrentFrameCount - /// - [UnityTest] - public IEnumerator DelayFrame_SingleFrame_ExecutesAfterOneFrame() - { - bool executed = false; - int executionFrame = -1; - int startFrame = EditorDelayManager.CurrentFrameCount; - - // Arrange & Act - DelayedExecution().Forget(); - - async Task DelayedExecution() - { - await EditorDelay.DelayFrame(1); - executed = true; - executionFrame = EditorDelayManager.CurrentFrameCount; - } - - // Initially not executed - Assert.IsFalse(executed, "Task should not be executed immediately"); - - // Wait for 1 frame and then check - yield return null; - - // Assert - Verify execution and frame count - Assert.IsTrue(executed, "Task should be executed after 1 frame"); - Assert.AreEqual(startFrame + 1, executionFrame, "Task should execute exactly 1 frame later"); - } - - /// - /// Multiple frame delay test - /// Check exact frame count using CurrentFrameCount - /// - [UnityTest] - public IEnumerator DelayFrame_MultipleFrames_ExecutesAfterCorrectFrames() - { - bool executed = false; - int executionFrame = -1; - const int delayFrames = 3; - int startFrame = EditorDelayManager.CurrentFrameCount; - - // Arrange & Act - DelayedExecution().Forget(); - - async Task DelayedExecution() - { - await EditorDelay.DelayFrame(delayFrames); - executed = true; - executionFrame = EditorDelayManager.CurrentFrameCount; - } - - // Wait for delayFrames number of frames - for (int i = 0; i < delayFrames; i++) - { - Assert.IsFalse(executed, $"Task should not be executed at frame {i + 1}"); - yield return null; - } - - // Should be executed after delayFrames - Assert.IsTrue(executed, $"Task should be executed after {delayFrames} frames"); - Assert.AreEqual(startFrame + delayFrames, executionFrame, $"Task should execute exactly {delayFrames} frames later"); - } - - /// - /// Concurrent execution order test - /// Check exact frame count using CurrentFrameCount - /// - [UnityTest] - public IEnumerator DelayFrame_ConcurrentTasks_ExecuteInCorrectOrder() - { - // Arrange - int startFrame = EditorDelayManager.CurrentFrameCount; - var executionFrames = new List<(string task, int frame)>(); - - Task1().Forget(); // After 1 frame - Task2().Forget(); // After 3 frames - Task3().Forget(); // After 2 frames - - async Task Task1() - { - await EditorDelay.DelayFrame(1); - executionLog.Add("Task1"); - executionFrames.Add(("Task1", EditorDelayManager.CurrentFrameCount)); - } - - async Task Task2() - { - await EditorDelay.DelayFrame(3); - executionLog.Add("Task2"); - executionFrames.Add(("Task2", EditorDelayManager.CurrentFrameCount)); - } - - async Task Task3() - { - await EditorDelay.DelayFrame(2); - executionLog.Add("Task3"); - executionFrames.Add(("Task3", EditorDelayManager.CurrentFrameCount)); - } - - // Act & Assert - Check frame by frame - yield return null; // After 1 frame - Assert.AreEqual(1, executionLog.Count, "Only Task1 should be executed after 1 frame"); - Assert.AreEqual("Task1", executionLog[0], "Task1 should execute first"); - Assert.AreEqual(startFrame + 1, executionFrames[0].frame, "Task1 should execute at frame startFrame + 1"); - - yield return null; // After 2 frames - Assert.AreEqual(2, executionLog.Count, "Task1 and Task3 should be executed after 2 frames"); - Assert.AreEqual("Task3", executionLog[1], "Task3 should execute second"); - Assert.AreEqual(startFrame + 2, executionFrames[1].frame, "Task3 should execute at frame startFrame + 2"); - - yield return null; // After 3 frames - Assert.AreEqual(3, executionLog.Count, "All tasks should be executed after 3 frames"); - Assert.AreEqual("Task2", executionLog[2], "Task2 should execute last"); - Assert.AreEqual(startFrame + 3, executionFrames[2].frame, "Task2 should execute at frame startFrame + 3"); - } - - /// - /// EditorDelayManager task count management test - /// - [UnityTest] - public IEnumerator DelayManager_TaskCount_ManagesCorrectly() - { - // Arrange - Register multiple tasks - StartMultipleTasks(); - - void StartMultipleTasks() - { - for (int i = 0; i < 5; i++) - { - int taskId = i; - _ = DelayedTask(taskId); - } - - async Task DelayedTask(int id) - { - await EditorDelay.DelayFrame(2); - executionLog.Add($"Task{id}"); - } - } - - // Act & Assert - yield return null; // After 1 frame - Assert.AreEqual(5, EditorDelayManager.PendingTaskCount, "5 tasks should be pending"); - - yield return null; // After 2 frames (execution completed) - Assert.AreEqual(0, EditorDelayManager.PendingTaskCount, "No tasks should be pending after execution"); - Assert.AreEqual(5, executionLog.Count, "All 5 tasks should be executed"); - } - - /// - /// CancellationToken immediate cancellation test - /// - [Test] - public void DelayFrame_WithImmediateCancellation_ThrowsImmediately() - { - // Arrange - Already cancelled token - CancellationTokenSource cts = new(); - cts.Cancel(); // Cancel in advance - - bool executed = false; - Exception caughtException = null; - - // Act & Assert - Cancellation exception should be thrown immediately - try - { - DelayedTask().Forget(); - async Task DelayedTask() - { - try - { - await EditorDelay.DelayFrame(5, cts.Token); - executed = true; - } - catch (Exception ex) - { - caughtException = ex; - } - } - } - catch (OperationCanceledException) - { - // Case where cancellation exception is thrown synchronously - caughtException = new OperationCanceledException(); - } - - // ThrowIfCancellationRequested should be called in IsCompleted or GetResult - Assert.IsFalse(executed, "Task should not execute when pre-cancelled"); - } - - /// - /// CancellationToken delayed cancellation test (simple version) - /// - [UnityTest] - public IEnumerator DelayFrame_WithDelayedCancellation_CancelsCorrectly() - { - // Arrange - CancellationTokenSource cts = new(); - bool taskStarted = false; - bool executed = false; - - // Act - Start task scheduled to complete after 5 frames - var _ = DelayedTaskWithCancellation(); - - async Task DelayedTaskWithCancellation() - { - taskStarted = true; - try - { - await EditorDelay.DelayFrame(5, cts.Token); - executed = true; - } - catch (OperationCanceledException) - { - // Cancellation processing does nothing special (executed remains false) - } - catch (Exception) - { - // Treat other exceptions as cancellation too (executed remains false) - } - } - - // Wait a bit until task starts - yield return null; - Assert.IsTrue(taskStarted, "Task should have started"); - - // Verify task is not yet executed - Assert.IsFalse(executed, "Task should not be executed immediately"); - - // Cancel after waiting 2 frames (before task completion) - yield return null; // 2 frames - yield return null; // 3 frames - - cts.Cancel(); // Cancel after 3 frames - - // Wait a bit for cancellation processing - yield return null; // 4 frames - yield return null; // 5 frames - - // Assert - Verify task was cancelled and not executed - Assert.IsFalse(executed, "Task should not execute when cancelled"); - } - - /// - /// Load test - Execute large number of tasks simultaneously - /// Check exact frame count using CurrentFrameCount - /// - [UnityTest] - public IEnumerator DelayFrame_StressTest_HandlesLargeTasks() - { - const int taskCount = 100; - int completedTasks = 0; - int startFrame = EditorDelayManager.CurrentFrameCount; - var completionFrames = new List(); - - // Arrange & Act - Start 100 tasks - var tasks = new List(); - for (int i = 0; i < taskCount; i++) - { - tasks.Add(StressTask()); - } - - async Task StressTask() - { - await EditorDelay.DelayFrame(1); - Interlocked.Increment(ref completedTasks); - lock (completionFrames) - { - completionFrames.Add(EditorDelayManager.CurrentFrameCount); - } - } - - // Initially all tasks are pending - Assert.AreEqual(taskCount, EditorDelayManager.PendingTaskCount, $"{taskCount} tasks should be pending"); - - yield return null; // After 1 frame - - // Assert - All tasks completed and frame count verification - Assert.AreEqual(taskCount, completedTasks, $"All {taskCount} tasks should be completed"); - Assert.AreEqual(0, EditorDelayManager.PendingTaskCount, "No tasks should be pending after completion"); - Assert.AreEqual(taskCount, completionFrames.Count, "All tasks should record completion frame"); - - // Verify all tasks completed at the same frame (startFrame + 1) - int expectedFrame = startFrame + 1; - foreach (int frame in completionFrames) - { - Assert.AreEqual(expectedFrame, frame, "All tasks should complete at the same frame"); - } - } - - /// - /// Frame counter reset functionality test - /// - [UnityTest] - public IEnumerator DelayManager_ResetFrameCount_ResetsCorrectly() - { - // Arrange - Advance frames then measure - yield return null; // Advance 1 frame - - int countAfterFrame = EditorDelayManager.CurrentFrameCount; - Assert.Greater(countAfterFrame, 0, "Frame count should be greater than 0 after waiting one frame"); - - // Act - Execute reset - EditorDelayManager.ResetFrameCount(); - - // Assert - Reset to zero - Assert.AreEqual(0, EditorDelayManager.CurrentFrameCount, "Frame count should be reset to 0"); - } - } -} diff --git a/Assets/Tests/Editor/EditorFrameWaiterTests.cs b/Assets/Tests/Editor/EditorFrameWaiterTests.cs new file mode 100644 index 0000000000..153366090a --- /dev/null +++ b/Assets/Tests/Editor/EditorFrameWaiterTests.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies Editor frame and wall-clock wait primitives. + /// + [TestFixture] + public sealed class EditorFrameWaiterTests + { + [SetUp] + public void SetUp() + { + EditorFrameWaiter.ClearAllForTests(); + EditorFrameWaiter.ResetFrameCountForTests(); + EditorFrameWaiter.InitializeForEditorStartup(); + } + + [TearDown] + public void TearDown() + { + EditorFrameWaiter.ClearAllForTests(); + } + + [Test] + public async Task WaitFramesAsync_WithZeroFrames_CompletesImmediately() + { + // Verifies that zero-frame waits do not subscribe to Editor update. + int startFrame = EditorFrameWaiter.CurrentFrameCount; + + await EditorFrameWaiter.WaitFramesAsync(0, CancellationToken.None); + + Assert.AreEqual(startFrame, EditorFrameWaiter.CurrentFrameCount); + Assert.AreEqual(0, EditorFrameWaiter.PendingWaitCount); + } + + [UnityTest] + public IEnumerator WaitFramesAsync_WithSingleFrame_CompletesAfterOneEditorUpdate() + { + // Verifies that a one-frame wait resumes after the next Editor update. + bool completed = false; + int completionFrame = -1; + int startFrame = EditorFrameWaiter.CurrentFrameCount; + + WaitAsync().Forget(); + + Assert.IsFalse(completed); + yield return null; + + Assert.IsTrue(completed); + Assert.AreEqual(startFrame + 1, completionFrame); + + async Task WaitAsync() + { + await EditorFrameWaiter.WaitFramesAsync(1, CancellationToken.None); + completed = true; + completionFrame = EditorFrameWaiter.CurrentFrameCount; + } + } + + [UnityTest] + public IEnumerator WaitFramesAsync_WithConcurrentWaits_CompletesByTargetFrame() + { + // Verifies that concurrent frame waits complete in target-frame order. + List executionLog = new List(); + int startFrame = EditorFrameWaiter.CurrentFrameCount; + + WaitAsync("one", 1, executionLog).Forget(); + WaitAsync("three", 3, executionLog).Forget(); + WaitAsync("two", 2, executionLog).Forget(); + + yield return null; + CollectionAssert.AreEqual(new[] { "one" }, executionLog); + Assert.AreEqual(startFrame + 1, EditorFrameWaiter.CurrentFrameCount); + + yield return null; + CollectionAssert.AreEqual(new[] { "one", "two" }, executionLog); + + yield return null; + CollectionAssert.AreEqual(new[] { "one", "two", "three" }, executionLog); + + async Task WaitAsync(string label, int frameCount, List log) + { + await EditorFrameWaiter.WaitFramesAsync(frameCount, CancellationToken.None); + log.Add(label); + } + } + + [UnityTest] + public IEnumerator WaitFramesAsync_WhenCancelledBeforeTarget_CancelsWithoutWaitingForTargetFrame() + { + // Verifies that cancellation releases the pending wait before the target frame arrives. + CancellationTokenSource cts = new CancellationTokenSource(); + Task waitTask = EditorFrameWaiter.WaitFramesAsync(5, cts.Token); + + yield return null; + Assert.AreEqual(1, EditorFrameWaiter.PendingWaitCount); + + cts.Cancel(); + yield return new WaitUntil(() => waitTask.IsCompleted); + + Assert.IsTrue(waitTask.IsCanceled); + Assert.AreEqual(0, EditorFrameWaiter.PendingWaitCount); + cts.Dispose(); + } + + [Test] + public async Task TimerDelay_Wait_CompletesWithoutEditorFrameDependency() + { + // Verifies that wall-clock waits do not register Editor frame wait requests. + Assert.AreEqual(0, EditorFrameWaiter.PendingWaitCount); + + await TimerDelay.Wait(10, CancellationToken.None); + + Assert.AreEqual(0, EditorFrameWaiter.PendingWaitCount); + } + + [UnityTest] + public IEnumerator WaitThenExecuteOnMainThread_WhenActionThrows_FaultsReturnedTask() + { + // Verifies that delayed main-thread action failures are observable by the awaiting caller. + Task waitTask = TimerDelay.WaitThenExecuteOnMainThread( + 1, + () => throw new InvalidOperationException("Delayed action failed."), + CancellationToken.None); + float startTime = Time.realtimeSinceStartup; + + while (!waitTask.IsCompleted && Time.realtimeSinceStartup - startTime < 2f) + { + yield return null; + } + + Assert.IsTrue(waitTask.IsFaulted); + Exception exception = waitTask.Exception?.GetBaseException(); + Assert.IsInstanceOf(exception); + Assert.AreEqual("Delayed action failed.", exception.Message); + } + + [UnityTest] + public IEnumerator WaitThenExecuteOnMainThread_WithZeroMilliseconds_DefersActionUntilEditorUpdate() + { + // Verifies that an already-completed timer wait still defers the action to an Editor update. + bool actionRan = false; + + Task waitTask = TimerDelay.WaitThenExecuteOnMainThread( + 0, + () => actionRan = true, + CancellationToken.None); + + Assert.IsFalse(actionRan); + yield return null; + + Assert.IsTrue(waitTask.IsCompleted); + Assert.IsFalse(waitTask.IsFaulted, $"Delayed action should not fault: {waitTask.Exception}"); + Assert.IsTrue(actionRan); + } + + [UnityTest] + public IEnumerator WaitFramesAsync_DoesNotRunSynchronousContinuationInsideEditorUpdate() + { + // Verifies that frame wait continuations are queued instead of running inline from Editor update. + int mainThreadId = Thread.CurrentThread.ManagedThreadId; + int continuationThreadId = -1; + Task waitTask = EditorFrameWaiter.WaitFramesAsync(1, CancellationToken.None); + Task continuationTask = waitTask.ContinueWith( + _ => continuationThreadId = Thread.CurrentThread.ManagedThreadId, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + float startTime = Time.realtimeSinceStartup; + + while (!continuationTask.IsCompleted && Time.realtimeSinceStartup - startTime < 2f) + { + yield return null; + } + + Assert.IsTrue(continuationTask.IsCompleted); + Assert.AreNotEqual(mainThreadId, continuationThreadId); + } + + [UnityTest] + public IEnumerator WaitThenExecuteOnMainThread_RunsActionOnCapturedSynchronizationContext() + { + // Verifies that the delayed action resumes on the SynchronizationContext captured before the timer wait. + SynchronizationContext capturedContext = SynchronizationContext.Current; + int capturedThreadId = Thread.CurrentThread.ManagedThreadId; + SynchronizationContext actionContext = null; + int actionThreadId = -1; + Task waitTask = TimerDelay.WaitThenExecuteOnMainThread( + 1, + () => + { + actionContext = SynchronizationContext.Current; + actionThreadId = Thread.CurrentThread.ManagedThreadId; + }, + CancellationToken.None); + float startTime = Time.realtimeSinceStartup; + + while (!waitTask.IsCompleted && Time.realtimeSinceStartup - startTime < 2f) + { + yield return null; + } + + Assert.IsTrue(waitTask.IsCompleted); + Assert.IsFalse(waitTask.IsFaulted, $"Delayed action should not fault: {waitTask.Exception}"); + Assert.AreSame(capturedContext, actionContext); + Assert.AreEqual(capturedThreadId, actionThreadId); + } + } +} diff --git a/Packages/src/Editor/ToolContracts/EditorDelayManager.cs.meta b/Assets/Tests/Editor/EditorFrameWaiterTests.cs.meta similarity index 83% rename from Packages/src/Editor/ToolContracts/EditorDelayManager.cs.meta rename to Assets/Tests/Editor/EditorFrameWaiterTests.cs.meta index 110afec943..fb357a1970 100644 --- a/Packages/src/Editor/ToolContracts/EditorDelayManager.cs.meta +++ b/Assets/Tests/Editor/EditorFrameWaiterTests.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: d43e1f6aceb664627af14cbfdd451ef5 +guid: 22f2f7d5d15b4f6fb4f9e8818d50e187 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/Tests/Editor/StaticFacadeStateGuardTests.cs b/Assets/Tests/Editor/StaticFacadeStateGuardTests.cs index fe07ab7e41..a748afae76 100644 --- a/Assets/Tests/Editor/StaticFacadeStateGuardTests.cs +++ b/Assets/Tests/Editor/StaticFacadeStateGuardTests.cs @@ -22,7 +22,7 @@ public sealed class StaticFacadeStateGuardTests "Packages/src/Editor/Application/UnityCliLoopToolRegistrar.cs", "Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeServices.cs", "Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Execution/DynamicCodeForegroundWarmupState.cs", - "Packages/src/Editor/ToolContracts/EditorDelayManager.cs", + "Packages/src/Editor/ToolContracts/EditorFrameWaiter.cs", "Packages/src/Editor/FirstPartyTools/Common/InputRecording/InputRecorder.cs", "Packages/src/Editor/FirstPartyTools/RecordInput/Application/RecordingsApplicationFacade.cs", "Packages/src/Editor/FirstPartyTools/Common/InputRecording/InputReplayer.cs", diff --git a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs index 13b4296990..49964d4020 100644 --- a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs +++ b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs @@ -62,6 +62,7 @@ public override void Setup() public override void TearDown() { InputSystemUpdateHelper.ResetPauseProviderForTests(); + InputSystemUpdateHelper.ResetTimeoutsForTests(); UloopPausePointRegistry.ResetForTests(); InputSettings settings = RequireInputSettings(); settings.updateMode = originalUpdateMode; @@ -184,6 +185,34 @@ public IEnumerator Press_WhenDebugBreakMarkerHits_Should_ReturnMarkerDetails() Assert.IsFalse(keyboard[Key.Space].isPressed, "Marker interruption should release the injected key state."); } + [UnityTest] + public IEnumerator Press_Cancellation_Should_ClearPressOverlay() + { + // Verifies that canceling an applied press releases input and clears transient overlay state. + yield return null; + + SimulateKeyboardSchema parameters = new() + { + Action = UnityCliLoopKeyboardAction.Press, + Key = "Space", + Duration = 2f + }; + CancellationTokenSource cts = new(); + Task task = tool.ExecuteWithCancellationAsync(parameters, cts.Token); + + yield return new WaitUntil(() => keyboard[Key.Space].isPressed || task.IsCompleted); + + Assert.IsFalse(task.IsCompleted, "Cancellation test must interrupt the applied press."); + Assert.AreEqual("Space", SimulateKeyboardOverlayState.PressKey, "Applied press should show transient overlay state before cancellation."); + + cts.Cancel(); + yield return WaitForTask(task, allowCanceled: true); + + Assert.IsTrue(task.IsCanceled, "Press cancellation should remain visible to the caller."); + Assert.IsFalse(keyboard[Key.Space].isPressed, "Canceled Press should release the injected key state."); + Assert.IsNull(SimulateKeyboardOverlayState.PressKey, "Canceled Press should clear transient overlay state."); + } + [UnityTest] public IEnumerator Press_Space_Should_SetWasPressedThisFrame() { @@ -724,7 +753,8 @@ public IEnumerator KeyDown_Cancellation_Should_RollBackHeldState() { yield return null; - SimulateKeyboardSchema parameters = new() { + SimulateKeyboardSchema parameters = new() + { Action = UnityCliLoopKeyboardAction.KeyDown, Key = "W" }; @@ -752,6 +782,109 @@ public IEnumerator KeyDown_Cancellation_Should_RollBackHeldState() Assert.IsTrue(lastResponse.Success, "Canceled KeyDown cleanup should leave later key-down requests usable."); } + [UnityTest] + public IEnumerator KeyUp_CancellationAfterRelease_Should_ClearHeldState() + { + // Verifies that canceling KeyUp after release does not leave held-key bookkeeping behind. + yield return null; + + yield return RunTool(new JObject + { + ["action"] = KeyboardAction.KeyDown.ToString(), + ["key"] = "W" + }); + Assert.IsTrue(lastResponse.Success, "KeyUp cancellation test requires an initially held key."); + Assert.IsTrue(KeyboardKeyState.IsKeyHeld(Key.W), "KeyUp cancellation test requires held-key bookkeeping."); + + SimulateKeyboardSchema parameters = new() + { + Action = UnityCliLoopKeyboardAction.KeyUp, + Key = "W" + }; + CancellationTokenSource cts = new(); + Task task = tool.ExecuteWithCancellationAsync(parameters, cts.Token); + cts.Cancel(); + + yield return WaitForTask(task, allowCanceled: true); + + Assert.IsTrue(task.IsCanceled, "KeyUp cancellation should remain visible to the caller."); + Assert.IsFalse(keyboard[Key.W].isPressed, "Canceled KeyUp should release the physical key state."); + Assert.IsFalse(KeyboardKeyState.IsKeyHeld(Key.W), "Canceled KeyUp should clear held-key bookkeeping."); + CollectionAssert.DoesNotContain(SimulateKeyboardOverlayState.HeldKeys, "W", "Canceled KeyUp should clear the overlay badge."); + + yield return RunTool(new JObject + { + ["action"] = KeyboardAction.KeyDown.ToString(), + ["key"] = "W" + }); + + Assert.IsTrue(lastResponse.Success, "Canceled KeyUp cleanup should leave later key-down requests usable."); + } + + [UnityTest] + public IEnumerator ApplyOnNextConfiguredUpdate_CancellationBeforeInputUpdate_ShouldRemoveCallback() + { + // Verifies that cancellation removes the pending Input System callback before the next update. + yield return null; + + InputSettings settings = RequireInputSettings(); + settings.updateMode = InputSettings.UpdateMode.ProcessEventsInDynamicUpdate; + Time.timeScale = 1f; + int applyCount = 0; + CancellationTokenSource cts = new(); + + Task task = InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + () => applyCount++, + cts.Token); + + Assert.AreEqual(1, InputSystemUpdateHelper.PendingConfiguredUpdateCallbackCount); + + cts.Cancel(); + yield return WaitForTask(task, allowCanceled: true); + + Assert.IsTrue(task.IsCanceled, "Apply cancellation should remain visible to the caller."); + Assert.AreEqual(0, applyCount, "Canceled apply should not run after callback cleanup."); + Assert.AreEqual(0, InputSystemUpdateHelper.PendingConfiguredUpdateCallbackCount, "Canceled apply should remove the pending Input System callback."); + } + + [UnityTest] + public IEnumerator ApplyOnNextConfiguredUpdate_WhenApplyThrows_ShouldFaultAndRemoveCallback() + { + // Verifies that apply failures complete the wait as faulted instead of leaving the callback pending. + yield return null; + + InputSettings settings = RequireInputSettings(); + settings.updateMode = InputSettings.UpdateMode.ProcessEventsInDynamicUpdate; + InvalidOperationException expectedException = new("Apply failed."); + Task task = InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + () => throw expectedException, + CancellationToken.None); + + Assert.AreEqual(1, InputSystemUpdateHelper.PendingConfiguredUpdateCallbackCount); + + yield return WaitForTask(task, allowFaulted: true); + + Assert.IsTrue(task.IsFaulted, "Apply failures should fault the returned task."); + Assert.AreSame(expectedException, task.Exception?.GetBaseException()); + Assert.AreEqual(0, InputSystemUpdateHelper.PendingConfiguredUpdateCallbackCount, "Faulted apply should remove the pending Input System callback."); + } + + [UnityTest] + public IEnumerator WaitForRuntimeFrames_WhenFrameGoalCannotComplete_ShouldReturnTimedOut() + { + // Verifies that frame observation has a wall-clock guard and does not wait forever. + yield return null; + + InputSystemUpdateHelper.ConfigureTimeoutsForTests(50, 50); + Task task = + InputSystemUpdateHelper.WaitForRuntimeFrames(int.MaxValue, CancellationToken.None); + + yield return WaitForTask(task); + + Assert.AreEqual(InputSimulationWaitOutcome.TimedOut, task.Result); + Assert.AreEqual(0, EditorFrameWaiter.PendingWaitCount, "Timed-out frame observation should cancel its pending frame wait."); + } + #endregion #region Helpers @@ -763,7 +896,7 @@ private IEnumerator RunTool(JObject parameters) lastResponse = (SimulateKeyboardResponse)task.Result; } - private static IEnumerator WaitForTask(Task task, bool allowCanceled = false) + private static IEnumerator WaitForTask(Task task, bool allowCanceled = false, bool allowFaulted = false) { float timeoutAt = Time.realtimeSinceStartup + 5f; yield return new WaitUntil(() => @@ -773,7 +906,10 @@ private static IEnumerator WaitForTask(Task task, bool allowCanceled = false) { Assert.IsFalse(task.IsCanceled, "Tool execution should not be canceled."); } - Assert.IsFalse(task.IsFaulted, $"Tool execution should not fault: {task.Exception}"); + if (!allowFaulted) + { + Assert.IsFalse(task.IsFaulted, $"Tool execution should not fault: {task.Exception}"); + } } private static InputSettings RequireInputSettings() diff --git a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs index aef127ee48..fe4ec73b39 100644 --- a/Assets/Tests/PlayMode/SimulateMouseInputTests.cs +++ b/Assets/Tests/PlayMode/SimulateMouseInputTests.cs @@ -38,6 +38,7 @@ public override void Setup() public override void TearDown() { InputSystemUpdateHelper.ResetPauseProviderForTests(); + InputSystemUpdateHelper.ResetTimeoutsForTests(); UloopPausePointRegistry.ResetForTests(); MouseInputState.ReleaseAllButtons(); Object.DestroyImmediate(mouseObserverGo); diff --git a/Packages/src/Editor/Application/ApplicationEditorStartup.cs b/Packages/src/Editor/Application/ApplicationEditorStartup.cs index 84907b3a35..a824a397eb 100644 --- a/Packages/src/Editor/Application/ApplicationEditorStartup.cs +++ b/Packages/src/Editor/Application/ApplicationEditorStartup.cs @@ -18,7 +18,7 @@ internal static void Initialize(IDomainReloadDetectionService domainReloadDetect UnityCliLoopEditorDomainReloadStateRegistration.RegisterForEditorStartup(); MainThreadSwitcher.InitializeForEditorStartup(); UnityCliLoopEditorStateSnapshot.InitializeForEditorStartup(); - EditorDelayManager.InitializeForEditorStartup(); + EditorFrameWaiter.InitializeForEditorStartup(); domainReloadDetectionService.RegisterForEditorStartup(); } } diff --git a/Packages/src/Editor/Application/SessionRecoveryService.cs b/Packages/src/Editor/Application/SessionRecoveryService.cs index 39a2c48de9..b6df7bafb5 100644 --- a/Packages/src/Editor/Application/SessionRecoveryService.cs +++ b/Packages/src/Editor/Application/SessionRecoveryService.cs @@ -81,8 +81,8 @@ public async Task RestoreServerStateIfNeededAsync(Cancellation public async Task StartReconnectionUITimeoutAsync(CancellationToken ct) { - int timeoutFrames = UnityCliLoopConstants.RECONNECTION_TIMEOUT_SECONDS * 60; - await EditorDelay.DelayFrame(timeoutFrames, ct); + int timeoutMilliseconds = UnityCliLoopConstants.RECONNECTION_TIMEOUT_SECONDS * 1000; + await TimerDelay.Wait(timeoutMilliseconds, ct); ct.ThrowIfCancellationRequested(); bool isStillShowingUI = _sessionStateService.GetShowReconnectingUI(); diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index e4106e44fc..fec283c84b 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -1,6 +1,6 @@ --- name: uloop-wait-for-debug-break -description: "Use this like an IDE breakpoint for Unity PlayMode/E2E when you need to pause a specific frame and inspect variables, GameObjects, or runtime state." +description: "Standard paused-frame proof for Unity PlayMode/E2E gameplay verification. Whenever you verify behavior driven by simulate-* input, physics, or UI events, pause at least one representative state transition with a debug break and inspect the frozen frame like an IDE breakpoint. simulate-* Success=true, action logs, screenshots, sleeps/retries, and final durable state supplement but do not replace this paused-frame proof." --- ## Quick Check Template diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs index bc48561f97..3d41933306 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs @@ -1,6 +1,7 @@ #nullable enable #if ULOOP_HAS_INPUT_SYSTEM using System; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using UnityEditor; @@ -8,17 +9,19 @@ using UnityEngine.InputSystem; using UnityEngine.InputSystem.LowLevel; +using io.github.hatayama.UnityCliLoop.Application; using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Reports whether input observation finished normally or stopped because Unity paused. + /// Reports whether input observation finished normally, paused, or exceeded the wall-clock guard. /// internal enum InputSimulationWaitOutcome { Completed = 0, - Paused = 1 + Paused = 1, + TimedOut = 2 } // Shared helper for applying Input System state changes at the correct update phase. @@ -31,47 +34,127 @@ internal static class InputSystemUpdateHelper { private const int StandardPressObservationFrames = 2; private const int ManualPressObservationFrames = 3; + private const int DefaultApplyTimeoutMilliseconds = 5000; + private const int DefaultFrameObservationTimeoutMilliseconds = 5000; + private const int PressDurationTimeoutGraceMilliseconds = 5000; + private const int ApplyWaitStateWaiting = 0; + private const int ApplyWaitStateApplying = 1; + private const int ApplyWaitStateFinishedWithoutApply = 2; private static Func isPausedProvider = () => EditorApplication.isPaused; + private static int applyTimeoutMilliseconds = DefaultApplyTimeoutMilliseconds; + private static int frameObservationTimeoutMilliseconds = DefaultFrameObservationTimeoutMilliseconds; + private static int pendingConfiguredUpdateCallbackCount; - public static Task ApplyOnNextConfiguredUpdate(Action apply, CancellationToken ct) + public static async Task ApplyOnNextConfiguredUpdate(Action apply, CancellationToken ct) { + Debug.Assert(apply != null, "apply must not be null"); + if (apply == null) + { + throw new ArgumentNullException(nameof(apply)); + } + InputUpdateType targetUpdateType = InputUpdateTypeResolver.Resolve(); if (InputUpdateTypeResolver.RequiresExplicitUpdate()) { return ApplyOnExplicitUpdate(apply, targetUpdateType, ct); } - TaskCompletionSource tcs = new(); + TaskCompletionSource tcs = + new(TaskCreationOptions.RunContinuationsAsynchronously); CancellationTokenRegistration registration = default; + int applyWaitState = ApplyWaitStateWaiting; Action? callback = null; + InputSystemUpdateSubscription? subscription = null; callback = () => { + Debug.Assert(callback != null, "callback must be assigned before subscription"); + Debug.Assert(subscription != null, "subscription must be assigned before callback invocation"); + if (Interlocked.CompareExchange( + ref applyWaitState, + ApplyWaitStateWaiting, + ApplyWaitStateWaiting) != ApplyWaitStateWaiting) + { + subscription?.Dispose(); + return; + } + InputUpdateType currentUpdateType = InputState.currentUpdateType; if (!InputUpdateTypeResolver.IsMatch(currentUpdateType, targetUpdateType)) { return; } - Debug.Assert(callback != null, "callback must be assigned before subscription"); - InputSystem.onBeforeUpdate -= callback; - registration.Dispose(); - apply(); - tcs.TrySetResult(true); + if (Interlocked.CompareExchange( + ref applyWaitState, + ApplyWaitStateApplying, + ApplyWaitStateWaiting) != ApplyWaitStateWaiting) + { + subscription?.Dispose(); + return; + } + + subscription?.Dispose(); + try + { + apply(); + tcs.TrySetResult(InputSimulationWaitOutcome.Completed); + } + catch (Exception exception) + { + // Convert apply failures into the awaited task result so timeout paths never wait on an orphaned TCS. + tcs.TrySetException(exception); + } }; - InputSystem.onBeforeUpdate += callback; - if (ct.CanBeCanceled) + subscription = new InputSystemUpdateSubscription(callback); + try { - registration = ct.Register(() => + if (ct.CanBeCanceled) { - Debug.Assert(callback != null, "callback must be assigned before cancellation"); - InputSystem.onBeforeUpdate -= callback; - tcs.TrySetCanceled(ct); - }); - } + registration = ct.Register(() => + { + if (Interlocked.CompareExchange( + ref applyWaitState, + ApplyWaitStateFinishedWithoutApply, + ApplyWaitStateWaiting) == ApplyWaitStateWaiting) + { + subscription?.Dispose(); + tcs.TrySetCanceled(ct); + } + }); + } - return tcs.Task; + using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + Task timeoutTask = TimerDelay.Wait(applyTimeoutMilliseconds, timeoutCts.Token); + Task completedTask = await Task.WhenAny(tcs.Task, timeoutTask).ConfigureAwait(false); + if (completedTask == timeoutTask) + { + await timeoutTask.ConfigureAwait(false); + if (Interlocked.CompareExchange( + ref applyWaitState, + ApplyWaitStateFinishedWithoutApply, + ApplyWaitStateWaiting) == ApplyWaitStateWaiting) + { + subscription.Dispose(); + return InputSimulationWaitOutcome.TimedOut; + } + + InputSimulationWaitOutcome appliedOutcome = await tcs.Task.ConfigureAwait(false); + await SwitchToMainThreadIfNeeded(CancellationToken.None); + return appliedOutcome; + } + + timeoutCts.Cancel(); + InputSimulationWaitOutcome outcome = await tcs.Task.ConfigureAwait(false); + await SwitchToMainThreadIfNeeded(CancellationToken.None); + return outcome; + } + finally + { + registration.Dispose(); + subscription?.Dispose(); + } } public static int GetMinimumObservationFrameCount() @@ -97,16 +180,20 @@ public static int GetMinimumObservationFrameCount() public static async Task WaitForObservationFrames(CancellationToken ct) { - return await WaitForRuntimeFrames(GetMinimumObservationFrameCount(), ct); + return await WaitForRuntimeFrames(GetMinimumObservationFrameCount(), ct).ConfigureAwait(false); } public static async Task WaitForPressLifetime(float duration, CancellationToken ct) { + await SwitchToMainThreadIfNeeded(ct); + int minimumObservationFrames = GetMinimumObservationFrameCount(); int startFrameCount = Time.frameCount; float startTime = Time.realtimeSinceStartup; float elapsed = 0f; int observedFrames = 0; + int timeoutMilliseconds = GetPressLifetimeTimeoutMilliseconds(duration); + System.Diagnostics.Stopwatch stopwatch = System.Diagnostics.Stopwatch.StartNew(); while (observedFrames < minimumObservationFrames || elapsed < duration) { @@ -115,7 +202,16 @@ public static async Task WaitForPressLifetime(float return InputSimulationWaitOutcome.Paused; } - await EditorDelay.DelayFrame(1, ct); + InputSimulationWaitOutcome frameOutcome = await WaitOneRuntimeFrameOrTimeout( + timeoutMilliseconds, + stopwatch, + ct).ConfigureAwait(false); + if (frameOutcome == InputSimulationWaitOutcome.TimedOut) + { + return InputSimulationWaitOutcome.TimedOut; + } + + await SwitchToMainThreadIfNeeded(ct); if (IsPaused()) { return InputSimulationWaitOutcome.Paused; @@ -156,13 +252,15 @@ public static void RunExplicitUpdate(InputUpdateType targetUpdateType) } } - private static Task ApplyOnExplicitUpdate(Action apply, InputUpdateType targetUpdateType, CancellationToken ct) + private static InputSimulationWaitOutcome ApplyOnExplicitUpdate( + Action apply, + InputUpdateType targetUpdateType, + CancellationToken ct) { ct.ThrowIfCancellationRequested(); - TaskCompletionSource tcs = new(); - CancellationTokenRegistration registration = default; Action? callback = null; + bool applied = false; callback = () => { @@ -174,34 +272,22 @@ private static Task ApplyOnExplicitUpdate(Action apply, InputUpdateType targetUp Debug.Assert(callback != null, "callback must be assigned before subscription"); InputSystem.onBeforeUpdate -= callback; - registration.Dispose(); apply(); - tcs.TrySetResult(true); + applied = true; }; InputSystem.onBeforeUpdate += callback; - if (ct.CanBeCanceled) - { - registration = ct.Register(() => - { - Debug.Assert(callback != null, "callback must be assigned before cancellation"); - InputSystem.onBeforeUpdate -= callback; - tcs.TrySetCanceled(ct); - }); - } RunExplicitUpdate(targetUpdateType); - if (!tcs.Task.IsCompleted) + if (!applied) { Debug.Assert(callback != null, "callback must be assigned before explicit update fallback"); InputSystem.onBeforeUpdate -= callback; - registration.Dispose(); apply(); RunExplicitUpdate(targetUpdateType); - tcs.TrySetResult(true); } - return tcs.Task; + return InputSimulationWaitOutcome.Completed; } private static InputSettings.UpdateMode GetExplicitUpdateMode( @@ -228,8 +314,12 @@ private static InputSettings.UpdateMode GetExplicitUpdateMode( public static async Task WaitForRuntimeFrames(int frameCount, CancellationToken ct) { + Debug.Assert(frameCount >= 0, "frameCount must be non-negative"); + await SwitchToMainThreadIfNeeded(ct); + int startFrameCount = Time.frameCount; int observedFrames = 0; + System.Diagnostics.Stopwatch stopwatch = System.Diagnostics.Stopwatch.StartNew(); while (observedFrames < frameCount) { @@ -238,7 +328,16 @@ public static async Task WaitForRuntimeFrames(int fr return InputSimulationWaitOutcome.Paused; } - await EditorDelay.DelayFrame(1, ct); + InputSimulationWaitOutcome frameOutcome = await WaitOneRuntimeFrameOrTimeout( + frameObservationTimeoutMilliseconds, + stopwatch, + ct).ConfigureAwait(false); + if (frameOutcome == InputSimulationWaitOutcome.TimedOut) + { + return InputSimulationWaitOutcome.TimedOut; + } + + await SwitchToMainThreadIfNeeded(ct); if (IsPaused()) { return InputSimulationWaitOutcome.Paused; @@ -250,6 +349,11 @@ public static async Task WaitForRuntimeFrames(int fr return InputSimulationWaitOutcome.Completed; } + public static InputSystemMainThreadAwaitable SwitchToMainThreadIfNeeded(CancellationToken ct) + { + return new InputSystemMainThreadAwaitable(ct); + } + internal static void ConfigurePauseProviderForTests(Func provider) { Debug.Assert(provider != null, "provider must not be null"); @@ -266,10 +370,164 @@ internal static void ResetPauseProviderForTests() isPausedProvider = () => EditorApplication.isPaused; } + internal static void ConfigureTimeoutsForTests(int applyTimeoutMs, int frameObservationTimeoutMs) + { + Debug.Assert(applyTimeoutMs > 0, "applyTimeoutMs must be positive"); + Debug.Assert(frameObservationTimeoutMs > 0, "frameObservationTimeoutMs must be positive"); + if (applyTimeoutMs <= 0) + { + throw new ArgumentOutOfRangeException(nameof(applyTimeoutMs)); + } + + if (frameObservationTimeoutMs <= 0) + { + throw new ArgumentOutOfRangeException(nameof(frameObservationTimeoutMs)); + } + + applyTimeoutMilliseconds = applyTimeoutMs; + frameObservationTimeoutMilliseconds = frameObservationTimeoutMs; + } + + internal static void ResetTimeoutsForTests() + { + applyTimeoutMilliseconds = DefaultApplyTimeoutMilliseconds; + frameObservationTimeoutMilliseconds = DefaultFrameObservationTimeoutMilliseconds; + } + + internal static int PendingConfiguredUpdateCallbackCount => Volatile.Read(ref pendingConfiguredUpdateCallbackCount); + private static bool IsPaused() { return isPausedProvider(); } + + private static async Task WaitOneRuntimeFrameOrTimeout( + int timeoutMilliseconds, + System.Diagnostics.Stopwatch stopwatch, + CancellationToken ct) + { + int remainingMilliseconds = timeoutMilliseconds - (int)stopwatch.ElapsedMilliseconds; + if (remainingMilliseconds <= 0) + { + return InputSimulationWaitOutcome.TimedOut; + } + + using CancellationTokenSource frameCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + Task frameTask = EditorFrameWaiter.WaitFramesAsync(1, frameCts.Token); + using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + Task timeoutTask = TimerDelay.Wait(remainingMilliseconds, timeoutCts.Token); + Task completedTask = await Task.WhenAny(frameTask, timeoutTask).ConfigureAwait(false); + if (completedTask == timeoutTask) + { + await timeoutTask.ConfigureAwait(false); + frameCts.Cancel(); + return InputSimulationWaitOutcome.TimedOut; + } + + timeoutCts.Cancel(); + await frameTask.ConfigureAwait(false); + return InputSimulationWaitOutcome.Completed; + } + + private static int GetPressLifetimeTimeoutMilliseconds(float duration) + { + double durationMilliseconds = Math.Ceiling(duration * 1000d); + double timeoutMilliseconds = durationMilliseconds + PressDurationTimeoutGraceMilliseconds; + if (timeoutMilliseconds > int.MaxValue) + { + return int.MaxValue; + } + + return Math.Max(frameObservationTimeoutMilliseconds, (int)timeoutMilliseconds); + } + + /// + /// Owns one Input System update subscription so timeout and cancellation paths remove it exactly once. + /// + private sealed class InputSystemUpdateSubscription : IDisposable + { + private readonly Action callback; + private int isDisposed; + + public InputSystemUpdateSubscription(Action callback) + { + Debug.Assert(callback != null, "callback must not be null"); + + this.callback = callback ?? throw new ArgumentNullException(nameof(callback)); + InputSystem.onBeforeUpdate += this.callback; + Interlocked.Increment(ref pendingConfiguredUpdateCallbackCount); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref isDisposed, 1) != 0) + { + return; + } + + Interlocked.Decrement(ref pendingConfiguredUpdateCallbackCount); + if (MainThreadSwitcher.IsMainThread) + { + InputSystem.onBeforeUpdate -= callback; + return; + } + + RemoveOnMainThreadAsync(CancellationToken.None).Forget(); + } + + private async Task RemoveOnMainThreadAsync(CancellationToken ct) + { + await SwitchToMainThreadIfNeeded(ct); + InputSystem.onBeforeUpdate -= callback; + } + } + } + + /// + /// Switches input simulation continuations back to Unity's main thread without wrapping the switch in a Task. + /// + internal readonly struct InputSystemMainThreadAwaitable + { + private readonly CancellationToken _ct; + + public InputSystemMainThreadAwaitable(CancellationToken ct) + { + _ct = ct; + } + + public Awaiter GetAwaiter() + { + return new Awaiter(_ct); + } + + public readonly struct Awaiter : INotifyCompletion + { + private readonly CancellationToken _ct; + + public Awaiter(CancellationToken ct) + { + _ct = ct; + } + + public bool IsCompleted + { + get + { + _ct.ThrowIfCancellationRequested(); + return MainThreadSwitcher.IsMainThread; + } + } + + public void GetResult() + { + _ct.ThrowIfCancellationRequested(); + } + + public void OnCompleted(Action continuation) + { + MainThreadSwitcher.SwitchToMainThread(_ct).GetAwaiter().OnCompleted(continuation); + } + } } } #endif diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/UnityCLILoop.FirstPartyTools.Common.InputSystem.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/UnityCLILoop.FirstPartyTools.Common.InputSystem.Editor.asmdef index 278208acb2..2c888dd640 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/UnityCLILoop.FirstPartyTools.Common.InputSystem.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/UnityCLILoop.FirstPartyTools.Common.InputSystem.Editor.asmdef @@ -2,6 +2,7 @@ "name": "UnityCLILoop.FirstPartyTools.Common.InputSystem.Editor", "rootNamespace": "io.github.hatayama.UnityCliLoop.FirstPartyTools", "references": [ + "GUID:214998e563c124e8a88199b2dd1f522d", "GUID:fc3fd32eddbee40e39c2d76dc184957b", "GUID:c956a21f824994ef087b6de566690b3d", "GUID:75469ad4d38634e559750d17036d5f7c" diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeDomainReloadWaitSignal.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeDomainReloadWaitSignal.cs index ae999ea01f..f331d62664 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeDomainReloadWaitSignal.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeDomainReloadWaitSignal.cs @@ -53,7 +53,7 @@ public async Task ShouldWaitAsync(CancellationToken ct) for (int frame = 0; frame < SIGNAL_SETTLE_FRAMES; frame++) { - await EditorDelay.DelayFrame(1, ct); + await EditorFrameWaiter.WaitFramesAsync(1, ct); if (ShouldRequestWait(_parameters, EditorApplication.isCompiling, _reloadSignalObserved)) { return true; diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs index 6a782e1495..df290bce97 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs @@ -73,7 +73,7 @@ public static EditorWindow[] FindWindowsByName(string windowName, WindowMatchMod } window.ShowTab(); - await EditorDelay.DelayFrame(2, ct); + await EditorFrameWaiter.WaitFramesAsync(2, ct); return CaptureWindowInternal(window, resolutionScale); } @@ -149,7 +149,7 @@ public static string[] GetOpenWindowNames() Debug.Assert(UnityEditor.EditorApplication.isPlaying, "CaptureGameRenderingAsync requires PlayMode"); // Wait for the game camera to complete at least one full render cycle after any state change - await EditorDelay.DelayFrame(2, ct); + await EditorFrameWaiter.WaitFramesAsync(2, ct); RenderTexture rt = GameViewBridge.GetRenderTexture(); if (rt == null) @@ -209,4 +209,3 @@ private static Texture2D ApplyResolutionScaling(Texture2D originalTexture, float } } } - diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotUseCase.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotUseCase.cs index 35ccb14716..aa393a0e4a 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotUseCase.cs @@ -89,7 +89,7 @@ private async Task CaptureRenderingAsync( request.ResolutionScale); Canvas.ForceUpdateCanvases(); // Chained CLI calls can read the previous GameView RT before overlay rendering catches up. - await EditorDelay.DelayFrame(ANNOTATION_OVERLAY_RENDER_WAIT_FRAMES, ct); + await EditorFrameWaiter.WaitFramesAsync(ANNOTATION_OVERLAY_RENDER_WAIT_FRAMES, ct); } (texture, yOffset) = await EditorWindowCaptureUtility.CaptureGameRenderingAsync( diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs index 701676be40..39e8c63273 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs @@ -175,23 +175,40 @@ private async Task ExecutePress( try { - await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate(() => KeyboardKeyState.SetKeyState(keyboard, key, true), ct); - pressWasApplied = true; - waitOutcome = await InputSystemUpdateHelper.WaitForPressLifetime(duration, ct); + waitOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + () => KeyboardKeyState.SetKeyState(keyboard, key, true), + ct).ConfigureAwait(false); + if (waitOutcome == InputSimulationWaitOutcome.Completed) + { + pressWasApplied = true; + waitOutcome = await InputSystemUpdateHelper.WaitForPressLifetime(duration, ct) + .ConfigureAwait(false); + } } finally { - if (pressWasApplied) + if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { - await ReleaseKeyStateIfPossible(keyboard, key); - KeyboardKeyState.UnregisterTransientKey(key); - if (waitOutcome == InputSimulationWaitOutcome.Paused) + ScheduleTimedOutPressCleanup(keyboard, key, pressWasApplied); + } + else if (pressWasApplied) + { + InputSimulationWaitOutcome releaseOutcome = + await ReleaseKeyStateIfPossible(keyboard, key).ConfigureAwait(false); + if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) { + waitOutcome = InputSimulationWaitOutcome.TimedOut; + ScheduleTimedOutPressCleanup(keyboard, key, false); + } + else if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + KeyboardKeyState.UnregisterTransientKey(key); SimulateKeyboardOverlayState.ClearPress(); } else { - await FinalizePressOverlay(ct); + KeyboardKeyState.UnregisterTransientKey(key); + await FinalizePressOverlay(ct).ConfigureAwait(false); } } else @@ -206,6 +223,11 @@ private async Task ExecutePress( return InterruptedPressResult(keyName); } + if (waitOutcome == InputSimulationWaitOutcome.TimedOut) + { + return TimedOutKeyResult(UnityCliLoopKeyboardAction.Press, keyName); + } + string durationText = duration > 0f ? $" for {InputSimulationDurationFormatter.FormatSeconds(duration)}s" : ""; return new UnityCliLoopKeyboardSimulationResult { @@ -237,18 +259,34 @@ private async Task ExecuteKeyDown(Keyboard try { - await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate(() => KeyboardKeyState.SetKeyState(keyboard, key, true), ct); - keyDownApplied = true; - KeyboardKeyState.SetKeyDown(key); - SimulateKeyboardOverlayState.AddHeldKey(keyName); - waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct); - committed = waitOutcome == InputSimulationWaitOutcome.Completed; + waitOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + () => KeyboardKeyState.SetKeyState(keyboard, key, true), + ct).ConfigureAwait(false); + if (waitOutcome == InputSimulationWaitOutcome.Completed) + { + keyDownApplied = true; + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); + KeyboardKeyState.SetKeyDown(key); + SimulateKeyboardOverlayState.AddHeldKey(keyName); + waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct) + .ConfigureAwait(false); + committed = waitOutcome == InputSimulationWaitOutcome.Completed; + } } finally { - if (keyDownApplied && !committed) + if (waitOutcome == InputSimulationWaitOutcome.TimedOut) + { + ScheduleTimedOutHeldKeyCleanup(keyboard, key, keyName, keyDownApplied); + } + else if (keyDownApplied && !committed) { - await RollbackHeldKey(keyboard, key, keyName); + InputSimulationWaitOutcome rollbackOutcome = + await RollbackHeldKey(keyboard, key, keyName).ConfigureAwait(false); + if (rollbackOutcome == InputSimulationWaitOutcome.TimedOut) + { + waitOutcome = InputSimulationWaitOutcome.TimedOut; + } } } @@ -257,6 +295,11 @@ private async Task ExecuteKeyDown(Keyboard return InterruptedKeyResult(UnityCliLoopKeyboardAction.KeyDown, keyName); } + if (waitOutcome == InputSimulationWaitOutcome.TimedOut) + { + return TimedOutKeyResult(UnityCliLoopKeyboardAction.KeyDown, keyName); + } + return new UnityCliLoopKeyboardSimulationResult { Success = true, @@ -281,15 +324,31 @@ private async Task ExecuteKeyUp(Keyboard k }; } - await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate(() => KeyboardKeyState.SetKeyState(keyboard, key, false), ct); + InputSimulationWaitOutcome releaseOutcome = + await ReleaseKeyStateIfPossible(keyboard, key).ConfigureAwait(false); + + if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) + { + ScheduleTimedOutHeldKeyCleanup(keyboard, key, keyName, false); + return TimedOutKeyResult(UnityCliLoopKeyboardAction.KeyUp, keyName); + } + + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); KeyboardKeyState.SetKeyUp(key); SimulateKeyboardOverlayState.RemoveHeldKey(keyName); - InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct); + + InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct) + .ConfigureAwait(false); if (waitOutcome == InputSimulationWaitOutcome.Paused) { return InterruptedKeyResult(UnityCliLoopKeyboardAction.KeyUp, keyName); } + if (waitOutcome == InputSimulationWaitOutcome.TimedOut) + { + return TimedOutKeyResult(UnityCliLoopKeyboardAction.KeyUp, keyName); + } + return new UnityCliLoopKeyboardSimulationResult { Success = true, @@ -329,6 +388,19 @@ private static UnityCliLoopKeyboardSimulationResult InterruptedKeyResult( return result; } + private static UnityCliLoopKeyboardSimulationResult TimedOutKeyResult( + UnityCliLoopKeyboardAction action, + string keyName) + { + return new UnityCliLoopKeyboardSimulationResult + { + Success = false, + Message = $"Keyboard input timed out while waiting for Unity Editor update. Key '{keyName}' cleanup is queued for the next Editor tick.", + Action = action.ToString(), + KeyName = keyName + }; + } + private static void AttachDebugBreakHit(UnityCliLoopKeyboardSimulationResult result) { if (result == null) @@ -360,6 +432,7 @@ private static void AttachDebugBreakHit(UnityCliLoopKeyboardSimulationResult res private static async Task FinalizePressOverlay(CancellationToken ct) { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); if (ct.IsCancellationRequested) { SimulateKeyboardOverlayState.ClearPress(); @@ -367,31 +440,118 @@ private static async Task FinalizePressOverlay(CancellationToken ct) } SimulateKeyboardOverlayState.ReleasePress(); - await EditorDelay.DelayFrame(1, CancellationToken.None); + await EditorFrameWaiter.WaitFramesAsync(1, CancellationToken.None).ConfigureAwait(false); } - private static async Task RollbackHeldKey(Keyboard keyboard, Key key, string keyName) + private static async Task RollbackHeldKey(Keyboard keyboard, Key key, string keyName) { - await ReleaseKeyStateIfPossible(keyboard, key); + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); + InputSimulationWaitOutcome releaseOutcome = + await ReleaseKeyStateIfPossible(keyboard, key).ConfigureAwait(false); + if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) + { + ScheduleTimedOutHeldKeyCleanup(keyboard, key, keyName, false); + return releaseOutcome; + } + + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); KeyboardKeyState.SetKeyUp(key); SimulateKeyboardOverlayState.RemoveHeldKey(keyName); + return releaseOutcome; } - private static async Task ReleaseKeyStateIfPossible(Keyboard keyboard, Key key) + private static async Task ReleaseKeyStateIfPossible(Keyboard keyboard, Key key) { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); if (!CanInjectKeyboardState(keyboard)) { - return; + return InputSimulationWaitOutcome.Completed; } if (EditorApplication.isPaused) { - KeyboardKeyState.SetKeyState(keyboard, key, false); - InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); + ReleaseKeyStateImmediately(keyboard, key); + return InputSimulationWaitOutcome.Completed; + } + + InputSimulationWaitOutcome releaseOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + () => KeyboardKeyState.SetKeyState(keyboard, key, false), + CancellationToken.None).ConfigureAwait(false); + if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) + { + ScheduleReleaseKeyStateImmediately(keyboard, key); + } + + return releaseOutcome; + } + + private static void ScheduleReleaseKeyStateImmediately(Keyboard keyboard, Key key) + { + ReleaseKeyStateImmediatelyOnMainThreadAsync(keyboard, key, CancellationToken.None).Forget(); + } + + private static async Task ReleaseKeyStateImmediatelyOnMainThreadAsync( + Keyboard keyboard, + Key key, + CancellationToken ct) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + ReleaseKeyStateImmediately(keyboard, key); + } + + private static void ReleaseKeyStateImmediately(Keyboard keyboard, Key key) + { + Debug.Assert(CanInjectKeyboardState(keyboard), "keyboard state can only be released while PlayMode has a keyboard"); + if (!CanInjectKeyboardState(keyboard)) + { return; } - await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate(() => KeyboardKeyState.SetKeyState(keyboard, key, false), CancellationToken.None); + KeyboardKeyState.SetKeyState(keyboard, key, false); + InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); + } + + private static void ScheduleTimedOutPressCleanup(Keyboard keyboard, Key key, bool pressWasApplied) + { + CleanupTimedOutPressAsync(keyboard, key, pressWasApplied, CancellationToken.None).Forget(); + } + + private static async Task CleanupTimedOutPressAsync( + Keyboard keyboard, + Key key, + bool pressWasApplied, + CancellationToken ct) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + if (pressWasApplied) + { + await ReleaseKeyStateIfPossible(keyboard, key).ConfigureAwait(false); + } + + KeyboardKeyState.UnregisterTransientKey(key); + SimulateKeyboardOverlayState.ClearPress(); + } + + private static void ScheduleTimedOutHeldKeyCleanup(Keyboard keyboard, Key key, string keyName, bool keyWasApplied) + { + CleanupTimedOutHeldKeyAsync(keyboard, key, keyName, keyWasApplied, CancellationToken.None).Forget(); + } + + private static async Task CleanupTimedOutHeldKeyAsync( + Keyboard keyboard, + Key key, + string keyName, + bool keyWasApplied, + CancellationToken ct) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + if (keyWasApplied) + { + await ReleaseKeyStateIfPossible(keyboard, key).ConfigureAwait(false); + } + + KeyboardKeyState.SetKeyUp(key); + SimulateKeyboardOverlayState.RemoveHeldKey(keyName); } private static bool CanInjectKeyboardState(Keyboard keyboard) diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index 210b8cc588..5fbd9d7458 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -44,9 +44,9 @@ Use `Press` for edge-triggered keyboard code such as `Keyboard.current.spaceKey. If a successful `Press` or `KeyDown` leaves `Keyboard.current..isPressed` true but runtime state does not change, do not immediately rewrite the user's runtime code to `isPressed`. First verify that the target component is active during the command, that it polls input in the configured Input System update phase, and that a missed `KeyDown` edge is followed by `KeyUp` before retrying. Use `KeyDown` / `KeyUp` when the scenario intentionally needs a held key. -### Optional Debug Break Inspection +### Debug Break Inspection (Standard for E2E) -- Use `UnityCliLoopDebug.Break("")` with `uloop-wait-for-debug-break` only when exact-frame evidence would reduce uncertainty. Final logs, screenshots, or durable state may be enough for simpler checks. +- Use `UnityCliLoopDebug.Break("")` with `uloop-wait-for-debug-break` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. - Put the marker at a natural state transition after the app consumed the key, such as after a command is accepted, a state mutation is committed, an evaluation step resolves, or a dependent component is updated. Do not place it immediately after sending `simulate-keyboard`. - If the key handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UnityCliLoopDebug.Break("")` and read them with `uloop-get-logs` while Unity is paused. A break hit proves the line was reached, not the frame-local values. - Treat `simulate-keyboard Success=true`, generic action logs, and final durable counters as useful evidence, but not as paused-frame proof. diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs index 5ae729df61..00f31d4285 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs @@ -177,10 +177,15 @@ private async Task ExecuteClick( string buttonName = button.ToString(); // Set mouse position before clicking - await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( - () => MouseInputState.SetPositionState(mouse, screenPos), ct); + InputSimulationWaitOutcome positionOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + () => MouseInputState.SetPositionState(mouse, screenPos), ct).ConfigureAwait(false); + if (positionOutcome == InputSimulationWaitOutcome.TimedOut) + { + return TimedOutButtonResult(UnityCliLoopMouseInputAction.Click, buttonName, inputPos); + } // Press button + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); MouseInputState.SetButtonDown(button); SimulateMouseInputOverlayState.SetButtonHeld(button, true); bool pressWasApplied = false; @@ -188,29 +193,53 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( try { - await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( - () => MouseInputState.SetButtonState(mouse, button, true), ct); - pressWasApplied = true; - waitOutcome = await InputSystemUpdateHelper.WaitForPressLifetime(request.Duration, ct); + waitOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + () => MouseInputState.SetButtonState(mouse, button, true), ct) + .ConfigureAwait(false); + if (waitOutcome == InputSimulationWaitOutcome.Completed) + { + pressWasApplied = true; + waitOutcome = await InputSystemUpdateHelper.WaitForPressLifetime(request.Duration, ct) + .ConfigureAwait(false); + } } finally { - if (pressWasApplied) + if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { - await ReleaseButtonIfPossible(mouse, button); - MouseInputState.SetButtonUp(button); + ScheduleTimedOutButtonCleanup(mouse, button, pressWasApplied); } - else + else if (pressWasApplied) { - MouseInputState.SetButtonUp(button); + InputSimulationWaitOutcome releaseOutcome = + await ReleaseButtonIfPossible(mouse, button).ConfigureAwait(false); + if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) + { + waitOutcome = InputSimulationWaitOutcome.TimedOut; + ScheduleTimedOutButtonCleanup(mouse, button, false); + } + else + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); + MouseInputState.SetButtonUp(button); + } } - if (waitOutcome == InputSimulationWaitOutcome.Paused) + else { - SimulateMouseInputOverlayState.Clear(); + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); + MouseInputState.SetButtonUp(button); } - else + + if (waitOutcome != InputSimulationWaitOutcome.TimedOut) { - SimulateMouseInputOverlayState.SetButtonHeld(button, false); + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + SimulateMouseInputOverlayState.Clear(); + } + else + { + SimulateMouseInputOverlayState.SetButtonHeld(button, false); + } } } @@ -222,6 +251,11 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( inputPos); } + if (waitOutcome == InputSimulationWaitOutcome.TimedOut) + { + return TimedOutButtonResult(UnityCliLoopMouseInputAction.Click, buttonName, inputPos); + } + string durationText = request.Duration > 0f ? $" for {InputSimulationDurationFormatter.FormatSeconds(request.Duration)}s" : ""; return new UnityCliLoopMouseInputSimulationResult { @@ -253,10 +287,15 @@ private async Task ExecuteLongPress( string buttonName = button.ToString(); // Set mouse position before pressing - await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( - () => MouseInputState.SetPositionState(mouse, screenPos), ct); + InputSimulationWaitOutcome positionOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + () => MouseInputState.SetPositionState(mouse, screenPos), ct).ConfigureAwait(false); + if (positionOutcome == InputSimulationWaitOutcome.TimedOut) + { + return TimedOutButtonResult(UnityCliLoopMouseInputAction.LongPress, buttonName, inputPos); + } // Press button + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); MouseInputState.SetButtonDown(button); SimulateMouseInputOverlayState.SetButtonHeld(button, true); bool pressWasApplied = false; @@ -264,28 +303,56 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( try { - await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( - () => MouseInputState.SetButtonState(mouse, button, true), ct); - pressWasApplied = true; + waitOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + () => MouseInputState.SetButtonState(mouse, button, true), ct) + .ConfigureAwait(false); // Hold for at least the minimum observation frames so the press // is visible to game code, then continue until duration elapses. - waitOutcome = await InputSystemUpdateHelper.WaitForPressLifetime(request.Duration, ct); + if (waitOutcome == InputSimulationWaitOutcome.Completed) + { + pressWasApplied = true; + waitOutcome = await InputSystemUpdateHelper.WaitForPressLifetime(request.Duration, ct) + .ConfigureAwait(false); + } } finally { - if (pressWasApplied) + if (waitOutcome == InputSimulationWaitOutcome.TimedOut) { - await ReleaseButtonIfPossible(mouse, button); + ScheduleTimedOutButtonCleanup(mouse, button, pressWasApplied); } - MouseInputState.SetButtonUp(button); - if (waitOutcome == InputSimulationWaitOutcome.Paused) + else if (pressWasApplied) { - SimulateMouseInputOverlayState.Clear(); + InputSimulationWaitOutcome releaseOutcome = + await ReleaseButtonIfPossible(mouse, button).ConfigureAwait(false); + if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) + { + waitOutcome = InputSimulationWaitOutcome.TimedOut; + ScheduleTimedOutButtonCleanup(mouse, button, false); + } + else + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); + MouseInputState.SetButtonUp(button); + } } else { - SimulateMouseInputOverlayState.SetButtonHeld(button, false); + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); + MouseInputState.SetButtonUp(button); + } + + if (waitOutcome != InputSimulationWaitOutcome.TimedOut) + { + if (waitOutcome == InputSimulationWaitOutcome.Paused) + { + SimulateMouseInputOverlayState.Clear(); + } + else + { + SimulateMouseInputOverlayState.SetButtonHeld(button, false); + } } } @@ -297,6 +364,11 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( inputPos); } + if (waitOutcome == InputSimulationWaitOutcome.TimedOut) + { + return TimedOutButtonResult(UnityCliLoopMouseInputAction.LongPress, buttonName, inputPos); + } + return new UnityCliLoopMouseInputSimulationResult { Success = true, @@ -314,15 +386,29 @@ private async Task ExecuteMoveDelta( Vector2 delta = new(request.DeltaX, request.DeltaY); SimulateMouseInputOverlayState.SetMoveDelta(delta); - await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( - () => MouseInputState.SetDeltaState(mouse, delta), ct); - InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct); + InputSimulationWaitOutcome applyOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + () => MouseInputState.SetDeltaState(mouse, delta), ct).ConfigureAwait(false); + if (applyOutcome == InputSimulationWaitOutcome.TimedOut) + { + ScheduleTimedOutMouseOverlayCleanup(); + return TimedOutActionResult(UnityCliLoopMouseInputAction.MoveDelta); + } + + InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct) + .ConfigureAwait(false); if (waitOutcome == InputSimulationWaitOutcome.Paused) { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); SimulateMouseInputOverlayState.Clear(); return InterruptedActionResult(UnityCliLoopMouseInputAction.MoveDelta); } + if (waitOutcome == InputSimulationWaitOutcome.TimedOut) + { + ScheduleTimedOutMouseOverlayCleanup(); + return TimedOutActionResult(UnityCliLoopMouseInputAction.MoveDelta); + } + return new UnityCliLoopMouseInputSimulationResult { Success = true, @@ -339,15 +425,29 @@ private async Task ExecuteScroll( int scrollDir = request.ScrollY > 0f ? 1 : request.ScrollY < 0f ? -1 : 0; SimulateMouseInputOverlayState.SetScrollDirection(scrollDir); - await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( - () => MouseInputState.SetScrollState(mouse, scroll), ct); - InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct); + InputSimulationWaitOutcome applyOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + () => MouseInputState.SetScrollState(mouse, scroll), ct).ConfigureAwait(false); + if (applyOutcome == InputSimulationWaitOutcome.TimedOut) + { + ScheduleTimedOutMouseOverlayCleanup(); + return TimedOutActionResult(UnityCliLoopMouseInputAction.Scroll); + } + + InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct) + .ConfigureAwait(false); if (waitOutcome == InputSimulationWaitOutcome.Paused) { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); SimulateMouseInputOverlayState.Clear(); return InterruptedActionResult(UnityCliLoopMouseInputAction.Scroll); } + if (waitOutcome == InputSimulationWaitOutcome.TimedOut) + { + ScheduleTimedOutMouseOverlayCleanup(); + return TimedOutActionResult(UnityCliLoopMouseInputAction.Scroll); + } + return new UnityCliLoopMouseInputSimulationResult { Success = true, @@ -379,6 +479,8 @@ private async Task ExecuteSmoothDelta( while (true) { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + float elapsed = Time.realtimeSinceStartup - startTime; float t = Mathf.Clamp01(elapsed / duration); @@ -386,18 +488,31 @@ private async Task ExecuteSmoothDelta( Vector2 frameDelta = totalDelta * frameFraction; SimulateMouseInputOverlayState.SetMoveDelta(frameDelta); - await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( - () => MouseInputState.InjectDelta(mouse, frameDelta), ct); + InputSimulationWaitOutcome applyOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + () => MouseInputState.InjectDelta(mouse, frameDelta), ct).ConfigureAwait(false); + if (applyOutcome == InputSimulationWaitOutcome.TimedOut) + { + ScheduleTimedOutDeltaCleanup(mouse); + return TimedOutActionResult(UnityCliLoopMouseInputAction.SmoothDelta); + } previousT = t; - InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForRuntimeFrames(1, ct); + InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForRuntimeFrames(1, ct) + .ConfigureAwait(false); if (waitOutcome == InputSimulationWaitOutcome.Paused) { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); ResetDeltaIfPossible(mouse); SimulateMouseInputOverlayState.Clear(); return InterruptedActionResult(UnityCliLoopMouseInputAction.SmoothDelta); } + if (waitOutcome == InputSimulationWaitOutcome.TimedOut) + { + ScheduleTimedOutDeltaCleanup(mouse); + return TimedOutActionResult(UnityCliLoopMouseInputAction.SmoothDelta); + } + if (t >= 1f) { break; @@ -405,8 +520,13 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( } // Reset delta to zero after the smooth operation completes - await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( - () => MouseInputState.InjectDelta(mouse, Vector2.zero), ct); + InputSimulationWaitOutcome resetOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + () => MouseInputState.InjectDelta(mouse, Vector2.zero), ct).ConfigureAwait(false); + if (resetOutcome == InputSimulationWaitOutcome.TimedOut) + { + ScheduleTimedOutDeltaCleanup(mouse); + return TimedOutActionResult(UnityCliLoopMouseInputAction.SmoothDelta); + } return new UnityCliLoopMouseInputSimulationResult { @@ -442,6 +562,29 @@ private static UnityCliLoopMouseInputSimulationResult InterruptedActionResult( return result; } + private static UnityCliLoopMouseInputSimulationResult TimedOutButtonResult( + UnityCliLoopMouseInputAction action, + string buttonName, + Vector2 inputPos) + { + UnityCliLoopMouseInputSimulationResult result = TimedOutActionResult(action); + result.Button = buttonName; + result.PositionX = inputPos.x; + result.PositionY = inputPos.y; + return result; + } + + private static UnityCliLoopMouseInputSimulationResult TimedOutActionResult( + UnityCliLoopMouseInputAction action) + { + return new UnityCliLoopMouseInputSimulationResult + { + Success = false, + Message = "Mouse input timed out while waiting for Unity Editor update. Cleanup is queued for the next Editor tick.", + Action = action.ToString() + }; + } + private static void AttachDebugBreakHit(UnityCliLoopMouseInputSimulationResult result) { if (result == null) @@ -471,22 +614,55 @@ private static void AttachDebugBreakHit(UnityCliLoopMouseInputSimulationResult r result.DebugBreakHitCount = snapshot.HitCount; } - private static async Task ReleaseButtonIfPossible(Mouse mouse, RuntimeMouseButton button) + private static async Task ReleaseButtonIfPossible(Mouse mouse, RuntimeMouseButton button) { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); if (!CanInjectMouseState(mouse)) { - return; + return InputSimulationWaitOutcome.Completed; } if (EditorApplication.isPaused) { - MouseInputState.SetButtonState(mouse, button, false); - InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); + ReleaseButtonImmediately(mouse, button); + return InputSimulationWaitOutcome.Completed; + } + + InputSimulationWaitOutcome releaseOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + () => MouseInputState.SetButtonState(mouse, button, false), + CancellationToken.None).ConfigureAwait(false); + if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) + { + ScheduleReleaseButtonImmediately(mouse, button); + } + + return releaseOutcome; + } + + private static void ScheduleReleaseButtonImmediately(Mouse mouse, RuntimeMouseButton button) + { + ReleaseButtonImmediatelyOnMainThreadAsync(mouse, button, CancellationToken.None).Forget(); + } + + private static async Task ReleaseButtonImmediatelyOnMainThreadAsync( + Mouse mouse, + RuntimeMouseButton button, + CancellationToken ct) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + ReleaseButtonImmediately(mouse, button); + } + + private static void ReleaseButtonImmediately(Mouse mouse, RuntimeMouseButton button) + { + Debug.Assert(CanInjectMouseState(mouse), "mouse button can only be released while PlayMode has a mouse"); + if (!CanInjectMouseState(mouse)) + { return; } - await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( - () => MouseInputState.SetButtonState(mouse, button, false), CancellationToken.None); + MouseInputState.SetButtonState(mouse, button, false); + InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); } private static void ResetDeltaIfPossible(Mouse mouse) @@ -503,6 +679,50 @@ private static void ResetDeltaIfPossible(Mouse mouse) } } + private static void ScheduleTimedOutButtonCleanup(Mouse mouse, RuntimeMouseButton button, bool pressWasApplied) + { + CleanupTimedOutButtonAsync(mouse, button, pressWasApplied, CancellationToken.None).Forget(); + } + + private static async Task CleanupTimedOutButtonAsync( + Mouse mouse, + RuntimeMouseButton button, + bool pressWasApplied, + CancellationToken ct) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + if (pressWasApplied) + { + await ReleaseButtonIfPossible(mouse, button).ConfigureAwait(false); + } + + MouseInputState.SetButtonUp(button); + SimulateMouseInputOverlayState.SetButtonHeld(button, false); + } + + private static void ScheduleTimedOutMouseOverlayCleanup() + { + CleanupTimedOutMouseOverlayAsync(CancellationToken.None).Forget(); + } + + private static async Task CleanupTimedOutMouseOverlayAsync(CancellationToken ct) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + SimulateMouseInputOverlayState.Clear(); + } + + private static void ScheduleTimedOutDeltaCleanup(Mouse mouse) + { + CleanupTimedOutDeltaAsync(mouse, CancellationToken.None).Forget(); + } + + private static async Task CleanupTimedOutDeltaAsync(Mouse mouse, CancellationToken ct) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + ResetDeltaIfPossible(mouse); + SimulateMouseInputOverlayState.Clear(); + } + private static bool CanInjectMouseState(Mouse mouse) { return EditorApplication.isPlaying && mouse != null; diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md index 8bb19ad246..8b1f12c814 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md @@ -15,7 +15,7 @@ Simulate mouse input via Input System in Unity PlayMode. 2. For Click/LongPress: determine the target screen position (use `uloop screenshot` to find coordinates) 3. Execute the needed `uloop simulate-mouse-input` commands 4. Inspect the result with the lightest useful evidence: runtime state, logs, or a screenshot -5. If exact-frame proof would reduce uncertainty, treat Debug Break inspection as an optional follow-up using the section below +5. When this input verifies a state transition, use Debug Break inspection from the section below as the standard frame proof 6. Report what happened and which evidence was used ## Tool Reference @@ -48,9 +48,9 @@ uloop simulate-mouse-input --action [options] | `SmoothDelta` | Mouse.current.delta (per-frame) | Inject mouse delta smoothly over `--duration` seconds (human-like camera pan) | | `Scroll` | Mouse.current.scroll | Inject scroll wheel input | -### Optional Debug Break Inspection +### Debug Break Inspection (Standard for E2E) -- Use `UnityCliLoopDebug.Break("")` with `uloop-wait-for-debug-break` only when exact-frame evidence would reduce uncertainty. Final logs, screenshots, or durable state may be enough for simpler checks. +- Use `UnityCliLoopDebug.Break("")` with `uloop-wait-for-debug-break` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. - Place the break at a natural state transition after the app consumed the mouse input, such as after a command is accepted, a state mutation is committed, an evaluation step resolves, a tracked value changes, or a dependent component is updated. Do not place it immediately after sending `simulate-mouse-input`. - If the mouse handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UnityCliLoopDebug.Break("")` and read them with `uloop-get-logs` while Unity is paused. A break hit proves the line was reached, not the frame-local values. - If the response has `InterruptedByDebugBreak: true`, Unity is paused for inspection and the tool released its held input bookkeeping. `DebugBreakId` and `DebugBreakHitCount` identify the break that paused Unity. Use `get-logs`, `get-hierarchy`, `find-game-objects`, or `execute-dynamic-code` before resuming. diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs index 8d2bfaaafd..bbd5e4a159 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/SimulateMouseUiUseCase.cs @@ -429,7 +429,7 @@ private async Task ExecuteLongPress( while (elapsed < parameters.Duration) { SimulateMouseUiOverlayState.UpdateLongPressElapsed(elapsed); - await EditorDelay.DelayFrame(1, ct); + await EditorFrameWaiter.WaitFramesAsync(1, ct); elapsed = Time.realtimeSinceStartup - startTime; } SimulateMouseUiOverlayState.UpdateLongPressElapsed(parameters.Duration); @@ -570,7 +570,7 @@ private async Task ExecuteDragOneShot( { await PlayExpandAnimation(ct); await InterpolateDragPosition(pointerData, target, screenEnd, parameters.DragSpeed, ct); - await EditorDelay.DelayFrame(1, ct); + await EditorFrameWaiter.WaitFramesAsync(1, ct); } finally { @@ -656,7 +656,7 @@ private async Task InterpolateDragPosition( if (duration <= 0f) { - await EditorDelay.DelayFrame(1, ct); + await EditorFrameWaiter.WaitFramesAsync(1, ct); Vector2 previousPosition = pointerData.position; pointerData.position = endPos; @@ -672,7 +672,7 @@ private async Task InterpolateDragPosition( do { - await EditorDelay.DelayFrame(1, ct); + await EditorFrameWaiter.WaitFramesAsync(1, ct); float elapsed = Time.realtimeSinceStartup - startTime; t = Mathf.Clamp01(elapsed / duration); @@ -893,7 +893,7 @@ private async Task ExecuteDragEnd( await InterpolateDragPosition( MouseDragState.PointerData!, MouseDragState.Target!, screenEnd, parameters.DragSpeed, ct); - await EditorDelay.DelayFrame(1, ct); + await EditorFrameWaiter.WaitFramesAsync(1, ct); } finally { @@ -962,7 +962,7 @@ private static async Task PlayExpandAnimation(CancellationToken ct) { float t = elapsed / EXPAND_DURATION; overlay.SetCursorScale(Mathf.Lerp(EXPAND_START_SCALE, 1f, t)); - await EditorDelay.DelayFrame(1, ct); + await EditorFrameWaiter.WaitFramesAsync(1, ct); elapsed = Time.realtimeSinceStartup - startTime; } overlay.SetCursorScale(1f); @@ -979,7 +979,7 @@ private static async Task PlayDissipateAnimation(CancellationToken ct) float t = elapsed / DISSIPATE_DURATION; overlay.SetCursorScale(Mathf.Lerp(1f, 0f, t)); overlay.SetAlpha(Mathf.Lerp(1f, 0f, t)); - await EditorDelay.DelayFrame(1, ct); + await EditorFrameWaiter.WaitFramesAsync(1, ct); elapsed = Time.realtimeSinceStartup - startTime; } overlay!.SetCursorScale(0f); diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md index 3629eeff87..5fd123722f 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md @@ -16,7 +16,7 @@ Simulate mouse interaction on Unity PlayMode UI. 3. Use the `AnnotatedElements` array to find the target element by `Label`, `Name`, or `Path` (A=frontmost, B=next, ...). Use `Interaction` to distinguish click targets from drag/drop/text targets, then use `SimX`/`SimY` directly as `--x`/`--y` coordinates. 4. Execute the needed `uloop simulate-mouse-ui` commands 5. Inspect the result with the lightest useful evidence: runtime state, logs, or a screenshot -6. If exact-frame proof would reduce uncertainty, treat Debug Break inspection as an optional follow-up using the section below +6. When this UI input verifies a state transition, use Debug Break inspection from the section below as the standard frame proof 7. Report what happened and which evidence was used ## Tool Reference @@ -76,9 +76,9 @@ uloop simulate-mouse-ui --action --x --y [options] - `--bypass-raycast` still uses coordinates for pointer event positions, but chooses the clicked, long-pressed, or dragged GameObject by `--target-path` - If `--target-path` or `--drop-target-path` matches multiple active GameObjects, the command fails instead of choosing an arbitrary duplicate -## Optional Debug Break Inspection +## Debug Break Inspection (Standard for E2E) -- Use `UnityCliLoopDebug.Break("")` with `uloop-wait-for-debug-break` only when exact-frame evidence would reduce uncertainty. Final logs, screenshots, or durable state may be enough for simpler checks. +- Use `UnityCliLoopDebug.Break("")` with `uloop-wait-for-debug-break` as the standard frame proof when this input drives a state transition you are verifying. Final logs, screenshots, and durable state supplement the paused-frame check but do not replace it. - Place the break at a natural transition after the app consumed the UI event, such as after a command is accepted, a state mutation is committed, a tracked value changes, a UI/domain state syncs, or a success/failure/end condition is entered. - If the UI handler has local variables, intermediate calculations, or branch reasons that `execute-dynamic-code` cannot inspect after the fact, log just those values near `UnityCliLoopDebug.Break("")` and read them with `uloop-get-logs` while Unity is paused. A break hit proves the line was reached, not the frame-local values. - Treat `simulate-mouse-ui Success=true`, generic action logs, and final durable counters as useful evidence, not paused-frame proof. diff --git a/Packages/src/Editor/ToolContracts/EditorDelay.cs b/Packages/src/Editor/ToolContracts/EditorDelay.cs deleted file mode 100644 index 1e6b8d28f8..0000000000 --- a/Packages/src/Editor/ToolContracts/EditorDelay.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; -using System.Runtime.CompilerServices; -using System.Threading; - -namespace io.github.hatayama.UnityCliLoop.ToolContracts -{ - /// - /// Frame-based asynchronous waiting (main thread non-blocking) - /// UniTask.Delay()-style implementation for Unity Editor - /// - public static class EditorDelay - { - /// - /// Wait for the specified number of frames - /// - /// Number of frames to wait (default: 1) - /// Cancellation token - /// Awaitable structure - public static DelayFrameAwaitable DelayFrame(int frameCount = 1, CancellationToken cancellationToken = default) - { - return new DelayFrameAwaitable(frameCount, cancellationToken); - } - } - - /// - /// Awaitable structure for frame waiting - /// Can be used with async/await - /// - public struct DelayFrameAwaitable - { - private readonly int _frameCount; - private readonly CancellationToken _cancellationToken; - - public DelayFrameAwaitable(int frameCount, CancellationToken cancellationToken) - { - this._frameCount = frameCount; - this._cancellationToken = cancellationToken; - } - - public Awaiter GetAwaiter() => new(_frameCount, _cancellationToken); - - /// - /// Awaiter structure for async/await - /// - public struct Awaiter : INotifyCompletion - { - private readonly int _frameCount; - private readonly CancellationToken _cancellationToken; - - public Awaiter(int frameCount, CancellationToken cancellationToken) - { - this._frameCount = frameCount; - this._cancellationToken = cancellationToken; - } - - /// - /// Whether waiting is completed - /// - public bool IsCompleted - { - get - { - _cancellationToken.ThrowIfCancellationRequested(); - return _frameCount <= 0; // Complete immediately if 0 frames or less - } - } - - /// - /// Get result when waiting is completed - /// - public void GetResult() - { - _cancellationToken.ThrowIfCancellationRequested(); - } - - /// - /// Register continuation processing - /// Code after await is passed as continuation - /// - public void OnCompleted(Action continuation) - { - _cancellationToken.ThrowIfCancellationRequested(); - - if (IsCompleted) - { - continuation(); - return; - } - - // Register continuation processing to EditorDelayManager - EditorDelayManager.RegisterDelay(continuation, _frameCount, _cancellationToken); - } - } - } -} \ No newline at end of file diff --git a/Packages/src/Editor/ToolContracts/EditorDelay.cs.meta b/Packages/src/Editor/ToolContracts/EditorDelay.cs.meta deleted file mode 100644 index 74760ff9c0..0000000000 --- a/Packages/src/Editor/ToolContracts/EditorDelay.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 8736979d7836d4547bbbf5abc9209c3c -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/src/Editor/ToolContracts/EditorDelayManager.cs b/Packages/src/Editor/ToolContracts/EditorDelayManager.cs deleted file mode 100644 index 400897ef63..0000000000 --- a/Packages/src/Editor/ToolContracts/EditorDelayManager.cs +++ /dev/null @@ -1,205 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using UnityEditor; - -namespace io.github.hatayama.UnityCliLoop.ToolContracts -{ - /// - /// Delay processing management class for Unity Editor - /// Driven by EditorApplication.update to manage frame-based waiting processes - /// - public sealed class EditorDelayManagerService - { - private readonly List _delayTasks = new(); - private readonly object _lockObject = new(); - private int _currentFrameCount = 0; - - /// - /// Class representing a waiting task - /// - private class DelayTask - { - public Action Continuation { get; } - public int RemainingFrames { get; set; } - public CancellationToken CancellationToken { get; } - - public DelayTask(Action continuation, int frames, CancellationToken cancellationToken) - { - Continuation = continuation ?? throw new ArgumentNullException(nameof(continuation)); - RemainingFrames = frames; - CancellationToken = cancellationToken; - } - } - - public void Initialize() - { - EditorApplication.update -= UpdateDelayTasks; - EditorApplication.update += UpdateDelayTasks; - EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; - EditorApplication.playModeStateChanged += OnPlayModeStateChanged; - } - - /// - /// Register a new waiting task - /// - /// Process to execute after waiting completion - /// Number of frames to wait - /// Cancellation token - public void RegisterDelay(Action continuation, int frames, CancellationToken cancellationToken) - { - if (continuation == null) - { - return; - } - - if (frames <= 0) - { - // Execute immediately if 0 frames or less - continuation.Invoke(); - return; - } - - lock (_lockObject) - { - _delayTasks.Add(new DelayTask(continuation, frames, cancellationToken)); - } - } - - /// - /// Update processing for waiting tasks called every frame - /// - private void UpdateDelayTasks() - { - // Update frame counter - _currentFrameCount++; - - if (_delayTasks.Count == 0) return; - - lock (_lockObject) - { - for (int i = _delayTasks.Count - 1; i >= 0; i--) - { - DelayTask task = _delayTasks[i]; - - // Remove cancelled tasks by throwing exceptions - if (task.CancellationToken.IsCancellationRequested) - { - _delayTasks.RemoveAt(i); - task.Continuation.Invoke(); - continue; - } - - // Decrease frame count - task.RemainingFrames--; - - // Execute and remove completed waiting tasks - if (task.RemainingFrames <= 0) - { - _delayTasks.RemoveAt(i); - task.Continuation.Invoke(); - } - } - } - } - - /// - /// Get current number of waiting tasks (for debugging) - /// - public int PendingTaskCount - { - get - { - lock (_lockObject) - { - return _delayTasks.Count; - } - } - } - - /// - /// Get current frame count (for testing) - /// - public int CurrentFrameCount - { - get - { - lock (_lockObject) - { - return _currentFrameCount; - } - } - } - - /// - /// Event handler for PlayMode state changes - /// - private void OnPlayModeStateChanged(PlayModeStateChange state) - { - if (state == PlayModeStateChange.ExitingEditMode || state == PlayModeStateChange.ExitingPlayMode) - { - ResetFrameCount(); - } - } - - /// - /// Reset frame counter (for testing and internal use) - /// - public void ResetFrameCount() - { - lock (_lockObject) - { - _currentFrameCount = 0; - } - } - - /// - /// Clear all waiting tasks (for testing) - /// - public void ClearAllTasks() - { - lock (_lockObject) - { - _delayTasks.Clear(); - } - } - } - - /// - /// Manages Editor Delay state and operations for the owning workflow. - /// - public static class EditorDelayManager - { - private static readonly EditorDelayManagerService ServiceValue = new EditorDelayManagerService(); - - public static void InitializeForEditorStartup() - { - ServiceValue.Initialize(); - } - - public static void RegisterDelay(Action continuation, int frames, CancellationToken cancellationToken) - { - ServiceValue.RegisterDelay(continuation, frames, cancellationToken); - } - - public static int PendingTaskCount - { - get { return ServiceValue.PendingTaskCount; } - } - - public static int CurrentFrameCount - { - get { return ServiceValue.CurrentFrameCount; } - } - - public static void ResetFrameCount() - { - ServiceValue.ResetFrameCount(); - } - - public static void ClearAllTasks() - { - ServiceValue.ClearAllTasks(); - } - } -} diff --git a/Packages/src/Editor/ToolContracts/EditorFrameWaiter.cs b/Packages/src/Editor/ToolContracts/EditorFrameWaiter.cs new file mode 100644 index 0000000000..deab286801 --- /dev/null +++ b/Packages/src/Editor/ToolContracts/EditorFrameWaiter.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using UnityEditor; + +namespace io.github.hatayama.UnityCliLoop.ToolContracts +{ + /// + /// Waits for Unity Editor update frames when code must observe rendered or runtime frame progress. + /// + public sealed class EditorFrameWaiterService + { + private readonly object _lockObject = new object(); + private readonly List _requests = new List(); + private int _currentFrameCount; + + public int CurrentFrameCount + { + get + { + lock (_lockObject) + { + return _currentFrameCount; + } + } + } + + public int PendingWaitCount + { + get + { + lock (_lockObject) + { + return _requests.Count; + } + } + } + + public void InitializeForEditorStartup() + { + EditorApplication.update -= UpdateRequests; + EditorApplication.update += UpdateRequests; + } + + public Task WaitFramesAsync(int frameCount, CancellationToken ct) + { + Debug.Assert(frameCount >= 0, "frameCount must be non-negative"); + ct.ThrowIfCancellationRequested(); + + if (frameCount <= 0) + { + return Task.CompletedTask; + } + + TaskCompletionSource completionSource = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + EditorFrameWaitRequest request; + lock (_lockObject) + { + request = new EditorFrameWaitRequest( + completionSource, + _currentFrameCount + frameCount, + RemoveRequestFromCancellation); + _requests.Add(request); + } + + request.RegisterCancellation(ct); + EditorApplication.QueuePlayerLoopUpdate(); + return completionSource.Task; + } + + public void ClearAllForTests() + { + List requestsToCancel; + lock (_lockObject) + { + requestsToCancel = new List(_requests); + _requests.Clear(); + } + + for (int i = 0; i < requestsToCancel.Count; i++) + { + requestsToCancel[i].CancelFromTest(); + } + } + + public void ResetFrameCountForTests() + { + lock (_lockObject) + { + _currentFrameCount = 0; + } + } + + private void UpdateRequests() + { + List completedRequests = new List(); + lock (_lockObject) + { + _currentFrameCount++; + for (int i = _requests.Count - 1; i >= 0; i--) + { + EditorFrameWaitRequest request = _requests[i]; + if (!request.IsReady(_currentFrameCount)) + { + continue; + } + + _requests.RemoveAt(i); + completedRequests.Add(request); + } + } + + for (int i = 0; i < completedRequests.Count; i++) + { + completedRequests[i].Complete(); + } + } + + private void RemoveRequestFromCancellation(EditorFrameWaitRequest request) + { + bool removed = false; + lock (_lockObject) + { + removed = _requests.Remove(request); + } + + if (removed) + { + request.CancelFromCancellation(); + } + } + } + + /// + /// Tracks a single Editor frame wait request and its cancellation lifetime. + /// + internal sealed class EditorFrameWaitRequest + { + private readonly TaskCompletionSource _completionSource; + private readonly int _targetFrameCount; + private readonly Action _removeRequest; + private CancellationTokenRegistration _cancellationRegistration; + private CancellationToken _ct; + private int _isCompleted; + + public EditorFrameWaitRequest( + TaskCompletionSource completionSource, + int targetFrameCount, + Action removeRequest) + { + Debug.Assert(completionSource != null, "completionSource must not be null"); + Debug.Assert(removeRequest != null, "removeRequest must not be null"); + + _completionSource = completionSource ?? throw new ArgumentNullException(nameof(completionSource)); + _targetFrameCount = targetFrameCount; + _removeRequest = removeRequest ?? throw new ArgumentNullException(nameof(removeRequest)); + } + + public bool IsReady(int currentFrameCount) + { + return currentFrameCount >= _targetFrameCount; + } + + public void RegisterCancellation(CancellationToken ct) + { + _ct = ct; + if (!ct.CanBeCanceled) + { + return; + } + + CancellationTokenRegistration registration = ct.Register(RemoveFromCancellation); + _cancellationRegistration = registration; + if (Interlocked.CompareExchange(ref _isCompleted, 0, 0) != 0) + { + registration.Dispose(); + } + } + + public void Complete() + { + if (Interlocked.Exchange(ref _isCompleted, 1) != 0) + { + return; + } + + _cancellationRegistration.Dispose(); + _completionSource.TrySetResult(true); + } + + public void CancelFromCancellation() + { + if (Interlocked.Exchange(ref _isCompleted, 1) != 0) + { + return; + } + + _cancellationRegistration.Dispose(); + _completionSource.TrySetCanceled(_ct); + } + + public void CancelFromTest() + { + if (Interlocked.Exchange(ref _isCompleted, 1) != 0) + { + return; + } + + _cancellationRegistration.Dispose(); + _completionSource.TrySetCanceled(); + } + + private void RemoveFromCancellation() + { + _removeRequest(this); + } + } + + /// + /// Provides Editor update frame waits without representing them as wall-clock timeouts. + /// + public static class EditorFrameWaiter + { + private static readonly EditorFrameWaiterService ServiceValue = new EditorFrameWaiterService(); + + public static int CurrentFrameCount => ServiceValue.CurrentFrameCount; + public static int PendingWaitCount => ServiceValue.PendingWaitCount; + + public static void InitializeForEditorStartup() + { + ServiceValue.InitializeForEditorStartup(); + } + + public static Task WaitFramesAsync(int frameCount, CancellationToken ct = default) + { + return ServiceValue.WaitFramesAsync(frameCount, ct); + } + + public static void ClearAllForTests() + { + ServiceValue.ClearAllForTests(); + } + + public static void ResetFrameCountForTests() + { + ServiceValue.ResetFrameCountForTests(); + } + } +} diff --git a/Assets/Tests/Editor/EditorDelayTests.cs.meta b/Packages/src/Editor/ToolContracts/EditorFrameWaiter.cs.meta similarity index 83% rename from Assets/Tests/Editor/EditorDelayTests.cs.meta rename to Packages/src/Editor/ToolContracts/EditorFrameWaiter.cs.meta index bf33545ca1..16783016a7 100644 --- a/Assets/Tests/Editor/EditorDelayTests.cs.meta +++ b/Packages/src/Editor/ToolContracts/EditorFrameWaiter.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: d101758ad21c046d68d9ec55262964f1 +guid: 1f65d186542d4f9cb81e25f7ec23b2b3 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Packages/src/Editor/ToolContracts/TaskExtensions.cs b/Packages/src/Editor/ToolContracts/TaskExtensions.cs index 86d42f96a4..8df7390728 100644 --- a/Packages/src/Editor/ToolContracts/TaskExtensions.cs +++ b/Packages/src/Editor/ToolContracts/TaskExtensions.cs @@ -37,34 +37,5 @@ private static async Task HandleTaskExceptions(Task task) } } - /// - /// Fire-and-forget extension method for DelayFrameAwaitable - /// Allows writing: EditorDelay.DelayFrame().Forget() - /// - /// DelayFrameAwaitable to fire and forget - public static void Forget(this DelayFrameAwaitable awaitable) - { - // Convert to Task and discard using consistent pattern - ConvertToTask(awaitable).Forget(); - } - - /// - /// Convert DelayFrameAwaitable to Task for fire-and-forget operations - /// Includes exception handling to prevent unobserved task exceptions in fire-and-forget scenarios - /// - private static async Task ConvertToTask(DelayFrameAwaitable awaitable) - { - try - { - await awaitable; - } - catch (System.Exception ex) - { - // Log the exception to prevent silent failures - UnityEngine.Debug.LogException(ex); - // Intentionally swallow exceptions in fire-and-forget scenarios - // to prevent unobserved task exceptions from crashing the application - } - } } -} \ No newline at end of file +} diff --git a/Packages/src/Editor/ToolContracts/TimerDelay.cs b/Packages/src/Editor/ToolContracts/TimerDelay.cs index 3bec3a9103..bb16b1d6ab 100644 --- a/Packages/src/Editor/ToolContracts/TimerDelay.cs +++ b/Packages/src/Editor/ToolContracts/TimerDelay.cs @@ -1,119 +1,138 @@ using System; -using System.Runtime.CompilerServices; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using UnityEditor; namespace io.github.hatayama.UnityCliLoop.ToolContracts { /// - /// Timer-based asynchronous delay for Unity Editor - /// Provides async/await support for time-based delays that work regardless of Unity Editor state + /// Provides wall-clock delay operations that do not require Unity Editor update frames. /// public static class TimerDelay { /// - /// Wait for the specified number of milliseconds + /// Waits for wall-clock time without depending on Unity Editor update callbacks. /// /// Milliseconds to wait - /// Cancellation token + /// Cancellation token /// Awaitable task - public static Task Wait(int milliseconds, CancellationToken cancellationToken = default) + public static Task Wait(int milliseconds, CancellationToken ct = default) { if (milliseconds <= 0) { + ct.ThrowIfCancellationRequested(); return Task.CompletedTask; } - TaskCompletionSource tcs = new(); - - Timer timer = null; - timer = new Timer(_ => - { - timer?.Dispose(); - timer = null; - if (!cancellationToken.IsCancellationRequested) - { - tcs.SetResult(true); - } - else - { - tcs.SetCanceled(); - } - }, null, milliseconds, Timeout.Infinite); - - // Handle cancellation - if (cancellationToken.CanBeCanceled) + TimerDelayState state = new TimerDelayState(ct); + Timer timer = new Timer(state.CompleteFromTimer, null, milliseconds, Timeout.Infinite); + state.AssignTimer(timer); + + if (ct.CanBeCanceled) { - cancellationToken.Register(() => - { - timer?.Dispose(); - timer = null; - tcs.TrySetCanceled(); - }); + CancellationTokenRegistration registration = ct.Register(state.CancelFromToken); + state.AssignRegistration(registration); } - return tcs.Task; + return state.Task; } /// - /// Wait for the specified number of milliseconds, then execute action on main thread + /// Waits for wall-clock time, then executes an action after the next Editor update. /// /// Milliseconds to wait /// Action to execute on main thread - /// Cancellation token + /// Cancellation token /// Awaitable task - public static Task WaitThenExecuteOnMainThread(int milliseconds, Action action, CancellationToken cancellationToken = default) + public static async Task WaitThenExecuteOnMainThread(int milliseconds, Action action, CancellationToken ct = default) { if (action == null) + { throw new ArgumentNullException(nameof(action)); + } - if (milliseconds <= 0) + SynchronizationContext synchronizationContext = SynchronizationContext.Current; + Debug.Assert( + synchronizationContext != null, + "WaitThenExecuteOnMainThread must start from Unity's main-thread synchronization context."); + if (synchronizationContext == null) { - EditorApplication.delayCall += () => action(); - return Task.CompletedTask; + throw new InvalidOperationException( + "WaitThenExecuteOnMainThread must start from Unity's main-thread synchronization context."); } - TaskCompletionSource tcs = new(); - - Timer timer = null; - timer = new Timer(_ => + await Wait(milliseconds, ct); + await EditorFrameWaiter.WaitFramesAsync(1, ct); + action(); + } + } + + /// + /// Owns Timer and cancellation registration disposal for one wall-clock wait. + /// + internal sealed class TimerDelayState + { + private readonly TaskCompletionSource _completionSource = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly CancellationToken _ct; + private Timer _timer; + private CancellationTokenRegistration _registration; + private int _isCompleted; + + public TimerDelayState(CancellationToken ct) + { + _ct = ct; + } + + public Task Task => _completionSource.Task; + + public void AssignTimer(Timer timer) + { + _timer = timer; + DisposeTimerIfAlreadyCompleted(timer); + } + + public void AssignRegistration(CancellationTokenRegistration registration) + { + _registration = registration; + if (Interlocked.CompareExchange(ref _isCompleted, 0, 0) != 0) + { + registration.Dispose(); + } + } + + public void CompleteFromTimer(object state) + { + if (Interlocked.Exchange(ref _isCompleted, 1) != 0) + { + return; + } + + _timer?.Dispose(); + _registration.Dispose(); + _completionSource.TrySetResult(true); + } + + public void CancelFromToken() + { + if (Interlocked.Exchange(ref _isCompleted, 1) != 0) { - timer?.Dispose(); - timer = null; - if (!cancellationToken.IsCancellationRequested) - { - EditorApplication.delayCall += () => - { - try - { - action(); - tcs.SetResult(true); - } - catch (Exception ex) - { - tcs.SetException(ex); - } - }; - } - else - { - tcs.SetCanceled(); - } - }, null, milliseconds, Timeout.Infinite); - - // Handle cancellation - if (cancellationToken.CanBeCanceled) + return; + } + + _timer?.Dispose(); + _registration.Dispose(); + _completionSource.TrySetCanceled(_ct); + } + + private void DisposeTimerIfAlreadyCompleted(Timer timer) + { + if (Interlocked.CompareExchange(ref _isCompleted, 0, 0) == 0) { - cancellationToken.Register(() => - { - timer?.Dispose(); - timer = null; - tcs.TrySetCanceled(); - }); + return; } - return tcs.Task; + timer.Dispose(); } } -} \ No newline at end of file +}