Skip to content

Append with interpolation may return corrupt pool-rented array if the interpolation expression throws #324

Closed

Description

@dhhoang

The bug

When Append($"...") or AppendLine($"...") is called with an interpolated string and an expression inside {...} throws, ValueStringBuilder can return its rented array to ArrayPool<char>.Shared twice. This is done if the array needs to be expanded at

Builder.EnsureCapacity(Builder.Length + literalLength + (formattedCount * 11));

To reproduce

File-based program, run with dotnet run Repro.cs (.NET 10 SDK):

#:package LinkDotNet.StringBuilder@3.7.1

using System.Buffers;
using LinkDotNet.StringBuilder;

var customers = new Dictionary<int, string>();
var orderId = 42;

try
{
    // Rents a 32-char array from ArrayPool<char>.Shared.
    using var sb = new ValueStringBuilder();

    // The text needs more than 32 chars, so the builder has to grow.
    // customers[7] does not exist, so the lookup throws in the middle of the Append.
    sb.Append($"Order {orderId} for customer {customers[7]}");
}
catch (KeyNotFoundException)
{
}

// Two arrays that are rented at the same time must be two different arrays.
var first = ArrayPool<char>.Shared.Rent(32);
var second = ArrayPool<char>.Shared.Rent(32);

Console.WriteLine(ReferenceEquals(first, second));

Actual: prints True. Both variables point to the same array.

Expected: prints False. The builder returns its array to the pool exactly once.

Cause

All line numbers refer to ValueStringBuilder.InterpolatedStringHandler.cs.

  1. The constructor of AppendInterpolatedStringHandler receives the builder by value (line 43). Builder is therefore a copy, and the copy and the original both hold the same rented array.
  2. Builder.EnsureCapacity(...) on line 48 grows the copy. Growing rents a larger array and returns the old one to the pool. The original builder still has the old array in arrayFromPool. Any later append on the copy that grows it has the same effect.
  3. Normally Append(ref handler) then runs this = handler.Builder (line 14), which brings the original up to date.
  4. If an expression inside {...} throws, step 3 never runs. When the original builder is disposed, it returns the old array a second time.

All of these have to be true:

  • The builder holds a rented array, not only a caller-supplied buffer.
  • The interpolated string is long enough to make the builder grow.
  • An expression inside {...}, or its ToString / TryFormat, throws.
  • The builder is disposed afterwards, for example through using.

Activity

  1. linkdotnet commented on Oct 3, 2026

    @linkdotnet
    Owner

    Hey @dhhoang - thanks for the report. And indeed the issue is holding on the builder. I will fix that.
    Thanks for that!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions