What's wrong
ProfileManager.CreateProfile (Keybinding/Services/ProfileManager.cs:~45-54) does a check-then-act and ignores the result of TryAdd:
if (_profiles.TryGetValue(normalizedId, out Profile? existingProfile)) return existingProfile;
Profile newProfile = new(normalizedId, name.Trim(), description);
_profiles.TryAdd(normalizedId, newProfile); // result ignored
return newProfile;
When two threads create the same id at once, the thread whose TryAdd fails still returns its own Profile, which is not in _profiles. The XML doc promises "the created profile, or existing profile if it already exists", and CLAUDE.md describes the services as thread-safe.
Failure scenario (reproduced)
Two threads behind a Barrier both call CreateProfile("p", "P"), repeated 2000 times. In 297 trials the returned instance was not ReferenceEquals to GetProfile("p").
Every SetChord on the orphaned instance is invisible to GetProfile, and SaveAsync never persists it.
This is separate from #109, which is about concurrent BindChord mutating a profile's inner Dictionary. This race is at the profile-map level.
Suggested fix
return _profiles.GetOrAdd(normalizedId, _ => new Profile(normalizedId, name.Trim(), description));
Acceptance: a concurrency test, with N threads calling CreateProfile for the same id, asserts that every returned instance is the one stored.
What's wrong
ProfileManager.CreateProfile(Keybinding/Services/ProfileManager.cs:~45-54) does a check-then-act and ignores the result ofTryAdd:When two threads create the same id at once, the thread whose
TryAddfails still returns its ownProfile, which is not in_profiles. The XML doc promises "the created profile, or existing profile if it already exists", and CLAUDE.md describes the services as thread-safe.Failure scenario (reproduced)
Two threads behind a
Barrierboth callCreateProfile("p", "P"), repeated 2000 times. In 297 trials the returned instance was notReferenceEqualstoGetProfile("p").Every
SetChordon the orphaned instance is invisible toGetProfile, andSaveAsyncnever persists it.This is separate from #109, which is about concurrent
BindChordmutating a profile's innerDictionary. This race is at the profile-map level.Suggested fix
Acceptance: a concurrency test, with N threads calling
CreateProfilefor the same id, asserts that every returned instance is the one stored.