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>
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ClawdDotNet.Core.Instances;
|
||||
|
||||
/// <summary>
|
||||
/// Der Weg, auf dem ein Instanzprozess von außen ansprechbar ist (#528, E-M1).
|
||||
///
|
||||
/// <para><b>Kein Port.</b> Ports müssten vergeben, verwaltet und gegen Kollisionen
|
||||
/// gesichert werden — genau das Problem, an dem die Vorbelegung <c>7001</c> scheitert,
|
||||
/// sobald zwei Instanzen laufen. Ein Socket im Instanzverzeichnis braucht keine Vergabe:
|
||||
/// Wo die Instanz liegt, liegt auch ihre Adresse.</para>
|
||||
///
|
||||
/// <para><b>Die Zugriffsrechte stehen am Dateisystem</b>, nicht in einem Token. Wer die
|
||||
/// Socketdatei öffnen darf, darf mit der Instanz sprechen — und das darf nur, wer ohnehin
|
||||
/// in ihr Verzeichnis sehen kann. Unter Linux setzt <see cref="Restrict"/> die Rechte auf
|
||||
/// 0600, unter Windows übernimmt das die Named Pipe mit
|
||||
/// <c>CurrentUserOnly</c>.</para>
|
||||
///
|
||||
/// <para><b>Warum HTTP darüber.</b> Der Manager leitet Anfragen weiter (E-M5). Bleibt das
|
||||
/// Protokoll HTTP, ist Weiterleiten eine Weiterleitung und keine Übersetzung: Endpunkte,
|
||||
/// Übertragungstypen und Ereignisstrom gelten unverändert weiter, es ändert sich nur die
|
||||
/// Bindung.</para>
|
||||
/// </summary>
|
||||
public static class InstanceEndpoint
|
||||
{
|
||||
/// <summary>Der Name der Socketdatei im Instanzverzeichnis.</summary>
|
||||
public const string SocketFileName = "manager.sock";
|
||||
|
||||
/// <summary>
|
||||
/// Ob auf dieser Maschine eine Named Pipe statt eines Unix-Domain-Sockets benutzt
|
||||
/// wird.
|
||||
///
|
||||
/// <para>Der Zielbetrieb ist Linux, entwickelt wird auf Windows — beide Wege müssen
|
||||
/// tragen. Windows kann seit 10/1803 auch Unix-Domain-Sockets, aber nicht jede
|
||||
/// Schicht darüber zieht mit; die Named Pipe ist dort der Weg, den das Betriebssystem
|
||||
/// selbst vorsieht, samt eigener Rechteverwaltung.</para>
|
||||
///
|
||||
/// <para>An Aufrufstellen, hinter denen etwas Plattformgebundenes steht, wird
|
||||
/// trotzdem <c>OperatingSystem.IsWindows()</c> geschrieben: Die Plattformanalyse
|
||||
/// erkennt nur den unmittelbaren Aufruf als Absicherung, nicht eine Eigenschaft, die
|
||||
/// dasselbe sagt.</para>
|
||||
/// </summary>
|
||||
public static bool UsesNamedPipe => OperatingSystem.IsWindows();
|
||||
|
||||
/// <summary>Die Socketdatei dieser Instanz. Auch unter Windows berechenbar — dort
|
||||
/// bestimmt sie über <see cref="PipeName"/> nur den Namen, es entsteht keine Datei.</summary>
|
||||
public static string SocketPath(string instanceDirectory)
|
||||
=> Path.Combine(Path.GetFullPath(instanceDirectory), SocketFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Der Name der Named Pipe dieser Instanz.
|
||||
///
|
||||
/// <para>Pipenamen sind maschinenweit und kennen keine Verzeichnisse. Der Name muss
|
||||
/// deshalb aus dem Instanzpfad entstehen, und zwar so, dass zwei Instanzen nie
|
||||
/// denselben bekommen — sonst redete der Manager mit der falschen. Ein Fingerabdruck
|
||||
/// des Pfades leistet das und trägt dabei nichts aus dem Pfad nach außen: Pipenamen
|
||||
/// sind für jeden Prozess der Maschine sichtbar, ein Klartextpfad darin wäre ein
|
||||
/// unnötiger Hinweis auf die Ablage.</para>
|
||||
/// </summary>
|
||||
public static string PipeName(string instanceDirectory)
|
||||
=> "clawd-" + Fingerprint(instanceDirectory);
|
||||
|
||||
/// <summary>Die Adresse, wie man sie einem Menschen ins Protokoll schreibt.</summary>
|
||||
public static string Describe(string instanceDirectory)
|
||||
=> UsesNamedPipe
|
||||
? $@"\\.\pipe\{PipeName(instanceDirectory)}"
|
||||
: SocketPath(instanceDirectory);
|
||||
|
||||
/// <summary>
|
||||
/// Räumt eine liegengebliebene Socketdatei weg.
|
||||
///
|
||||
/// <para><b>Ohne das startet die Instanz nach einem harten Ende nicht wieder.</b> Ein
|
||||
/// Unix-Domain-Socket ist eine Datei, und sie überlebt den Prozess, der sie angelegt
|
||||
/// hat. Beim nächsten Binden kommt <c>EADDRINUSE</c> — von einem Prozess, den es
|
||||
/// nicht mehr gibt. Genau der Fall aus #529: Windows hat die Anwendung erschlagen,
|
||||
/// aufgeräumt hat niemand.</para>
|
||||
///
|
||||
/// <para>Unter Windows ohne Wirkung: Eine Named Pipe verschwindet mit ihrem letzten
|
||||
/// Halter.</para>
|
||||
/// </summary>
|
||||
public static void ClearStale(string instanceDirectory)
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
|
||||
var path = SocketPath(instanceDirectory);
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Kein Grund, den Start zu verhindern: Das Binden meldet gleich selbst, ob
|
||||
// die Adresse frei ist, und diese Meldung ist die genauere.
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Setzt die Rechte der Socketdatei auf 0600 — nur der eigene Benutzer.
|
||||
///
|
||||
/// <para>Erst nach dem Binden aufzurufen: Vorher gibt es die Datei nicht. Unter
|
||||
/// Windows ohne Wirkung; dort trägt <c>NamedPipeTransportOptions.CurrentUserOnly</c>
|
||||
/// dieselbe Zusage.</para>
|
||||
/// </summary>
|
||||
public static void Restrict(string instanceDirectory)
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
|
||||
var path = SocketPath(instanceDirectory);
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
|
||||
or PlatformNotSupportedException)
|
||||
{
|
||||
// Der Aufrufer protokolliert. Ein Socket mit weiteren Rechten ist ein Mangel,
|
||||
// aber keiner, der die Instanz am Laufen hindern sollte — im Verzeichnis
|
||||
// daneben liegt ohnehin alles, was schützenswert ist, und dessen Rechte
|
||||
// stehen bereits.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein <see cref="HttpClient"/>, der über den Socket dieser Instanz spricht.
|
||||
///
|
||||
/// <para>Die Adresse in der Anfrage ist ein Platzhalter — <c>http://instanz</c> —,
|
||||
/// weil HTTP einen Host verlangt und der Socket keinen hat. Verbunden wird über den
|
||||
/// Rückruf, nicht über den Namen.</para>
|
||||
/// </summary>
|
||||
public static HttpClient CreateClient(string instanceDirectory)
|
||||
=> new(CreateHandler(instanceDirectory)) { BaseAddress = new Uri("http://instanz") };
|
||||
|
||||
/// <inheritdoc cref="CreateClient"/>
|
||||
public static HttpMessageHandler CreateHandler(string instanceDirectory)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
var name = PipeName(instanceDirectory);
|
||||
|
||||
return new SocketsHttpHandler
|
||||
{
|
||||
ConnectCallback = async (_, ct) =>
|
||||
{
|
||||
var stream = new System.IO.Pipes.NamedPipeClientStream(
|
||||
".", name,
|
||||
System.IO.Pipes.PipeDirection.InOut,
|
||||
System.IO.Pipes.PipeOptions.Asynchronous
|
||||
| System.IO.Pipes.PipeOptions.CurrentUserOnly);
|
||||
|
||||
// 150 ms Frist: Ein lokaler Socket antwortet sofort (< 2 ms).
|
||||
// Läuft die Instanz nicht, wartet Windows sonst bis zum Abbruch des Aufrufers.
|
||||
await stream.ConnectAsync(150, ct);
|
||||
return stream;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var endpoint = new UnixDomainSocketEndPoint(SocketPath(instanceDirectory));
|
||||
|
||||
return new SocketsHttpHandler
|
||||
{
|
||||
ConnectCallback = async (_, ct) =>
|
||||
{
|
||||
var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
|
||||
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(endpoint, ct);
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fingerabdruck eines Instanzpfades: 16 Hexzeichen aus SHA-256.
|
||||
///
|
||||
/// <para>Unter Windows wird die Schreibweise vorher zusammengeführt — dort sind
|
||||
/// <c>C:\Instanzen\Test</c> und <c>c:\instanzen\test</c> dasselbe Verzeichnis und
|
||||
/// müssen denselben Pipenamen ergeben. Unter Linux sind es zwei Verzeichnisse und
|
||||
/// dürfen es nicht.</para>
|
||||
/// </summary>
|
||||
private static string Fingerprint(string instanceDirectory)
|
||||
{
|
||||
var full = Path.GetFullPath(instanceDirectory).TrimEnd(
|
||||
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
|
||||
if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS())
|
||||
full = full.ToLowerInvariant();
|
||||
|
||||
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(full));
|
||||
return Convert.ToHexStringLower(digest)[..16];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
|
||||
namespace ClawdDotNet.Core.Instances;
|
||||
|
||||
/// <summary>
|
||||
/// Wo die Verzeichnisse einer Instanz liegen.
|
||||
///
|
||||
/// <para><b>In Core, weil zwei Seiten es brauchen</b> (#528): die Anwendung, die dorthin
|
||||
/// schreibt, und der Instanzmanager, der beim Absturz einer Instanz den letzten Ausschnitt
|
||||
/// ihres Protokolls in seine Meldung nimmt. Der Manager darf <c>ClawdDotNet.App</c> nicht
|
||||
/// kennen — dort hängen sechzehn Werkzeugprojekte —, und dieselbe Rechnung an zwei Stellen
|
||||
/// zu führen wäre die schlechtere Antwort darauf.</para>
|
||||
/// </summary>
|
||||
public static class InstanceLayout
|
||||
{
|
||||
/// <summary>
|
||||
/// Wohin diese Instanz protokolliert. Zwei Regeln:
|
||||
///
|
||||
/// <para><b>Leer heißt <c><Instanzverzeichnis>/Logs</c></b> — nicht mehr das
|
||||
/// anwendungsweite Verzeichnis. Solange alle Instanzen in dieselben Dateien
|
||||
/// schrieben, stand in <c>Startup.log</c> abwechselnd, was zwei Instanzen zu sagen
|
||||
/// hatten, ohne Kennzeichnung, welche gerade spricht; im Mehrinstanzbetrieb war jede
|
||||
/// Fehlersuche damit Ratearbeit (#528, Stufe 1).</para>
|
||||
///
|
||||
/// <para><b>Ein relativer Wert zählt gegen das Instanzverzeichnis</b>, nicht gegen
|
||||
/// das Arbeitsverzeichnis des Prozesses. Sonst hängt der Ort davon ab, aus welchem
|
||||
/// Verzeichnis jemand gestartet hat: Ein systemd-Dienst ohne <c>WorkingDirectory=</c>
|
||||
/// startet in <c>/</c>, und <c>"./Logs"</c> zeigt dann auf <c>/Logs</c> — für ein
|
||||
/// Dienstkonto nicht anlegbar. Beim Start aus dem Installationsverzeichnis schrieb
|
||||
/// die Anwendung stattdessen in sich selbst, wo ein Update sie überschreibt.</para>
|
||||
/// </summary>
|
||||
/// <param name="instance">Die geladene Instanz — liefert Vorrang und Bezugspunkt.</param>
|
||||
/// <param name="instancePath">Das Verzeichnis, aus dem die Instanz geladen wurde.</param>
|
||||
public static string ResolveLogDirectory(InstanceConfig instance, string instancePath)
|
||||
=> ResolveInstanceDirectory(instance.LogDirectory, "Logs", instance, instancePath);
|
||||
|
||||
/// <summary>
|
||||
/// Wohin die Sicherungen dieser Instanz gehen. Leer heißt
|
||||
/// <c><Instanzverzeichnis>/Backups</c>.
|
||||
///
|
||||
/// <para>Der gemeinsame Ordner war nicht nur unordentlich: Die Aufbewahrung räumt
|
||||
/// dort nach dem Präfix <c>backup_<Instanzname>_</c> auf, und bei zwei Namen,
|
||||
/// die ineinander liegen (<c>Test</c> und <c>Test_2</c>), zählt die eine Instanz die
|
||||
/// Archive der anderen mit (#528, Stufe 1).</para>
|
||||
/// </summary>
|
||||
public static string ResolveBackupDirectory(InstanceConfig instance, string instancePath)
|
||||
=> ResolveInstanceDirectory(instance.BackupDirectory, "Backups", instance, instancePath);
|
||||
|
||||
/// <summary>
|
||||
/// Gemeinsame Auflösung: eingetragener Wert vor Vorgabe, relativ vor dem
|
||||
/// Instanzverzeichnis.
|
||||
/// </summary>
|
||||
private static string ResolveInstanceDirectory(
|
||||
string configured, string fallbackFolder, InstanceConfig instance, string instancePath)
|
||||
{
|
||||
// InstanceConfig.WorkingDirectory steht seit CreateInstance mit dem absoluten
|
||||
// Instanzpfad in der Datei; instancePath ist derselbe Ort und trägt auch dann,
|
||||
// wenn jemand das Feld von Hand geleert oder relativ gesetzt hat.
|
||||
var instanceBase = Path.IsPathRooted(instance.WorkingDirectory)
|
||||
? instance.WorkingDirectory
|
||||
: instancePath;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(configured))
|
||||
return Path.GetFullPath(Path.Combine(instanceBase, fallbackFolder));
|
||||
|
||||
return Path.GetFullPath(
|
||||
Path.IsPathRooted(configured)
|
||||
? configured
|
||||
: Path.Combine(instanceBase, configured));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Instances;
|
||||
|
||||
/// <summary>Wo eine Instanz beim Herunterfahren gerade steht.</summary>
|
||||
public enum ShutdownPhase
|
||||
{
|
||||
/// <summary>Niemand hat um ein Ende gebeten. Der Normalbetrieb.</summary>
|
||||
Running,
|
||||
|
||||
/// <summary>Die Tür ist zu, die offenen Arbeiten laufen aus.</summary>
|
||||
Draining,
|
||||
|
||||
/// <summary>Nichts mehr offen — oder die Frist ist abgelaufen. Der Wirt darf enden.</summary>
|
||||
Done
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine Stelle, an der Arbeit offen sein kann: Agentenläufe, Aufgaben, was noch kommt.
|
||||
///
|
||||
/// <para>Bewusst über Rückrufe und nicht als Schnittstelle an <c>AgentEngine</c> oder
|
||||
/// <c>TaskScanner</c>: Die beiden sollen nicht wissen, dass es ein Herunterfahren gibt.
|
||||
/// Sie können zwei Dinge — keine neue Arbeit mehr annehmen und sagen, wie viel noch
|
||||
/// läuft. Was daraus folgt, entscheidet der Koordinator.</para>
|
||||
/// </summary>
|
||||
/// <param name="Name">Wie diese Quelle im Protokoll und in der Puls-Auskunft heißt.</param>
|
||||
/// <param name="OpenCount">Wie viele Arbeiten hier gerade noch laufen.</param>
|
||||
/// <param name="StopAccepting">Schließt die Tür: ab jetzt keine neue Arbeit mehr.</param>
|
||||
public sealed record WorkSource(
|
||||
string Name,
|
||||
Func<int> OpenCount,
|
||||
Action StopAccepting);
|
||||
|
||||
/// <summary>Was gerade offen ist, je Quelle.</summary>
|
||||
public sealed record OpenWork(string Name, int Count);
|
||||
|
||||
/// <summary>
|
||||
/// Der Stand des Herunterfahrens, wie ihn der Instanzmanager über den Puls erfährt.
|
||||
/// </summary>
|
||||
/// <param name="Phase">Laufend, am Auslaufen, fertig.</param>
|
||||
/// <param name="Open">Was noch offen ist, je Quelle. Leer heißt: nichts mehr.</param>
|
||||
/// <param name="Elapsed">Wie lange das Auslaufen schon dauert.</param>
|
||||
/// <param name="Remaining">Wie viel von der Frist noch übrig ist.</param>
|
||||
public sealed record ShutdownSnapshot(
|
||||
ShutdownPhase Phase,
|
||||
IReadOnlyList<OpenWork> Open,
|
||||
TimeSpan Elapsed,
|
||||
TimeSpan Remaining)
|
||||
{
|
||||
/// <summary>Die Summe über alle Quellen.</summary>
|
||||
public int TotalOpen => Open.Sum(o => o.Count);
|
||||
|
||||
public static ShutdownSnapshot Idle { get; } =
|
||||
new(ShutdownPhase.Running, [], TimeSpan.Zero, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Das geordnete Ende einer Instanz: erst die Tür zu, dann auslaufen lassen, dann enden.
|
||||
///
|
||||
/// <para><b>Warum es das braucht.</b> Bis hierher war jedes Beenden ein Abschneiden.
|
||||
/// <c>TaskScanner.DisposeAsync</c> bricht den laufenden Takt über sein
|
||||
/// <c>CancellationToken</c> ab — mitten in <c>ProcessAsync</c>, also <em>nachdem</em> eine
|
||||
/// Aufgabe beansprucht wurde und <em>bevor</em> <c>CompleteClaimAsync</c> sie wieder
|
||||
/// freigibt. Die Aufgabe steht danach auf <c>running</c> und bleibt dort, bis das Lease
|
||||
/// abläuft. Ein Agentenlauf mitten im Werkzeugaufruf verliert seine Antwort ganz.</para>
|
||||
///
|
||||
/// <para><b>Die Reihenfolge ist der ganze Trick.</b> Erst wird die Tür geschlossen, dann
|
||||
/// gewartet. Andersherum wartet man ewig: Der Scanner beansprucht im nächsten Takt die
|
||||
/// nächste Aufgabe, und die Zahl der offenen Arbeiten geht nie auf null.</para>
|
||||
///
|
||||
/// <para><b>Die Frist ist eine Zusage, keine Hoffnung.</b> Nach
|
||||
/// <see cref="Budget"/> endet die Instanz, ob fertig oder nicht — sonst hinge sie an
|
||||
/// einem Agentenlauf, der auf ein Modell wartet, das nicht antwortet. Was dann noch offen
|
||||
/// war, steht im Protokoll; verschwiegen wird es nicht.</para>
|
||||
///
|
||||
/// <para><b>Und der Manager sieht zu.</b> <see cref="Snapshot"/> geht über den
|
||||
/// Puls-Endpunkt an den Instanzmanager. Solange hier Fortschritt gemeldet wird, wartet er
|
||||
/// — er soll eine Instanz nicht abschießen, die gerade ordentlich aufräumt.</para>
|
||||
/// </summary>
|
||||
public sealed class ShutdownCoordinator(
|
||||
ILogger logger,
|
||||
TimeSpan? budget = null,
|
||||
TimeProvider? clock = null)
|
||||
{
|
||||
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
|
||||
private readonly List<WorkSource> _sources = [];
|
||||
private readonly Lock _lock = new();
|
||||
private readonly TaskCompletionSource _finished =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
private DateTimeOffset _startedAt;
|
||||
private int _begun;
|
||||
|
||||
/// <summary>
|
||||
/// Wie lange das Auslaufen höchstens dauern darf.
|
||||
///
|
||||
/// <para>Zwei Minuten, weil ein Agentenlauf mit Werkzeugaufrufen selten kürzer ist und
|
||||
/// selten länger dauert. Der Manager rechnet damit — seine eigene Frist muss größer
|
||||
/// sein, sonst schießt er in das Aufräumen hinein.</para>
|
||||
/// </summary>
|
||||
public TimeSpan Budget { get; } = budget ?? TimeSpan.FromMinutes(2);
|
||||
|
||||
public ShutdownPhase Phase { get; private set; } = ShutdownPhase.Running;
|
||||
|
||||
/// <summary>Ist erfüllt, sobald das Auslaufen vorbei ist — fertig oder abgelaufen.</summary>
|
||||
public Task Finished => _finished.Task;
|
||||
|
||||
/// <summary>
|
||||
/// Meldet eine Stelle an, an der Arbeit offen sein kann.
|
||||
///
|
||||
/// <para>Vor <see cref="BeginAsync"/> aufzurufen. Wer sich später meldet, wird nicht
|
||||
/// mehr abgewartet — und das ist richtig so: Was nach dem Schließen der Tür entsteht,
|
||||
/// hätte gar nicht entstehen dürfen.</para>
|
||||
/// </summary>
|
||||
public void Register(WorkSource source)
|
||||
{
|
||||
lock (_lock) _sources.Add(source);
|
||||
}
|
||||
|
||||
/// <summary>Der aktuelle Stand, für den Puls an den Manager.</summary>
|
||||
public ShutdownSnapshot Snapshot()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (Phase == ShutdownPhase.Running)
|
||||
return ShutdownSnapshot.Idle;
|
||||
|
||||
var elapsed = _clock.GetUtcNow() - _startedAt;
|
||||
var remaining = Budget - elapsed;
|
||||
|
||||
return new ShutdownSnapshot(
|
||||
Phase,
|
||||
Offen(),
|
||||
elapsed,
|
||||
remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schließt die Tür und wartet, bis nichts mehr offen ist — höchstens
|
||||
/// <see cref="Budget"/> lang.
|
||||
///
|
||||
/// <para>Ein zweiter Aufruf wartet auf denselben Vorgang, statt einen neuen
|
||||
/// anzustoßen. Beide Wege dorthin gibt es wirklich: Der Manager bittet über den
|
||||
/// Socket, und gleichzeitig kann jemand am Fenster auf das Kreuz klicken.</para>
|
||||
/// </summary>
|
||||
public async Task BeginAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _begun, 1) != 0)
|
||||
{
|
||||
await _finished.Task.WaitAsync(ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
List<WorkSource> quellen;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_startedAt = _clock.GetUtcNow();
|
||||
Phase = ShutdownPhase.Draining;
|
||||
quellen = [.. _sources];
|
||||
}
|
||||
|
||||
// Erst die Tuer. Wer hier wartet, bevor er schliesst, wartet, bis der Strom
|
||||
// ausfaellt.
|
||||
foreach (var quelle in quellen)
|
||||
{
|
||||
try { quelle.StopAccepting(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex,
|
||||
"{Quelle} liess sich nicht schliessen — es wird trotzdem gewartet.",
|
||||
quelle.Name);
|
||||
}
|
||||
}
|
||||
|
||||
var offenZuBeginn = Snapshot();
|
||||
|
||||
if (offenZuBeginn.TotalOpen == 0)
|
||||
{
|
||||
logger.LogInformation("Nichts mehr offen — die Instanz endet sofort.");
|
||||
Vollenden();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Geordnetes Ende: {Anzahl} offene Arbeiten ({Aufschluesselung}). "
|
||||
+ "Es wird bis zu {Frist:0}s gewartet.",
|
||||
offenZuBeginn.TotalOpen, Beschreiben(offenZuBeginn.Open), Budget.TotalSeconds);
|
||||
|
||||
var deadline = _startedAt + Budget;
|
||||
var zuletztGemeldet = offenZuBeginn.TotalOpen;
|
||||
|
||||
while (_clock.GetUtcNow() < deadline)
|
||||
{
|
||||
var stand = Snapshot();
|
||||
|
||||
if (stand.TotalOpen == 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Alle Arbeiten abgeschlossen nach {Dauer:0.0}s — die Instanz endet.",
|
||||
stand.Elapsed.TotalSeconds);
|
||||
|
||||
Vollenden();
|
||||
return;
|
||||
}
|
||||
|
||||
if (stand.TotalOpen != zuletztGemeldet)
|
||||
{
|
||||
// Fortschritt gehoert ins Protokoll. Nicht als Verzierung: Er ist der
|
||||
// Unterschied zwischen "raeumt auf" und "haengt", und genau den muss
|
||||
// hinterher jemand nachlesen koennen.
|
||||
logger.LogInformation(
|
||||
"Noch {Anzahl} offen ({Aufschluesselung}), {Rest:0}s Frist.",
|
||||
stand.TotalOpen, Beschreiben(stand.Open), stand.Remaining.TotalSeconds);
|
||||
|
||||
zuletztGemeldet = stand.TotalOpen;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(250), _clock, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Abgebrochen heisst hier nicht "weitermachen", sondern "keine Zeit mehr".
|
||||
// Der Wirt will enden; das Warten war der Dienst, nicht der Zweck.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var uebrig = Snapshot();
|
||||
|
||||
if (uebrig.TotalOpen > 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Die Frist von {Frist:0}s ist abgelaufen, {Anzahl} Arbeiten waren noch offen "
|
||||
+ "({Aufschluesselung}). Sie werden abgebrochen.",
|
||||
Budget.TotalSeconds, uebrig.TotalOpen, Beschreiben(uebrig.Open));
|
||||
}
|
||||
|
||||
Vollenden();
|
||||
}
|
||||
|
||||
private void Vollenden()
|
||||
{
|
||||
lock (_lock) Phase = ShutdownPhase.Done;
|
||||
_finished.TrySetResult();
|
||||
}
|
||||
|
||||
private List<OpenWork> Offen()
|
||||
{
|
||||
var liste = new List<OpenWork>();
|
||||
|
||||
foreach (var quelle in _sources)
|
||||
{
|
||||
int anzahl;
|
||||
|
||||
try { anzahl = quelle.OpenCount(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Eine Quelle, die beim Zaehlen stolpert, darf das Ende nicht aufhalten.
|
||||
// Null ist hier die sichere Antwort: Sie verlaengert nichts.
|
||||
logger.LogError(ex, "{Quelle} liess sich nicht zaehlen.", quelle.Name);
|
||||
anzahl = 0;
|
||||
}
|
||||
|
||||
if (anzahl > 0) liste.Add(new OpenWork(quelle.Name, anzahl));
|
||||
}
|
||||
|
||||
return liste;
|
||||
}
|
||||
|
||||
private static string Beschreiben(IReadOnlyList<OpenWork> offen)
|
||||
=> offen.Count == 0 ? "nichts" : string.Join(", ", offen.Select(o => $"{o.Name}: {o.Count}"));
|
||||
}
|
||||
Reference in New Issue
Block a user