File: DocumentExtractionCleanupTests.cs
Web Access
Project: src\test\Libraries\Microsoft.Extensions.DocumentExtraction.Abstractions.Tests\Microsoft.Extensions.DocumentExtraction.Abstractions.Tests.csproj (Microsoft.Extensions.DocumentExtraction.Abstractions.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DocumentExtraction;
using Microsoft.Extensions.Documents;
using Xunit;
 
namespace Microsoft.Extensions.DocumentExtraction.Tests;
 
public sealed class DocumentExtractionCleanupTests
{
    [Theory]
    [InlineData(false, false)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    [InlineData(true, true)]
    public async Task Adapter_FailureOrCancellationCleansProviderStreamsAndKeepsPrimaryFirst(bool cancel, bool throwingCleanup)
    {
        using MemoryStream input = new([42, 43]);
        using Native native = new();
        using CancellationTokenSource cancellation = new();
        IOException firstFailure = new("first body cleanup");
        IOException lastFailure = new("last body cleanup");
        using CleanupStream first = new(throwingCleanup ? firstFailure : null);
        using CleanupStream middle = new();
        using CleanupStream last = new(throwingCleanup ? lastFailure : null);
        InvalidOperationException primary = new("provider mapping failed");
        using SemaphoreSlim acquired = new(0, 1);
        using SemaphoreSlim release = new(0, 1);
        using PartialAdapter adapter = new([first, middle, last], native, primary, acquired, release);
 
        Exception actual = await Assert.ThrowsAnyAsync<Exception>(async () =>
        {
            Task<ExtractionDocument> pending = adapter.ExtractAsync(input, "partial", "application/pdf",
                new DocumentExtractionOptions { }, cancellation.Token);
            await acquired.WaitAsync();
            Assert.False(pending.IsCompleted);
            Assert.Equal(3, adapter.AcquireCount);
            Assert.True(first.CanRead);
            Assert.True(middle.CanRead);
            Assert.True(last.CanRead);
            if (cancel)
            {
                cancellation.Cancel();
            }
 
            release.Release();
            await pending;
        });
        Exception actualPrimary = actual;
        if (throwingCleanup)
        {
            AggregateException aggregate = Assert.IsType<AggregateException>(actual).Flatten();
            Assert.Equal(3, aggregate.InnerExceptions.Count);
            actualPrimary = aggregate.InnerExceptions[0];
            Assert.Same(firstFailure, aggregate.InnerExceptions[1]);
            Assert.Same(lastFailure, aggregate.InnerExceptions[2]);
        }
 
        if (cancel)
        {
            Assert.Equal(cancellation.Token, Assert.IsAssignableFrom<OperationCanceledException>(actualPrimary).CancellationToken);
        }
        else
        {
            Assert.Same(primary, actualPrimary);
        }
 
        Assert.Equal(cancellation.Token, adapter.Token);
        Assert.Equal(1, first.DisposeCount);
        Assert.Equal(1, middle.DisposeCount);
        Assert.Equal(1, last.DisposeCount);
        Assert.False(first.CanRead);
        Assert.False(middle.CanRead);
        Assert.False(last.CanRead);
        adapter.Dispose();
        Assert.Equal(1, first.DisposeCount);
        Assert.Equal(0, native.DisposeCount);
        Assert.Equal(0, native.ToStringCount);
        Assert.Equal(42, input.ReadByte());
    }
 
    [Fact]
    public async Task Adapter_PreCanceledCallAcquiresNothing()
    {
        using MemoryStream input = new([42]);
        using Native native = new();
        using CancellationTokenSource cancellation = new();
        cancellation.Cancel();
        using CleanupStream stream = new();
        using SemaphoreSlim acquired = new(0, 1);
        using SemaphoreSlim release = new(0, 1);
        using PartialAdapter adapter = new([stream], native, new InvalidOperationException("unused"), acquired, release);
 
        OperationCanceledException error = await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
            adapter.ExtractAsync(input, "document", "application/pdf", cancellationToken: cancellation.Token));
 
        Assert.Equal(cancellation.Token, error.CancellationToken);
        Assert.Equal(0, adapter.AcquireCount);
        Assert.Equal(0, acquired.CurrentCount);
        Assert.Equal(0, stream.DisposeCount);
        Assert.True(stream.CanRead);
        Assert.Equal(0, native.DisposeCount);
        Assert.Equal(42, input.ReadByte());
    }
 
    // This fake models an adapter's responsibility, not middleware or a core body collector.
    // Resources belong to the adapter's operation, never to the data result.
    private sealed class PartialAdapter(CleanupStream[] bodies, Native native, Exception primary, SemaphoreSlim acquired, SemaphoreSlim release) : DocumentExtractionClient
    {
        internal int AcquireCount { get; private set; }
        internal CancellationToken Token { get; private set; }
 
        [SuppressMessage("Design", "CA1031:Do not catch general exception types",
            Justification = "Attempts cleanup of every resource before reporting primary and cleanup failures together.")]
        public override async Task<ExtractionDocument> ExtractAsync(Stream document, string identifier, string mediaType,
            DocumentExtractionOptions? options = null, CancellationToken cancellationToken = default)
        {
            Token = cancellationToken;
            cancellationToken.ThrowIfCancellationRequested();
            ExtractionDocument partial = new(identifier) { RawRepresentation = native };
            try
            {
                foreach (CleanupStream body in bodies)
                {
                    AcquireCount++;
                }
 
                acquired.Release();
                Assert.Same(native, partial.RawRepresentation);
                Assert.Empty(partial.Sections);
 
                // Cancellation is observed explicitly after the deterministic acquisition gate.
                await release.WaitAsync(CancellationToken.None).ConfigureAwait(false);
                cancellationToken.ThrowIfCancellationRequested();
                throw primary;
            }
            catch (Exception failure)
            {
                List<Exception> failures = [failure];
                for (int i = 0; i < AcquireCount; i++)
                {
                    try
                    {
                        bodies[i].Dispose();
                    }
                    catch (Exception cleanup)
                    {
                        failures.Add(cleanup);
                    }
                }
 
                if (failures.Count > 1)
                {
                    throw new AggregateException(failures);
                }
 
                throw;
            }
        }
    }
 
    private sealed class Native : IDisposable
    {
        internal int DisposeCount { get; private set; }
        internal int ToStringCount { get; private set; }
        public void Dispose() => DisposeCount++;
        public override string ToString()
        {
            ToStringCount++;
            return "NATIVE_MUST_NOT_BE_INSPECTED";
        }
    }
 
    private sealed class CleanupStream(Exception? failure = null) : MemoryStream(new byte[] { 17, 23 })
    {
        internal int DisposeCount { get; private set; }
 
        protected override void Dispose(bool disposing)
        {
            if (DisposeCount != 0)
            {
                return;
            }
 
            if (disposing)
            {
                DisposeCount++;
            }
 
            base.Dispose(disposing);
            if (disposing && failure is not null)
            {
                throw failure;
            }
        }
    }
}