Serialize cache and store mutations per persona - #161
Merged
Merged
Conversation
Remove() removed from the in-memory cache first and the store second, while AddOrReplace() persists first and caches second, and the two were not synchronized against each other. A concurrent AddOrReplace() for the same persona could land between Remove()'s two halves: it persisted and cached a new credential, and Remove() then deleted the entry it had just written. Remove() returned true while the credential stayed readable from the cache for the lifetime of the process - a revoked credential that keeps working. Mutations of one persona's cache entry and store entry now happen together under a lock striped on the persona, so AddOrReplace() and Remove() for the same persona can no longer interleave. Remove() also clears the store before the cache, mirroring AddOrReplace()'s persist-then-cache order instead of inverting it. TryGet()'s store-loading path takes the same lock. It is a cache hit away from being a pure read, but the load-then-cache is the same pair of mutations, and loading either side of a removal would put back exactly what the removal took out. A cache hit is still answered without a lock. Fixes #159 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLBd6CLjwb1r6wuDqDzW4J
|
matt-edmondson
pushed a commit
that referenced
this pull request
Sep 26, 2026
#161 and this branch both added a bullet to README.md's platform notes at the same point, so merging #161 left this branch conflicted. Both bullets are kept: the persona-locking one from #161 first, since it continues the thread-safety bullet above it, then this branch's plaintext-scrubbing one. No code conflicted. The two changes are independent - #161 locks the cache and store per persona in CredentialCache.cs, this one moves the Linux store's plaintext onto scrubbed buffers - and the Save path's new NativeSecretBuffer.OfCredential call runs under that lock without re-entering the cache, so the two compose. 54 tests pass on the merge result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLBd6CLjwb1r6wuDqDzW4J
This was referenced Sep 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Fixes #159
The defect
AddOrReplacepersists then caches;Removedid the opposite, cache then store; and nothing synchronized the two. A concurrentAddOrReplacefor the same persona could land betweenRemove's two halves, persist and cache a new credential, and haveRemovethen delete the entry it had just written. End state: readable from_credentials, absent from the store, withRemovehaving returnedtrue.Measured at
main(410f9d1), .NET SDK 10.0.401, Linux — a store that parks the first thread to enter itsRemovewhile a second thread completesAddOrReplace:That is a
Removeused as a logout or revoke primitive reporting success while the credential stays usable until the process exits.The change
Mutations of one persona's cache entry and store entry now happen together under a lock:
Striped (64), not one lock per persona. A
ConcurrentDictionary<PersonaGUID, Lock>would grow for the lifetime of the process with nothing to key its eviction on — this library's personas are long-lived and there is no signal for when one is done with. Two personas sharing a stripe serialize when they need not, which costs a little contention and never correctness.Striping on
GetHashCode()is what makes it sound._credentialskeys on the defaultEqualityComparer<PersonaGUID>, so equal personas are one key and — by the hash/equals contract — one stripe. Two distinctPersonaGUIDinstances carrying the same value therefore serialize against each other; aConditionalWeakTableor any instance-keyed scheme would not, and would have left the race open for exactly the callers who build the GUID rather than holding one.Removealso clears the store first, mirroringAddOrReplace's persist-then-cache order rather than inverting it. Under the lock the ordering no longer decides the race, but it does decide what a throwing store leaves behind: store-first means a failedStore.Removeleaves both halves intact, matching the invariantAddOrReplace's doc comment already states. The issue offers this as an alternative to locking; it is neither sufficient alone (it narrows the window rather than closing it) nor redundant once the lock is there.TryGettakes the lock tooThis is the one part of the change the issue does not ask for, so the reasoning in full.
TryGetlooks like a read, but on a cache miss it writes —Store.TryLoadthenGetOrAdd. That is the same pair of mutations, and it can straddle a removal the same way:TryGetmisses the cache and loads the credential from the store.Removeruns to completion.TryGetcaches what it loaded.Same end state, same severity, reached without
AddOrReplaceat all. Guarding only the two methods named in the issue would have left the invariant it states — present in both or absent from both — still false. A cache hit, which is the common path, is answered before the lock is taken, so the fast path is unchanged.The cost is real and worth naming: the lock is held across a native store call, so a
Removeracing anAddOrReplaceon one persona now serializes at the speed of libsecret or Keychain rather than returning immediately with the wrong answer.README.mdalready tells callers to treat these as I/O; this adds the sentence saying why that matters more now.Tests
PersonaMutationRaceTests.cs.RemoveBlockingCredentialStoreparks the first thread to enterRemoveand lets the test drive a second thread throughAddOrReplacewhile it is held there, which is what makes the interleaving deterministic rather than hoped for.Its wait is bounded, not indefinite, and that is load-bearing: once mutations serialize, the second thread cannot reach
Release— it is blocked behind the removal — so an unbounded wait would deadlock the fixed code instead of letting it finish. The fixed code takes the 1s timeout and then completes; the unfixed code is released in milliseconds.ReleaseTimedOutis reported in the failure message rather than asserted on, so the test pins the outcome and not the implementation.RemoveInterleavedWithAddOrReplace…/the same PersonaGUID instanceRemoveInterleavedWithAddOrReplace…/a distinct PersonaGUID of equal valueConcurrentAddOrReplaceAndRemoveOnTheSamePersonaAgreeOnTheFinalStateThe second row is not a duplicate of the first. It is the case a plausible wrong fix passes, and it also asserts the hash/equals property the striping depends on, so the scheme breaking is a test failure rather than a silent return of the race.
The third is the issue's requested stress test and it does catch the defect — the window turned out to be wide enough to hit within 500 attempts on every run — but the deterministic cases are the ones that catch it every time, and they are what I would rely on.
Proved failing without the fix. Reverting only
CredentialCache/CredentialCache.csand keeping the tests, three consecutive runs:All three fail on the reported symptom —
readable=True but store=False— not on a message that merely changed shape. The 32 pre-existing tests are unaffected in both directions.CredentialCacheIsThreadSafeUnderConcurrentAccessstill passes; as the issue notes, it never interleaved the two methods on one key.Verification
dotnet build CredentialCache.sln -c Release— succeeded, 0 warnings, 0 errors, acrossnet9.0andnet10.0dotnet test CredentialCache.sln -c Release— 40 total, 35 passed, 0 failed, 5 skipped, three consecutive runsCredentialCache.cs— 3 of 40 failed, three consecutive runs, as aboveThe 5 skips are pre-existing and environmental: 4
NativeCredentialStoreTestscases self-skip because no Secret Service is available in this container, and 1 is Windows-only.Worth knowing
Building this repository rewrites
.gitignorein the working tree from the SDK's shared template — it adds Unity.metaand Godot rules unrelated to anything here. It is not part of this diff; I reverted it. Anyone working this repository will see it appear after a build and should not commit it by accident.Not in this change
Disposestill clears_credentialswithout taking the persona locks. It is documented as releasing in-memory state only and touches no store, so it cannot produce the disagreement this fixes; making disposal wait on in-flight mutations is a lifetime question rather than this race.🤖 Generated with Claude Code
https://claude.ai/code/session_01LLBd6CLjwb1r6wuDqDzW4J
Generated by Claude Code