Files
ClawdDotNet/tests/ClawdDotNet.Manager.Tests/AutostartTests.cs
T
RichardandClaude Opus 5 7d4e8b3096 Autostart durch Manager, mit Sicherungsautomat (#528)
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>
2026-09-09 00:01:52 +02:00

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);
}
}