Entschieden vom Benutzer am 2026-09-08. Der Schalter heisst autostartByManager und steht in InstanceSettings.json - bei der Instanz, nicht beim Manager. ZWEI DINGE IN EINEM SCHALTER, weil es dieselbe Zusage ist: Der Manager startet diese Instanz, wenn er selbst hochkommt, und er startet sie wieder, wenn sie abstuerzt. Ohne den Schalter ruehrt er nichts an - ein Manager, der nach einem Neustart der Maschine alles hochfaehrt, was er findet, startet auch das, was jemand mit Absicht angehalten hat. Ausgeschaltet vorbelegt. DER SICHERUNGSAUTOMAT ist der eigentliche Inhalt. Wieder hochzufahren ist die einfache Haelfte; die schwierige ist zu erkennen, wann man es lassen muss. Nach drei Neustarts in zehn Minuten (maxRestartsInWindow, restartWindowMinutes) setzt der Manager aus und meldet es. Die Instanz bleibt dann aus, bis jemand sie von Hand startet - und dieser Start raeumt den Zaehler ab, denn wer von Hand startet, hat entweder etwas geaendert oder weiss, was er tut. Das Zeitfenster wandert: Eine Instanz, die einmal im Monat abstuerzt und wieder hochkommt, laeuft nie in die Grenze. Nur bei einem Absturz, nicht wenn eine Instanz verstummt. Eine stumme Instanz lebt noch; sie neu zu starten hiesse, sie erst abzuschiessen, und ob der Manager das von sich aus darf, ist nicht entschieden. Als offene Frage aufgeschrieben - genau dieser Zustand war der Anlass des Vorhabens. Der Schalter steht bei der Instanz, weil er eine Eigenschaft dieser Instanz ist und weil die Instanzdatei dem Instanzprozess gehoert: Sie vom Manager schreiben zu lassen, waehrend die Instanz laeuft, waeren zwei Schreiber auf einer Datei. Der Manager liest ihn nur; geaendert wird er auf der Einstellungsseite der Instanz. EIN DEFEKT, den erst diese Tests gezeigt haben: StartAsync gab "laeuft schon" zurueck, wenn die Aufsicht die Instanz fuer laufend hielt - ohne nachzusehen. Zwischen zwei Aufsichtsdurchgaengen liegen aber Sekunden, und in denen kann sie wegbrechen. Wer in dieser Luecke "starten" verlangte, bekam einen Erfolg, hinter dem nichts stand, und der naechste Durchgang meldete einen Absturz, den niemand mehr erwartete. Jetzt wird gefragt, bevor zugestimmt wird. UND EIN TESTDOUBLE, das die Wirklichkeit nicht abbildete: Der falsche Socket antwortete unabhaengig davon, ob ein Prozess lief. Damit war jeder Testaufbau, der die Antwort vor dem Hochfahren hinterlegte, stillschweigend ein anderer Test als beabsichtigt - eine Antwort sagt dem Manager "laeuft bereits", er uebernimmt, und der Starter wird nie angefasst. Dreimal passiert. Der falsche Socket haengt jetzt am falschen Starter. Sicherungen im Manager sind als #530 zurueckgestellt, mit Ausloeser. Vorab entschieden ist dort nur eines: Zurueckspielen ausschliesslich bei inaktiver Instanz. 19 neue Tests. Build ohne Warnungen, 793 Tests gruen, 8 uebersprungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
360 lines
13 KiB
C#
360 lines
13 KiB
C#
using System.Text.Json;
|
|
using ClawdDotNet.Core.Config;
|
|
using ClawdDotNet.Core.Instances;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Manager.Tests;
|
|
|
|
/// <summary>
|
|
/// „Autostart durch Manager" — der Schalter, der eine Instanz am Laufen hält (#528).
|
|
///
|
|
/// <para><b>Zwei Dinge in einem Schalter, weil es dieselbe Zusage ist:</b> Der Manager
|
|
/// startet die Instanz beim Hochkommen, und er startet sie nach einem Absturz wieder. Ohne
|
|
/// den Schalter rührt er nichts an — ein Manager, der nach einem Neustart der Maschine
|
|
/// alles hochfährt, was er findet, startet auch das, was jemand mit Absicht angehalten
|
|
/// hat.</para>
|
|
///
|
|
/// <para><b>Und der Sicherungsautomat ist der eigentliche Inhalt.</b> Wieder hochzufahren
|
|
/// ist die einfache Hälfte; die schwierige ist zu erkennen, wann man es lassen muss.</para>
|
|
/// </summary>
|
|
public sealed class AutostartTests : IAsyncDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(
|
|
Path.GetTempPath(), "clawd-tests", "auto-" + Guid.NewGuid().ToString("N")[..8]);
|
|
|
|
private readonly FakeLauncher _launcher = new();
|
|
private readonly FakeChannel _kanal = new();
|
|
private readonly FakeTokenSource _tokens = new();
|
|
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 AutostartTests()
|
|
{
|
|
Directory.CreateDirectory(_root);
|
|
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
|
|
|
|
// Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - sonst hiesse eine
|
|
// vorab hinterlegte Antwort "laeuft schon", und der Manager wuerde uebernehmen
|
|
// statt zu starten.
|
|
_kanal.Launcher = _launcher;
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await Task.CompletedTask;
|
|
try { Directory.Delete(_root, recursive: true); }
|
|
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
|
}
|
|
|
|
private ManagerCore Manager(RestartPolicy? policy = null)
|
|
{
|
|
var register = new InstanceRegistry(
|
|
_verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options);
|
|
|
|
return new ManagerCore(
|
|
new ManagerSettingsStore(_root), register, _tokens, NullLoggerFactory.Instance,
|
|
restartPolicy: policy);
|
|
}
|
|
|
|
/// <summary>Legt eine Instanz an und setzt den Schalter in ihrer Instanzdatei.</summary>
|
|
private string Instanz(string name, bool autostart)
|
|
{
|
|
var pfad = _verzeichnisse.CreateInstance(name);
|
|
var datei = Path.Combine(pfad, "InstanceSettings.json");
|
|
|
|
var config = JsonSerializer.Deserialize<InstanceConfig>(File.ReadAllText(datei))!;
|
|
config.AutostartByManager = autostart;
|
|
File.WriteAllText(datei, JsonSerializer.Serialize(config));
|
|
|
|
_kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-" + name, name, "0.1.2");
|
|
|
|
return pfad;
|
|
}
|
|
|
|
private static SupervisionEvent Absturz(string pfad, string name) => new(
|
|
InstanceEventKind.Crashed, Path.GetFullPath(pfad), name, 4711, 137,
|
|
"unerwartet beendet", DateTimeOffset.UtcNow);
|
|
|
|
// ─── Beim Hochkommen ───
|
|
|
|
[Fact]
|
|
public async Task Eine_Instanz_mit_Autostart_wird_beim_Hochfahren_gestartet()
|
|
{
|
|
Instanz("Alpha", autostart: true);
|
|
|
|
await using var manager = Manager();
|
|
await manager.StartAsync();
|
|
|
|
_launcher.Starts.ShouldBe(1);
|
|
manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Running);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ohne_Autostart_bleibt_sie_aus()
|
|
{
|
|
Instanz("Alpha", autostart: false);
|
|
|
|
await using var manager = Manager();
|
|
await manager.StartAsync();
|
|
|
|
_launcher.Starts.ShouldBe(0);
|
|
manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Was schon läuft, wird nicht noch einmal gestartet: Ein zweiter Prozess auf derselben
|
|
/// <c>state.db</c> wäre das Gegenteil von hilfreich.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Was_schon_laeuft_wird_nicht_noch_einmal_gestartet()
|
|
{
|
|
var pfad = Instanz("Alpha", autostart: true);
|
|
|
|
await using (var erster = Manager())
|
|
await erster.StartAsync();
|
|
|
|
_launcher.Starts.ShouldBe(1);
|
|
|
|
await using var zweiter = Manager();
|
|
await zweiter.StartAsync();
|
|
|
|
_launcher.Starts.ShouldBe(1, "Uebernommen, nicht neu gestartet.");
|
|
zweiter.Instances.Instances.Single().ProcessId.ShouldBe(_launcher.Last!.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Der_Schalter_steht_in_der_Liste()
|
|
{
|
|
Instanz("Mit", autostart: true);
|
|
Instanz("Ohne", autostart: false);
|
|
|
|
await using var manager = Manager();
|
|
await manager.StartAsync();
|
|
|
|
manager.Instances.Instances.Single(i => i.Name == "Mit").AutostartByManager.ShouldBeTrue();
|
|
manager.Instances.Instances.Single(i => i.Name == "Ohne").AutostartByManager.ShouldBeFalse();
|
|
}
|
|
|
|
// ─── Nach einem Absturz ───
|
|
|
|
[Fact]
|
|
public async Task Nach_einem_Absturz_wird_wieder_gestartet()
|
|
{
|
|
var pfad = Instanz("Alpha", autostart: true);
|
|
|
|
await using var manager = Manager();
|
|
await manager.StartAsync();
|
|
|
|
var vorher = _launcher.Starts;
|
|
|
|
// Der Prozess ist weg; die Aufsicht hat es gemeldet.
|
|
_launcher.Last!.Exit(137);
|
|
await manager.RestartAfterCrashAsync(Absturz(pfad, "Alpha"));
|
|
|
|
_launcher.Starts.ShouldBe(vorher + 1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ohne_Autostart_wird_nach_einem_Absturz_nichts_getan()
|
|
{
|
|
var pfad = Instanz("Alpha", autostart: false);
|
|
|
|
await using var manager = Manager();
|
|
await manager.StartAsync();
|
|
await manager.StartInstanceAsync(pfad);
|
|
|
|
_launcher.Last!.Exit(137);
|
|
await manager.RestartAfterCrashAsync(Absturz(pfad, "Alpha"));
|
|
|
|
_launcher.Starts.ShouldBe(1, "Nur der Start von Hand, kein Neustart.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Nach der Grenze setzt der Manager aus.
|
|
///
|
|
/// <para>Was an einer kaputten Konfiguration scheitert, scheitert beim zehnten Versuch
|
|
/// genauso — dann hat man statt einer toten Instanz eine tote Instanz, ein volles
|
|
/// Protokoll und einen Bugtracker voller Wiederholungen.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Nach_drei_Neustarts_im_Zeitfenster_setzt_der_Manager_aus()
|
|
{
|
|
var pfad = Instanz("Wackelig", autostart: true);
|
|
|
|
await using var manager = Manager(new RestartPolicy(MaxRestarts: 3, Window: TimeSpan.FromMinutes(10)));
|
|
await manager.StartAsync();
|
|
|
|
var nachStart = _launcher.Starts;
|
|
|
|
for (var i = 0; i < 6; i++)
|
|
{
|
|
_launcher.Last!.Exit(137);
|
|
await manager.RestartAfterCrashAsync(Absturz(pfad, "Wackelig"));
|
|
}
|
|
|
|
(_launcher.Starts - nachStart).ShouldBe(3, "Drei Versuche, dann Schluss.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wer von Hand startet, hat entweder etwas geändert oder weiß, was er tut. Ihm die
|
|
/// Versuche von vorhin anzurechnen hieße, ihn für den Absturz zu bestrafen.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Ein_Start_von_Hand_raeumt_den_Zaehler_ab()
|
|
{
|
|
var pfad = Instanz("Wackelig", autostart: true);
|
|
|
|
await using var manager = Manager(new RestartPolicy(3, TimeSpan.FromMinutes(10)));
|
|
await manager.StartAsync();
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
_launcher.Last!.Exit(137);
|
|
await manager.RestartAfterCrashAsync(Absturz(pfad, "Wackelig"));
|
|
}
|
|
|
|
var erschoepft = _launcher.Starts;
|
|
|
|
// Ein neuer Manager faengt bei null an - genau wie ein Start von Hand.
|
|
await using var frisch = Manager(new RestartPolicy(3, TimeSpan.FromMinutes(10)));
|
|
await frisch.StartAsync();
|
|
|
|
_launcher.Last!.Exit(137);
|
|
await frisch.RestartAfterCrashAsync(Absturz(pfad, "Wackelig"));
|
|
|
|
_launcher.Starts.ShouldBeGreaterThan(erschoepft);
|
|
}
|
|
}
|
|
|
|
/// <summary>Der Sicherungsautomat für sich genommen.</summary>
|
|
public sealed class RestartLedgerTests
|
|
{
|
|
private sealed class Uhr(DateTimeOffset jetzt) : TimeProvider
|
|
{
|
|
public DateTimeOffset Jetzt { get; set; } = jetzt;
|
|
public override DateTimeOffset GetUtcNow() => Jetzt;
|
|
}
|
|
|
|
[Fact]
|
|
public void Bis_zur_Grenze_ja_danach_nein()
|
|
{
|
|
var buch = new RestartLedger(new RestartPolicy(3, TimeSpan.FromMinutes(10)));
|
|
|
|
buch.TryRecord("/a", out _).ShouldBeTrue();
|
|
buch.TryRecord("/a", out _).ShouldBeTrue();
|
|
buch.TryRecord("/a", out var dritter).ShouldBeTrue();
|
|
dritter.ShouldBe(3);
|
|
|
|
buch.TryRecord("/a", out _).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Jede_Instanz_hat_ihr_eigenes_Konto()
|
|
{
|
|
var buch = new RestartLedger(new RestartPolicy(1, TimeSpan.FromMinutes(10)));
|
|
|
|
buch.TryRecord("/a", out _).ShouldBeTrue();
|
|
buch.TryRecord("/a", out _).ShouldBeFalse();
|
|
|
|
buch.TryRecord("/b", out _).ShouldBeTrue("Die eine Instanz sperrt die andere nicht.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Das Zeitfenster wandert: Eine Instanz, die einmal im Monat abstürzt und wieder
|
|
/// hochkommt, läuft nie in die Grenze — eine, die im Minutentakt fällt, sofort.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Alte_Versuche_fallen_aus_dem_Fenster()
|
|
{
|
|
var uhr = new Uhr(DateTimeOffset.UnixEpoch);
|
|
var buch = new RestartLedger(new RestartPolicy(2, TimeSpan.FromMinutes(10)), uhr);
|
|
|
|
buch.TryRecord("/a", out _).ShouldBeTrue();
|
|
buch.TryRecord("/a", out _).ShouldBeTrue();
|
|
buch.TryRecord("/a", out _).ShouldBeFalse();
|
|
|
|
uhr.Jetzt = uhr.Jetzt.AddMinutes(11);
|
|
|
|
buch.TryRecord("/a", out var wieder).ShouldBeTrue();
|
|
wieder.ShouldBe(1, "Die alten Versuche zaehlen nicht mehr mit.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Vergessen_setzt_zurueck()
|
|
{
|
|
var buch = new RestartLedger(new RestartPolicy(1, TimeSpan.FromMinutes(10)));
|
|
|
|
buch.TryRecord("/a", out _).ShouldBeTrue();
|
|
buch.TryRecord("/a", out _).ShouldBeFalse();
|
|
|
|
buch.Forget("/a");
|
|
|
|
buch.TryRecord("/a", out _).ShouldBeTrue();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ein Start, der auf einen veralteten Merkzettel trifft.
|
|
///
|
|
/// <para><b>Der Defekt, den erst die Autostart-Tests gezeigt haben.</b> Zwischen zwei
|
|
/// Aufsichtsdurchgängen liegen Sekunden, und in denen kann eine Instanz wegbrechen. Wer in
|
|
/// dieser Lücke „starten" verlangte, bekam „läuft schon" — einen Erfolg, hinter dem nichts
|
|
/// stand. Der nächste Durchgang meldete dann einen Absturz, den niemand mehr erwartete.</para>
|
|
/// </summary>
|
|
public sealed class StaleStateTests : IAsyncDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(
|
|
Path.GetTempPath(), "clawd-tests", "stale-" + Guid.NewGuid().ToString("N")[..8]);
|
|
|
|
private readonly FakeLauncher _launcher = new();
|
|
private readonly FakeChannel _kanal = new();
|
|
|
|
public StaleStateTests()
|
|
{
|
|
Directory.CreateDirectory(_root);
|
|
_kanal.Launcher = _launcher;
|
|
_kanal.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await Task.CompletedTask;
|
|
try { Directory.Delete(_root, recursive: true); }
|
|
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ein_Start_prueft_nach_statt_dem_eigenen_Zustand_zu_glauben()
|
|
{
|
|
var dir = Path.Combine(_root, "Instance-Alpha");
|
|
Directory.CreateDirectory(dir);
|
|
|
|
await using var aufsicht = new InstanceSupervisor(
|
|
dir, _launcher, _kanal, NullLogger.Instance,
|
|
new SupervisionOptions(
|
|
TimeSpan.FromSeconds(30), // absichtlich traege: kein Durchgang dazwischen
|
|
TimeSpan.FromMilliseconds(600),
|
|
3,
|
|
TimeSpan.FromMilliseconds(300)));
|
|
|
|
(await aufsicht.StartAsync()).ShouldBeTrue();
|
|
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
|
|
|
|
// Die Instanz bricht weg. Die Aufsicht hat es noch nicht bemerkt - der naechste
|
|
// Durchgang kaeme erst in dreissig Sekunden.
|
|
_launcher.Last!.Exit(137);
|
|
aufsicht.Condition.ShouldBe(InstanceCondition.Running, "Noch nichts bemerkt.");
|
|
|
|
(await aufsicht.StartAsync()).ShouldBeTrue();
|
|
|
|
_launcher.Starts.ShouldBe(2, "Es wurde wirklich gestartet, nicht nur zugestimmt.");
|
|
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
|
|
}
|
|
}
|