using ClawdDotNet.Core.Instances; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Manager.Tests; /// /// Die Aufsicht über eine Instanz (#528, Stufe 3). /// /// Der Anlass. Am 2026-09-08 hat sich eine Instanz aufgehängt und wurde von /// Windows beendet. Zu diesem Vorgang steht keine einzige Zeile in den Instanzprotokollen, /// kein Bugtracker-Eintrag, kein Watchdog-Alarm — weil Meldeweg und Watchdog in genau dem /// Prozess lebten, über den sie hätten berichten sollen (#529). Die Abnahmebedingung für /// diese Stufe lautet deshalb: eine Instanz abschießen, und der Manager merkt es und /// meldet es. /// /// Der schwierigere Fall steht darunter: ein Prozess, der lebt und nicht /// mehr arbeitet. Den sieht keine Prozessliste — nur der Socket. /// public sealed class InstanceSupervisorTests : IDisposable { private readonly string _dir = Path.Combine( Path.GetTempPath(), "clawd-tests", "sup-" + Guid.NewGuid().ToString("N")[..8]); private readonly FakeLauncher _launcher = new(); private readonly FakeChannel _kanal = new(); private readonly List _ereignisse = []; private static readonly InstanceIdentity Test = new("i-1", "Test", "0.1.2"); /// /// Kurze Fristen: Der Test soll die Aufsicht prüfen, nicht die Geduld. /// /// Knapp über dem Abfragetakt von 250 ms, damit die Warteschleifen je einen /// Durchgang schaffen — es geht um die Entscheidung am Ende der Frist, nicht darum, /// wie oft dazwischen gefragt wurde. /// private static readonly SupervisionOptions Options = new( ProbeInterval: TimeSpan.FromMilliseconds(50), StartTimeout: TimeSpan.FromMilliseconds(600), SilentChecksBeforeAlarm: 3, StopGrace: TimeSpan.FromMilliseconds(300), SilentKillGrace: TimeSpan.FromMilliseconds(300), MaxDrainWait: TimeSpan.FromSeconds(2)); public InstanceSupervisorTests() { Directory.CreateDirectory(_dir); // Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im // Betrieb. Eine vorab hinterlegte Antwort hiesse sonst "laeuft schon", und die // Aufsicht wuerde uebernehmen statt zu starten. _kanal.Launcher = _launcher; } public void Dispose() { try { Directory.Delete(_dir, recursive: true); } catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } } private InstanceSupervisor Aufsicht(SupervisionOptions? options = null) { var supervisor = new InstanceSupervisor( _dir, _launcher, _kanal, NullLogger.Instance, options ?? Options); supervisor.Observed += _ereignisse.Add; return supervisor; } private IEnumerable Arten => _ereignisse.Select(e => e.Kind); // ─── Starten ─── [Fact] public async Task Ein_Start_gilt_erst_wenn_die_Instanz_antwortet() { _kanal.Answer = Test; await using var aufsicht = Aufsicht(); (await aufsicht.StartAsync()).ShouldBeTrue(); aufsicht.Condition.ShouldBe(InstanceCondition.Running); aufsicht.InstanceName.ShouldBe("Test"); Arten.ShouldBe([InstanceEventKind.Started]); var state = InstanceStateStore.Read(_dir).ShouldNotBeNull(); state.InstanceId.ShouldBe("i-1"); state.ProcessId.ShouldBe(_launcher.Last!.Id); state.ManagerProcessId.ShouldBe(Environment.ProcessId); } /// /// „Der Prozess läuft" ist nicht dasselbe wie „die Instanz läuft". /// /// Zwischen beidem liegen Datenbank, Agenten und Kestrel. Wer nur auf /// Process.Start wartet, hält eine Instanz für gestartet, die gleich an einer /// kaputten Konfiguration scheitert. /// [Fact] public async Task Ein_Prozess_der_sich_nie_meldet_gilt_nicht_als_gestartet() { _kanal.Answer = null; // laeuft, sagt aber nichts await using var aufsicht = Aufsicht(); (await aufsicht.StartAsync()).ShouldBeFalse(); aufsicht.Condition.ShouldBe(InstanceCondition.Stopped); Arten.ShouldBe([InstanceEventKind.StartFailed]); // Nicht stehen lassen: Ein Prozess, der laeuft und schweigt, haelt den Socket // besetzt und blockiert damit den naechsten Versuch. _launcher.Last!.WasKilled.ShouldBeTrue(); InstanceStateStore.Read(_dir).ShouldBeNull(); } [Fact] public async Task Ein_Prozess_der_gar_nicht_startet_meldet_das() { _launcher.FailStartWith = new InvalidOperationException("Programmdatei fehlt"); await using var aufsicht = Aufsicht(); (await aufsicht.StartAsync()).ShouldBeFalse(); Arten.ShouldBe([InstanceEventKind.StartFailed]); _ereignisse[0].Message.ShouldContain("Programmdatei fehlt"); } // ─── Der Fall, um den es geht ─── [Fact] public async Task Ein_abgeschossener_Prozess_wird_bemerkt_und_gemeldet() { _kanal.Answer = Test; await using var aufsicht = Aufsicht(); await aufsicht.StartAsync(); // Jemand schiesst die Instanz ab. Niemand hat darum gebeten. _launcher.Last!.Exit(code: 137); _kanal.Answer = null; await aufsicht.CheckAsync(); aufsicht.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."); // Und die Zustandsdatei zeigt nicht mehr auf einen Prozess, den es nicht gibt. InstanceStateStore.Read(_dir).ShouldBeNull(); } /// /// Der schwierigere Fall: Der Prozess lebt und arbeitet nicht mehr. /// /// Genau das war der Zustand am 2026-09-08 — die Anwendung stand einwandfrei in /// der Prozessliste, bis Windows sie als „reagiert nicht mehr" beendet hat. Eine /// Aufsicht, die nur Prozesse zählt, sieht davon nichts. /// [Fact] public async Task Ein_lebender_aber_stummer_Prozess_faellt_auf() { _kanal.Answer = Test; await using var aufsicht = Aufsicht(); await aufsicht.StartAsync(); _kanal.Answer = null; // haengt - der Prozess lebt weiter // Nicht beim ersten Aussetzer: Der waere Alltag, und ein Alarm bei jedem // Aussetzer ist nach einer Woche der Alarm, den niemand mehr liest. await aufsicht.CheckAsync(); await aufsicht.CheckAsync(); Arten.ShouldNotContain(InstanceEventKind.Unresponsive); aufsicht.Condition.ShouldBe(InstanceCondition.Running); await aufsicht.CheckAsync(); aufsicht.Condition.ShouldBe(InstanceCondition.Silent); var alarm = _ereignisse.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() { _kanal.Answer = Test; await using var aufsicht = Aufsicht(); await aufsicht.StartAsync(); _kanal.Answer = null; for (var i = 0; i < 20; i++) await aufsicht.CheckAsync(); _ereignisse.Count(e => e.Kind == InstanceEventKind.Unresponsive).ShouldBe(1); } [Fact] public async Task Wer_wieder_antwortet_wird_wieder_als_laufend_gefuehrt() { _kanal.Answer = Test; await using var aufsicht = Aufsicht(); await aufsicht.StartAsync(); _kanal.Answer = null; for (var i = 0; i < 3; i++) await aufsicht.CheckAsync(); aufsicht.Condition.ShouldBe(InstanceCondition.Silent); _kanal.Answer = Test; await aufsicht.CheckAsync(); aufsicht.Condition.ShouldBe(InstanceCondition.Running); Arten.ShouldContain(InstanceEventKind.Recovered); } // ─── Anhalten ─── [Fact] public async Task Ein_erbetenes_Ende_ist_kein_Absturz() { _kanal.Answer = Test; await using var aufsicht = Aufsicht(); await aufsicht.StartAsync(); // Die Instanz endet auf die Bitte hin - so wie im Betrieb. _launcher.Last!.Exit(code: 0); _kanal.Answer = null; (await aufsicht.StopAsync()).ShouldBeTrue(); _kanal.StopRequests.ShouldBe(1, "Erst bitten, nicht schiessen."); _launcher.Last.WasKilled.ShouldBeFalse(); aufsicht.Condition.ShouldBe(InstanceCondition.Stopped); Arten.ShouldNotContain(InstanceEventKind.Crashed); _ereignisse.Last().Kind.ShouldBe(InstanceEventKind.StoppedOrderly); _ereignisse.Last().WorthReporting.ShouldBeFalse(); } [Fact] public async Task Wer_nicht_hoert_wird_nach_der_Frist_beendet() { _kanal.Answer = Test; await using var aufsicht = Aufsicht(); await aufsicht.StartAsync(); // Antwortet weiter und endet nicht - die Bitte verhallt. _kanal.StopRequestArrives = false; (await aufsicht.StopAsync()).ShouldBeTrue(); _kanal.StopRequests.ShouldBe(1); _launcher.Last!.WasKilled.ShouldBeTrue(); aufsicht.Condition.ShouldBe(InstanceCondition.Stopped); } /// /// Schweigen ist kein Todesnachweis, solange ein Prozesshandle das Gegenteil sagt. /// /// Hier lag der gefährlichste Fehlschluss. Die Warteschleife brach ab, /// sobald der Socket nicht mehr antwortete, meldete „beendet" und gab true /// zurück — ohne den Prozess anzufassen. Bei einer verstummten Instanz ist der Socket /// aber von vornherein still: Der Aufruf meldete sofort Erfolg, der Prozess lief /// weiter, und der Neustart setzte einen zweiten auf dieselbe state.db. Genau /// der Schaden, gegen den dieses Vorhaben gebaut ist. /// [Fact] public async Task Ein_stummer_aber_lebender_Prozess_gilt_nicht_als_beendet() { _kanal.Answer = Test; await using var aufsicht = Aufsicht(); await aufsicht.StartAsync(); var prozess = _launcher.Last.ShouldNotBeNull(); // Sie haengt: Der Socket schweigt, der Prozess lebt. _kanal.Verstummen(_dir); (await aufsicht.StopAsync()).ShouldBeTrue(); prozess.WasKilled.ShouldBeTrue( "Wer schweigt und lebt, muss beendet werden - nicht fuer beendet erklaert."); prozess.HasExited.ShouldBeTrue(); aufsicht.Condition.ShouldBe(InstanceCondition.Stopped); } /// /// Wer aufräumt, wird nicht abgeschossen — die Frist wird nachgezogen. /// /// Die Instanz gibt sich selbst eine Frist zum Auslaufen. Wäre die des Managers /// kürzer, schösse er mitten in das Aufräumen hinein, um das er gerade gebeten /// hat. /// [Fact] public async Task Wer_aufraeumt_bekommt_die_Frist_nachgezogen() { _kanal.Answer = Test; // #722: Die Aufsicht fragt alle 250 ms und schiebt die Frist nur bei "raeume auf" weiter. // Mit 300 ms Frist kam unter Parallellast die naechste Frage zu spaet, und der Test sah // einen Abschuss, wo keiner hingehoert. Eine Sekunde Frist laesst dem Takt Luft; die // Instanz raeumt trotzdem deutlich laenger auf, als die Frist allein erlaubt. await using var aufsicht = Aufsicht(Options with { StopGrace = TimeSpan.FromSeconds(1), MaxDrainWait = TimeSpan.FromSeconds(20) }); await aufsicht.StartAsync(); var prozess = _launcher.Last.ShouldNotBeNull(); // Sie antwortet und meldet: Ich raeume auf. Laenger als StopGrace (1 s), aber // kuerzer als MaxDrainWait. _kanal.Answer = Test with { Phase = ShutdownPhase.Draining, OpenWork = 1 }; _kanal.OnStopRequest = _ => Task.Run(async () => { await Task.Delay(2500); prozess.Exit(0); _kanal.Answer = null; }); (await aufsicht.StopAsync()).ShouldBeTrue(); prozess.WasKilled.ShouldBeFalse("Wer aufraeumt, wird nicht abgeschossen."); aufsicht.Condition.ShouldBe(InstanceCondition.Stopped); } /// /// Eine Instanz, die der Manager nicht selbst gestartet hat und die nicht hört, kann /// er auch nicht beenden — und das muss er sagen, statt Erfolg zu melden. /// /// Der Fall entsteht nach einem Managerneustart, wenn die Prozesskennung nicht /// mehr zu greifen ist (siehe ). /// [Fact] public async Task Was_nicht_zu_fassen_ist_wird_nicht_als_beendet_gemeldet() { _kanal.Answer = Test; _kanal.StopRequestArrives = false; // Sie lief schon, bevor dieser Manager kam - und ohne Zustandsdatei ist ihr // Prozess fuer ihn nicht zu greifen. _launcher.Fremdstart(_dir, 4711); await using var aufsicht = Aufsicht(); await aufsicht.ReattachAsync(); // uebernommen, aber ohne Prozess aufsicht.ProcessId.ShouldBeNull(); (await aufsicht.StopAsync()).ShouldBeFalse(); aufsicht.Condition.ShouldBe(InstanceCondition.Running); Arten.ShouldNotContain(InstanceEventKind.StoppedOrderly); } // ─── Wiederfinden (E-M6) ─── [Fact] public async Task Eine_laufende_Instanz_wird_nach_einem_Managerneustart_uebernommen() { _kanal.Answer = Test; // Erster Manager startet sie. await using (var erster = Aufsicht()) await erster.StartAsync(); _ereignisse.Clear(); // Zweiter Manager, derselbe Rechner, dieselbe laufende Instanz. await using var zweiter = Aufsicht(); (await zweiter.ReattachAsync()).ShouldBeTrue(); zweiter.Condition.ShouldBe(InstanceCondition.Running); zweiter.InstanceName.ShouldBe("Test"); zweiter.ProcessId.ShouldBe(_launcher.Last!.Id); // Uebernehmen ist kein Starten - es gibt nichts zu melden. _ereignisse.ShouldBeEmpty(); _launcher.Starts.ShouldBe(1); } /// /// Eine wiederverwendete Prozesskennung darf nicht zur Übernahme eines fremden /// Programms führen. /// /// Nach einem Neustart der Maschine gehört 4711 mit einiger Wahrscheinlichkeit /// etwas ganz anderem. Es dann zu beaufsichtigen — und beim Anhalten zu beenden — /// wäre deutlich schlimmer, als die Instanz gar nicht wiederzufinden. /// [Fact] public async Task Eine_wiederverwendete_Prozesskennung_wird_nicht_fuer_die_Instanz_gehalten() { _kanal.Answer = Test; await using (var erster = Aufsicht()) await erster.StartAsync(); var alte = _launcher.Last!; // Dieselbe Kennung, viel spaeter gestartet: ein anderer Prozess. _launcher.Running[alte.Id] = new FakeProcess(alte.Id, alte.StartedAt.AddHours(3)); await using var zweiter = Aufsicht(); (await zweiter.ReattachAsync()).ShouldBeTrue("Der Socket antwortet ja."); zweiter.ProcessId.ShouldBeNull( "Der fremde Prozess darf nicht als der eigene uebernommen werden."); } [Fact] public async Task Eine_Zustandsdatei_ohne_laufende_Instanz_wird_weggeraeumt() { _kanal.Answer = Test; await using (var erster = Aufsicht()) await erster.StartAsync(); InstanceStateStore.Read(_dir).ShouldNotBeNull(); // Der Manager war weg, die Instanz ist inzwischen auch weg. _kanal.Answer = null; await using var zweiter = Aufsicht(); (await zweiter.ReattachAsync()).ShouldBeFalse(); zweiter.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() { // Der Fall nach einem Absturz des Managers, bevor er schreiben konnte - oder // nachdem jemand die Datei geloescht hat. Der Socket antwortet, also laeuft sie. _kanal.Answer = Test; _launcher.Fremdstart(_dir, 4711); await using var aufsicht = Aufsicht(); (await aufsicht.ReattachAsync()).ShouldBeTrue(); aufsicht.Condition.ShouldBe(InstanceCondition.Running); aufsicht.ProcessId.ShouldBeNull("Es gibt keinen Prozess, den dieser Manager kennt."); InstanceStateStore.Read(_dir).ShouldNotBeNull(); } // ─── Was gemeldet wird und was nicht ─── /// /// Der Manager meldet, was aus seiner Sicht relevant ist — und nur das (Konzept, /// Abschnitt 4). Was die Instanz betrifft, bleibt in der Instanz und geht mit ihrem /// eigenen Sub-Token raus. /// [Theory] [InlineData(InstanceEventKind.Crashed, true)] [InlineData(InstanceEventKind.Unresponsive, true)] [InlineData(InstanceEventKind.StartFailed, true)] [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) { new SupervisionEvent(kind, _dir, "Test", 1, null, "", DateTimeOffset.UtcNow) .WorthReporting.ShouldBe(erwartet); } }