Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
applyTo:
- "src/FSharp.Core/prim-types.{fs,fsi}"
- "src/FSharp.Core/list.{fs,fsi}"
- "src/FSharp.Core/array.{fs,fsi}"
- "src/Compiler/Facilities/LanguageFeatures.{fs,fsi}"
- "src/Compiler/Checking/PostInferenceChecks.fs"
- "src/Compiler/Checking/Expressions/CheckExpressions.fs"
- "src/Compiler/Checking/SignatureConformance.fs"
- "src/Compiler/Optimize/Optimizer.fs"
- "src/Compiler/TypedTree/TypedTree.{fs,fsi}"
- "src/Compiler/TypedTree/TypedTreePickle.{fs,fsi}"
- "src/Compiler/TypedTree/TypedTreeOps.Attributes.fs"
- "src/Compiler/TypedTree/WellKnownAttribs.{fs,fsi}"
- "tests/FSharp.Compiler.ComponentTests/EmittedIL/OptimizeClosureIfNotInlined.fs"
- "tests/FSharp.Compiler.ComponentTests/Conformance/Signatures/SignatureEnforcedAttributes.fs"
---

# FSharp.Core/compiler feature coupling

FSharp.Core is built by multiple compiler generations. An attribute or optimization flag used by Core can be ordinary metadata to the stage-1 compiler but a language-gated construct to the freshly built compiler.

- When FSharp.Core unconditionally defines or applies a compiler-recognized construct, assign its `LanguageFeature` to the lowest language version used to build that source. Do not leave it preview-only when Proto builds Core at a stable version; otherwise make the Core usage conditional.
- Test successful declaration at the assigned version and FS3350 at the preceding version. If imported metadata consumption is intentionally version-independent, compile the library at the assigned version and consume it at an older language version.
- Rebuild Proto FSharp.Core at the assigned language version with the freshly built Release compiler. A stage-1 or ordinary source-build is insufficient because an older compiler can treat a new attribute as ordinary metadata.
- For serialized optimization flags, preserve old-reader layout and correctness. Verify that an older compiler can consume the newer Core, and assess the performance fallback when it ignores the new optimization metadata.

See `docs/postmortems/near-miss-core-language-feature-gate-skew.md` for the stage-2 failure that established this contract.
1 change: 1 addition & 0 deletions docs/postmortems/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ These are referenced from [agentic instructions](https://github.com/dotnet/fshar
- [`regression-legacy-inline-metadata-dynamic-invocation.md`](regression-legacy-inline-metadata-dynamic-invocation.md) — a new inline-flag case reused a serialized bit pattern that already meant "required inline" in F# 5 binaries, breaking cross-assembly SRTP at runtime.
- [`regression-sourcebuild-cpm-runtime-version-floor.md`](regression-sourcebuild-cpm-runtime-version-floor.md) — renaming the CPM runtime-package pins to computed `$(System*CentralVersion)` aliases with a floor defeated source-build's `$(System*Version)` override, causing prebuilt/`NU1109` failures in the VMR that fsharp CI could not see.
- [`regression-parse-tree-fidelity-return-attributes.md`](regression-parse-tree-fidelity-return-attributes.md) — a semantic lowering moved into the parser made `SynBinding.attributes` drop `[<return: X>]`, so tools reading the untyped tree silently deleted attributes the source visibly had.
- [`near-miss-core-language-feature-gate-skew.md`](near-miss-core-language-feature-gate-skew.md) — FSharp.Core adopted optimization metadata unconditionally while the new compiler kept its declaration preview-only, so only a fresh-compiler VMR stage-2 rebuild exposed the F# 11 mismatch.
72 changes: 72 additions & 0 deletions docs/postmortems/near-miss-core-language-feature-gate-skew.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
title: Core language-feature gate skew
category: Postmortems
categoryindex: 550
index: 600
---
# Near miss: FSharp.Core used a feature gated beyond its build language

## Summary

[#20422](https://github.com/dotnet/fsharp/pull/20422) made `[<OptimizeClosureIfNotInlined>]` part of FSharp.Core and applied it to inline List and Array functions, while the new compiler allowed declarations using the attribute only under `LangVersion=preview`. The .NET VMR stage-2 build then used that compiler to rebuild Proto FSharp.Core at F# 11 and failed with FS3350. VMR CI caught the regression before release; no released SDK, NuGet package, or user workload was affected.

## Error Manifestation

In VMR build 1601188, `SB_CentOSStream10_Offline_CurrentSourceBuiltSdk_x64` passed stage 1 and the vertical builds, then failed when stage 2 rebuilt Proto FSharp.Core with the newly source-built compiler:

```text
list.fs(242,71): error FS3350: Feature 'optimize a curried closure argument when its inlining fails' is not available in F# 11.0. Please use language version 'PREVIEW' or greater.
```

The source line was the unconditional `[<InlineIfLambda; OptimizeClosureIfNotInlined>]` annotation on `List.iteri`. Other annotated List and Array functions had the same incompatible compiler/Core version contract.

## Root Cause

The change crossed a self-hosting boundary that was treated as two independent concerns:

1. FSharp.Core defined `OptimizeClosureIfNotInlinedAttribute`, made affected List and Array functions inline, and attached the attribute to their callback parameters. This made the attribute part of the library surface and its optimization metadata for every FSharp.Core build.
2. The compiler recognized the attribute, stored it in a `ValFlags` bit, and used it to hoist one `OptimizedClosures.Adapt` when an opaque callback could not be inlined. Declaration validation separately mapped `LanguageFeature.OptimizeClosureIfNotInlined` to `previewVersion`.

`Configuration=Proto` does not select preview. Once FSharp.Core used the attribute unconditionally, its declaration gate had to be available in every language version used to build that source, including F# 11.

The asymmetry explained the apparently contradictory results. A stage-1 compiler predating #20422 did not recognize `OptimizeClosureIfNotInlined` as a well-known attribute, so it treated the annotation as ordinary metadata and compiled FSharp.Core. The newly built stage-2 compiler recognized the attribute and ran `checkLanguageFeatureError` while checking its declaration, so the same source failed at F# 11.

Consumer language version was not the problem. Imported metadata does not run the declaration check, and optimizer consumption of the `OptimizeClosureIfNotInlined` flag is not language-version gated. The component test that compiles the attributed library at F# 11 and consumes it at F# 8 proves that an old-language consumer can still receive the optimization.

Compiler/Core binary skew also remains correct. `ValFlags` is serialized as a fixed-width `int64`; a compiler predating this flag reads the same field and ignores the unknown bit, so the metadata layout remains aligned and the inline function still executes correctly. That older compiler cannot perform the new hoisted-`Adapt` transform, however, so an opaque callback can retain per-call curried dispatch inside the newly inline List or Array loop. The skew is therefore a performance compatibility concern, not a correctness or metadata compatibility failure.

The violated invariant was: **when FSharp.Core unconditionally adopts compiler-recognized syntax, attributes, or optimization metadata, the compiler feature gate and every compiler generation that builds Core must agree on the minimum supported language version.**

## Why It Escaped

The positive and structural component tests compiled their attributed sources with `withLangVersionPreview`, so they proved the feature implementation without proving the F# 11 boundary required by Proto. The availability test rejected F# 8 but did not assert that F# 11 was the first accepted version.

The ordinary bootstrap also masked the defect. Stage 1 used an older compiler that did not recognize the new attribute, while the normal non-Proto product build could use preview. Only the VMR stage-2 flow combined the newly built compiler with a Proto FSharp.Core rebuild at F# 11. fsharp CI did not exercise that exact fresh-compiler/Proto-language combination before the change forward-flowed.

The failure appeared in FSharp.Core, but the incompatible decision was in the compiler's language-feature table. That separation made each side look locally valid while violating their shared self-hosting contract.

## Fix

[#20571](https://github.com/dotnet/fsharp/pull/20571) maps `LanguageFeature.OptimizeClosureIfNotInlined` to `languageVersion110`. Positive optimization tests and structural-validation tests now compile at F# 11, the boundary test rejects F# 10 with FS3350, and the cross-assembly test compiles the attributed library at F# 11 while retaining its F# 8 consumer.

The matching language release note moved from preview to F# 11. Validation rebuilt Proto FSharp.Core at F# 11 with the freshly built Release compiler, reproducing the VMR stage-2 compiler/Core pairing.

## Timeline

| Date | Event |
| --- | --- |
| 2026-09-16 | #20422 merged as `ec437d5ac2f03c27eb3b4a9ec14fb829570baadf`, adding the attribute, compiler optimization, and unconditional List/Array usage. |
| 2026-09-17 | VMR build 1601188 passed stage 1 and vertical builds, then failed the offline current-source-built-SDK stage-2 Proto FSharp.Core rebuild with FS3350. |
| 2026-09-18 | #20571 assigned the feature to F# 11, moved the release note, pinned the F# 11/F# 10/F# 8 boundaries, and passed a fresh-compiler Proto FSharp.Core rebuild. |

## Prevention

`.github/instructions/CoreCompilerFeatureCoupling.instructions.md` scopes the shared contract to the FSharp.Core declarations/usages, compiler feature gate and metadata machinery, optimizer, and regression tests involved in this class of change.

For any compiler-recognized construct or optimization metadata adopted unconditionally by FSharp.Core:

- assign its language feature to the lowest language version used to build that Core source, or make the Core usage conditional;
- test successful declaration at that version and FS3350 at the preceding version;
- test imported metadata with an older consumer language version when consumption is intentionally version-independent;
- rebuild Proto FSharp.Core at that language version with the freshly built Release compiler, not only the stage-1 compiler;
- preserve cross-version metadata layout and correctness, and assess the performance fallback when an older compiler ignores new optimization metadata.
2 changes: 1 addition & 1 deletion docs/release-notes/.FSharp.Compiler.Service/11.0.100.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@
* IL: use empty tables for members when possible ([PR #20249](https://github.com/dotnet/fsharp/pull/20249))
* Make Entity's adhoc members list lazy ([PR #20286](https://github.com/dotnet/fsharp/pull/20286/changes))
* Constraint solver: `TryD` is now `inline` with `[<InlineIfLambda>]` on its always-run continuation, so the argument closures are no longer allocated at the (very hot) constraint-solver call sites; `IgnoreFailedMemberConstraintResolution` is `inline` so its forwarded continuation stays a literal. ([PR #20367](https://github.com/dotnet/fsharp/pull/20367))
* `[<OptimizeClosureIfNotInlined>]` adapts opaque callbacks once instead of checking their arity on every call. Lifted recursive methods no longer trigger unrelated file initialization, which could deadlock. ([PR #20422](https://github.com/dotnet/fsharp/pull/20422))
* `[<OptimizeClosureIfNotInlined>]`, available in F# 11, adapts opaque callbacks once instead of checking their arity on every call. Lifted recursive methods no longer trigger unrelated file initialization, which could deadlock. ([PR #20422](https://github.com/dotnet/fsharp/pull/20422), [PR #20571](https://github.com/dotnet/fsharp/pull/20571))
* `DelayedILModuleReader` no longer boxes its cached `ILModuleReader` on every read: the field is typed `ILModuleReader | null` and matched directly. ([PR #20413](https://github.com/dotnet/fsharp/pull/20413))
* Typed tree: create a type's augmentation on first use ([PR #20494](https://github.com/dotnet/fsharp/pull/20494))
* Optimizer: passing a partial application of a non-inline module-level function to an `[<InlineIfLambda>]` parameter (e.g. `xs |> Option.map (f a b)`) no longer allocates a per-call `FSharpFunc` closure when a captured argument is non-trivial (a field read, a call). Under optimization the argument is eta-expanded to a lambda with its captured evaluations floated above the binding, so the parameter's uses beta-reduce and the closure is eliminated. Captured arguments are still evaluated exactly once, in their original left-to-right order, and the binding keeps its sequence point. Partial applications of inline/SRTP functions and curried members can still allocate closures. ([PR #20487](https://github.com/dotnet/fsharp/pull/20487))
Expand Down
1 change: 1 addition & 0 deletions docs/release-notes/.Language/11.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Warn (FS3884) when a function or delegate value is used as an interpolated string argument, since it will be formatted via `ToString` rather than being applied. ([PR #19289](https://github.com/dotnet/fsharp/pull/19289))
* Added `MethodOverloadsCache` language feature that caches overload resolution results for repeated method calls, significantly improving compilation performance. ([PR #19072](https://github.com/dotnet/fsharp/pull/19072))
* Added `ErrorOnMissingSignatureAttribute` language feature: makes FS3888 (compiler-semantic attribute on the `.fs` but not on the `.fsi`) an error instead of a warning. ([Issue #19560](https://github.com/dotnet/fsharp/issues/19560), [PR #19880](https://github.com/dotnet/fsharp/pull/19880))
* `[<OptimizeClosureIfNotInlined>]`, paired with `[<InlineIfLambda>]` on a curried arity 2–5 callback of an inlined function, makes the optimizer adapt the callback once via `OptimizedClosures` when it is passed opaquely rather than as a known lambda (feature `OptimizeClosureIfNotInlined`). ([PR #20422](https://github.com/dotnet/fsharp/pull/20422))
* Support common types of `NotNullIfNotNullAttribute` usage. If a method parameter is marked with `NotNullIfNotNullAttribute`, the compiler will now honor this attribute and mark the return type as non-null. ([PR #19977](https://github.com/dotnet/fsharp/pull/19977))
* Spread operator for records ([RFC FS-1151](https://github.com/fsharp/fslang-design/pull/805), [PR #18927](https://github.com/dotnet/fsharp/pull/18927))
* Added `AccessProtectedBaseFieldFromClosure` language feature: a derived member can now read a `protected` base-class field from an ordinary closure (lambda, delegate, `async`/`seq`/`lazy`, `function`, or list/array literal), which previously failed with FS1097 even though direct access compiles. Object expressions remain unsupported — bind the field to a local function or expose it through a member. ([Issue #5302](https://github.com/dotnet/fsharp/issues/5302))
Expand Down
1 change: 0 additions & 1 deletion docs/release-notes/.Language/preview.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
* Warn (FS3884) when a function or delegate value is used as an interpolated string argument, since it will be formatted via `ToString` rather than being applied. ([PR #19289](https://github.com/dotnet/fsharp/pull/19289))
* Added `MethodOverloadsCache` language feature (preview) that caches overload resolution results for repeated method calls, significantly improving compilation performance. ([PR #19072](https://github.com/dotnet/fsharp/pull/19072))
* Added `ErrorOnMissingSignatureAttribute` preview language feature: makes FS3888 (compiler-semantic attribute on the `.fs` but not on the `.fsi`) an error instead of a warning. ([Issue #19560](https://github.com/dotnet/fsharp/issues/19560), [PR #19880](https://github.com/dotnet/fsharp/pull/19880))
* `[<OptimizeClosureIfNotInlined>]`, paired with `[<InlineIfLambda>]` on a curried arity 2–5 callback of an inlined function, makes the optimizer adapt the callback once via `OptimizedClosures` when it is passed opaquely rather than as a known lambda (feature `OptimizeClosureIfNotInlined`). ([PR #20422](https://github.com/dotnet/fsharp/pull/20422))
* Support common types of `NotNullIfNotNullAttribute` usage. If a method parameter is marked with `NotNullIfNotNullAttribute`, the compiler will now honor this attribute and mark the return type as non-null. ([PR #19977](https://github.com/dotnet/fsharp/pull/19977))
* Spread operator for records ([RFC FS-1151](https://github.com/fsharp/fslang-design/pull/805), [PR #18927](https://github.com/dotnet/fsharp/pull/18927))
* Added `AccessProtectedBaseFieldFromClosure` preview language feature: a derived member can now read a `protected` base-class field from an ordinary closure (lambda, delegate, `async`/`seq`/`lazy`, `function`, or list/array literal), which previously failed with FS1097 even though direct access compiles. Object expressions remain unsupported — bind the field to a local function or expose it through a member. ([Issue #5302](https://github.com/dotnet/fsharp/issues/5302))
Expand Down
2 changes: 1 addition & 1 deletion src/Compiler/Facilities/LanguageFeatures.fs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ type LanguageVersion(versionText, ?disabledFeaturesArray: LanguageFeature array)
LanguageFeature.AccessProtectedBaseFieldFromClosure, languageVersion110 // #5302: read a protected base field from a closure
LanguageFeature.RecordSpreads, languageVersion110
LanguageFeature.TypeArgumentDependencyOrdering, languageVersion110
LanguageFeature.OptimizeClosureIfNotInlined, languageVersion110

// Difference between languageVersion110 and preview - 11.0 gets turned on automatically by picking a preview .NET 11 SDK
// previewVersion is only when "preview" is specified explicitly in project files and users also need a preview SDK
Expand All @@ -204,7 +205,6 @@ type LanguageVersion(versionText, ?disabledFeaturesArray: LanguageFeature array)
// Unfinished features that still need work before they can be assigned a release language version.
LanguageFeature.FromEndSlicing, previewVersion // Unfinished features --- needs work
LanguageFeature.ExtensionConstraintSolutions, previewVersion
LanguageFeature.OptimizeClosureIfNotInlined, previewVersion
]

static let defaultLanguageVersion = LanguageVersion("default")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ let mkFolder () : int -> int -> int -> int = fun s x y -> s + x * y

let private optimized source =
FSharp source
|> withLangVersionPreview
|> withLangVersion "11.0"
|> withOptions [ "--optimize+" ]
|> compile
|> shouldSucceed

let private runOutput source =
FSharp source
|> withLangVersionPreview
|> withLangVersion "11.0"
|> withOptions [ "--optimize+" ]
|> compileExeAndRun
|> shouldSucceed
Expand Down Expand Up @@ -201,7 +201,7 @@ let inline fold2 ([<InlineIfLambda; OptimizeClosureIfNotInlined>] folder: 'S ->
for i in 0 .. a.Length - 1 do s <- folder s a.[i] b.[i]
s
"""
|> withLangVersionPreview
|> withLangVersion "11.0"
|> withOptions [ "--optimize+" ]
|> asLibrary

Expand Down Expand Up @@ -234,15 +234,15 @@ let callOpaque (a: int[]) (b: int[]) = Lib.fold2 (mkFolder ()) 0 a b
[<InlineData("type D = delegate of [<InlineIfLambda; OptimizeClosureIfNotInlined>] f: (int -> int -> int) -> unit")>]
let ``attribute is rejected where it cannot take effect`` (decl: string) =
FSharp ("module M\n" + decl)
|> withLangVersionPreview
|> withLangVersion "11.0"
|> compile
|> shouldFail
|> withErrorCode 3916

[<Fact>]
let ``attribute requires the preview language feature`` () =
let ``attribute requires FSharp 11`` () =
FSharp "module M\nlet inline f ([<InlineIfLambda; OptimizeClosureIfNotInlined>] g: int -> int -> int) x y = g x y"
|> withLangVersion "8.0"
|> withLangVersion "10.0"
|> compile
|> shouldFail
|> withErrorCode 3350
Loading