What's wrong
JsonKeybindingRepository writes every file with File.WriteAllTextAsync directly onto the target path:
SaveProfileAsync:
|
public async Task SaveProfileAsync(Profile profile) |
|
{ |
|
Ensure.NotNull(profile); |
|
|
|
await EnsureInitializedAsync().ConfigureAwait(false); |
|
|
|
List<Profile> profiles = await LoadAllProfilesInternalAsync().ConfigureAwait(false); |
|
List<Profile> profileList = [.. profiles]; |
|
|
|
// Remove existing profile with same ID if it exists |
|
profileList.RemoveAll(p => p.Id == profile.Id); |
|
profileList.Add(profile); |
|
|
|
string profilesPath = Path.Combine(_dataDirectory, ProfilesFileName); |
|
IEnumerable<ProfileDto> profileDtos = profileList.Select(p => new ProfileDto |
|
{ |
|
Id = p.Id, |
|
Name = p.Name, |
|
Description = p.Description, |
|
Chords = p.GetAllChords().ToDictionary( |
|
kvp => kvp.Key, |
|
kvp => new ChordDto |
|
{ |
|
Notes = [.. kvp.Value.Notes.Select(n => n.ToString())] |
|
}) |
|
}); |
|
|
|
string json = JsonSerializer.Serialize(profileDtos, _jsonOptions); |
|
await File.WriteAllTextAsync(profilesPath, json).ConfigureAwait(false); |
|
} |
DeleteProfileAsync:
|
string json = JsonSerializer.Serialize(profileDtos, _jsonOptions); |
|
await File.WriteAllTextAsync(profilesPath, json).ConfigureAwait(false); |
SaveCommandsAsync:
|
string json = JsonSerializer.Serialize(commandDtos, _jsonOptions); |
|
await File.WriteAllTextAsync(commandsPath, json).ConfigureAwait(false); |
SaveActiveProfileAsync:
|
string json = JsonSerializer.Serialize(activeProfileDto, _jsonOptions); |
|
await File.WriteAllTextAsync(activeProfilePath, json).ConfigureAwait(false); |
WriteAllTextAsync truncates the file before writing, so a crash or power loss partway through leaves a truncated profiles.json. That one file holds every profile, and KeybindingManager.SaveAsync rewrites it once per profile, so the window is large.
On the next start, LoadAllProfilesInternalAsync catches the JsonException and silently returns an empty list (#L116-L120). LoadCommandsAsync does the same for commands.json (#L193-L197). The app therefore starts with no profiles. The usual startup path, CreateDefaultProfile() followed by SaveAsync(), then overwrites the damaged file, and every user binding it contained is lost for good.
Reproduction (verified against current main)
var dir = /* temp dir */;
using (var m = new KeybindingManager(dir)) {
await m.InitializeAsync();
m.Commands.RegisterCommand(new Command("file.save", "Save"));
for (int i = 0; i < 5; i++) m.Profiles.CreateProfile("p" + i, "P" + i).SetChord("file.save", Chord.Parse("Ctrl+S"));
await m.SaveAsync();
}
// simulate a torn write: keep only the first half of profiles.json
var path = Path.Combine(dir, "profiles.json");
var full = File.ReadAllText(path); File.WriteAllText(path, full[..(full.Length / 2)]);
using (var m = new KeybindingManager(dir)) {
await m.InitializeAsync(); // no error; GetAllProfiles().Count == 0
m.CreateDefaultProfile();
await m.SaveAsync(); // profiles.json now contains only "default"
}
Result: after the reload the profile count is 0, and the final profiles.json holds only the empty default profile. The five saved profiles and their bindings are gone for good.
Suggested fix
- Write each file to a temp file in the same directory, then swap it into place with
File.Move(temp, target, overwrite: true) or File.Replace. A crash then leaves either the old file or the new one, never half of one.
- Keep tolerating a file that fails to parse, but first move it aside (for example to
profiles.json.corrupt-<timestamp>) so the next save cannot destroy the only copy.
Acceptance criteria
- A test that truncates
profiles.json, reloads, creates a profile and saves finds the original bytes preserved in a side file.
- No save path truncates the live file in place; all of them go through a temp file and a move or replace.
- Loading a corrupt file still returns empty rather than throwing, as it does today.
Related: #148 (concurrent read-modify-write races between saves) is a different root cause in the same code.
What's wrong
JsonKeybindingRepositorywrites every file withFile.WriteAllTextAsyncdirectly onto the target path:SaveProfileAsync:Keybinding/Keybinding/Services/JsonKeybindingRepository.cs
Lines 43 to 72 in 03d498c
DeleteProfileAsync:Keybinding/Keybinding/Services/JsonKeybindingRepository.cs
Lines 148 to 149 in 03d498c
SaveCommandsAsync:Keybinding/Keybinding/Services/JsonKeybindingRepository.cs
Lines 168 to 169 in 03d498c
SaveActiveProfileAsync:Keybinding/Keybinding/Services/JsonKeybindingRepository.cs
Lines 208 to 209 in 03d498c
WriteAllTextAsynctruncates the file before writing, so a crash or power loss partway through leaves a truncatedprofiles.json. That one file holds every profile, andKeybindingManager.SaveAsyncrewrites it once per profile, so the window is large.On the next start,
LoadAllProfilesInternalAsynccatches theJsonExceptionand silently returns an empty list (#L116-L120).LoadCommandsAsyncdoes the same forcommands.json(#L193-L197). The app therefore starts with no profiles. The usual startup path,CreateDefaultProfile()followed bySaveAsync(), then overwrites the damaged file, and every user binding it contained is lost for good.Reproduction (verified against current main)
Result: after the reload the profile count is 0, and the final
profiles.jsonholds only the emptydefaultprofile. The five saved profiles and their bindings are gone for good.Suggested fix
File.Move(temp, target, overwrite: true)orFile.Replace. A crash then leaves either the old file or the new one, never half of one.profiles.json.corrupt-<timestamp>) so the next save cannot destroy the only copy.Acceptance criteria
profiles.json, reloads, creates a profile and saves finds the original bytes preserved in a side file.Related: #148 (concurrent read-modify-write races between saves) is a different root cause in the same code.