Why XUnit deadlocks if I fire and forget socket.Listen() from async void Method?

57 Views Asked by At

I've found a strange behavior that I cannot understand.

The code below is a reduction of the problem. The private async call method Listen() is normally a Method of an object that is called in a Test, and the object is then disposed in the Dispose of the Test. Here I put it internally to reduce the size of the code.

If I run the test, it will deadlock after completion, and never enter the TestDeadLock.Dispose().

If I do a using() in the Test there won't be any problem because the socket will be disposed and the async called dropped. However I want to understand the situation, and I prefer factorizing the Dispose() and object instantiation.

using Xunit;
using System.Net;
using System;
using System.Net.Sockets;
using System.Threading.Tasks;

#if NET6_0_OR_GREATER

namespace Tests
{
    // Avoid paralellizing socket tests
    [Collection("SocketTests")]
    public class TestDeadlock : IDisposable
    {
        private readonly Socket _socket;
        private bool _disposed;

        public TestDeadlock()
        {
            _socket = new Socket(SocketType.Stream, ProtocolType.IP);
            _socket.Bind(new IPEndPoint(IPAddress.Loopback, 10000));
            _socket.Listen();
        }

        // in my code this method is a method of the Object that is instanced and disposed by the test lib
        private async void Listen()
        {
            var client = await _socket.AcceptAsync();
        }
        
        [Fact]
        public void GoDeadLock()
        {
            Listen();
            Assert.True(true);
        }

        // this does not dead locks the test
        private async Task ListenNoDeadLock()
        {
            var client = await _socket.AcceptAsync();
        }
        
        [Fact]
        public void WorkButGivesWarning()
        {
            ListenNoDeadLock(); // Task not awaited => warning
            Assert.True(true);
        }

        protected virtual void Dispose(bool disposing)
        {
            // This is never reached. XUnits blocks just after exiting the Fact
            if (!_disposed)
            {
                if (disposing)
                {
                    _socket.Dispose();
                }

                _disposed = true;
            }
        }

        public void Dispose()
        {
            // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
            Dispose(disposing: true);
            GC.SuppressFinalize(this);
        }
    }
}

#endif

However, if I change the Listen method from async void to async Task (and still fire and forget it)... it works!

But I don't want this method to return a task, because I want it to just start an asynchronous operation in the background.

I know I can just do a Task.Run in listen, but I feel it will use a thread just to reach the await and drop it.

Would you have an explanation and some advises about a clean way of implementing this ?

EDIT: I think I found a clean way to implement the Fire and Forget, handling exceptions, and not deadlocking XUnits:

    // better solution
    private void GoodFireAndForget()
    {
        Task.Run( async () => 
        {
            var client = await _socket.AcceptAsync();

        }).ContinueWith((task) =>
        {
            task.Exception.Handle((e) =>
                {
                    // handle exceptions
                    return true;
                });
        }, TaskContinuationOptions.OnlyOnFaulted);
    }

    [Fact]
    public void WorksAndSeemsTheGoodWay()
    {
        GoodFireAndForget();
        Assert.True(true);
    }
1

There are 1 best solutions below

0
Kicest On BEST ANSWER

I think I have the explanation of the XUnit dead lock thanks to https://www.damirscorner.com/blog/posts/20220415-UsingAsyncVoidInXunitTests.html

According to this link:

[XUnit] can correctly handle asynchronous tests with async void signature thanks to its custom synchronization context. I could not find any documentation on this other than the summary comment in its code:

"This implementation of SynchronizationContext allows the developer to track the count of outstanding async void operations, and wait for them all to complete."

So it looks likte behind the hood XUnit is waiting synchronously for all Async Void Methods to finish. And we know that it may deadlock sometimes. In consequence, any Async Void present in the tested library could block Xunit. I guess that async task are cancelled thanks to the task object (even if I don't understand how).