Files
RichardandClaude Opus 4.8 1157d28588 Konfig- und Zustandsdateien atomar schreiben
File.WriteAllText kuerzt die Zieldatei zuerst auf null und fuellt sie dann. Bricht
der Vorgang dazwischen ab, ist der alte Inhalt weg und der neue unvollstaendig.

Das ist im Betrieb bereits eingetreten: In der Instanz TradingTeam lag eine
TokenUsage.json.corrupt_..., die die Fehlerbehandlung beiseitegelegt hatte. Der
Verbrauch bis dahin war verloren.

AtomicFile schreibt in eine Nebendatei, erzwingt das Schreiben auf die Platte und
ersetzt dann. Umgestellt sind ChatHistory, ChatContext, alle Instanz- und
Agentenkonfigurationen, Identity und Soul, die App-Einstellungen sowie der
Stock-Index.

Zum Ersetzen wurde das Windows-Verhalten gemessen statt vermutet. Mit einem Leser,
der die Zieldatei geoeffnet haelt:

  Freigabe des Lesers      File.Move   File.Replace
  Read                     scheitert   scheitert
  ReadWrite                scheitert   scheitert
  ReadWrite | Delete       scheitert   funktioniert

File.Move verlangt die Zieldatei exklusiv und scheitert deshalb immer, sobald
jemand sie geoeffnet hat. Daher File.Replace — und ein Lesehelfer
AtomicFile.ReadAllText, der das Loeschen freigibt, damit unsere eigenen Leser
keinen Schreiber blockieren. Die Leser in InstanceDirectoryManager und beim Laden
der Chatverlaeufe nutzen ihn jetzt.

Zusaetzlich ein Schloss je Zieldatei: Zwei gleichzeitige Schreibvorgaenge auf
dieselbe Datei sind ohnehin ein Rennen, ohne Serialisierung scheitern sie aber
zusaetzlich mit "Zugriff verweigert". Fuer fremde Leser wie Virenscanner bleibt
eine Wiederholung mit Wartezeit.

366 Tests gruen (218 Core, 148 Tools).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:47:38 +02:00

211 lines
7.2 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);
// 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<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();
}
}