Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions src/typstsharp.tests/Tests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Buffers;
using System.Text;
using UglyToad.PdfPig;
using UglyToad.PdfPig.Content;
Expand Down Expand Up @@ -721,6 +722,97 @@ public async Task OutputStreamRejectsReadsAfterTheDocumentIsDisposed()
await Assert.That(() => stream.Read(new byte[16].AsSpan())).Throws<ObjectDisposedException>();
}

/// <summary>
/// A span read must not route through <see cref="Stream.Read(Span{byte})"/>, which serves the read
/// from an array rented from the process-wide <see cref="ArrayPool{T}"/> and returns it without
/// clearing it. That would leave the rendered document readable by the next unrelated component to
/// rent from the same pool, which is exactly what PooledBuffer goes out of its way to prevent.
/// </summary>
[Test]
public async Task SpanReadLeavesNoDocumentBytesInTheSharedArrayPool()
{
using var compiler = TypstCompiler.FromSource("= Not for the next renter");
using var document = compiler.CompileToDocument();

int length = (int)document.GetOutputLength();

// Prime the pool so that a rent of this size is served from a known array rather than a
// fresh allocation. Nothing may await between here and the final rent, or the thread-local
// pool slot the priming lands in may not be the one the read and the check see.
byte[] primed = ArrayPool<byte>.Shared.Rent(length);
primed.AsSpan().Clear();
ArrayPool<byte>.Shared.Return(primed);

using (var stream = document.OpenOutputStream())
{
stream.ReadExactly(new byte[length]);
}

byte[] afterwards = ArrayPool<byte>.Shared.Rent(length);
bool carriesTheDocument = afterwards.AsSpan(0, 5).SequenceEqual("%PDF-"u8);
ArrayPool<byte>.Shared.Return(afterwards);

await Assert.That(carriesTheDocument).IsFalse();
}

[Test]
public async Task SpanReadReturnsTheWholeBufferAndThenReportsEndOfStream()
{
using var compiler = TypstCompiler.FromSource("= Read by span");
using var document = compiler.CompileToDocument();
var expected = document.GetOutputBytes();

using var stream = document.OpenOutputStream();

// A span larger than the document is the boundary worth pinning: the read must stop at the
// end of the buffer rather than at the end of the span.
var oversized = new byte[expected.Length + 64];
int read = stream.Read(oversized.AsSpan());

await Assert.That(read).IsEqualTo(expected.Length);
await Assert.That(oversized.AsSpan(0, expected.Length).SequenceEqual(expected)).IsTrue();
await Assert.That(stream.Read(oversized.AsSpan())).IsEqualTo(0);
await Assert.That(stream.Read(Span<byte>.Empty)).IsEqualTo(0);
}

[Test]
public async Task ChunkedSpanReadsReassembleTheDocument()
{
using var compiler = TypstCompiler.FromSource(TwoPageSource);
using var document = compiler.CompileToDocument();
var expected = document.GetOutputBytes();

using var stream = document.OpenOutputStream();
var reassembled = new byte[expected.Length];

// Chunks that do not divide the length evenly, so the final short read is covered too.
const int chunk = 1000;
int offset = 0;
int read;
while ((read = stream.Read(reassembled.AsSpan(offset, Math.Min(chunk, reassembled.Length - offset)))) > 0)
{
offset += read;
}

await Assert.That(offset).IsEqualTo(expected.Length);
await Assert.That(reassembled.SequenceEqual(expected)).IsTrue();
}

[Test]
public async Task AsyncReadMatchesOutputBytes()
{
using var compiler = TypstCompiler.FromSource("= Read asynchronously");
using var document = compiler.CompileToDocument();
var expected = document.GetOutputBytes();

using var stream = document.OpenOutputStream();
var destination = new byte[expected.Length];
int read = await stream.ReadAsync(destination.AsMemory());

await Assert.That(read).IsEqualTo(expected.Length);
await Assert.That(destination.SequenceEqual(expected)).IsTrue();
}

[Test]
public async Task OutputLengthMatchesOutputBytes()
{
Expand Down
45 changes: 37 additions & 8 deletions src/typstsharp/TypstDocument.cs
Original file line number Diff line number Diff line change
Expand Up @@ -326,24 +326,53 @@ private unsafe void Free()
private sealed unsafe class OutputStream : UnmanagedMemoryStream
{
private readonly TypstDocument _owner;
private readonly byte* _pointer;

internal OutputStream(TypstDocument owner, byte* pointer, long length)
: base(pointer, length, length, FileAccess.Read)
{
_owner = owner;
_pointer = pointer;
}

// Only the byte-array reads are overridden. Every other read path on Stream and
// UnmanagedMemoryStream, span and async alike, ends up calling one of these two, so this
// covers them all. Overriding Read(Span) as well would recurse: the UnmanagedMemoryStream
// override delegates to Stream.Read(Span) for any derived type, and that implementation
// calls back into Read(byte[]).
public override int Read(byte[] buffer, int offset, int count)
/// <summary>
/// Copies straight out of the native buffer, which is what keeps every span-shaped and
/// asynchronous read off the managed heap.
/// </summary>
/// <remarks>
/// <see cref="UnmanagedMemoryStream"/> only takes its own direct path when the runtime type is
/// exactly <see cref="UnmanagedMemoryStream"/>; for a derived type it defers to
/// <see cref="Stream.Read(Span{byte})"/>, which rents an array the size of the caller's span
/// from the shared <see cref="ArrayPool{T}"/>, reads into it, copies it out, and returns it to
/// the pool without clearing it. That costs a second copy of every byte and, worse, leaves the
/// rendered document in a process-wide pool for whatever rents from it next, which is the very
/// thing <c>PooledBuffer</c> clears its buffer to avoid. Reading the pointer here sidesteps
/// both. It does not recurse: recursion would need a call back into <c>base.Read(Span)</c>.
/// </remarks>
public override int Read(Span<byte> buffer)
{
ObjectDisposedException.ThrowIf(_owner.IsDisposed, _owner);
int read = base.Read(buffer, offset, count);

long position = Position;
int count = (int)Math.Min((long)buffer.Length, Length - position);
if (count <= 0)
{
GC.KeepAlive(_owner);
return 0;
}

new ReadOnlySpan<byte>(_pointer + position, count).CopyTo(buffer);
Position = position + count;
GC.KeepAlive(_owner);
return read;
return count;
}

// Every other read path on Stream and UnmanagedMemoryStream, byte array and async alike,
// funnels into Read(Span) above.
public override int Read(byte[] buffer, int offset, int count)
{
ValidateBufferArguments(buffer, offset, count);
return Read(new Span<byte>(buffer, offset, count));
}

public override int ReadByte()
Expand Down
Loading