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>
This commit is contained in:
Richard
2026-10-04 13:18:43 +02:00
co-authored by Claude Opus 5.5
parent 837a634a22
commit 77062facc9
190 changed files with 4813 additions and 4813 deletions
+110 -110
View File
@@ -23,9 +23,9 @@ public sealed class AutostartTests : IAsyncDisposable
Path.GetTempPath(), "clawd-tests", "auto-" + Guid.NewGuid().ToString("N")[..8]);
private readonly FakeLauncher _launcher = new();
private readonly FakeChannel _kanal = new();
private readonly FakeChannel _channel = new();
private readonly FakeTokenSource _tokens = new();
private readonly InstanceDirectoryManager _verzeichnisse;
private readonly InstanceDirectoryManager _directories;
private static readonly SupervisionOptions Options = new(
ProbeInterval: TimeSpan.FromMilliseconds(50),
@@ -38,12 +38,12 @@ public sealed class AutostartTests : IAsyncDisposable
public AutostartTests()
{
Directory.CreateDirectory(_root);
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
_directories = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
// The socket only answers if a process is really running - otherwise an
// answer stored in advance would mean "already running", and the manager would take over
// instead of starting.
_kanal.Launcher = _launcher;
_channel.Launcher = _launcher;
}
public async ValueTask DisposeAsync()
@@ -56,7 +56,7 @@ public sealed class AutostartTests : IAsyncDisposable
private ManagerCore Manager(RestartPolicy? policy = null)
{
var register = new InstanceRegistry(
_verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options);
_directories, _launcher, _channel, NullLoggerFactory.Instance, Options);
return new ManagerCore(
new ManagerSettingsStore(_root), register, _tokens, NullLoggerFactory.Instance,
@@ -64,30 +64,30 @@ public sealed class AutostartTests : IAsyncDisposable
}
/// <summary>Creates an instance and sets the switch in its instance file.</summary>
private string Instanz(string name, bool autostart)
private string Instance(string name, bool autostart)
{
var pfad = _verzeichnisse.CreateInstance(name);
var datei = Path.Combine(pfad, "InstanceSettings.json");
var path = _directories.CreateInstance(name);
var datei = Path.Combine(path, "InstanceSettings.json");
var config = JsonSerializer.Deserialize<InstanceConfig>(File.ReadAllText(datei))!;
config.AutostartByManager = autostart;
File.WriteAllText(datei, JsonSerializer.Serialize(config));
_kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-" + name, name, "0.1.2");
_channel.AnswerFor[Path.GetFullPath(path)] = new InstanceIdentity("i-" + name, name, "0.1.2");
return pfad;
return path;
}
private static SupervisionEvent Absturz(string pfad, string name) => new(
InstanceEventKind.Crashed, Path.GetFullPath(pfad), name, 4711, 137,
private static SupervisionEvent Crash(string path, string name) => new(
InstanceEventKind.Crashed, Path.GetFullPath(path), name, 4711, 137,
"unerwartet beendet", DateTimeOffset.UtcNow);
// ─── When coming up ───
[Fact]
public async Task Eine_Instanz_mit_Autostart_wird_beim_Hochfahren_gestartet()
public async Task An_instance_with_autostart_is_started_on_startup()
{
Instanz("Alpha", autostart: true);
Instance("Alpha", autostart: true);
await using var manager = Manager();
await manager.StartAsync();
@@ -97,9 +97,9 @@ public sealed class AutostartTests : IAsyncDisposable
}
[Fact]
public async Task Ohne_Autostart_bleibt_sie_aus()
public async Task Without_autostart_it_stays_off()
{
Instanz("Alpha", autostart: false);
Instance("Alpha", autostart: false);
await using var manager = Manager();
await manager.StartAsync();
@@ -113,27 +113,27 @@ public sealed class AutostartTests : IAsyncDisposable
/// <c>state.db</c> would be the opposite of helpful.
/// </summary>
[Fact]
public async Task Was_schon_laeuft_wird_nicht_noch_einmal_gestartet()
public async Task What_already_runs_is_not_started_again()
{
var pfad = Instanz("Alpha", autostart: true);
var path = Instance("Alpha", autostart: true);
await using (var erster = Manager())
await erster.StartAsync();
await using (var firstOne = Manager())
await firstOne.StartAsync();
_launcher.Starts.ShouldBe(1);
await using var zweiter = Manager();
await zweiter.StartAsync();
await using var secondOne = Manager();
await secondOne.StartAsync();
_launcher.Starts.ShouldBe(1, "Uebernommen, nicht neu gestartet.");
zweiter.Instances.Instances.Single().ProcessId.ShouldBe(_launcher.Last!.Id);
secondOne.Instances.Instances.Single().ProcessId.ShouldBe(_launcher.Last!.Id);
}
[Fact]
public async Task Der_Schalter_steht_in_der_Liste()
public async Task The_switch_is_in_the_list()
{
Instanz("Mit", autostart: true);
Instanz("Ohne", autostart: false);
Instance("Mit", autostart: true);
Instance("Ohne", autostart: false);
await using var manager = Manager();
await manager.StartAsync();
@@ -145,33 +145,33 @@ public sealed class AutostartTests : IAsyncDisposable
// ─── After a crash ───
[Fact]
public async Task Nach_einem_Absturz_wird_wieder_gestartet()
public async Task After_a_crash_it_is_restarted()
{
var pfad = Instanz("Alpha", autostart: true);
var path = Instance("Alpha", autostart: true);
await using var manager = Manager();
await manager.StartAsync();
var vorher = _launcher.Starts;
var before = _launcher.Starts;
// The process is gone; supervision has reported it.
_launcher.Last!.Exit(137);
await manager.RestartAfterCrashAsync(Absturz(pfad, "Alpha"));
await manager.RestartAfterCrashAsync(Crash(path, "Alpha"));
_launcher.Starts.ShouldBe(vorher + 1);
_launcher.Starts.ShouldBe(before + 1);
}
[Fact]
public async Task Ohne_Autostart_wird_nach_einem_Absturz_nichts_getan()
public async Task Without_autostart_nothing_is_done_after_a_crash()
{
var pfad = Instanz("Alpha", autostart: false);
var path = Instance("Alpha", autostart: false);
await using var manager = Manager();
await manager.StartAsync();
await manager.StartInstanceAsync(pfad);
await manager.StartInstanceAsync(path);
_launcher.Last!.Exit(137);
await manager.RestartAfterCrashAsync(Absturz(pfad, "Alpha"));
await manager.RestartAfterCrashAsync(Crash(path, "Alpha"));
_launcher.Starts.ShouldBe(1, "Nur der Start von Hand, kein Neustart.");
}
@@ -187,38 +187,38 @@ public sealed class AutostartTests : IAsyncDisposable
/// the start nobody sits on the database any more.</para>
/// </summary>
[Fact]
public async Task Eine_verstummte_Instanz_wird_erst_beendet_und_dann_neu_gestartet()
public async Task A_silent_instance_is_first_stopped_and_then_restarted()
{
var pfad = Instanz("Alpha", autostart: true);
var path = Instance("Alpha", autostart: true);
await using var manager = Manager();
await manager.StartAsync();
var alt = _launcher.Last.ShouldNotBeNull();
var old = _launcher.Last.ShouldNotBeNull();
_launcher.Starts.ShouldBe(1);
// From now on it hangs: the process lives, the socket no longer answers.
_kanal.Verstummen(pfad);
_channel.GoSilent(path);
var aufsicht = manager.Instances.SupervisorFor(pfad).ShouldNotBeNull();
var supervisor = manager.Instances.SupervisorFor(path).ShouldNotBeNull();
// Three passes until the alarm - nothing is done before that.
for (var i = 0; i < 3; i++) await aufsicht.CheckAsync();
for (var i = 0; i < 3; i++) await supervisor.CheckAsync();
aufsicht.Condition.ShouldBe(InstanceCondition.Silent);
alt.WasKilled.ShouldBeFalse("Vor Ablauf der Frist wird nicht abgeschossen.");
supervisor.Condition.ShouldBe(InstanceCondition.Silent);
old.WasKilled.ShouldBeFalse("Vor Ablauf der Frist wird nicht abgeschossen.");
// The deadline expires (SilentKillGrace: 300ms).
await Task.Delay(400);
await aufsicht.CheckAsync();
await supervisor.CheckAsync();
alt.WasKilled.ShouldBeTrue("Nach der Frist wird der haengende Prozess beendet.");
old.WasKilled.ShouldBeTrue("Nach der Frist wird der haengende Prozess beendet.");
// The crash path restarts - and the new process is a different one.
await Warte(() => _launcher.Starts >= 2);
await WaitFor(() => _launcher.Starts >= 2);
_launcher.Last.ShouldNotBeNull().Id.ShouldNotBe(alt.Id);
alt.HasExited.ShouldBeTrue("Der alte Prozess darf beim Start nicht mehr leben.");
_launcher.Last.ShouldNotBeNull().Id.ShouldNotBe(old.Id);
old.HasExited.ShouldBeTrue("Der alte Prozess darf beim Start nicht mehr leben.");
}
/// <summary>
@@ -229,31 +229,31 @@ public sealed class AutostartTests : IAsyncDisposable
/// "someone answered".</para>
/// </summary>
[Fact]
public async Task Wer_aufraeumt_gilt_nicht_als_verstummt()
public async Task Whoever_cleans_up_does_not_count_as_silent()
{
var pfad = Instanz("Alpha", autostart: true);
var path = Instance("Alpha", autostart: true);
await using var manager = Manager();
await manager.StartAsync();
var prozess = _launcher.Last.ShouldNotBeNull();
var process = _launcher.Last.ShouldNotBeNull();
_kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity(
_channel.AnswerFor[Path.GetFullPath(path)] = new InstanceIdentity(
"i-Alpha", "Alpha", "0.1.2",
ShutdownPhase.Draining, OpenWork: 2, OpenSummary: "Agentenläufe: 2");
var aufsicht = manager.Instances.SupervisorFor(pfad).ShouldNotBeNull();
var supervisor = manager.Instances.SupervisorFor(path).ShouldNotBeNull();
for (var i = 0; i < 5; i++) await aufsicht.CheckAsync();
for (var i = 0; i < 5; i++) await supervisor.CheckAsync();
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
prozess.WasKilled.ShouldBeFalse();
supervisor.Condition.ShouldBe(InstanceCondition.Running);
process.WasKilled.ShouldBeFalse();
_launcher.Starts.ShouldBe(1);
}
private static async Task Warte(Func<bool> bedingung)
private static async Task WaitFor(Func<bool> condition)
{
for (var i = 0; i < 100 && !bedingung(); i++)
for (var i = 0; i < 100 && !condition(); i++)
await Task.Delay(20);
}
@@ -265,22 +265,22 @@ public sealed class AutostartTests : IAsyncDisposable
/// log and a bug tracker full of repetitions.</para>
/// </summary>
[Fact]
public async Task Nach_drei_Neustarts_im_Zeitfenster_setzt_der_Manager_aus()
public async Task After_three_restarts_in_the_time_window_the_manager_pauses()
{
var pfad = Instanz("Wackelig", autostart: true);
var path = Instance("Wackelig", autostart: true);
await using var manager = Manager(new RestartPolicy(MaxRestarts: 3, Window: TimeSpan.FromMinutes(10)));
await manager.StartAsync();
var nachStart = _launcher.Starts;
var afterStart = _launcher.Starts;
for (var i = 0; i < 6; i++)
{
_launcher.Last!.Exit(137);
await manager.RestartAfterCrashAsync(Absturz(pfad, "Wackelig"));
await manager.RestartAfterCrashAsync(Crash(path, "Wackelig"));
}
(_launcher.Starts - nachStart).ShouldBe(3, "Drei Versuche, dann Schluss.");
(_launcher.Starts - afterStart).ShouldBe(3, "Drei Versuche, dann Schluss.");
}
/// <summary>
@@ -288,9 +288,9 @@ public sealed class AutostartTests : IAsyncDisposable
/// the earlier attempts would mean punishing them for the crash.
/// </summary>
[Fact]
public async Task Ein_Start_von_Hand_raeumt_den_Zaehler_ab()
public async Task A_manual_start_clears_the_counter()
{
var pfad = Instanz("Wackelig", autostart: true);
var path = Instance("Wackelig", autostart: true);
await using var manager = Manager(new RestartPolicy(3, TimeSpan.FromMinutes(10)));
await manager.StartAsync();
@@ -298,53 +298,53 @@ public sealed class AutostartTests : IAsyncDisposable
for (var i = 0; i < 5; i++)
{
_launcher.Last!.Exit(137);
await manager.RestartAfterCrashAsync(Absturz(pfad, "Wackelig"));
await manager.RestartAfterCrashAsync(Crash(path, "Wackelig"));
}
var erschoepft = _launcher.Starts;
var exhausted = _launcher.Starts;
// A new manager starts at zero - just like a start by hand.
await using var frisch = Manager(new RestartPolicy(3, TimeSpan.FromMinutes(10)));
await frisch.StartAsync();
await using var fresh = Manager(new RestartPolicy(3, TimeSpan.FromMinutes(10)));
await fresh.StartAsync();
_launcher.Last!.Exit(137);
await frisch.RestartAfterCrashAsync(Absturz(pfad, "Wackelig"));
await fresh.RestartAfterCrashAsync(Crash(path, "Wackelig"));
_launcher.Starts.ShouldBeGreaterThan(erschoepft);
_launcher.Starts.ShouldBeGreaterThan(exhausted);
}
}
/// <summary>The circuit breaker on its own.</summary>
public sealed class RestartLedgerTests
{
private sealed class Uhr(DateTimeOffset jetzt) : TimeProvider
private sealed class Clock(DateTimeOffset jetzt) : TimeProvider
{
public DateTimeOffset Jetzt { get; set; } = jetzt;
public override DateTimeOffset GetUtcNow() => Jetzt;
}
[Fact]
public void Bis_zur_Grenze_ja_danach_nein()
public void Yes_up_to_the_limit_no_after_it()
{
var buch = new RestartLedger(new RestartPolicy(3, TimeSpan.FromMinutes(10)));
var book = new RestartLedger(new RestartPolicy(3, TimeSpan.FromMinutes(10)));
buch.TryRecord("/a", out _).ShouldBeTrue();
buch.TryRecord("/a", out _).ShouldBeTrue();
buch.TryRecord("/a", out var dritter).ShouldBeTrue();
dritter.ShouldBe(3);
book.TryRecord("/a", out _).ShouldBeTrue();
book.TryRecord("/a", out _).ShouldBeTrue();
book.TryRecord("/a", out var third).ShouldBeTrue();
third.ShouldBe(3);
buch.TryRecord("/a", out _).ShouldBeFalse();
book.TryRecord("/a", out _).ShouldBeFalse();
}
[Fact]
public void Jede_Instanz_hat_ihr_eigenes_Konto()
public void Every_instance_has_its_own_account()
{
var buch = new RestartLedger(new RestartPolicy(1, TimeSpan.FromMinutes(10)));
var book = new RestartLedger(new RestartPolicy(1, TimeSpan.FromMinutes(10)));
buch.TryRecord("/a", out _).ShouldBeTrue();
buch.TryRecord("/a", out _).ShouldBeFalse();
book.TryRecord("/a", out _).ShouldBeTrue();
book.TryRecord("/a", out _).ShouldBeFalse();
buch.TryRecord("/b", out _).ShouldBeTrue("Die eine Instanz sperrt die andere nicht.");
book.TryRecord("/b", out _).ShouldBeTrue("Die eine Instanz sperrt die andere nicht.");
}
/// <summary>
@@ -352,32 +352,32 @@ public sealed class RestartLedgerTests
/// up never runs into the limit — one that falls every minute does immediately.
/// </summary>
[Fact]
public void Alte_Versuche_fallen_aus_dem_Fenster()
public void Old_attempts_fall_out_of_the_window()
{
var uhr = new Uhr(DateTimeOffset.UnixEpoch);
var buch = new RestartLedger(new RestartPolicy(2, TimeSpan.FromMinutes(10)), uhr);
var clock = new Clock(DateTimeOffset.UnixEpoch);
var book = new RestartLedger(new RestartPolicy(2, TimeSpan.FromMinutes(10)), clock);
buch.TryRecord("/a", out _).ShouldBeTrue();
buch.TryRecord("/a", out _).ShouldBeTrue();
buch.TryRecord("/a", out _).ShouldBeFalse();
book.TryRecord("/a", out _).ShouldBeTrue();
book.TryRecord("/a", out _).ShouldBeTrue();
book.TryRecord("/a", out _).ShouldBeFalse();
uhr.Jetzt = uhr.Jetzt.AddMinutes(11);
clock.Jetzt = clock.Jetzt.AddMinutes(11);
buch.TryRecord("/a", out var wieder).ShouldBeTrue();
wieder.ShouldBe(1, "Die alten Versuche zaehlen nicht mehr mit.");
book.TryRecord("/a", out var repeated).ShouldBeTrue();
repeated.ShouldBe(1, "Die alten Versuche zaehlen nicht mehr mit.");
}
[Fact]
public void Vergessen_setzt_zurueck()
public void Forgetting_resets()
{
var buch = new RestartLedger(new RestartPolicy(1, TimeSpan.FromMinutes(10)));
var book = new RestartLedger(new RestartPolicy(1, TimeSpan.FromMinutes(10)));
buch.TryRecord("/a", out _).ShouldBeTrue();
buch.TryRecord("/a", out _).ShouldBeFalse();
book.TryRecord("/a", out _).ShouldBeTrue();
book.TryRecord("/a", out _).ShouldBeFalse();
buch.Forget("/a");
book.Forget("/a");
buch.TryRecord("/a", out _).ShouldBeTrue();
book.TryRecord("/a", out _).ShouldBeTrue();
}
}
@@ -395,13 +395,13 @@ public sealed class StaleStateTests : IAsyncDisposable
Path.GetTempPath(), "clawd-tests", "stale-" + Guid.NewGuid().ToString("N")[..8]);
private readonly FakeLauncher _launcher = new();
private readonly FakeChannel _kanal = new();
private readonly FakeChannel _channel = new();
public StaleStateTests()
{
Directory.CreateDirectory(_root);
_kanal.Launcher = _launcher;
_kanal.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
_channel.Launcher = _launcher;
_channel.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
}
public async ValueTask DisposeAsync()
@@ -412,13 +412,13 @@ public sealed class StaleStateTests : IAsyncDisposable
}
[Fact]
public async Task Ein_Start_prueft_nach_statt_dem_eigenen_Zustand_zu_glauben()
public async Task A_start_verifies_instead_of_trusting_its_own_state()
{
var dir = Path.Combine(_root, "Instance-Alpha");
Directory.CreateDirectory(dir);
await using var aufsicht = new InstanceSupervisor(
dir, _launcher, _kanal, NullLogger.Instance,
await using var supervisor = new InstanceSupervisor(
dir, _launcher, _channel, NullLogger.Instance,
new SupervisionOptions(
TimeSpan.FromSeconds(30), // deliberately sluggish: no pass in between
TimeSpan.FromMilliseconds(600),
@@ -427,17 +427,17 @@ public sealed class StaleStateTests : IAsyncDisposable
TimeSpan.FromMilliseconds(300),
TimeSpan.FromSeconds(2)));
(await aufsicht.StartAsync()).ShouldBeTrue();
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
(await supervisor.StartAsync()).ShouldBeTrue();
supervisor.Condition.ShouldBe(InstanceCondition.Running);
// The instance breaks away. Supervision has not noticed yet - the next
// pass would only come in thirty seconds.
_launcher.Last!.Exit(137);
aufsicht.Condition.ShouldBe(InstanceCondition.Running, "Noch nichts bemerkt.");
supervisor.Condition.ShouldBe(InstanceCondition.Running, "Noch nichts bemerkt.");
(await aufsicht.StartAsync()).ShouldBeTrue();
(await supervisor.StartAsync()).ShouldBeTrue();
_launcher.Starts.ShouldBe(2, "Es wurde wirklich gestartet, nicht nur zugestimmt.");
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
supervisor.Condition.ShouldBe(InstanceCondition.Running);
}
}
@@ -25,7 +25,7 @@ public sealed class BackupManagementTests : IDisposable
}
[Fact]
public void BackupDirectory_wird_standardmaessig_unter_Backups_aufgeloest()
public void BackupDirectory_resolves_under_Backups_by_default()
{
var instancePath = Path.Combine(_root, "TestInstance");
Directory.CreateDirectory(instancePath);
@@ -37,7 +37,7 @@ public sealed class BackupManagementTests : IDisposable
}
[Fact]
public async Task Backup_kann_erstellt_geprueft_und_wiederhergestellt_werden()
public async Task Backup_can_be_created_verified_and_restored()
{
var instancePath = Path.Combine(_root, "InstanceAlpha");
Directory.CreateDirectory(instancePath);
+8 -8
View File
@@ -82,12 +82,12 @@ internal sealed class FakeLauncher : IInstanceLauncher
/// <para>Does not count as a start — exactly therein lies the difference several tests
/// check: what runs is taken over and not booted a second time.</para>
/// </summary>
public FakeProcess Fremdstart(string instanceDirectory, int processId)
public FakeProcess StartedElsewhere(string instanceDirectory, int processId)
{
var prozess = new FakeProcess(processId, Now);
Running[processId] = prozess;
ByDirectory[Path.GetFullPath(instanceDirectory)] = prozess;
return prozess;
var process = new FakeProcess(processId, Now);
Running[processId] = process;
ByDirectory[Path.GetFullPath(instanceDirectory)] = process;
return process;
}
public IInstanceProcess? Attach(int processId, DateTimeOffset startedAt)
@@ -145,7 +145,7 @@ internal sealed class FakeChannel : IInstanceChannel
public HashSet<string> Verstummt { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>The instance stops answering without its process ending.</summary>
public void Verstummen(string instanceDirectory)
public void GoSilent(string instanceDirectory)
=> Verstummt.Add(Path.GetFullPath(instanceDirectory));
public int Calls { get; private set; }
@@ -167,8 +167,8 @@ internal sealed class FakeChannel : IInstanceChannel
return Task.FromResult<InstanceIdentity?>(null);
return Task.FromResult(
AnswerFor.TryGetValue(Path.GetFullPath(instanceDirectory), out var eigene)
? eigene
AnswerFor.TryGetValue(Path.GetFullPath(instanceDirectory), out var ownOnes)
? ownOnes
: Answer);
}
@@ -18,10 +18,10 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
Path.GetTempPath(), "clawd-tests", "reg-" + Guid.NewGuid().ToString("N")[..8]);
private readonly FakeLauncher _launcher = new();
private readonly FakeChannel _kanal = new();
private readonly List<SupervisionEvent> _ereignisse = [];
private readonly FakeChannel _channel = new();
private readonly List<SupervisionEvent> _events = [];
private readonly InstanceDirectoryManager _verzeichnisse;
private readonly InstanceDirectoryManager _directories;
private static readonly SupervisionOptions Options = new(
ProbeInterval: TimeSpan.FromMilliseconds(50),
@@ -33,18 +33,18 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
public InstanceRegistryTests()
{
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
_directories = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
// The socket only answers if a process is really running - exactly as in
// operation (see FakeChannel.Launcher).
_kanal.Launcher = _launcher;
_channel.Launcher = _launcher;
}
private InstanceRegistry Register()
{
var register = new InstanceRegistry(
_verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options);
_directories, _launcher, _channel, NullLoggerFactory.Instance, Options);
register.Observed += _ereignisse.Add;
register.Observed += _events.Add;
return register;
}
@@ -58,7 +58,7 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
// ─── What is there ───
[Fact]
public async Task Ein_leeres_Instanzenverzeichnis_ergibt_ein_leeres_Register()
public async Task An_empty_instances_directory_gives_an_empty_registry()
{
await using var register = Register();
await register.RefreshAsync();
@@ -67,10 +67,10 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
}
[Fact]
public async Task Angelegte_Instanzen_stehen_im_Register()
public async Task Created_instances_are_in_the_registry()
{
_verzeichnisse.CreateInstance("Alpha");
_verzeichnisse.CreateInstance("Beta");
_directories.CreateInstance("Alpha");
_directories.CreateInstance("Beta");
await using var register = Register();
await register.RefreshAsync();
@@ -84,27 +84,27 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
/// restart of the machine, also start what someone stopped on purpose.
/// </summary>
[Fact]
public async Task Beim_Nachsehen_wird_nichts_von_selbst_gestartet()
public async Task Checking_starts_nothing_by_itself()
{
_verzeichnisse.CreateInstance("Alpha");
_directories.CreateInstance("Alpha");
await using var register = Register();
await register.RefreshAsync();
_launcher.Starts.ShouldBe(0);
_ereignisse.ShouldBeEmpty();
_events.ShouldBeEmpty();
}
[Fact]
public async Task Eine_weggeraeumte_Instanz_faellt_aus_dem_Register()
public async Task A_cleared_instance_drops_out_of_the_registry()
{
var pfad = _verzeichnisse.CreateInstance("Vergaenglich");
var path = _directories.CreateInstance("Vergaenglich");
await using var register = Register();
await register.RefreshAsync();
register.Instances.Count.ShouldBe(1);
Directory.Delete(pfad, recursive: true);
Directory.Delete(path, recursive: true);
await register.RefreshAsync();
register.Instances.ShouldBeEmpty();
@@ -113,41 +113,41 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
// ─── Create, start, stop ───
[Fact]
public async Task Eine_neu_angelegte_Instanz_ist_sofort_im_Register_und_laeuft_nicht()
public async Task A_newly_created_instance_is_in_the_registry_at_once_and_not_running()
{
await using var register = Register();
var pfad = register.Create("Frisch");
var path = register.Create("Frisch");
Directory.Exists(pfad).ShouldBeTrue();
File.Exists(Path.Combine(pfad, "InstanceSettings.json")).ShouldBeTrue();
Directory.Exists(path).ShouldBeTrue();
File.Exists(Path.Combine(path, "InstanceSettings.json")).ShouldBeTrue();
var instanz = register.Instances.ShouldHaveSingleItem();
instanz.Condition.ShouldBe(InstanceCondition.Unknown);
var instance = register.Instances.ShouldHaveSingleItem();
instance.Condition.ShouldBe(InstanceCondition.Unknown);
_launcher.Starts.ShouldBe(0);
}
[Fact]
public async Task Starten_und_Anhalten_gehen_ueber_das_Register()
public async Task Starting_and_stopping_go_through_the_registry()
{
_kanal.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
_channel.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
await using var register = Register();
var pfad = register.Create("Alpha");
var path = register.Create("Alpha");
(await register.StartAsync(pfad)).ShouldBeTrue();
(await register.StartAsync(path)).ShouldBeTrue();
register.Instances.Single().Condition.ShouldBe(InstanceCondition.Running);
// The instance ends upon the request.
var prozess = _launcher.Last!;
_kanal.OnStopRequest = _ => { _kanal.Answer = null; prozess.Exit(0); };
var process = _launcher.Last!;
_channel.OnStopRequest = _ => { _channel.Answer = null; process.Exit(0); };
(await register.StopAsync(pfad)).ShouldBeTrue();
(await register.StopAsync(path)).ShouldBeTrue();
register.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped);
}
[Fact]
public async Task Ein_unbekanntes_Verzeichnis_wird_nicht_stillschweigend_angelegt()
public async Task An_unknown_directory_is_not_created_silently()
{
await using var register = Register();
@@ -158,99 +158,99 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
// ─── E-M6 ───
[Fact]
public async Task Laufende_Instanzen_werden_beim_Nachsehen_uebernommen()
public async Task Running_instances_are_adopted_when_checking()
{
string pfad;
string path;
// The first manager creates two and starts only one.
await using (var erster = Register())
await using (var firstOne = Register())
{
pfad = erster.Create("Alpha");
erster.Create("Beta");
path = firstOne.Create("Alpha");
firstOne.Create("Beta");
// Only Alpha answers. Information that names the same instance for every
// directory would turn green here and in operation mix up two
// instances.
_kanal.AnswerFor[Path.GetFullPath(pfad)] =
_channel.AnswerFor[Path.GetFullPath(path)] =
new InstanceIdentity("i-1", "Alpha", "0.1.2");
(await erster.StartAsync(pfad)).ShouldBeTrue();
(await firstOne.StartAsync(path)).ShouldBeTrue();
}
_ereignisse.Clear();
_events.Clear();
// Second manager, the same machine.
await using var zweiter = Register();
await zweiter.RefreshAsync();
await using var secondOne = Register();
await secondOne.RefreshAsync();
var alpha = zweiter.Instances.Single(i => i.Name == "Alpha");
var alpha = secondOne.Instances.Single(i => i.Name == "Alpha");
alpha.Condition.ShouldBe(InstanceCondition.Running);
alpha.ProcessId.ShouldBe(_launcher.Last!.Id);
// Beta does not answer and stays off - it is not pulled up along with it.
zweiter.Instances.Single(i => i.Name == "Beta").Condition
secondOne.Instances.Single(i => i.Name == "Beta").Condition
.ShouldBe(InstanceCondition.Stopped);
// Taking over is not starting.
_launcher.Starts.ShouldBe(1);
_ereignisse.ShouldBeEmpty();
_events.ShouldBeEmpty();
}
/// <summary>
/// Whoever closes the manager wants to close the manager — not its instances.
/// </summary>
[Fact]
public async Task Das_Beenden_des_Managers_beendet_keine_Instanz()
public async Task Stopping_the_manager_stops_no_instance()
{
_kanal.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
_channel.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
var register = Register();
var pfad = register.Create("Alpha");
await register.StartAsync(pfad);
var path = register.Create("Alpha");
await register.StartAsync(path);
await register.DisposeAsync();
_kanal.StopRequests.ShouldBe(0);
_channel.StopRequests.ShouldBe(0);
_launcher.Last!.WasKilled.ShouldBeFalse();
_launcher.Last.HasExited.ShouldBeFalse();
// And the state file stays behind - it is the hint for the next one.
InstanceStateStore.Read(pfad).ShouldNotBeNull();
InstanceStateStore.Read(path).ShouldNotBeNull();
}
/// <summary>
/// The one exception: a license revocation is not an outage, but a verdict.
/// </summary>
[Fact]
public async Task Bei_einem_Grund_der_alle_betrifft_werden_alle_angehalten()
public async Task With_a_reason_affecting_everyone_all_are_paused()
{
_kanal.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
_channel.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
await using var register = Register();
var a = register.Create("Alpha");
await register.StartAsync(a);
var prozess = _launcher.Last!;
_kanal.OnStopRequest = _ => { _kanal.Answer = null; prozess.Exit(0); };
var process = _launcher.Last!;
_channel.OnStopRequest = _ => { _channel.Answer = null; process.Exit(0); };
await register.StopAllAsync("Lizenz widerrufen");
register.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped);
_kanal.StopRequests.ShouldBe(1);
_channel.StopRequests.ShouldBe(1);
}
// ─── Occurrences ───
[Fact]
public async Task Vorkommnisse_einzelner_Instanzen_kommen_beim_Register_an()
public async Task Incidents_of_single_instances_reach_the_registry()
{
_kanal.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
_channel.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
await using var register = Register();
var pfad = register.Create("Alpha");
await register.StartAsync(pfad);
var path = register.Create("Alpha");
await register.StartAsync(path);
_ereignisse.Select(e => e.Kind).ShouldBe([InstanceEventKind.Started]);
_ereignisse[0].InstanceDirectory.ShouldBe(Path.GetFullPath(pfad));
_events.Select(e => e.Kind).ShouldBe([InstanceEventKind.Started]);
_events[0].InstanceDirectory.ShouldBe(Path.GetFullPath(path));
}
}
@@ -23,8 +23,8 @@ public sealed class InstanceSupervisorTests : IDisposable
Path.GetTempPath(), "clawd-tests", "sup-" + Guid.NewGuid().ToString("N")[..8]);
private readonly FakeLauncher _launcher = new();
private readonly FakeChannel _kanal = new();
private readonly List<SupervisionEvent> _ereignisse = [];
private readonly FakeChannel _channel = new();
private readonly List<SupervisionEvent> _events = [];
private static readonly InstanceIdentity Test = new("i-1", "Test", "0.1.2");
@@ -50,7 +50,7 @@ public sealed class InstanceSupervisorTests : IDisposable
// The socket only answers if a process is really running - exactly as in
// operation. An answer stored in advance would otherwise mean "already running", and
// supervision would take over instead of starting.
_kanal.Launcher = _launcher;
_channel.Launcher = _launcher;
}
public void Dispose()
@@ -59,29 +59,29 @@ public sealed class InstanceSupervisorTests : IDisposable
catch { /* A leftover test folder does not turn a run red. */ }
}
private InstanceSupervisor Aufsicht(SupervisionOptions? options = null)
private InstanceSupervisor Supervisor(SupervisionOptions? options = null)
{
var supervisor = new InstanceSupervisor(
_dir, _launcher, _kanal, NullLogger.Instance, options ?? Options);
_dir, _launcher, _channel, NullLogger.Instance, options ?? Options);
supervisor.Observed += _ereignisse.Add;
supervisor.Observed += _events.Add;
return supervisor;
}
private IEnumerable<InstanceEventKind> Arten => _ereignisse.Select(e => e.Kind);
private IEnumerable<InstanceEventKind> Arten => _events.Select(e => e.Kind);
// ─── Starting ───
[Fact]
public async Task Ein_Start_gilt_erst_wenn_die_Instanz_antwortet()
public async Task A_start_only_counts_when_the_instance_answers()
{
_kanal.Answer = Test;
_channel.Answer = Test;
await using var aufsicht = Aufsicht();
(await aufsicht.StartAsync()).ShouldBeTrue();
await using var supervisor = Supervisor();
(await supervisor.StartAsync()).ShouldBeTrue();
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
aufsicht.InstanceName.ShouldBe("Test");
supervisor.Condition.ShouldBe(InstanceCondition.Running);
supervisor.InstanceName.ShouldBe("Test");
Arten.ShouldBe([InstanceEventKind.Started]);
var state = InstanceStateStore.Read(_dir).ShouldNotBeNull();
@@ -98,14 +98,14 @@ public sealed class InstanceSupervisorTests : IDisposable
/// broken configuration.</para>
/// </summary>
[Fact]
public async Task Ein_Prozess_der_sich_nie_meldet_gilt_nicht_als_gestartet()
public async Task A_process_that_never_reports_does_not_count_as_started()
{
_kanal.Answer = null; // runs, but says nothing
_channel.Answer = null; // runs, but says nothing
await using var aufsicht = Aufsicht();
(await aufsicht.StartAsync()).ShouldBeFalse();
await using var supervisor = Supervisor();
(await supervisor.StartAsync()).ShouldBeFalse();
aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
supervisor.Condition.ShouldBe(InstanceCondition.Stopped);
Arten.ShouldBe([InstanceEventKind.StartFailed]);
// Do not leave it standing: a process that runs and stays silent keeps the socket
@@ -115,39 +115,39 @@ public sealed class InstanceSupervisorTests : IDisposable
}
[Fact]
public async Task Ein_Prozess_der_gar_nicht_startet_meldet_das()
public async Task A_process_that_does_not_start_at_all_reports_it()
{
_launcher.FailStartWith = new InvalidOperationException("Programmdatei fehlt");
await using var aufsicht = Aufsicht();
(await aufsicht.StartAsync()).ShouldBeFalse();
await using var supervisor = Supervisor();
(await supervisor.StartAsync()).ShouldBeFalse();
Arten.ShouldBe([InstanceEventKind.StartFailed]);
_ereignisse[0].Message.ShouldContain("Programmdatei fehlt");
_events[0].Message.ShouldContain("Programmdatei fehlt");
}
// ─── The case this is about ───
[Fact]
public async Task Ein_abgeschossener_Prozess_wird_bemerkt_und_gemeldet()
public async Task A_killed_process_is_noticed_and_reported()
{
_kanal.Answer = Test;
_channel.Answer = Test;
await using var aufsicht = Aufsicht();
await aufsicht.StartAsync();
await using var supervisor = Supervisor();
await supervisor.StartAsync();
// Someone kills the instance. Nobody asked for it.
_launcher.Last!.Exit(code: 137);
_kanal.Answer = null;
_channel.Answer = null;
await aufsicht.CheckAsync();
await supervisor.CheckAsync();
aufsicht.Condition.ShouldBe(InstanceCondition.Crashed);
supervisor.Condition.ShouldBe(InstanceCondition.Crashed);
var absturz = _ereignisse.Single(e => e.Kind == InstanceEventKind.Crashed);
absturz.ExitCode.ShouldBe(137);
absturz.InstanceName.ShouldBe("Test");
absturz.WorthReporting.ShouldBeTrue("Das ist die Meldung, die es 2026-09-08 nicht gab.");
var crash = _events.Single(e => e.Kind == InstanceEventKind.Crashed);
crash.ExitCode.ShouldBe(137);
crash.InstanceName.ShouldBe("Test");
crash.WorthReporting.ShouldBeTrue("Das ist die Meldung, die es 2026-09-08 nicht gab.");
// And the state file no longer points to a process that does not exist.
InstanceStateStore.Read(_dir).ShouldBeNull();
@@ -161,106 +161,106 @@ public sealed class InstanceSupervisorTests : IDisposable
/// supervision that only counts processes sees nothing of it.</para>
/// </summary>
[Fact]
public async Task Ein_lebender_aber_stummer_Prozess_faellt_auf()
public async Task A_living_but_silent_process_is_noticed()
{
_kanal.Answer = Test;
_channel.Answer = Test;
await using var aufsicht = Aufsicht();
await aufsicht.StartAsync();
await using var supervisor = Supervisor();
await supervisor.StartAsync();
_kanal.Answer = null; // hangs - the process lives on
_channel.Answer = null; // hangs - the process lives on
// Not at the first hiccup: that would be everyday life, and an alarm at every
// hiccup is after a week the alarm nobody reads any more.
await aufsicht.CheckAsync();
await aufsicht.CheckAsync();
await supervisor.CheckAsync();
await supervisor.CheckAsync();
Arten.ShouldNotContain(InstanceEventKind.Unresponsive);
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
supervisor.Condition.ShouldBe(InstanceCondition.Running);
await aufsicht.CheckAsync();
await supervisor.CheckAsync();
aufsicht.Condition.ShouldBe(InstanceCondition.Silent);
var alarm = _ereignisse.Single(e => e.Kind == InstanceEventKind.Unresponsive);
supervisor.Condition.ShouldBe(InstanceCondition.Silent);
var alarm = _events.Single(e => e.Kind == InstanceEventKind.Unresponsive);
alarm.WorthReporting.ShouldBeTrue();
_launcher.Last!.HasExited.ShouldBeFalse("Der Prozess laeuft ja - das ist der Punkt.");
}
[Fact]
public async Task Ein_stummer_Prozess_wird_einmal_gemeldet_und_nicht_bei_jedem_Durchgang()
public async Task A_silent_process_is_reported_once_and_not_on_every_pass()
{
_kanal.Answer = Test;
_channel.Answer = Test;
await using var aufsicht = Aufsicht();
await aufsicht.StartAsync();
await using var supervisor = Supervisor();
await supervisor.StartAsync();
_kanal.Answer = null;
_channel.Answer = null;
for (var i = 0; i < 20; i++)
await aufsicht.CheckAsync();
await supervisor.CheckAsync();
_ereignisse.Count(e => e.Kind == InstanceEventKind.Unresponsive).ShouldBe(1);
_events.Count(e => e.Kind == InstanceEventKind.Unresponsive).ShouldBe(1);
}
[Fact]
public async Task Wer_wieder_antwortet_wird_wieder_als_laufend_gefuehrt()
public async Task Whoever_answers_again_is_listed_as_running_again()
{
_kanal.Answer = Test;
_channel.Answer = Test;
await using var aufsicht = Aufsicht();
await aufsicht.StartAsync();
await using var supervisor = Supervisor();
await supervisor.StartAsync();
_kanal.Answer = null;
for (var i = 0; i < 3; i++) await aufsicht.CheckAsync();
aufsicht.Condition.ShouldBe(InstanceCondition.Silent);
_channel.Answer = null;
for (var i = 0; i < 3; i++) await supervisor.CheckAsync();
supervisor.Condition.ShouldBe(InstanceCondition.Silent);
_kanal.Answer = Test;
await aufsicht.CheckAsync();
_channel.Answer = Test;
await supervisor.CheckAsync();
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
supervisor.Condition.ShouldBe(InstanceCondition.Running);
Arten.ShouldContain(InstanceEventKind.Recovered);
}
// ─── Stopping ───
[Fact]
public async Task Ein_erbetenes_Ende_ist_kein_Absturz()
public async Task A_requested_exit_is_not_a_crash()
{
_kanal.Answer = Test;
_channel.Answer = Test;
await using var aufsicht = Aufsicht();
await aufsicht.StartAsync();
await using var supervisor = Supervisor();
await supervisor.StartAsync();
// The instance ends upon the request - as in operation.
_launcher.Last!.Exit(code: 0);
_kanal.Answer = null;
_channel.Answer = null;
(await aufsicht.StopAsync()).ShouldBeTrue();
(await supervisor.StopAsync()).ShouldBeTrue();
_kanal.StopRequests.ShouldBe(1, "Erst bitten, nicht schiessen.");
_channel.StopRequests.ShouldBe(1, "Erst bitten, nicht schiessen.");
_launcher.Last.WasKilled.ShouldBeFalse();
aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
supervisor.Condition.ShouldBe(InstanceCondition.Stopped);
Arten.ShouldNotContain(InstanceEventKind.Crashed);
_ereignisse.Last().Kind.ShouldBe(InstanceEventKind.StoppedOrderly);
_ereignisse.Last().WorthReporting.ShouldBeFalse();
_events.Last().Kind.ShouldBe(InstanceEventKind.StoppedOrderly);
_events.Last().WorthReporting.ShouldBeFalse();
}
[Fact]
public async Task Wer_nicht_hoert_wird_nach_der_Frist_beendet()
public async Task Whoever_does_not_listen_is_stopped_after_the_deadline()
{
_kanal.Answer = Test;
_channel.Answer = Test;
await using var aufsicht = Aufsicht();
await aufsicht.StartAsync();
await using var supervisor = Supervisor();
await supervisor.StartAsync();
// Keeps answering and does not end - the request goes unheard.
_kanal.StopRequestArrives = false;
_channel.StopRequestArrives = false;
(await aufsicht.StopAsync()).ShouldBeTrue();
(await supervisor.StopAsync()).ShouldBeTrue();
_kanal.StopRequests.ShouldBe(1);
_channel.StopRequests.ShouldBe(1);
_launcher.Last!.WasKilled.ShouldBeTrue();
aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
supervisor.Condition.ShouldBe(InstanceCondition.Stopped);
}
/// <summary>
@@ -274,25 +274,25 @@ public sealed class InstanceSupervisorTests : IDisposable
/// the damage this project is built against.</para>
/// </summary>
[Fact]
public async Task Ein_stummer_aber_lebender_Prozess_gilt_nicht_als_beendet()
public async Task A_silent_but_living_process_does_not_count_as_ended()
{
_kanal.Answer = Test;
_channel.Answer = Test;
await using var aufsicht = Aufsicht();
await aufsicht.StartAsync();
await using var supervisor = Supervisor();
await supervisor.StartAsync();
var prozess = _launcher.Last.ShouldNotBeNull();
var process = _launcher.Last.ShouldNotBeNull();
// It hangs: the socket is silent, the process lives.
_kanal.Verstummen(_dir);
_channel.GoSilent(_dir);
(await aufsicht.StopAsync()).ShouldBeTrue();
(await supervisor.StopAsync()).ShouldBeTrue();
prozess.WasKilled.ShouldBeTrue(
process.WasKilled.ShouldBeTrue(
"Wer schweigt und lebt, muss beendet werden - nicht fuer beendet erklaert.");
prozess.HasExited.ShouldBeTrue();
aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
process.HasExited.ShouldBeTrue();
supervisor.Condition.ShouldBe(InstanceCondition.Stopped);
}
/// <summary>
@@ -303,34 +303,34 @@ public sealed class InstanceSupervisorTests : IDisposable
/// for.</para>
/// </summary>
[Fact]
public async Task Wer_aufraeumt_bekommt_die_Frist_nachgezogen()
public async Task Whoever_cleans_up_gets_the_deadline_extended()
{
_kanal.Answer = Test;
_channel.Answer = Test;
// #722: Supervision asks every 250 ms and only extends the deadline on "cleaning up".
// With a 300 ms deadline the next question came too late under parallel load, and the test saw
// a kill where none belongs. A one-second deadline leaves the tick room; the
// instance still cleans up considerably longer than the deadline alone allows.
await using var aufsicht = Aufsicht(Options with { StopGrace = TimeSpan.FromSeconds(1), MaxDrainWait = TimeSpan.FromSeconds(20) });
await aufsicht.StartAsync();
await using var supervisor = Supervisor(Options with { StopGrace = TimeSpan.FromSeconds(1), MaxDrainWait = TimeSpan.FromSeconds(20) });
await supervisor.StartAsync();
var prozess = _launcher.Last.ShouldNotBeNull();
var process = _launcher.Last.ShouldNotBeNull();
// It answers and reports: I am cleaning up. Longer than StopGrace (1 s), but
// shorter than MaxDrainWait.
_kanal.Answer = Test with { Phase = ShutdownPhase.Draining, OpenWork = 1 };
_channel.Answer = Test with { Phase = ShutdownPhase.Draining, OpenWork = 1 };
_kanal.OnStopRequest = _ => Task.Run(async () =>
_channel.OnStopRequest = _ => Task.Run(async () =>
{
await Task.Delay(2500);
prozess.Exit(0);
_kanal.Answer = null;
process.Exit(0);
_channel.Answer = null;
});
(await aufsicht.StopAsync()).ShouldBeTrue();
(await supervisor.StopAsync()).ShouldBeTrue();
prozess.WasKilled.ShouldBeFalse("Wer aufraeumt, wird nicht abgeschossen.");
aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
process.WasKilled.ShouldBeFalse("Wer aufraeumt, wird nicht abgeschossen.");
supervisor.Condition.ShouldBe(InstanceCondition.Stopped);
}
/// <summary>
@@ -341,48 +341,48 @@ public sealed class InstanceSupervisorTests : IDisposable
/// be grabbed (see <see cref="Eine_wiederverwendete_Prozesskennung_wird_nicht_fuer_die_Instanz_gehalten"/>).</para>
/// </summary>
[Fact]
public async Task Was_nicht_zu_fassen_ist_wird_nicht_als_beendet_gemeldet()
public async Task What_cannot_be_grasped_is_not_reported_as_ended()
{
_kanal.Answer = Test;
_kanal.StopRequestArrives = false;
_channel.Answer = Test;
_channel.StopRequestArrives = false;
// It was already running before this manager came - and without a state file its
// process cannot be grabbed by it.
_launcher.Fremdstart(_dir, 4711);
_launcher.StartedElsewhere(_dir, 4711);
await using var aufsicht = Aufsicht();
await aufsicht.ReattachAsync(); // taken over, but without a process
aufsicht.ProcessId.ShouldBeNull();
await using var supervisor = Supervisor();
await supervisor.ReattachAsync(); // taken over, but without a process
supervisor.ProcessId.ShouldBeNull();
(await aufsicht.StopAsync()).ShouldBeFalse();
(await supervisor.StopAsync()).ShouldBeFalse();
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
supervisor.Condition.ShouldBe(InstanceCondition.Running);
Arten.ShouldNotContain(InstanceEventKind.StoppedOrderly);
}
// ─── Rediscovering (E-M6) ───
[Fact]
public async Task Eine_laufende_Instanz_wird_nach_einem_Managerneustart_uebernommen()
public async Task A_running_instance_is_adopted_after_a_manager_restart()
{
_kanal.Answer = Test;
_channel.Answer = Test;
// The first manager starts it.
await using (var erster = Aufsicht())
await erster.StartAsync();
await using (var firstOne = Supervisor())
await firstOne.StartAsync();
_ereignisse.Clear();
_events.Clear();
// Second manager, the same machine, the same running instance.
await using var zweiter = Aufsicht();
(await zweiter.ReattachAsync()).ShouldBeTrue();
await using var secondOne = Supervisor();
(await secondOne.ReattachAsync()).ShouldBeTrue();
zweiter.Condition.ShouldBe(InstanceCondition.Running);
zweiter.InstanceName.ShouldBe("Test");
zweiter.ProcessId.ShouldBe(_launcher.Last!.Id);
secondOne.Condition.ShouldBe(InstanceCondition.Running);
secondOne.InstanceName.ShouldBe("Test");
secondOne.ProcessId.ShouldBe(_launcher.Last!.Id);
// Taking over is not starting - there is nothing to report.
_ereignisse.ShouldBeEmpty();
_events.ShouldBeEmpty();
_launcher.Starts.ShouldBe(1);
}
@@ -397,57 +397,57 @@ public sealed class InstanceSupervisorTests : IDisposable
[Fact]
public async Task Eine_wiederverwendete_Prozesskennung_wird_nicht_fuer_die_Instanz_gehalten()
{
_kanal.Answer = Test;
_channel.Answer = Test;
await using (var erster = Aufsicht())
await erster.StartAsync();
await using (var firstOne = Supervisor())
await firstOne.StartAsync();
var alte = _launcher.Last!;
var oldOnes = _launcher.Last!;
// The same ID, started much later: a different process.
_launcher.Running[alte.Id] = new FakeProcess(alte.Id, alte.StartedAt.AddHours(3));
_launcher.Running[oldOnes.Id] = new FakeProcess(oldOnes.Id, oldOnes.StartedAt.AddHours(3));
await using var zweiter = Aufsicht();
(await zweiter.ReattachAsync()).ShouldBeTrue("Der Socket antwortet ja.");
await using var secondOne = Supervisor();
(await secondOne.ReattachAsync()).ShouldBeTrue("Der Socket antwortet ja.");
zweiter.ProcessId.ShouldBeNull(
secondOne.ProcessId.ShouldBeNull(
"Der fremde Prozess darf nicht als der eigene uebernommen werden.");
}
[Fact]
public async Task Eine_Zustandsdatei_ohne_laufende_Instanz_wird_weggeraeumt()
public async Task A_state_file_without_a_running_instance_is_cleared_away()
{
_kanal.Answer = Test;
_channel.Answer = Test;
await using (var erster = Aufsicht())
await erster.StartAsync();
await using (var firstOne = Supervisor())
await firstOne.StartAsync();
InstanceStateStore.Read(_dir).ShouldNotBeNull();
// The manager was gone, the instance is gone by now too.
_kanal.Answer = null;
_channel.Answer = null;
await using var zweiter = Aufsicht();
(await zweiter.ReattachAsync()).ShouldBeFalse();
await using var secondOne = Supervisor();
(await secondOne.ReattachAsync()).ShouldBeFalse();
zweiter.Condition.ShouldBe(InstanceCondition.Stopped);
secondOne.Condition.ShouldBe(InstanceCondition.Stopped);
InstanceStateStore.Read(_dir).ShouldBeNull(
"Eine Datei, die auf niemanden zeigt, laesst es aussehen, als liefe etwas.");
}
[Fact]
public async Task Ohne_Zustandsdatei_wird_eine_antwortende_Instanz_trotzdem_uebernommen()
public async Task Without_state_file_an_answering_instance_is_still_adopted()
{
// The case after a crash of the manager before it could write - or
// after someone deleted the file. The socket answers, so it is running.
_kanal.Answer = Test;
_launcher.Fremdstart(_dir, 4711);
_channel.Answer = Test;
_launcher.StartedElsewhere(_dir, 4711);
await using var aufsicht = Aufsicht();
(await aufsicht.ReattachAsync()).ShouldBeTrue();
await using var supervisor = Supervisor();
(await supervisor.ReattachAsync()).ShouldBeTrue();
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
aufsicht.ProcessId.ShouldBeNull("Es gibt keinen Prozess, den dieser Manager kennt.");
supervisor.Condition.ShouldBe(InstanceCondition.Running);
supervisor.ProcessId.ShouldBeNull("Es gibt keinen Prozess, den dieser Manager kennt.");
InstanceStateStore.Read(_dir).ShouldNotBeNull();
}
@@ -465,9 +465,9 @@ public sealed class InstanceSupervisorTests : IDisposable
[InlineData(InstanceEventKind.Started, false)]
[InlineData(InstanceEventKind.StoppedOrderly, false)]
[InlineData(InstanceEventKind.Recovered, false)]
public void Gemeldet_wird_nur_was_niemand_bestellt_hat(InstanceEventKind kind, bool erwartet)
public void Only_what_nobody_ordered_is_reported(InstanceEventKind kind, bool expected)
{
new SupervisionEvent(kind, _dir, "Test", 1, null, "", DateTimeOffset.UtcNow)
.WorthReporting.ShouldBe(erwartet);
.WorthReporting.ShouldBe(expected);
}
}
@@ -18,15 +18,15 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
private readonly string _root = Path.Combine(
Path.GetTempPath(), "clawd-tests", "mbak-" + Guid.NewGuid().ToString("N")[..8]);
private readonly InstanceDirectoryManager _verzeichnisse;
private readonly InstanceDirectoryManager _directories;
private readonly ManagerSettingsStore _settings;
private readonly FakeUploader _upload = new();
private readonly FesteUhr _uhr = new(new DateTimeOffset(2026, 9, 11, 2, 0, 0, TimeSpan.Zero));
private readonly FixedClock _clock = new(new DateTimeOffset(2026, 9, 11, 2, 0, 0, TimeSpan.Zero));
public ManagerBackupsTests()
{
Directory.CreateDirectory(_root);
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
_directories = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
_settings = new ManagerSettingsStore(_root);
_settings.Settings.LicenseKey = Lizenz;
}
@@ -39,36 +39,36 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
catch { /* A leftover test folder does not turn a run red. */ }
}
private string Instanz(string name, bool automatisch = true, string uhrzeit = "03:00", int behalten = 14)
private string Instance(string name, bool automatic = true, string timeOfDay = "03:00", int kept = 14)
{
var pfad = _verzeichnisse.CreateInstance(name);
var datei = Path.Combine(pfad, "InstanceSettings.json");
var path = _directories.CreateInstance(name);
var datei = Path.Combine(path, "InstanceSettings.json");
var config = JsonSerializer.Deserialize<InstanceConfig>(File.ReadAllText(datei))!;
config.AutoBackupEnabled = automatisch;
config.AutoBackupTime = uhrzeit;
config.BackupKeepCount = behalten;
config.AutoBackupEnabled = automatic;
config.AutoBackupTime = timeOfDay;
config.BackupKeepCount = kept;
File.WriteAllText(datei, JsonSerializer.Serialize(config));
return pfad;
return path;
}
private async Task<ManagerBackups> DienstAsync(IBackupUploader? upload = null)
private async Task<ManagerBackups> ServiceAsync(IBackupUploader? upload = null)
{
var register = new InstanceRegistry(
_verzeichnisse, new FakeLauncher(), new FakeChannel(), NullLoggerFactory.Instance,
_directories, new FakeLauncher(), new FakeChannel(), NullLoggerFactory.Instance,
SupervisionOptions.Default);
await register.RefreshAsync();
return new ManagerBackups(
_settings, register, upload ?? _upload, NullLogger.Instance, _uhr);
_settings, register, upload ?? _upload, NullLogger.Instance, _clock);
}
private static string[] Sicherungen(string pfad)
private static string[] Backups(string path)
{
var ordner = Path.Combine(pfad, "Backups");
return Directory.Exists(ordner) ? Directory.GetFiles(ordner) : [];
var folder = Path.Combine(path, "Backups");
return Directory.Exists(folder) ? Directory.GetFiles(folder) : [];
}
// ─── Names ───
@@ -78,21 +78,21 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
/// <c>Test</c> — otherwise the cleanup of one throws away the backups of the other.
/// </summary>
[Fact]
public void Ineinanderliegende_Instanznamen_werden_nicht_verwechselt()
public void Overlapping_instance_names_are_not_confused()
{
var eigene = BackupNaming.FileNameFor("Test", new DateTime(2026, 9, 11, 3, 0, 0));
var fremde = BackupNaming.FileNameFor("Test_2", new DateTime(2026, 9, 11, 3, 0, 0));
var ownOnes = BackupNaming.FileNameFor("Test", new DateTime(2026, 9, 11, 3, 0, 0));
var foreignOnes = BackupNaming.FileNameFor("Test_2", new DateTime(2026, 9, 11, 3, 0, 0));
BackupNaming.BelongsTo(eigene, "Test").ShouldBeTrue();
BackupNaming.BelongsTo(fremde, "Test").ShouldBeFalse();
BackupNaming.BelongsTo(fremde, "Test_2").ShouldBeTrue();
BackupNaming.BelongsTo(ownOnes, "Test").ShouldBeTrue();
BackupNaming.BelongsTo(foreignOnes, "Test").ShouldBeFalse();
BackupNaming.BelongsTo(foreignOnes, "Test_2").ShouldBeTrue();
}
[Theory]
[InlineData("backup_Alpha_2026-09-11_0300.zip")] // daily backup of the instance
[InlineData("backup_Alpha_20260911_030000.zip")] // first administration view
[InlineData("backup_Alpha_2026-09-11_030000.clawdbak")] // Manager
public void Aeltere_Schreibweisen_werden_erkannt(string name)
public void Older_spellings_are_recognised(string name)
{
BackupNaming.TimestampOf(name, "Alpha").ShouldBe(new DateTime(2026, 9, 11, 3, 0, 0));
}
@@ -101,7 +101,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
[InlineData("notizen.txt")]
[InlineData("backup_Alpha_kaputt.zip")]
[InlineData("backup_Alpha_2026-09-11_0300.zip.part")]
public void Fremde_Dateien_gehoeren_niemandem(string name)
public void Foreign_files_belong_to_nobody(string name)
{
BackupNaming.BelongsTo(name, "Alpha").ShouldBeFalse();
}
@@ -109,35 +109,35 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
// ─── Backing up ───
[Fact]
public async Task Ohne_Lizenzschluessel_wird_keine_Sicherung_angelegt()
public async Task Without_license_key_no_backup_is_created()
{
var pfad = Instanz("Alpha");
var path = Instance("Alpha");
_settings.Settings.LicenseKey = "";
await using var dienst = await DienstAsync();
var instanz = (await RegisterAsync()).Single();
await using var service = await ServiceAsync();
var instance = (await RegisterAsync()).Single();
await Should.ThrowAsync<BackupException>(() =>
dienst.CreateAsync(instanz, false, true, CancellationToken.None));
service.CreateAsync(instance, false, true, CancellationToken.None));
Sicherungen(pfad).ShouldBeEmpty("Unverschlüsselt wird nichts abgelegt — auch nicht ersatzweise.");
Backups(path).ShouldBeEmpty("Unverschlüsselt wird nichts abgelegt — auch nicht ersatzweise.");
}
[Fact]
public async Task Eine_Sicherung_ist_verschluesselt_und_wird_hochgeladen()
public async Task A_backup_is_encrypted_and_uploaded()
{
var pfad = Instanz("Alpha");
var path = Instance("Alpha");
await using var dienst = await DienstAsync();
var instanz = (await RegisterAsync()).Single();
await using var service = await ServiceAsync();
var instance = (await RegisterAsync()).Single();
var ergebnis = await dienst.CreateAsync(instanz, false, true, CancellationToken.None);
var result = await service.CreateAsync(instance, false, true, CancellationToken.None);
LicenseBackupCipher.IsEncrypted(ergebnis.Path).ShouldBeTrue();
LicenseBackupCipher.MatchesKey(ergebnis.Path, Lizenz).ShouldBeTrue();
LicenseBackupCipher.IsEncrypted(result.Path).ShouldBeTrue();
LicenseBackupCipher.MatchesKey(result.Path, Lizenz).ShouldBeTrue();
ergebnis.Uploaded.ShouldBeTrue();
_upload.Hochgeladen.ShouldBe([ergebnis.Path]);
result.Uploaded.ShouldBeTrue();
_upload.Hochgeladen.ShouldBe([result.Path]);
}
/// <summary>
@@ -145,71 +145,71 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
/// failed — it lies locally, after all.
/// </summary>
[Fact]
public async Task Ein_gescheiterter_Upload_laesst_die_Sicherung_stehen()
public async Task A_failed_upload_keeps_the_backup()
{
Instanz("Alpha");
Instance("Alpha");
_upload.Fehler = new IOException("Server nicht erreichbar");
await using var dienst = await DienstAsync();
var instanz = (await RegisterAsync()).Single();
await using var service = await ServiceAsync();
var instance = (await RegisterAsync()).Single();
var ergebnis = await dienst.CreateAsync(instanz, false, true, CancellationToken.None);
var result = await service.CreateAsync(instance, false, true, CancellationToken.None);
File.Exists(ergebnis.Path).ShouldBeTrue();
ergebnis.Uploaded.ShouldBeFalse();
ergebnis.UploadProblem.ShouldBe("Server nicht erreichbar");
File.Exists(result.Path).ShouldBeTrue();
result.Uploaded.ShouldBeFalse();
result.UploadProblem.ShouldBe("Server nicht erreichbar");
}
[Fact]
public async Task Aufgeraeumt_wird_nur_die_eigene_Instanz()
public async Task Only_the_own_instance_is_cleaned_up()
{
var ordner = Path.Combine(_root, "ablage");
Directory.CreateDirectory(ordner);
var folder = Path.Combine(_root, "ablage");
Directory.CreateDirectory(folder);
for (var tag = 1; tag <= 5; tag++)
{
var zeit = new DateTime(2026, 9, tag, 3, 0, 0);
File.WriteAllText(Path.Combine(ordner, BackupNaming.FileNameFor("Test", zeit)), "");
File.WriteAllText(Path.Combine(ordner, BackupNaming.FileNameFor("Test_2", zeit)), "");
var time = new DateTime(2026, 9, tag, 3, 0, 0);
File.WriteAllText(Path.Combine(folder, BackupNaming.FileNameFor("Test", time)), "");
File.WriteAllText(Path.Combine(folder, BackupNaming.FileNameFor("Test_2", time)), "");
}
await using var dienst = await DienstAsync();
dienst.Rotate(ordner, "Test", keep: 2);
await using var service = await ServiceAsync();
service.Rotate(folder, "Test", keep: 2);
var namen = Directory.GetFiles(ordner).Select(Path.GetFileName).ToList();
var names = Directory.GetFiles(folder).Select(Path.GetFileName).ToList();
namen.Count(n => BackupNaming.BelongsTo(n!, "Test")).ShouldBe(2);
namen.Count(n => BackupNaming.BelongsTo(n!, "Test_2")).ShouldBe(5, "Die andere Instanz bleibt unberührt.");
namen.ShouldContain(BackupNaming.FileNameFor("Test", new DateTime(2026, 9, 5, 3, 0, 0)));
names.Count(n => BackupNaming.BelongsTo(n!, "Test")).ShouldBe(2);
names.Count(n => BackupNaming.BelongsTo(n!, "Test_2")).ShouldBe(5, "Die andere Instanz bleibt unberührt.");
names.ShouldContain(BackupNaming.FileNameFor("Test", new DateTime(2026, 9, 5, 3, 0, 0)));
}
// ─── The daily timer ───
[Fact]
public async Task Vor_der_Uhrzeit_passiert_nichts()
public async Task Before_the_time_nothing_happens()
{
var pfad = Instanz("Alpha", uhrzeit: "03:00");
var path = Instance("Alpha", timeOfDay: "03:00");
await using var dienst = await DienstAsync();
await dienst.TickAsync(); // 02:00
await using var service = await ServiceAsync();
await service.TickAsync(); // 02:00
Sicherungen(pfad).ShouldBeEmpty();
Backups(path).ShouldBeEmpty();
}
[Fact]
public async Task Einmal_am_Tag_und_nicht_oefter()
public async Task Once_a_day_and_not_more_often()
{
var pfad = Instanz("Alpha", uhrzeit: "03:00");
var path = Instance("Alpha", timeOfDay: "03:00");
await using var dienst = await DienstAsync();
await using var service = await ServiceAsync();
_uhr.Jetzt = _uhr.Jetzt.AddHours(1.5); // 03:30
await dienst.TickAsync();
_clock.Jetzt = _clock.Jetzt.AddHours(1.5); // 03:30
await service.TickAsync();
_uhr.Jetzt = _uhr.Jetzt.AddMinutes(30); // 04:00
await dienst.TickAsync();
_clock.Jetzt = _clock.Jetzt.AddMinutes(30); // 04:00
await service.TickAsync();
Sicherungen(pfad).Length.ShouldBe(1);
Backups(path).Length.ShouldBe(1);
}
/// <summary>
@@ -217,32 +217,32 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
/// restarts after the backup time gets a second backup.
/// </summary>
[Fact]
public async Task Ein_Neustart_des_Managers_erzeugt_keine_zweite_Sicherung()
public async Task A_manager_restart_creates_no_second_backup()
{
var pfad = Instanz("Alpha", uhrzeit: "03:00");
_uhr.Jetzt = _uhr.Jetzt.AddHours(1.5);
var path = Instance("Alpha", timeOfDay: "03:00");
_clock.Jetzt = _clock.Jetzt.AddHours(1.5);
await using (var erster = await DienstAsync())
await erster.TickAsync();
await using (var firstOne = await ServiceAsync())
await firstOne.TickAsync();
_uhr.Jetzt = _uhr.Jetzt.AddHours(1);
_clock.Jetzt = _clock.Jetzt.AddHours(1);
await using (var zweiter = await DienstAsync())
await zweiter.TickAsync();
await using (var secondOne = await ServiceAsync())
await secondOne.TickAsync();
Sicherungen(pfad).Length.ShouldBe(1);
Backups(path).Length.ShouldBe(1);
}
[Fact]
public async Task Ohne_Schalter_keine_automatische_Sicherung()
public async Task Without_switch_no_automatic_backup()
{
var pfad = Instanz("Alpha", automatisch: false);
_uhr.Jetzt = _uhr.Jetzt.AddHours(5);
var path = Instance("Alpha", automatic: false);
_clock.Jetzt = _clock.Jetzt.AddHours(5);
await using var dienst = await DienstAsync();
await dienst.TickAsync();
await using var service = await ServiceAsync();
await service.TickAsync();
Sicherungen(pfad).ShouldBeEmpty();
Backups(path).ShouldBeEmpty();
}
// ─── Helpers ───
@@ -250,7 +250,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
private async Task<IReadOnlyList<ManagedInstance>> RegisterAsync()
{
var register = new InstanceRegistry(
_verzeichnisse, new FakeLauncher(), new FakeChannel(), NullLoggerFactory.Instance,
_directories, new FakeLauncher(), new FakeChannel(), NullLoggerFactory.Instance,
SupervisionOptions.Default);
await register.RefreshAsync();
@@ -274,7 +274,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
}
/// <summary>A clock in UTC, so "local" means the same everywhere in the test.</summary>
private sealed class FesteUhr(DateTimeOffset start) : TimeProvider
private sealed class FixedClock(DateTimeOffset start) : TimeProvider
{
public DateTimeOffset Jetzt { get; set; } = start;
public override DateTimeOffset GetUtcNow() => Jetzt;
@@ -33,9 +33,9 @@ public sealed class ManagerCoreTests : IAsyncDisposable
Path.GetTempPath(), "clawd-tests", "core-" + Guid.NewGuid().ToString("N")[..8]);
private readonly FakeLauncher _launcher = new();
private readonly FakeChannel _kanal = new();
private readonly FakeChannel _channel = new();
private readonly FakeTokenSource _tokens = new();
private readonly InstanceDirectoryManager _verzeichnisse;
private readonly InstanceDirectoryManager _directories;
private static readonly SupervisionOptions Options = new(
ProbeInterval: TimeSpan.FromMilliseconds(50),
@@ -48,16 +48,16 @@ public sealed class ManagerCoreTests : IAsyncDisposable
public ManagerCoreTests()
{
Directory.CreateDirectory(_root);
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
_directories = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
// The socket only answers if a process is really running - exactly as in
// operation (see FakeChannel.Launcher).
_kanal.Launcher = _launcher;
_channel.Launcher = _launcher;
}
private ManagerCore Manager()
{
var register = new InstanceRegistry(
_verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options);
_directories, _launcher, _channel, NullLoggerFactory.Instance, Options);
return new ManagerCore(
new ManagerSettingsStore(_root), register, _tokens, NullLoggerFactory.Instance);
@@ -79,22 +79,22 @@ public sealed class ManagerCoreTests : IAsyncDisposable
/// manager then takes it over (E-M6), the launcher is never touched, and the test
/// checks something other than it claims. Exactly that happened to me here twice.</para>
/// </summary>
private async Task<StartRefusal> StarteUndPruefe(ManagerCore manager, string pfad, string name)
private async Task<StartRefusal> StartAndCheck(ManagerCore manager, string path, string name)
{
var vorher = _launcher.Starts;
var before = _launcher.Starts;
_kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", name, "0.1.2");
_channel.AnswerFor[Path.GetFullPath(path)] = new InstanceIdentity("i-1", name, "0.1.2");
var ergebnis = await manager.StartInstanceAsync(pfad);
var result = await manager.StartInstanceAsync(path);
_launcher.Starts.ShouldBe(vorher + 1, "Gestartet, nicht uebernommen.");
return ergebnis;
_launcher.Starts.ShouldBe(before + 1, "Gestartet, nicht uebernommen.");
return result;
}
// ─── Starting up ───
[Fact]
public async Task Ohne_Lizenzpruefung_arbeitet_der_Manager()
public async Task Without_license_check_the_manager_works()
{
// No gatekeeper means no lock - otherwise there would be a chicken-and-egg lock
// before anyone can even enter a key.
@@ -105,9 +105,9 @@ public sealed class ManagerCoreTests : IAsyncDisposable
}
[Fact]
public async Task Beim_Hochfahren_wird_nichts_gestartet()
public async Task Nothing_is_started_on_startup()
{
_verzeichnisse.CreateInstance("Alpha");
_directories.CreateInstance("Alpha");
await using var manager = Manager();
await manager.StartAsync();
@@ -119,45 +119,45 @@ public sealed class ManagerCoreTests : IAsyncDisposable
// ─── Token ───
[Fact]
public async Task Eine_neue_Instanz_bekommt_gleich_ihr_eigenes_Token()
public async Task A_new_instance_gets_its_own_token_right_away()
{
// At creation and not at the first start: then the instance file belongs
// to nobody without doubt.
await using var manager = Manager();
var pfad = await manager.CreateInstanceAsync("Frisch");
var path = await manager.CreateInstanceAsync("Frisch");
_tokens.Fuer.ShouldBe([Path.GetFullPath(pfad)]);
_tokens.Fuer.ShouldBe([Path.GetFullPath(path)]);
}
[Fact]
public async Task Vor_dem_Start_wird_das_Token_nachgeholt()
public async Task Before_the_start_the_token_is_fetched()
{
// The case of an instance that existed before the manager - or for which the
// Deploymentcenter was not reachable at creation.
var pfad = _verzeichnisse.CreateInstance("Alt");
var path = _directories.CreateInstance("Alt");
await using var manager = Manager();
await manager.StartAsync();
(await StarteUndPruefe(manager, pfad, "Alt")).ShouldBe(StartRefusal.None);
(await StartAndCheck(manager, path, "Alt")).ShouldBe(StartRefusal.None);
_tokens.Fuer.ShouldContain(Path.GetFullPath(pfad));
_tokens.Fuer.ShouldContain(Path.GetFullPath(path));
}
[Fact]
public async Task Ein_fehlgeschlagener_Tokenbezug_verhindert_den_Start_nicht()
public async Task A_failed_token_fetch_does_not_prevent_the_start()
{
// Monitoring that only runs with perfect permissions is silent exactly
// when you need it.
_tokens.Erfolg = false;
var pfad = _verzeichnisse.CreateInstance("Alt");
var path = _directories.CreateInstance("Alt");
await using var manager = Manager();
await manager.StartAsync();
(await StarteUndPruefe(manager, pfad, "Alt")).ShouldBe(StartRefusal.None);
(await StartAndCheck(manager, path, "Alt")).ShouldBe(StartRefusal.None);
}
// ─── The verdict ───
@@ -170,38 +170,38 @@ public sealed class ManagerCoreTests : IAsyncDisposable
/// network problem would take all installations down at the same time.</para>
/// </summary>
[Fact]
public async Task Ein_Lizenzwiderruf_haelt_alle_laufenden_Instanzen_an()
public async Task A_license_revocation_pauses_all_running_instances()
{
var pfad = _verzeichnisse.CreateInstance("Alpha");
var path = _directories.CreateInstance("Alpha");
await using var manager = Manager();
await manager.StartAsync();
await StarteUndPruefe(manager, pfad, "Alpha");
await StartAndCheck(manager, path, "Alpha");
manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Running);
// The instance ends upon the request.
var prozess = _launcher.Last!;
_kanal.OnStopRequest = _ => { _kanal.AnswerFor[Path.GetFullPath(pfad)] = null; prozess.Exit(0); };
var process = _launcher.Last!;
_channel.OnStopRequest = _ => { _channel.AnswerFor[Path.GetFullPath(path)] = null; process.Exit(0); };
await manager.HandleLicenseRevokedAsync("Widerrufen durch den Hersteller");
manager.LicenseHolds.ShouldBeFalse();
manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped);
_kanal.StopRequests.ShouldBe(1, "Erst bitten, nicht schiessen.");
_channel.StopRequests.ShouldBe(1, "Erst bitten, nicht schiessen.");
}
[Fact]
public async Task Nach_einem_Widerruf_startet_keine_Instanz_mehr()
public async Task After_a_revocation_no_instance_starts()
{
var pfad = _verzeichnisse.CreateInstance("Alpha");
var path = _directories.CreateInstance("Alpha");
await using var manager = Manager();
await manager.StartAsync();
await manager.HandleLicenseRevokedAsync("Widerrufen");
(await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.NoLicense);
(await manager.StartInstanceAsync(path)).ShouldBe(StartRefusal.NoLicense);
_launcher.Starts.ShouldBe(0);
_tokens.Fuer.ShouldBeEmpty("Ohne Lizenz wird auch kein Token verbraucht.");
@@ -210,14 +210,14 @@ public sealed class ManagerCoreTests : IAsyncDisposable
// ─── When the start fails ───
[Fact]
public async Task Eine_Instanz_die_sich_nicht_meldet_gilt_als_nicht_gestartet()
public async Task An_instance_that_does_not_report_counts_as_not_started()
{
var pfad = _verzeichnisse.CreateInstance("Stumm");
var path = _directories.CreateInstance("Stumm");
await using var manager = Manager();
await manager.StartAsync();
// The process runs, the instance never reports in.
(await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.DidNotStart);
(await manager.StartInstanceAsync(path)).ShouldBe(StartRefusal.DidNotStart);
}
}
@@ -21,7 +21,7 @@ public sealed class ManagerSettingsTests : IDisposable
}
[Fact]
public void Eine_fehlende_Datei_wird_mit_Vorgaben_angelegt()
public void A_missing_file_is_created_with_defaults()
{
var store = new ManagerSettingsStore(_dir);
var settings = store.Load();
@@ -36,7 +36,7 @@ public sealed class ManagerSettingsTests : IDisposable
/// runtime, otherwise nobody could work with them.
/// </summary>
[Fact]
public void Geheimnisse_stehen_nicht_im_Klartext_in_der_Datei()
public void Secrets_are_not_in_plaintext_in_the_file()
{
var store = new ManagerSettingsStore(_dir);
store.Load();
@@ -48,9 +48,9 @@ public sealed class ManagerSettingsTests : IDisposable
File.ReadAllText(store.SettingsPath).ShouldNotContain("dc_master_geheim_1234567890");
File.ReadAllText(store.SettingsPath).ShouldNotContain("LIZ-1234-5678");
var erneut = new ManagerSettingsStore(_dir).Load();
erneut.DeploymentcenterMasterToken.ShouldBe("dc_master_geheim_1234567890");
erneut.LicenseKey.ShouldBe("LIZ-1234-5678");
var again = new ManagerSettingsStore(_dir).Load();
again.DeploymentcenterMasterToken.ShouldBe("dc_master_geheim_1234567890");
again.LicenseKey.ShouldBe("LIZ-1234-5678");
}
/// <summary>
@@ -61,13 +61,13 @@ public sealed class ManagerSettingsTests : IDisposable
/// with defaults. This test clarifies whether that was due to the file.</para>
/// </summary>
[Fact]
public void Eine_Datei_mit_Byte_Order_Mark_gilt_auch()
public void A_file_with_byte_order_mark_counts_too()
{
var pfad = Path.Combine(_dir, "manager.json");
var path = Path.Combine(_dir, "manager.json");
// Exactly as PowerShell writes it: with BOM, with double spaces after
// the colon, and with doubled backslashes in the path.
var inhalt = """
var content = """
{
"instanceExecutable": "J:\\Programme\\ClawdDotNet.exe",
"webUiPort": 7009,
@@ -75,8 +75,8 @@ public sealed class ManagerSettingsTests : IDisposable
}
""";
File.WriteAllText(pfad, inhalt, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
File.ReadAllBytes(pfad)[0].ShouldBe((byte)0xEF, "Der Test braucht das BOM.");
File.WriteAllText(path, content, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
File.ReadAllBytes(path)[0].ShouldBe((byte)0xEF, "Der Test braucht das BOM.");
var store = new ManagerSettingsStore(_dir);
var settings = store.Load();
@@ -92,7 +92,7 @@ public sealed class ManagerSettingsTests : IDisposable
/// no instance would run at all, and that is the worse state.
/// </summary>
[Fact]
public void Eine_kaputte_Datei_verhindert_den_Start_nicht()
public void A_broken_file_does_not_prevent_the_start()
{
File.WriteAllText(Path.Combine(_dir, "manager.json"), "{ das ist kein JSON");
@@ -111,7 +111,7 @@ public sealed class ManagerSettingsTests : IDisposable
/// alarm at every hiccup.
/// </summary>
[Fact]
public void Unsinnige_Aufsichtswerte_werden_eingefangen()
public void Nonsensical_supervision_values_are_caught()
{
var settings = new ManagerSettings
{
@@ -133,30 +133,30 @@ public sealed class ManagerSettingsTests : IDisposable
/// <summary>The log excerpt that only gives a crash report its value.</summary>
public sealed class InstanceLogExcerptTests : IDisposable
{
private readonly string _instanz = Path.Combine(
private readonly string _instance = Path.Combine(
Path.GetTempPath(), "clawd-tests", "log-" + Guid.NewGuid().ToString("N")[..8]);
public InstanceLogExcerptTests() => Directory.CreateDirectory(_instanz);
public InstanceLogExcerptTests() => Directory.CreateDirectory(_instance);
public void Dispose()
{
try { Directory.Delete(_instanz, recursive: true); }
try { Directory.Delete(_instance, recursive: true); }
catch { /* A leftover test folder does not turn a run red. */ }
}
private void Schreibe(string tag, string modul, params string[] zeilen)
private void Write(string tag, string module, params string[] lines)
{
var dir = Path.Combine(_instanz, "Logs", tag);
var dir = Path.Combine(_instance, "Logs", tag);
Directory.CreateDirectory(dir);
File.WriteAllLines(Path.Combine(dir, modul), zeilen);
File.WriteAllLines(Path.Combine(dir, module), lines);
}
[Fact]
public void Ohne_Protokoll_kommt_nichts_zurueck()
public void Without_log_nothing_comes_back()
{
// Not an error, but a finding: a process that was killed before its
// write buffer was flushed leaves exactly nothing (#529).
InstanceLogExcerpt.Read(_instanz).ShouldBeEmpty();
InstanceLogExcerpt.Read(_instance).ShouldBeEmpty();
}
/// <summary>
@@ -165,42 +165,42 @@ public sealed class InstanceLogExcerptTests : IDisposable
/// next.
/// </summary>
[Fact]
public void Die_Module_werden_nach_Zeit_zusammengefuehrt()
public void The_modules_are_merged_by_time()
{
Schreibe("2026-09-08", "Startup.log",
Write("2026-09-08", "Startup.log",
"[17:26:48.100] [INF] Instanz Test2 angelegt",
"[17:26:50.900] [INF] Application is shutting down");
Schreibe("2026-09-08", "Web.log",
Write("2026-09-08", "Web.log",
"[17:26:49.500] [INF] Weboberfläche beendet");
var zeilen = InstanceLogExcerpt.Read(_instanz);
var lines = InstanceLogExcerpt.Read(_instance);
zeilen.Count.ShouldBe(3);
zeilen[0].ShouldContain("17:26:48");
zeilen[1].ShouldContain("17:26:49");
zeilen[2].ShouldContain("17:26:50");
lines.Count.ShouldBe(3);
lines[0].ShouldContain("17:26:48");
lines[1].ShouldContain("17:26:49");
lines[2].ShouldContain("17:26:50");
// Which file a line comes from is part of it.
zeilen[1].ShouldStartWith("Web.log:");
lines[1].ShouldStartWith("Web.log:");
}
[Fact]
public void Nur_der_juengste_Tag_zaehlt()
public void Only_the_most_recent_day_counts()
{
Schreibe("2026-09-01", "Startup.log", "[10:00:00.000] [INF] uralt");
Schreibe("2026-09-08", "Startup.log", "[10:00:00.000] [INF] frisch");
Write("2026-09-01", "Startup.log", "[10:00:00.000] [INF] uralt");
Write("2026-09-08", "Startup.log", "[10:00:00.000] [INF] frisch");
InstanceLogExcerpt.Read(_instanz).ShouldHaveSingleItem().ShouldContain("frisch");
InstanceLogExcerpt.Read(_instance).ShouldHaveSingleItem().ShouldContain("frisch");
}
[Fact]
public void Es_werden_nur_die_letzten_Zeilen_genommen()
public void Only_the_last_lines_are_taken()
{
Schreibe("2026-09-08", "Startup.log",
Write("2026-09-08", "Startup.log",
Enumerable.Range(0, 500).Select(i => $"[10:00:{i % 60:00}.000] [INF] Zeile {i}").ToArray());
InstanceLogExcerpt.Read(_instanz, lines: 10).Count.ShouldBeLessThanOrEqualTo(10);
InstanceLogExcerpt.Read(_instance, lines: 10).Count.ShouldBeLessThanOrEqualTo(10);
}
/// <summary>
@@ -208,33 +208,33 @@ public sealed class InstanceLogExcerptTests : IDisposable
/// that is just ending. Whoever wanted to open it exclusively might get nothing at all.
/// </summary>
[Fact]
public void Eine_offen_beschriebene_Datei_laesst_sich_lesen()
public void A_file_open_for_writing_can_be_read()
{
Schreibe("2026-09-08", "Startup.log", "[10:00:00.000] [INF] erste Zeile");
Write("2026-09-08", "Startup.log", "[10:00:00.000] [INF] erste Zeile");
var pfad = Path.Combine(_instanz, "Logs", "2026-09-08", "Startup.log");
var path = Path.Combine(_instance, "Logs", "2026-09-08", "Startup.log");
using var offen = new FileStream(
pfad, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
using var open = new FileStream(
path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
InstanceLogExcerpt.Read(_instanz).ShouldNotBeEmpty();
InstanceLogExcerpt.Read(_instance).ShouldNotBeEmpty();
}
[Fact]
public void Ein_eigenes_Protokollverzeichnis_wird_beachtet()
public void A_custom_log_directory_is_respected()
{
var eigen = Path.Combine(_instanz, "AndereLogs");
Directory.CreateDirectory(Path.Combine(eigen, "2026-09-08"));
var own = Path.Combine(_instance, "AndereLogs");
Directory.CreateDirectory(Path.Combine(own, "2026-09-08"));
File.WriteAllLines(
Path.Combine(eigen, "2026-09-08", "Startup.log"),
Path.Combine(own, "2026-09-08", "Startup.log"),
["[10:00:00.000] [INF] woanders"]);
var config = new ClawdDotNet.Core.Config.InstanceConfig
{
WorkingDirectory = _instanz,
WorkingDirectory = _instance,
LogDirectory = "AndereLogs"
};
InstanceLogExcerpt.Read(_instanz, config).ShouldHaveSingleItem().ShouldContain("woanders");
InstanceLogExcerpt.Read(_instance, config).ShouldHaveSingleItem().ShouldContain("woanders");
}
}
@@ -43,34 +43,34 @@ public sealed class SupervisionReporterTests
{
private readonly CapturingHandler _handler = new();
private SupervisionReporter Melder(Func<string, IReadOnlyList<string>>? auszug = null)
private SupervisionReporter Reporter(Func<string, IReadOnlyList<string>>? excerpt = null)
{
var api = new DeploymentcenterApi("https://dc.example", "t", new HttpClient(_handler));
var bugtracker = new BugtrackerClient(api, "clawddotnet", "production", "0.1.2");
return new SupervisionReporter(bugtracker, NullLogger.Instance, auszug ?? (_ => []));
return new SupervisionReporter(bugtracker, NullLogger.Instance, excerpt ?? (_ => []));
}
private static SupervisionEvent Ereignis(
private static SupervisionEvent Event(
InstanceEventKind kind, int? exitCode = null, string dir = @"C:\Instanzen\Instance-Alpha")
=> new(kind, dir, "Alpha", 4711, exitCode, "Instanz Alpha ist unerwartet beendet worden.",
DateTimeOffset.Parse("2026-09-08T17:27:45Z"));
[Fact]
public async Task Ein_Absturz_geht_raus()
public async Task A_crash_goes_out()
{
(await Melder().ReportAsync(Ereignis(InstanceEventKind.Crashed, exitCode: 137)))
(await Reporter().ReportAsync(Event(InstanceEventKind.Crashed, exitCode: 137)))
.ShouldBeTrue();
_handler.Calls.ShouldBe(1);
var rumpf = _handler.LastBody.ShouldNotBeNull();
rumpf.ShouldContain("unerwartet beendet");
rumpf.ShouldContain("4711");
rumpf.ShouldContain("137");
rumpf.ShouldContain("instanzmanager");
rumpf.ShouldContain("clawddotnet"); // project binding
rumpf.ShouldContain("0.1.2"); // build_version
var body = _handler.LastBody.ShouldNotBeNull();
body.ShouldContain("unerwartet beendet");
body.ShouldContain("4711");
body.ShouldContain("137");
body.ShouldContain("instanzmanager");
body.ShouldContain("clawddotnet"); // project binding
body.ShouldContain("0.1.2"); // build_version
}
/// <summary>
@@ -81,9 +81,9 @@ public sealed class SupervisionReporterTests
[InlineData(InstanceEventKind.Started)]
[InlineData(InstanceEventKind.StoppedOrderly)]
[InlineData(InstanceEventKind.Recovered)]
public async Task Alltag_wird_nicht_gemeldet(InstanceEventKind kind)
public async Task Routine_is_not_reported(InstanceEventKind kind)
{
(await Melder().ReportAsync(Ereignis(kind))).ShouldBeFalse();
(await Reporter().ReportAsync(Event(kind))).ShouldBeFalse();
_handler.Calls.ShouldBe(0);
}
@@ -93,31 +93,31 @@ public sealed class SupervisionReporterTests
/// not a dozen identical ones.
/// </summary>
[Fact]
public async Task Dieselbe_Instanz_und_dieselbe_Art_ergeben_denselben_Schluessel()
public async Task The_same_instance_and_kind_give_the_same_key()
{
var melder = Melder();
var reporter = Reporter();
await melder.ReportAsync(Ereignis(InstanceEventKind.Crashed));
var erster = SchluesselAus(_handler.LastBody!);
await reporter.ReportAsync(Event(InstanceEventKind.Crashed));
var firstOne = KeyFrom(_handler.LastBody!);
await melder.ReportAsync(Ereignis(InstanceEventKind.Crashed));
SchluesselAus(_handler.LastBody!).ShouldBe(erster);
await reporter.ReportAsync(Event(InstanceEventKind.Crashed));
KeyFrom(_handler.LastBody!).ShouldBe(firstOne);
// A different kind is a different finding.
await melder.ReportAsync(Ereignis(InstanceEventKind.Unresponsive));
SchluesselAus(_handler.LastBody!).ShouldNotBe(erster);
await reporter.ReportAsync(Event(InstanceEventKind.Unresponsive));
KeyFrom(_handler.LastBody!).ShouldNotBe(firstOne);
// And a different instance all the more.
await melder.ReportAsync(
Ereignis(InstanceEventKind.Crashed, dir: @"C:\Instanzen\Instance-Beta"));
SchluesselAus(_handler.LastBody!).ShouldNotBe(erster);
await reporter.ReportAsync(
Event(InstanceEventKind.Crashed, dir: @"C:\Instanzen\Instance-Beta"));
KeyFrom(_handler.LastBody!).ShouldNotBe(firstOne);
}
[Fact]
public async Task Der_Protokollauszug_kommt_mit()
public async Task The_log_excerpt_comes_along()
{
await Melder(_ => ["Startup.log: [17:26:50] [INF] Application is shutting down"])
.ReportAsync(Ereignis(InstanceEventKind.Crashed));
await Reporter(_ => ["Startup.log: [17:26:50] [INF] Application is shutting down"])
.ReportAsync(Event(InstanceEventKind.Crashed));
_handler.LastBody.ShouldNotBeNull().ShouldContain("Application is shutting down");
}
@@ -128,9 +128,9 @@ public sealed class SupervisionReporterTests
/// Exactly that was the state from #529.
/// </summary>
[Fact]
public async Task Ein_leerer_Protokollauszug_wird_ausdruecklich_gesagt()
public async Task An_empty_log_excerpt_is_stated_explicitly()
{
await Melder(_ => []).ReportAsync(Ereignis(InstanceEventKind.Crashed));
await Reporter(_ => []).ReportAsync(Event(InstanceEventKind.Crashed));
_handler.LastBody.ShouldNotBeNull().ShouldContain("KEINE");
}
@@ -141,20 +141,20 @@ public sealed class SupervisionReporterTests
/// own report.
/// </summary>
[Fact]
public async Task Ein_Serverfehler_reisst_die_Aufsicht_nicht_mit()
public async Task A_server_error_does_not_take_supervision_down()
{
var handler = new CapturingHandler(HttpStatusCode.InternalServerError,
"""{"status":"error","error":{"code":"boom","message":"kaputt"}}""");
var api = new DeploymentcenterApi("https://dc.example", "t", new HttpClient(handler));
var melder = new SupervisionReporter(
var reporter = new SupervisionReporter(
new BugtrackerClient(api, "clawddotnet", "production", "0.1.2"),
NullLogger.Instance, _ => []);
(await melder.ReportAsync(Ereignis(InstanceEventKind.Crashed))).ShouldBeFalse();
(await reporter.ReportAsync(Event(InstanceEventKind.Crashed))).ShouldBeFalse();
}
private static string SchluesselAus(string body)
private static string KeyFrom(string body)
{
using var doc = System.Text.Json.JsonDocument.Parse(body);
return doc.RootElement.GetProperty("client_ref").GetString()!;