Skip to content

Stop the app-model tests racing the model's own callbacks - #191

Merged
FrodeHus merged 3 commits into
mainfrom
fix-appmodel-test-race
Sep 18, 2026
Merged

FrodeHus merged 3 commits into
mainfrom
fix-appmodel-test-race

Conversation

@FrodeHus

Copy link
Copy Markdown
Owner

What this changes

Elevate.App.Tests failed roughly once in twenty runs with:

System.InvalidOperationException : Operations that change non-concurrent collections
must have exclusive access. A concurrent update was performed on this collection and
corrupted its state.
  at System.Collections.Generic.Dictionary`2.set_Item
  at AppModel.ActivateCoreAsync ... AppModel.Activation.cs:line 172

AppModel captures SynchronizationContext.Current and marshals the activation coordinator's
progress callback back onto it, because in the app that is the one UI thread every member is
required to run on. The context xUnit installs is not a thread at all — AsyncTestSyncContext.Post
hands the callback to the thread pool — so that callback could mutate Progress and Active while
the authoritative loop at the end of ActivateCoreAsync was writing the same dictionaries.

Nothing is wrong with the app. There _context is the dispatcher, genuinely single-threaded, so
the callback and the loop are serialised; the comment on that callback already allows it to land on
either side of the loop. Only the harness broke the precondition the model documents.

The fix is one hunk in TestModel: construct the model with no ambient context, so Post runs
inline. The coordinator raises progress from inside the call the model is awaiting, so the callback
lands before that await returns — one of the two orderings the UI thread already produces, and the
deterministic one. No production code changes.

Checklist

  • swift test passes in macos/ (ElevateCore) — untouched, no Swift in this diff
  • dotnet test Elevate.Cli.sln passes in cli/ when the CLI or Elevate.Core changed — untouched
  • xcodebuild ... test passes for ElevateAppTests — untouched
  • No Elevate.xcodeproj committed
  • No real client ids, tenant ids, tokens or account names in the diff
  • Documentation updated where behaviour changed — no behaviour change, test-only
  • CHANGELOG.md updated under ## [Unreleased] — not user-visible, so no entry

How it was verified

QuickActivateUsesTheRememberedReasonOrAsksForTheDialog is where it surfaced. Before: 2 failures in
25 runs of that filter. After: 0 in 40 consecutive runs, and the full Elevate.sln suite clean
5 times over (Elevate.Core.Tests 497, Elevate.App.Tests 165).

The race was diagnosed rather than guessed at: a scratch test confirmed the ambient context under
xUnit is Xunit.Sdk.AsyncTestSyncContext, and the stack trace names the exact dictionary write.

🤖 Generated with Claude Code

AppModel captures SynchronizationContext.Current and marshals callbacks
from the activation coordinator back onto it, because in the app that is
the one UI thread every member is required to run on. The context xUnit
installs is not a thread at all: AsyncTestSyncContext.Post hands the
callback to the thread pool. So an activation's progress callback could
mutate Progress and Active while the authoritative loop at the end of
ActivateCoreAsync was writing the same dictionaries, and the suite failed
about once in twenty runs with "Operations that change non-concurrent
collections must have exclusive access" out of Dictionary.set_Item.

Nothing is wrong with the app: there the context is the dispatcher, so
the callback and the loop are serialised on one thread, and the comment
on that callback already allows it to land on either side of the loop.
Only the harness broke the precondition.

Construct the test model with no ambient context, so Post runs inline.
The coordinator raises progress from inside the call the model is
awaiting, so the callback now lands before that await returns - one of
the two orderings the UI thread already produces, and the deterministic
one.

QuickActivateUsesTheRememberedReasonOrAsksForTheDialog is where it
surfaced, having failed twice in twenty-five runs before; it passed forty
consecutive runs after, and the whole suite five.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@FrodeHus
FrodeHus enabled auto-merge (squash) September 18, 2026 21:21
Running the model's callbacks inline rather than through xUnit's
thread-pool context made two tests from #181 fail reliably in CI, having
passed locally by luck of scheduling.

ARowIsPropagatingTheMomentItIsActivated asserts the state
WatchPropagation sets before any probe runs, but the helper paced probes
a millisecond apart, so the probe could settle first and the row was
already done. It now holds the probe off entirely; the test is about
what happens before one, so no probe should be able to race it.

AConfirmedProbeClearsTheRowAndNotifies waited for the row to settle and
then asserted on the notification, which the model raises without
awaiting - so it landed just after. It now waits for the notification
too. Its sibling, which proves a role ready at once is NOT announced,
gained a short delay so the absence is evidence rather than impatience.

RecordingNotifier is guarded while there: a probe reports from a
background task, so the list was appended from one thread and read from
another.

Twelve consecutive runs of the app suite, clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@FrodeHus FrodeHus added no changelog This pull request needs no CHANGELOG.md entry and removed no changelog This pull request needs no CHANGELOG.md entry labels Sep 18, 2026
@FrodeHus
FrodeHus merged commit 7e293e3 into main Sep 18, 2026
13 checks passed
@FrodeHus
FrodeHus deleted the fix-appmodel-test-race branch September 18, 2026 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no changelog This pull request needs no CHANGELOG.md entry

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant