using System.Text; using System.Text.Json; using ClawdDotNet.Core.Storage; using Shouldly; namespace ClawdDotNet.Core.Tests.Storage; /// /// All configuration and state files were written with File.WriteAllText. /// That first truncates the target file to zero and then fills it — if the operation /// aborts in between, the old content is gone and the new one incomplete. /// /// That has already happened in operation: an instance contained a /// TokenUsage.json.corrupt_… /// public sealed class AtomicFileTests : IDisposable { private readonly string _directory; public AtomicFileTests() { _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_directory); } public void Dispose() { try { Directory.Delete(_directory, recursive: true); } catch { /* Cleaning up is secondary */ } } private string Path_(string name) => Path.Combine(_directory, name); // ═══════════════════════════════════════════════════════════ // Basic behavior // ═══════════════════════════════════════════════════════════ [Fact] public void Content_is_written() { var path = Path_("datei.json"); AtomicFile.WriteAllText(path, """{"a":1}"""); File.ReadAllText(path).ShouldBe("""{"a":1}"""); } [Fact] public void An_existing_file_is_replaced() { var path = Path_("datei.json"); File.WriteAllText(path, "alter Inhalt der deutlich laenger ist"); AtomicFile.WriteAllText(path, "neu"); File.ReadAllText(path).ShouldBe("neu"); } [Fact] public void Missing_directories_are_created() { var path = Path_(Path.Combine("a", "b", "c", "datei.json")); AtomicFile.WriteAllText(path, "inhalt"); File.Exists(path).ShouldBeTrue(); } [Fact] public void No_side_files_are_left_behind() { var path = Path_("datei.json"); AtomicFile.WriteAllText(path, "eins"); AtomicFile.WriteAllText(path, "zwei"); Directory.GetFiles(_directory).ShouldHaveSingleItem(); } [Fact] public void Umlauts_and_emoji_are_written_as_UTF8_without_BOM() { var path = Path_("datei.txt"); const string content = "Grüße aus München 🦀"; AtomicFile.WriteAllText(path, content); var bytes = File.ReadAllBytes(path); bytes[0].ShouldNotBe((byte)0xEF, "kein BOM am Dateianfang"); new UTF8Encoding(false).GetString(bytes).ShouldBe(content); } [Fact] public async Task The_async_version_behaves_the_same() { var path = Path_("datei.json"); await AtomicFile.WriteAllTextAsync(path, "inhalt"); File.ReadAllText(path).ShouldBe("inhalt"); Directory.GetFiles(_directory).ShouldHaveSingleItem(); } // ═══════════════════════════════════════════════════════════ // The actual promise: never half a content // ═══════════════════════════════════════════════════════════ /// /// While writing, reading happens in parallel. With the old procedure you inevitably /// catch truncated intermediate states — here every read may /// only see a complete document. /// [Fact] public async Task While_writing_half_content_is_never_visible() { var path = Path_("gross.json"); var small = JsonSerializer.Serialize(Enumerable.Range(0, 50).Select(i => $"eintrag-{i}")); var large = JsonSerializer.Serialize(Enumerable.Range(0, 20_000).Select(i => $"eintrag-{i}")); AtomicFile.WriteAllText(path, small); var error = 0; var readBack = 0; // Hard time limit, so an error in the writer does not leave the test hanging. using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); var writer = Task.Run(async () => { try { // #722: Writing continues until the reader has seen enough — not a fixed // number of times. Under parallel load the reader otherwise only got five looks. for (var i = 0; (i < 40 || Volatile.Read(ref readBack) < 20) && !cts.IsCancellationRequested; i++) { AtomicFile.WriteAllText(path, i % 2 == 0 ? large : small); await Task.Delay(1); } } finally { await cts.CancelAsync(); } }); var reader = Task.Run(async () => { while (!cts.IsCancellationRequested) { try { // AtomicFile.ReadAllText, so the reader does not block the // writer — that is exactly what the helper exists for. var content = AtomicFile.ReadAllText(path); Interlocked.Increment(ref readBack); // Must always be a complete JSON array. JsonSerializer.Deserialize(content); } catch (JsonException) { Interlocked.Increment(ref error); } catch (IOException) { // Briefly not being able to open it is not a data error. } await Task.Delay(5); } }); await Task.WhenAll(writer, reader); readBack.ShouldBeGreaterThan(5, "der Test muss tatsächlich gelesen haben"); error.ShouldBe(0, "es darf nie ein unvollständiges Dokument sichtbar sein"); } /// /// The non-obvious part: a reader holding the file open would /// block the replacement — unless it shares delete. That is exactly what /// does. /// /// Measured on Windows: File.Move fails in every case with an open reader, /// File.Replace copes with a sharing reader. Hence Replace. /// [Fact] public void An_open_reader_does_not_block_writing() { var path = Path_("gehalten.json"); AtomicFile.WriteAllText(path, """{"stand":"alt"}"""); using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); Should.NotThrow(() => AtomicFile.WriteAllText(path, """{"stand":"neu"}""")); AtomicFile.ReadAllText(path).ShouldBe("""{"stand":"neu"}"""); } [Fact] public void Parallel_writes_leave_a_valid_file() { var path = Path_("parallel.json"); Parallel.For(0, 40, i => AtomicFile.WriteAllText(path, JsonSerializer.Serialize(new { lauf = i }))); // Which run wins is open — but the file must be readable. Should.NotThrow(() => JsonDocument.Parse(File.ReadAllText(path))); Directory.GetFiles(_directory).ShouldHaveSingleItem(); } }