Skip to content

Commit 3badc95

Browse files
rbenzingclaude
andcommitted
fix: replace lock() with SemaphoreSlim in KeyManager and OPKManager async paths
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a148ea0 commit 3badc95

3 files changed

Lines changed: 102 additions & 19 deletions

File tree

LibEmiddle.Tests.Unit/KeyManagementTests.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using System;
99
using System.Threading.Tasks;
1010
using System.Collections.Generic;
11+
using System.Linq;
1112
using Microsoft.VisualStudio.TestPlatform.ObjectModel.DataCollection;
1213

1314
namespace LibEmiddle.Tests.Unit
@@ -230,6 +231,26 @@ public async Task KeyCache_ShouldBeThreadSafe()
230231
await _keyManager.DeleteKeyAsync(keyId);
231232
}
232233

234+
/// <summary>
235+
/// Tests that concurrent store/retrieve operations do not deadlock with SemaphoreSlim
236+
/// </summary>
237+
[TestMethod]
238+
public async Task KeyManager_ConcurrentCacheAccess_DoesNotDeadlock()
239+
{
240+
// 20 concurrent tasks each store and retrieve a unique key
241+
var tasks = Enumerable.Range(0, 20).Select(i => Task.Run(async () =>
242+
{
243+
var keyId = $"concurrent-test-key-{i}";
244+
var keyData = new byte[32];
245+
System.Security.Cryptography.RandomNumberGenerator.Fill(keyData);
246+
await _keyManager.StoreKeyAsync(keyId, keyData);
247+
var retrieved = await _keyManager.RetrieveKeyAsync(keyId);
248+
Assert.IsNotNull(retrieved, $"Key {keyId} should be retrievable after store");
249+
}));
250+
251+
await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(10));
252+
}
253+
233254
#endregion
234255
}
235256
}

LibEmiddle/KeyManagement/KeyManager.cs

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public class KeyManager : IKeyManager, IDisposable
1717
private readonly ConcurrentDictionary<string, CachedKey> _keyCache = new();
1818
private readonly TimeSpan _cacheExpiration = TimeSpan.FromMinutes(10);
1919
private readonly Timer _cacheCleanupTimer;
20-
private readonly object _cacheLock = new object();
20+
private readonly SemaphoreSlim _cacheLock = new SemaphoreSlim(1, 1);
2121
private volatile bool _disposed;
2222

2323
/// <summary>
@@ -87,7 +87,7 @@ public async Task<bool> StoreKeyAsync(string keyId, byte[] key, string? password
8787
// If storage was successful, update the cache
8888
if (success)
8989
{
90-
CacheKey(keyId, key);
90+
await CacheKeyAsync(keyId, key).ConfigureAwait(false);
9191
}
9292

9393
return success;
@@ -108,7 +108,8 @@ public async Task<bool> StoreKeyAsync(string keyId, byte[] key, string? password
108108
try
109109
{
110110
// Check the cache first with secure handling
111-
lock (_cacheLock)
111+
await _cacheLock.WaitAsync().ConfigureAwait(false);
112+
try
112113
{
113114
if (_keyCache.TryGetValue(keyId, out CachedKey? cachedKey))
114115
{
@@ -124,14 +125,18 @@ public async Task<bool> StoreKeyAsync(string keyId, byte[] key, string? password
124125
}
125126
}
126127
}
128+
finally
129+
{
130+
_cacheLock.Release();
131+
}
127132

128133
// Not in cache, retrieve from storage
129134
byte[]? key = await _cryptoProvider.RetrieveKeyAsync(keyId, password);
130135

131136
// If key was found, update the cache
132137
if (key != null)
133138
{
134-
CacheKey(keyId, key);
139+
await CacheKeyAsync(keyId, key).ConfigureAwait(false);
135140
}
136141

137142
if (key == null)
@@ -162,13 +167,18 @@ public async Task<bool> DeleteKeyAsync(string keyId, string? password = null)
162167
try
163168
{
164169
// Remove from cache with secure disposal
165-
lock (_cacheLock)
170+
await _cacheLock.WaitAsync().ConfigureAwait(false);
171+
try
166172
{
167173
if (_keyCache.TryRemove(keyId, out CachedKey? cachedKey))
168174
{
169175
cachedKey.Dispose();
170176
}
171177
}
178+
finally
179+
{
180+
_cacheLock.Release();
181+
}
172182

173183
// Delete from storage
174184
return await _cryptoProvider.DeleteKeyAsync(keyId);
@@ -314,9 +324,10 @@ public async Task<TimeSpan> GetTimeUntilRotationAsync(string keyId, TimeSpan rot
314324
/// </summary>
315325
/// <param name="keyId">The identifier for the key.</param>
316326
/// <param name="key">The key to cache.</param>
317-
private void CacheKey(string keyId, byte[] key)
327+
private async Task CacheKeyAsync(string keyId, byte[] key)
318328
{
319-
lock (_cacheLock)
329+
await _cacheLock.WaitAsync().ConfigureAwait(false);
330+
try
320331
{
321332
// Remove existing cached key if present
322333
if (_keyCache.TryRemove(keyId, out CachedKey? existingKey))
@@ -327,6 +338,10 @@ private void CacheKey(string keyId, byte[] key)
327338
// Store new cached key with expiration
328339
_keyCache[keyId] = new CachedKey(key, _cacheExpiration);
329340
}
341+
finally
342+
{
343+
_cacheLock.Release();
344+
}
330345
}
331346

332347
/// <summary>
@@ -336,7 +351,8 @@ private void CleanupCache(object? state)
336351
{
337352
try
338353
{
339-
lock (_cacheLock)
354+
_cacheLock.Wait();
355+
try
340356
{
341357
var expiredKeys = new List<string>();
342358

@@ -358,6 +374,10 @@ private void CleanupCache(object? state)
358374
}
359375
}
360376
}
377+
finally
378+
{
379+
_cacheLock.Release();
380+
}
361381
}
362382
catch (Exception ex)
363383
{
@@ -388,16 +408,22 @@ protected virtual void Dispose(bool disposing)
388408
_cacheCleanupTimer.Dispose();
389409

390410
// Clear and dispose the key cache securely
391-
lock (_cacheLock)
411+
_cacheLock.Wait();
412+
try
392413
{
393414
foreach (var cachedKey in _keyCache.Values)
394415
{
395416
cachedKey.Dispose();
396417
}
397418
_keyCache.Clear();
398419
}
420+
finally
421+
{
422+
_cacheLock.Release();
423+
}
399424

400-
// Dispose the key storage
425+
// Dispose the semaphore and key storage
426+
_cacheLock.Dispose();
401427
_keyStorage.Dispose();
402428
}
403429

LibEmiddle/KeyManagement/OPKManager.cs

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public sealed class OPKManager : IDisposable
3232
// -----------------------------------------------------------------------
3333

3434
private readonly string _storageFilePath;
35-
private readonly object _lock = new object();
35+
private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1);
3636
private readonly HashSet<uint> _consumedIds;
3737

3838
// Replenishment callback — kept as null until set; called on a background task.
@@ -86,10 +86,15 @@ public void SetReplenishmentCallback(Func<int, Task> callback)
8686
public bool IsConsumed(uint opkId)
8787
{
8888
ThrowIfDisposed();
89-
lock (_lock)
89+
_lock.Wait();
90+
try
9091
{
9192
return _consumedIds.Contains(opkId);
9293
}
94+
finally
95+
{
96+
_lock.Release();
97+
}
9398
}
9499

95100
/// <summary>
@@ -112,14 +117,19 @@ public bool TryConsume(uint opkId, IReadOnlyList<uint>? allKnownIds = null)
112117
ThrowIfDisposed();
113118
bool added;
114119
int availableCount;
115-
lock (_lock)
120+
_lock.Wait();
121+
try
116122
{
117123
// HashSet.Add returns false when the element is already present.
118124
// Both the check and the insertion happen inside one lock acquisition,
119125
// so two concurrent arrivals with the same OPK ID cannot both succeed.
120126
added = _consumedIds.Add(opkId);
121127
availableCount = ComputeAvailableCount(allKnownIds);
122128
}
129+
finally
130+
{
131+
_lock.Release();
132+
}
123133

124134
if (added)
125135
{
@@ -154,11 +164,16 @@ public void MarkConsumed(uint opkId, IReadOnlyList<uint>? allKnownIds = null)
154164
bool changed;
155165
int availableCount;
156166

157-
lock (_lock)
167+
_lock.Wait();
168+
try
158169
{
159170
changed = _consumedIds.Add(opkId);
160171
availableCount = ComputeAvailableCount(allKnownIds);
161172
}
173+
finally
174+
{
175+
_lock.Release();
176+
}
162177

163178
if (changed)
164179
{
@@ -181,7 +196,8 @@ public IReadOnlyList<uint> FilterAvailable(IReadOnlyList<uint> allIds)
181196
ThrowIfDisposed();
182197
ArgumentNullException.ThrowIfNull(allIds);
183198

184-
lock (_lock)
199+
_lock.Wait();
200+
try
185201
{
186202
var result = new List<uint>(allIds.Count);
187203
foreach (uint id in allIds)
@@ -191,6 +207,10 @@ public IReadOnlyList<uint> FilterAvailable(IReadOnlyList<uint> allIds)
191207
}
192208
return result;
193209
}
210+
finally
211+
{
212+
_lock.Release();
213+
}
194214
}
195215

196216
/// <summary>
@@ -200,10 +220,15 @@ public IReadOnlyList<uint> FilterAvailable(IReadOnlyList<uint> allIds)
200220
public int GetAvailableCount(IReadOnlyList<uint>? allKnownIds)
201221
{
202222
ThrowIfDisposed();
203-
lock (_lock)
223+
_lock.Wait();
224+
try
204225
{
205226
return ComputeAvailableCount(allKnownIds);
206227
}
228+
finally
229+
{
230+
_lock.Release();
231+
}
207232
}
208233

209234
/// <summary>
@@ -214,10 +239,15 @@ public int ConsumedCount
214239
get
215240
{
216241
ThrowIfDisposed();
217-
lock (_lock)
242+
_lock.Wait();
243+
try
218244
{
219245
return _consumedIds.Count;
220246
}
247+
finally
248+
{
249+
_lock.Release();
250+
}
221251
}
222252
}
223253

@@ -255,10 +285,15 @@ private void PersistToDisk()
255285
try
256286
{
257287
List<uint> snapshot;
258-
lock (_lock)
288+
_lock.Wait();
289+
try
259290
{
260291
snapshot = new List<uint>(_consumedIds);
261292
}
293+
finally
294+
{
295+
_lock.Release();
296+
}
262297

263298
string json = JsonSerializer.Serialize(snapshot);
264299
// Write atomically via a temp file then replace.
@@ -337,10 +372,11 @@ private void ThrowIfDisposed()
337372
// IDisposable
338373
// -----------------------------------------------------------------------
339374

340-
/// <summary>Disposes the manager (no-op: no unmanaged resources).</summary>
375+
/// <summary>Disposes the manager and releases resources.</summary>
341376
public void Dispose()
342377
{
343378
_disposed = true;
379+
_lock.Dispose();
344380
}
345381
}
346382
}

0 commit comments

Comments
 (0)