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; } }