using System.Text; using System.Text.Json; using ClawdDotNet.Core.Storage; using Shouldly; namespace ClawdDotNet.Core.Tests.Storage; /// /// Alle Konfigurations- und Zustandsdateien wurden mit File.WriteAllText geschrieben. /// Das kürzt die Zieldatei zuerst auf null und füllt sie dann — bricht der Vorgang /// dazwischen ab, ist der alte Inhalt weg und der neue unvollständig. /// /// Das ist im Betrieb bereits eingetreten: In einer Instanz lag eine /// 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 { /* Aufräumen ist Nebensache */ } } private string Path_(string name) => Path.Combine(_directory, name); // ═══════════════════════════════════════════════════════════ // Grundverhalten // ═══════════════════════════════════════════════════════════ [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(); } // ═══════════════════════════════════════════════════════════ // Die eigentliche Zusage: nie ein halber Inhalt // ═══════════════════════════════════════════════════════════ /// /// Während geschrieben wird, wird parallel gelesen. Beim alten Verfahren erwischt /// man dabei zwangsläufig abgeschnittene Zwischenstände — hier darf jeder Lesevorgang /// nur ein vollständiges Dokument sehen. /// [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); // Harte Zeitgrenze, damit ein Fehler im Schreiber den Test nicht hängen lässt. using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); var schreiber = Task.Run(async () => { try { for (var i = 0; i < 40; i++) { AtomicFile.WriteAllText(path, i % 2 == 0 ? gross : klein); await Task.Delay(1); } } finally { await cts.CancelAsync(); } }); var fehler = 0; var gelesen = 0; var leser = Task.Run(async () => { while (!cts.IsCancellationRequested) { try { // AtomicFile.ReadAllText, damit der Leser den Schreiber nicht // blockiert — genau dafür gibt es den Helfer. var inhalt = AtomicFile.ReadAllText(path); gelesen++; // Muss immer ein vollständiges JSON-Array sein. JsonSerializer.Deserialize(inhalt); } catch (JsonException) { Interlocked.Increment(ref fehler); } catch (IOException) { // Kurzzeitig nicht zu öffnen ist kein Datenfehler. } 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"); } /// /// Der nicht offensichtliche Teil: Ein Leser, der die Datei geöffnet hält, würde /// das Ersetzen blockieren — es sei denn, er gibt das Löschen frei. Genau das tut /// . /// /// Gemessen auf Windows: File.Move scheitert in jedem Fall an einem offenen Leser, /// File.Replace kommt mit einem freigebenden Leser zurecht. Deshalb 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 }))); // Welcher Lauf gewinnt, ist offen — die Datei muss aber lesbar sein. Should.NotThrow(() => JsonDocument.Parse(File.ReadAllText(path))); Directory.GetFiles(_directory).ShouldHaveSingleItem(); } }