diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InputWaiter.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InputWaiter.cs index d50f284f480..18185b9f4d6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InputWaiter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InputWaiter.cs @@ -33,10 +33,24 @@ public void SignalInput() } } - public Task WaitForInputAsync(CancellationToken cancellationToken = default) => this.WaitForInputAsync(null, cancellationToken); + /// + /// Waits until input is signaled. This wait never expires; it completes only when + /// is called or is cancelled. + /// + /// A token to cancel the wait. + public Task WaitForInputAsync(CancellationToken cancellationToken = default) => this._inputSignal.WaitAsync(cancellationToken); - public async Task WaitForInputAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) + /// + /// Waits until input is signaled or expires. + /// + /// The maximum time to wait for input. + /// A token to cancel the wait. + /// + /// if the wait was released by ; + /// if expired first. + /// + public async Task WaitForInputAsync(TimeSpan timeout, CancellationToken cancellationToken = default) { - await this._inputSignal.WaitAsync(timeout ?? TimeSpan.FromMilliseconds(-1), cancellationToken).ConfigureAwait(false); + return await this._inputSignal.WaitAsync(timeout, cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterTests.cs index 512fc71b230..495aa888f26 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterTests.cs @@ -10,6 +10,12 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests; public sealed class InputWaiterTests : IDisposable { + /// + /// Liveness backstop for waits that are expected to complete. Never the thing under test: + /// it is set far above the timeouts being exercised so a regression fails instead of hanging. + /// + private static readonly TimeSpan s_guardTimeout = TimeSpan.FromSeconds(30); + private readonly InputWaiter _waiter = new(); public void Dispose() @@ -21,39 +27,37 @@ public void Dispose() [Fact] public async Task InputWaiter_WaitForInputAsync_CompletesAfterSignalAsync() { + // Arrange this._waiter.SignalInput(); - // WaitForInputAsync should complete immediately since input was already signaled - Task waitTask = this._waiter.WaitForInputAsync(CancellationToken.None); - Task completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(1))); + // Act + bool signaled = await this._waiter.WaitForInputAsync(s_guardTimeout); - completed.Should().BeSameAs(waitTask, "the wait task should complete before the timeout"); - await waitTask; + // Assert + signaled.Should().BeTrue("the already-signaled input should release the wait"); } [Fact] public async Task InputWaiter_WaitForInputAsync_BlocksUntilSignaledAsync() { - // Use the no-timeout overload so that the wait can only be released by SignalInput. - // A finite timeout would make this test's logic racy: the component correctly - // honors the timeout, but if the test thread is starved of CPU time (CI load, - // GC pause) long enough for the timeout to fire, waitTask completes before - // SignalInput is called and the "should not complete before signaled" assertion - // flakes. Timeout behavior is covered separately below. - Task waitTask = this._waiter.WaitForInputAsync(CancellationToken.None); + // Arrange - the no-timeout overload is used so that only SignalInput can release the wait. + using CancellationTokenSource guard = new(); + Task waitTask = this._waiter.WaitForInputAsync(guard.Token); + // Assert - the waiter stays blocked while no input has been signaled. Task completedBeforeSignal = await Task.WhenAny(waitTask, Task.Delay(100)); completedBeforeSignal.Should().NotBeSameAs( waitTask, "the waiter should not complete before input is signaled"); + // Act this._waiter.SignalInput(); - Task completedAfterSignal = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(1))); - completedAfterSignal.Should().BeSameAs( - waitTask, - "the wait task should complete after being signaled"); + // Armed only once the signal has released the wait, since cancelling a still-pending + // waiter would fail the test rather than guard it. + guard.CancelAfter(s_guardTimeout); + // Assert - completion alone proves the signal released the wait. await waitTask; } @@ -98,33 +102,34 @@ public async Task InputWaiter_WaitForInputAsync_DoesNotCompleteWhenNotSignaledAs [Fact] public async Task InputWaiter_WaitForInputAsync_CanBeSignaledMultipleTimesSequentiallyAsync() { - // First signal/wait cycle + // Arrange / Act - first signal/wait cycle this._waiter.SignalInput(); - await this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(1)); + bool firstSignaled = await this._waiter.WaitForInputAsync(s_guardTimeout); - // Second signal/wait cycle + // Arrange / Act - second signal/wait cycle this._waiter.SignalInput(); - await this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(1)); + bool secondSignaled = await this._waiter.WaitForInputAsync(s_guardTimeout); + + // Assert each cycle was released by its signal rather than by an expiring timeout. + firstSignaled.Should().BeTrue("the first signal should release the first wait"); + secondSignaled.Should().BeTrue("the second signal should release the second wait"); } - [Fact(Skip = "Flaky in merge_group; see https://github.com/microsoft/agent-framework/issues/7360")] + [Fact] public async Task InputWaiter_WaitForInputAsync_CompletesWhenTimeoutExpiresAsync() { - // Verify that a finite timeout releases the block even without a signal. - // We only assert that it *does* complete (within a generous outer bound); - // we intentionally do not assert that it stays blocked until the timeout, - // because that would re-introduce the same wall-clock flakiness - // described in BlocksUntilSignaledAsync (see comment on that test). - // - // The generous outer bound is itself not generous enough: on the loaded - // net472/windows-latest leg, thread pool starvation can delay the 300ms - // continuation past the 5s guard, so Task.Delay wins the race and the - // identity assertion below fails. Quarantined until the assertion is - // rewritten to not depend on scheduling latency. - Task waitTask = this._waiter.WaitForInputAsync(TimeSpan.FromMilliseconds(300)); - - Task completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(5))); - completed.Should().BeSameAs(waitTask, "the wait task should complete once the timeout expires"); - await waitTask; + // Arrange - nothing signals this waiter, so an expiring timeout is the only thing that + // can release the wait, and the returned flag proves which one did. The guard only + // bounds a wait that never returns; it is not part of the assertion. + using CancellationTokenSource guard = new(); + + // Act + Task waitTask = this._waiter.WaitForInputAsync(TimeSpan.FromMilliseconds(300), guard.Token); + guard.CancelAfter(s_guardTimeout); + + bool signaled = await waitTask; + + // Assert + signaled.Should().BeFalse("the wait should be released by the expiring timeout rather than by a signal"); } }