mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-06 03:07:04 +02:00
CmdPal: Make settings and app state immutable (#46451)
## Summary This PR refactors CmdPal settings/state to be immutable end-to-end. ### Core changes - Convert model types to immutable records / init-only properties: - `SettingsModel` - `AppStateModel` - `ProviderSettings` - `DockSettings` - `RecentCommandsManager` - supporting settings types (fallback/hotkey/alias/top-level hotkey/history items, etc.) - Replace mutable collections with immutable equivalents where appropriate: - `ImmutableDictionary<,>` - `ImmutableList<>` - Move mutation flow to atomic service updates: - `ISettingsService.UpdateSettings(Func<SettingsModel, SettingsModel>)` - `IAppStateService.UpdateState(Func<AppStateModel, AppStateModel>)` - Update ViewModels/managers/services to use copy-on-write (`with`) patterns instead of in-place mutation. - Update serialization context + tests for immutable model graph compatibility. ## Why Issue #46437 is caused by mutable shared state being updated from different execution paths/threads, leading to race-prone behavior during persistence/serialization. By making settings/app state immutable and using atomic swap/update patterns, we remove in-place mutation and eliminate this class of concurrency bug. ## Validation - Built successfully: - `Microsoft.CmdPal.UI.ViewModels` - `Microsoft.CmdPal.UI` - `Microsoft.CmdPal.UI.ViewModels.UnitTests` - Updated unit tests for immutable update patterns. Fixes #46437 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -22,20 +22,35 @@ public sealed class AppStateService : IAppStateService
|
||||
_persistence = persistence;
|
||||
_appInfoService = appInfoService;
|
||||
_filePath = StateJsonPath();
|
||||
State = _persistence.Load(_filePath, JsonSerializationContext.Default.AppStateModel);
|
||||
_state = _persistence.Load(_filePath, JsonSerializationContext.Default.AppStateModel);
|
||||
}
|
||||
|
||||
private AppStateModel _state;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public AppStateModel State { get; private set; }
|
||||
public AppStateModel State => Volatile.Read(ref _state);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event TypedEventHandler<IAppStateService, AppStateModel>? StateChanged;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Save()
|
||||
public void Save() => UpdateState(s => s);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void UpdateState(Func<AppStateModel, AppStateModel> transform)
|
||||
{
|
||||
_persistence.Save(State, _filePath, JsonSerializationContext.Default.AppStateModel);
|
||||
StateChanged?.Invoke(this, State);
|
||||
AppStateModel snapshot;
|
||||
AppStateModel updated;
|
||||
do
|
||||
{
|
||||
snapshot = Volatile.Read(ref _state);
|
||||
updated = transform(snapshot);
|
||||
}
|
||||
while (Interlocked.CompareExchange(ref _state, updated, snapshot) != snapshot);
|
||||
|
||||
var newState = Volatile.Read(ref _state);
|
||||
_persistence.Save(newState, _filePath, JsonSerializationContext.Default.AppStateModel);
|
||||
StateChanged?.Invoke(this, newState);
|
||||
}
|
||||
|
||||
private string StateJsonPath()
|
||||
|
||||
@@ -21,6 +21,12 @@ public interface IAppStateService
|
||||
/// </summary>
|
||||
void Save();
|
||||
|
||||
/// <summary>
|
||||
/// Atomically applies a transformation to the current state, persists the result,
|
||||
/// and raises <see cref="StateChanged"/>.
|
||||
/// </summary>
|
||||
void UpdateState(Func<AppStateModel, AppStateModel> transform);
|
||||
|
||||
/// <summary>
|
||||
/// Raised after state has been saved to disk.
|
||||
/// </summary>
|
||||
|
||||
@@ -22,6 +22,12 @@ public interface ISettingsService
|
||||
/// <param name="hotReload">When <see langword="true"/>, raises <see cref="SettingsChanged"/> after saving.</param>
|
||||
void Save(bool hotReload = true);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically applies a transformation to the current settings, persists the result,
|
||||
/// and optionally raises <see cref="SettingsChanged"/>.
|
||||
/// </summary>
|
||||
void UpdateSettings(Func<SettingsModel, SettingsModel> transform, bool hotReload = true);
|
||||
|
||||
/// <summary>
|
||||
/// Raised after settings are saved with <paramref name="hotReload"/> enabled, or after <see cref="Reload"/>.
|
||||
/// </summary>
|
||||
|
||||
@@ -29,27 +29,38 @@ public sealed class SettingsService : ISettingsService
|
||||
_persistence = persistence;
|
||||
_appInfoService = appInfoService;
|
||||
_filePath = SettingsJsonPath();
|
||||
Settings = _persistence.Load(_filePath, JsonSerializationContext.Default.SettingsModel);
|
||||
_settings = _persistence.Load(_filePath, JsonSerializationContext.Default.SettingsModel);
|
||||
ApplyMigrations();
|
||||
}
|
||||
|
||||
private SettingsModel _settings;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SettingsModel Settings { get; private set; }
|
||||
public SettingsModel Settings => Volatile.Read(ref _settings);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event TypedEventHandler<ISettingsService, SettingsModel>? SettingsChanged;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Save(bool hotReload = true)
|
||||
{
|
||||
_persistence.Save(
|
||||
Settings,
|
||||
_filePath,
|
||||
JsonSerializationContext.Default.SettingsModel);
|
||||
public void Save(bool hotReload = true) => UpdateSettings(s => s, hotReload);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void UpdateSettings(Func<SettingsModel, SettingsModel> transform, bool hotReload = true)
|
||||
{
|
||||
SettingsModel snapshot;
|
||||
SettingsModel updated;
|
||||
do
|
||||
{
|
||||
snapshot = Volatile.Read(ref _settings);
|
||||
updated = transform(snapshot);
|
||||
}
|
||||
while (Interlocked.CompareExchange(ref _settings, updated, snapshot) != snapshot);
|
||||
|
||||
var newSettings = Volatile.Read(ref _settings);
|
||||
_persistence.Save(newSettings, _filePath, JsonSerializationContext.Default.SettingsModel);
|
||||
if (hotReload)
|
||||
{
|
||||
SettingsChanged?.Invoke(this, Settings);
|
||||
SettingsChanged?.Invoke(this, newSettings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,10 +82,10 @@ public sealed class SettingsService : ISettingsService
|
||||
migratedAny |= TryMigrate(
|
||||
"Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan)",
|
||||
root,
|
||||
Settings,
|
||||
ref _settings,
|
||||
nameof(SettingsModel.AutoGoHomeInterval),
|
||||
DeprecatedHotkeyGoesHomeKey,
|
||||
(model, goesHome) => model.AutoGoHomeInterval = goesHome ? TimeSpan.Zero : Timeout.InfiniteTimeSpan,
|
||||
(ref SettingsModel model, bool goesHome) => model = model with { AutoGoHomeInterval = goesHome ? TimeSpan.Zero : Timeout.InfiniteTimeSpan },
|
||||
JsonSerializationContext.Default.Boolean);
|
||||
}
|
||||
}
|
||||
@@ -89,13 +100,15 @@ public sealed class SettingsService : ISettingsService
|
||||
}
|
||||
}
|
||||
|
||||
private delegate void MigrationApply<T>(ref SettingsModel model, T value);
|
||||
|
||||
private static bool TryMigrate<T>(
|
||||
string migrationName,
|
||||
JsonObject root,
|
||||
SettingsModel model,
|
||||
ref SettingsModel model,
|
||||
string newKey,
|
||||
string oldKey,
|
||||
Action<SettingsModel, T> apply,
|
||||
MigrationApply<T> apply,
|
||||
JsonTypeInfo<T> jsonTypeInfo)
|
||||
{
|
||||
try
|
||||
@@ -108,7 +121,7 @@ public sealed class SettingsService : ISettingsService
|
||||
if (root.TryGetPropertyValue(oldKey, out var oldNode) && oldNode is not null)
|
||||
{
|
||||
var value = oldNode.Deserialize(jsonTypeInfo);
|
||||
apply(model, value!);
|
||||
apply(ref model, value!);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user