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>
213 lines
7.3 KiB
C#
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();
|
|
}
|
|
}
|