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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions IntervalAction.Test/IntervalActionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
{
Interlocked.Increment(ref executions);
// Simulate a long running task.
Thread.Sleep(500);

Check warning on line 49 in IntervalAction.Test/IntervalActionTests.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not use 'Thread.Sleep()' in a test.

Check warning on line 49 in IntervalAction.Test/IntervalActionTests.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not use 'Thread.Sleep()' in a test.

Check warning on line 49 in IntervalAction.Test/IntervalActionTests.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not use 'Thread.Sleep()' in a test.

Check warning on line 49 in IntervalAction.Test/IntervalActionTests.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not use 'Thread.Sleep()' in a test.
},
IntervalType = IntervalType.FromLastStart
};
Expand Down Expand Up @@ -582,6 +582,55 @@
Assert.AreSame(pollingTask, finished, "The polling loop should fault after the action throws.");
}

[TestMethod]
public async Task RethrowExceptionsReportsActionThrowingTaskCanceledException()
{
// Arrange: HttpClient reports a timeout as TaskCanceledException, which is an
// OperationCanceledException, and an async method that ends with one is Canceled, not Faulted
IntervalActionOptions options = new()
{
PollingInterval = TimeSpan.FromMilliseconds(10),
ActionInterval = TimeSpan.Zero,
Action = () => throw new TaskCanceledException("http timeout"),
IntervalType = IntervalType.FromLastStart
};
IntervalAction intervalAction = IntervalAction.Start(options);
await WaitForPollingToFaultAsync(intervalAction).ConfigureAwait(false);

// Act
TaskCanceledException exception = Assert.ThrowsExactly<TaskCanceledException>(intervalAction.RethrowExceptions);
intervalAction.Stop();

// Assert
Assert.AreEqual("http timeout", exception.Message);
}

[TestMethod]
public async Task RethrowExceptionsReportsAsyncActionThrowingTaskCanceledException()
{
// Arrange: the action's own timeout, not a cancellation that Stop() asked for
IntervalActionOptions options = new()
{
PollingInterval = TimeSpan.FromMilliseconds(10),
ActionInterval = TimeSpan.Zero,
AsyncAction = async _ =>
{
await Task.Yield();
throw new TaskCanceledException("http timeout");
},
IntervalType = IntervalType.FromLastStart
};
IntervalAction intervalAction = IntervalAction.Start(options);
await WaitForPollingToFaultAsync(intervalAction).ConfigureAwait(false);

// Act
TaskCanceledException exception = Assert.ThrowsExactly<TaskCanceledException>(intervalAction.RethrowExceptions);
intervalAction.Stop();

// Assert
Assert.AreEqual("http timeout", exception.Message);
}

private static void ThrowFromNamedMethod() => throw new InvalidOperationException("Thrown from a named method");

[TestMethod]
Expand Down
42 changes: 38 additions & 4 deletions IntervalAction/IntervalAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -384,10 +384,10 @@ internal bool TryRun()
{
if (ActionTask?.IsCompleted ?? false)
{
if (ActionTask.Exception is not null)
if (GetActionFailure(ActionTask) is { } failure)
{
// Rethrow through ExceptionDispatchInfo so the trace still shows where the action failed
ExceptionDispatchInfo.Capture(ActionTask.Exception.GetBaseException()).Throw();
ExceptionDispatchInfo.Capture(failure).Throw();
}

ActionTask = null;
Expand Down Expand Up @@ -469,6 +469,40 @@ private async Task RunAsyncAction(Func<CancellationToken, Task> asyncAction, Can
}
}

/// <summary>
/// Returns the exception a finished action run failed with, or <see langword="null"/> if it succeeded.
/// </summary>
/// <param name="actionTask">A completed <see cref="ActionTask"/>.</param>
/// <returns>The exception the action threw, or <see langword="null"/>.</returns>
/// <remarks>
/// An action that throws an <see cref="OperationCanceledException"/>, such as the
/// <see cref="TaskCanceledException"/> HttpClient reports a timeout with, leaves an asynchronous
/// run Canceled rather than Faulted, with no <see cref="Task.Exception"/>. Nothing cancels a run on
/// purpose: a run that <see cref="Stop"/> cancels ends normally. So a Canceled run is a failure,
/// and awaiting it recovers the exception the action threw.
/// </remarks>
private static Exception? GetActionFailure(Task actionTask)
{
if (actionTask.IsFaulted)
{
return actionTask.Exception?.GetBaseException();
}

if (actionTask.IsCanceled)
{
try
{
actionTask.GetAwaiter().GetResult();
}
catch (OperationCanceledException exception)
{
return exception;
}
}

return null;
}

/// <summary>
/// Reports whether <see cref="ActionInterval"/> has passed since the last run, measured on a
/// monotonic clock. Callers hold <see cref="Lock"/>.
Expand Down Expand Up @@ -516,8 +550,8 @@ public void RethrowExceptions()
{
lock (Lock)
{
exception = ActionTask is { IsFaulted: true } faultedAction
? faultedAction.Exception?.GetBaseException()
exception = ActionTask is { IsCompleted: true } finishedAction
? GetActionFailure(finishedAction)
: null;
}
}
Expand Down
Loading