Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Nov 11, 2025

Investigation: Object expressions in struct types generate invalid IL with byref fields

Problem

When object expressions inside struct member methods capture struct constructor parameters or fields, the compiler generates illegal byref<Struct> fields in closure classes, causing TypeLoadException at runtime.

Relates to #19068

This pull request was created as a result of the following prompt from Copilot chat.

Fix: Object expressions in struct types generate invalid IL with byref fields

Problem

When an object expression is created inside a struct member method and references values from the struct's constructor parameters or fields, the F# compiler generates invalid IL code. The generated closure class contains a byref<Struct> field, which is illegal according to CLI rules. This results in a TypeLoadException at runtime.

Repro Code (Currently Crashes)

type Class(test : obj) = class end

[<struct>]
type Struct(test : obj) =
  member _.Test() = {
    new Class(test) with
    member _.ToString() = ""
  }

// Runtime crash: TypeLoadException
let s = Struct(42)
let obj = s.Test()

Current Broken Behavior

The compiler generates IL equivalent to:

class Test@5 {
    ref Struct _;  // ← ILLEGAL! Classes cannot have byref fields
    
    Test@5(ref Struct s) {
        Unsafe.As<struct, ref="" struct="">(ref this._) = ref s;
        base(s.test);
    }
}

This violates .NET CLI rules and crashes at runtime.

Root Cause

The compiler's object expression handling in src/Compiler/Checking/Expressions/CheckExpressions.fs treats the enclosing struct instance (baseValOpt) as a captured variable. Instead of extracting the specific values needed from the struct, it tries to capture a reference to the entire struct, which becomes an illegal byref field in the generated closure class.

Solution: Extract and Capture Values Instead of Struct Reference

At type-checking time (before closure conversion), detect when an object expression inside a struct would capture the struct instance, and instead:

  1. Analyze what specific struct fields/parameters are actually used by the object expression methods
  2. Extract those values into local variables before creating the object expression
  3. Rewrite the object expression methods to use the local variables instead of accessing through this/baseValOpt
  4. Eliminate baseValOpt since we no longer need the struct reference

This preserves F# struct copy-by-value semantics and generates legal IL.

Expected Behavior After Fix

The compiler should generate IL equivalent to:

class Test@5 {
    object test$captured;  // ← LEGAL! Just an object field
    
    Test@5(object test) {
        this.test$captured = test;
        base(test);
    }
}

Implementation Location

Primary File: src/Compiler/Checking/Expressions/CheckExpressions.fs

Target Function: The function that type-checks object expressions and constructs Expr.Obj (likely named TcObjectExpr or similar, typically around lines 3000-4500)

Implementation Steps

Step 1: Add Helper to Collect Captured Struct Members

Add a function that analyzes object expression methods to find which struct members are captured:

/// Analyzes object expression methods to determine what struct members are captured
let collectCapturedStructMembers (g: TcGlobals) (structVal: Val) (overrides: TObjExprMethod list) (iimpls: (TType * TObjExprMethod list) list) =
    // Get all override method bodies
    let allMethods = overrides @ (iimpls |> List.collect snd)
    let methodBodies = allMethods |> List.map (fun (TObjExprMethod(_, _, _, _, body, _)) -> body)
    
    // Find free variables in all method bodies
    let allFreeVars = 
        methodBodies 
        |> List.collect (fun body -> 
            let frees = freeInExpr (CollectLocalsWithStackGuard()) body
            frees.FreeLocals |> Zset.toList)
        |> List.distinct
    
    // Filter to variables that are fields/parameters of the struct
    allFreeVars
    |> List.filter (fun freeVar ->
        // Check if this variable originates from the struct's declaring entity
        match freeVar.DeclaringEntity with
        | Parent tcref when tyconRefEq g tcref structVal.DeclaringEntity -> true
        | _ -> false)

Step 2: Add Helper to Create Capture Bindings

Add a function that creates local bindings for captured struct members:

/// Creates local bindings to capture specific struct members by value
let createCaptureBindings (g: TcGlobals) (structVal: Val) (capturedMembers: Val list) m =
    capturedMembers
    |> List.map (fun memberVal ->
        // Create a fresh local variable
        let localId = mkSynId m (memberVal.LogicalName + "$captured")
        let localVal = mkLocal localId memberVal.Type
        
        // Create expression to read the member value from struct
        let readExpr = exprForValRef m (mkLocalValRef memberVal)
        
        // Return the local variable and its binding
        localVal, mkInvisibleBind localVal readExpr)
    |> List.unzip

Step 3: Add Helper to Rewrite Method Bodies

Add a function that rewrites object expression methods to use captured locals:

/// Rewrites object expression method bodies to use captured locals instead of struct members
let rewriteMethodsToUseCapturedLocals (capturedMembers: Val list) (captureLocals: Val list) (methods: TObjExprMethod list) =
    // Build substitution map: struct member -> local variable
    let substMap = 
        (capturedMembers, captureLocals)
        ||> List.zip
        |> List.fold (fun acc (oldVal, newVal) ->
            ValMap.add oldVal (mkLocalValRef newVal) acc) ValMap.empty
    
    // Rewrite each method
    methods |> List.map (fun (TObjExprMethod(slotsig, attribs, tps, vs, body, methodRange)) ->
        let newBody = remarkExpr methodRange (remapValRefsInExpr substMap body)
        TObjExprMethod(slotsig, attribs, tps, vs, newBody, methodRange))

Step 4: Modify Object Expression Type-Checking

In the main TcObjectExpr function (or wherever Expr.Obj is constructed), add logic to handle struct captures:

// Find where baseValOpt, overrides, iimpls are created, then add:

// Check if we need to extract captures from a struct
let baseValOpt, prependBindings, overridesRewritten, iimplsRewritten = 
    match baseValOpt with
    | Some baseVal when isStructTy cenv.g baseVal.Type ->
        // Analyze what members are captured
        let capturedMembers = 
            collectCapturedStructMembers cenv.g baseVal overrides iimpls
        
        if List.isEmpty capturedMembers then
            // No captures - object expression doesn't use anything from struct
            None, [], overrides, iimpls
        else
            // Create local bindings for each captured member
            let captureLocals, captureBindings = 
                createCaptureBindings cenv.g baseVal capturedMembers m
            
            // Rewrite override methods to use the local variables
            let overridesRewritten = 
                rewriteMethodsToUseCapturedLocals capturedMembers captureLocals overrides
            
            let iimplsRewritten = 
                iimpls |> List.map (fun (ty, methods) -> 
                    ty, rewriteMethodsToUseCapturedLocals capturedMembers captureLocals methods)
            
            // Return: no baseVal, but we have bindings to prepend
            None, captureBindings, overridesRewritten, iimplsRewritten
    
    | _ -> 
        // Not a struct or no baseValOpt - use existing logic
        baseValOpt, [], overrides, iimpls

// Create the object expression with rewritten methods
let objExpr = 
    mkObjExpr(overallTy, baseValOpt, basecall, overridesRewritten, iimplsRewritten, m)

// Wrap with capture bindings if any
let finalExpr =
    if List.isEmpty prependBindings then
        objExpr
    else
        mkLetsBind m prependBindings objExpr

Test Cases to Add

Test 1: Basic Capture (Original Repro) - Runtime Test

File: tests/FSharp.Compiler.ComponentTests/Conformance/Expressions/ObjectExpressions/ObjectExpressionsInStructs.fs

[<fact>]
let ``Object expression in struct capturing constructor parameter should work`` () =
    Fsx """
type Class(test : obj) = class end

[<Struct>]
type Struct(test : obj) =
  member _.Test() = {
    new Class(test) with
    member _.ToString() = ""
  }

let s = Struct(42)
let obj = s.Test()
printfn "%s" (obj.ToString())
    """
    |> compileExeAndRun
    |> shouldSucceed

Test 2: Multiple Captures - Runtime Test

[<Fact>]
let ``Object expression in struct capturing multiple fields`` () =
    Fsx """
[<Struct>]
type Struct(a: int, b: string) =
  member _.Test() = {
    new Object() with
    member _.ToString() = sprintf "%d: %s" a b
  }

let s = Struct(42, "hello")
let obj = s.Test()
if obj.ToString() <> "42: hello" then failwith "Failed"
    """
    |> compileExeAndRun
    |> shouldSucceed

Test 3: No Captures - Regression Test

[<Fact>]
let ``Object expression in struct with no captures should not regress`` () =
    Fsx """
[<Struct>]
type Struct(test : obj) =
  member _.Test() = {
    new Object() with
    member _.ToString() = "constant"
  }

let s = Struct(42)
let obj = s.Test()
if obj.ToString() <> "constant" then failwith "Failed"
    """
    |> compileExeAndRun
    |> shouldSucceed

Test 4: Copy Semantics - Runtime Test

[<Fact>]
let ``Object expression captures struct values at creation time (copy semantics)`` () =
    Fsx """
[<Struct>]
type MutableStruct() =
    [<DefaultValue>] val mutable x : int
    member this.CreateObj() = {
        new Object() with
        member _.Get() = this.x
    }

let mutable s = MutableStruct()
s.x <- 10
let obj = s.CreateObj()
s.x <- 99
// Should see the value at capture time (10), not after mutation (99)
if (obj :?> obj).GetType().GetMethod("Get").Invoke(obj, [||]) :?> int <> 10 then 
    failwith "Expected 10"
    """
    |> compileExeAndRun
    |> shouldSucceed

Test 5: EmittedIL Verification Test

File: tests/FSharp.Compiler.ComponentTests/EmittedIL/Structure/ObjectExpressionsInStructs.fs

[<Theory; Directory(__SOURCE_DIRECTORY__, Includes=[|"ObjectExprInStruct.fs"|])>]
let ``Verify no byref fields in closure for struct object expression`` compilation =
    compilation
    |> asFs
    |> withOptions ["--optimize-"]
    |> compile
    |> shouldSucceed
    |> verifyIL [
        // Verify the closure class doesn't have byref fields
        ".class private auto ansi sealed nested assembly beforefieldinit"
        ".field public object test\\$captured"  // Should be object, not byref
    ]

With test file ObjectExprInStruct.fs:

type Class(test : obj) = class end

[<Struct>]
type Struct(test : obj) =
  member _.Test() = {
    new Class(test) with
    member _.ToString() = ""
  }

Test 6: Interface Implementation

[<Fact>]
let ``Object expression in struct implementing interface`` () =
    Fsx """
[<Struct>]
type Struct(value: int) =
  member _.CreateDisposable() = {
    new System.IDisposable with
    member _.Dispose() = printfn "Disposed: %d" value
  }

let s = Struct(42)
let disp = s.CreateDisposable()
disp.Dispose()
    """
    |> compileExeAndRun
    |> shouldSucceed
    |> withStdOutContains "Disposed: 42"

Test 7: Nested Struct Access

[<Fact>]
let ``Object expression in struct with nested struct field`` () =
    Fsx """
[<Struct>]
type Inner(x: int) =
    member _.X = x

[<Struct>]
type Outer(inner: Inner) =
    member _.Test() = {
        new Object() with
        member _.ToString() = inner.X.ToString()
    }

let s = Outer(Inner(42))
let obj = s.Test()
if obj.ToString() <> "42" then failwith "Failed"
    """
    |> compileExeAndRun
    |> shouldSucceed

Success Criteria

  1. ✅ Original repro code compiles without errors
  2. ✅ Generated IL contains no byref fields in closure classes
  3. ✅ Code runs without TypeLoadException
  4. ✅ Struct copy-by-value semantics are preserved
  5. ✅ No regressions in existing compiler tests
  6. ✅ All new test cases pass

Additional Context

Release Notes

Release notes have been added to docs/release-notes/.FSharp.Compiler.Service/11.0.0.md documenting this fix.


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

…ef fields

Co-authored-by: T-Gro <46543583+T-Gro@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix invalid IL generation with byref fields in structs Fix object expressions in struct types generating invalid IL with byref fields Nov 11, 2025
Copilot AI requested a review from T-Gro November 11, 2025 13:15
Co-authored-by: T-Gro <46543583+T-Gro@users.noreply.github.com>
…ementations

Co-authored-by: T-Gro <46543583+T-Gro@users.noreply.github.com>
…ar early guards

Moved transformation logic from CheckExpressions.fs to CheckExpressionsOps.fs as requested.
Added clear early guard conditions and comprehensive documentation.

The transformation now has explicit early exits for:
- Interface-only implementations (isInterfaceTy check)
- Empty method bodies
- No struct members captured

This makes the code architecture clearer about when the new code executes.

Co-authored-by: T-Gro <46543583+T-Gro@users.noreply.github.com>
Removed the overly complex generic type annotation for overridesAndVirts parameter
that was causing type mismatch errors. F# can now properly infer the type from usage.

Co-authored-by: T-Gro <46543583+T-Gro@users.noreply.github.com>
The transformation is causing false positives - it's being applied to object
expressions in non-struct contexts, leading to byref type errors in files like
ServiceParamInfoLocations.fs.

Root cause: The check for `v.IsInstanceMember && isStructTyconRef v.DeclaringEntity`
is too broad - it matches any struct instance member that appears as a free variable,
even when the object expression is not inside a struct instance member method.

Need to add context awareness to only transform when we're truly inside a struct
instance member context, not just when we happen to reference any struct member.

Co-authored-by: T-Gro <46543583+T-Gro@users.noreply.github.com>
Copilot AI changed the title Fix object expressions in struct types generating invalid IL with byref fields [WIP] Fix object expressions in struct types generating invalid IL with byref fields Dec 31, 2025
Following reviewer's suggestion, the transformation now uses env.eFamilyType to
detect when we're actually inside a struct instance member method, and checks
that baseValOpt's type matches the enclosing struct.

This fixes false positives by ensuring we only transform when:
1. Not a pure interface implementation (not isInterfaceTy)
2. Inside a struct instance member (env.eFamilyType is a struct tyconRef)
3. baseVal is being captured (baseValOpt is Some)
4. baseVal's type matches the enclosing struct (tyconRefEq check)

This prevents transformation in module functions or class methods that happen
to reference struct members, which was causing build failures.

Co-authored-by: T-Gro <46543583+T-Gro@users.noreply.github.com>
@dotnet dotnet deleted a comment from Copilot AI Jan 15, 2026
@dotnet dotnet deleted a comment from Copilot AI Jan 15, 2026
@T-Gro
Copy link
Member

T-Gro commented Jan 19, 2026

/run fantomas

@T-Gro T-Gro marked this pull request as ready for review January 19, 2026 11:26
@T-Gro T-Gro requested a review from a team as a code owner January 19, 2026 11:26
@T-Gro T-Gro requested a review from abonie January 19, 2026 11:26
@github-actions
Copy link
Contributor

🔧 CLI Command Report

  • Command: /run fantomas
  • Outcome: success

✅ Patch applied:
- Files changed: 1
- Lines changed: 65

@T-Gro T-Gro enabled auto-merge (squash) January 19, 2026 12:24
@T-Gro T-Gro changed the title [WIP] Investigation: Object expressions in struct types generating invalid IL with byref fields Bugfix :: Object expressions in struct types generating invalid IL with byref fields Jan 19, 2026
@T-Gro
Copy link
Member

T-Gro commented Jan 19, 2026

@copilot :

Add release notes pointing to this PR #19070 based on the linked issue, tests added and implementation.

Added release note entry to FSharp.Compiler.Service/11.0.0.md documenting
the fix for object expressions in struct types generating invalid IL with
byref fields (Issue #19068, PR #19070).

The entry describes the runtime crash (TypeLoadException) that was fixed.

Co-authored-by: T-Gro <46543583+T-Gro@users.noreply.github.com>
auto-merge was automatically disabled January 19, 2026 12:36

Head branch was pushed to by a user without write access

Copy link
Contributor Author

Copilot AI commented Jan 19, 2026

@copilot :

Add release notes pointing to this PR #19070 based on the linked issue, tests added and implementation.

Added release note entry in commit 1c5a092 to docs/release-notes/.FSharp.Compiler.Service/11.0.0.md documenting the fix for Issue #19068.

@T-Gro T-Gro enabled auto-merge (squash) January 19, 2026 12:54
@T-Gro T-Gro added the NO_RELEASE_NOTES Label for pull requests which signals, that user opted-out of providing release notes label Jan 20, 2026
@github-project-automation github-project-automation bot moved this from New to In Progress in F# Compiler and Tooling Jan 20, 2026
@T-Gro T-Gro merged commit a9d3ead into main Jan 20, 2026
40 of 41 checks passed
@github-project-automation github-project-automation bot moved this from In Progress to Done in F# Compiler and Tooling Jan 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

NO_RELEASE_NOTES Label for pull requests which signals, that user opted-out of providing release notes

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

4 participants