Instanzmanager: Weboberflaeche kennt Instanzen, geordnetes Ende, Puls (#528, #531)

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:
Richard
2026-09-10 11:15:59 +02:00
co-authored by Claude Opus 5
parent d44bbeac1d
commit dcffcdcff5
48 changed files with 4852 additions and 171 deletions
+67 -6
View File
@@ -3,8 +3,34 @@ using ClawdDotNet.Core.Instances;
namespace ClawdDotNet.Manager;
/// <summary>Was eine Instanz über sich sagt, wenn man sie über ihren Socket fragt.</summary>
public sealed record InstanceIdentity(string InstanceId, string InstanceName, string AppVersion);
/// <summary>
/// Was eine Instanz über sich sagt, wenn man sie über ihren Socket fragt — ihr Puls.
///
/// <para><b>Die Identität ist der Beweis, der Rest ist die Auskunft.</b> Dass überhaupt
/// jemand antwortet, unterscheidet „läuft" von „tot". Erst
/// <see cref="Phase"/> und <see cref="OpenWork"/> unterscheiden die beiden Fälle, die
/// gleich aussehen und gegensätzlich zu behandeln sind: eine Instanz, die hängt, und
/// eine, die gerade geordnet aufräumt.</para>
/// </summary>
/// <param name="Phase">Läuft, fährt herunter, ist fertig.</param>
/// <param name="OpenWork">Wie viele Arbeiten beim Herunterfahren noch auslaufen.</param>
/// <param name="OpenSummary">Was das im Klartext ist, fürs Protokoll.</param>
public sealed record InstanceIdentity(
string InstanceId,
string InstanceName,
string AppVersion,
ShutdownPhase Phase = ShutdownPhase.Running,
int OpenWork = 0,
string OpenSummary = "")
{
/// <summary>
/// Fährt diese Instanz gerade geordnet herunter und ist dabei noch beschäftigt?
///
/// <para>Die Aufsicht darf so eine Instanz nicht abschießen — sie tut genau das,
/// worum gebeten wurde.</para>
/// </summary>
public bool IsWindingDown => Phase == ShutdownPhase.Draining;
}
/// <summary>
/// Das Gespräch mit einer Instanz über ihren Socket: Wer bist du — und bitte beende dich.
@@ -74,18 +100,26 @@ public sealed class SocketInstanceChannel(TimeSpan? timeout = null) : IInstanceC
using var client = InstanceEndpoint.CreateClient(instanceDirectory);
client.Timeout = _timeout;
using var antwort = await client.GetAsync("/api/info", ct).ConfigureAwait(false);
// Der Puls und nicht /api/info: Er liegt hinter dem Socket-Gate, gehoert also
// dem Manager allein, und er sagt zusaetzlich, ob die Instanz gerade
// aufraeumt. Von dieser Unterscheidung haengt ab, ob abgeschossen wird.
using var antwort = await client
.GetAsync("/api/instanz/puls", ct).ConfigureAwait(false);
if (!antwort.IsSuccessStatusCode)
return null;
var json = await antwort.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
return new InstanceIdentity(
Text(doc.RootElement, "instanceId"),
Text(doc.RootElement, "instanceName"),
Text(doc.RootElement, "appVersion"));
Text(root, "instanceId"),
Text(root, "instanceName"),
Text(root, "appVersion"),
Phase(root),
Zahl(root, "offenGesamt"),
Beschreibung(root));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
@@ -139,4 +173,31 @@ public sealed class SocketInstanceChannel(TimeSpan? timeout = null) : IInstanceC
=> root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() ?? ""
: "";
private static int Zahl(JsonElement root, string name)
=> root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number
? v.GetInt32()
: 0;
/// <summary>
/// Die Phase aus der Antwort — im Zweifel <c>Running</c>.
///
/// <para>Ein unbekannter Wert darf hier nicht als „fährt herunter" durchgehen: Das
/// wäre der Freibrief, mit dem eine hängende Instanz beliebig lange stehen bliebe.
/// <c>Running</c> ist die Auslegung, bei der die Aufsicht wachsam bleibt.</para>
/// </summary>
private static ShutdownPhase Phase(JsonElement root)
=> Enum.TryParse<ShutdownPhase>(Text(root, "phase"), ignoreCase: true, out var phase)
? phase
: ShutdownPhase.Running;
private static string Beschreibung(JsonElement root)
{
if (!root.TryGetProperty("offeneArbeiten", out var liste)
|| liste.ValueKind != JsonValueKind.Array)
return "";
return string.Join(", ", liste.EnumerateArray()
.Select(e => $"{Text(e, "name")}: {Zahl(e, "anzahl")}"));
}
}
@@ -249,6 +249,15 @@ public sealed class InstanceRegistry : IAsyncDisposable
}
}
/// <summary>
/// Die Aufsicht über eine bestimmte Instanz, oder <c>null</c>.
///
/// <para>Damit sich ein Aufsichtsdurchgang von außen auslösen lässt, statt auf die Uhr
/// zu warten — Tests mit Wartezeiten sind Tests, die man irgendwann abschaltet.</para>
/// </summary>
public InstanceSupervisor? SupervisorFor(string instanceDirectory)
=> _supervisors.GetValueOrDefault(Path.GetFullPath(instanceDirectory));
private InstanceSupervisor Find(string instanceDirectory)
{
var pfad = Path.GetFullPath(instanceDirectory);
+273 -23
View File
@@ -34,17 +34,37 @@ public enum InstanceCondition
/// 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 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));
StopGrace: TimeSpan.FromSeconds(20),
SilentKillGrace: TimeSpan.FromSeconds(30),
MaxDrainWait: TimeSpan.FromMinutes(5));
}
/// <summary>
@@ -91,6 +111,9 @@ public sealed class InstanceSupervisor : IAsyncDisposable
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,
@@ -136,23 +159,58 @@ public sealed class InstanceSupervisor : IAsyncDisposable
/// </summary>
public async Task<bool> StartAsync(CancellationToken ct = default)
{
if (Condition == InstanceCondition.Running)
{
// Nicht dem eigenen Merkzettel glauben, sondern nachfragen. Zwischen zwei
// Aufsichtsdurchgaengen liegen Sekunden, und in denen kann die Instanz weg
// sein: Wer hier "laeuft schon" antwortet, meldet einen Erfolg, hinter dem
// nichts steht - und der naechste Durchgang meldet dann den Absturz, den
// niemand mehr erwartet.
if (await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false) is not null)
return true;
// 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);
_logger.LogInformation(
"Instanz {Name} galt als laufend, antwortet aber nicht mehr — sie wird gestartet.",
InstanceName);
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
{
@@ -207,6 +265,57 @@ public sealed class InstanceSupervisor : IAsyncDisposable
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;
@@ -324,6 +433,7 @@ public sealed class InstanceSupervisor : IAsyncDisposable
if (identity is not null)
{
_silentChecks = 0;
_silentSince = null;
if (Condition == InstanceCondition.Silent)
{
@@ -336,6 +446,17 @@ public sealed class InstanceSupervisor : IAsyncDisposable
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;
}
@@ -364,16 +485,101 @@ public sealed class InstanceSupervisor : IAsyncDisposable
_silentChecks++;
if (Condition == InstanceCondition.Silent)
return; // Schon gemeldet. Ein Alarm je Vorfall, nicht je Durchgang.
{
// 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)}.");
+ $"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>
@@ -424,17 +630,53 @@ public sealed class InstanceSupervisor : IAsyncDisposable
+ "es gilt trotzdem die Frist.", InstanceName);
}
var deadline = _clock.GetUtcNow() + _options.StopGrace;
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 }
|| await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false) is null)
if (_process is { HasExited: true })
{
Finish(_process?.ExitCode, gewaltsam: false);
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);
}
@@ -450,10 +692,18 @@ public sealed class InstanceSupervisor : IAsyncDisposable
}
_logger.LogWarning(
"Instanz {Name} hat binnen {Frist}s nicht geendet — sie wird beendet.",
InstanceName, _options.StopGrace.TotalSeconds);
"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;
}
_process.Kill();
Finish(_process.ExitCode, gewaltsam: true);
return true;
}
+4
View File
@@ -102,6 +102,10 @@ public sealed class ManagerCore : IAsyncDisposable
if (_reporter is not null)
_ = _reporter.ReportAsync(ereignis);
// Nur beim Absturz, und das schliesst die verstummte Instanz ein: Die
// Aufsicht beendet sie nach ihrer Frist und meldet das als Absturz - denn
// gewollt hat es niemand. Ein zweiter Einstieg bei "Unresponsive" waere ein
// zweiter Neustart fuer denselben Vorfall.
if (ereignis.Kind == InstanceEventKind.Crashed)
_ = RestartAfterCrashAsync(ereignis);
};
+19 -1
View File
@@ -130,6 +130,22 @@ public sealed class ManagerSettings
[JsonPropertyName("restartWindowMinutes")]
public int RestartWindowMinutes { get; set; } = 10;
/// <summary>
/// Wie lange eine verstummte Instanz nach der angeordneten Beendigung noch bekommt,
/// bevor der Prozess hart beendet wird.
/// </summary>
[JsonPropertyName("silentKillGraceSeconds")]
public int SilentKillGraceSeconds { get; set; } = 30;
/// <summary>
/// Die Obergrenze beim Anhalten, solange die Instanz meldet, dass sie aufräumt.
///
/// <para>Muss über der Frist liegen, die sich die Instanz selbst gibt — sonst schießt
/// der Manager in das Aufräumen hinein, um das er gebeten hat.</para>
/// </summary>
[JsonPropertyName("maxDrainWaitSeconds")]
public int MaxDrainWaitSeconds { get; set; } = 300;
/// <summary>Die geprüfte Neustartregel.</summary>
public RestartPolicy ToRestartPolicy() => new(
MaxRestarts: Math.Clamp(MaxRestartsInWindow, 1, 100),
@@ -146,7 +162,9 @@ public sealed class ManagerSettings
ProbeInterval: TimeSpan.FromSeconds(Math.Clamp(ProbeIntervalSeconds, 1, 3600)),
StartTimeout: TimeSpan.FromSeconds(Math.Clamp(StartTimeoutSeconds, 5, 600)),
SilentChecksBeforeAlarm: Math.Clamp(SilentChecksBeforeAlarm, 1, 100),
StopGrace: TimeSpan.FromSeconds(Math.Clamp(StopGraceSeconds, 1, 600)));
StopGrace: TimeSpan.FromSeconds(Math.Clamp(StopGraceSeconds, 1, 600)),
SilentKillGrace: TimeSpan.FromSeconds(Math.Clamp(SilentKillGraceSeconds, 1, 600)),
MaxDrainWait: TimeSpan.FromSeconds(Math.Clamp(MaxDrainWaitSeconds, 1, 3600)));
}
/// <summary>