Drei Manager-Tests liessen den Prozess vor der Stopp-Bitte enden; unter Last sah der Aufsichtstakt das zuerst und meldete Crashed statt Stopped. Die FakeChannel bekommt OnStopRequest, die Tests beenden darin. Der Drain-Test bekommt 20 s statt 2 s Luft. AtomicFile schreibt, bis der Leser 20 Blicke hatte, statt fest 40 Mal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
202 lines
7.7 KiB
C#
202 lines
7.7 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ein Prozess, der schon lief, bevor dieser Manager kam (E-M6).
|
|
///
|
|
/// <para>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.</para>
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <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; }
|
|
|
|
/// <summary>
|
|
/// Verzeichnisse, deren Instanz verstummt ist: Der Prozess lebt, der Socket schweigt.
|
|
///
|
|
/// <para>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 <see cref="Launcher"/> bindet die Antwort sonst genau an das, was hier
|
|
/// auseinanderfallen soll.</para>
|
|
/// </summary>
|
|
public HashSet<string> Verstummt { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
/// <summary>Die Instanz hört auf zu antworten, ohne dass ihr Prozess endet.</summary>
|
|
public void Verstummen(string instanceDirectory)
|
|
=> Verstummt.Add(Path.GetFullPath(instanceDirectory));
|
|
|
|
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 (Verstummt.Contains(Path.GetFullPath(instanceDirectory)))
|
|
return Task.FromResult<InstanceIdentity?>(null);
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #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.
|
|
/// </summary>
|
|
public Action<string>? OnStopRequest { get; set; }
|
|
|
|
public Task<bool> RequestStopAsync(string instanceDirectory, CancellationToken ct = default)
|
|
{
|
|
StopRequests++;
|
|
if (StopRequestArrives)
|
|
OnStopRequest?.Invoke(instanceDirectory);
|
|
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);
|
|
}
|
|
}
|