Weboberflaeche (#531): X-Instanz an der einen fetch-Stelle, Instanzauswahl in der Kopfzeile, Verwaltungsansicht mit Anlegen/Starten/Anhalten/Fenster, Herkunft der Ereignisse, Sicherungs- und Einstellungsansicht. 402/409/502 werden eigen behandelt. Sicherungen im Manager (#530): Auflisten, Anlegen, Wiederherstellen, Loeschen. Das Zurueckspielen haelt die Instanz vorher an und startet sie danach wieder. Geordnetes Ende (Konzept 9.8). Zwei Befunde waren der Anlass: Der Manager konnte eine Instanz MIT FENSTER nie geordnet beenden. Control.ShutdownRequested war nur in HeadlessRun verdrahtet, nicht in der Avalonia-Anwendung - und ein Fenster ist die Vorgabe. Die Bitte wurde mit 202 beantwortet, niemand hoerte zu, und der Manager schoss den Prozess nach Ablauf seiner Frist jedes Mal ab. InstanceSupervisor.StopAsync hielt Socket-Schweigen fuer einen Todesnachweis und meldete "beendet", ohne den Prozess anzufassen. Bei einer verstummten Instanz ist der Socket von vornherein still - der Neustart haette einen zweiten Prozess auf dieselbe state.db gesetzt. Genau der Schaden, gegen den #528 gebaut ist. - ShutdownCoordinator: erst die Tuer schliessen, dann auf offene Arbeiten warten, Frist zwei Minuten. Vorher brach TaskScanner.DisposeAsync mitten in ProcessAsync ab, sodass die Aufgabe bis zum Lease-Ablauf auf "running" stehenblieb. - GET /api/instanz/puls hinter dem Socket-Gate: Phase und offene Arbeiten. Weiter gefragt statt gemeldet - ein Puls, den die Instanz von sich aus schickt, fehlt genau dann, wenn sie haengt. - Eskalationsleiter: drei Durchgaenge ohne Antwort, einmal um ein Ende bitten, nach 30s beenden und den Vollzug abwarten, gemeldet als Absturz. - EnsureDeadAsync vor jedem Start. Weiteres aus dem Review: - .gitignore verankert: "Instances/" passte auch auf src/ClawdDotNet.Core/Instances/. InstanceEndpoint.cs und InstanceLayout.cs waren dadurch nie eingecheckt. - Das WebUI-Token stand im Protokoll des Managers. Konsole ja, Datei nein. - Ein Klick auf "Instanz anlegen" loeste requestSubmit() zweimal aus. - [Obsolete] auf WebServerPort wieder entfernt: sechs Warnungen, die niemand abstellen konnte, solange E-M5 nicht fertig ist. - Die Wiederherstellung bricht nicht mehr ab, wenn der Browser weggeht. - Entwicklungspfade zum Instanzmanager aus Segmenten statt mit Rueckstrichen, und der Port kommt aus manager.json statt fest aus der Zeile. Offen und gemeldet: --managed hebelt die Lizenzpruefung aus (#535). 806 Tests gruen, 0 Warnungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
193 lines
7.2 KiB
C#
193 lines
7.2 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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|