¿Debería DisposeAsync lanzar excepciones de tareas en segundo plano o dejar que el cliente las observe explícitamente?

No creo que esta pregunta sea un duplicado de "Forma correcta de tratar las excepciones en DisposeAsync" .

Digamos mi clase que implementa IAsynsDisposableporque tiene una tarea en segundo plano de larga ejecución y DisposeAsyncfinaliza esa tarea. Un patrón familiar podría ser la Completionpropiedad, por ejemplo ChannelReader<T>.Completion(a pesar de ChannelReaderque no implementa IAsynsDisposable).

¿Se considera una buena práctica propagar las Completionexcepciones de la tarea al exterior DisposeAsync?

Aquí hay un ejemplo completo que se puede copiar/pegar en un dotnet new consoleproyecto. Nota await this.Completioninterior DisposeAsync:

try
{
    await using var service = new BackgroundService(TimeSpan.FromSeconds(2));
    await Task.Delay(TimeSpan.FromSeconds(3));
}
catch (Exception ex)
{
    Console.WriteLine(ex);
    Console.ReadLine();
}

class BackgroundService: IAsyncDisposable
{
    public Task Completion { get; }

    private CancellationTokenSource _diposalCts = new();

    public BackgroundService(TimeSpan timeSpan)
    {
        this.Completion = Run(timeSpan);
    }

    public async ValueTask DisposeAsync()
    {
        _diposalCts.Cancel();
        try
        {
            await this.Completion;
        }
        finally
        {
            _diposalCts.Dispose();
        }
    }

    private async Task Run(TimeSpan timeSpan)
    {
        try
        {
            await Task.Delay(timeSpan, _diposalCts.Token);
            throw new InvalidOperationException("Boo!");
        }
        catch (OperationCanceledException)
        {
        }
    }
}

Alternativamente, puedo observar service.Completionexplícitamente en el código del cliente (e ignorar sus excepciones internas DiposeAsyncpara evitar que se arrojen dos veces), como a continuación:

try
{
    await using var service = new BackgroundService(TimeSpan.FromSeconds(2));
    await Task.Delay(TimeSpan.FromSeconds(3));
    await service.Completion;
}
catch (Exception ex)
{
    Console.WriteLine(ex);
    Console.ReadLine();
}

class BackgroundService: IAsyncDisposable
{
    public Task Completion { get; }

    private CancellationTokenSource _diposalCts = new();

    public BackgroundService(TimeSpan timeSpan)
    {
        this.Completion = Run(timeSpan);
    }

    public async ValueTask DisposeAsync()
    {
        _diposalCts.Cancel();
        try
        {
            await this.Completion;
        }
        catch
        {
            // the client should observe this.Completion
        }
        finally
        {
            _diposalCts.Dispose();
        }
    }

    private async Task Run(TimeSpan timeSpan)
    {
        try
        {
            await Task.Delay(timeSpan, _diposalCts.Token);
            throw new InvalidOperationException("Boo!");
        }
        catch (OperationCanceledException)
        {
        }
    }
}

¿Hay consenso sobre qué opción es mejor?

Answer

Por ahora, me he decidido por una clase de ayudante reutilizable LongRunningAsyncDisposable( aquí hay una esencia , advertencia: apenas probada todavía), que permite:

  • para iniciar una tarea en segundo plano;
  • detenga esta tarea (a través de un token de cancelación) llamando IAsyncDisposable.DisposeAsyncen cualquier momento, de una manera segura para subprocesos y amigable con la concurrencia ;
  • configure si DisposeAsyncdebe volver a lanzar las excepciones de la tarea ( DisposeAsyncesperará la finalización de la tarea de cualquier manera, antes de realizar una limpieza);
  • observe el estado de la tarea, el resultado y las excepciones en cualquier momento a través de LongRunningAsyncDisposable.Completionla propiedad.