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>
257 lines
8.5 KiB
C#
257 lines
8.5 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.
|
|
var prozess = _launcher.Last!;
|
|
_kanal.OnStopRequest = _ => { _kanal.Answer = null; prozess.Exit(0); };
|
|
|
|
(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);
|
|
|
|
var prozess = _launcher.Last!;
|
|
_kanal.OnStopRequest = _ => { _kanal.Answer = null; prozess.Exit(0); };
|
|
|
|
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));
|
|
}
|
|
}
|