Stop the app-model tests racing the model's own callbacks - #191
Merged
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
Elevate.App.Testsfailed roughly once in twenty runs with:AppModelcapturesSynchronizationContext.Currentand marshals the activation coordinator'sprogress 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.Posthands the callback to the thread pool — so that callback could mutate
ProgressandActivewhilethe authoritative loop at the end of
ActivateCoreAsyncwas writing the same dictionaries.Nothing is wrong with the app. There
_contextis the dispatcher, genuinely single-threaded, sothe 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, soPostrunsinline. 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 testpasses inmacos/(ElevateCore) — untouched, no Swift in this diffdotnet test Elevate.Cli.slnpasses incli/when the CLI orElevate.Corechanged — untouchedxcodebuild ... testpasses for ElevateAppTests — untouchedElevate.xcodeprojcommittedCHANGELOG.mdupdated under## [Unreleased]— not user-visible, so no entryHow it was verified
QuickActivateUsesTheRememberedReasonOrAsksForTheDialogis where it surfaced. Before: 2 failures in25 runs of that filter. After: 0 in 40 consecutive runs, and the full
Elevate.slnsuite clean5 times over (
Elevate.Core.Tests497,Elevate.App.Tests165).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