Der zweite Teil von Stufe 3. Die Aufsicht ueber eine einzelne Instanz stand schon; jetzt gibt es die Klammer darum und den Weg nach draussen. InstanceRegistry haelt je Instanz eine Aufsicht. Zwei Zusagen, beide aus E-M6: Beim Start wird UEBERNOMMEN, nicht gestartet - ein Manager, der beim Hochkommen alles hochfaehrt, was er findet, startet nach einem Neustart der Maschine auch das, was jemand mit Absicht angehalten hat. Und beim Beenden wird nichts beendet: Wer den Manager schliesst, will den Manager schliessen. Einzige Ausnahme ist StopAllAsync fuer einen Grund, der alle betrifft - und der heisst Lizenzwiderruf. SupervisionReporter macht aus einem Befund eine Bugtracker-Meldung, stellvertretend: mit Prozesskennung, Abschiedscode und dem Ausschnitt aus dem Instanzprotokoll, der noch auf der Platte liegt. Der Idempotenzschluessel ist stabil je Instanz und Art - stuerzt dieselbe Instanz wiederholt ab, zaehlt der Server hoch, statt den Bugtracker mit Zwillingen zu fuellen. Ein leerer Protokollauszug ist dabei die aussagekraeftigste Zeile der Meldung und wird ausdruecklich als solche geschrieben. Genau das war der Zustand am 2026-09-08: Der Prozess wurde erschlagen, bevor sein Schreibpuffer geleert war, und hinterliess nichts. manager.json traegt die Felder aus Abschnitt 6 des Konzepts und sonst nichts. Lizenzschluessel, Master-Token und Web-Token liegen darin verschluesselt und zur Laufzeit im Klartext - dieselbe Bauart wie die Anwendungseinstellungen, mit Absicht. Unsinnige Aufsichtswerte werden eingefangen, weil die Datei von Hand bearbeitbar ist. InstanceLayout ist nach Core gezogen (ResolveLogDirectory, ResolveBackupDirectory). Der Manager braucht das Protokollverzeichnis einer Instanz fuer den Auszug in der Meldung und darf ClawdDotNet.App nicht kennen; dieselbe Rechnung an zwei Stellen zu fuehren waere die schlechtere Antwort. AppHost leitet nur noch weiter. EIN DEFEKT, den erst die eigenen Tests gezeigt haben: Eine angehaltene Instanz erschien im Register als "Instance-Alpha" statt "Alpha". Die Aufsicht kennt den Namen erst, wenn die Instanz einmal geantwortet hat, und eine angehaltene tut das nie - waehrend genau diese Ansicht der Manager anzeigt. Das Register fuehrt den Namen jetzt aus InstanceSettings.json mit. Und ein Testdouble, das zu viel behauptet hat: Der falsche Socket antwortete fuer jedes Verzeichnis dieselbe Instanz. Das laesst Tests gruen werden, die im Betrieb zwei Instanzen verwechseln wuerden; er antwortet jetzt je Verzeichnis. 29 neue Tests. Build ohne Warnungen, 766 Tests gruen, 8 uebersprungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
122 lines
3.9 KiB
C#
122 lines
3.9 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>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;
|
|
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);
|
|
|
|
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++;
|
|
|
|
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);
|
|
}
|
|
}
|