Browse Source

Fix #3850: recover ReadOnlySpan<T> array literals from the 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.
pull/3882/head
Sebastien Lebreton 3 months ago committed by Siegfried Pammer
parent
commit
b0e446618f
  1. 6
      ICSharpCode.Decompiler.Tests/ILPrettyTestRunner.cs
  2. 6
      ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs
  3. 13
      ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CachedReadOnlySpanFromLazyCache.cs
  4. 63
      ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CachedReadOnlySpanFromLazyCache.il
  5. 17
      ICSharpCode.Decompiler.Tests/TestCases/Pretty/CachedReadOnlySpanInitialization.cs
  6. 1
      ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs
  7. 121
      ICSharpCode.Decompiler/IL/Transforms/CachedReadOnlySpanInitialization.cs

6
ICSharpCode.Decompiler.Tests/ILPrettyTestRunner.cs

@ -273,6 +273,12 @@ namespace ICSharpCode.Decompiler.Tests @@ -273,6 +273,12 @@ namespace ICSharpCode.Decompiler.Tests
await Run();
}
[Test]
public async Task CachedReadOnlySpanFromLazyCache()
{
await Run();
}
[Test]
public async Task ConstantBlobs()
{

6
ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs

@ -693,6 +693,12 @@ namespace ICSharpCode.Decompiler.Tests @@ -693,6 +693,12 @@ namespace ICSharpCode.Decompiler.Tests
await RunForLibrary(cscOptions: cscOptions);
}
[Test]
public async Task CachedReadOnlySpanInitialization([ValueSource(nameof(roslyn2OrNewerOptions))] CompilerOptions cscOptions)
{
await RunForLibrary(cscOptions: cscOptions);
}
[Test]
public async Task RefFields([ValueSource(nameof(roslyn4OrNewerOptions))] CompilerOptions cscOptions)
{

13
ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CachedReadOnlySpanFromLazyCache.cs

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
using System;
namespace ICSharpCode.Decompiler.Tests.TestCases.ILPretty
{
public static class CachedReadOnlySpanFromLazyCache
{
public static ReadOnlySpan<char> NewLine {
get {
return new ReadOnlySpan<char>(new char[2] { '\r', '\n' });
}
}
}
}

63
ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CachedReadOnlySpanFromLazyCache.il

@ -0,0 +1,63 @@ @@ -0,0 +1,63 @@
// Regression fixture for the CachedReadOnlySpanInitialization transform.
//
// This is real Roslyn codegen (net6.0, /optimize) for
// public static ReadOnlySpan<char> NewLine => new char[] { '\r', '\n' };
// On target frameworks without RuntimeHelpers.CreateSpan (pre-.NET 7, or netstandard2.0 +
// System.Memory) Roslyn caches the backing array in a lazy <PrivateImplementationDetails> static
// field. Without the transform the decompiled output references that compiler-synthesized type,
// whose escaped name is not expressible in C# and is never declared, so it fails to recompile.
// The transform collapses the cache back to the ReadOnlySpan constructor.
.assembly extern System.Runtime
{
.publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
.ver 4:0:0:0
}
.assembly CachedReadOnlySpanFromLazyCache
{
.hash algorithm 0x00008004
.ver 1:0:0:0
}
.module CachedReadOnlySpanFromLazyCache.dll
.class public abstract auto ansi sealed beforefieldinit ICSharpCode.Decompiler.Tests.TestCases.ILPretty.CachedReadOnlySpanFromLazyCache
extends [System.Runtime]System.Object
{
.method public hidebysig specialname static
valuetype [System.Runtime]System.ReadOnlySpan`1<char>
get_NewLine() cil managed
{
.maxstack 8
IL_0000: ldsfld char[] '<PrivateImplementationDetails>'::B7F560303EE2CCA55615B53FCFF87C6AB2C55F9E71A6CEA93C61B572213E7075_A1
IL_0005: dup
IL_0006: brtrue.s IL_0020
IL_0008: pop
IL_0009: ldc.i4.2
IL_000a: newarr [System.Runtime]System.Char
IL_000f: dup
IL_0010: ldtoken field int32 '<PrivateImplementationDetails>'::B7F560303EE2CCA55615B53FCFF87C6AB2C55F9E71A6CEA93C61B572213E7075
IL_0015: call void [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [System.Runtime]System.Array,
valuetype [System.Runtime]System.RuntimeFieldHandle)
IL_001a: dup
IL_001b: stsfld char[] '<PrivateImplementationDetails>'::B7F560303EE2CCA55615B53FCFF87C6AB2C55F9E71A6CEA93C61B572213E7075_A1
IL_0020: newobj instance void valuetype [System.Runtime]System.ReadOnlySpan`1<char>::.ctor(!0[])
IL_0025: ret
}
.property valuetype [System.Runtime]System.ReadOnlySpan`1<char> NewLine()
{
.get valuetype [System.Runtime]System.ReadOnlySpan`1<char> ICSharpCode.Decompiler.Tests.TestCases.ILPretty.CachedReadOnlySpanFromLazyCache::get_NewLine()
}
}
.class private auto ansi sealed '<PrivateImplementationDetails>'
extends [System.Runtime]System.Object
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 )
.field static assembly initonly int32 B7F560303EE2CCA55615B53FCFF87C6AB2C55F9E71A6CEA93C61B572213E7075 at I_00002870
.field static assembly char[] B7F560303EE2CCA55615B53FCFF87C6AB2C55F9E71A6CEA93C61B572213E7075_A1
}
.data cil I_00002870 = bytearray (
0D 00 0A 00)

17
ICSharpCode.Decompiler.Tests/TestCases/Pretty/CachedReadOnlySpanInitialization.cs

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
using System;
namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty
{
public static class CachedReadOnlySpanInitialization
{
// On target frameworks without RuntimeHelpers.CreateSpan (before .NET 7) Roslyn emits a
// compiler-generated lazy cache in <PrivateImplementationDetails> for a ReadOnlySpan<char>
// created from a multi-byte array literal. The CachedReadOnlySpanInitialization transform
// collapses that cache back to the explicit ReadOnlySpan constructor.
#if NET70
public static ReadOnlySpan<char> NewLine => new char[2] { '\r', '\n' };
#else
public static ReadOnlySpan<char> NewLine => new ReadOnlySpan<char>(new char[2] { '\r', '\n' });
#endif
}
}

1
ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs

@ -134,6 +134,7 @@ namespace ICSharpCode.Decompiler.CSharp @@ -134,6 +134,7 @@ namespace ICSharpCode.Decompiler.CSharp
// CachedDelegateInitialization must run after ConditionDetection and before/in LoopingBlockTransform
// and must run before NullCoalescingTransform
new CachedDelegateInitialization(),
new CachedReadOnlySpanInitialization(),
new StatementTransform(
// per-block transforms that depend on each other, and thus need to
// run interleaved (statement by statement).

121
ICSharpCode.Decompiler/IL/Transforms/CachedReadOnlySpanInitialization.cs

@ -0,0 +1,121 @@ @@ -0,0 +1,121 @@
// Copyright (c) 2026 Sebastien Lebreton
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using ICSharpCode.Decompiler.TypeSystem;
namespace ICSharpCode.Decompiler.IL.Transforms
{
/// <summary>
/// Collapses the compiler-generated lazy cache Roslyn emits for a <c>ReadOnlySpan&lt;T&gt;</c> that is
/// created from an array literal on target frameworks without <c>RuntimeHelpers.CreateSpan</c> (e.g.
/// .NET Framework or netstandard2.0 + System.Memory):
/// <code>
/// stloc V(ldobj T[](ldsflda &lt;PrivateImplementationDetails&gt;.cache))
/// if (V == null) {
/// stloc V(arrayInitializer)
/// stobj T[](ldsflda &lt;PrivateImplementationDetails&gt;.cache, ldloc V)
/// }
/// ... single usage of V, e.g. newobj ReadOnlySpan&lt;T&gt;(ldloc V) ...
/// </code>
/// is turned into:
/// <code>
/// stloc V(arrayInitializer)
/// ... single usage of V ...
/// </code>
/// Afterwards the existing array-initializer transforms recover the array literal, so the reference to
/// the compiler-synthesized <c>&lt;PrivateImplementationDetails&gt;</c> cache field disappears. Without
/// this transform the decompiled output references that field, whose escaped name
/// (<c>&lt;PrivateImplementationDetails&gt;</c>) is not expressible in C# and is never declared, so the
/// output does not recompile (CS0400).
///
/// This mirrors <see cref="CachedDelegateInitialization"/>, which collapses the analogous lazy cache for
/// anonymous-method delegates, and therefore runs right after it.
/// </summary>
public class CachedReadOnlySpanInitialization : IBlockTransform
{
public void Run(Block block, BlockTransformContext context)
{
if (!context.Settings.ArrayInitializers)
return;
// The store that loads the cache field precedes the if, at block.Instructions[i - 1],
// so there is nothing to match when i == 0.
for (int i = context.IndexOfFirstAlreadyTransformedInstruction - 1; i >= 1; i--)
{
if (block.Instructions[i] is IfInstruction inst && DoTransform(block, i, inst, context))
{
context.IndexOfFirstAlreadyTransformedInstruction = block.Instructions.Count;
}
}
}
/// <summary>
/// Matches
/// <code>
/// stloc V(ldobj(ldsflda cacheField)) // block.Instructions[i - 1]
/// if (comp(ldloc V == ldnull)) { // block.Instructions[i]
/// stloc V(value)
/// stobj(ldsflda cacheField, ldloc V)
/// }
/// </code>
/// and replaces the load-from-cache with the initializer value, dropping the if:
/// <code>
/// stloc V(value)
/// </code>
/// </summary>
static bool DoTransform(Block block, int i, IfInstruction inst, BlockTransformContext context)
{
// storeBeforeIf: stloc V(ldobj(ldsflda cacheField)), cacheField a compiler-generated static field.
if (block.Instructions[i - 1] is not StLoc { Value: LdObj { Target: LdsFlda { Field: var cacheField } } } storeBeforeIf)
return false;
if (!cacheField.IsCompilerGeneratedOrIsInCompilerGeneratedClass())
return false;
// V is assigned exactly twice (before-if load + in-if init) and read exactly three times
// (null-check condition + cache write-back + one real downstream usage), with no address-of.
var v = storeBeforeIf.Variable;
if (v.StoreCount != 2 || v.LoadCount != 3 || v.AddressCount != 0)
return false;
// The if must be a simple `if (...) { ... }` (no else) with a two-instruction body.
if (!inst.FalseInst.MatchNop() || inst.TrueInst is not Block trueBlock || trueBlock.Instructions.Count != 2)
return false;
// condition: V == null (MatchCompEqualsNull also accepts null == V and the negated forms).
if (!inst.Condition.MatchCompEqualsNull(out var nullCheckArg) || !nullCheckArg.MatchLdLoc(v))
return false;
// trueBlock[0]: stloc V(value)
if (trueBlock.Instructions[0] is not StLoc storeValue || storeValue.Variable != v)
return false;
// trueBlock[1]: stobj(ldsflda cacheField, ldloc V) -> the write-back to the same cache field.
if (trueBlock.Instructions[1] is not StObj stobj || !stobj.Target.MatchLdsFlda(out var cacheField2)
|| !cacheField.Equals(cacheField2) || !stobj.Value.MatchLdLoc(v))
{
return false;
}
context.Step("CachedReadOnlySpanInitialization", inst);
storeBeforeIf.Value = storeValue.Value;
block.Instructions.RemoveAt(i);
return true;
}
}
}
Loading…
Cancel
Save