From e1c35d742315ccdbbac4d821c70ac0e9a8bce4ea Mon Sep 17 00:00:00 2001 From: Fritiof Hedman Date: Sun, 7 Jun 2026 16:03:11 +0200 Subject: [PATCH] 8.2 FrameLockProvider Keyed per-frame SemaphoreSlim(1,1) over a ConcurrentDictionary with a disposable releaser, so the next increment can serialize the upload pipeline per FrameName. Co-Authored-By: Claude Opus 4.7 (1M context) --- IMPLEMENTATION.md | 2 +- .../Concurrency/FrameLockProvider.cs | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/FrameProcessor/Concurrency/FrameLockProvider.cs diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index d25ee36..069777b 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -198,7 +198,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor. - Matches request path `/api/*`; reads `X-Api-Key` header; constant-time compare against `ApiKeyOptions` only if `ApiKeyOptions`is set to non-empty. - 401 on mismatch. `/i/{mac}.png` and `/health` unaffected. -### [ ] 8.2 `FrameLockProvider` +### [x] 8.2 `FrameLockProvider` - `ConcurrentDictionary` (each `SemaphoreSlim(1, 1)`). - `Task AcquireAsync(FrameName, CancellationToken)` returning a disposable that releases on dispose. diff --git a/src/FrameProcessor/Concurrency/FrameLockProvider.cs b/src/FrameProcessor/Concurrency/FrameLockProvider.cs new file mode 100644 index 0000000..5d36308 --- /dev/null +++ b/src/FrameProcessor/Concurrency/FrameLockProvider.cs @@ -0,0 +1,33 @@ +using System.Collections.Concurrent; +using FrameProcessor.Domain; + +namespace FrameProcessor.Concurrency; + +/// +/// Hands out a per-frame so concurrent uploads to the same +/// frame are serialized while different frames remain independent +/// (see SPEC.md ยง3.4, CLAUDE.md "Per-frame serialization"). +/// +public sealed class FrameLockProvider +{ + private readonly ConcurrentDictionary _locks = new(); + + public async Task AcquireAsync(FrameName frame, CancellationToken cancellationToken) + { + var semaphore = _locks.GetOrAdd(frame, _ => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + return new Releaser(semaphore); + } + + private sealed class Releaser : IDisposable + { + private SemaphoreSlim? _semaphore; + + public Releaser(SemaphoreSlim semaphore) => _semaphore = semaphore; + + public void Dispose() + { + Interlocked.Exchange(ref _semaphore, null)?.Release(); + } + } +}