Files
ClawdDotNet/tests/ClawdDotNet.Manager.Tests/InstanceRegistryTests.cs
T
RichardandClaude Opus 5 dcffcdcff5 Instanzmanager: Weboberflaeche kennt Instanzen, geordnetes Ende, Puls (#528, #531)
Weboberflaeche (#531): X-Instanz an der einen fetch-Stelle, Instanzauswahl in der
Kopfzeile, Verwaltungsansicht mit Anlegen/Starten/Anhalten/Fenster, Herkunft der
Ereignisse, Sicherungs- und Einstellungsansicht. 402/409/502 werden eigen behandelt.

Sicherungen im Manager (#530): Auflisten, Anlegen, Wiederherstellen, Loeschen. Das
Zurueckspielen haelt die Instanz vorher an und startet sie danach wieder.

Geordnetes Ende (Konzept 9.8). Zwei Befunde waren der Anlass:

Der Manager konnte eine Instanz MIT FENSTER nie geordnet beenden.
Control.ShutdownRequested war nur in HeadlessRun verdrahtet, nicht in der
Avalonia-Anwendung - und ein Fenster ist die Vorgabe. Die Bitte wurde mit 202
beantwortet, niemand hoerte zu, und der Manager schoss den Prozess nach Ablauf
seiner Frist jedes Mal ab.

InstanceSupervisor.StopAsync hielt Socket-Schweigen fuer einen Todesnachweis und
meldete "beendet", ohne den Prozess anzufassen. Bei einer verstummten Instanz ist
der Socket von vornherein still - der Neustart haette einen zweiten Prozess auf
dieselbe state.db gesetzt. Genau der Schaden, gegen den #528 gebaut ist.

- ShutdownCoordinator: erst die Tuer schliessen, dann auf offene Arbeiten warten,
  Frist zwei Minuten. Vorher brach TaskScanner.DisposeAsync mitten in ProcessAsync
  ab, sodass die Aufgabe bis zum Lease-Ablauf auf "running" stehenblieb.
- GET /api/instanz/puls hinter dem Socket-Gate: Phase und offene Arbeiten. Weiter
  gefragt statt gemeldet - ein Puls, den die Instanz von sich aus schickt, fehlt
  genau dann, wenn sie haengt.
- Eskalationsleiter: drei Durchgaenge ohne Antwort, einmal um ein Ende bitten,
  nach 30s beenden und den Vollzug abwarten, gemeldet als Absturz.
- EnsureDeadAsync vor jedem Start.

Weiteres aus dem Review:
- .gitignore verankert: "Instances/" passte auch auf src/ClawdDotNet.Core/Instances/.
  InstanceEndpoint.cs und InstanceLayout.cs waren dadurch nie eingecheckt.
- Das WebUI-Token stand im Protokoll des Managers. Konsole ja, Datei nein.
- Ein Klick auf "Instanz anlegen" loeste requestSubmit() zweimal aus.
- [Obsolete] auf WebServerPort wieder entfernt: sechs Warnungen, die niemand
  abstellen konnte, solange E-M5 nicht fertig ist.
- Die Wiederherstellung bricht nicht mehr ab, wenn der Browser weggeht.
- Entwicklungspfade zum Instanzmanager aus Segmenten statt mit Rueckstrichen, und
  der Port kommt aus manager.json statt fest aus der Zeile.

Offen und gemeldet: --managed hebelt die Lizenzpruefung aus (#535).

806 Tests gruen, 0 Warnungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 11:15:59 +02:00

257 lines
8.4 KiB
C#

using ClawdDotNet.Core.Instances;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Manager.Tests;
/// <summary>
/// Das Register aller Instanzen einer Maschine (#528, Stufe 3).
///
/// <para>Zwei Zusagen stehen hier über allem, und beide folgen aus E-M6: <b>Beim Start
/// wird übernommen, nicht gestartet</b>, und <b>beim Beenden wird nichts beendet.</b>
/// Fällt der Manager aus, laufen die Instanzen weiter — er darf nicht der Einzelpunkt
/// sein, gegen den dieses ganze Vorhaben gebaut wird.</para>
/// </summary>
public sealed class InstanceRegistryTests : IAsyncDisposable
{
private readonly string _root = Path.Combine(
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 InstanceDirectoryManager _verzeichnisse;
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 InstanceRegistryTests()
{
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
// Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
// Betrieb (siehe FakeChannel.Launcher).
_kanal.Launcher = _launcher;
}
private InstanceRegistry Register()
{
var register = new InstanceRegistry(
_verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options);
register.Observed += _ereignisse.Add;
return register;
}
public async ValueTask DisposeAsync()
{
await Task.CompletedTask;
try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
}
// ─── Was da ist ───
[Fact]
public async Task Ein_leeres_Instanzenverzeichnis_ergibt_ein_leeres_Register()
{
await using var register = Register();
await register.RefreshAsync();
register.Instances.ShouldBeEmpty();
}
[Fact]
public async Task Angelegte_Instanzen_stehen_im_Register()
{
_verzeichnisse.CreateInstance("Alpha");
_verzeichnisse.CreateInstance("Beta");
await using var register = Register();
await register.RefreshAsync();
register.Instances.Select(i => i.Name).ShouldBe(["Alpha", "Beta"]);
register.Instances.ShouldAllBe(i => i.Condition == InstanceCondition.Stopped);
}
/// <summary>
/// Ein Manager, der beim Hochkommen alles startet, was er findet, würde nach einem
/// Neustart der Maschine auch das starten, was jemand mit Absicht angehalten hat.
/// </summary>
[Fact]
public async Task Beim_Nachsehen_wird_nichts_von_selbst_gestartet()
{
_verzeichnisse.CreateInstance("Alpha");
await using var register = Register();
await register.RefreshAsync();
_launcher.Starts.ShouldBe(0);
_ereignisse.ShouldBeEmpty();
}
[Fact]
public async Task Eine_weggeraeumte_Instanz_faellt_aus_dem_Register()
{
var pfad = _verzeichnisse.CreateInstance("Vergaenglich");
await using var register = Register();
await register.RefreshAsync();
register.Instances.Count.ShouldBe(1);
Directory.Delete(pfad, recursive: true);
await register.RefreshAsync();
register.Instances.ShouldBeEmpty();
}
// ─── Anlegen, starten, anhalten ───
[Fact]
public async Task Eine_neu_angelegte_Instanz_ist_sofort_im_Register_und_laeuft_nicht()
{
await using var register = Register();
var pfad = register.Create("Frisch");
Directory.Exists(pfad).ShouldBeTrue();
File.Exists(Path.Combine(pfad, "InstanceSettings.json")).ShouldBeTrue();
var instanz = register.Instances.ShouldHaveSingleItem();
instanz.Condition.ShouldBe(InstanceCondition.Unknown);
_launcher.Starts.ShouldBe(0);
}
[Fact]
public async Task Starten_und_Anhalten_gehen_ueber_das_Register()
{
_kanal.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
await using var register = Register();
var pfad = register.Create("Alpha");
(await register.StartAsync(pfad)).ShouldBeTrue();
register.Instances.Single().Condition.ShouldBe(InstanceCondition.Running);
// Die Instanz endet auf die Bitte hin.
_launcher.Last!.Exit(0);
_kanal.Answer = null;
(await register.StopAsync(pfad)).ShouldBeTrue();
register.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped);
}
[Fact]
public async Task Ein_unbekanntes_Verzeichnis_wird_nicht_stillschweigend_angelegt()
{
await using var register = Register();
await Should.ThrowAsync<KeyNotFoundException>(
() => register.StartAsync(Path.Combine(_root, "gibtsnicht")));
}
// ─── E-M6 ───
[Fact]
public async Task Laufende_Instanzen_werden_beim_Nachsehen_uebernommen()
{
string pfad;
// Erster Manager legt zwei an und startet nur eine.
await using (var erster = Register())
{
pfad = erster.Create("Alpha");
erster.Create("Beta");
// Nur Alpha antwortet. Eine Auskunft, die fuer jedes Verzeichnis dieselbe
// Instanz nennt, wuerde hier gruen werden und im Betrieb zwei Instanzen
// verwechseln.
_kanal.AnswerFor[Path.GetFullPath(pfad)] =
new InstanceIdentity("i-1", "Alpha", "0.1.2");
(await erster.StartAsync(pfad)).ShouldBeTrue();
}
_ereignisse.Clear();
// Zweiter Manager, derselbe Rechner.
await using var zweiter = Register();
await zweiter.RefreshAsync();
var alpha = zweiter.Instances.Single(i => i.Name == "Alpha");
alpha.Condition.ShouldBe(InstanceCondition.Running);
alpha.ProcessId.ShouldBe(_launcher.Last!.Id);
// Beta antwortet nicht und bleibt aus - sie wird nicht mit hochgezogen.
zweiter.Instances.Single(i => i.Name == "Beta").Condition
.ShouldBe(InstanceCondition.Stopped);
// Uebernehmen ist kein Starten.
_launcher.Starts.ShouldBe(1);
_ereignisse.ShouldBeEmpty();
}
/// <summary>
/// Wer den Manager schließt, will den Manager schließen — nicht seine Instanzen.
/// </summary>
[Fact]
public async Task Das_Beenden_des_Managers_beendet_keine_Instanz()
{
_kanal.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
var register = Register();
var pfad = register.Create("Alpha");
await register.StartAsync(pfad);
await register.DisposeAsync();
_kanal.StopRequests.ShouldBe(0);
_launcher.Last!.WasKilled.ShouldBeFalse();
_launcher.Last.HasExited.ShouldBeFalse();
// Und die Zustandsdatei bleibt liegen - sie ist der Hinweis fuer den naechsten.
InstanceStateStore.Read(pfad).ShouldNotBeNull();
}
/// <summary>
/// Die eine Ausnahme: Ein Lizenzwiderruf ist kein Ausfall, sondern ein Urteil.
/// </summary>
[Fact]
public async Task Bei_einem_Grund_der_alle_betrifft_werden_alle_angehalten()
{
_kanal.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
await using var register = Register();
var a = register.Create("Alpha");
await register.StartAsync(a);
_launcher.Last!.Exit(0);
_kanal.Answer = null;
await register.StopAllAsync("Lizenz widerrufen");
register.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped);
_kanal.StopRequests.ShouldBe(1);
}
// ─── Vorkommnisse ───
[Fact]
public async Task Vorkommnisse_einzelner_Instanzen_kommen_beim_Register_an()
{
_kanal.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
await using var register = Register();
var pfad = register.Create("Alpha");
await register.StartAsync(pfad);
_ereignisse.Select(e => e.Kind).ShouldBe([InstanceEventKind.Started]);
_ereignisse[0].InstanceDirectory.ShouldBe(Path.GetFullPath(pfad));
}
}