Skip to content

fix: throw when an array-backed container is modified during enumeration [patch] - #84

Merged
matt-edmondson merged 3 commits into
mainfrom
claude/containers-70-enumerator-version
Sep 28, 2026
Merged

matt-edmondson merged 3 commits into
mainfrom
claude/containers-70-enumerator-version

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #70

What was wrong

ContiguousCollection, ContiguousSet, ContiguousMap, OrderedMap and InsertionOrderMap enumerated with a bare for (i < Count) yield return items[i] and kept no version counter. Changing the collection inside a foreach never threw. Elements were silently skipped or visited twice. The List<T>-backed siblings do throw, so switching to the faster contiguous type changed a loud failure into wrong data.

Change

  • Each of the five types has a private int version. It is bumped by every structural or value change:
    • add and insert
    • remove and RemoveAt
    • clear
    • indexer set, both overwrite and insert-via-indexer on the maps
  • Every enumerator captures version when GetEnumerator() is called, as List<T> does, and checks it on each MoveNext, including the one after the last element. This covers the main enumerator and the maps' Keys/Values views. A change throws InvalidOperationException with the BCL message.
  • The check lives in a small internal helper, Enumeration.ThrowIfModified.
  • Calls that change nothing do not bump the version: a ContiguousSet.Add of a duplicate, or a Remove of a missing item or key. So UnionWith(this) still works.
  • Constructors, Clone() and GetRange() don't bump, since they build a fresh instance.
  • RingBuffer is unchanged. It is written to in real time, and the issue marks it optional.

Tests

New tests for each affected type:

  • remove during foreach throws
  • every mutator invalidates a live enumerator; for the maps, the pair, Keys and Values enumerators are all checked
  • a change before the first MoveNext or after the last element throws (ContiguousCollection)
  • unchanged enumeration and no-op set changes don't throw

Results:

  • With the five library files reverted, 13 of the new tests fail.
  • With the fix, the full suite passes: 418/418 (dotnet test).
  • dotnet build Containers succeeds for net10.0, net9.0, net8.0, netstandard2.1 and netstandard2.0.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PtWVWaDh3nqJL1eKD7vFxU


Generated by Claude Code

…ion [patch]

ContiguousCollection, ContiguousSet, ContiguousMap, OrderedMap and
InsertionOrderMap enumerated with a bare index loop and no version
counter. Removing or adding during a foreach silently skipped or
repeated elements instead of throwing InvalidOperationException, unlike
their List-backed siblings.

Each now keeps a version, bumped by every add, insert, remove, clear and
indexer set. Their enumerators, including the maps' Keys and Values,
capture it when GetEnumerator() is called and throw on the next
MoveNext once it has changed. A set Add of a duplicate, or a Remove of a
missing item, changes nothing and does not invalidate enumeration.

RingBuffer is left as it is: it is written to in real time, and the
issue marks it optional.

Fixes #70

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PtWVWaDh3nqJL1eKD7vFxU
Each enumerator is now a one-line GetEnumerator that passes the version
to a private iterator, rather than a wrapper around a local function.
That removes the repeated block Sonar counted as duplicated new code.
The map enumerators check the version before reading the entry, and the
three identical map test sets now call one shared assertion helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PtWVWaDh3nqJL1eKD7vFxU
The three maps each carried their own versioned pair, key and value
iterators. The code was identical and sat inside runs the maps already
shared, so Sonar counted it as duplicated new code. Each map now exposes
Version, KeyAt and ValueAt through an internal IVersionedEntries
interface, and Enumeration.Pairs/Keys/Values do the iterating once. The
version is still captured when GetEnumerator() is called.

ContiguousMap.Clear bumps the version first rather than mid-method, so
the bump does not extend the run it shares with InsertionOrderMap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PtWVWaDh3nqJL1eKD7vFxU
@sonarqubecloud

Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor Author

The SonarCloud quality gate passes now. Duplication on new code dropped from 10.1% to 0.4% after e2a87d9, which moves the versioned map enumeration into Enumeration.Pairs/Keys/Values behind the internal IVersionedEntries interface.

Sonar still lists 2 new issues that don't block the gate. The Sonar project is private, so I can't see which rules they are. If someone with access pastes the rule IDs and lines here, I'll fix them on this branch.


Generated by Claude Code

@matt-edmondson
matt-edmondson merged commit 152036b into main Sep 28, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/containers-70-enumerator-version branch September 28, 2026 01:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Modifying a Contiguous*/OrderedMap/InsertionOrderMap during foreach silently skips elements instead of throwing

2 participants