diff --git a/src/CanKit.Pro.IsoTp/IsoTpChannel.cs b/src/CanKit.Pro.IsoTp/IsoTpChannel.cs index dc8cf7b..cc590ae 100644 --- a/src/CanKit.Pro.IsoTp/IsoTpChannel.cs +++ b/src/CanKit.Pro.IsoTp/IsoTpChannel.cs @@ -677,15 +677,45 @@ private async Task RunReaderAsync() { return; // expected on Dispose } - catch (Exception ex) + catch (Exception ex) when (ex is not OutOfMemoryException + 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 diff --git a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpChannelIntegrationTests.cs b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpChannelIntegrationTests.cs index c29ab05..e89ae3e 100644 --- a/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpChannelIntegrationTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpChannelIntegrationTests.cs @@ -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(TaskCreationOptions.RunContinuationsAsynchronously); + channel.BackgroundExceptionOccurred += (_, ex) => reported.TrySetResult(ex); + + var first = channel.ReceiveAsync(); + var second = channel.ReceiveAsync(); + service.WakeReader(); + + Func firstAct = () => first.WaitAsync(ShortTimeout); + (await firstAct.Should().ThrowAsync()).Which.Should().BeSameAs(fault); + Func secondAct = () => second.WaitAsync(ShortTimeout); + (await secondAct.Should().ThrowAsync()).Which.Should().BeSameAs(fault); + + (await reported.Task.WaitAsync(ShortTimeout)).Should().BeSameAs(fault); + + Func later = () => channel.ReceiveAsync().WaitAsync(ShortTimeout); + (await later.Should().ThrowAsync()).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 readerFaulted = () => reader.WaitAsync(ShortTimeout); + (await readerFaulted.Should().ThrowAsync()).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]