Instanzmanager Stufe 3: Prozessaufsicht, Start und Stopp (#528)
Der Anlass des ganzen Vorhabens: Am 2026-09-08 hat sich eine Instanz aufgehaengt 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, ueber den sie haetten berichten sollen. Ab jetzt sieht jemand anders hin. Neu ist ClawdDotNet.Manager mit genau EINEM Projektverweis: Core. Das ist keine Sparsamkeit, sondern die halbe Architektur - App schleppt sechzehn Werkzeugprojekte hinter sich her, und "keine Werkzeuge im Manager" soll eine Eigenschaft des Bauplans sein und keine Absichtserklaerung. DIE ENTSCHEIDUNG, AN DER ALLES HAENGT: Die Aufsicht fragt den Socket, nicht die Prozessliste. Die Prozessliste sagt, dass etwas da ist. Sie sagt nicht, ob es noch arbeitet - und genau das war der Zustand, den niemand bemerkt hat. Ein Durchgang kennt drei Antworten: Socket antwortet, sie laeuft. Socket schweigt und der Prozess ist weg, Absturz mit Abschiedscode. Socket schweigt und der Prozess lebt, verstummt - aber erst nach mehreren Fehlversuchen in Folge, denn ein Alarm bei jedem Aussetzer ist nach einer Woche der Alarm, den niemand mehr liest. Ein Start gilt erst, wenn die Instanz antwortet. Zwischen "der Prozess laeuft" und "die Instanz laeuft" liegen Datenbank, Agenten und Kestrel; wer nur auf Process.Start wartet, haelt eine Instanz fuer gestartet, die gleich an einer kaputten Konfiguration scheitert. Angehalten wird durch Bitten. Neu auf der Instanzseite: POST /api/instanz/beenden, ausschliesslich ueber den Socket - ueber TCP gibt es den Endpunkt nicht, auch mit gueltigem Token nicht, und er antwortet dort 404 statt 403. Die Lebensdauer einer Instanz gehoert dem Manager; eine Browsersitzung soll den Prozess, der sie bedient, nicht beenden koennen. Ausgeloest wird der Wunsch erst nach dem Absenden der Antwort (Response.OnCompleted), sonst bekaeme der Manager statt einer Zusage einen Verbindungsabbruch und hielte ihn fuer einen Fehlschlag. Wiederfinden nach einem Managerausfall (E-M6): Die Zustandsdatei instance-state.json ist der Hinweis, der Socket ist der Beweis. Erst wird gefragt, wer da antwortet; nur bei gleicher Instanzkennung wird die Prozesskennung aus der Datei angefasst. Andersherum fuehrt eine wiederverwendete Nummer nach einem Neustart der Maschine geradewegs dazu, ein fremdes Programm zu beaufsichtigen und beim Anhalten zu beenden. Selbsttaetiger Neustart ist bewusst NICHT gebaut. Die Abnahmebedingung lautet "merkt es und meldet es", und zum Neustarten trifft das Konzept keine Entscheidung; eine Instanz in einer Schleife wieder hochzufahren, die jedes Mal abstuerzt, waere die falsche Vorgabe fuer etwas, das niemand entschieden hat. Als offene Frage aufgeschrieben. ABNAHME, mit echten Prozessen gemessen: Manager startet Instanz Test in 0,7 s, identifiziert sie ueber die Named Pipe, schreibt die Zustandsdatei (pid 54476, id d68af6fd). Prozess von aussen abgeschossen. Naechster Durchgang: Crashed, Code -1, meldenswert, Zustandsdatei weggeraeumt. Dazu 20 Tests gegen gesteuerte Prozesse - ein Absturz zum gewuenschten Zeitpunkt und eine wiederverwendete Prozesskennung sind mit einem echten Programm nicht herzustellen. Noch offen in dieser Stufe: Instanzen anlegen, Lizenz im Manager, Sub-Token je Instanz, und der Managerprozess selbst. Build ohne Warnungen, 737 Tests gruen, 8 uebersprungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
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));
|
||||
|
||||
public InstanceSupervisorTests() => Directory.CreateDirectory(_dir);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); }
|
||||
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
||||
}
|
||||
|
||||
private InstanceSupervisor Aufsicht()
|
||||
{
|
||||
var supervisor = new InstanceSupervisor(
|
||||
_dir, _launcher, _kanal, NullLogger.Instance, 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>
|
||||
/// 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;
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user