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
40 changes: 40 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 @@ -322,6 +322,46 @@
StringAssert.Contains(exception.StackTrace, nameof(ThrowFromNamedMethod));
}

[TestMethod]
public async Task RethrowExceptionsReportsAnActionThatThrowsAfterStop()
{
// Arrange: the action blocks until released, so it is still running when Stop() is called
string exceptionMessage = "Thrown after stop";
using ManualResetEventSlim release = new();
using ManualResetEventSlim started = new();
IntervalActionOptions options = new()
{
PollingInterval = TimeSpan.FromMilliseconds(10),
ActionInterval = TimeSpan.FromHours(1),
Action = () =>
{
started.Set();
release.Wait();

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_IntervalAction&issues=AaEL2hwN_0fjMEsTkTX3&open=AaEL2hwN_0fjMEsTkTX3&pullRequest=76
throw new InvalidOperationException(exceptionMessage);
},
IntervalType = IntervalType.FromLastStart
};

IntervalAction intervalAction = IntervalAction.Start(options);
Assert.IsTrue(started.Wait(TimeSpan.FromSeconds(10)), "The action should start.");

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_IntervalAction&issues=AaEL2hwN_0fjMEsTkTX4&open=AaEL2hwN_0fjMEsTkTX4&pullRequest=76

// Act: stop, let the polling loop exit normally, and only then let the action throw, so no
// later tick is left to carry the exception to the polling task
intervalAction.Stop();
Task pollingTask = intervalAction.PollingTask;
Assert.AreSame(pollingTask, await Task.WhenAny(pollingTask, Task.Delay(TimeSpan.FromSeconds(10))).ConfigureAwait(false), "The polling loop should exit after Stop().");

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_IntervalAction&issues=AaEL2hwN_0fjMEsTkTX5&open=AaEL2hwN_0fjMEsTkTX5&pullRequest=76
Assert.IsFalse(pollingTask.IsFaulted, "The polling loop should exit normally.");

release.Set();
Task? actionTask = intervalAction.ActionTask;
Assert.IsNotNull(actionTask, "The action should still be running.");
Assert.AreSame(actionTask, await Task.WhenAny(actionTask, Task.Delay(TimeSpan.FromSeconds(10))).ConfigureAwait(false), "The action should finish once released.");

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_IntervalAction&issues=AaEL2hwN_0fjMEsTkTX6&open=AaEL2hwN_0fjMEsTkTX6&pullRequest=76

// Assert
InvalidOperationException exception = Assert.ThrowsExactly<InvalidOperationException>(intervalAction.RethrowExceptions);
Assert.AreEqual(exceptionMessage, exception.Message);
}

/// <summary>
/// Waits until the polling loop has ended, which for an action that throws is when the loop
/// has observed the fault and <see cref="IntervalAction.RethrowExceptions"/> has something to throw.
Expand Down
24 changes: 22 additions & 2 deletions IntervalAction/IntervalAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -330,11 +330,31 @@ private static TimeSpan GetElapsedTime(long startingTimestamp) =>
/// <summary>
/// Rethrows any exceptions that occurred during the polling task.
/// </summary>
/// <remarks>
/// An action's exception normally reaches <see cref="PollingTask"/> on the tick after the action
/// faults. After <see cref="Stop"/> there is no later tick, so an action that was still running
/// when polling stopped, and then threw, would leave its exception on <see cref="ActionTask"/>
/// where nothing reads it. That is checked here too, so the usual shutdown sequence of
/// <see cref="Stop"/> then <see cref="RethrowExceptions"/> reports a failure from the final run.
/// A restart still discards such an exception, as it does a faulted polling loop.
/// </remarks>
public void RethrowExceptions()
{
if (PollingTask.Exception is not null)
Exception? exception = PollingTask.Exception?.GetBaseException();

if (exception is null)
{
lock (Lock)
{
exception = ActionTask is { IsFaulted: true } faultedAction
? faultedAction.Exception?.GetBaseException()
: null;
}
}

if (exception is not null)
{
ExceptionDispatchInfo.Capture(PollingTask.Exception.GetBaseException()).Throw();
ExceptionDispatchInfo.Capture(exception).Throw();
}
}
}
Loading