Skip to content

Add CSWINRT2021 analyzer for reads from write-only Span<T> parameters - #2438

Open
Sergio0694 wants to merge 11 commits into
staging/3.0from
user/sergiopedri/span-fill-array-analyzer
Open

Add CSWINRT2021 analyzer for reads from write-only Span<T> parameters#2438
Sergio0694 wants to merge 11 commits into
staging/3.0from
user/sergiopedri/span-fill-array-analyzer

Conversation

@Sergio0694

@Sergio0694 Sergio0694 commented Jun 12, 2026

Copy link
Copy Markdown
Member

Summary

Adds a new Roslyn analyzer (CSWINRT2021, severity Warning) that detects when the implementation of an authored Windows Runtime method reads from a Span<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] += 1

  • Conversions of the span to ReadOnlySpan<T> (implicit or explicit), including when passed as an argument

  • foreach iteration over the span, when the loop variable is by-value or ref readonly (every element is read)

  • Reads through a writable ref foreach loop 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:

    public void Fill(Span<int> span)
    {
        foreach (ref int x in span)
        {
            int y = x;     // CSWINRT2021, this reads an uninitialized element
            x = 42;        // Fine, this writes to the element
        }
    }

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:

public void Fill(Span<int> span)
{
    span.Clear();

    int a = span[0];                    // Fine, every element was initialized

    span[1] = 2;
    int b = span[1];                    // Fine, this element was initialized

    foreach (ref int x in span)
    {
        x = 3;
        int c = x;                      // Fine, this element was initialized
    }
}

The writes that are recognized are span.Clear() and span.Fill(value) (which cover any subsequent read, including foreach loops and ReadOnlySpan<T> conversions), and span[i] = value / Foo(out span[i]) (which 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 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, unimplemented partial void methods, and holes of an interpolated string whose handler can report failure). Only definite writes count, so ref arguments and writable ref aliases 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 writable ref alias. 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 a ref local 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 (CsWinRTComponent is true), and only for by-value Span<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:

  • Write-only usages of an element, whether accessed through the indexer or through a writable ref foreach loop variable: assignments (span[i] = value, x = value), out/ref arguments, and writable ref aliases (ref var slot = ref span[i])
  • foreach loops with a writable ref loop variable, which can be used to fill all elements
  • Non-indexer members such as span.Length and span.IsEmpty
  • Parameters on private/internal methods, nested types, structs, or local functions, and ReadOnlySpan<T> parameters

Reads 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 the CSWINRT2021 (WriteOnlySpanParameterRead) descriptor.
  • src/Authoring/WinRT.SourceGenerator2/AnalyzerReleases.Shipped.md: register CSWINRT2021 under the 3.0.0 release.
  • 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.

@Sergio0694 Sergio0694 added enhancement New feature or request authoring Related to authoring feature work CsWinRT 3.0 labels Jun 12, 2026
@Sergio0694
Sergio0694 requested a review from manodasanW June 12, 2026 21:34
Sergio0694 and others added 4 commits August 1, 2026 12:21
…' 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
Sergio0694 force-pushed the user/sergiopedri/span-fill-array-analyzer branch from 1a78089 to 87a4ca0 Compare August 1, 2026 19:28
Sergio0694 and others added 7 commits August 1, 2026 12:58
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
@Sergio0694 Sergio0694 changed the title Add CSWINRT2018 analyzer for reads from write-only Span<T> parameters Add CSWINRT2021 analyzer for reads from write-only Span<T> parameters Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

authoring Related to authoring feature work CsWinRT 3.0 enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant