Skip to content

Writers should have async API#1211

Merged
adamhathcock merged 8 commits intomasterfrom
adam/writers-should-async
Feb 22, 2026
Merged

Writers should have async API#1211
adamhathcock merged 8 commits intomasterfrom
adam/writers-should-async

Conversation

@adamhathcock
Copy link
Owner

This pull request refactors and improves the asynchronous archive and writer APIs throughout the codebase. The main goals are to add proper support for cancellation tokens, return ValueTask for better async performance, and provide optimized file stream helpers for asynchronous reading and writing, especially on .NET 8.0+. These changes make the library's async APIs safer, more consistent, and better performing.

API Consistency and Async Improvements:

  • Updated the IMultiArchiveFactory interface and all implementing classes (e.g., GZipFactory, RarFactory, SevenZipFactory, TarFactory, ZipFactory) so that OpenAsyncArchive now returns a ValueTask<IAsyncArchive> and accepts a CancellationToken. This ensures proper async/cancellation support across all archive types. [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [11] [12]
  • Updated the OpenAsyncWriter methods in writer factories to return ValueTask<IAsyncWriter> and properly support cancellation tokens. [1] [2] [3]

Async File Stream Utilities:

  • Added new utility methods in Utility.Async.cs for opening file streams asynchronously for both reading and writing. These helpers use File.OpenHandle with FileOptions.Asynchronous on .NET 8.0+ for optimal performance and fall back to the legacy FileStream constructor on older frameworks.

Reader Factory Async Improvements:

  • Updated OpenAsyncReader methods in ReaderFactory.Async.cs and TarReader.Factory.cs to use the new async file stream utilities, ensuring that file streams are opened asynchronously and cancellation tokens are respected. [1] [2]

Internal Refactoring for Cancellation Support:

  • Refactored internal calls in ArchiveFactory.Async.cs to ensure that cancellation tokens are properly passed through and awaited, further improving cancellation support in async archive operations. [1] [2]

Minor Dependency Updates:

  • Added missing using System.Threading.Tasks; directives in affected files to support the new async signatures. [1] [2]

Copilot AI review requested due to automatic review settings February 12, 2026 12:12
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request comprehensively refactors the async archive and writer APIs to improve performance, add proper cancellation support, and return ValueTask for better async efficiency. The changes affect factory interfaces, implementations, utility methods, and test files across the codebase.

Changes:

  • Converted factory async methods (OpenAsyncArchive, OpenAsyncWriter) to return ValueTask<T> and accept CancellationToken parameters for improved async performance and proper cancellation support
  • Added new async file stream utility methods (OpenAsyncReadStream, OpenAsyncWriteStream) that use File.OpenHandle with FileOptions.Asynchronous on .NET 8.0+ for optimal performance
  • Extended WriterOptionsExtensions with polymorphic WithLeaveStreamOpen method supporting all IWriterOptions implementations
  • Updated all test files to properly await the new async factory methods

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/SharpCompress/Archives/IMultiArchiveFactory.cs Updated interface methods to return ValueTask<IAsyncArchive> and accept CancellationToken
src/SharpCompress/Archives/ArchiveFactory.Async.cs Updated internal factory calls to properly await and pass cancellation tokens through
src/SharpCompress/Factories/GZipFactory.cs Implemented async factory methods returning ValueTask with cancellation support
src/SharpCompress/Factories/RarFactory.cs Implemented async factory methods returning ValueTask with cancellation support
src/SharpCompress/Factories/SevenZipFactory.cs Implemented async factory methods returning ValueTask with cancellation support
src/SharpCompress/Factories/TarFactory.cs Implemented async factory methods returning ValueTask with cancellation support
src/SharpCompress/Factories/ZipFactory.cs Implemented async factory methods returning ValueTask with cancellation support
src/SharpCompress/Writers/IWriterFactory.cs Updated interface to return ValueTask<IAsyncWriter>
src/SharpCompress/Writers/WriterFactory.cs Updated factory methods to return ValueTask<IAsyncWriter> and use async file streams; added WithLeaveStreamOpen calls
src/SharpCompress/Writers/WriterOptionsExtensions.cs Added polymorphic WithLeaveStreamOpen extension method for IWriterOptions interface
src/SharpCompress/Utility.Async.cs Added OpenAsyncReadStream and OpenAsyncWriteStream helper methods with platform-specific optimizations
src/SharpCompress/Readers/ReaderFactory.Async.cs Updated to use async file stream utilities for opening files
src/SharpCompress/Readers/Tar/TarReader.Factory.cs Updated to use async file stream utilities for opening files
tests/SharpCompress.Test/ArchiveTests.cs Updated CreateWriterWithLevelAsync to return ValueTask and properly await factory calls
tests/SharpCompress.Test/WriterTests.cs Added await to OpenAsyncWriter call
tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs Added await to OpenAsyncWriter calls
tests/SharpCompress.Test/GZip/GZipWriterAsyncTests.cs Added await to OpenAsyncWriter call
tests/SharpCompress.Test/GZip/AsyncTests.cs Added await to OpenAsyncWriter call
tests/SharpCompress.Test/Zip/ZipMemoryArchiveWithCrcAsyncTests.cs Added await to OpenAsyncWriter and CreateWriterWithLevelAsync calls

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

# Conflicts:
#	src/SharpCompress/Writers/WriterOptionsExtensions.cs
#	tests/SharpCompress.Test/GZip/GZipWriterAsyncTests.cs
#	tests/SharpCompress.Test/WriterTests.cs
Copilot AI review requested due to automatic review settings February 22, 2026 13:22
return OpenWriter(
fileInfo.OpenWrite(),
archiveType,
writerOptions.WithLeaveStreamOpen(false)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: WithLeaveStreamOpen(false) throws for custom IWriterOptions implementations

IWriterOptions is an interface and consumers can pass their own implementations. The new WithLeaveStreamOpen(bool) extension only supports a fixed set of record types and throws NotSupportedException for anything else, which is a behavioral break compared to the previous pass-through. Consider keeping the original options instance when the type is unknown (or provide a safe fallback) to preserve compatibility.

@kilo-code-bot
Copy link
Contributor

kilo-code-bot bot commented Feb 22, 2026

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated 9 comments.

Comments suppressed due to low confidence (5)

tests/SharpCompress.Test/GZip/GZipWriterAsyncTests.cs:32

  • IAsyncWriter implements IAsyncDisposable; using using here will call Dispose() instead of DisposeAsync(). In async tests this can skip async flush/finalization and cause intermittent failures (especially on legacy frameworks). Prefer await using for writers opened via OpenAsyncWriter.
        using (
            var writer = await WriterFactory.OpenAsyncWriter(
                new AsyncOnlyStream(stream),
                ArchiveType.GZip,
                new WriterOptions(CompressionType.GZip)
            )
        )

tests/SharpCompress.Test/Zip/ZipMemoryArchiveWithCrcAsyncTests.cs:143

  • WriterFactory.OpenAsyncWriter(...) returns IAsyncWriter (IAsyncDisposable). Disposing it synchronously via using can skip async finalization; prefer await using for this block.
        using (
            var writer = await WriterFactory.OpenAsyncWriter(
                new AsyncOnlyStream(zipStream),
                ArchiveType.Zip,
                writerOptions
            )
        )

tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs:43

  • IAsyncWriter is IAsyncDisposable; using using will dispose synchronously. Use await using here to ensure the async writer finalizes and flushes correctly before the tar file is read back.
            using (
                var writer = await WriterFactory.OpenAsyncWriter(
                    new AsyncOnlyStream(stream),
                    ArchiveType.Tar,
                    new WriterOptions(CompressionType.None) { LeaveStreamOpen = false }
                )
            )

tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs:102

  • IAsyncWriter implements IAsyncDisposable; this should be await using so DisposeAsync() runs (important for flushing/completing archive output) before subsequent verification.
        using (
            var writer = await WriterFactory.OpenAsyncWriter(
                new AsyncOnlyStream(stream),
                ArchiveType.Tar,
                new WriterOptions(CompressionType.None) { LeaveStreamOpen = false }
            )
        )

tests/SharpCompress.Test/GZip/AsyncTests.cs:112

  • OpenAsyncWriter returns an IAsyncWriter (IAsyncDisposable). Using using here calls Dispose() instead of DisposeAsync(), which can leave the ZIP not fully finalized before you reopen it. Prefer await using for the writer block.
        using (
            var writer = await WriterFactory.OpenAsyncWriter(
                new AsyncOnlyStream(stream),
                ArchiveType.Zip,
                new WriterOptions(CompressionType.Deflate) { LeaveStreamOpen = false }
            )
        )

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 63 to 69
using (
var writer = CreateWriterWithLevelAsync(zipStream, compressionType, compressionLevel)
var writer = await CreateWriterWithLevelAsync(
zipStream,
compressionType,
compressionLevel
)
)
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This async writer is IAsyncDisposable; using will invoke synchronous disposal. Use await using so DisposeAsync() runs and the in-memory zip is fully finalized before verification.

Copilot uses AI. Check for mistakes.
Comment on lines 29 to +34
fileInfo.NotNull(nameof(fileInfo));
return OpenWriter(fileInfo.OpenWrite(), archiveType, writerOptions);
return OpenWriter(
fileInfo.OpenWrite(),
archiveType,
writerOptions.WithLeaveStreamOpen(false)
);
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fileInfo.OpenWrite() creates a stream that won't be disposed if OpenWriter(...) throws (e.g., unsupported archive type/options). Consider disposing the file stream on failure (e.g., open the stream into a local, try to create the writer, and Dispose() the stream in a catch/finally when writer creation fails).

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +34
return OpenWriter(
fileInfo.OpenWrite(),
archiveType,
writerOptions.WithLeaveStreamOpen(false)
);
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

writerOptions.WithLeaveStreamOpen(false) can throw NotSupportedException for any custom IWriterOptions implementation not covered by the switch, which is a breaking behavior change for public OpenWriter/OpenAsyncWriter(FileInfo, ...) overloads. Consider avoiding throwing when LeaveStreamOpen is already the requested value, and/or using a different mechanism to ensure owned file streams are disposed without requiring options cloning.

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +69
return await OpenAsyncWriter(
stream,
archiveType,
writerOptions.WithLeaveStreamOpen(false),
cancellationToken
)
.ConfigureAwait(false);
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file stream opened here (OpenAsyncWriteStream) will leak if OpenAsyncWriter(stream, ...) throws before returning the writer. Wrap writer creation in a try/catch/finally (or equivalent) so the stream is disposed on failure.

Suggested change
return await OpenAsyncWriter(
stream,
archiveType,
writerOptions.WithLeaveStreamOpen(false),
cancellationToken
)
.ConfigureAwait(false);
try
{
return await OpenAsyncWriter(
stream,
archiveType,
writerOptions.WithLeaveStreamOpen(false),
cancellationToken
)
.ConfigureAwait(false);
}
catch
{
stream.Dispose();
throw;
}

Copilot uses AI. Check for mistakes.
Comment on lines +144 to +145
return await OpenAsyncReader(stream, readerOptions, cancellationToken)
.ConfigureAwait(false);
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file stream opened here isn’t disposed if OpenAsyncReader(stream, ...) throws before returning a reader. Consider disposing stream on failure (try/finally) to prevent leaking file handles.

Suggested change
return await OpenAsyncReader(stream, readerOptions, cancellationToken)
.ConfigureAwait(false);
try
{
return await OpenAsyncReader(stream, readerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch
{
stream.Dispose();
throw;
}

Copilot uses AI. Check for mistakes.
Comment on lines 204 to 210
using (
var writer = CreateWriterWithLevelAsync(zipStream, compressionType, compressionLevel)
var writer = await CreateWriterWithLevelAsync(
zipStream,
compressionType,
compressionLevel
)
)
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IAsyncWriter is IAsyncDisposable; this using will not await disposal. Use await using so any async flush/finalization completes before reopening the archive.

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +49
public static IWriterOptions WithLeaveStreamOpen(
this IWriterOptions options,
bool leaveStreamOpen
) =>
options switch
{
WriterOptions writerOptions => writerOptions with { LeaveStreamOpen = leaveStreamOpen },
ZipWriterOptions zipOptions => zipOptions with { LeaveStreamOpen = leaveStreamOpen },
TarWriterOptions tarOptions => tarOptions with { LeaveStreamOpen = leaveStreamOpen },
GZipWriterOptions gzipOptions => gzipOptions with { LeaveStreamOpen = leaveStreamOpen },
_ => throw new NotSupportedException(
$"Cannot set LeaveStreamOpen on options of type {options.GetType().Name}. "
+ "Options must be a record type implementing IWriterOptions."
),
};
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper throws for any IWriterOptions implementation outside the known record types, even when no change is needed. At minimum, consider returning options directly when options.LeaveStreamOpen == leaveStreamOpen to avoid surprising NotSupportedExceptions for custom options that already have the desired value.

Copilot uses AI. Check for mistakes.
options ??= ReaderOptions.ForOwnedFile;
return OpenAsyncReader(fileInfo.OpenRead(), options, cancellationToken);
var stream = fileInfo.OpenAsyncReadStream(cancellationToken);
return await OpenAsyncReader(stream, options, cancellationToken).ConfigureAwait(false);
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stream opened from fileInfo.OpenAsyncReadStream(...) is not disposed if OpenAsyncReader(stream, ...) throws (e.g., invalid format). Wrap the call so the stream is disposed on failure to avoid file handle leaks.

Suggested change
return await OpenAsyncReader(stream, options, cancellationToken).ConfigureAwait(false);
try
{
return await OpenAsyncReader(stream, options, cancellationToken).ConfigureAwait(false);
}
catch
{
stream.Dispose();
throw;
}

Copilot uses AI. Check for mistakes.
<DefineConstants>$(DefineConstants);LEGACY_DOTNET</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition=" '$(TargetFramework)' == 'net8.0' Or '$(TargetFramework)' == 'net9.0' Or '$(TargetFramework)' == 'net10.0' ">
<PropertyGroup Condition=" '$(TargetFramework)' == 'net8.0' Or '$(TargetFramework)' == 'net10.0' ">
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

net9.0 is still listed in TargetFrameworks, but it was removed from the IsTrimmable/IsAotCompatible condition. If net9.0 is intended to remain supported, consider adding it back to this property group (or document why it’s intentionally excluded).

Suggested change
<PropertyGroup Condition=" '$(TargetFramework)' == 'net8.0' Or '$(TargetFramework)' == 'net10.0' ">
<PropertyGroup Condition=" '$(TargetFramework)' == 'net8.0' Or '$(TargetFramework)' == 'net9.0' Or '$(TargetFramework)' == 'net10.0' ">

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants