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:
co-authored by
Claude Opus 5.5
parent
837a634a22
commit
77062facc9
@@ -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);
|
||||
|
||||
@@ -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()!;
|
||||
|
||||
Reference in New Issue
Block a user