From 065cb2d7e700594f37de5c34bdf0024c0c5ab6dc Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 10 Jun 2026 20:19:13 +0900 Subject: [PATCH 1/5] Fix editor input waits with wall-clock timeouts Replace the old update-driven EditorDelay primitive with a focused EditorFrameWaiter for frame waits, and route timeout-sensitive waits through TimerDelay so tool calls can return even when Editor updates stall. - Add timed-out input simulation outcomes with deferred main-thread cleanup. - Use EditorFrameWaiter for frame-based editor waits and TimerDelay for wall-clock waits. - Update focused editor and input simulation tests for the new wait contracts. --- Assets/Editor/EditorDelayManualTests.cs | 62 +-- Assets/Tests/Editor/EditorDelayTests.cs | 376 ------------------ Assets/Tests/Editor/EditorFrameWaiterTests.cs | 126 ++++++ .../Editor/EditorFrameWaiterTests.cs.meta | 2 +- .../Editor/StaticFacadeStateGuardTests.cs | 2 +- .../Tests/PlayMode/SimulateKeyboardTests.cs | 16 + .../Tests/PlayMode/SimulateMouseInputTests.cs | 1 + .../Application/ApplicationEditorStartup.cs | 2 +- .../Application/SessionRecoveryService.cs | 4 +- .../InputSystem/InputSystemUpdateHelper.cs | 227 +++++++++-- ...artyTools.Common.InputSystem.Editor.asmdef | 1 + .../DynamicCodeDomainReloadWaitSignal.cs | 2 +- .../Screenshot/EditorWindowCaptureUtility.cs | 5 +- .../Screenshot/ScreenshotUseCase.cs | 2 +- .../SimulateKeyboardUseCase.cs | 145 ++++++- .../SimulateMouseInputUseCase.cs | 247 ++++++++++-- .../SimulateMouseUi/SimulateMouseUiUseCase.cs | 14 +- .../src/Editor/ToolContracts/EditorDelay.cs | 95 ----- .../Editor/ToolContracts/EditorDelay.cs.meta | 11 - .../ToolContracts/EditorDelayManager.cs | 205 ---------- .../Editor/ToolContracts/EditorFrameWaiter.cs | 251 ++++++++++++ .../ToolContracts/EditorFrameWaiter.cs.meta | 2 +- .../Editor/ToolContracts/TaskExtensions.cs | 31 +- .../src/Editor/ToolContracts/TimerDelay.cs | 168 ++++---- 24 files changed, 1069 insertions(+), 928 deletions(-) delete mode 100644 Assets/Tests/Editor/EditorDelayTests.cs create mode 100644 Assets/Tests/Editor/EditorFrameWaiterTests.cs rename Packages/src/Editor/ToolContracts/EditorDelayManager.cs.meta => Assets/Tests/Editor/EditorFrameWaiterTests.cs.meta (83%) delete mode 100644 Packages/src/Editor/ToolContracts/EditorDelay.cs delete mode 100644 Packages/src/Editor/ToolContracts/EditorDelay.cs.meta delete mode 100644 Packages/src/Editor/ToolContracts/EditorDelayManager.cs create mode 100644 Packages/src/Editor/ToolContracts/EditorFrameWaiter.cs rename Assets/Tests/Editor/EditorDelayTests.cs.meta => Packages/src/Editor/ToolContracts/EditorFrameWaiter.cs.meta (83%) 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..780040e141 --- /dev/null +++ b/Assets/Tests/Editor/EditorFrameWaiterTests.cs @@ -0,0 +1,126 @@ +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); + } + } +} 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..7f55601d12 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; @@ -752,6 +753,21 @@ public IEnumerator KeyDown_Cancellation_Should_RollBackHeldState() Assert.IsTrue(lastResponse.Success, "Canceled KeyDown cleanup should leave later key-down requests usable."); } + [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); + } + #endregion #region Helpers 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/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs index bc48561f97..8c45081c31 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,33 +34,59 @@ 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 static Func isPausedProvider = () => EditorApplication.isPaused; + private static int applyTimeoutMilliseconds = DefaultApplyTimeoutMilliseconds; + private static int frameObservationTimeoutMilliseconds = DefaultFrameObservationTimeoutMilliseconds; - 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(); CancellationTokenRegistration registration = default; + int isFinished = 0; Action? callback = null; callback = () => { + Debug.Assert(callback != null, "callback must be assigned before subscription"); + if (Interlocked.CompareExchange(ref isFinished, 0, 0) != 0) + { + InputSystem.onBeforeUpdate -= callback; + registration.Dispose(); + return; + } + InputUpdateType currentUpdateType = InputState.currentUpdateType; if (!InputUpdateTypeResolver.IsMatch(currentUpdateType, targetUpdateType)) { return; } - Debug.Assert(callback != null, "callback must be assigned before subscription"); + if (Interlocked.Exchange(ref isFinished, 1) != 0) + { + InputSystem.onBeforeUpdate -= callback; + registration.Dispose(); + return; + } + InputSystem.onBeforeUpdate -= callback; registration.Dispose(); apply(); - tcs.TrySetResult(true); + tcs.TrySetResult(InputSimulationWaitOutcome.Completed); }; InputSystem.onBeforeUpdate += callback; @@ -65,13 +94,23 @@ public static Task ApplyOnNextConfiguredUpdate(Action apply, CancellationToken c { registration = ct.Register(() => { - Debug.Assert(callback != null, "callback must be assigned before cancellation"); - InputSystem.onBeforeUpdate -= callback; + Interlocked.Exchange(ref isFinished, 1); tcs.TrySetCanceled(ct); }); } - return tcs.Task; + Task timeoutTask = TimerDelay.Wait(applyTimeoutMilliseconds, ct); + Task completedTask = await Task.WhenAny(tcs.Task, timeoutTask).ConfigureAwait(false); + if (completedTask == timeoutTask) + { + await timeoutTask.ConfigureAwait(false); + Interlocked.Exchange(ref isFinished, 1); + return InputSimulationWaitOutcome.TimedOut; + } + + InputSimulationWaitOutcome outcome = await tcs.Task.ConfigureAwait(false); + await SwitchToMainThreadIfNeeded(ct); + return outcome; } public static int GetMinimumObservationFrameCount() @@ -97,16 +136,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 +158,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 +208,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 +228,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 +270,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 +284,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 +305,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 +326,117 @@ 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; + } + 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; + } + + Task frameTask = EditorFrameWaiter.WaitFramesAsync(1, ct); + Task timeoutTask = TimerDelay.Wait(remainingMilliseconds, ct); + Task completedTask = await Task.WhenAny(frameTask, timeoutTask).ConfigureAwait(false); + if (completedTask == timeoutTask) + { + await timeoutTask.ConfigureAwait(false); + return InputSimulationWaitOutcome.TimedOut; + } + + 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); + } + } + + /// + /// 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..391a1922af 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs @@ -175,15 +175,25 @@ 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) + { + ScheduleTimedOutPressCleanup(keyboard, key, pressWasApplied); + } + else if (pressWasApplied) { - await ReleaseKeyStateIfPossible(keyboard, key); + await ReleaseKeyStateIfPossible(keyboard, key).ConfigureAwait(false); KeyboardKeyState.UnregisterTransientKey(key); if (waitOutcome == InputSimulationWaitOutcome.Paused) { @@ -191,7 +201,7 @@ private async Task ExecutePress( } else { - await FinalizePressOverlay(ct); + await FinalizePressOverlay(ct).ConfigureAwait(false); } } else @@ -206,6 +216,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 +252,29 @@ 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) + { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + keyDownApplied = true; + 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); + await RollbackHeldKey(keyboard, key, keyName).ConfigureAwait(false); } } @@ -257,6 +283,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 +312,30 @@ private async Task ExecuteKeyUp(Keyboard k }; } - await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate(() => KeyboardKeyState.SetKeyState(keyboard, key, false), ct); + InputSimulationWaitOutcome applyOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + () => KeyboardKeyState.SetKeyState(keyboard, key, false), + ct).ConfigureAwait(false); + if (applyOutcome == InputSimulationWaitOutcome.TimedOut) + { + ScheduleTimedOutHeldKeyCleanup(keyboard, key, keyName, true); + return TimedOutKeyResult(UnityCliLoopKeyboardAction.KeyUp, keyName); + } + + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); 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 +375,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 +419,7 @@ private static void AttachDebugBreakHit(UnityCliLoopKeyboardSimulationResult res private static async Task FinalizePressOverlay(CancellationToken ct) { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); if (ct.IsCancellationRequested) { SimulateKeyboardOverlayState.ClearPress(); @@ -367,18 +427,20 @@ 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) { - await ReleaseKeyStateIfPossible(keyboard, key); + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); + await ReleaseKeyStateIfPossible(keyboard, key).ConfigureAwait(false); KeyboardKeyState.SetKeyUp(key); SimulateKeyboardOverlayState.RemoveHeldKey(keyName); } private static async Task ReleaseKeyStateIfPossible(Keyboard keyboard, Key key) { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); if (!CanInjectKeyboardState(keyboard)) { return; @@ -391,7 +453,52 @@ private static async Task ReleaseKeyStateIfPossible(Keyboard keyboard, Key key) return; } - await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate(() => KeyboardKeyState.SetKeyState(keyboard, key, false), CancellationToken.None); + await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + () => KeyboardKeyState.SetKeyState(keyboard, key, false), + CancellationToken.None).ConfigureAwait(false); + } + + 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/SimulateMouseInput/SimulateMouseInputUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs index 5ae729df61..e4d495e3e4 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,44 @@ 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) { + await ReleaseButtonIfPossible(mouse, button).ConfigureAwait(false); + 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 +242,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 +278,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 +294,47 @@ 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(); + await ReleaseButtonIfPossible(mouse, button).ConfigureAwait(false); + 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 +346,11 @@ await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( inputPos); } + if (waitOutcome == InputSimulationWaitOutcome.TimedOut) + { + return TimedOutButtonResult(UnityCliLoopMouseInputAction.LongPress, buttonName, inputPos); + } + return new UnityCliLoopMouseInputSimulationResult { Success = true, @@ -314,15 +368,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 +407,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 +461,8 @@ private async Task ExecuteSmoothDelta( while (true) { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + float elapsed = Time.realtimeSinceStartup - startTime; float t = Mathf.Clamp01(elapsed / duration); @@ -386,18 +470,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 +502,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 +544,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) @@ -473,6 +598,7 @@ private static void AttachDebugBreakHit(UnityCliLoopMouseInputSimulationResult r private static async Task ReleaseButtonIfPossible(Mouse mouse, RuntimeMouseButton button) { + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); if (!CanInjectMouseState(mouse)) { return; @@ -486,7 +612,8 @@ private static async Task ReleaseButtonIfPossible(Mouse mouse, RuntimeMouseButto } await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( - () => MouseInputState.SetButtonState(mouse, button, false), CancellationToken.None); + () => MouseInputState.SetButtonState(mouse, button, false), + CancellationToken.None).ConfigureAwait(false); } private static void ResetDeltaIfPossible(Mouse mouse) @@ -503,6 +630,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/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/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..4764da3121 --- /dev/null +++ b/Packages/src/Editor/ToolContracts/EditorFrameWaiter.cs @@ -0,0 +1,251 @@ +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(); + 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..5eb2425fa6 100644 --- a/Packages/src/Editor/ToolContracts/TimerDelay.cs +++ b/Packages/src/Editor/ToolContracts/TimerDelay.cs @@ -1,5 +1,4 @@ using System; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using UnityEditor; @@ -7,113 +6,132 @@ 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 queues an action for the next Editor main-thread callback. /// /// 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) + await Wait(milliseconds, ct).ConfigureAwait(false); + + TaskCompletionSource completionSource = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + EditorApplication.delayCall += () => { - EditorApplication.delayCall += () => action(); - return Task.CompletedTask; + action(); + completionSource.TrySetResult(true); + }; + EditorApplication.QueuePlayerLoopUpdate(); + + await completionSource.Task.ConfigureAwait(false); + } + } + + /// + /// 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; } - TaskCompletionSource tcs = new(); - - Timer timer = null; - timer = new Timer(_ => + _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 +} From fd604b896a0c097df946e8c8c9903bf2939bd800 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 10 Jun 2026 20:45:13 +0900 Subject: [PATCH 2/5] update skills --- Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md | 2 +- .../Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md | 4 ++-- .../Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md | 4 ++-- .../src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) 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/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/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md index 8bb19ad246..e5409ce353 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md @@ -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/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md index 3629eeff87..ef5adcc4d0 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md @@ -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. From 659f4077ba98f49dd1036ea65c60aceda01dac48 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 10 Jun 2026 21:24:30 +0900 Subject: [PATCH 3/5] Fix delayed action and input timeout races Ensure delayed main-thread actions fault the returned task when the action throws, and make input apply timeouts win only before an input update starts applying state. Add regression coverage for delayed action exception propagation. --- Assets/Tests/Editor/EditorFrameWaiterTests.cs | 22 +++ .../Tests/PlayMode/SimulateKeyboardTests.cs | 97 +++++++++++- .../InputSystem/InputSystemUpdateHelper.cs | 141 ++++++++++++++---- .../SimulateKeyboardUseCase.cs | 93 +++++++++--- .../SimulateMouseInputUseCase.cs | 73 +++++++-- .../src/Editor/ToolContracts/TimerDelay.cs | 22 +-- 6 files changed, 375 insertions(+), 73 deletions(-) diff --git a/Assets/Tests/Editor/EditorFrameWaiterTests.cs b/Assets/Tests/Editor/EditorFrameWaiterTests.cs index 780040e141..dafafb8c00 100644 --- a/Assets/Tests/Editor/EditorFrameWaiterTests.cs +++ b/Assets/Tests/Editor/EditorFrameWaiterTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections; using System.Collections.Generic; using System.Threading; @@ -122,5 +123,26 @@ public async Task TimerDelay_Wait_CompletesWithoutEditorFrameDependency() 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); + } } } diff --git a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs index 7f55601d12..a03e12ecdd 100644 --- a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs +++ b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs @@ -185,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() { @@ -725,7 +753,8 @@ public IEnumerator KeyDown_Cancellation_Should_RollBackHeldState() { yield return null; - SimulateKeyboardSchema parameters = new() { + SimulateKeyboardSchema parameters = new() + { Action = UnityCliLoopKeyboardAction.KeyDown, Key = "W" }; @@ -753,6 +782,71 @@ 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 WaitForRuntimeFrames_WhenFrameGoalCannotComplete_ShouldReturnTimedOut() { @@ -766,6 +860,7 @@ public IEnumerator WaitForRuntimeFrames_WhenFrameGoalCannotComplete_ShouldReturn 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 diff --git a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs index 8c45081c31..0d5d314aaf 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs @@ -37,9 +37,13 @@ internal static class InputSystemUpdateHelper 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 async Task ApplyOnNextConfiguredUpdate(Action apply, CancellationToken ct) { @@ -55,18 +59,23 @@ public static async Task ApplyOnNextConfiguredUpdate return ApplyOnExplicitUpdate(apply, targetUpdateType, ct); } - TaskCompletionSource tcs = new(); + TaskCompletionSource tcs = + new(TaskCreationOptions.RunContinuationsAsynchronously); CancellationTokenRegistration registration = default; - int isFinished = 0; + int applyWaitState = ApplyWaitStateWaiting; Action? callback = null; + InputSystemUpdateSubscription? subscription = null; callback = () => { Debug.Assert(callback != null, "callback must be assigned before subscription"); - if (Interlocked.CompareExchange(ref isFinished, 0, 0) != 0) + Debug.Assert(subscription != null, "subscription must be assigned before callback invocation"); + if (Interlocked.CompareExchange( + ref applyWaitState, + ApplyWaitStateWaiting, + ApplyWaitStateWaiting) != ApplyWaitStateWaiting) { - InputSystem.onBeforeUpdate -= callback; - registration.Dispose(); + subscription?.Dispose(); return; } @@ -76,41 +85,68 @@ public static async Task ApplyOnNextConfiguredUpdate return; } - if (Interlocked.Exchange(ref isFinished, 1) != 0) + if (Interlocked.CompareExchange( + ref applyWaitState, + ApplyWaitStateApplying, + ApplyWaitStateWaiting) != ApplyWaitStateWaiting) { - InputSystem.onBeforeUpdate -= callback; - registration.Dispose(); + subscription?.Dispose(); return; } - InputSystem.onBeforeUpdate -= callback; - registration.Dispose(); + subscription?.Dispose(); apply(); tcs.TrySetResult(InputSimulationWaitOutcome.Completed); }; - InputSystem.onBeforeUpdate += callback; - if (ct.CanBeCanceled) + subscription = new InputSystemUpdateSubscription(callback); + try { - registration = ct.Register(() => + if (ct.CanBeCanceled) { - Interlocked.Exchange(ref isFinished, 1); - tcs.TrySetCanceled(ct); - }); - } + registration = ct.Register(() => + { + if (Interlocked.CompareExchange( + ref applyWaitState, + ApplyWaitStateFinishedWithoutApply, + ApplyWaitStateWaiting) == ApplyWaitStateWaiting) + { + subscription?.Dispose(); + tcs.TrySetCanceled(ct); + } + }); + } - Task timeoutTask = TimerDelay.Wait(applyTimeoutMilliseconds, ct); - Task completedTask = await Task.WhenAny(tcs.Task, timeoutTask).ConfigureAwait(false); - if (completedTask == timeoutTask) + 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 { - await timeoutTask.ConfigureAwait(false); - Interlocked.Exchange(ref isFinished, 1); - return InputSimulationWaitOutcome.TimedOut; + registration.Dispose(); + subscription?.Dispose(); } - - InputSimulationWaitOutcome outcome = await tcs.Task.ConfigureAwait(false); - await SwitchToMainThreadIfNeeded(ct); - return outcome; } public static int GetMinimumObservationFrameCount() @@ -350,6 +386,8 @@ internal static void ResetTimeoutsForTests() frameObservationTimeoutMilliseconds = DefaultFrameObservationTimeoutMilliseconds; } + internal static int PendingConfiguredUpdateCallbackCount => Volatile.Read(ref pendingConfiguredUpdateCallbackCount); + private static bool IsPaused() { return isPausedProvider(); @@ -366,15 +404,19 @@ private static async Task WaitOneRuntimeFrameOrTimeo return InputSimulationWaitOutcome.TimedOut; } - Task frameTask = EditorFrameWaiter.WaitFramesAsync(1, ct); - Task timeoutTask = TimerDelay.Wait(remainingMilliseconds, ct); + 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; } @@ -390,6 +432,47 @@ private static int GetPressLifetimeTimeoutMilliseconds(float duration) 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; + } + } } /// diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs index 391a1922af..39e8c63273 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs @@ -193,14 +193,21 @@ private async Task ExecutePress( } else if (pressWasApplied) { - await ReleaseKeyStateIfPossible(keyboard, key).ConfigureAwait(false); - KeyboardKeyState.UnregisterTransientKey(key); - if (waitOutcome == InputSimulationWaitOutcome.Paused) + 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 { + KeyboardKeyState.UnregisterTransientKey(key); await FinalizePressOverlay(ct).ConfigureAwait(false); } } @@ -257,8 +264,8 @@ private async Task ExecuteKeyDown(Keyboard ct).ConfigureAwait(false); if (waitOutcome == InputSimulationWaitOutcome.Completed) { - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); keyDownApplied = true; + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); KeyboardKeyState.SetKeyDown(key); SimulateKeyboardOverlayState.AddHeldKey(keyName); waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct) @@ -274,7 +281,12 @@ private async Task ExecuteKeyDown(Keyboard } else if (keyDownApplied && !committed) { - await RollbackHeldKey(keyboard, key, keyName).ConfigureAwait(false); + InputSimulationWaitOutcome rollbackOutcome = + await RollbackHeldKey(keyboard, key, keyName).ConfigureAwait(false); + if (rollbackOutcome == InputSimulationWaitOutcome.TimedOut) + { + waitOutcome = InputSimulationWaitOutcome.TimedOut; + } } } @@ -312,18 +324,19 @@ private async Task ExecuteKeyUp(Keyboard k }; } - InputSimulationWaitOutcome applyOutcome = await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( - () => KeyboardKeyState.SetKeyState(keyboard, key, false), - ct).ConfigureAwait(false); - if (applyOutcome == InputSimulationWaitOutcome.TimedOut) + InputSimulationWaitOutcome releaseOutcome = + await ReleaseKeyStateIfPossible(keyboard, key).ConfigureAwait(false); + + if (releaseOutcome == InputSimulationWaitOutcome.TimedOut) { - ScheduleTimedOutHeldKeyCleanup(keyboard, key, keyName, true); + ScheduleTimedOutHeldKeyCleanup(keyboard, key, keyName, false); return TimedOutKeyResult(UnityCliLoopKeyboardAction.KeyUp, keyName); } - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); KeyboardKeyState.SetKeyUp(key); SimulateKeyboardOverlayState.RemoveHeldKey(keyName); + InputSimulationWaitOutcome waitOutcome = await InputSystemUpdateHelper.WaitForObservationFrames(ct) .ConfigureAwait(false); if (waitOutcome == InputSimulationWaitOutcome.Paused) @@ -419,7 +432,7 @@ private static void AttachDebugBreakHit(UnityCliLoopKeyboardSimulationResult res private static async Task FinalizePressOverlay(CancellationToken ct) { - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(ct); + await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); if (ct.IsCancellationRequested) { SimulateKeyboardOverlayState.ClearPress(); @@ -430,32 +443,72 @@ private static async Task FinalizePressOverlay(CancellationToken ct) 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 InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); - await ReleaseKeyStateIfPossible(keyboard, key).ConfigureAwait(false); + 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()); - return; + ReleaseKeyStateImmediately(keyboard, key); + return InputSimulationWaitOutcome.Completed; } - await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + 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; + } + + KeyboardKeyState.SetKeyState(keyboard, key, false); + InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); } private static void ScheduleTimedOutPressCleanup(Keyboard keyboard, Key key, bool pressWasApplied) diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs index e4d495e3e4..00f31d4285 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/SimulateMouseInputUseCase.cs @@ -211,9 +211,18 @@ private async Task ExecuteClick( } else if (pressWasApplied) { - await ReleaseButtonIfPossible(mouse, button).ConfigureAwait(false); - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); - 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); + } } else { @@ -315,9 +324,18 @@ private async Task ExecuteLongPress( } else if (pressWasApplied) { - await ReleaseButtonIfPossible(mouse, button).ConfigureAwait(false); - await InputSystemUpdateHelper.SwitchToMainThreadIfNeeded(CancellationToken.None); - 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); + } } else { @@ -596,24 +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()); - return; + ReleaseButtonImmediately(mouse, button); + return InputSimulationWaitOutcome.Completed; } - await InputSystemUpdateHelper.ApplyOnNextConfiguredUpdate( + 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; + } + + MouseInputState.SetButtonState(mouse, button, false); + InputSystemUpdateHelper.RunExplicitUpdate(InputUpdateTypeResolver.Resolve()); } private static void ResetDeltaIfPossible(Mouse mouse) diff --git a/Packages/src/Editor/ToolContracts/TimerDelay.cs b/Packages/src/Editor/ToolContracts/TimerDelay.cs index 5eb2425fa6..027ede9852 100644 --- a/Packages/src/Editor/ToolContracts/TimerDelay.cs +++ b/Packages/src/Editor/ToolContracts/TimerDelay.cs @@ -1,7 +1,7 @@ using System; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using UnityEditor; namespace io.github.hatayama.UnityCliLoop.ToolContracts { @@ -51,18 +51,18 @@ public static async Task WaitThenExecuteOnMainThread(int milliseconds, Action ac throw new ArgumentNullException(nameof(action)); } - await Wait(milliseconds, ct).ConfigureAwait(false); - - TaskCompletionSource completionSource = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - EditorApplication.delayCall += () => + SynchronizationContext synchronizationContext = SynchronizationContext.Current; + Debug.Assert( + synchronizationContext != null, + "WaitThenExecuteOnMainThread must start from Unity's main-thread synchronization context."); + if (synchronizationContext == null) { - action(); - completionSource.TrySetResult(true); - }; - EditorApplication.QueuePlayerLoopUpdate(); + throw new InvalidOperationException( + "WaitThenExecuteOnMainThread must start from Unity's main-thread synchronization context."); + } - await completionSource.Task.ConfigureAwait(false); + await Wait(milliseconds, ct); + action(); } } From f17587a00636db489ace61bbd0c57951ecb169eb Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 10 Jun 2026 23:09:43 +0900 Subject: [PATCH 4/5] Cover timer delay SynchronizationContext continuation Add regression coverage showing that WaitThenExecuteOnMainThread resumes its delayed action on the SynchronizationContext captured before the timer wait. This documents the intended behavior behind the PR review discussion without changing production code. --- Assets/Tests/Editor/EditorFrameWaiterTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Assets/Tests/Editor/EditorFrameWaiterTests.cs b/Assets/Tests/Editor/EditorFrameWaiterTests.cs index dafafb8c00..f7cdc4509c 100644 --- a/Assets/Tests/Editor/EditorFrameWaiterTests.cs +++ b/Assets/Tests/Editor/EditorFrameWaiterTests.cs @@ -144,5 +144,34 @@ public IEnumerator WaitThenExecuteOnMainThread_WhenActionThrows_FaultsReturnedTa Assert.IsInstanceOf(exception); Assert.AreEqual("Delayed action failed.", exception.Message); } + + [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); + } } } From e4e409752ab919381a81384439cbb1b9477eede3 Mon Sep 17 00:00:00 2001 From: hatayama Date: Wed, 10 Jun 2026 23:16:21 +0900 Subject: [PATCH 5/5] Harden editor wait continuations and input apply failures Ensure Editor frame waits do not run continuations inline from Editor update, defer zero-duration TimerDelay main-thread actions until an Editor update, and surface Input System apply failures through the awaited task so timeout paths do not wait on orphaned completions. Also align mouse skill workflow text with the Debug Break E2E guidance. --- Assets/Tests/Editor/EditorFrameWaiterTests.cs | 42 +++++++++++++++++++ .../Tests/PlayMode/SimulateKeyboardTests.cs | 29 ++++++++++++- .../InputSystem/InputSystemUpdateHelper.cs | 12 +++++- .../SimulateMouseInput/Skill/SKILL.md | 2 +- .../SimulateMouseUi/Skill/SKILL.md | 2 +- .../Editor/ToolContracts/EditorFrameWaiter.cs | 3 +- .../src/Editor/ToolContracts/TimerDelay.cs | 3 +- 7 files changed, 85 insertions(+), 8 deletions(-) diff --git a/Assets/Tests/Editor/EditorFrameWaiterTests.cs b/Assets/Tests/Editor/EditorFrameWaiterTests.cs index f7cdc4509c..153366090a 100644 --- a/Assets/Tests/Editor/EditorFrameWaiterTests.cs +++ b/Assets/Tests/Editor/EditorFrameWaiterTests.cs @@ -145,6 +145,48 @@ public IEnumerator WaitThenExecuteOnMainThread_WhenActionThrows_FaultsReturnedTa 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() { diff --git a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs index a03e12ecdd..49964d4020 100644 --- a/Assets/Tests/PlayMode/SimulateKeyboardTests.cs +++ b/Assets/Tests/PlayMode/SimulateKeyboardTests.cs @@ -847,6 +847,28 @@ public IEnumerator ApplyOnNextConfiguredUpdate_CancellationBeforeInputUpdate_Sho 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() { @@ -874,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(() => @@ -884,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/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs index 0d5d314aaf..3d41933306 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/InputSystem/InputSystemUpdateHelper.cs @@ -95,8 +95,16 @@ public static async Task ApplyOnNextConfiguredUpdate } subscription?.Dispose(); - apply(); - tcs.TrySetResult(InputSimulationWaitOutcome.Completed); + 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); + } }; subscription = new InputSystemUpdateSubscription(callback); diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md index e5409ce353..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 diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md index ef5adcc4d0..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 diff --git a/Packages/src/Editor/ToolContracts/EditorFrameWaiter.cs b/Packages/src/Editor/ToolContracts/EditorFrameWaiter.cs index 4764da3121..deab286801 100644 --- a/Packages/src/Editor/ToolContracts/EditorFrameWaiter.cs +++ b/Packages/src/Editor/ToolContracts/EditorFrameWaiter.cs @@ -54,7 +54,8 @@ public Task WaitFramesAsync(int frameCount, CancellationToken ct) return Task.CompletedTask; } - TaskCompletionSource completionSource = new TaskCompletionSource(); + TaskCompletionSource completionSource = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); EditorFrameWaitRequest request; lock (_lockObject) { diff --git a/Packages/src/Editor/ToolContracts/TimerDelay.cs b/Packages/src/Editor/ToolContracts/TimerDelay.cs index 027ede9852..bb16b1d6ab 100644 --- a/Packages/src/Editor/ToolContracts/TimerDelay.cs +++ b/Packages/src/Editor/ToolContracts/TimerDelay.cs @@ -38,7 +38,7 @@ public static Task Wait(int milliseconds, CancellationToken ct = default) } /// - /// Waits for wall-clock time, then queues an action for the next Editor main-thread callback. + /// Waits for wall-clock time, then executes an action after the next Editor update. /// /// Milliseconds to wait /// Action to execute on main thread @@ -62,6 +62,7 @@ public static async Task WaitThenExecuteOnMainThread(int milliseconds, Action ac } await Wait(milliseconds, ct); + await EditorFrameWaiter.WaitFramesAsync(1, ct); action(); } }