namespace ClawdDotNet.Manager.Tests;
///
/// Ein Instanzprozess, den der Test steuert.
///
/// 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.
///
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; }
/// Der Prozess endet — mit oder ohne Zutun.
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; }
/// Welcher Prozess zu welchem Instanzverzeichnis gehört.
public Dictionary ByDirectory { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// Ob für dieses Verzeichnis gerade ein Prozess lebt.
public bool IsRunning(string instanceDirectory)
=> ByDirectory.TryGetValue(Path.GetFullPath(instanceDirectory), out var p)
&& !p.HasExited;
/// Wenn gesetzt, scheitert der Start damit.
public Exception? FailStartWith { get; set; }
/// Was liefern soll — bereits laufende Prozesse.
public Dictionary 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;
}
///
/// Ein Prozess, der schon lief, bevor dieser Manager kam (E-M6).
///
/// Zählt nicht als Start — genau darin liegt der Unterschied, den mehrere Tests
/// prüfen: Was läuft, wird übernommen und nicht ein zweites Mal hochgefahren.
///
public FakeProcess Fremdstart(string instanceDirectory, int processId)
{
var prozess = new FakeProcess(processId, Now);
Running[processId] = prozess;
ByDirectory[Path.GetFullPath(instanceDirectory)] = prozess;
return prozess;
}
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;
}
}
/// Der Socket, den der Test antworten oder schweigen lässt.
internal sealed class FakeChannel : IInstanceChannel
{
/// Die Antwort für alle Verzeichnisse, sofern keine eigene hinterlegt ist.
public InstanceIdentity? Answer { get; set; }
///
/// Antworten je Instanzverzeichnis.
///
/// 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.
///
public Dictionary AnswerFor { get; } =
new(StringComparer.OrdinalIgnoreCase);
///
/// Wenn gesetzt, antwortet der Socket nur für Instanzen, die dieser Starter
/// tatsächlich gestartet hat — so wie im Betrieb.
///
/// Ohne das ist ein Testaufbau leicht falsch: 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.
///
/// 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.
///
public FakeLauncher? Launcher { get; set; }
///
/// Verzeichnisse, deren Instanz verstummt ist: Der Prozess lebt, der Socket schweigt.
///
/// Der Fall aus #529 — und der einzige, in dem die Aufsicht von sich aus zur
/// Gewalt greift. Ohne diesen Schalter lässt er sich nicht nachstellen: Die Kopplung
/// an bindet die Antwort sonst genau an das, was hier
/// auseinanderfallen soll.
///
public HashSet Verstummt { get; } = new(StringComparer.OrdinalIgnoreCase);
/// Die Instanz hört auf zu antworten, ohne dass ihr Prozess endet.
public void Verstummen(string instanceDirectory)
=> Verstummt.Add(Path.GetFullPath(instanceDirectory));
public int Calls { get; private set; }
/// Wie oft die Instanz gebeten wurde, sich zu beenden.
public int StopRequests { get; private set; }
/// Ob die Bitte ankommt. Falsch heisst: Die Instanz hoert gar nicht mehr zu.
public bool StopRequestArrives { get; set; } = true;
public Task AskAsync(string instanceDirectory, CancellationToken ct = default)
{
Calls++;
if (Verstummt.Contains(Path.GetFullPath(instanceDirectory)))
return Task.FromResult(null);
if (Launcher is not null && !Launcher.IsRunning(instanceDirectory))
return Task.FromResult(null);
return Task.FromResult(
AnswerFor.TryGetValue(Path.GetFullPath(instanceDirectory), out var eigene)
? eigene
: Answer);
}
///
/// #722: What the instance does when the request arrives — typically: end. A test that
/// ended the process before asking raced the supervisor's own check: under load the check
/// saw the process gone first and called it a crash, not a stop.
///
public Action? OnStopRequest { get; set; }
public Task RequestStopAsync(string instanceDirectory, CancellationToken ct = default)
{
StopRequests++;
if (StopRequestArrives)
OnStopRequest?.Invoke(instanceDirectory);
return Task.FromResult(StopRequestArrives);
}
/// Welche Instanzen gebeten wurden, ihr Fenster nach vorn zu holen.
public List FocusRequests { get; } = [];
/// Ob die Instanz ein Fenster hat, das nach vorn kommen kann.
public bool HasWindow { get; set; } = true;
public Task RequestFocusAsync(string instanceDirectory, CancellationToken ct = default)
{
FocusRequests.Add(Path.GetFullPath(instanceDirectory));
return Task.FromResult(HasWindow);
}
}