Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Storage/AtomicFileTests.cs
T
RichardandClaude Opus 5.5 77062facc9 Stufe 4c (#764): Bezeichner auf Englisch - Typen, Methoden, Felder, Parameter, Locals, Testnamen
Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt).
Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e):
Properties, Record-Parameter, Enum-Member sowie die Parameter der
Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder
anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:18:43 +02:00

213 lines
7.3 KiB
C#

using System.Text;
using System.Text.Json;
using ClawdDotNet.Core.Storage;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Storage;
/// <summary>
/// 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_…
/// </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 { /* 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
// ═══════════════════════════════════════════════════════════
/// <summary>
/// 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.
/// </summary>
[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<string[]>(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");
}
/// <summary>
/// The non-obvious part: a reader holding the file open would
/// block the replacement — unless it shares delete. That is exactly what
/// <see cref="AtomicFile.ReadAllText"/> does.
///
/// Measured on Windows: File.Move fails in every case with an open reader,
/// File.Replace copes with a sharing reader. Hence Replace.
/// </summary>
[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();
}
}