Files
ClawdDotNet/tests/ClawdDotNet.Manager.Tests/Doubles.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

162 lines
5.8 KiB
C#

namespace ClawdDotNet.Manager.Tests;
/// <summary>
/// Ein Instanzprozess, den der Test steuert.
///
/// <para>Ein echter Prozess ließe die Aufsicht nur oberflächlich prüfen: Ein Absturz zum
/// gewünschten Zeitpunkt, ein Prozess, der lebt und schweigt, eine wiederverwendete
/// Prozesskennung — das alles ist mit einem laufenden Programm entweder nicht oder nur
/// mit Wartezeiten herzustellen, und Tests mit Wartezeiten sind Tests, die man
/// irgendwann abschaltet.</para>
/// </summary>
internal sealed class FakeProcess(int id, DateTimeOffset startedAt) : IInstanceProcess
{
public int Id { get; } = id;
public DateTimeOffset StartedAt { get; } = startedAt;
public bool HasExited { get; private set; }
public int? ExitCode { get; private set; }
public bool WasKilled { get; private set; }
public bool Disposed { get; private set; }
/// <summary>Der Prozess endet — mit oder ohne Zutun.</summary>
public void Exit(int code)
{
HasExited = true;
ExitCode = code;
}
public void Kill()
{
WasKilled = true;
if (!HasExited) Exit(-1);
}
public void Dispose() => Disposed = true;
}
internal sealed class FakeLauncher : IInstanceLauncher
{
private int _nextId = 1000;
public FakeProcess? Last { get; private set; }
public int Starts { get; private set; }
/// <summary>Welcher Prozess zu welchem Instanzverzeichnis gehört.</summary>
public Dictionary<string, FakeProcess> ByDirectory { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>Ob für dieses Verzeichnis gerade ein Prozess lebt.</summary>
public bool IsRunning(string instanceDirectory)
=> ByDirectory.TryGetValue(Path.GetFullPath(instanceDirectory), out var p)
&& !p.HasExited;
/// <summary>Wenn gesetzt, scheitert der Start damit.</summary>
public Exception? FailStartWith { get; set; }
/// <summary>Was <see cref="Attach"/> liefern soll — bereits laufende Prozesse.</summary>
public Dictionary<int, FakeProcess> Running { get; } = [];
public DateTimeOffset Now { get; set; } = DateTimeOffset.UnixEpoch;
public IInstanceProcess Start(string instanceDirectory)
{
Starts++;
if (FailStartWith is not null) throw FailStartWith;
Last = new FakeProcess(_nextId++, Now);
Running[Last.Id] = Last;
ByDirectory[Path.GetFullPath(instanceDirectory)] = Last;
return Last;
}
public IInstanceProcess? Attach(int processId, DateTimeOffset startedAt)
{
if (!Running.TryGetValue(processId, out var process))
return null;
// Dieselbe Pruefung wie im Echtbetrieb: gleiche Kennung, aber andere Startzeit
// heisst anderer Prozess.
return (process.StartedAt - startedAt).Duration() > TimeSpan.FromSeconds(2)
? null
: process;
}
}
/// <summary>Der Socket, den der Test antworten oder schweigen lässt.</summary>
internal sealed class FakeChannel : IInstanceChannel
{
/// <summary>Die Antwort für alle Verzeichnisse, sofern keine eigene hinterlegt ist.</summary>
public InstanceIdentity? Answer { get; set; }
/// <summary>
/// Antworten je Instanzverzeichnis.
///
/// <para>Nötig, sobald mehr als eine Instanz im Spiel ist: Eine Auskunft, die für jedes
/// Verzeichnis dieselbe Instanz nennt, lässt Tests grün werden, die im Betrieb zwei
/// verschiedene Instanzen verwechseln würden.</para>
/// </summary>
public Dictionary<string, InstanceIdentity?> AnswerFor { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Wenn gesetzt, antwortet der Socket nur für Instanzen, die dieser Starter
/// tatsächlich gestartet hat — so wie im Betrieb.
///
/// <para><b>Ohne das ist ein Testaufbau leicht falsch:</b> Wer die Antwort hinterlegt,
/// bevor der Manager hochfährt, sagt damit „diese Instanz läuft bereits". Der Manager
/// übernimmt sie dann (E-M6), der Starter wird nie angefasst — und der Test prüft etwas
/// anderes, als er behauptet. Das ist mir dreimal passiert; mit dieser Kopplung kann es
/// nicht mehr passieren.</para>
///
/// <para>Nicht gesetzt heißt: Die hinterlegte Antwort gilt immer. Das braucht, wer eine
/// Instanz beschreiben will, die läuft, ohne dass dieser Manager sie gestartet hat.</para>
/// </summary>
public FakeLauncher? Launcher { get; set; }
public int Calls { get; private set; }
/// <summary>Wie oft die Instanz gebeten wurde, sich zu beenden.</summary>
public int StopRequests { get; private set; }
/// <summary>Ob die Bitte ankommt. Falsch heisst: Die Instanz hoert gar nicht mehr zu.</summary>
public bool StopRequestArrives { get; set; } = true;
public Task<InstanceIdentity?> AskAsync(string instanceDirectory, CancellationToken ct = default)
{
Calls++;
if (Launcher is not null && !Launcher.IsRunning(instanceDirectory))
return Task.FromResult<InstanceIdentity?>(null);
return Task.FromResult(
AnswerFor.TryGetValue(Path.GetFullPath(instanceDirectory), out var eigene)
? eigene
: Answer);
}
public Task<bool> RequestStopAsync(string instanceDirectory, CancellationToken ct = default)
{
StopRequests++;
return Task.FromResult(StopRequestArrives);
}
/// <summary>Welche Instanzen gebeten wurden, ihr Fenster nach vorn zu holen.</summary>
public List<string> FocusRequests { get; } = [];
/// <summary>Ob die Instanz ein Fenster hat, das nach vorn kommen kann.</summary>
public bool HasWindow { get; set; } = true;
public Task<bool> RequestFocusAsync(string instanceDirectory, CancellationToken ct = default)
{
FocusRequests.Add(Path.GetFullPath(instanceDirectory));
return Task.FromResult(HasWindow);
}
}