Add CSWINRT2021 analyzer for reads from write-only Span<T> parameters - #2438
Open
Sergio0694 wants to merge 11 commits into
Open
Add CSWINRT2021 analyzer for reads from write-only Span<T> parameters#2438Sergio0694 wants to merge 11 commits into
Sergio0694 wants to merge 11 commits into
Conversation
…' parameters Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Detects reads from 'Span<T>' parameters on Windows Runtime methods, which are projected as write-only fill arrays in the ABI. Covers indexer reads, conversions to 'ReadOnlySpan<T>', and 'foreach' iteration, while skipping write-only usages (assignment targets, 'out'/'ref' arguments, writable 'ref' aliases and loop variables) to avoid false positives. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A 'foreach' loop with a writable 'ref' loop variable can legitimately be used to fill a write-only 'Span<T>' parameter, so the loop itself is not reported. However, the loop variable aliases the current element, so reading through it is still a read from the fill array. The 'foreach' handler now walks the loop body and reports every read through such a loop variable, reusing the same write-only heuristics already used for indexer accesses. The loop variable can't be captured by a lambda or be ref reassigned, so all of its aliasing usages are guaranteed to be in the body. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 55ba2269-4b18-4924-895a-bd614f8ed6a8
Sergio0694
force-pushed
the
user/sergiopedri/span-fill-array-analyzer
branch
from
August 1, 2026 19:28
1a78089 to
87a4ca0
Compare
The analyzer used to register a separate operation action for each kind of read it detects, which means every candidate read was inspected in isolation. That makes it impossible to reason about the code that runs before a read, which is needed to tell apart a read of an uninitialized element from a read of one the method has already filled in. Switch to a single operation block action that walks the body of each method with a fill array parameter and dispatches to a dedicated helper per read kind. The method level checks now run once per body instead of once per candidate read, and the parameter checks make sure the referenced parameter belongs to the method being analyzed (and not to a nested lambda or local function). There is no change in behavior: this is only preparation for the next commit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8c86c0e5-5a20-48b7-b8c6-b04c963edef0
Reading back a value the method itself has just written is perfectly valid: the
element being read is no longer uninitialized, so there is nothing to warn
about. The analyzer had no notion of that, which made it warn on a number of
legitimate patterns, such as filling a span and then reading it back.
A read is now skipped when a write covering the same location is guaranteed to
run before it:
- 'span.Clear()' and 'span.Fill(value)' initialize every element, so they
cover any subsequent read, including 'foreach' loops and conversions to
'ReadOnlySpan<T>'.
- 'span[i] = value' and 'Foo(out span[i])' cover subsequent reads of that
same element, including 'in' arguments and 'ref readonly' aliases.
- The same two forms through a writable 'ref' 'foreach' loop variable cover
subsequent reads of the element that variable aliases.
The analysis is deliberately conservative, as missing a write only means an
extra warning, whereas trusting one that doesn't actually happen would silently
hide a real bug. A write is only ever considered when it appears in a statement
that precedes (an ancestor of) the read within a common enclosing block, and
when it is unconditionally reached within that statement: anything nested in a
construct that might not run ('if', '?:', '??', '?.', 'switch', loops, 'try',
lambdas, local functions, '&&' and '||') is ignored. Only definite writes count,
so 'ref' arguments and writable 'ref' aliases are not treated as initializing.
Finally, nothing is suppressed in a body containing a 'goto' (which could jump
over the write), or when the span parameter or the index variable might have
been reassigned in between.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8c86c0e5-5a20-48b7-b8c6-b04c963edef0
Cover the reads that are now recognized as valid, i.e. those preceded by a
'Clear()' or 'Fill(value)' call, by an assignment to the same element, or by an
assignment through a writable 'ref' 'foreach' loop variable.
Also cover the cases that must keep warning, to make sure the new logic can't
hide an actual read of an uninitialized element: reads before the write, writes
to a different element or to a different span, writes that are only reached
conditionally (including in a 'switch' section or behind '&&'), writes that
might not happen at all ('ref' arguments and writable 'ref' aliases), writes
skipped by a 'goto', a same named extension method, and writes invalidated by
the index variable or the span parameter being reassigned in between.
The reads in 'ForEachWritableRefVariableReads_Warn' now come before the writes
in the loop body, as the loop variable is otherwise initialized by the time
they run, which is exactly the pattern that is no longer reported.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8c86c0e5-5a20-48b7-b8c6-b04c963edef0
Four cases were found where a read could be skipped even though the write it
relies upon might not have happened, or applied to a different location:
- Field and property initializers can reference the parameters of a primary
constructor, but their owning symbol is the field or the property, not the
constructor, so those blocks were not analyzed at all. Whether a parameter
is a fill array is now decided from the parameter itself, as it was before
the switch to an operation block action, and the owning symbol is only used
to skip method bodies that can't possibly contain a read.
- A deconstruction assignment ('(i, _) = ...') was not recognized as a write
to the index variable, so a stale index was considered stable.
- A writable 'ref' alias or a capture created before the write escaped the
scan of the statements between the write and the read, which meant the
index variable (or even the span parameter itself) could be modified
without being noticed. Nothing is skipped anymore in a body that creates
such an indirection, in the same way as for 'goto'.
- A call to a method annotated with '[Conditional]' is removed entirely by
the compiler, arguments included, when the associated preprocessor symbol
is not defined, so a write nested in one is now treated as conditional.
The well known symbols the analysis needs are bundled together while at it, as
detecting '[Conditional]' calls requires one more of them.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8c86c0e5-5a20-48b7-b8c6-b04c963edef0
A second pass over the analysis found three more ways a write could be trusted
when it might not have happened, or when it applied to a different element:
- An override cannot carry '[Conditional]' itself, but it does inherit the
conditional symbols of the method it overrides, so a call bound to it is
erased just the same. The whole 'OverriddenMethod' chain is now inspected.
- A writable 'ref' alias was only recognized when its referent was spelled
out as a local or a parameter, which let a conditional 'ref' expression, a
'ref' returning call or a pointer sneak an indirection past the check. Any
writable 'ref' alias now disables suppression, except for an alias to an
element of a span (which can only ever write to that element, never
redirect the span or change an index), and so does any use of a pointer.
- An index that is itself a 'ref' local or parameter aliases some other
storage, such as a field or an array element, so its value can change with
no write to the index in sight. Such an index is no longer considered
stable.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8c86c0e5-5a20-48b7-b8c6-b04c963edef0
Two more ways a write could be trusted when it might not apply, or might not
have happened at all:
- A reference to an operand can escape the call that receives it, either by
being stored in a 'ref' field or by being wrapped in a span, and can then
be used to modify that operand at any point. Scanning the statements
between the write and the read is not enough for those, as a reference
handed out before the write is still live afterwards, so the whole body is
now checked for a reference to the span parameter or the index variable
being passed by reference.
- A call to a 'partial void' method that has no implementing declaration is
removed by the compiler, the evaluation of its arguments included, exactly
like a call to a '[Conditional]' method whose symbol is not defined.
The carve-out that kept suppression enabled for an alias to a span element is
dropped while at it. It was not sound (a span can be created over an index
variable, e.g. with 'MemoryMarshal.CreateSpan'), and it also had no effect in
practice: a write through such an alias is not recognized as covering anyway,
and the loop variable of a 'foreach' loop is unaffected either way, as its
declarator has no initializer.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8c86c0e5-5a20-48b7-b8c6-b04c963edef0
An interpolated string handler whose constructor has an 'out bool' success parameter, or whose append calls return 'bool', can report failure, in which case the compiler skips the evaluation of the remaining holes. A write inside one of them is therefore not guaranteed to run, in the same way as a write nested in any other construct that might be skipped. This is reachable with BCL types alone, e.g. through 'MemoryExtensions.TryWrite', whose handler reports failure when the destination is too small. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8c86c0e5-5a20-48b7-b8c6-b04c963edef0
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a new Roslyn analyzer (
CSWINRT2021, severityWarning) that detects when the implementation of an authored Windows Runtime method reads from aSpan<T>parameter. Such parameters are projected as write-only fill arrays in the Windows Runtime ABI, so reading them is not supported.Motivation
In the Windows Runtime ABI, array parameters use one of three conventions:
ReadOnlySpan<T>-> pass array ([in], read-only)out T[]-> receive array ([out]with byref)Span<T>-> fill array ([out]without byref)A fill array is write-only: the implementation is handed a caller-allocated buffer that it is expected to fill, and reading the existing contents is not supported (see this write-up on WinRT array parameters). Until now nothing flagged accidental reads from such a parameter, which can lead to subtle, hard-to-diagnose bugs in authored components. This analyzer surfaces the most common mistakes at compile time, while being careful to avoid false positives on legitimate write-only usage.
What it detects
For a by-value
System.Span<T>parameter of a Windows Runtime-exposed method, the analyzer reports a read when it sees:Indexer reads:
var x = span[i],Foo(span[i]),ref readonly var x = ref span[i],Foo(in span[i]),span[i]++,span[i] += 1Conversions of the span to
ReadOnlySpan<T>(implicit or explicit), including when passed as an argumentforeachiteration over the span, when the loop variable is by-value orref readonly(every element is read)Reads through a writable
refforeachloop variable, which aliases the current element. The loop itself is valid (it can be used to fill the span), so only the individual reads are reported:Reading back what the method has written
Reading back a value the method itself has just written is perfectly valid, as the element being read is no longer uninitialized. The analyzer accounts for that, and skips a read whenever a write covering the same location is guaranteed to run before it:
The writes that are recognized are
span.Clear()andspan.Fill(value)(which cover any subsequent read, includingforeachloops andReadOnlySpan<T>conversions), andspan[i] = value/Foo(out span[i])(which cover subsequent reads of that same element, includinginarguments andref readonlyaliases). The same two forms through a writablerefforeachloop variable cover subsequent reads of the element it aliases.The analysis is deliberately conservative, since missing a write only means an extra warning, whereas trusting one that doesn't actually happen would silently hide a real bug. A write is only ever considered when it appears in a statement that precedes (an ancestor of) the read within a common enclosing block, and when it is unconditionally reached within that statement: anything nested in a construct that might be skipped is ignored (
if,?:,??,?.,switch, loops,try, lambdas, local functions,&&and||, plus calls that the compiler can erase together with their arguments, such as[Conditional]methods, unimplementedpartial voidmethods, and holes of an interpolated string whose handler can report failure). Only definite writes count, sorefarguments and writablerefaliases are not treated as initializing.Nothing is skipped at all in a body that could modify a variable in a way the ordered scan of statements cannot see: one containing a
goto, a lambda, a local function, a pointer, or a writablerefalias. On top of that, a write is discarded when the span parameter or the index variable might have been reassigned in between, when the index is itself areflocal or parameter (so it aliases storage that can change on its own), or when a reference to either of them is handed out anywhere in the method (as it can outlive the call that receives it).Avoiding false positives
The analyzer only runs when authoring a component (
CsWinRTComponentistrue), and only for by-valueSpan<T>parameters of methods that are actually part of the ABI surface: public methods and constructors, or explicit implementations of public interfaces, declared on public, top-level classes. It intentionally does not warn on:refforeachloop variable: assignments (span[i] = value,x = value),out/refarguments, and writablerefaliases (ref var slot = ref span[i])foreachloops with a writablerefloop variable, which can be used to fill all elementsspan.Lengthandspan.IsEmptyReadOnlySpan<T>parametersReads that flow through aliasing or a call to another method taking a
Span<T>are an accepted blind spot, since the analyzer cannot reason about the callee's behavior.Changes
src/Authoring/WinRT.SourceGenerator2/Diagnostics/DiagnosticDescriptors.cs: add theCSWINRT2021(WriteOnlySpanParameterRead) descriptor.src/Authoring/WinRT.SourceGenerator2/AnalyzerReleases.Shipped.md: registerCSWINRT2021under the3.0.0release.src/Authoring/WinRT.SourceGenerator2/Diagnostics/Analyzers/WriteOnlySpanParameterAnalyzer.cs: new analyzer, using an operation block action so that each candidate read can be inspected together with the code that necessarily runs before it.src/Tests/SourceGenerator2Test/Test_WriteOnlySpanParameterAnalyzer.cs: tests covering the warning cases, the write-only / non-exposed cases that must stay silent, and the reads that are skipped because the method has already written to the same location.