Repository navigation
Description
What's wrong
Since #188, KeybindingManagerFactory.CreateManager() returns a new KeybindingManager built over the shared DI ICommandRegistry, IProfileManager and IKeybindingRepository (Keybinding/Services/KeybindingManagerFactory.cs:33-44). The IKeybindingManagerFactory docs say it shares their state.
The persistence bookkeeping that #106, #124 and #150 introduced is kept per manager instance, though. _persistedProfileIds, _persistedCommandIds and _activeProfileKnown live in Keybinding/KeybindingManager.cs:17-28, and only that instance's own InitializeAsync/SaveAsync fills them. SaveAsync uses them like this:
- Commands (
KeybindingManager.cs:~146): it keeps every stored command that is not in_persistedCommandIds. A fresh factory manager's set is empty, so a command unregistered through it is written back tocommands.json. - Profiles (
KeybindingManager.cs:~166): it deletes only stored profiles that are in_persistedProfileIds. For a fresh factory manager that set is empty, so nothing is deleted from storage.
Failure scenario
services.AddKeybinding(dir). Resolve the registeredKeybindingManagerand callInitializeAsync(). This loads profilesdefaultandvim, and commandold.cmd.using var m = factory.CreateManager();m.Profiles.DeleteProfile("vim"); m.Commands.UnregisterCommand("old.cmd"); await m.SaveAsync();- Restart (create a fresh manager over
dirand callInitializeAsync()).vimandold.cmdare both back.
I reproduced this with an MSTest case against HEAD 6d9183d, and both asserts fail.
Calling m.InitializeAsync() before editing is not a safe workaround:
- It reloads from disk into the shared registry and profile manager. That resurrects profiles another holder deleted in memory but has not saved yet (
KeybindingManager.cs:103-107). - It resets the shared active profile to the stored one, which overwrites an unsaved choice (
:115-119).
Suggested fix
Keep the persisted-id state with the shared services rather than per facade instance. For example, hold it in a small singleton tracker registered alongside IKeybindingRepository, or inside the repository itself. Managers built over the same registry, profile manager and repository then agree on what is already on disk.
Acceptance criteria
- In the scenario above, after reloading
dirwith a fresh manager,vimandold.cmdare both gone. - A factory-created manager can save deletions without first calling
InitializeAsync. - Calling
InitializeAsyncon a factory-created manager does not resurrect profiles deleted in the sharedProfileManager, and does not change the in-memory active profile. - The existing single-manager guarantees from Deleted profiles come back after SaveAsync + restart, because deletion is never persisted #106 and SaveAsync on a manager that never called InitializeAsync deletes every stored profile #124 still hold. In particular, a manager over a repository it never loaded must not delete stored profiles.
Related, but not the same: #195, a race between two managers saving at once. This bug needs no concurrency.
Activity
matt-edmondson commentedon Oct 29, 2026
Triage
Keybinding/KeybindingManager.cs(per-instance persisted-id tracking) andKeybinding/Services/KeybindingManagerFactory.csGenerated by Claude Code