Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/Logsmith.Generator/Emission/MethodEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ internal static string EmitMethodBody(LogMethodInfo method)
sb.AppendLine($" global::Logsmith.LogManager.Dispatch(in __entry, __utf8Message, __state, WriteProperties_{method.MethodName});");
}

// Return any ArrayPool buffer rented during overflow
sb.AppendLine(" writer.Dispose();");
sb.AppendLine(" }");
return sb.ToString();
}
Expand Down
58 changes: 48 additions & 10 deletions src/Logsmith/Utf8LogWriter.cs
Original file line number Diff line number Diff line change
@@ -1,43 +1,56 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Unicode;

namespace Logsmith;

public ref struct Utf8LogWriter
{
private readonly Span<byte> _buffer;
private Span<byte> _buffer;
private int _position;
private byte[]? _rented;

public Utf8LogWriter(Span<byte> buffer)
{
_buffer = buffer;
_position = 0;
_rented = null;
}

public void Dispose()
{
var rented = _rented;
if (rented is not null)
{
_rented = null;
ArrayPool<byte>.Shared.Return(rented);
}
}

public void Write(ReadOnlySpan<byte> utf8Literal)
{
if (utf8Literal.Length > _buffer.Length - _position)
return;
Grow(utf8Literal.Length);

utf8Literal.CopyTo(_buffer[_position..]);
_position += utf8Literal.Length;
}

public void WriteFormatted<T>(in T value) where T : IUtf8SpanFormattable
{
if (value.TryFormat(_buffer[_position..], out int bytesWritten, default, null))
{
_position += bytesWritten;
}
int bytesWritten;
while (!value.TryFormat(_buffer[_position..], out bytesWritten, default, null))
Grow(_buffer.Length);
_position += bytesWritten;
}

public void WriteFormatted<T>(in T value, ReadOnlySpan<char> format) where T : IUtf8SpanFormattable
{
if (value.TryFormat(_buffer[_position..], out int bytesWritten, format, null))
{
_position += bytesWritten;
}
int bytesWritten;
while (!value.TryFormat(_buffer[_position..], out bytesWritten, format, null))
Grow(_buffer.Length);
_position += bytesWritten;
}

public void WriteString(string? value)
Expand All @@ -50,6 +63,15 @@ public void WriteString(string? value)

var status = Utf8.FromUtf16(value, _buffer[_position..], out _, out int bytesWritten);
if (status == OperationStatus.Done)
{
_position += bytesWritten;
return;
}

// Buffer too small — grow to exact requirement and retry
Grow(Encoding.UTF8.GetByteCount(value));
status = Utf8.FromUtf16(value, _buffer[_position..], out _, out bytesWritten);
if (status == OperationStatus.Done)
{
_position += bytesWritten;
}
Expand All @@ -58,4 +80,20 @@ public void WriteString(string? value)
public ReadOnlySpan<byte> GetWritten() => _buffer[.._position];

public int BytesWritten => _position;

[MethodImpl(MethodImplOptions.NoInlining)]
private void Grow(int needed)
{
int required = _position + needed;
int newSize = Math.Max(_buffer.Length * 2, required);
var newArray = ArrayPool<byte>.Shared.Rent(newSize);
_buffer[.._position].CopyTo(newArray);

var old = _rented;
_rented = newArray;
_buffer = newArray;

if (old is not null)
ArrayPool<byte>.Shared.Return(old);
}
}
93 changes: 93 additions & 0 deletions tests/Logsmith.Tests/Utf8LogWriterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public void WriteFormatted_WithFormat_AppliesFormatString()

var result = Encoding.UTF8.GetString(writer.GetWritten());
Assert.That(result, Is.EqualTo("3.14"));
writer.Dispose();
}

[Test]
Expand All @@ -27,5 +28,97 @@ public void WriteFormatted_WithEmptyFormat_SameAsDefault()

var result = Encoding.UTF8.GetString(writer.GetWritten());
Assert.That(result, Is.EqualTo("42"));
writer.Dispose();
}

[Test]
public void WriteString_WithCurlyBraces_PreservesBraces()
{
Span<byte> buffer = stackalloc byte[512];
var writer = new Utf8LogWriter(buffer);

writer.Write("Vulkan validation: "u8);
writer.WriteString("{VkBuffer 0x00000001234ABCDEF} - Memory type not suitable");

var result = Encoding.UTF8.GetString(writer.GetWritten());
Assert.That(result, Is.EqualTo("Vulkan validation: {VkBuffer 0x00000001234ABCDEF} - Memory type not suitable"));
writer.Dispose();
}

[Test]
public void WriteString_OverflowBuffer_FallsBackToArrayPool()
{
// Buffer only fits the literal prefix, not the string param
Span<byte> buffer = stackalloc byte[30];
var writer = new Utf8LogWriter(buffer);

writer.Write("Prefix: "u8); // 8 bytes, leaves 22 bytes
writer.WriteString("{VkBuffer 0x00000001234ABCDEF} - Memory type not suitable"); // 57 bytes — overflows

var result = Encoding.UTF8.GetString(writer.GetWritten());
Assert.That(result, Is.EqualTo("Prefix: {VkBuffer 0x00000001234ABCDEF} - Memory type not suitable"));
writer.Dispose();
}

[Test]
public void WriteString_LongMessage_PreservedViaArrayPoolFallback()
{
// Max generated buffer is 4096 bytes. A single string param can easily exceed that —
// stack traces, serialized payloads, validation messages, SQL queries, etc.
const int maxGeneratedBuffer = 4096;
Span<byte> buffer = stackalloc byte[maxGeneratedBuffer];
var writer = new Utf8LogWriter(buffer);

writer.Write("Payload: "u8); // 9 bytes

// 8KB string — double the max buffer. Not unusual for serialized objects,
// full stack traces, or verbose diagnostic output.
var longMessage = new string('X', 8192);

writer.WriteString(longMessage);

var result = Encoding.UTF8.GetString(writer.GetWritten());
Assert.That(result, Is.EqualTo("Payload: " + longMessage));
Assert.That(writer.BytesWritten, Is.EqualTo(9 + 8192));
writer.Dispose();
}

[Test]
public void Write_LiteralOverflow_PreservedViaArrayPoolFallback()
{
Span<byte> buffer = stackalloc byte[4];
var writer = new Utf8LogWriter(buffer);

writer.Write("Hello, world!"u8); // 13 bytes into 4-byte buffer

var result = Encoding.UTF8.GetString(writer.GetWritten());
Assert.That(result, Is.EqualTo("Hello, world!"));
writer.Dispose();
}

[Test]
public void WriteFormatted_Overflow_PreservedViaArrayPoolFallback()
{
Span<byte> buffer = stackalloc byte[4];
var writer = new Utf8LogWriter(buffer);

writer.WriteFormatted(123456789L); // needs ~9 bytes, only 4 available

var result = Encoding.UTF8.GetString(writer.GetWritten());
Assert.That(result, Is.EqualTo("123456789"));
writer.Dispose();
}

[Test]
public void Dispose_NoOverflow_NoOp()
{
Span<byte> buffer = stackalloc byte[128];
var writer = new Utf8LogWriter(buffer);

writer.Write("hello"u8);

// Dispose on stackalloc-only path should be a no-op (no rented buffer to return)
writer.Dispose();
Assert.Pass();
}
}
Loading