Files
ClawdDotNet/tests/ClawdDotNet.Manager.Tests/InstanceRegistryTests.cs
T
RichardandClaude Opus 5 72b0764d1a Instanzmanager: Register, Einstellungen und stellvertretende Meldung (#528)
Der zweite Teil von Stufe 3. Die Aufsicht ueber eine einzelne Instanz stand
schon; jetzt gibt es die Klammer darum und den Weg nach draussen.

InstanceRegistry haelt je Instanz eine Aufsicht. Zwei Zusagen, beide aus
E-M6: Beim Start wird UEBERNOMMEN, nicht gestartet - ein Manager, der beim
Hochkommen alles hochfaehrt, was er findet, startet nach einem Neustart der
Maschine auch das, was jemand mit Absicht angehalten hat. Und beim Beenden
wird nichts beendet: Wer den Manager schliesst, will den Manager
schliessen. Einzige Ausnahme ist StopAllAsync fuer einen Grund, der alle
betrifft - und der heisst Lizenzwiderruf.

SupervisionReporter macht aus einem Befund eine Bugtracker-Meldung,
stellvertretend: mit Prozesskennung, Abschiedscode und dem Ausschnitt aus
dem Instanzprotokoll, der noch auf der Platte liegt. Der Idempotenzschluessel
ist stabil je Instanz und Art - stuerzt dieselbe Instanz wiederholt ab,
zaehlt der Server hoch, statt den Bugtracker mit Zwillingen zu fuellen.

Ein leerer Protokollauszug ist dabei die aussagekraeftigste Zeile der
Meldung und wird ausdruecklich als solche geschrieben. Genau das war der
Zustand am 2026-09-08: Der Prozess wurde erschlagen, bevor sein
Schreibpuffer geleert war, und hinterliess nichts.

manager.json traegt die Felder aus Abschnitt 6 des Konzepts und sonst
nichts. Lizenzschluessel, Master-Token und Web-Token liegen darin
verschluesselt und zur Laufzeit im Klartext - dieselbe Bauart wie die
Anwendungseinstellungen, mit Absicht. Unsinnige Aufsichtswerte werden
eingefangen, weil die Datei von Hand bearbeitbar ist.

InstanceLayout ist nach Core gezogen (ResolveLogDirectory,
ResolveBackupDirectory). Der Manager braucht das Protokollverzeichnis einer
Instanz fuer den Auszug in der Meldung und darf ClawdDotNet.App nicht
kennen; dieselbe Rechnung an zwei Stellen zu fuehren waere die schlechtere
Antwort. AppHost leitet nur noch weiter.

EIN DEFEKT, den erst die eigenen Tests gezeigt haben: Eine angehaltene
Instanz erschien im Register als "Instance-Alpha" statt "Alpha". Die
Aufsicht kennt den Namen erst, wenn die Instanz einmal geantwortet hat, und
eine angehaltene tut das nie - waehrend genau diese Ansicht der Manager
anzeigt. Das Register fuehrt den Namen jetzt aus InstanceSettings.json mit.

Und ein Testdouble, das zu viel behauptet hat: Der falsche Socket
antwortete fuer jedes Verzeichnis dieselbe Instanz. Das laesst Tests gruen
werden, die im Betrieb zwei Instanzen verwechseln wuerden; er antwortet
jetzt je Verzeichnis.

29 neue Tests. Build ohne Warnungen, 766 Tests gruen, 8 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:45:22 +02:00

250 lines
8.2 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));
public InstanceRegistryTests()
=> _verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
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));
}
}