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 Inhalt_wird_geschrieben() { var path = Path_("datei.json"); AtomicFile.WriteAllText(path, """{"a":1}"""); File.ReadAllText(path).ShouldBe("""{"a":1}"""); } [Fact] public void Eine_bestehende_Datei_wird_ersetzt() { 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 Fehlende_Verzeichnisse_werden_angelegt() { var path = Path_(Path.Combine("a", "b", "c", "datei.json")); AtomicFile.WriteAllText(path, "inhalt"); File.Exists(path).ShouldBeTrue(); } [Fact] public void Es_bleiben_keine_Nebendateien_zurueck() { var path = Path_("datei.json"); AtomicFile.WriteAllText(path, "eins"); AtomicFile.WriteAllText(path, "zwei"); Directory.GetFiles(_directory).ShouldHaveSingleItem(); } [Fact] public void Umlaute_und_Emoji_werden_als_UTF8_ohne_BOM_geschrieben() { 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 Die_asynchrone_Fassung_verhaelt_sich_gleich() { 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 Waehrend_des_Schreibens_wird_nie_ein_halber_Inhalt_sichtbar() { var path = Path_("gross.json"); var klein = JsonSerializer.Serialize(Enumerable.Range(0, 50).Select(i => $"eintrag-{i}")); var gross = JsonSerializer.Serialize(Enumerable.Range(0, 20_000).Select(i => $"eintrag-{i}")); AtomicFile.WriteAllText(path, klein); var fehler = 0; var gelesen = 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 schreiber = 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 gelesen) < 20) && !cts.IsCancellationRequested; i++) { AtomicFile.WriteAllText(path, i % 2 == 0 ? gross : klein); await Task.Delay(1); } } finally { await cts.CancelAsync(); } }); var leser = 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 inhalt = AtomicFile.ReadAllText(path); Interlocked.Increment(ref gelesen); // Must always be a complete JSON array. JsonSerializer.Deserialize(inhalt); } catch (JsonException) { Interlocked.Increment(ref fehler); } catch (IOException) { // Briefly not being able to open it is not a data error. } await Task.Delay(5); } }); await Task.WhenAll(schreiber, leser); gelesen.ShouldBeGreaterThan(5, "der Test muss tatsächlich gelesen haben"); fehler.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 Ein_offener_Leser_blockiert_das_Schreiben_nicht() { 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 Parallele_Schreibvorgaenge_hinterlassen_eine_gueltige_Datei() { 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(); } }