Skip to content

Fix persistence, cache, encoding, DI and distribution bugs [patch] - #164

Merged
matt-edmondson merged 23 commits into
mainfrom
claude/project-thread-erlpos
Oct 27, 2026
Merged

matt-edmondson merged 23 commits into
mainfrom
claude/project-thread-erlpos

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

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 (replacing PersistenceDefaults.cs). ConfigHome and DataHome now forward RetrieveOrCreateAsync to it instead of using the interface's check-then-store default.

Cache

Smaller fixes

Streams, compression and encryption

Other fixes

Testing

Full Essentials.Tests on 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

claude added 10 commits October 26, 2026 10:39
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
@matt-edmondson matt-edmondson self-assigned this Oct 26, 2026
Comment thread Essentials.CacheProviders.InMemory/InMemoryCacheProvider.cs Fixed
Comment thread Shared/FileRecordStore.cs
Comment on lines +203 to +206
catch (Exception ex)
{
throw new PersistenceProviderException($"Failed to retrieve all keys from {location}", ex);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread Essentials.CacheProviders.InMemory/InMemoryCacheProvider.cs Fixed
Comment thread Shared/FileRecordStore.cs Fixed
claude added 10 commits October 26, 2026 10:54
…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();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +112 to +118
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;
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Test on windows-latest failed in Concurrent_Stores_To_One_Key_Never_Throw_Or_Hide_The_Key. A read that landed while an overwriting move was swapping the record got UnauthorizedAccessException: the file being replaced was still pending deletion, so Windows returned access denied instead of a sharing violation. This PR's retry only covered the sharing violation.

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
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit d48ea27 into main Oct 27, 2026
15 checks passed
@matt-edmondson
matt-edmondson deleted the claude/project-thread-erlpos branch October 27, 2026 13:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants