Skip to content

fix(tests): snapshot ExecutionLog under lock to fix parallel race#6194

Merged
thomhurst merged 1 commit into
mainfrom
fix/aspnet-executionlog-race
Jun 8, 2026
Merged

fix(tests): snapshot ExecutionLog under lock to fix parallel race#6194
thomhurst merged 1 commit into
mainfrom
fix/aspnet-executionlog-race

Conversation

@thomhurst

Copy link
Copy Markdown
Owner

Problem

ExecutionOrderReproductionTests (TUnit.Example.Asp.Net.TestProject) intermittently fails on CI with:

InvalidOperationException: Collection was modified; enumeration operation may not execute.
   at System.Collections.Generic.List`1.Enumerator.MoveNext()

Observed on modularpipeline (ubuntu-latest) (e.g. PR #6188, where it is unrelated to the change under test).

Root cause

ExecutionLog is a shared static List<string>. Log() takes a lock when writing, but the final foreach (var entry in ExecutionLog) enumerated the list without the lock. Tests in the class run in parallel, and their lifecycle hooks (ConfigureTestOptions, SetupAsync, ConfigureTestConfiguration, …) all call Log()Add(). One instance mutating the list while another enumerates it throws.

Fix

Snapshot the list under the lock before enumerating. Test-only change.

ExecutionOrderReproductionTests.ExecutionLog is a shared static List<string>. Log() locks on write, but the final foreach enumerated the list without the lock. Parallel test instances calling Log() from their lifecycle hooks mutated the list mid-enumeration, throwing "Collection was modified; enumeration operation may not execute" (flaky, observed on ubuntu CI). Snapshot the list under the lock before enumerating.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Summary

Clean, minimal fix for a real flaky-test race condition. The diagnosis is accurate and the solution is correct.


What the PR does

ExecutionLog is a static List<string> shared across all parallel test instances in the class. Log() correctly acquires Lock before calling Add(), but the foreach at the end of Api_Should_Start_After_ConfigureTestConfiguration enumerated the list without holding the lock. Parallel test instances calling Log() from their lifecycle hooks (ConfigureTestOptions, SetupAsync, etc.) could mutate the list mid-enumeration, throwing the observed InvalidOperationException.

The fix snapshots the list inside the lock before enumerating:

List<string> executionLogSnapshot;
lock (Lock)
{
    executionLogSnapshot = ExecutionLog.ToList();
}
foreach (var entry in executionLogSnapshot) { ... }

This is the idiomatic, correct pattern for this scenario.


Findings

No issues found. A few observations for completeness:

  1. The fix is sufficient and well-scoped. Only the enumeration site needed the lock; the snapshot approach avoids holding the lock during I/O (Console.WriteLine), which is the right call.

  2. The inline comment is justified. CLAUDE.md allows comments for non-obvious invariants/workarounds — a shared static list with parallel test mutation is exactly that kind of subtle constraint.

  3. No other enumeration sites were missed. Connection_String_Should_Be_Overridden and other tests don't enumerate ExecutionLog, so this is the only callsite that needed fixing.

  4. Alternative considered (not needed here): Replacing List<string> + lock with ConcurrentBag<string> would avoid needing a separate lock object entirely, but since enumeration order matters for the diagnostic log and the existing pattern is already established, this would be a separate cleanup — not required for the correctness fix.

LGTM. This is a correct, test-only fix that eliminates a real source of CI flakiness.

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@thomhurst thomhurst enabled auto-merge (squash) June 8, 2026 18:59
@thomhurst thomhurst merged commit b5eb400 into main Jun 8, 2026
14 checks passed
@thomhurst thomhurst deleted the fix/aspnet-executionlog-race branch June 8, 2026 19:00
This was referenced Jun 9, 2026
intellitect-bot pushed a commit to IntelliTect/EssentialCSharp.Web that referenced this pull request Jun 9, 2026
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.51.0 to
1.53.0.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.53.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.53.0 -->

## What's Changed
### Other Changes
* feat(assertions): return typed value from IsAssignableTo<T> (#​6184)
by @​thomhurst in thomhurst/TUnit#6187
* fix: stop doubling backslashes in source-gen emitted FilePath (breaks
HTML report source links) by @​thomhurst in
thomhurst/TUnit#6193
* feat(assertions): add ContainsKey().And.Value drill-in for
dictionaries (#​6185) by @​thomhurst in
thomhurst/TUnit#6188
* fix(tests): snapshot ExecutionLog under lock to fix parallel race by
@​thomhurst in thomhurst/TUnit#6194
* fix(engine): run lifecycle hooks before test class construction
(#​6192) by @​thomhurst in thomhurst/TUnit#6195
* feat(assertions): inference-friendly pinned overload for covariant
[AssertionExtension] with own generic (#​5922) by @​thomhurst in
thomhurst/TUnit#6196
* feat: add DeferEnumeration to defer data-source expansion to runtime
(#​5833) by @​thomhurst in thomhurst/TUnit#6197
### Dependencies
* chore(deps): update tunit to 1.51.0 by @​thomhurst in
thomhurst/TUnit#6186
* chore(deps): update microsoft.testing to 18.8.0 by @​thomhurst in
thomhurst/TUnit#6191
* chore(deps): update aspire to 13.4.3 by @​thomhurst in
thomhurst/TUnit#6198


**Full Changelog**:
thomhurst/TUnit@v1.51.0...v1.53.0

Commits viewable in [compare
view](thomhurst/TUnit@v1.51.0...v1.53.0).
</details>

Updated [TUnit.AspNetCore](https://github.com/thomhurst/TUnit) from
1.51.0 to 1.53.0.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit.AspNetCore's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.53.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.53.0 -->

## What's Changed
### Other Changes
* feat(assertions): return typed value from IsAssignableTo<T> (#​6184)
by @​thomhurst in thomhurst/TUnit#6187
* fix: stop doubling backslashes in source-gen emitted FilePath (breaks
HTML report source links) by @​thomhurst in
thomhurst/TUnit#6193
* feat(assertions): add ContainsKey().And.Value drill-in for
dictionaries (#​6185) by @​thomhurst in
thomhurst/TUnit#6188
* fix(tests): snapshot ExecutionLog under lock to fix parallel race by
@​thomhurst in thomhurst/TUnit#6194
* fix(engine): run lifecycle hooks before test class construction
(#​6192) by @​thomhurst in thomhurst/TUnit#6195
* feat(assertions): inference-friendly pinned overload for covariant
[AssertionExtension] with own generic (#​5922) by @​thomhurst in
thomhurst/TUnit#6196
* feat: add DeferEnumeration to defer data-source expansion to runtime
(#​5833) by @​thomhurst in thomhurst/TUnit#6197
### Dependencies
* chore(deps): update tunit to 1.51.0 by @​thomhurst in
thomhurst/TUnit#6186
* chore(deps): update microsoft.testing to 18.8.0 by @​thomhurst in
thomhurst/TUnit#6191
* chore(deps): update aspire to 13.4.3 by @​thomhurst in
thomhurst/TUnit#6198


**Full Changelog**:
thomhurst/TUnit@v1.51.0...v1.53.0

Commits viewable in [compare
view](thomhurst/TUnit@v1.51.0...v1.53.0).
</details>

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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.

1 participant