using ClawdDotNet.Core.Instances;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Manager;
/// Was die Aufsicht über den Zustand einer Instanz weiß.
public enum InstanceCondition
{
/// Noch nicht nachgesehen.
Unknown,
/// Läuft nicht — und das ist in Ordnung so.
Stopped,
/// Läuft und antwortet.
Running,
/// Der Prozess ist da, antwortet aber nicht mehr.
Silent,
/// Weg, ohne dass jemand darum gebeten hätte.
Crashed
}
/// Stellschrauben der Aufsicht.
/// Wie oft nachgesehen wird.
/// Wie lange eine frisch gestartete Instanz Zeit hat, sich zu melden.
///
/// Wie viele Fehlversuche in Folge nötig sind, bevor „antwortet nicht" gilt.
///
/// 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.
///
/// Wie lange eine Instanz nach der Bitte zu enden noch bekommt.
///
/// Wie lange eine verstummte Instanz nach der angeordneten Beendigung noch bekommt.
///
/// 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.
///
///
/// Die absolute Obergrenze beim Anhalten, wenn die Instanz meldet, dass sie aufräumt.
///
/// Muss größer sein als die Frist, die sich die Instanz selbst gibt
/// (ShutdownCoordinator.Budget) — 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.
///
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));
}
///
/// Die Aufsicht über eine Instanz: starten, anhalten, nachsehen, wiederfinden.
///
/// Der Anlass. 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 in 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).
///
/// Deshalb fragt diese Klasse den Socket und nicht die Prozessliste. 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.
///
/// Ein Durchgang, drei mögliche Antworten. 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.
///
/// Was hier nicht passiert: Neustarten. 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.
///
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;
/// Seit wann das Verstummen andauert — der Beginn der Frist bis zum harten Ende.
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; }
/// Der Name, sobald die Instanz einmal geantwortet hat; sonst der Ordnername.
public string InstanceName { get; private set; } = "";
public InstanceCondition Condition { get; private set; } = InstanceCondition.Unknown;
public int? ProcessId => _process?.Id;
///
/// Jedes Vorkommnis, einmal.
///
/// Kommt auf dem Faden des Verursachers — wer eine Oberfläche daran
/// hängt, geht über den Dispatcher.
///
public event Action? Observed;
// ─── Starten ───
///
/// Startet die Instanz und wartet, bis sie sich über ihren Socket meldet.
///
/// Der Prozess läuft ist nicht dasselbe wie die Instanz läuft. Zwischen
/// beidem liegen Datenbank, Agenten und Kestrel. Wer nur auf Process.Start
/// wartet, hält eine Instanz für gestartet, die gleich an einer kaputten
/// Konfiguration scheitert — und merkt es erst, wenn jemand sie braucht.
///
public async Task 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;
}
///
/// Stellt sicher, dass der bisherige Prozess wirklich weg ist — notfalls mit Gewalt.
///
/// Warum das eine eigene Prüfung ist. Kill() 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
/// state.db — und das ist kein Moment, sondern eine kaputte Datei.
///
/// Ohne Prozesshandle gilt der Socket. Bei einer wiederaufgenommenen
/// Instanz gibt es nichts zu greifen (siehe ). Dann ist
/// die einzige verfügbare Auskunft, dass niemand mehr antwortet — und die muss
/// reichen, denn mehr ist nicht zu haben.
///
/// true, wenn nachweislich nichts mehr läuft.
public async Task 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 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) ───
///
/// Nimmt eine Instanz wieder in die Aufsicht, die schon lief.
///
/// 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.
///
/// Die Datei ist der Hinweis, der Socket ist der Beweis. 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.
///
/// true, wenn eine laufende Instanz übernommen wurde.
public async Task 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 ───
///
/// Ein Aufsichtsdurchgang. Öffentlich, damit Tests ihn auslösen können, statt auf
/// eine Uhr zu warten.
///
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);
}
}
///
/// Die Frist für eine verstummte Instanz läuft ab: Der Prozess wird beendet.
///
/// 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.
///
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.");
}
/// Sieht von selbst nach, im eingestellten Takt.
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 ───
///
/// Bittet die Instanz zu enden und wartet, bis sie es tut.
///
/// Erst bitten, dann zwingen. 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
/// nichts geschehen ist, wird beendet.
///
public async Task 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;
}
}