Skip to content

A torn or corrupt profiles.json loads as zero profiles and the next SaveAsync overwrites it for good (non-atomic saves, corrupt file not kept) #157

Description

@matt-edmondson

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions