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