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