Skip to content

Commit 8bf6235

Browse files
Fix SecureSettings cache race (#4620)
Replace the SecureSettings cache with ConcurrentDictionary so parallel package manager initialization cannot corrupt shared state. Add regression coverage for concurrent cache misses in SecureSettings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0e7c2bd commit 8bf6235

2 files changed

Lines changed: 57 additions & 25 deletions

File tree

src/UniGetUI.Core.SecureSettings/SecureSettings.cs

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Concurrent;
12
using System.Diagnostics;
23
using UniGetUI.Core.Data;
34
using UniGetUI.Core.Tools;
@@ -38,7 +39,7 @@ public static string ResolveKey(K key)
3839
};
3940
}
4041

41-
private static readonly Dictionary<string, bool> _cache = new();
42+
private static readonly ConcurrentDictionary<string, bool> _cache = new();
4243

4344
public static class Args
4445
{
@@ -49,31 +50,13 @@ public static class Args
4950
public static bool Get(K key)
5051
{
5152
string purifiedSetting = CoreTools.MakeValidFileName(ResolveKey(key));
52-
if (_cache.TryGetValue(purifiedSetting, out var value))
53-
{
54-
return value;
55-
}
56-
57-
string purifiedUser = CoreTools.MakeValidFileName(Environment.UserName);
58-
59-
var settingsLocation = Path.Join(GetSecureSettingsRoot(), purifiedUser);
60-
var settingFile = Path.Join(settingsLocation, purifiedSetting);
61-
62-
if (!Directory.Exists(settingsLocation))
63-
{
64-
_cache[purifiedSetting] = false;
65-
return false;
66-
}
67-
68-
bool exists = File.Exists(settingFile);
69-
_cache[purifiedSetting] = exists;
70-
return exists;
53+
return _cache.GetOrAdd(purifiedSetting, ResolveSettingValue);
7154
}
7255

7356
public static async Task<bool> TrySet(K key, bool enabled)
7457
{
7558
string purifiedSetting = CoreTools.MakeValidFileName(ResolveKey(key));
76-
_cache.Remove(purifiedSetting);
59+
_cache.TryRemove(purifiedSetting, out _);
7760

7861
string purifiedUser = CoreTools.MakeValidFileName(Environment.UserName);
7962

@@ -107,7 +90,7 @@ public static int ApplyForUser(string username, string setting, bool enable)
10790
try
10891
{
10992
string purifiedSetting = CoreTools.MakeValidFileName(setting);
110-
_cache.Remove(purifiedSetting);
93+
_cache.TryRemove(purifiedSetting, out _);
11194

11295
string purifiedUser = CoreTools.MakeValidFileName(username);
11396

@@ -158,4 +141,17 @@ private static string GetSecureSettingsRoot()
158141

159142
return Path.Join(CoreData.UniGetUIDataDirectory, "SecureSettings");
160143
}
144+
145+
private static bool ResolveSettingValue(string purifiedSetting)
146+
{
147+
string purifiedUser = CoreTools.MakeValidFileName(Environment.UserName);
148+
var settingsLocation = Path.Join(GetSecureSettingsRoot(), purifiedUser);
149+
if (!Directory.Exists(settingsLocation))
150+
{
151+
return false;
152+
}
153+
154+
var settingFile = Path.Join(settingsLocation, purifiedSetting);
155+
return File.Exists(settingFile);
156+
}
161157
}

src/UniGetUI.Core.Settings.Tests/SecureSettingsTests.cs

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Concurrent;
12
using System.Reflection;
23
using UniGetUI.Core.Tools;
34
using SecureSettingsStore = UniGetUI.Core.SettingsEngine.SecureSettings.SecureSettings;
@@ -87,6 +88,40 @@ public void Get_RefreshesCachedValueAfterApplyForUserWrites()
8788
Assert.False(SecureSettingsStore.Get(SecureSettingsStore.K.AllowCLIArguments));
8889
}
8990

91+
[Fact]
92+
public async Task Get_AllowsConcurrentCacheMisses()
93+
{
94+
string username = Environment.UserName;
95+
string setting = SecureSettingsStore.ResolveKey(
96+
SecureSettingsStore.K.AllowCustomManagerPaths
97+
);
98+
Assert.Equal(0, SecureSettingsStore.ApplyForUser(username, setting, true));
99+
100+
for (int iteration = 0; iteration < 25; iteration++)
101+
{
102+
ClearSecureSettingsCache();
103+
using ManualResetEventSlim startGate = new(false);
104+
105+
Task<bool>[] tasks = Enumerable
106+
.Range(0, 64)
107+
.Select(_ =>
108+
Task.Run(() =>
109+
{
110+
startGate.Wait();
111+
return SecureSettingsStore.Get(
112+
SecureSettingsStore.K.AllowCustomManagerPaths
113+
);
114+
})
115+
)
116+
.ToArray();
117+
118+
startGate.Set();
119+
bool[] results = await Task.WhenAll(tasks);
120+
121+
Assert.All(results, Assert.True);
122+
}
123+
}
124+
90125
private string GetCurrentUserSettingsDirectory() =>
91126
Path.Combine(_testRoot, CoreTools.MakeValidFileName(Environment.UserName));
92127

@@ -97,15 +132,16 @@ private string GetSettingsFilePath(string username, string setting) =>
97132
CoreTools.MakeValidFileName(setting)
98133
);
99134

100-
private static void ClearSecureSettingsCache()
135+
private static ConcurrentDictionary<string, bool> GetCache()
101136
{
102137
FieldInfo? cacheField = typeof(SecureSettingsStore).GetField(
103138
"_cache",
104139
BindingFlags.NonPublic | BindingFlags.Static
105140
);
106141
Assert.NotNull(cacheField);
107142

108-
var cache = Assert.IsType<Dictionary<string, bool>>(cacheField.GetValue(null));
109-
cache.Clear();
143+
return Assert.IsType<ConcurrentDictionary<string, bool>>(cacheField.GetValue(null));
110144
}
145+
146+
private static void ClearSecureSettingsCache() => GetCache().Clear();
111147
}

0 commit comments

Comments
 (0)