Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Storage/AtomicFileTests.cs
T
RichardandClaude Opus 5.5 753d633cf3 Linux-Wackeltests: Instanz endet erst auf die Stopp-Bitte, Lesetest schreibt bis genug gelesen ist (#722)
Drei Manager-Tests liessen den Prozess vor der Stopp-Bitte enden; unter Last
sah der Aufsichtstakt das zuerst und meldete Crashed statt Stopped. Die
FakeChannel bekommt OnStopRequest, die Tests beenden darin. Der Drain-Test
bekommt 20 s statt 2 s Luft. AtomicFile schreibt, bis der Leser 20 Blicke
hatte, statt fest 40 Mal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:50:51 +02:00

213 lines
7.5 KiB
C#

using System.Text;
using System.Text.Json;
using ClawdDotNet.Core.Storage;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Storage;
/// <summary>
/// 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_…
/// </summary>
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
// ═══════════════════════════════════════════════════════════
/// <summary>
/// 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.
/// </summary>
[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;
// 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
{
// #722: Geschrieben wird, bis der Leser genug gesehen hat — nicht eine feste
// Zahl von Malen. Unter Parallellast kam der Leser sonst nur auf fuenf Blicke.
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, damit der Leser den Schreiber nicht
// blockiert — genau dafür gibt es den Helfer.
var inhalt = AtomicFile.ReadAllText(path);
Interlocked.Increment(ref gelesen);
// Muss immer ein vollständiges JSON-Array sein.
JsonSerializer.Deserialize<string[]>(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");
}
/// <summary>
/// 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
/// <see cref="AtomicFile.ReadAllText"/>.
///
/// Gemessen auf Windows: File.Move scheitert in jedem Fall an einem offenen Leser,
/// File.Replace kommt mit einem freigebenden Leser zurecht. Deshalb Replace.
/// </summary>
[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();
}
}