using System.Text.Json;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Instances;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Manager.Tests;
///
/// „Autostart durch Manager" — der Schalter, der eine Instanz am Laufen hält (#528).
///
/// Zwei Dinge in einem Schalter, weil es dieselbe Zusage ist: 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.
///
/// Und der Sicherungsautomat ist der eigentliche Inhalt. Wieder hochzufahren
/// ist die einfache Hälfte; die schwierige ist zu erkennen, wann man es lassen muss.
///
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);
}
/// Legt eine Instanz an und setzt den Schalter in ihrer Instanzdatei.
private string Instanz(string name, bool autostart)
{
var pfad = _verzeichnisse.CreateInstance(name);
var datei = Path.Combine(pfad, "InstanceSettings.json");
var config = JsonSerializer.Deserialize(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);
}
///
/// Was schon läuft, wird nicht noch einmal gestartet: Ein zweiter Prozess auf derselben
/// state.db wäre das Gegenteil von hilfreich.
///
[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.");
}
///
/// Nach der Grenze setzt der Manager aus.
///
/// 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.
///
[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.");
}
///
/// 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.
///
[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);
}
}
/// Der Sicherungsautomat für sich genommen.
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.");
}
///
/// 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.
///
[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();
}
}
///
/// Ein Start, der auf einen veralteten Merkzettel trifft.
///
/// Der Defekt, den erst die Autostart-Tests gezeigt haben. 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.
///
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);
}
}