Drei Manager-Tests liessen den Prozess vor der Stopp-Bitte enden; unter Last sah der Aufsichtstakt das zuerst und meldete Crashed statt Stopped. Die FakeChannel bekommt OnStopRequest, die Tests beenden darin. Der Drain-Test bekommt 20 s statt 2 s Luft. AtomicFile schreibt, bis der Leser 20 Blicke hatte, statt fest 40 Mal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
472 lines
17 KiB
C#
472 lines
17 KiB
C#
using ClawdDotNet.Core.Instances;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Manager.Tests;
|
|
|
|
/// <summary>
|
|
/// Die Aufsicht über eine Instanz (#528, Stufe 3).
|
|
///
|
|
/// <para><b>Der Anlass.</b> 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: <b>eine Instanz abschießen, und der Manager merkt es und
|
|
/// meldet es.</b></para>
|
|
///
|
|
/// <para>Der schwierigere Fall steht darunter: ein Prozess, der <em>lebt</em> und nicht
|
|
/// mehr arbeitet. Den sieht keine Prozessliste — nur der Socket.</para>
|
|
/// </summary>
|
|
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<SupervisionEvent> _ereignisse = [];
|
|
|
|
private static readonly InstanceIdentity Test = new("i-1", "Test", "0.1.2");
|
|
|
|
/// <summary>
|
|
/// Kurze Fristen: Der Test soll die Aufsicht prüfen, nicht die Geduld.
|
|
///
|
|
/// <para>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.</para>
|
|
/// </summary>
|
|
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<InstanceEventKind> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// „Der Prozess läuft" ist nicht dasselbe wie „die Instanz läuft".
|
|
///
|
|
/// <para>Zwischen beidem liegen Datenbank, Agenten und Kestrel. Wer nur auf
|
|
/// <c>Process.Start</c> wartet, hält eine Instanz für gestartet, die gleich an einer
|
|
/// kaputten Konfiguration scheitert.</para>
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Der schwierigere Fall: Der Prozess lebt und arbeitet nicht mehr.
|
|
///
|
|
/// <para>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.</para>
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Schweigen ist kein Todesnachweis, solange ein Prozesshandle das Gegenteil sagt.
|
|
///
|
|
/// <para><b>Hier lag der gefährlichste Fehlschluss.</b> Die Warteschleife brach ab,
|
|
/// sobald der Socket nicht mehr antwortete, meldete „beendet" und gab <c>true</c>
|
|
/// 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 <c>state.db</c>. Genau
|
|
/// der Schaden, gegen den dieses Vorhaben gebaut ist.</para>
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wer aufräumt, wird nicht abgeschossen — die Frist wird nachgezogen.
|
|
///
|
|
/// <para>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.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Wer_aufraeumt_bekommt_die_Frist_nachgezogen()
|
|
{
|
|
_kanal.Answer = Test;
|
|
|
|
// #722: Viel Luft bis MaxDrainWait — unter Parallellast kam die Verzoegerung von 700 ms
|
|
// sonst erst nach den 2 s an, und der Test sah einen Abschuss, wo keiner hingehoert.
|
|
await using var aufsicht = Aufsicht(Options with { MaxDrainWait = TimeSpan.FromSeconds(20) });
|
|
await aufsicht.StartAsync();
|
|
|
|
var prozess = _launcher.Last.ShouldNotBeNull();
|
|
|
|
// Sie antwortet und meldet: Ich raeume auf. Laenger als StopGrace (300ms), aber
|
|
// kuerzer als MaxDrainWait.
|
|
_kanal.Answer = Test with { Phase = ShutdownPhase.Draining, OpenWork = 1 };
|
|
|
|
_kanal.OnStopRequest = _ => Task.Run(async () =>
|
|
{
|
|
await Task.Delay(700);
|
|
_kanal.Answer = null;
|
|
prozess.Exit(0);
|
|
});
|
|
|
|
(await aufsicht.StopAsync()).ShouldBeTrue();
|
|
|
|
prozess.WasKilled.ShouldBeFalse("Wer aufraeumt, wird nicht abgeschossen.");
|
|
aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
///
|
|
/// <para>Der Fall entsteht nach einem Managerneustart, wenn die Prozesskennung nicht
|
|
/// mehr zu greifen ist (siehe <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()
|
|
{
|
|
_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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Eine wiederverwendete Prozesskennung darf nicht zur Übernahme eines fremden
|
|
/// Programms führen.
|
|
///
|
|
/// <para>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.</para>
|
|
/// </summary>
|
|
[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 ───
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
}
|