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>
759 lines
31 KiB
C#
759 lines
31 KiB
C#
using ClawdDotNet.Core.Instances;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ClawdDotNet.Manager;
|
|
|
|
/// <summary>Was die Aufsicht über den Zustand einer Instanz weiß.</summary>
|
|
public enum InstanceCondition
|
|
{
|
|
/// <summary>Noch nicht nachgesehen.</summary>
|
|
Unknown,
|
|
|
|
/// <summary>Läuft nicht — und das ist in Ordnung so.</summary>
|
|
Stopped,
|
|
|
|
/// <summary>Läuft und antwortet.</summary>
|
|
Running,
|
|
|
|
/// <summary>Der Prozess ist da, antwortet aber nicht mehr.</summary>
|
|
Silent,
|
|
|
|
/// <summary>Weg, ohne dass jemand darum gebeten hätte.</summary>
|
|
Crashed
|
|
}
|
|
|
|
/// <summary>Stellschrauben der Aufsicht.</summary>
|
|
/// <param name="ProbeInterval">Wie oft nachgesehen wird.</param>
|
|
/// <param name="StartTimeout">Wie lange eine frisch gestartete Instanz Zeit hat, sich zu melden.</param>
|
|
/// <param name="SilentChecksBeforeAlarm">
|
|
/// Wie viele Fehlversuche in Folge nötig sind, bevor „antwortet nicht" gilt.
|
|
///
|
|
/// <para>Nicht einer: Eine einzelne verpasste Antwort ist Alltag — die Maschine war
|
|
/// beschäftigt, ein Agentenlauf hat den Prozess gerade voll ausgelastet. Ein Alarm bei
|
|
/// jedem Aussetzer wäre nach einer Woche der Alarm, den niemand mehr liest, und dann ist
|
|
/// die Aufsicht ihr Geld nicht mehr wert.</para>
|
|
/// </param>
|
|
/// <param name="StopGrace">Wie lange eine Instanz nach der Bitte zu enden noch bekommt.</param>
|
|
/// <param name="SilentKillGrace">
|
|
/// Wie lange eine verstummte Instanz nach der angeordneten Beendigung noch bekommt.
|
|
///
|
|
/// <para>Der Bitte über den Socket ist bei einer Instanz, die eben auf demselben Socket
|
|
/// nicht geantwortet hat, wenig zuzutrauen — sie kostet einen Versuch und wird deshalb
|
|
/// unternommen, aber gewartet wird nicht auf sie, sondern auf die Uhr. Antwortet die
|
|
/// Instanz in dieser Zeit wieder, war es ein Aussetzer und niemand wird abgeschossen.</para>
|
|
/// </param>
|
|
/// <param name="MaxDrainWait">
|
|
/// Die absolute Obergrenze beim Anhalten, wenn die Instanz meldet, dass sie aufräumt.
|
|
///
|
|
/// <para>Muss größer sein als die Frist, die sich die Instanz selbst gibt
|
|
/// (<c>ShutdownCoordinator.Budget</c>) — sonst schießt der Manager mitten in das
|
|
/// Aufräumen hinein, um das er gerade gebeten hat. Und trotzdem endlich: Eine Instanz,
|
|
/// die dauerhaft „ich räume noch auf" sagt, hängt genauso wie eine, die schweigt.</para>
|
|
/// </param>
|
|
public sealed record SupervisionOptions(
|
|
TimeSpan ProbeInterval,
|
|
TimeSpan StartTimeout,
|
|
int SilentChecksBeforeAlarm,
|
|
TimeSpan StopGrace,
|
|
TimeSpan SilentKillGrace,
|
|
TimeSpan MaxDrainWait)
|
|
{
|
|
public static SupervisionOptions Default { get; } = new(
|
|
ProbeInterval: TimeSpan.FromSeconds(15),
|
|
StartTimeout: TimeSpan.FromSeconds(60),
|
|
SilentChecksBeforeAlarm: 3,
|
|
StopGrace: TimeSpan.FromSeconds(20),
|
|
SilentKillGrace: TimeSpan.FromSeconds(30),
|
|
MaxDrainWait: TimeSpan.FromMinutes(5));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Die Aufsicht über <b>eine</b> Instanz: starten, anhalten, nachsehen, wiederfinden.
|
|
///
|
|
/// <para><b>Der Anlass.</b> Am 2026-09-08 hat sich eine Instanz aufgehängt und wurde von
|
|
/// Windows beendet. Zu diesem Vorgang steht keine einzige Zeile in den Instanzprotokollen
|
|
/// — der Schreibpuffer wurde nie geleert. Keine Fehlermeldung, kein Bugtracker-Eintrag,
|
|
/// kein Watchdog-Alarm. Der Grund ist bauartbedingt: Meldeweg, Watchdog und Fehler-Stream
|
|
/// lebten <em>in</em> dem Prozess, über dessen Zustand sie berichten sollten. Fällt er
|
|
/// hart, fällt der Meldeweg mit — genau in dem Fall, der gemeldet werden müsste (#528,
|
|
/// #529).</para>
|
|
///
|
|
/// <para><b>Deshalb fragt diese Klasse den Socket und nicht die Prozessliste.</b> Die
|
|
/// Prozessliste sagt, dass etwas da ist. Sie sagt nicht, ob es noch arbeitet — und genau
|
|
/// das war der Zustand, den niemand bemerkt hat. Ein Prozess, der auf seinem eigenen
|
|
/// Socket dreimal hintereinander nicht antwortet, ist ein Befund, auch wenn er in jeder
|
|
/// Prozessliste steht.</para>
|
|
///
|
|
/// <para><b>Ein Durchgang, drei mögliche Antworten.</b> Der Socket antwortet: Sie lebt.
|
|
/// Der Socket schweigt und der Prozess ist weg: Absturz, mit Abschiedscode. Der Socket
|
|
/// schweigt und der Prozess ist da: verstummt — nach genügend Fehlversuchen in Folge.
|
|
/// Mehr Fälle gibt es nicht, und das ist der Grund, warum die Aufsicht so wenig Code
|
|
/// braucht.</para>
|
|
///
|
|
/// <para><b>Was hier nicht passiert: Neustarten.</b> Die Abnahmebedingung für diese Stufe
|
|
/// lautet „der Manager merkt es und meldet es", und das Konzept trifft zum
|
|
/// selbsttätigen Neustart keine Entscheidung. Eine Instanz in einer Schleife wieder
|
|
/// hochzufahren, die jedes Mal abstürzt, wäre die falsche Vorgabe für etwas, das niemand
|
|
/// entschieden hat — sie gehört als eigene Entscheidung aufgeschrieben, nicht als
|
|
/// Nebenwirkung eingebaut.</para>
|
|
/// </summary>
|
|
public sealed class InstanceSupervisor : IAsyncDisposable
|
|
{
|
|
private readonly SupervisionOptions _options;
|
|
private readonly IInstanceLauncher _launcher;
|
|
private readonly IInstanceChannel _channel;
|
|
private readonly ILogger _logger;
|
|
private readonly TimeProvider _clock;
|
|
private readonly CancellationTokenSource _cts = new();
|
|
|
|
private IInstanceProcess? _process;
|
|
private Task? _loop;
|
|
private int _silentChecks;
|
|
private bool _stopRequested;
|
|
|
|
/// <summary>Seit wann das Verstummen andauert — der Beginn der Frist bis zum harten Ende.</summary>
|
|
private DateTimeOffset? _silentSince;
|
|
|
|
public InstanceSupervisor(
|
|
string instanceDirectory,
|
|
IInstanceLauncher launcher,
|
|
IInstanceChannel channel,
|
|
ILogger logger,
|
|
SupervisionOptions? options = null,
|
|
TimeProvider? clock = null)
|
|
{
|
|
InstanceDirectory = Path.GetFullPath(instanceDirectory);
|
|
_launcher = launcher;
|
|
_channel = channel;
|
|
_logger = logger;
|
|
_options = options ?? SupervisionOptions.Default;
|
|
_clock = clock ?? TimeProvider.System;
|
|
}
|
|
|
|
public string InstanceDirectory { get; }
|
|
|
|
/// <summary>Der Name, sobald die Instanz einmal geantwortet hat; sonst der Ordnername.</summary>
|
|
public string InstanceName { get; private set; } = "";
|
|
|
|
public InstanceCondition Condition { get; private set; } = InstanceCondition.Unknown;
|
|
|
|
public int? ProcessId => _process?.Id;
|
|
|
|
/// <summary>
|
|
/// Jedes Vorkommnis, einmal.
|
|
///
|
|
/// <para><b>Kommt auf dem Faden des Verursachers</b> — wer eine Oberfläche daran
|
|
/// hängt, geht über den Dispatcher.</para>
|
|
/// </summary>
|
|
public event Action<SupervisionEvent>? Observed;
|
|
|
|
// ─── Starten ───
|
|
|
|
/// <summary>
|
|
/// Startet die Instanz und wartet, bis sie sich über ihren Socket meldet.
|
|
///
|
|
/// <para><b>Der Prozess läuft ist nicht dasselbe wie die Instanz läuft.</b> Zwischen
|
|
/// beidem liegen Datenbank, Agenten und Kestrel. Wer nur auf <c>Process.Start</c>
|
|
/// wartet, hält eine Instanz für gestartet, die gleich an einer kaputten
|
|
/// Konfiguration scheitert — und merkt es erst, wenn jemand sie braucht.</para>
|
|
/// </summary>
|
|
public async Task<bool> StartAsync(CancellationToken ct = default)
|
|
{
|
|
// Nicht dem eigenen Merkzettel glauben, sondern nachfragen - und zwar immer,
|
|
// nicht nur wenn die Aufsicht die Instanz fuer laufend haelt. Zwischen zwei
|
|
// Durchgaengen liegen Sekunden, und in denen kann sie ebenso wegbrechen wie
|
|
// wiederkommen. Wer hier ungefragt startet, setzt im zweiten Fall einen zweiten
|
|
// Prozess auf dieselbe state.db.
|
|
var vorhanden = await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false);
|
|
|
|
if (vorhanden is not null)
|
|
{
|
|
if (vorhanden.IsWindingDown)
|
|
{
|
|
// Sie faehrt gerade herunter. Jetzt zu starten hiesse, den neuen Prozess
|
|
// gegen den alten laufen zu lassen, waehrend der noch aufraeumt.
|
|
_logger.LogWarning(
|
|
"Instanz {Name} fährt gerade herunter ({Offen} offen) — es wird nicht "
|
|
+ "gestartet, solange sie das tut.",
|
|
InstanceName, vorhanden.OpenSummary);
|
|
|
|
return false;
|
|
}
|
|
|
|
if (Condition != InstanceCondition.Running)
|
|
{
|
|
_logger.LogInformation(
|
|
"Instanz {Name} galt als {Zustand}, antwortet aber — sie wird übernommen "
|
|
+ "statt gestartet.", InstanceName, Condition);
|
|
}
|
|
|
|
InstanceName = vorhanden.InstanceName;
|
|
Condition = InstanceCondition.Running;
|
|
_silentChecks = 0;
|
|
_silentSince = null;
|
|
|
|
return true;
|
|
}
|
|
|
|
// Ab hier antwortet niemand. Bevor gestartet wird, muss ein etwaiger alter
|
|
// Prozess nachweislich weg sein - genau hier entsteht sonst der Schaden, gegen
|
|
// den dieses ganze Vorhaben gebaut ist: zwei Prozesse auf einer Datenbank.
|
|
if (!await EnsureDeadAsync(ct).ConfigureAwait(false))
|
|
{
|
|
Raise(InstanceEventKind.StartFailed, _process?.Id, null,
|
|
$"Instanz {InstanceName} wird nicht gestartet: Der bisherige Prozess "
|
|
+ $"{_process?.Id} lebt noch und war nicht zu beenden. Zwei Prozesse auf "
|
|
+ "derselben Datenbank wären schlimmer als eine Instanz, die aus bleibt.");
|
|
|
|
return false;
|
|
}
|
|
|
|
_stopRequested = false;
|
|
_silentChecks = 0;
|
|
_silentSince = null;
|
|
|
|
try
|
|
{
|
|
_process?.Dispose();
|
|
_process = _launcher.Start(InstanceDirectory);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Raise(InstanceEventKind.StartFailed, null, null,
|
|
$"Der Instanzprozess ließ sich nicht starten: {ex.Message}");
|
|
|
|
Condition = InstanceCondition.Stopped;
|
|
return false;
|
|
}
|
|
|
|
var identity = await WaitForAnswerAsync(ct).ConfigureAwait(false);
|
|
|
|
if (identity is null)
|
|
{
|
|
var exitCode = _process.ExitCode;
|
|
|
|
// Aufraeumen: Ein Prozess, der laeuft und nicht antwortet, ist nichts, was
|
|
// man stehen laesst - er haelt den Socket besetzt und blockiert damit den
|
|
// naechsten Versuch.
|
|
if (!_process.HasExited) _process.Kill();
|
|
|
|
Raise(InstanceEventKind.StartFailed, _process.Id, exitCode,
|
|
$"Die Instanz hat sich binnen {_options.StartTimeout.TotalSeconds:0}s nicht "
|
|
+ $"auf {InstanceEndpoint.Describe(InstanceDirectory)} gemeldet.");
|
|
|
|
Condition = InstanceCondition.Stopped;
|
|
InstanceStateStore.Clear(InstanceDirectory);
|
|
return false;
|
|
}
|
|
|
|
InstanceName = identity.InstanceName;
|
|
Condition = InstanceCondition.Running;
|
|
|
|
InstanceStateStore.Write(InstanceDirectory, new InstanceState
|
|
{
|
|
InstanceId = identity.InstanceId,
|
|
InstanceName = identity.InstanceName,
|
|
ProcessId = _process.Id,
|
|
StartedAt = _process.StartedAt,
|
|
Endpoint = InstanceEndpoint.Describe(InstanceDirectory),
|
|
ManagerProcessId = Environment.ProcessId
|
|
});
|
|
|
|
Raise(InstanceEventKind.Started, _process.Id, null,
|
|
$"Instanz {identity.InstanceName} läuft (Version {identity.AppVersion}).");
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stellt sicher, dass der bisherige Prozess wirklich weg ist — notfalls mit Gewalt.
|
|
///
|
|
/// <para><b>Warum das eine eigene Prüfung ist.</b> <c>Kill()</c> ist eine Bitte an das
|
|
/// Betriebssystem, keine vollzogene Tatsache: Der Aufruf kehrt zurück, bevor der
|
|
/// Prozess verschwunden ist, und bis dahin hält er die Datenbank und den Socket. Wer
|
|
/// unmittelbar danach startet, hat für einen Moment zwei Prozesse auf einer
|
|
/// <c>state.db</c> — und das ist kein Moment, sondern eine kaputte Datei.</para>
|
|
///
|
|
/// <para><b>Ohne Prozesshandle gilt der Socket.</b> Bei einer wiederaufgenommenen
|
|
/// Instanz gibt es nichts zu greifen (siehe <see cref="ReattachAsync"/>). Dann ist
|
|
/// die einzige verfügbare Auskunft, dass niemand mehr antwortet — und die muss
|
|
/// reichen, denn mehr ist nicht zu haben.</para>
|
|
/// </summary>
|
|
/// <returns><c>true</c>, wenn nachweislich nichts mehr läuft.</returns>
|
|
public async Task<bool> EnsureDeadAsync(CancellationToken ct = default)
|
|
{
|
|
if (_process is null)
|
|
{
|
|
// Kein Handle: Der Socket ist der einzige Zeuge. Wer hier gerufen wird, hat
|
|
// ihn gerade gefragt - aber ein zweites Mal fragen kostet nichts und deckt
|
|
// den Fall ab, dass zwischendurch jemand hochgekommen ist.
|
|
return await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false) is null;
|
|
}
|
|
|
|
if (_process.HasExited)
|
|
return true;
|
|
|
|
_logger.LogWarning(
|
|
"Prozess {Pid} von Instanz {Name} lebt noch — er wird beendet, bevor "
|
|
+ "irgendetwas Neues startet.", _process.Id, InstanceName);
|
|
|
|
try { _process.Kill(); }
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Prozess {Pid} liess sich nicht beenden.", _process.Id);
|
|
}
|
|
|
|
// Kill ist eine Bitte. Auf den Vollzug wird gewartet.
|
|
var deadline = _clock.GetUtcNow() + _options.SilentKillGrace;
|
|
|
|
while (_clock.GetUtcNow() < deadline)
|
|
{
|
|
if (_process.HasExited) return true;
|
|
|
|
await Task.Delay(TimeSpan.FromMilliseconds(250), _clock, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
return _process.HasExited;
|
|
}
|
|
|
|
private async Task<InstanceIdentity?> WaitForAnswerAsync(CancellationToken ct)
|
|
{
|
|
var deadline = _clock.GetUtcNow() + _options.StartTimeout;
|
|
|
|
while (_clock.GetUtcNow() < deadline)
|
|
{
|
|
if (_process is { HasExited: true })
|
|
return null; // Aufgegeben, bevor er antworten konnte.
|
|
|
|
var identity = await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false);
|
|
if (identity is not null) return identity;
|
|
|
|
await Task.Delay(TimeSpan.FromMilliseconds(250), _clock, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ─── Wiederfinden (E-M6) ───
|
|
|
|
/// <summary>
|
|
/// Nimmt eine Instanz wieder in die Aufsicht, die schon lief.
|
|
///
|
|
/// <para>Fällt der Manager aus, laufen die Instanzen weiter — er darf nicht der
|
|
/// Einzelpunkt sein, gegen den dieses Vorhaben gebaut wird. Der neu gestartete Manager
|
|
/// findet sie über die Zustandsdatei im Instanzverzeichnis wieder.</para>
|
|
///
|
|
/// <para><b>Die Datei ist der Hinweis, der Socket ist der Beweis.</b> Erst wird
|
|
/// gefragt, wer da antwortet; nur wenn dieselbe Instanzkennung zurückkommt, wird die
|
|
/// Prozesskennung aus der Datei überhaupt angefasst. Andersherum — Prozess suchen,
|
|
/// finden, für die eigene Instanz halten — führt nach einem Neustart der Maschine
|
|
/// geradewegs dazu, ein fremdes Programm zu beaufsichtigen und im Zweifel zu
|
|
/// beenden.</para>
|
|
/// </summary>
|
|
/// <returns><c>true</c>, wenn eine laufende Instanz übernommen wurde.</returns>
|
|
public async Task<bool> ReattachAsync(CancellationToken ct = default)
|
|
{
|
|
var state = InstanceStateStore.Read(InstanceDirectory);
|
|
var identity = await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false);
|
|
|
|
if (identity is null)
|
|
{
|
|
// Nichts da. Eine Zustandsdatei, die auf niemanden zeigt, ist Altpapier -
|
|
// und solange sie liegt, sieht es aus, als liefe etwas.
|
|
if (state is not null) InstanceStateStore.Clear(InstanceDirectory);
|
|
|
|
Condition = InstanceCondition.Stopped;
|
|
return false;
|
|
}
|
|
|
|
if (state is not null
|
|
&& !string.IsNullOrEmpty(state.InstanceId)
|
|
&& !string.Equals(state.InstanceId, identity.InstanceId, StringComparison.Ordinal))
|
|
{
|
|
// Da antwortet jemand, aber nicht der, der in der Datei steht. Die Datei ist
|
|
// veraltet; die Auskunft vom Socket gilt.
|
|
_logger.LogWarning(
|
|
"Zustandsdatei nennt Instanz {Erwartet}, geantwortet hat {Tatsaechlich} — "
|
|
+ "die Datei wird überschrieben.", state.InstanceId, identity.InstanceId);
|
|
|
|
state = null;
|
|
}
|
|
|
|
InstanceName = identity.InstanceName;
|
|
Condition = InstanceCondition.Running;
|
|
_silentChecks = 0;
|
|
_stopRequested = false;
|
|
|
|
_process?.Dispose();
|
|
_process = state is not null
|
|
? _launcher.Attach(state.ProcessId, state.StartedAt)
|
|
: null;
|
|
|
|
if (_process is null)
|
|
{
|
|
// Sie antwortet, aber der Prozess dahinter ist nicht zu greifen: keine
|
|
// Zustandsdatei, eine veraltete, oder die Kennung gehoert inzwischen einem
|
|
// anderen. Beaufsichtigen laesst sie sich trotzdem - nur beenden nicht mit
|
|
// Gewalt, sondern ausschliesslich ueber ihren Socket.
|
|
_logger.LogInformation(
|
|
"Instanz {Name} übernommen, ohne den Prozess zu fassen zu bekommen — "
|
|
+ "sie lässt sich über ihren Socket ansprechen, aber nicht abschießen.",
|
|
identity.InstanceName);
|
|
}
|
|
|
|
InstanceStateStore.Write(InstanceDirectory, new InstanceState
|
|
{
|
|
InstanceId = identity.InstanceId,
|
|
InstanceName = identity.InstanceName,
|
|
ProcessId = _process?.Id ?? state?.ProcessId ?? 0,
|
|
StartedAt = _process?.StartedAt ?? state?.StartedAt ?? _clock.GetUtcNow(),
|
|
Endpoint = InstanceEndpoint.Describe(InstanceDirectory),
|
|
ManagerProcessId = Environment.ProcessId
|
|
});
|
|
|
|
_logger.LogInformation(
|
|
"Laufende Instanz {Name} wieder in die Aufsicht genommen.", identity.InstanceName);
|
|
|
|
return true;
|
|
}
|
|
|
|
// ─── Nachsehen ───
|
|
|
|
/// <summary>
|
|
/// Ein Aufsichtsdurchgang. Öffentlich, damit Tests ihn auslösen können, statt auf
|
|
/// eine Uhr zu warten.
|
|
/// </summary>
|
|
public async Task CheckAsync(CancellationToken ct = default)
|
|
{
|
|
if (Condition is InstanceCondition.Stopped or InstanceCondition.Crashed)
|
|
return; // Nichts zu beaufsichtigen.
|
|
|
|
var identity = await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false);
|
|
|
|
if (identity is not null)
|
|
{
|
|
_silentChecks = 0;
|
|
_silentSince = null;
|
|
|
|
if (Condition == InstanceCondition.Silent)
|
|
{
|
|
Condition = InstanceCondition.Running;
|
|
Raise(InstanceEventKind.Recovered, _process?.Id, null,
|
|
$"Instanz {InstanceName} antwortet wieder.");
|
|
}
|
|
else
|
|
{
|
|
Condition = InstanceCondition.Running;
|
|
}
|
|
|
|
if (identity.IsWindingDown)
|
|
{
|
|
// Kein Vorkommnis, sondern eine Auskunft: Sie tut, worum gebeten wurde.
|
|
// Ins Protokoll gehoert es trotzdem - hinterher will jemand nachlesen
|
|
// koennen, wie lange das Aufraeumen gedauert hat und woran.
|
|
_logger.LogInformation(
|
|
"Instanz {Name} fährt herunter, noch offen: {Offen}.",
|
|
InstanceName, string.IsNullOrEmpty(identity.OpenSummary)
|
|
? "nichts" : identity.OpenSummary);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Ab hier: Der Socket schweigt.
|
|
|
|
if (_process is { HasExited: true })
|
|
{
|
|
var exitCode = _process.ExitCode;
|
|
|
|
Condition = _stopRequested ? InstanceCondition.Stopped : InstanceCondition.Crashed;
|
|
InstanceStateStore.Clear(InstanceDirectory);
|
|
|
|
Raise(_stopRequested ? InstanceEventKind.StoppedOrderly : InstanceEventKind.Crashed,
|
|
_process.Id, exitCode,
|
|
_stopRequested
|
|
? $"Instanz {InstanceName} beendet (Code {Describe(exitCode)})."
|
|
: $"Instanz {InstanceName} ist unerwartet beendet worden "
|
|
+ $"(Code {Describe(exitCode)}). Niemand hat darum gebeten.");
|
|
|
|
return;
|
|
}
|
|
|
|
// Der Prozess laeuft (oder ist nicht zu fassen) und antwortet nicht. Genau der
|
|
// Zustand aus #529 - deshalb nicht beim ersten Mal Alarm schlagen, aber auch
|
|
// nicht ewig zusehen.
|
|
_silentChecks++;
|
|
|
|
if (Condition == InstanceCondition.Silent)
|
|
{
|
|
// Der Vorfall laeuft schon. Kein zweiter Alarm - aber die Uhr laeuft.
|
|
await EskalierenAsync(ct).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (_silentChecks < _options.SilentChecksBeforeAlarm)
|
|
return;
|
|
|
|
Condition = InstanceCondition.Silent;
|
|
_silentSince = _clock.GetUtcNow();
|
|
|
|
Raise(InstanceEventKind.Unresponsive, _process?.Id, null,
|
|
$"Instanz {InstanceName} läuft, antwortet aber seit {_silentChecks} Durchgängen "
|
|
+ $"nicht mehr auf {InstanceEndpoint.Describe(InstanceDirectory)}. Sie wird "
|
|
+ $"gebeten zu enden; nach {_options.SilentKillGrace.TotalSeconds:0}s ohne "
|
|
+ "Antwort wird der Prozess beendet.");
|
|
|
|
// Ein Versuch, mehr nicht. Wer eben auf demselben Socket nicht geantwortet hat,
|
|
// wird die Bitte wahrscheinlich auch nicht hoeren - aber "wahrscheinlich nicht"
|
|
// ist kein Grund, es zu lassen: Es kann ein einzelner haengender Endpunkt sein
|
|
// und nicht der ganze Prozess. Gewartet wird auf die Uhr, nicht auf die Antwort.
|
|
try
|
|
{
|
|
if (await _channel.RequestStopAsync(InstanceDirectory, ct).ConfigureAwait(false))
|
|
{
|
|
// Ausdruecklich ohne _stopRequested: Niemand hat gewollt, dass diese
|
|
// Instanz endet. Sie antwortet nicht, und was daraufhin passiert, ist ein
|
|
// Absturz und keine geordnete Beendigung - auch dann, wenn der letzte
|
|
// Anstoss von hier kam. Nur so greift der Autostart, und nur so steht
|
|
// hinterher im Protokoll, was wirklich vorgefallen ist.
|
|
_logger.LogInformation(
|
|
"Die Bitte zu enden ist bei der verstummten Instanz {Name} angekommen.",
|
|
InstanceName);
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
_logger.LogInformation(ex,
|
|
"Die Bitte zu enden erreichte die verstummte Instanz {Name} nicht.", InstanceName);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Die Frist für eine verstummte Instanz läuft ab: Der Prozess wird beendet.
|
|
///
|
|
/// <para>Erst hier, nicht beim ersten Schweigen. Zwischen „antwortet gerade nicht" und
|
|
/// „ist hin" liegt ein Agentenlauf, der die Maschine auslastet — und ein Manager, der
|
|
/// dabei abschießt, ist schlimmer als gar keiner.</para>
|
|
/// </summary>
|
|
private async Task EskalierenAsync(CancellationToken ct)
|
|
{
|
|
if (_silentSince is not { } seit) return;
|
|
|
|
var verstrichen = _clock.GetUtcNow() - seit;
|
|
if (verstrichen < _options.SilentKillGrace) return;
|
|
|
|
if (_process is null)
|
|
{
|
|
// Nicht zu greifen (siehe ReattachAsync). Einmal melden, dann Ruhe - sonst
|
|
// steht diese Zeile in jedem Durchgang bis in alle Ewigkeit.
|
|
_silentSince = null;
|
|
|
|
_logger.LogError(
|
|
"Instanz {Name} antwortet seit {Dauer:0}s nicht und ist von diesem Manager "
|
|
+ "nicht zu beenden — es gibt keinen Prozess, den er greifen kann.",
|
|
InstanceName, verstrichen.TotalSeconds);
|
|
|
|
return;
|
|
}
|
|
|
|
_logger.LogWarning(
|
|
"Instanz {Name} antwortet seit {Dauer:0}s nicht — der Prozess wird beendet.",
|
|
InstanceName, verstrichen.TotalSeconds);
|
|
|
|
var pid = _process.Id;
|
|
var tot = await EnsureDeadAsync(ct).ConfigureAwait(false);
|
|
|
|
_silentSince = null;
|
|
|
|
if (!tot)
|
|
{
|
|
_logger.LogError(
|
|
"Prozess {Pid} von Instanz {Name} liess sich nicht beenden. Sie bleibt "
|
|
+ "unter Aufsicht; gestartet wird nichts, solange er lebt.", pid, InstanceName);
|
|
|
|
return;
|
|
}
|
|
|
|
Condition = InstanceCondition.Crashed;
|
|
InstanceStateStore.Clear(InstanceDirectory);
|
|
|
|
Raise(InstanceEventKind.Crashed, pid, _process.ExitCode,
|
|
$"Instanz {InstanceName} hat {verstrichen.TotalSeconds:0}s nicht geantwortet "
|
|
+ "und wurde beendet.");
|
|
}
|
|
|
|
/// <summary>Sieht von selbst nach, im eingestellten Takt.</summary>
|
|
public void BeginWatching() => _loop ??= WatchAsync(_cts.Token);
|
|
|
|
private async Task WatchAsync(CancellationToken ct)
|
|
{
|
|
using var timer = new PeriodicTimer(_options.ProbeInterval, _clock);
|
|
|
|
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
|
|
{
|
|
try
|
|
{
|
|
await CheckAsync(ct).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
// Eine Aufsicht, die an einem Fehler stirbt, ist ab dann keine mehr -
|
|
// und zwar still, was der schlechteste aller Zustaende waere.
|
|
_logger.LogError(ex, "Aufsichtsdurchgang für {Pfad} fehlgeschlagen.",
|
|
InstanceDirectory);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Anhalten ───
|
|
|
|
/// <summary>
|
|
/// Bittet die Instanz zu enden und wartet, bis sie es tut.
|
|
///
|
|
/// <para><b>Erst bitten, dann zwingen.</b> Ein Instanzprozess hält eine offene
|
|
/// SQLite-Datenbank, laufende Agenten und Chatverläufe im Arbeitsspeicher; ihn
|
|
/// abzuschießen heißt, das alles mitten im Satz abzuschneiden. Gefragt wird über den
|
|
/// Socket — den Weg, den es ohnehin gibt. Erst wenn nach
|
|
/// <see cref="SupervisionOptions.StopGrace"/> nichts geschehen ist, wird beendet.</para>
|
|
/// </summary>
|
|
public async Task<bool> StopAsync(CancellationToken ct = default)
|
|
{
|
|
if (Condition is InstanceCondition.Stopped or InstanceCondition.Crashed)
|
|
return true;
|
|
|
|
_stopRequested = true;
|
|
|
|
if (!await _channel.RequestStopAsync(InstanceDirectory, ct).ConfigureAwait(false))
|
|
{
|
|
_logger.LogInformation(
|
|
"Instanz {Name} war für die Bitte zu enden nicht erreichbar — "
|
|
+ "es gilt trotzdem die Frist.", InstanceName);
|
|
}
|
|
|
|
var begonnen = _clock.GetUtcNow();
|
|
var hartesEnde = begonnen + _options.MaxDrainWait;
|
|
var deadline = begonnen + _options.StopGrace;
|
|
var aufraeumenGemeldet = false;
|
|
|
|
while (_clock.GetUtcNow() < deadline)
|
|
{
|
|
if (_process is { HasExited: true })
|
|
{
|
|
Finish(_process.ExitCode, gewaltsam: false);
|
|
return true;
|
|
}
|
|
|
|
var puls = await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false);
|
|
|
|
if (puls is null)
|
|
{
|
|
// Schweigen ist nur dann ein Beweis, wenn es keinen anderen Zeugen gibt.
|
|
// Haelt dieser Manager ein Prozesshandle und sagt es "lebt noch", dann
|
|
// lebt der Prozess - er antwortet bloss nicht mehr. Wer hier "beendet"
|
|
// meldet und anschliessend neu startet, setzt einen zweiten Prozess auf
|
|
// dieselbe state.db. Genau das war der Anlass dieses Vorhabens.
|
|
if (_process is null)
|
|
{
|
|
Finish(null, gewaltsam: false);
|
|
return true;
|
|
}
|
|
}
|
|
else if (puls.IsWindingDown)
|
|
{
|
|
// Sie raeumt auf - also tut sie genau das, worum gebeten wurde. Solange
|
|
// das laeuft, wird die Frist nachgezogen, aber nicht ins Unendliche:
|
|
// MaxDrainWait ist die Grenze, hinter der auch "ich raeume noch auf"
|
|
// nur noch eine andere Art zu haengen ist.
|
|
var neu = _clock.GetUtcNow() + _options.StopGrace;
|
|
deadline = neu < hartesEnde ? neu : hartesEnde;
|
|
|
|
if (!aufraeumenGemeldet)
|
|
{
|
|
aufraeumenGemeldet = true;
|
|
|
|
_logger.LogInformation(
|
|
"Instanz {Name} räumt auf ({Offen}) — es wird bis zu {Grenze:0}s gewartet.",
|
|
InstanceName, puls.OpenSummary, _options.MaxDrainWait.TotalSeconds);
|
|
}
|
|
}
|
|
|
|
await Task.Delay(TimeSpan.FromMilliseconds(250), _clock, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
if (_process is null)
|
|
{
|
|
// Sie hoert nicht, und zu fassen ist sie auch nicht (siehe ReattachAsync).
|
|
_logger.LogWarning(
|
|
"Instanz {Name} reagiert nicht auf die Bitte zu enden und ist nicht zu "
|
|
+ "beenden — es gibt keinen Prozess, den dieser Manager greifen kann.",
|
|
InstanceName);
|
|
|
|
return false;
|
|
}
|
|
|
|
_logger.LogWarning(
|
|
"Instanz {Name} hat binnen {Frist:0}s nicht geendet — sie wird beendet.",
|
|
InstanceName, (_clock.GetUtcNow() - begonnen).TotalSeconds);
|
|
|
|
if (!await EnsureDeadAsync(ct).ConfigureAwait(false))
|
|
{
|
|
_logger.LogError(
|
|
"Prozess {Pid} von Instanz {Name} liess sich nicht beenden.",
|
|
_process.Id, InstanceName);
|
|
|
|
return false;
|
|
}
|
|
|
|
Finish(_process.ExitCode, gewaltsam: true);
|
|
return true;
|
|
}
|
|
|
|
private void Finish(int? exitCode, bool gewaltsam)
|
|
{
|
|
Condition = InstanceCondition.Stopped;
|
|
InstanceStateStore.Clear(InstanceDirectory);
|
|
|
|
Raise(InstanceEventKind.StoppedOrderly, _process?.Id, exitCode,
|
|
gewaltsam
|
|
? $"Instanz {InstanceName} musste beendet werden (Code {Describe(exitCode)})."
|
|
: $"Instanz {InstanceName} beendet (Code {Describe(exitCode)}).");
|
|
}
|
|
|
|
// ─── Kleinkram ───
|
|
|
|
private static string Describe(int? exitCode) => exitCode?.ToString() ?? "unbekannt";
|
|
|
|
private void Raise(InstanceEventKind kind, int? pid, int? exitCode, string message)
|
|
{
|
|
var ereignis = new SupervisionEvent(
|
|
kind, InstanceDirectory,
|
|
string.IsNullOrEmpty(InstanceName) ? Path.GetFileName(InstanceDirectory) : InstanceName,
|
|
pid, exitCode, message, _clock.GetUtcNow());
|
|
|
|
if (ereignis.WorthReporting)
|
|
_logger.LogError("{Meldung}", message);
|
|
else
|
|
_logger.LogInformation("{Meldung}", message);
|
|
|
|
Observed?.Invoke(ereignis);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _cts.CancelAsync().ConfigureAwait(false);
|
|
|
|
if (_loop is not null)
|
|
{
|
|
try { await _loop.ConfigureAwait(false); }
|
|
catch (OperationCanceledException) { /* erwartet */ }
|
|
}
|
|
|
|
_cts.Dispose();
|
|
|
|
// Der Prozess wird NICHT beendet: Faellt der Manager weg, laufen die Instanzen
|
|
// weiter (E-M6). Losgelassen wird nur das Handle.
|
|
_process?.Dispose();
|
|
_process = null;
|
|
}
|
|
}
|