You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Concurrent saves silently lose profiles: JsonKeybindingRepository does unsynchronised read-modify-write of profiles.json #148
Write the whole file back with File.WriteAllTextAsync.
Nothing synchronises these steps, so two concurrent calls overwrite each other's changes. There is a worse case: LoadAllProfilesInternalAsync turns a JsonException into an empty list (L116-L120). If a save reads a half-written file from another save, it treats the store as empty and writes back a file that contains only its own profile, which wipes every other profile.
CLAUDE.md promises "thread-safe operations in all services". #142 recently fixed the same class of problem for Profile chords, but it never covered the persistence layer.
Reproduction
These were run on Linux against the current main; no exception was thrown in any run.
Eight parallel repo.SaveProfileAsync(new Profile("p" + i, …)) calls against a fresh directory, followed by LoadAllProfilesAsync(), returned fewer than 8 profiles in 21 of 30 trials.
A single KeybindingManager with 5 profiles ran Task.WhenAll(m.SaveAsync(), m.SaveAsync()), and a fresh manager then loaded it. The fresh manager saw fewer than 5 profiles in 13 of 30 trials.
On Windows, the overlapping WriteAllTextAsync calls can also throw IOException (sharing violation).
A realistic trigger is a DI-singleton KeybindingManager (as in the WebAPI example) handling two requests that both call SaveAsync.
Suggested fix
Serialise every read-modify-write of each file with a SemaphoreSlim(1, 1) held by the repository.
Write to a temporary file in the same directory, then File.Move(temp, path, overwrite: true), so a reader never sees a partial file.
On the save and delete paths, don't treat a JsonException as an empty store. Rethrow it, or refuse to write, so one bad read cannot erase the file.
Optionally, add a batch SaveProfilesAsync(IEnumerable<Profile>) so KeybindingManager.SaveAsync writes once instead of once per profile.
Add a regression test that runs parallel SaveProfileAsync calls with distinct IDs and asserts that all profiles are present afterwards.
#93 (delegating persistence to ktsu.AppDataStorage) would replace this code. Until then, this bug is live.
Priority: High. User profiles are lost silently, with no exception, in 21 of 30 trials of a simple parallel-save repro. The JsonException → empty-list fallback lets one torn read wipe every other profile. A DI-singleton manager handling concurrent requests is a normal way to hit this, and it contradicts CLAUDE.md's thread-safety promise.
Area / suggested assignment:Keybinding/Services/JsonKeybindingRepository.cs, covering save and delete of both profiles.json and commands.json
Activity
matt-edmondson commentedon Sep 29, 2026
Triage
JsonException→ empty-list fallback lets one torn read wipe every other profile. A DI-singleton manager handling concurrent requests is a normal way to hit this, and it contradicts CLAUDE.md's thread-safety promise.Keybinding/Services/JsonKeybindingRepository.cs, covering save and delete of bothprofiles.jsonandcommands.jsonGenerated by Claude Code