Skip to content

Fix #3850: recover ReadOnlySpan<T> array literals from the legacy lazy cache#3851

Merged
siegfriedpammer merged 1 commit into
icsharpcode:masterfrom
sailro:fix-readonlyspan-privateimpldetails-cache
Jul 13, 2026
Merged

Fix #3850: recover ReadOnlySpan<T> array literals from the legacy lazy cache#3851
siegfriedpammer merged 1 commit into
icsharpcode:masterfrom
sailro:fix-readonlyspan-privateimpldetails-cache

Conversation

@sailro

@sailro sailro commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Fixes #3850.

On target frameworks without RuntimeHelpers.CreateSpan (.NET Framework, or netstandard2.0 + System.Memory), Roslyn caches the backing array of a ReadOnlySpan<T> built from an array literal in a lazy <PrivateImplementationDetails> static field:

object obj = <PrivateImplementationDetails>.cache;
if (obj == null) {
    obj = new char[] { CR, LF };
    <PrivateImplementationDetails>.cache = (char[])obj;
}
... new ReadOnlySpan<char>((char[])obj) ...

The decompiled output referenced the compiler-synthesized <PrivateImplementationDetails> type, which is never declared and whose name is not expressible in C#, so the output failed to recompile (CS0400 in whole-project output).

Fix

A new CachedReadOnlySpanInitialization block transform collapses the lazy cache, mirroring CachedDelegateInitialization (which collapses the same lazy-static-field cache for anonymous-method delegates) and running right after it. Once the cache is collapsed, the existing array-initializer transforms recover the array literal, so the <PrivateImplementationDetails> reference disappears - the same result the modern RuntimeHelpers.CreateSpan path (net7+) already produces.

Before (does not recompile):

public static ReadOnlySpan<char> NewLine
{
    get
    {
        object obj = <PrivateImplementationDetails>.B7F5...7075_A1;
        if (obj == null)
        {
            obj = new char[2] { CR, LF };
            <PrivateImplementationDetails>.B7F5...7075_A1 = (char[])obj;
        }
        return new ReadOnlySpan<char>((char[])obj);
    }
}

After:

public static ReadOnlySpan<char> NewLine => new ReadOnlySpan<char>(new char[2] { CR, LF });

The transform only fires on a compiler-generated static cache field with this exact lazy-init shape, so it does not touch the existing <PrivateImplementationDetails> switch-on-string cache (covered by the existing CS1xSwitch_Debug test) or user code.

Test

ILPretty/CachedReadOnlySpanInitialization exercises the legacy lazy-cache IL and checks the recovered output.

Real-world occurrence: Markdig CodeInlineParser (net462).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses a decompilation correctness issue on frameworks without RuntimeHelpers.CreateSpan by collapsing Roslyn’s legacy lazy static cache pattern used for ReadOnlySpan<T> array literals, preventing unexpressible/undeclared <PrivateImplementationDetails> references in the generated C#.

Changes:

  • Add CachedReadOnlySpanInitialization block transform to collapse the legacy lazy-cache initialization pattern.
  • Register the new transform immediately after CachedDelegateInitialization in the IL transform pipeline.
  • Add an ILPretty regression test (CachedReadOnlySpanInitialization) with expected C# output.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
ICSharpCode.Decompiler/IL/Transforms/CachedReadOnlySpanInitialization.cs New block transform that recognizes and collapses the legacy lazy cache pattern for ReadOnlySpan<T> array literals.
ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs Inserts the new transform after CachedDelegateInitialization in the per-block transform pipeline.
ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CachedReadOnlySpanInitialization.il New ILPretty input reproducing the legacy cached-array pattern (using <PrivateImplementationDetails>).
ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CachedReadOnlySpanInitialization.cs Expected decompiled C# output fixture for the new ILPretty test.
ICSharpCode.Decompiler.Tests/ILPrettyTestRunner.cs Adds a new NUnit test entry to run the new ILPretty case.
ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj Includes the new .il test input in the project.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ICSharpCode.Decompiler/IL/Transforms/CachedReadOnlySpanInitialization.cs Outdated
Comment thread ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj Outdated
Comment thread ICSharpCode.Decompiler/IL/Transforms/CachedReadOnlySpanInitialization.cs Outdated
Comment thread ICSharpCode.Decompiler/IL/Transforms/CachedReadOnlySpanInitialization.cs Outdated
Comment thread ICSharpCode.Decompiler/IL/Transforms/CachedReadOnlySpanInitialization.cs Outdated
Comment thread ICSharpCode.Decompiler/IL/Transforms/CachedReadOnlySpanInitialization.cs Outdated
Comment thread ICSharpCode.Decompiler/IL/Transforms/CachedReadOnlySpanInitialization.cs Outdated
Comment thread ICSharpCode.Decompiler.Tests/ILPrettyTestRunner.cs Outdated
@sailro
sailro requested a review from siegfriedpammer July 12, 2026 22:02
@siegfriedpammer

siegfriedpammer commented Jul 13, 2026

Copy link
Copy Markdown
Member

@sailro I think I have to apologize to you, after some investigation I noticed that the legacy pattern with the cache field was in-fact the correct choice of test. I have re-added a test. Sorry again.

… legacy lazy cache

Roslyn caches a ReadOnlySpan<T> created from an array literal in a
<PrivateImplementationDetails> field on target frameworks without
RuntimeHelpers.CreateSpan (e.g. .NET Framework / netstandard2.0 + System.Memory):

    object obj = <PrivateImplementationDetails>.cache;
    if (obj == null) {
        obj = new char[] { '\r', '\n' };
        <PrivateImplementationDetails>.cache = (char[])obj;
    }
    ... new ReadOnlySpan<char>((char[])obj) ...

The decompiled output referenced the compiler-synthesized
<PrivateImplementationDetails> type, whose escaped name is not expressible in C#
and is never declared, so the output failed to recompile (CS0400).

The modern RuntimeHelpers.CreateSpan form was already handled
(TransformRuntimeHelpersCreateSpanInitialization); this adds the analogous
handling for the legacy lazy-cache form, mirroring CachedDelegateInitialization
(which collapses the same lazy-static-field cache for anonymous-method delegates).
Once the cache is collapsed, the existing array-initializer transforms recover
the array literal, so the <PrivateImplementationDetails> reference disappears.

Test: ILPretty/CachedReadOnlySpanInitialization.
@siegfriedpammer
siegfriedpammer force-pushed the fix-readonlyspan-privateimpldetails-cache branch from 5b2b4cd to 29f68aa Compare July 13, 2026 07:19
@siegfriedpammer
siegfriedpammer merged commit b0e4466 into icsharpcode:master Jul 13, 2026
7 checks passed
@sailro
sailro deleted the fix-readonlyspan-privateimpldetails-cache branch July 13, 2026 09:23
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.

Decompiling a ReadOnlySpan<T> built from an array literal references the undeclared <PrivateImplementationDetails> cache (does not recompile)

3 participants