Skip to content

Lock a profile's chords so concurrent binds cannot corrupt them [minor] - #142

Merged
matt-edmondson merged 2 commits into
mainfrom
fix/profile-chords-thread-safety
Sep 29, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
fix/profile-chords-thread-safety

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Parallel BindChord, UnbindChord and FindCommandByChord calls no longer lose bindings or throw. Profile kept its bindings in a plain Dictionary that was changed without synchronization while other calls enumerated it.

Change

  • Every read and write of a profile's bindings takes a per-profile Lock: SetChord, GetChord, HasChord, RemoveChord, ClearChords, BoundCommands and GetAllChords.
  • KeybindingService.FindCommandByChord/ExecuteChord, ProfileManager.DuplicateProfile and both JsonKeybindingRepository save paths now read through the GetAllChords() snapshot instead of enumerating the live dictionary.
  • New Profile.ChordCount, which replaces KeybindingManager.GetSummary's use of Chords.Count.

API decision

The triage asked for a decision between a breaking change and an [Obsolete] transition. This PR takes the transition. Profile.Chords keeps its type and still returns the live dictionary, but it is now [Obsolete] because anything done through it bypasses the lock. It can be removed or narrowed to IReadOnlyDictionary in a later major version. That is why this is [minor].

The RenameProfile race from the issue is already gone on main, because rename now happens in place (Profile.Rename) rather than as a TryRemove + TryAdd swap.

Tests

ProfileChordConcurrencyTests registers 20,000 commands. It runs BindChord, FindCommandByChord, GetAllChords, and UnbindChord for every even command, all under Parallel.For. It then requires exactly 10,000 bindings, all of them odd.

  • Without the fix: fails with AggregateException (IndexOutOfRangeException from the torn dictionary). I ran it by reverting the library changes and swapping ChordCount for GetAllChords().Count.
  • With the fix: passes. The full suite (122 tests) passed three runs in a row.

Fixes #109

🤖 Generated with Claude Code

https://claude.ai/code/session_015BJVibRvmTotkqEGp3695R


Generated by Claude Code

matt-edmondson and others added 2 commits September 28, 2026 17:25
Profile kept its bindings in a plain Dictionary that SetChord, RemoveChord
and ClearChords changed without synchronization while FindCommandByChord
and SaveAsync enumerated it. Parallel binds lost bindings and threw.

Every read and write of the bindings now takes a per-profile lock, and the
services read them through snapshots. The public Chords dictionary is
marked [Obsolete] because touching it bypasses the lock; ChordCount
replaces its only internal use that wasn't a snapshot.

Fixes #109

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BJVibRvmTotkqEGp3695R
The new code's coverage fell below SonarCloud's gate. These tests exercise
the rewritten accessors, their blank-id guards, the obsolete Chords
property, and the registration filter ExecuteChord applies.

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

Copy link
Copy Markdown

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 BindChord calls corrupt a profile's bindings, despite the "thread-safe services" guarantee

1 participant