Repository navigation
Fix persistence, cache, encoding, DI and distribution bugs [patch] - #164
Conversation
Fixes #139 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
Fixes #146 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
Fixes #150 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
…lize or deserialize Fixes #161 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
…ncellation and unreadable records FileSystem and Temp now share one FileRecordStore instead of two copies. - A record that is there but cannot be parsed throws instead of reading as missing, so RetrieveOrCreateAsync never writes a default over it. Fixes #82 - Cancellation part way through surfaces as OperationCanceledException. Fixes #121 - A remove landing between a reader's existence check and its read reports the key missing. Fixes #123 - Deletes retry the sharing violation a reader or replace raises. Fixes #143 - RetrieveOrCreateAsync publishes its new instance only if nothing appeared meanwhile (CreateNew, since a non-overwriting File.Move is check-then-rename on Unix), on every provider including InMemory, ConfigHome and DataHome. Fixes #144 - ClearAsync removes staging files an interrupted write abandoned. Fixes #145 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
net8 and later skipped it while earlier targets rejected it. Fixes #140 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
Fixes #142 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
Fixes #149 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
…and make GetOrAdd atomic - Expiry uses Stopwatch timestamps, so a wall-clock step changes nothing. Fixes #162 - Sets sweep expired entries now and then. Fixes #79 - GetOrAdd adds only if nothing live is cached. Fixes #85 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
Refs #140 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
| catch (Exception ex) | ||
| { | ||
| throw new PersistenceProviderException($"Failed to retrieve all keys from {location}", ex); | ||
| } |
There was a problem hiding this comment.
This stays as it is on purpose. The store's contract is that cancellation passes through and every other failure becomes a PersistenceProviderException, which this PR's CLAUDE.md entry for FileRecordStore documents. Narrowing the catch would let a serializer- or key-conversion failure of any other type escape unwrapped, which is a behavior change for callers.
Generated by Claude Code
…paths HmacKeyedHashCore's stream TryHash caught only IOException, so a disposed or write-only stream threw ObjectDisposedException or NotSupportedException out of a Try method. The MD5 and SHA stream paths had the same gap. Both now report false, matching the non-cryptographic providers. Fixes #74 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
Compression, AES and the encoders let the NotSupportedException the BCL raises for a write-only source or a read-only destination escape a Try method, and the self-contained obfuscators caught nothing at all. All of them now return false, and the self-allocating wrappers throw their documented InvalidOperationException. Fixes #86 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
Weights such as [1e308, 1e308, 0] summed to infinity, so every share was zero and the last outcome took every draw although its weight was zero. The weights are now summed divided by the largest when the plain total overflows. The categorical CDF is pinned to one from the last outcome with weight onward, so a trailing zero weight no longer inherits the rounding gap below one. Fixes #78 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
Serialize wrote snake_case names through Tomlyn while Deserialize used a hand-written mapper that looked for the PascalCase name, skipped lists and nested tables, and threw MissingMethodException for types without a parameterless constructor. Deserialize now binds with Tomlyn's typed ToModel under the same options Serialize writes with, ignores unknown keys, and returns default for a type Tomlyn cannot create. Fixes #69 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
Roll deleted the oldest file and shifted the rest before moving the active one, so while a viewer held the log open without delete sharing every entry deleted another rolled file until no history was left. Each move is now recorded and undone if a later one fails, the oldest file is deleted only after the roll completes, and an entry whose roll is refused is appended past the limit rather than dropped. Fixes #120 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
Every AES path copied the caller's key with ToArray and dropped the copy unzeroed, and the span paths did the same with the plaintext and the decrypted result. Each copy is now cleared with CryptographicOperations.ZeroMemory as soon as the transform holds its own, as the HMAC providers already do. A known-answer test checks every path against an independent Aes, since a round trip would not notice a key zeroed too early. Fixes #98 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
After the shell exited, Execute joined its output drains with no token check, so a job the command left running on the pipes made a cancel do nothing until that job ended, and the run was then reported as a success. The drains are now waited for under the token, the two abandon waits share one budget, and the net6.0 and netstandard2.1 async reads are bounded by the token too. Tests pin both #125 and #97. The tree kill that #97 asks for already shipped with #100; the new test covers its subshell case and how promptly the call returns. Fixes #125 Fixes #97 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
…nc paths Only Gzip had a byte-identity test between its async and sync stream paths. The test now runs over Gzip, Deflate, ZLib and Brotli, and an AES test with a fixed key and IV compares TryEncryptAsync with TryEncrypt. Test-only change. Fixes #83 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
The FNV hashes, the Hex encoder and the Xor, Caesar and BitRotate obfuscators called ReadByte and WriteByte per byte, which on a stream that does not override them allocates and makes a call per byte: about eleven times as slow, with 32 bytes of garbage per input byte. Each now transforms a pooled chunk at a time, as their async paths already did. Tests compare every stream path with its span path for empty, short and multi-chunk inputs through a short-reading stream. Fixes #91 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
GZipStream and ZLibStream treat input that runs out as a clean end of stream, so a payload cut off part way decompressed to a shorter one and was reported as a success. Every decompression path now checks the format's trailer against the output, and Brotli goes through BrotliDecoder, which reports an unfinished stream itself. Raw Deflate has no trailer, which its documentation now says. Fixes #80 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
| using BrotliStream brotliStream = new(compressedData, CompressionMode.Decompress, leaveOpen: true); | ||
| brotliStream.CopyTo(destination); | ||
| return true; | ||
| BrotliDecoder decoder = new(); |
There was a problem hiding this comment.
This stays as it is. BrotliDecoder is a mutable struct, and a using local is read-only, so each Decompress call would run on a defensive copy. That works today only because the copy shares the decoder's native handle. The explicit try/finally keeps the calls on the one variable that gets disposed.
Generated by Claude Code
| { | ||
| BrotliStream brotliStream = new(compressedData, CompressionMode.Decompress, leaveOpen: true); | ||
| await using (brotliStream.ConfigureAwait(false)) | ||
| BrotliDecoder decoder = new(); |
There was a problem hiding this comment.
This stays as it is. BrotliDecoder is a mutable struct, and a using local is read-only, so each Decompress call would run on a defensive copy. That works today only because the copy shares the decoder's native handle. The explicit try/finally keeps the calls on the one variable that gets disposed.
Generated by Claude Code
| foreach (int cut in new[] { 1, 2, 3, 4, 8, 12, length / 3, length / 2, length - 9, length - 8, length - 5, length - 4, length - 2, length - 1 }) | ||
| { | ||
| if (cut > 0 && cut < length) | ||
| { | ||
| yield return cut; | ||
| } | ||
| } |
There was a problem hiding this comment.
This stays as it is. It's a test helper, and the guard just drops the cut offsets that fall outside a short payload, so a Where wouldn't make it any clearer.
Generated by Claude Code
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
A read landing as an overwriting move swaps the target can be refused with access denied rather than a sharing violation, because the replaced file is still pending deletion. The concurrent-store test caught it on the Windows runner. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
|
The next commit also retries access denied on Windows reads. A real permission problem still surfaces once the attempts run out. I can only reproduce this on Windows, so the next Windows run is the check. Generated by Claude Code |
…alysis The FNV hash and Xor, Caesar and BitRotate obfuscation stream paths each carried the same pooled-buffer loop, which SonarCloud counted as duplication; it now lives once in Shared/StreamChunks.cs. The file logger's roll recorded its moves through a local function, so Sonar read the undo loop as unreachable (S2583); the moves list is now passed in. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
|



Requested by Matt · project thread
One commit per issue (or per tight cluster), each with a regression test.
Persistence (FileSystem, Temp, ConfigHome, DataHome, InMemory)
FileSystem and Temp were two copies of the same code, so they now share
Shared/FileRecordStore.cs(replacingPersistenceDefaults.cs). ConfigHome and DataHome now forwardRetrieveOrCreateAsyncto it instead of using the interface's check-then-store default.PersistenceProviderExceptioninstead of reading as missing, soRetrieveOrCreateAsyncnever writes a default over it. Behaviour change:RetrieveAsyncof a malformed record now throws rather than returning null.OperationCanceledException.RetrieveOrCreateAsyncpublishes its new instance only if nothing appeared meanwhile. The new race test caught that .NET's non-overwritingFile.Moveis check-then-rename on Unix (measured: 4 lost writes in 3000), soAtomicFile.TryCreateAsyncopens withFileMode.CreateNewinstead, and readers wait briefly on an empty file. Off Windows, .NET's emulatedFileShare.Nonerefusal carries a rawEWOULDBLOCK(11 / 35) as its HResult, which now counts as a sharing violation. InMemory gets an atomicGetOrAdd-based version.ClearAsyncremoves<guid>.tmpstaging files older than a minute (younger ones belong to writes in flight).TempPersistenceProvidergains a publicBaseDirectory, like FileSystem's.Cache
Stopwatchtimestamps, notDateTime.UtcNow. An internal clock constructor lets tests move time instead of sleeping.GetOrAddis declared on the provider and adds only if nothing live is cached; racing callers get the cached instance.Smaller fixes
UserDirectoriesusesPath.IsPathFullyQualified, so\cfgandC:cfgfall back to the defaults.max - minoverflows, and its CDF is a product of ratios so wide-but-finite ranges no longer overflow the squares.COM0,LPT0and the superscriptCOM¹²³/LPT¹²³are escaped.AddEssentials()go throughAddDefaultOrKeep, so registering one first (including JSON with options) keeps it the default.long;GetMaxEncodedLengththrows for inputs that can't fit andTryEncodereturns false instead of throwing after a partial write.TrySerialize/Deserializereport System.Text.Json'sInvalidOperationExceptionas failure. It lands with RetrieveOrCreateAsync overwrites a stored file it cannot parse with new T(), destroying the user's data #82, as the issue asked.Streams, compression and encryption
BrotliDecoder, which reports an unfinished stream. Raw Deflate has no trailer, and its docs now say so.Try*in compression, AES, encoding and obfuscation returns false for an unreadable source or unwritable destination instead of throwingNotSupportedException.EncryptCbc.Other fixes
Chooseand the categorical distribution handle weights whose total overflows, and a trailing zero weight is never picked through rounding.Testing
Full
Essentials.Testson net10.0: 1336 passed, 1 skipped (a Windows-only test). The persistence suite passed 20 runs in a row after the create race fix. The whole solution builds with the analyzers on every target framework. The pre-net8 Base64 path compiles but only net10 runs in tests.https://claude.ai/code/session_01TmbAehyjJjPCQdRASdECes
Generated by Claude Code