Skip to content

ContiguousSet.AsSpan() / ContiguousMap.AsSpan() return a writable Span that silently desyncs the uniqueness/key index (duplicates in a set, wrong lookups in a map) #78

Closed

Description

@matt-edmondson

What's wrong

ContiguousSet<T>.AsSpan() (Containers/ContiguousSet.cs:543) and ContiguousMap<TKey,TValue>.AsSpan() (Containers/ContiguousMap.cs:565) return a mutable Span<T> / Span<Entry> over the backing items array:

public Span<T> AsSpan() => new(items, 0, Count);          // ContiguousSet
public Span<Entry> AsSpan() => new(items, 0, Count);      // ContiguousMap

Both types also keep a side index: uniquenessSet for the set and keyToIndex for the map. Writing through the span changes items but leaves that index untouched, so the container breaks its own invariants with no error. The doc comments give no warning. ContiguousCollection<T>.AsSpan() is fine because it has no side index.

Reproduction (verified against a local build of main)

Set

var s = new ContiguousSet<int> { 1, 2, 3 };
s.AsSpan()[0] = 2;
// items enumerate as 2,2,3  -> duplicate inside a set
s.Contains(1);   // True, although 1 is no longer stored
s.Add(1);        // False, so 1 cannot be re-added
s.Remove(2);     // items now 2,3
s.Remove(2);     // False, but 2 is still enumerated

Map

var m = new ContiguousMap<string,int> { ["a"] = 1, ["b"] = 2 };
m.AsSpan()[0] = new ContiguousMap<string,int>.Entry("b", 99);
// Keys enumerate as b,b
m["a"];              // 99
m.ContainsKey("a");  // True

Any caller who treats AsSpan() as the fast path for in-place updates, which is what a writable span invites, corrupts the container with no error.

Suggested fix

  • Make AsSpan() on ContiguousSet and ContiguousMap return ReadOnlySpan<…>, or remove it and keep only AsReadOnlySpan(). This is a breaking public API change, so it needs a major/minor version bump per the repo's versioning.
  • If in-place value mutation on the map is a use case worth keeping, add a narrow API instead, e.g. ref TValue GetValueRefOrNullRef(TKey key) in the style of CollectionsMarshal. It can update values without letting callers rewrite keys.

Acceptance criteria: no public API on ContiguousSet or ContiguousMap can change stored elements or keys without updating uniquenessSet / keyToIndex, and a test covers the scenario above.

Activity

  1. matt-edmondson commented on Sep 26, 2026

    @matt-edmondson
    ContributorAuthor

    Triage

    • Category: Bug
    • Priority: Medium. It corrupts data silently: once a caller writes through the span, the set holds duplicates and the map's Contains/Add/Remove/indexer return wrong answers, and nothing raises an error. It stops short of High because it only triggers when a caller writes through AsSpan(). The read-only paths and ContiguousCollection<T> are not affected.
    • Area / suggested assignee: The public API surface of ContiguousSet<T> and ContiguousMap<TKey,TValue>: AsSpan() against the uniquenessSet / keyToIndex side indexes. Maintainer: @matt-edmondson.
    • Duplicates: None. It is related to Modifying a Contiguous*/OrderedMap/InsertionOrderMap during foreach silently skips elements instead of throwing #70 (mutation during foreach silently skips elements), which is the same kind of problem: the containers don't protect their own invariants against caller mutation. It is not the same defect, so fix it separately.
    • In progress: No open PR covers it. Open Containers PR Keep the stored key when ContiguousMap's indexer updates a value #75 ("Keep the stored key when ContiguousMap's indexer updates a value") touches ContiguousMap near the same code, so merge that first or coordinate to avoid conflicts.

    Next steps: Changing AsSpan() to return ReadOnlySpan<…>, or removing it in favour of AsReadOnlySpan(), breaks the public API and needs a [major] bump. Decide that before any code is written. If you want to keep in-place value updates on the map, a ref TValue GetValueRefOrNullRef(TKey key)-style accessor provides them without exposing keys. Turn the reproduction in the issue into the regression test the acceptance criteria ask for.


    Generated by Claude Code

  2. matt-edmondson commented on Sep 28, 2026

    @matt-edmondson
    ContributorAuthor

    Decision (maintainer, 2026-09-28)

    • AsSpan() on ContiguousSet<T> and ContiguousMap<TKey,TValue> returns ReadOnlySpan<…>.
    • Add a ref value accessor to ContiguousMap, in the style of ref TValue GetValueRefOrNullRef(TKey key), so values can still be updated in place without exposing keys.
    • This is a public API break and ships as a major release, following the org-wide rule to take the correct fix and bump major.

    Next reader: implement both changes and turn the reproduction above into regression tests. Merge or coordinate with #75 first, because it touches the same ContiguousMap code, and release with [major].


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions