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: 35 additions & 5 deletions src/CanKit.Pro.IsoTp/IsoTpChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -677,15 +677,45 @@ private async Task RunReaderAsync()
{
return; // expected on Dispose
}
catch (Exception ex)
catch (Exception ex) when (ex is not OutOfMemoryException
Comment thread
dborgards marked this conversation as resolved.
and not StackOverflowException
and not AccessViolationException
and not AppDomainUnloadedException
and not BadImageFormatException
and not CannotUnloadAppDomainException
and not ThreadAbortException)
{
lost = ex;
}
catch (Exception ex) when (ex is OutOfMemoryException
or StackOverflowException
or AccessViolationException
or AppDomainUnloadedException
or BadImageFormatException
or CannotUnloadAppDomainException
or ThreadAbortException)
{
// The filter above is what keeps the generic catch closed, so these used to leave
// this method entirely. This method is the reader task. Nothing else observes that
// task except Dispose, and Dispose swallows the wait, so the inbox loss was never
// published and a ReceiveAsync already waiting never woke. Publish first, then
// rethrow: the post is queued before the task faults, and the task still faults.
// A stack overflow the runtime detects itself is not delivered here (the process
// ends), and ThreadAbortException is not thrown on this runtime. An
// OutOfMemoryException from the pump is delivered, and has to take this path.
PublishSubscriptionLoss(ex);
throw;
}

// Nothing will ever arrive again. A receiver waiting on the inbox would wait for ever, so
// the reason is recorded and the receivers are woken: what is buffered is delivered first,
// and then every receiver -- not just one -- gets the failure. Sends already fail on their
// own, the bus refuses them. On the actor, like everything else that touches the inbox.
PublishSubscriptionLoss(lost);
}

// Nothing will ever arrive again. A receiver waiting on the inbox would wait for ever, so
// the reason is recorded and the receivers are woken: what is buffered is delivered first,
// and then every receiver -- not just one -- gets the failure. Sends already fail on their
// own, the bus refuses them. On the actor, like everything else that touches the inbox.
private void PublishSubscriptionLoss(Exception lost)
{
try
{
// Under the pump lock: a caller that is pumping the subscription right now -- it took
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1105,6 +1105,58 @@ public async Task A_Failing_Subscription_Ends_The_Inbox_With_Its_Failure()
lock (reported) reported.Should().ContainSingle().Which.Message.Should().Be("the demux broke");
}

// The reader's generic-catch filter does not handle these. They are still reader failures:
// a receive that is already waiting, and one that starts afterwards, both have to observe
// the same exception, and it has to be raised once. The subscription throws the instance,
// so a passing test is the inbox-loss signal, not a stand-in for it. StackOverflowException
// and AccessViolationException are left out: an explicit throw is catchable, but a real
// stack overflow aborts the process. ThreadAbortException cannot be constructed here.
[Theory]
[InlineData(nameof(OutOfMemoryException))]
[InlineData(nameof(BadImageFormatException))]
[InlineData(nameof(AppDomainUnloadedException))]
[InlineData(nameof(CannotUnloadAppDomainException))]
public async Task An_Excluded_Reader_Failure_Still_Wakes_Waiting_And_Later_Receives(string kind)
{
var fault = ExcludedReaderFailure(kind);
var service = new StarvedReaderBusService { ReaderFault = fault };
using var actor = new ProtocolActor();
using var channel = new IsoTpChannel(service, IsoTpEndpoint.Normal(0x123, 0x321), FastOptions(),
ownsService: false, actor);
var reported = new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
channel.BackgroundExceptionOccurred += (_, ex) => reported.TrySetResult(ex);

var first = channel.ReceiveAsync();
var second = channel.ReceiveAsync();
service.WakeReader();

Func<Task> firstAct = () => first.WaitAsync(ShortTimeout);
(await firstAct.Should().ThrowAsync<Exception>()).Which.Should().BeSameAs(fault);
Func<Task> secondAct = () => second.WaitAsync(ShortTimeout);
(await secondAct.Should().ThrowAsync<Exception>()).Which.Should().BeSameAs(fault);

(await reported.Task.WaitAsync(ShortTimeout)).Should().BeSameAs(fault);

Func<Task> later = () => channel.ReceiveAsync().WaitAsync(ShortTimeout);
(await later.Should().ThrowAsync<Exception>()).Which.Should().BeSameAs(fault);

// Publishing the loss must not swallow the failure: the reader task still faults
// with the same instance. WaitAsync observes that fault, or times out if it was swallowed.
var reader = (Task)typeof(IsoTpChannel).GetField("_readerTask",
BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(channel)!;
Func<Task> readerFaulted = () => reader.WaitAsync(ShortTimeout);
(await readerFaulted.Should().ThrowAsync<Exception>()).Which.Should().BeSameAs(fault);
}

private static Exception ExcludedReaderFailure(string kind) => kind switch
{
nameof(OutOfMemoryException) => new OutOfMemoryException("the reader failed"),
nameof(BadImageFormatException) => new BadImageFormatException("the reader failed"),
nameof(AppDomainUnloadedException) => new AppDomainUnloadedException("the reader failed"),
nameof(CannotUnloadAppDomainException) => new CannotUnloadAppDomainException("the reader failed"),
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Not an excluded reader failure under test."),
};

// A reassembly under way dies with the subscription: it is withdrawn, so
// GetReceptionsInProgress does not keep reporting a transfer nobody will complete.
[Fact]
Expand Down
Loading