Skip to content

Return the stored profile from concurrent CreateProfile calls - #117

Merged
matt-edmondson merged 2 commits into
mainfrom
fix/create-profile-race
Sep 26, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
fix/create-profile-race

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #112

What was wrong

ProfileManager.CreateProfile looked the id up and, if it wasn't there, created a Profile and called TryAdd without checking whether the add succeeded. When two callers raced on the same id, both could get past the lookup. The caller whose TryAdd lost still returned its own instance, which was never stored. Any chord set on that instance was invisible to GetProfile, and SaveAsync never persisted it.

Change

CreateProfile now returns _profiles.GetOrAdd(normalizedId, _ => new Profile(...)), so every caller gets the one stored instance. Under contention the factory may run more than once, but only one result is stored and returned, which is what the documented contract ("the created profile, or existing profile if it already exists") needs.

Tests

New test: ProfileCreateConcurrencyTests.CreateProfile_CalledConcurrentlyForOneId_ReturnsTheStoredProfileToEveryCaller. Over 500 trials, 8 threads behind a Barrier all call CreateProfile("p", "P"), and the test asserts that every returned instance is the one GetProfile("p") returns.

  • With the fix reverted, it failed on both of two runs. With the fix, it passes.
  • dotnet test: 75 passed.

This PR doesn't touch the neighbouring ProfileManager concurrency issues, #109 (the inner Dictionary in BindChord) and #113 (RenameProfile).

🤖 Generated with Claude Code

https://claude.ai/code/session_01P6PRqvoi3pKgXQu5F8XJb1


Generated by Claude Code

CreateProfile looked the id up, then created a Profile and ignored
whether TryAdd stored it. Two callers racing on the same id could each
return their own instance while only one was in the map, so bindings set
on the other were invisible to GetProfile and never saved.

Use ConcurrentDictionary.GetOrAdd so every caller gets the stored
instance.

Fixes #112

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6PRqvoi3pKgXQu5F8XJb1
Use GetOrAdd's key parameter rather than capturing normalizedId (S6612),
and pass the test's cancellation token to Barrier.SignalAndWait
(MSTEST0049).

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

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 3d2395b into main Sep 26, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the fix/create-profile-race branch September 26, 2026 09:55
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.

Concurrent CreateProfile calls for the same id can return a Profile that is never stored, so its bindings are silently lost

2 participants