Fix NullReferenceException in Entity on unlinked placeholder entities - #20274
Conversation
❗ Release notes requiredYou can open this PR in browser to add release notes: open in github.dev Caution No release notes found for the changed paths (see table below). Please make sure to add an entry with an informative description of the change as well as link to this pull request, issue and language suggestion if applicable. Release notes for this repository are based on Keep A Changelog format. The following format is recommended for this repository: `* . (PR #XXXXX)`
If you believe that release notes are not necessary for this PR, please add NO_RELEASE_NOTES label to the pull request.
Warning No PR link found in some release notes, please consider adding it.
|
NullReferenceException in Entity on unlinked placeholder entities
There was a problem hiding this comment.
@T-Gro this is another fix (I can move to another PR if you want)
T-Gro
left a comment
There was a problem hiding this comment.
Thanks for digging into this — resilient partial classification during mid-edit is a real, worthwhile goal, and the guarded remapTyconAug / entity_modul_type remap in TypedTreeOps.Remapping.fs are the right shape. A few blocking items before this can go in:
It doesn't build. Marking entity_modul_type and entity_tycon_tcaug as | null in TypedTree.fsi propagates nullability to consumers that weren't updated, and nullness warnings are errors here (outside Proto). A local -c Release build fails with FS3261 at:
TypedTreeOps.Transforms.fs(991)—d.entity_modul_type.Valueis the same unguarded deref you fixed inRemapping.fs, missed here.TypedTreePickle.fs(2818)—p_maybe_lazy p_modul_typ x.entity_modul_typepasses a nullable to a non-nullable target.TypedTreePickle.fs(2837–2852)— eightTyconAugmentation | nullincompatibilities in the tcaug pickle/unpickle path.
That's why every Build_And_Test_* leg is red. If you make these fields nullable you have to sweep all consumers, including the pickler.
~1360 lines of the TypedTree.fs diff are trailing-whitespace churn — only ~20 lines are real change. This trips CheckCodeFormatting, buries the actual fix, and will conflict with everything. Please revert the whitespace-only edits (and the lone one in lib.fs) so the diff is just the guards.
Scope of the type change. These fields were already effectively null for NewUnlinked() placeholders (Unchecked.defaultof<_>), so | null documents an existing latent invariant — but flipping two core Entity fields to nullable forces null-checks everywhere and is a big hammer for an IDE-resilience fix. Worth considering whether the dangling unlinked entity reaching the classification path is itself the bug to fix, rather than making the field type nullable tree-wide.
NewUnlinked() value changes are unnecessary and risky. Switching the placeholder to entity_logical_name = "<unknown>", entity_tycon_repr = TNoRepr, entity_typars = NotLazy [] changes observable pre-Link state on the metadata-unpickling hot path. IsLinked keys off entity_attribs (still defaultof), so linkage detection survives, but none of this is needed for the NRE fix — suggest reverting to Unchecked.defaultof<_>.
No regression test. The linked issue has a clean repro (break a type decl mid-edit); please encode it so this can't silently come back.
lib.fs WeakMap.TryAdd behind #if NETSTANDARD2_0 is correct and a nice cleanup, but it's unrelated to the NRE — fine to keep, just calling it out.
One more: the issue body references files/functions not in this diff (vsintegration/src/FSharp.Editor/*, NewModified*, FreeVars, a NullRef-TypedTree-Classification-Fix.md). Reconciling the description with the actual change will help reviewers follow along.
d9c55d0 to
026148d
Compare
T-Gro
left a comment
There was a problem hiding this comment.
One note on the null-fallback design (complements the root-cause point).
| member x.TypeContents = x.entity_tycon_tcaug | ||
| member x.TypeContents = | ||
| match x.entity_tycon_tcaug with | ||
| | null -> TyconAugmentation.Create() |
There was a problem hiding this comment.
🤖🕵️ On the null branch TypeContents returns a fresh TyconAugmentation.Create() each access, but callers mutate the result in place (tcaug_super <-, tcaug_abstract <-, tcaug_closed <-). If an unlinked placeholder ever reaches such a path, those writes land on a throwaway object and are silently lost — turning a fail-fast NRE into silent typed-tree corruption.
| null -> TyconAugmentation.Create()ModuleOrNamespaceType (line 884) has the same shape. A shared sentinel, or keeping unlinked entities out of these paths (the root-cause fix), is safer.
There was a problem hiding this comment.
So do you think we must save TyconAugmentation.Create() on empty Entity creation?
There was a problem hiding this comment.
Fixed in the latest commit
There was a problem hiding this comment.
Also, should we then initialize these objects on empty entity creation instead of doing that lazily?
a9fc80b to
24f2fc9
Compare
This comment has been minimized.
This comment has been minimized.
Always return initialized value for `Entity.entity_modul_type` and `Entity.entity_tycon_tcaug`.
Always return initialized value for `Entity.entity_modul_type` and `Entity.entity_tycon_tcaug`.
c3d9be5 to
4a4d62e
Compare
Always return initialized value for `Entity.entity_modul_type` and `Entity.entity_tycon_tcaug`.
4a4d62e to
ba36e07
Compare
Always return initialized value for `Entity.entity_modul_type` and `Entity.entity_tycon_tcaug`.
ba36e07 to
3f31b99
Compare
This comment has been minimized.
This comment has been minimized.
Always return initialized value for `Entity.entity_modul_type` and `Entity.entity_tycon_tcaug`.
45d3fb2 to
c199150
Compare
|
@T-Gro is my approach correct now? |
T-Gro
left a comment
There was a problem hiding this comment.
Can you pls revert the whitespace only changes and only pick the essentials of the fix?
| #if NETSTANDARD2_0 | ||
| try table.Add(key, value) with | :? ArgumentException -> () | ||
| #else | ||
| table.TryAdd(key, value) |> ignore |
There was a problem hiding this comment.
🤖🕵️ Please drop the #if NETSTANDARD2_0 / #else here and keep the single try table.Add(key, value) with :? ArgumentException -> () form — it compiles on every TFM. FCS ships netstandard2.0 only, so the #else table.TryAdd branch never ships. Don't move it to a separate PR either; a net-current-only branch would be rejected for the same reason.
c199150 to
9acab97
Compare
Always return initialized value for `Entity.entity_modul_type` and `Entity.entity_tycon_tcaug`.
|
🔍 Tooling Safety Check — Affects-Bootstrap, Affects-Compiler-Output, Scope-Review-Needed
|
* Used safe `ModuleOrNamespaceType` property instead of `entity_modul_type`. * Used safe `TypeContents` property instead of `entity_tycon_tcaug`. * Optimized `TextViewEventsHandler` memory usage with `voption`
Always return initialized value for `Entity.entity_modul_type` and `Entity.entity_tycon_tcaug`.
9acab97 to
6be833e
Compare
| { entity_typars = Unchecked.defaultof<_> | ||
| static member NewUnlinked() : Entity = | ||
| { entity_typars = LazyWithContext.NotLazy [] | ||
| entity_flags = Unchecked.defaultof<_> | ||
| entity_stamp = Unchecked.defaultof<_> | ||
| entity_logical_name = Unchecked.defaultof<_> | ||
| entity_range = Unchecked.defaultof<_> | ||
| entity_logical_name = "<unknown>" |
There was a problem hiding this comment.
It's important that this does absolute minimum of allocations, Entity is a hot path object.
The string constant is a risk of incorrect data (as opposed to a NRE).
There was a problem hiding this comment.
Should it be handled on the property level as the other nullable fields?
or would you like me just make it nullable and handle on usage?
| let allFieldsText = | ||
| fields | ||
| |> Array.map (fun f -> f.LogicalName) | ||
| |> Seq.map _.LogicalName |
There was a problem hiding this comment.
There is no need to materialize transformation as the String.concat accepts seq. So why would we allocate an array?
There was a problem hiding this comment.
Can we close this?
| // does not work correctly (they may get incorrectly relinked to a default member) | ||
| |> List.filter (fun (isExplicitImpl, _) -> not isExplicitImpl) | ||
| |> List.map (fun (_, vref) -> vref.LogicalName, vref)), | ||
| |> Seq.filter (fun (isExplicitImpl, _) -> not isExplicitImpl) |
There was a problem hiding this comment.
Same here there is no need to materialize the list twice. One lazy sequence materialized at the end
There was a problem hiding this comment.
Can we close this?
| member x.ModuleOrNamespaceType = x.entity_modul_type.Force() | ||
| member x.ModuleOrNamespaceType = | ||
| match x.entity_modul_type with | ||
| | null -> x.entity_modul_type <- MaybeLazy.Strict (Construct.NewEmptyModuleOrNamespaceType ModuleOrType) |
There was a problem hiding this comment.
What are the consequences and what is the added value of adding a NewEmptyModuleOrNamespaceType here?
There was a problem hiding this comment.
The main point was to prevent the NullReferenceException
Summary
Fixes #20269 by making typed-tree remapping and classification resilient to placeholder/unlinked entities created while the file is in a broken mid-edit state. Instead of allowing a single null placeholder to crash the entire IDE classification pipeline, the compiler now treats absent module-type augmentation data as null and skips the remap/guard path safely.
Changes
TyconAugmentationandentity_modul_typevalues.TyconAugmentationshape.WeakMapcache update compatible across target frameworks while preserving existing behavior.NullReferenceExceptionbreaks IDE syntax coloring on unlinked placeholder entities (e.g. broken code mid-edit) #20269.