Skip to content

Passing in parameters in constructor of read-only struct shouldn't be allowed #122237

Description

@NicolasDorier

Problem

Passing in parameters to a constructor of a readonly struct shouldn't be possible. Or at least an analyzer should tell us something is wrong.

I made a video explaining the issue.

I am the maintainer of a widely used library, and a user reported a bug to me.
This happened:

  • Only on the Android runtime (though I don’t think Android itself is at fault)
  • Only on the fifth execution of the problematic code
  • Attempts to isolate the behavior in a simple Program.cs did not reproduce it

I managed to pin down the specific code that was causing the issue.

This code just create a variable a of type GEJ (readonly struct), then re-affect to itself 10 times.

var a = new GEJ(new FE(1, 2, 3, 4, 5, 6, 7, 8, 9, 10), new FE(11, 22, 33, 44, 55, 66, 77, 88, 99, 11), new FE(21, 22, 23, 24, 25, 26, 27, 28, 29, 210));
for (int o = 0; o < 10; o++)
{
	AddLogs(logs, "Before", a);
	a = new GEJ(a.x, a.y, a.z, a.infinity);
	AddLogs(logs, "After", a);
}

This code, when running outside of Android or outside my library, behaved as expected: the value of a did not change.

However, on Android and within my library’s code, it would deterministically stop working after roughly five iterations of the loop. After the fifth iteration, a would become 0.

The bug was here... can you see it?

public GEJ(in FE x, in FE y, in FE z, bool infinity)

And the fix was to remove in from the constructor.

What I believe happens is that when executing a = new GEJ(a.x, a.y, a.z, a.infinity);, in some unclear circumstances, the runtime zeros the stack space for variable a before running the constructor. But because those were in parameters, no copy was made and a.x is passed by reference and is equal to zero due to the stack cleaning.

Reproduction

  1. Clone the repository.
  2. Start android emulator (I use Android studio Pixel 8 API 36.0)
  3. Deploy (I used dotnet build -t:Run -f net9.0-android -p:JavaSdkDirectory=/usr/lib/jvm/jdk-21.0.9+10 -p:AndroidSdkDirectory=/home/nicolasdorier/Android/Sdk)
  4. Click on Run Test in the app.
    The code run is
    public static void Bug(GEJ x, List<string> logs)
    {
        var a = x;
        for (int i = 0; i < 1500; i++)
        {
            logs.Add($"Before {i}: " + a.x.n0.ToString());
            a = new GEJ(a.x, a.y, a.z, a.infinity);
            logs.Add($"After {i}: " + a.x.n0.ToString());
        }
    }

What you will see is that, at the 1000th iteration, a.x.n0 becomes inexplicably 0:

Image

Conclusion

This bug is triggered under such precise circumstances that I feel the language, or at least a Roslyn analyzer, should prevent us from using in in a struct constructor. Or maybe the runtime should detect that a defensive copy is needed.

Video

I made a video explaining the issue as best as I could.

In Parameter Warning

Activity

  1. CyrusNajmabadi commented on Dec 4, 2025

    @CyrusNajmabadi
    Contributor

    I don't understand. There's some bug with the runtime+android. Shouldn't that big just be fixed wherever it exists?

    Why does Roslyn need to make changes here?

  2. NicolasDorier commented on Dec 5, 2025

    @NicolasDorier
    Author

    @CyrusNajmabadi I do not think this is a bug from Android runtime. A behavior different from other runtime? yes, but I don't think Android runtime is necessarily wrong here.

    a = new A(a.x) when A is a struct and the constructor's parameter is in shouldn't be possible.

    I don't think the runtime is wrong to zero the stack before running the constructor. But either a defensive copy should have been done despite the parameter being in OR it shouldn't compile OR some analyzer should warn me that I am doing something ending up in uncharted territory.

  3. CyrusNajmabadi commented on Dec 5, 2025

    @CyrusNajmabadi
    Contributor

    when A is a struct and the constructor's parameter is in shouldn't be possible.

    Why not? It seems perfectly reasonable (and very important to support) to me.

    A.x might be a very large struct that you do not want copied. The constructor may be reading a few small pieces of data from it. Passing by in is the way you want to do this efficiently.

  4. CyrusNajmabadi commented on Dec 5, 2025

    @CyrusNajmabadi
    Contributor

    I don't think the runtime is wrong to zero the stack before running the constructor.

    Zeroing the stack inside the constructor is fine. But it shouldn't zero references to data being passed in, or the location it is going to write into. That's a bug.

    But either a defensive copy should have been done

    That would defeat the point of 'in'. You want to cheaply pass a reference without a copy, knowing it can't mutate.

    OR it shouldn't compile

    The code is legal and sensible. It should compile. Imagine the data you are passing in is huge, but you will only grab a little of it in the constructor. If you don't allow in, then that forces expensive copies. in exists exactly to allow this to be efficient. :-)

    OR some analyzer should warn me

    We're not going to earn because some runtime has a bug and doesn't execute this code properly. The right thing to do is fix that runtime.

  5. PetSerAl commented on Dec 5, 2025

    @PetSerAl
    Contributor

    C# specification is very clear, what behavior is expected here. It, in fact, require that struct constructor should be invoked on temporary local variable, and only then assigned to named local.

    That program, for example, required to print 25, but not 36:

    #:property Configuration=Release
    using System;
    
    Test1();
    Test2();
    
    void Test1() {
        S s = new();
        s = new();
        try {
            s = new();
        } catch { }
        Console.Write(s.I);
    }
    
    void Test2() {
        S s = new(0);
        s = new(0);
        try {
            s = new(0);
        } catch { }
        Console.Write(s.I);
    }
    
    readonly struct S {
        static int s_i;
        public readonly int I;
        public S() {
            I = ++s_i;
            if(I % 3 is 0) {
                throw new Exception();
            }
        }
        public S(in int i) : this() { }
    }

    https://lab.razor.fyi/#lVGxSgNBECWSQrcSv-CBCBcC4YxdQqpgcVbiCYKNnptNMnDZPXb2TI5wnY2Ff-BH-EP5D0vZSzySEASnmpk3M--9XfHZFOLWmolNZh3JZx_N815mTaasKzA0ekyT3CaOjB7cqVQlrETOpCeIC3Zq1hfiXrG7DFr9Kun6RLwaGmHTx1IAQAzGAFrN_YBv7JXOFpvJA2AJmTg5xRJlVQ-NZpOqzoMlpwLuRK2-KLdouwdowz3e8E_i8H_MViUjo9MC7GwuHeLNSXaJIwnSDvxE65NZ_pKSRL3iwWgHimv5PiIM0G7X6z5oHES4wBWIEW7PVoam1sy9CVwvpMr81_2-Y-Vo7WuXj3Qlg1rowU2JvQCUohQ3R5Q-nhx_vb1_r8anzefGovED

    C# compiler sometimes emit call to constructor directly on named local, but only when it can guaranty, that previous value of local can not be observed. For example, first two calls to new S() in Test1 are emitted as:

    call instance void S::.ctor()

    and only last as:

    newobj instance void S::.ctor()

    But, when you use constructor with in parameter (in Test2) all three calls to constructor use temporary local variable for S instance:

    newobj instance void S::.ctor(int32&)

    Now you should inspect what IL emitted for your code

    a = new GEJ(a.x, a.y, a.z, a.infinity);

    If it is newobj (as I expect it should be), but not call, then your "defensive copy" is already here: runtime is not allowed to modify named local a, until constructor successfully complete. Now if when tiered compilation kicks in, and JIT compiler decides to eliminate that temporary local and call constructor on named local directly, while it is still in use, then it is clear violation on specification.

  6. jjonescz commented on Dec 5, 2025

    @jjonescz
    Member

    This indeed sounds like an issue in runtime (probably JIT tiered compilation given it reproes only after some iterations), not roslyn. But we would need more details like a self-contained reproducible example to investigate further. Thanks.

  7. transferred this issue fromdotnet/roslynon Dec 5, 2025
  8. added
    needs-area-labelAn area label is needed to ensure this gets routed to the appropriate area owners
    on Dec 5, 2025
  9. added
    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI
    and removed
    needs-area-labelAn area label is needed to ensure this gets routed to the appropriate area owners
    on Dec 6, 2025
  10. NicolasDorier commented on Dec 9, 2025

    @NicolasDorier
    Author

    Thanks for the input, I will work on a repro. If that's indeed a bug, I want to note that this never happened on anything else than Android runtime.

  11. NicolasDorier commented on Dec 9, 2025

    @NicolasDorier
    Author

    @jjonescz I have managed to repro into a self-contained project... I added a Reproduction section to the description of this issue.

    I have no clue why this repro is taking 1000 iterations while with my library it takes only 5, but at least I could reproduce!

    @PetSerAl, if the spec is clear on this, this is probably a runtime bug. If that is the case, I am not sure this is the right repo, as it doesn't happen on linux or windows runtime. Only Android.

  12. JulieLeeMSFT commented on Dec 11, 2025

    @JulieLeeMSFT
    Member
  13. removed
    untriagedNew issue has not been triaged by the area owner
    on Dec 11, 2025
  14. added this to the 11.0.0 milestone on Dec 11, 2025
  15. added and removed
    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI
    on Jan 5, 2026
  16. dotnet-policy-service commented on Jan 5, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch
    See info in area-owners.md if you want to be subscribed.

  17. dotnet-policy-service commented on Jan 5, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @steveisok, @vitek-karas
    See info in area-owners.md if you want to be subscribed.

  18. added
    in-prThere is an active PR which will close this issue when it is merged
    on Jul 30, 2026
  19. lateralusX commented on Jul 30, 2026

    @lateralusX
    Member

    Fix in #131586. It's an interpreter optimization issue when running with INTERP_OPT_SUPER_INSTRUCTIONS, that only kicks in after executing a method 1000 times.

  20. added a commit that references this issue on Jul 31, 2026
    6e9facc
  21. locked and limited conversation to collaborators on Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area-Codegen-JIT-monoin-prThere is an active PR which will close this issue when it is merged

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions