Skip to content

Make the encoding providers' stream paths genuinely async - #22

Merged
matt-edmondson merged 4 commits into
mainfrom
claude/github-issues-b0eba5
Sep 7, 2026
Merged

Make the encoding providers' stream paths genuinely async#22
matt-edmondson merged 4 commits into
mainfrom
claude/github-issues-b0eba5

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Addresses the non-breaking half of #8 for the encoding category.

#8 splits into two halves that pull in opposite directions. One is a decision only you can make — whether the in-memory operations keep an async surface at all, and at what cost in major versions. The other is not a decision at all:

Stream-based operations should become genuinely asynchronous. ... This is additive work with no surface change.

That half was done for compression, AES and the two hash interfaces in #21. This does it for encoding. Obfuscation and serialization are still outstanding; see the end.

What changes

Base64EncodingProvider and HexEncodingProvider now declare the two Try…Async(Stream, Stream, ...) primitives themselves, which replaces IEncodingProvider's Task.Run defaults.

IEncodingProvider's derived stream members — Try…Async(ReadOnlyMemory, Stream) and …Async(Stream) — are re-expressed over those primitives, exactly the way ICompressionProvider already layers its own. So declaring two members converts six paths per provider, and any future encoding provider gets the same deal.

The transform itself is deliberately not offloaded. It is CPU work on a buffer already in memory, and whether that is worth a thread pool dispatch is the caller's decision — which is the other half of #8, still open and still breaking.

Each provider mirrors its own memory profile

The two providers do not share one shape, because their synchronous paths don't either, and an async path that quietly changed a provider's memory characteristics would be a worse trade than the one it fixes.

Hex transforms a chunk at a time and keeps the constant memory use its synchronous path has. Hex maps one byte to two, so a chunk boundary never splits a pair when encoding.

Decoding is the part with a trap in it. It reads two characters per byte, so a chunk can end mid-pair — and separately, ReadAsync may legally return fewer bytes than asked for at any point, which a network stream routinely does. A reader that treats a short read as end-of-stream silently truncates its output. So each read is topped up until the buffer holds an even number of characters or the stream has genuinely ended; a leftover character then means truncated input, and it reports failure exactly as the synchronous path does.

Base64 buffers the whole input, because it maps three bytes to four and a chunked transform would have to carry a partial group across every boundary. Its synchronous path already buffers, so this changes nothing about its memory characteristics — only that the read and the write no longer hold a thread.

Testing

Four new tests per provider, covering what the existing short round-trip cannot:

  • A payload spanning multiple chunks (40 KB against an 8 KB chunk). A boundary bug is invisible on anything that fits in one chunk.
  • A stream that returns one byte per read. MemoryStream never reads short, so no test built on one can catch the truncation bug above; this drip-feeds to force the case.
  • Agreement between the derived overloads and the primitive, since they are now layered over it rather than over the synchronous path.
  • Cancellation, caught by base type — the framework may surface OperationCanceledException or its TaskCanceledException subclass depending on the stream, and an exact-type assertion would be brittle.

I checked these tests actually bite rather than just pass: reverting the short-read fill loop to a single ReadAsync fails four of them.

Full suite: 655 passed, 0 warnings and 0 errors with the ktsu analyzers loaded.

What this does not do

#8 stays open, and deliberately:

  • The in-memory half is yours to decide. Options 1–3 in the issue (drop the async surface / move to ValueTask / document as-is) are all breaking except the last, and the issue frames it as a decision rather than a task. I have not pre-empted it.
  • Serialization is blocked by ISerializationProvider takes TextWriter/TextReader, which blocks genuine async serialization #18. ISerializationProvider is built on TextWriter/TextReader, and System.Text.Json has no async overload for either — so its stream paths cannot become genuine without the signature redesign ISerializationProvider takes TextWriter/TextReader, which blocks genuine async serialization #18 describes.
  • Obfuscation is left for its own change. Seven providers with genuinely different stream shapes: Xor streams byte-wise, Reverse needs the whole input, Base64/Hex delegate to the encoders, Composite chains others. Converting them properly means a per-provider judgement like the one above, not one blanket edit, and folding that into this PR would bury it.

README and CLAUDE.md are updated to say which paths are now genuine — the README claim was the one thing #8 says should be corrected regardless of which option is chosen.

🤖 Generated with Claude Code

https://claude.ai/code/session_01N1cVfacJWw1uM42DUmPfUg


Generated by Claude Code

Addresses the non-breaking half of #8 for the encoding category: stream
operations should do real asynchronous I/O rather than occupy a thread
pool thread for the duration of a disk or network read.

`Base64EncodingProvider` and `HexEncodingProvider` now declare the two
`Try…Async(Stream, Stream, ...)` primitives themselves, which replaces
`IEncodingProvider`'s `Task.Run` defaults. The interface's derived stream
members — `Try…Async(ReadOnlyMemory, Stream)` and `…Async(Stream)` — are
re-expressed over those primitives, the same way the compression
interface already layers its own, so declaring two members converts six
paths per provider.

The transform itself is deliberately not offloaded. It is CPU work on a
buffer already in memory, and whether that is worth a thread pool
dispatch is the caller's decision — which is the other half of #8, still
open and still a breaking choice.

Each provider mirrors its own synchronous memory profile rather than
adopting one shared shape:

- Hex transforms a chunk at a time and keeps constant memory use. Hex
  maps one byte to two, so a chunk boundary never splits a pair when
  encoding. Decoding reads two characters per byte, so each read is
  topped up to an even length before transforming — a `ReadAsync` may
  legally return fewer bytes than asked for, and treating that as
  end-of-stream would silently truncate the output.
- Base64 buffers the whole input, because it maps three bytes to four
  and a chunked transform would have to carry a partial group across
  every boundary. Its synchronous path already buffers, so this changes
  no memory characteristics.

Tests cover a payload spanning multiple chunks, a stream that returns
one byte per read (a `MemoryStream` never reads short, so it cannot
catch that class of bug on its own), agreement between the derived
overloads and the primitive, and cancellation. Reverting the short-read
fill loop to a single read fails four of them.

Obfuscation and serialization stream paths are unchanged. Serialization
is blocked by #18; obfuscation is seven providers with differing stream
shapes and is better done as its own change.

README and CLAUDE.md updated to say which paths are now genuine.

Full suite: 655 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1cVfacJWw1uM42DUmPfUg
Coverage of the new code measured 64.9% the way Sonar computes it, which
would have failed the quality gate. The gap was entirely the paths that
report failure rather than succeed — exactly the ones a `Try` method
exists for.

Adds tests for null streams, input neither provider can decode, a stream
that faults on read and on write, and a stream disposed before use. Also
exercises `TryDecodeAsync(ReadOnlyMemory, Stream)`, which the
derived-overload test previously covered only in the encode direction.

Removes the `ArgumentException` catch from the two new Base64 async
methods. Nothing in them can throw it: `CopyToAsync` validates its own
buffer size, which is a constant here, and reports an unreadable stream
as `NotSupportedException`, while `WriteAsync` over a valid range cannot
throw it either. The synchronous path keeps its own catch, where the
`Write(byte[], int, int)` overload can still raise one.

New-code coverage 64.9% -> 92.3%. Full suite: 663 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1cVfacJWw1uM42DUmPfUg

Copy link
Copy Markdown
Contributor Author

Pushed a second commit before this went to review, because I measured the coverage the quality gate uses and the first commit would not have passed it.

New-code coverage was 64.9%, against a gate of 80%. The gap was entirely the paths that report failure rather than succeed — which is to say, the whole reason these are Try methods. The round-trip tests proved the happy path and nothing else.

Added tests for null streams, input neither provider can decode, a stream that faults on read and separately on write (two different awaits, two different failure modes), and a stream disposed before use. Also exercised TryDecodeAsync(ReadOnlyMemory, Stream), which the derived-overload test had only covered in the encode direction.

64.9% → 92.3%. Full suite now 663 passed.

One deletion worth flagging, since it is behaviour and not test scaffolding: the ArgumentException catch is gone from the two new Base64 async methods. Nothing in them can throw it — CopyToAsync validates its own buffer size (a constant here) and reports an unreadable stream as NotSupportedException, and WriteAsync over a valid range cannot throw it. The synchronous path keeps its catch, where the Write(byte[], int, int) overload genuinely can raise one. A catch clause nothing can reach is dead code that also reads as a claim about the method that isn't true.

Also in the diff, and not mine: .editorconfig line 385 changed from [*.cpp,*.h,*.hpp,…] to [*.{cpp,h,hpp,…}]. Something in the build rewrote it. It is a genuine fix — comma-separated section headers are not valid EditorConfig syntax and that section was matching nothing — but it is unrelated to this PR, so say the word and I will take it back out.


Generated by Claude Code

Comment thread Essentials.Tests/EncodingProviderTests.cs Fixed
`Encoding_Async_ReportsADisposedStream` built a MemoryStream and disposed
it by hand, so an assertion failure before that call would have leaked
it. The stream is now held by an `await using` block with the explicit
`DisposeAsync` inside it: the explicit call is the state under test, and
the block guarantees disposal on every path out. MemoryStream.Dispose is
idempotent, so disposing twice is a no-op.

Written as a block with `ConfigureAwait(false)` on the resource rather
than as an `await using` declaration, which CA2007 rejects in this
project.

Reported by github-code-quality on #22. Full suite: 663 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1cVfacJWw1uM42DUmPfUg
Comment thread Essentials.Tests/EncodingProviderTests.cs Outdated
The previous fix left the test holding a disposed local, which three
analysers disagree about how to express. CodeQL wants an `await using`
declaration; CA2007 rejects that declaration without ConfigureAwait, and
wrapping the resource in ConfigureAwait is what hides the ownership from
CodeQL in the first place; CA1849 rejects the synchronous Dispose that
sidesteps both.

The disagreement is a symptom rather than the problem. A test that needs
an already-closed stream does not need to own a disposable at all, so
building one moves into a helper that creates it, disposes it, and
returns it. Disposal is then complete and local to the helper, and the
test body holds an object whose lifetime is already over.

Full suite: 663 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1cVfacJWw1uM42DUmPfUg
@matt-edmondson
matt-edmondson merged commit 73d11a8 into main Sep 7, 2026
5 checks passed
@matt-edmondson
matt-edmondson deleted the claude/github-issues-b0eba5 branch September 7, 2026 12:22
@sonarqubecloud

sonarqubecloud Bot commented Sep 7, 2026

Copy link
Copy Markdown

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