Skip to content

fix: read output streams straight from native memory - #4

Closed
msallin wants to merge 1 commit into
developfrom
feat/output-stream-span-read
Closed

fix: read output streams straight from native memory#4
msallin wants to merge 1 commit into
developfrom
feat/output-stream-span-read

Conversation

@msallin

@msallin msallin commented Sep 6, 2026

Copy link
Copy Markdown
Member

OutputStream overrode only the byte-array reads. Because the runtime type is then no longer exactly UnmanagedMemoryStream, its Read(Span) defers to Stream.Read(Span), which rents an array from ArrayPool<byte>.Shared, reads into it, copies it out, and returns it to the pool without clearing it.

Two consequences:

  • Every span-shaped or asynchronous read copies each byte twice. Measured on a 4 MiB buffer: 0.767 ms per read against 0.276 ms for a plain UnmanagedMemoryStream.
  • The rendered document stays in a process-wide pool, readable by the next component that rents from it. PooledBuffer clears its buffer specifically to prevent this.

Overriding Read(Span) to copy from the pointer removes both and restores parity with a plain UnmanagedMemoryStream (0.274 ms). The byte-array overload funnels into it, and ReadAsync(Memory) picks it up because UnmanagedMemoryStream.ReadAsync calls the virtual Read(Span).

The replaced comment claimed that overriding Read(Span) would recurse. It does not: recursion would require calling back into base.Read(Span).

CopyTo is unaffected. It uses an 81920-byte rented buffer like every other stream, not a document-sized one.

Tests

SpanReadLeavesNoDocumentBytesInTheSharedArrayPool primes the pool, performs a span read, and rents again to assert the document is not there. Confirmed failing against the previous implementation before the fix was applied. Also added: oversized-span and end-of-stream boundaries, chunked reads that do not divide evenly, and the ReadAsync(Memory) path.

60/60 tests pass. Builds clean on net8, net9 and net10 with zero warnings.

OutputStream overrode only the byte-array reads. Because the runtime type is
then no longer exactly UnmanagedMemoryStream, its Read(Span) stops taking the
direct path and defers to Stream.Read(Span), which rents an array the size of
the caller's span from the shared ArrayPool, reads into it, copies it out and
returns it to the pool without clearing it.

That has two consequences. Every span-shaped or asynchronous read copies each
byte twice, and the rendered document is left in a process-wide pool where the
next component to rent from it can read the bytes back - the same disclosure
PooledBuffer clears its buffer to avoid.

Overriding Read(Span) to copy from the pointer removes both. It does not
recurse; that would need a call back into base.Read(Span). The byte-array
overload now funnels into it, and ReadAsync picks it up for free.
@msallin
msallin force-pushed the feat/output-stream-span-read branch from d52fdc2 to c5d0fe9 Compare September 6, 2026 19:16
@msallin

msallin commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Reopened upstream against the parent repository: evolvedlight#47

@msallin msallin closed this Sep 6, 2026
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.

1 participant