Instanzmanager Stufe 3: Prozessaufsicht, Start und Stopp (#528)
Der Anlass des ganzen Vorhabens: Am 2026-09-08 hat sich eine Instanz aufgehaengt und wurde von Windows beendet. Zu diesem Vorgang steht keine einzige Zeile in den Instanzprotokollen, kein Bugtracker-Eintrag, kein Watchdog-Alarm - weil Meldeweg und Watchdog in genau dem Prozess lebten, ueber den sie haetten berichten sollen. Ab jetzt sieht jemand anders hin. Neu ist ClawdDotNet.Manager mit genau EINEM Projektverweis: Core. Das ist keine Sparsamkeit, sondern die halbe Architektur - App schleppt sechzehn Werkzeugprojekte hinter sich her, und "keine Werkzeuge im Manager" soll eine Eigenschaft des Bauplans sein und keine Absichtserklaerung. DIE ENTSCHEIDUNG, AN DER ALLES HAENGT: Die Aufsicht fragt den Socket, 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 Durchgang kennt drei Antworten: Socket antwortet, sie laeuft. Socket schweigt und der Prozess ist weg, Absturz mit Abschiedscode. Socket schweigt und der Prozess lebt, verstummt - aber erst nach mehreren Fehlversuchen in Folge, denn ein Alarm bei jedem Aussetzer ist nach einer Woche der Alarm, den niemand mehr liest. Ein Start gilt erst, wenn die Instanz antwortet. Zwischen "der Prozess laeuft" und "die Instanz laeuft" liegen Datenbank, Agenten und Kestrel; wer nur auf Process.Start wartet, haelt eine Instanz fuer gestartet, die gleich an einer kaputten Konfiguration scheitert. Angehalten wird durch Bitten. Neu auf der Instanzseite: POST /api/instanz/beenden, ausschliesslich ueber den Socket - ueber TCP gibt es den Endpunkt nicht, auch mit gueltigem Token nicht, und er antwortet dort 404 statt 403. Die Lebensdauer einer Instanz gehoert dem Manager; eine Browsersitzung soll den Prozess, der sie bedient, nicht beenden koennen. Ausgeloest wird der Wunsch erst nach dem Absenden der Antwort (Response.OnCompleted), sonst bekaeme der Manager statt einer Zusage einen Verbindungsabbruch und hielte ihn fuer einen Fehlschlag. Wiederfinden nach einem Managerausfall (E-M6): Die Zustandsdatei instance-state.json ist der Hinweis, der Socket ist der Beweis. Erst wird gefragt, wer da antwortet; nur bei gleicher Instanzkennung wird die Prozesskennung aus der Datei angefasst. Andersherum fuehrt eine wiederverwendete Nummer nach einem Neustart der Maschine geradewegs dazu, ein fremdes Programm zu beaufsichtigen und beim Anhalten zu beenden. Selbsttaetiger Neustart ist bewusst NICHT gebaut. Die Abnahmebedingung lautet "merkt es und meldet es", und zum Neustarten trifft das Konzept keine Entscheidung; eine Instanz in einer Schleife wieder hochzufahren, die jedes Mal abstuerzt, waere die falsche Vorgabe fuer etwas, das niemand entschieden hat. Als offene Frage aufgeschrieben. ABNAHME, mit echten Prozessen gemessen: Manager startet Instanz Test in 0,7 s, identifiziert sie ueber die Named Pipe, schreibt die Zustandsdatei (pid 54476, id d68af6fd). Prozess von aussen abgeschossen. Naechster Durchgang: Crashed, Code -1, meldenswert, Zustandsdatei weggeraeumt. Dazu 20 Tests gegen gesteuerte Prozesse - ein Absturz zum gewuenschten Zeitpunkt und eine wiederverwendete Prozesskennung sind mit einem echten Programm nicht herzustellen. Noch offen in dieser Stufe: Instanzen anlegen, Lizenz im Manager, Sub-Token je Instanz, und der Managerprozess selbst. Build ohne Warnungen, 737 Tests gruen, 8 uebersprungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,11 +20,13 @@
|
||||
<Project Path="src/ClawdDotNet.Tools.Taskboard/ClawdDotNet.Tools.Taskboard.csproj" />
|
||||
<Project Path="src/ClawdDotNet.Tools.RocketChat/ClawdDotNet.Tools.RocketChat.csproj" />
|
||||
<Project Path="src/ClawdDotNet.Web/ClawdDotNet.Web.csproj" />
|
||||
<Project Path="src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ClawdDotNet.App.Tests/ClawdDotNet.App.Tests.csproj" />
|
||||
<Project Path="tests/ClawdDotNet.Core.Tests/ClawdDotNet.Core.Tests.csproj" />
|
||||
<Project Path="tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj" />
|
||||
<Project Path="tests/ClawdDotNet.Web.Tests/ClawdDotNet.Web.Tests.csproj" />
|
||||
<Project Path="tests/ClawdDotNet.Manager.Tests/ClawdDotNet.Manager.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -282,6 +282,14 @@ startet sie und gibt ihr den Socketpfad mit; die Instanz verbindet sich einmal
|
||||
zurück und beendet sich, wenn das nicht gelingt. Kein Selbstzweck-Handschlag,
|
||||
sondern die Prüfung aus E-M3.
|
||||
|
||||
**Startet der Manager eine abgestürzte Instanz wieder?** Bisher nicht — er merkt es und
|
||||
meldet es, mehr nicht. Dafür spricht, dass eine Instanz, die nachts um drei abstürzt, bis
|
||||
zum Morgen tot bleibt. Dagegen spricht die Schleife: Eine Instanz, die an einer kaputten
|
||||
Konfiguration scheitert, stürzt beim zehnten Versuch genauso ab wie beim ersten, und dann
|
||||
hat man statt einer toten Instanz eine tote Instanz und ein volles Protokoll. Wenn es
|
||||
kommt, dann mit Wartezeitverdopplung und einer Obergrenze, nach der er es sein lässt und
|
||||
das meldet.
|
||||
|
||||
**Der Aufhänger aus 1.1 ist nicht geklärt** (#529). Er verschwindet mit dem
|
||||
Umbau vermutlich von selbst, weil es den Selbst-Neustart nicht mehr gibt — aber
|
||||
„vermutlich" ist kein Befund. Zwei unbelegte Verdachtsmomente: `BackupScheduler`
|
||||
@@ -313,7 +321,7 @@ Verbindung und führt sie zusammen, mit der Instanzkennung an jedem Ereignis.
|
||||
|---|---|---|
|
||||
| 1 ✔ | Einstellungen und Protokolle je Instanz trennen (Abschnitt 6) | Zwei Instanzen gleichzeitig, keine schreibt der anderen ins Protokoll |
|
||||
| 2 ✔ | Instanzprozess kopflos lauffähig, Kestrel auf dem Socket | Eine Instanz läuft ohne Fenster, der Socket antwortet |
|
||||
| 3 | Manager: anlegen, starten, anhalten, Prozessaufsicht, Lizenz, Sub-Token | Eine Instanz abschießen — der Manager merkt es und meldet es |
|
||||
| 3 ◑ | Manager: anlegen, starten, anhalten, Prozessaufsicht, Lizenz, Sub-Token | Eine Instanz abschießen — der Manager merkt es und meldet es |
|
||||
| 4 | Weboberfläche zieht um, Weiterleitung, ein Ereignisstrom | Eine Adresse, ein Zugang, Wechsel ohne Neustart |
|
||||
| 5 | Instanzfenster als eigener Prozess, Wechsel holt das Fenster nach vorn | Kein Neustart mehr beim Wechseln |
|
||||
| 6 | Sicherungen im Manager (falls Abschnitt 7 dafür entscheidet) | Zurückspielen hält die Instanz an und startet sie wieder |
|
||||
@@ -399,3 +407,65 @@ Aufräumen beim Beenden) sind unter Windows gegenstandslos und werden dort mit B
|
||||
ungeprüft, obwohl er die Zielplattform ist. Und das geordnete Ende auf `SIGTERM` ließ sich
|
||||
hier nicht auslösen: Windows kennt das Signal für einen fensterlosen Prozess nicht.
|
||||
Beides gehört auf die Linux-Testmaschine.
|
||||
|
||||
### 9.3 Was Stufe 3 bisher gebracht hat
|
||||
|
||||
**Die Aufsicht steht, Lizenz und Sub-Token fehlen noch.** Neu ist das Projekt
|
||||
`ClawdDotNet.Manager` mit genau **einem** Projektverweis: `ClawdDotNet.Core`. Das ist
|
||||
keine Sparsamkeit, sondern die halbe Architektur — `ClawdDotNet.App` schleppt sechzehn
|
||||
Werkzeugprojekte hinter sich her, und „keine Werkzeuge im Manager" soll eine Eigenschaft
|
||||
des Bauplans sein und keine Absichtserklärung. Damit `InstanceDirectoryManager` von beiden
|
||||
Seiten erreichbar ist, ist er nach `Core/Instances` gezogen; dorthin gehört er ohnehin,
|
||||
denn er beschreibt, wie eine Instanz auf der Platte liegt.
|
||||
|
||||
**Der Socket ist die Auskunftsquelle, nicht die Prozessliste.** Das ist die eine
|
||||
Entscheidung, an der alles hängt. Die Prozessliste sagt, *dass* etwas da ist — sie sagt
|
||||
nicht, ob es noch arbeitet, und genau das war der Zustand aus 1.1: Die Anwendung stand
|
||||
einwandfrei in der Prozessliste, bis Windows sie als „reagiert nicht mehr" beendet hat.
|
||||
Ein Aufsichtsdurchgang fragt deshalb den Socket und kennt drei Antworten:
|
||||
|
||||
| Socket | Prozess | Befund |
|
||||
|---|---|---|
|
||||
| antwortet | — | läuft |
|
||||
| schweigt | ist weg | **Absturz**, mit Abschiedscode |
|
||||
| schweigt | lebt | **verstummt** — nach genügend Fehlversuchen in Folge |
|
||||
|
||||
Nicht beim ersten Aussetzer: Eine einzelne verpasste Antwort ist Alltag, und ein Alarm bei
|
||||
jedem Aussetzer ist nach einer Woche der Alarm, den niemand mehr liest.
|
||||
|
||||
**Ein Start gilt erst, wenn die Instanz antwortet.** Zwischen „der Prozess läuft" und „die
|
||||
Instanz läuft" 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.
|
||||
|
||||
**Angehalten wird durch Bitten, nicht durch Schießen.** Neu auf der Instanzseite:
|
||||
`POST /api/instanz/beenden`, **ausschließlich über den Socket** — über TCP gibt es den
|
||||
Endpunkt nicht, auch mit gültigem Token nicht, und er antwortet dort mit 404 statt 403.
|
||||
Die Lebensdauer einer Instanz gehört dem Manager; eine Browsersitzung soll den Prozess,
|
||||
der sie bedient, nicht beenden können. Erst nach Ablauf der Frist wird hart beendet.
|
||||
|
||||
**Wiederfinden nach einem Managerausfall (E-M6).** Die Zustandsdatei
|
||||
`<Instanz>/instance-state.json` ist der *Hinweis*, der Socket ist der *Beweis*: Erst wird
|
||||
gefragt, wer da antwortet, und 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 beim Anhalten zu beenden. Startzeit und
|
||||
Prozesskennung zusammen entlarven eine wiederverwendete Nummer.
|
||||
|
||||
**Selbsttätiger Neustart ist bewusst nicht gebaut.** Die Abnahmebedingung lautet „merkt es
|
||||
und meldet es", und zum Neustarten trifft dieses Papier 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. **Das ist eine offene Frage** — siehe Abschnitt 7.
|
||||
|
||||
**Abnahme, mit echten Prozessen gemessen.** Der Manager hat die Instanz `Test` in 0,7 s
|
||||
gestartet und über die Named Pipe identifiziert, die Zustandsdatei geschrieben
|
||||
(`pid=54476`, `id=d68af6fd`), dann wurde der Prozess von außen abgeschossen. Der nächste
|
||||
Durchgang meldete `Crashed`, Abschiedscode −1, meldenswert, und räumte die Zustandsdatei
|
||||
weg. Dazu 20 Tests gegen gesteuerte Prozesse — ein Absturz zum gewünschten Zeitpunkt und
|
||||
eine wiederverwendete Prozesskennung sind mit einem echten Programm nicht herzustellen.
|
||||
|
||||
**Ein Hinweis zur Messumgebung.** Die Abnahme lief über einen eigens gebauten Treiber
|
||||
außerhalb der Werkzeugumgebung. Ein Prozess, den ein Agentenwerkzeug hier startet, landet
|
||||
in einem Job-Objekt, das seine Kinder sofort mitnimmt; der Instanzprozess starb dadurch,
|
||||
bevor er die erste Protokollzeile schreiben konnte. Das ist ein Artefakt der Umgebung und
|
||||
keine Eigenschaft des Managers — aber es heißt, dass ein Abnahmetest dieser Art nicht in
|
||||
die gewöhnliche Testreihe gehört.
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Runtime.InteropServices;
|
||||
using ClawdDotNet.App;
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Web;
|
||||
using ClawdDotNet.Web.Endpoints;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Desktop;
|
||||
@@ -88,7 +89,7 @@ internal static class HeadlessRun
|
||||
"Instanz {Name} läuft ohne Fenster auf {Endpunkt}",
|
||||
host.Instance.InstanceName, web.Endpoint);
|
||||
|
||||
await WaitForShutdownAsync(logger);
|
||||
await WaitForShutdownAsync(logger, web.ShutdownSignal);
|
||||
|
||||
logger.LogInformation("Instanz {Name} wird beendet.", host.Instance.InstanceName);
|
||||
}
|
||||
@@ -100,16 +101,16 @@ internal static class HeadlessRun
|
||||
/// <summary>
|
||||
/// Wartet, bis jemand Schluss sagt.
|
||||
///
|
||||
/// <para>Drei Wege, weil drei Absender in Frage kommen: <c>SIGTERM</c> von systemd
|
||||
/// oder vom Instanzmanager, <c>SIGINT</c> vom Terminal, und
|
||||
/// <see cref="AppDomain.ProcessExit"/> als letzte Gelegenheit.</para>
|
||||
/// <para>Vier Wege, weil vier Absender in Frage kommen: die Bitte des Instanzmanagers
|
||||
/// über den Socket (der Regelfall), <c>SIGTERM</c> von systemd, <c>SIGINT</c> vom
|
||||
/// Terminal, und <see cref="AppDomain.ProcessExit"/> als letzte Gelegenheit.</para>
|
||||
///
|
||||
/// <para><b>Das Signal wird abgefangen, nicht durchgelassen</b>
|
||||
/// (<c>context.Cancel = true</c>). Sonst räumte die Laufzeit den Prozess ab, während
|
||||
/// das geordnete Herunterfahren noch läuft — und genau dabei wird der Schreibpuffer
|
||||
/// des Protokolls geleert. Ein Ende ohne diese Zeilen ist der Zustand aus #529.</para>
|
||||
/// </summary>
|
||||
private static async Task WaitForShutdownAsync(ILogger logger)
|
||||
private static async Task WaitForShutdownAsync(ILogger logger, ShutdownSignal signal)
|
||||
{
|
||||
var schluss = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
@@ -119,6 +120,11 @@ internal static class HeadlessRun
|
||||
logger.LogInformation("Beendigung angefordert ({Grund}).", grund);
|
||||
}
|
||||
|
||||
// Der Instanzmanager, ueber den Socket. Der uebliche Weg, sobald es ihn gibt:
|
||||
// Er bittet, statt zu schiessen - ein Instanzprozess haelt eine offene Datenbank
|
||||
// und laufende Agenten, und beides will zu Ende gebracht werden.
|
||||
signal.Requested += () => Beenden("Instanzmanager");
|
||||
|
||||
using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
|
||||
{
|
||||
context.Cancel = true;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Manager</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- EIN Verweis, und der ist die halbe Architektur (#528).
|
||||
|
||||
ClawdDotNet.App haengt sechzehn Werkzeugprojekte hinter sich (Telegram, Mail, FTP,
|
||||
Rocket.Chat, ...). Der Manager soll keines davon kennen: Er startet Instanzen,
|
||||
beobachtet sie und meldet, was mit ihnen passiert - Fachlogik laeuft ausschliesslich
|
||||
in den Instanzprozessen. Waere App hier verwiesen, wuerde das nicht ausgefuehrt,
|
||||
aber geladen, und "keine Werkzeuge im Manager" waere eine Absichtserklaerung statt
|
||||
einer Eigenschaft des Bauplans.
|
||||
|
||||
Wer hier einen Verweis auf ClawdDotNet.App, ClawdDotNet.Web oder ein
|
||||
Werkzeugprojekt ergaenzt, hat den Schnitt verletzt. -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ClawdDotNet.Manager.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Text.Json;
|
||||
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>
|
||||
/// Das Gespräch mit einer Instanz über ihren Socket: Wer bist du — und bitte beende dich.
|
||||
///
|
||||
/// <para>Beides gehört zusammen, weil es derselbe Weg ist. Und beides steht hinter einer
|
||||
/// Schnittstelle, damit die Aufsicht prüfbar bleibt, ohne echte Prozesse zu starten: Ein
|
||||
/// Absturz zum gewünschten Zeitpunkt, ein Prozess, der lebt und schweigt, eine
|
||||
/// wiederverwendete Prozesskennung — das alles ist mit einem laufenden Programm entweder
|
||||
/// nicht oder nur mit Wartezeiten herzustellen, und Tests mit Wartezeiten sind Tests, die
|
||||
/// man irgendwann abschaltet.</para>
|
||||
/// </summary>
|
||||
public interface IInstanceChannel
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>null</c> heißt: Da antwortet niemand. Alles andere ist die Auskunft der
|
||||
/// laufenden Instanz.
|
||||
/// </summary>
|
||||
Task<InstanceIdentity?> AskAsync(string instanceDirectory, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Bittet die Instanz, sich zu beenden.
|
||||
///
|
||||
/// <para><c>false</c> heißt nur, dass die Bitte nicht angekommen ist — nicht, dass die
|
||||
/// Instanz noch läuft. Ob sie endet, beantwortet allein der nächste Blick auf ihren
|
||||
/// Socket.</para>
|
||||
/// </summary>
|
||||
Task<bool> RequestStopAsync(string instanceDirectory, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der echte Weg: eine HTTP-Anfrage über den Socket im Instanzverzeichnis.
|
||||
///
|
||||
/// <para><b>Warum der Socket und nicht die Prozessliste.</b> Zwei Gründe, und der zweite
|
||||
/// ist der wichtigere:</para>
|
||||
///
|
||||
/// <para>Erstens ist eine Prozesskennung kein Beweis. Nach einem Neustart der Maschine
|
||||
/// gehört 4711 mit einiger Wahrscheinlichkeit einem fremden Programm; der Socket dagegen
|
||||
/// antwortet mit der Instanzkennung, und die ist eindeutig.</para>
|
||||
///
|
||||
/// <para>Zweitens — und das ist der Anlass des ganzen Vorhabens — sagt die Prozessliste
|
||||
/// nur, <em>dass</em> etwas läuft, nicht ob es noch arbeitet. Am 2026-09-08 hat Windows
|
||||
/// eine Anwendung als „reagiert nicht mehr" beendet; bis dahin stand sie einwandfrei in
|
||||
/// der Prozessliste (#529). Ein Prozess, der auf seinem eigenen Socket nicht mehr
|
||||
/// antwortet, ist genau der Fall, den niemand mitbekommen hat.</para>
|
||||
/// </summary>
|
||||
public sealed class SocketInstanceChannel(TimeSpan? timeout = null) : IInstanceChannel
|
||||
{
|
||||
/// <summary>
|
||||
/// Kurz bemessen. Der Socket liegt auf derselben Maschine; wer hier länger als ein
|
||||
/// paar Sekunden braucht, antwortet nicht mehr im Sinne der Frage.
|
||||
/// </summary>
|
||||
private readonly TimeSpan _timeout = timeout ?? TimeSpan.FromSeconds(5);
|
||||
|
||||
public async Task<InstanceIdentity?> AskAsync(
|
||||
string instanceDirectory, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = InstanceEndpoint.CreateClient(instanceDirectory);
|
||||
client.Timeout = _timeout;
|
||||
|
||||
using var antwort = await client.GetAsync("/api/info", ct).ConfigureAwait(false);
|
||||
|
||||
if (!antwort.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var json = await antwort.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
return new InstanceIdentity(
|
||||
Text(doc.RootElement, "instanceId"),
|
||||
Text(doc.RootElement, "instanceName"),
|
||||
Text(doc.RootElement, "appVersion"));
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Der Aufrufer hat abgebrochen - das ist keine Aussage ueber die Instanz.
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Alles andere heisst dasselbe: Da antwortet niemand. Ob die Socketdatei
|
||||
// fehlt, die Verbindung abgewiesen wurde oder die Zeit ablief, ist fuer die
|
||||
// Aufsicht derselbe Befund - und keiner, der sie aus dem Tritt bringen darf.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> RequestStopAsync(
|
||||
string instanceDirectory, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = InstanceEndpoint.CreateClient(instanceDirectory);
|
||||
client.Timeout = _timeout;
|
||||
|
||||
using var antwort = await client
|
||||
.PostAsync("/api/instanz/beenden", content: null, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return antwort.IsSuccessStatusCode;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Nicht erreichbar. Das ist keine gute Nachricht, aber auch kein Fehler, den
|
||||
// die Aufsicht behandeln muesste - gleich beginnt ohnehin die Frist, nach der
|
||||
// haerter durchgegriffen wird.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Text(JsonElement root, string name)
|
||||
=> root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString() ?? ""
|
||||
: "";
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ClawdDotNet.Manager;
|
||||
|
||||
/// <summary>
|
||||
/// Ein gestarteter oder wiedergefundener Instanzprozess, soweit die Aufsicht ihn braucht.
|
||||
///
|
||||
/// <para>Bewusst schmal. Die Aufsicht fragt <b>nicht</b> die Prozessliste, ob eine Instanz
|
||||
/// lebt — das beantwortet der Socket, und nur er beantwortet es richtig (siehe
|
||||
/// <see cref="SocketInstanceProbe"/>). Vom Prozess braucht sie dreierlei: seine Kennung
|
||||
/// fürs Protokoll, den Abschiedscode, wenn er weg ist, und die Möglichkeit, ihn zu
|
||||
/// beenden, wenn er nicht mehr auf Bitten hört.</para>
|
||||
/// </summary>
|
||||
public interface IInstanceProcess : IDisposable
|
||||
{
|
||||
int Id { get; }
|
||||
|
||||
DateTimeOffset StartedAt { get; }
|
||||
|
||||
bool HasExited { get; }
|
||||
|
||||
/// <summary>Der Abschiedscode, oder <c>null</c>, solange der Prozess läuft.</summary>
|
||||
int? ExitCode { get; }
|
||||
|
||||
/// <summary>Das harte Ende. Nur, wenn die Bitte über den Socket nichts genützt hat.</summary>
|
||||
void Kill();
|
||||
}
|
||||
|
||||
/// <summary>Startet Instanzprozesse und findet laufende wieder.</summary>
|
||||
public interface IInstanceLauncher
|
||||
{
|
||||
/// <summary>Startet eine Instanz. Wirft, wenn der Prozess nicht anläuft.</summary>
|
||||
IInstanceProcess Start(string instanceDirectory);
|
||||
|
||||
/// <summary>
|
||||
/// Nimmt einen bereits laufenden Prozess wieder in die Hand (E-M6).
|
||||
///
|
||||
/// <para><c>null</c>, wenn es ihn nicht mehr gibt oder wenn er nicht der ist, der
|
||||
/// gesucht wird — siehe <paramref name="startedAt"/>.</para>
|
||||
/// </summary>
|
||||
/// <param name="startedAt">
|
||||
/// Die Startzeit aus der Zustandsdatei. Sie entlarvt eine wiederverwendete
|
||||
/// Prozesskennung: Nach einem Neustart der Maschine gehört 4711 mit einiger
|
||||
/// Wahrscheinlichkeit einem fremden Programm, und das dann für die eigene Instanz zu
|
||||
/// halten wäre schlimmer, als sie gar nicht zu finden.
|
||||
/// </param>
|
||||
IInstanceProcess? Attach(int processId, DateTimeOffset startedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der echte Start: <c>ClawdDotNet --headless --instance <Verzeichnis></c>.
|
||||
///
|
||||
/// <para><b>Vorerst ohne Fenster.</b> Nach E-M2 behält jede Instanz ihr eigenes
|
||||
/// Desktop-Fenster; das kommt mit Stufe 5, wenn der Wechsel das Fenster nach vorn holt.
|
||||
/// Bis dahin ist kopflos die Betriebsart, die sich vollständig beobachten lässt — und
|
||||
/// eine Instanz, die auf einem Server ohne Bildschirmsitzung gestartet wird, braucht sie
|
||||
/// ohnehin.</para>
|
||||
/// </summary>
|
||||
public sealed class ProcessInstanceLauncher(string executablePath, bool headless = true)
|
||||
: IInstanceLauncher
|
||||
{
|
||||
/// <summary>Wo die Programmdatei liegt — in der Regel neben der des Managers.</summary>
|
||||
public string ExecutablePath { get; } = Path.GetFullPath(executablePath);
|
||||
|
||||
/// <summary>
|
||||
/// Wie weit Start- und Zustandszeit auseinanderliegen dürfen, damit es derselbe
|
||||
/// Prozess ist.
|
||||
///
|
||||
/// <para>Nicht null: Die Startzeit kommt aus dem Betriebssystem, die Zustandsdatei
|
||||
/// wurde einen Wimpernschlag später geschrieben, und beide runden unterschiedlich.
|
||||
/// Zwei Sekunden sind großzügig genug für diese Differenz und weit zu eng, als dass
|
||||
/// eine wiederverwendete Kennung hindurchpasste.</para>
|
||||
/// </summary>
|
||||
private static readonly TimeSpan StartTimeTolerance = TimeSpan.FromSeconds(2);
|
||||
|
||||
public IInstanceProcess Start(string instanceDirectory)
|
||||
{
|
||||
var start = new ProcessStartInfo
|
||||
{
|
||||
FileName = ExecutablePath,
|
||||
WorkingDirectory = Path.GetDirectoryName(ExecutablePath) ?? ".",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
if (headless) start.ArgumentList.Add("--headless");
|
||||
|
||||
start.ArgumentList.Add("--instance");
|
||||
start.ArgumentList.Add(Path.GetFullPath(instanceDirectory));
|
||||
|
||||
var process = Process.Start(start)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Der Instanzprozess ließ sich nicht starten: {ExecutablePath}");
|
||||
|
||||
return new RunningProcess(process);
|
||||
}
|
||||
|
||||
public IInstanceProcess? Attach(int processId, DateTimeOffset startedAt)
|
||||
{
|
||||
try
|
||||
{
|
||||
var process = Process.GetProcessById(processId);
|
||||
|
||||
var actual = new DateTimeOffset(process.StartTime.ToUniversalTime(), TimeSpan.Zero);
|
||||
|
||||
if ((actual - startedAt).Duration() > StartTimeTolerance)
|
||||
{
|
||||
// Dieselbe Nummer, ein anderer Prozess.
|
||||
process.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RunningProcess(process);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||
{
|
||||
// Gibt es nicht (mehr), oder die Startzeit ist nicht abfragbar, weil er
|
||||
// gerade endet. Beides heisst: nicht wiederzufinden.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RunningProcess(Process process) : IInstanceProcess
|
||||
{
|
||||
public int Id { get; } = process.Id;
|
||||
|
||||
public DateTimeOffset StartedAt { get; } =
|
||||
new(process.StartTime.ToUniversalTime(), TimeSpan.Zero);
|
||||
|
||||
public bool HasExited
|
||||
{
|
||||
get
|
||||
{
|
||||
try { return process.HasExited; }
|
||||
catch (InvalidOperationException) { return true; }
|
||||
}
|
||||
}
|
||||
|
||||
public int? ExitCode
|
||||
{
|
||||
get
|
||||
{
|
||||
try { return process.HasExited ? process.ExitCode : null; }
|
||||
catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Kill()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Mit der ganzen Nachkommenschaft: Ein Instanzprozess, der selbst etwas
|
||||
// gestartet hat, liesse sonst Waisen zurueck.
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException)
|
||||
{
|
||||
// Schon weg, oder das Betriebssystem laesst es nicht zu. In beiden
|
||||
// Faellen ist das Ziel - der Prozess ist fort - entweder erreicht oder
|
||||
// nicht erreichbar, und beides sagt gleich der naechste Aufsichtsdurchgang.
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => process.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
|
||||
namespace ClawdDotNet.Manager;
|
||||
|
||||
/// <summary>
|
||||
/// Was der Manager über einen laufenden Instanzprozess auf die Platte schreibt.
|
||||
///
|
||||
/// <para><b>Warum es diese Datei gibt (E-M6).</b> Fällt der Manager aus, laufen die
|
||||
/// Instanzen weiter — er darf nicht der Einzelpunkt sein, gegen den dieses ganze Vorhaben
|
||||
/// gebaut wird. Der neu gestartete Manager muss sie also wiederfinden, und zwar ohne
|
||||
/// Datenbank: Alles, was er dafür braucht, liegt als JSON im Instanzverzeichnis.</para>
|
||||
///
|
||||
/// <para><b>Die Prozesskennung allein taugt dafür nicht.</b> Nach einem Neustart der
|
||||
/// Maschine ist 4711 mit einiger Wahrscheinlichkeit irgendein anderer Prozess — und den
|
||||
/// dann für die eigene Instanz zu halten, wäre schlimmer als sie gar nicht zu finden. Der
|
||||
/// verlässliche Beweis ist der Socket: Antwortet er mit derselben Instanzkennung, ist es
|
||||
/// dieselbe Instanz. Deshalb steht sie hier mit drin. <see cref="StartedAt"/> und
|
||||
/// <see cref="ProcessId"/> sind das Zweitmerkmal — sie sagen, welchen Prozess man
|
||||
/// anfassen muss, sobald die Identität feststeht.</para>
|
||||
/// </summary>
|
||||
public sealed class InstanceState
|
||||
{
|
||||
/// <summary>Der Dateiname im Instanzverzeichnis.</summary>
|
||||
public const string FileName = "instance-state.json";
|
||||
|
||||
[JsonPropertyName("instanceId")]
|
||||
public string InstanceId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("instanceName")]
|
||||
public string InstanceName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("processId")]
|
||||
public int ProcessId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Startzeit des Prozesses, nicht der Instanz.
|
||||
///
|
||||
/// <para>Zusammen mit <see cref="ProcessId"/> ergibt sie ein Paar, das eine
|
||||
/// wiederverwendete Kennung entlarvt: Ein Prozess, der später gestartet wurde als
|
||||
/// hier steht, ist nicht der, den wir gestartet haben.</para>
|
||||
/// </summary>
|
||||
[JsonPropertyName("startedAt")]
|
||||
public DateTimeOffset StartedAt { get; set; }
|
||||
|
||||
/// <summary>Socket oder Named Pipe — wie man mit dieser Instanz spricht.</summary>
|
||||
[JsonPropertyName("endpoint")]
|
||||
public string Endpoint { get; set; } = "";
|
||||
|
||||
/// <summary>Welcher Managerprozess sie gestartet hat. Nur zur Diagnose.</summary>
|
||||
[JsonPropertyName("managerProcessId")]
|
||||
public int ManagerProcessId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest und schreibt die Zustandsdatei einer Instanz.
|
||||
///
|
||||
/// <para>Bewusst nachsichtig beim Lesen: Eine kaputte oder halb geschriebene Datei heißt
|
||||
/// „ich weiß nichts über diese Instanz" und nicht „Ausnahme". Der Manager kann damit
|
||||
/// umgehen — er fragt dann eben den Socket, und wenn der schweigt, läuft sie nicht.
|
||||
/// Geschrieben wird über <see cref="AtomicFile"/>, damit ein Absturz mitten im Schreiben
|
||||
/// keine halbe Datei hinterlässt.</para>
|
||||
/// </summary>
|
||||
public static class InstanceStateStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static string PathFor(string instanceDirectory)
|
||||
=> Path.Combine(Path.GetFullPath(instanceDirectory), InstanceState.FileName);
|
||||
|
||||
/// <summary>Der hinterlegte Zustand, oder <c>null</c>: keine Datei, keine lesbare.</summary>
|
||||
public static InstanceState? Read(string instanceDirectory)
|
||||
{
|
||||
var path = PathFor(instanceDirectory);
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path)) return null;
|
||||
|
||||
return JsonSerializer.Deserialize<InstanceState>(
|
||||
AtomicFile.ReadAllText(path), JsonOpts);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Write(string instanceDirectory, InstanceState state)
|
||||
=> AtomicFile.WriteAllText(
|
||||
PathFor(instanceDirectory), JsonSerializer.Serialize(state, JsonOpts));
|
||||
|
||||
/// <summary>
|
||||
/// Räumt die Zustandsdatei weg — nach einem geordneten Ende und nachdem festgestellt
|
||||
/// wurde, dass ein Eintrag ins Leere zeigt.
|
||||
///
|
||||
/// <para>Eine liegengebliebene Datei ist kein Drama: Der Manager prüft ohnehin den
|
||||
/// Socket, bevor er ihr glaubt. Sie wegzuräumen erspart aber die Frage, warum eine
|
||||
/// Instanz „laut Datei läuft" und trotzdem nicht antwortet.</para>
|
||||
/// </summary>
|
||||
public static void Clear(string instanceDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = PathFor(instanceDirectory);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Nicht der Rede wert - siehe oben.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
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>
|
||||
public sealed record SupervisionOptions(
|
||||
TimeSpan ProbeInterval,
|
||||
TimeSpan StartTimeout,
|
||||
int SilentChecksBeforeAlarm,
|
||||
TimeSpan StopGrace)
|
||||
{
|
||||
public static SupervisionOptions Default { get; } = new(
|
||||
ProbeInterval: TimeSpan.FromSeconds(15),
|
||||
StartTimeout: TimeSpan.FromSeconds(60),
|
||||
SilentChecksBeforeAlarm: 3,
|
||||
StopGrace: TimeSpan.FromSeconds(20));
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
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)
|
||||
{
|
||||
if (Condition == InstanceCondition.Running)
|
||||
return true;
|
||||
|
||||
_stopRequested = false;
|
||||
_silentChecks = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (Condition == InstanceCondition.Silent)
|
||||
{
|
||||
Condition = InstanceCondition.Running;
|
||||
Raise(InstanceEventKind.Recovered, _process?.Id, null,
|
||||
$"Instanz {InstanceName} antwortet wieder.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Condition = InstanceCondition.Running;
|
||||
}
|
||||
|
||||
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)
|
||||
return; // Schon gemeldet. Ein Alarm je Vorfall, nicht je Durchgang.
|
||||
|
||||
if (_silentChecks < _options.SilentChecksBeforeAlarm)
|
||||
return;
|
||||
|
||||
Condition = InstanceCondition.Silent;
|
||||
|
||||
Raise(InstanceEventKind.Unresponsive, _process?.Id, null,
|
||||
$"Instanz {InstanceName} läuft, antwortet aber seit {_silentChecks} Durchgängen "
|
||||
+ $"nicht mehr auf {InstanceEndpoint.Describe(InstanceDirectory)}.");
|
||||
}
|
||||
|
||||
/// <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 deadline = _clock.GetUtcNow() + _options.StopGrace;
|
||||
|
||||
while (_clock.GetUtcNow() < deadline)
|
||||
{
|
||||
if (_process is { HasExited: true }
|
||||
|| await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false) is null)
|
||||
{
|
||||
Finish(_process?.ExitCode, gewaltsam: false);
|
||||
return true;
|
||||
}
|
||||
|
||||
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}s nicht geendet — sie wird beendet.",
|
||||
InstanceName, _options.StopGrace.TotalSeconds);
|
||||
|
||||
_process.Kill();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace ClawdDotNet.Manager;
|
||||
|
||||
/// <summary>Was der Manager an einer Instanz beobachtet hat.</summary>
|
||||
public enum InstanceEventKind
|
||||
{
|
||||
/// <summary>Gestartet und über ihren Socket erreichbar.</summary>
|
||||
Started,
|
||||
|
||||
/// <summary>Der Prozess kam hoch, meldete sich aber nicht — oder kam gar nicht hoch.</summary>
|
||||
StartFailed,
|
||||
|
||||
/// <summary>Auf Geheiß beendet.</summary>
|
||||
StoppedOrderly,
|
||||
|
||||
/// <summary>Weg, ohne dass jemand darum gebeten hätte.</summary>
|
||||
Crashed,
|
||||
|
||||
/// <summary>
|
||||
/// Der Prozess läuft, antwortet aber nicht mehr auf seinem Socket.
|
||||
///
|
||||
/// <para>Der Fall, der dieses ganze Vorhaben ausgelöst hat: Am 2026-09-08 stand die
|
||||
/// Anwendung einwandfrei in der Prozessliste und arbeitete längst nicht mehr; Windows
|
||||
/// hat sie schließlich erschlagen (#529). Bis dahin hatte niemand etwas gemerkt — die
|
||||
/// Prozessliste sagt nur, dass etwas da ist.</para>
|
||||
/// </summary>
|
||||
Unresponsive,
|
||||
|
||||
/// <summary>Antwortet wieder, nachdem sie stumm war.</summary>
|
||||
Recovered
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Vorkommnis in der Aufsicht.
|
||||
///
|
||||
/// <para><b>Das ist die Liste aus Abschnitt 4 des Konzepts, und sie ist absichtlich
|
||||
/// kurz.</b> Der Manager protokolliert und meldet, was aus <em>seiner</em> Sicht
|
||||
/// relevant ist: Start, Stopp, Absturz, Verstummen. Ein fehlgeschlagener Werkzeugaufruf,
|
||||
/// ein abgebrochener Agentenlauf, eine überschrittene Budgetgrenze — all das bleibt in
|
||||
/// der Instanz und wird von ihr selbst gemeldet, mit ihrem eigenen Sub-Token. Der Manager
|
||||
/// sieht davon nichts und soll davon nichts sehen.</para>
|
||||
/// </summary>
|
||||
/// <param name="Kind">Was passiert ist.</param>
|
||||
/// <param name="InstanceDirectory">Um welche Instanz es geht — der Pfad ist ihre Kennung.</param>
|
||||
/// <param name="InstanceName">Für Menschen. Kann leer sein, solange die Instanz nie geantwortet hat.</param>
|
||||
/// <param name="ProcessId">Die Prozesskennung, soweit bekannt.</param>
|
||||
/// <param name="ExitCode">Nur bei <see cref="InstanceEventKind.Crashed"/> und <see cref="InstanceEventKind.StoppedOrderly"/>.</param>
|
||||
/// <param name="Message">Ein Satz, der ins Protokoll passt.</param>
|
||||
/// <param name="At">Wann.</param>
|
||||
public sealed record SupervisionEvent(
|
||||
InstanceEventKind Kind,
|
||||
string InstanceDirectory,
|
||||
string InstanceName,
|
||||
int? ProcessId,
|
||||
int? ExitCode,
|
||||
string Message,
|
||||
DateTimeOffset At)
|
||||
{
|
||||
/// <summary>
|
||||
/// Ob dieses Vorkommnis über das Protokoll hinaus ins Deploymentcenter gehört.
|
||||
///
|
||||
/// <para>Start und geordnetes Ende sind Zustandswechsel und stehen im Protokoll.
|
||||
/// Gemeldet wird, was niemand bestellt hat — und zwar stellvertretend, weil die
|
||||
/// Instanz es selbst nicht mehr kann. Genau dafür gibt es den Manager: Fällt ein
|
||||
/// Prozess hart, fällt sein eigener Meldeweg mit.</para>
|
||||
/// </summary>
|
||||
public bool WorthReporting
|
||||
=> Kind is InstanceEventKind.Crashed
|
||||
or InstanceEventKind.Unresponsive
|
||||
or InstanceEventKind.StartFailed;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ internal static class ApiEndpoints
|
||||
api.MapLogs();
|
||||
|
||||
app.MapEvents();
|
||||
app.MapControl();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using ClawdDotNet.Web.Security;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace ClawdDotNet.Web.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Der Wunsch, dass dieser Instanzprozess endet.
|
||||
///
|
||||
/// <para>Ein eigener Typ und kein Rückruf am Endpunkt, weil zwei Seiten ihn brauchen:
|
||||
/// Der Endpunkt löst ihn aus, der Wirtsprozess wartet darauf. Wer wartet, ist verschieden
|
||||
/// — kopflos die Schleife in <c>HeadlessRun</c>, mit Fenster die Avalonia-Anwendung.</para>
|
||||
/// </summary>
|
||||
public sealed class ShutdownSignal
|
||||
{
|
||||
/// <summary>Wird höchstens einmal ausgelöst.</summary>
|
||||
public event Action? Requested;
|
||||
|
||||
private int _raised;
|
||||
|
||||
/// <summary>
|
||||
/// Meldet den Wunsch an. Mehrfaches Aufrufen tut nichts — ein zweiter Aufruf würde
|
||||
/// sonst mitten im Herunterfahren ein zweites Herunterfahren anstoßen.
|
||||
/// </summary>
|
||||
public bool Raise()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _raised, 1) != 0)
|
||||
return false;
|
||||
|
||||
Requested?.Invoke();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Was den Instanzprozess selbst betrifft — nicht seinen Inhalt.
|
||||
///
|
||||
/// <para><b>Ausschließlich über den Socket erreichbar.</b> Über TCP gibt es diese
|
||||
/// Endpunkte nicht, auch mit gültigem Token nicht, und sie antworten dort mit 404 statt
|
||||
/// mit 403: Was es nicht gibt, muss man auch nicht erklären. Der Grund ist der Schnitt aus
|
||||
/// #528 — die Lebensdauer einer Instanz gehört dem Instanzmanager. Eine Browsersitzung
|
||||
/// soll den Prozess, der sie gerade bedient, nicht beenden können; der Manager soll es,
|
||||
/// und er kommt über den Socket.</para>
|
||||
/// </summary>
|
||||
internal static class ControlEndpoints
|
||||
{
|
||||
public static void MapControl(this WebApplication app)
|
||||
{
|
||||
var control = app.MapGroup("/api/instanz");
|
||||
|
||||
control.MapPost("/beenden", (HttpContext context, ShutdownSignal signal) =>
|
||||
{
|
||||
if (!LocalTransport.Applies(context))
|
||||
return Results.NotFound();
|
||||
|
||||
// Erst antworten, dann enden - und deshalb OnCompleted statt eines Aufrufs
|
||||
// an dieser Stelle. Ein hier ausgeloester Wunsch faehrt den Wirt sofort
|
||||
// herunter, mitten in dieser Anfrage; der Manager bekaeme statt einer Zusage
|
||||
// einen Verbindungsabbruch und haelt ihn fuer einen Fehlschlag. Genau davon
|
||||
// haengt aber ab, ob er anschliessend geduldig wartet oder abschiesst.
|
||||
context.Response.OnCompleted(() =>
|
||||
{
|
||||
signal.Raise();
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
// 202 und nicht 200: Beim Antworten laeuft der Prozess noch.
|
||||
return Results.Accepted(value: new { angenommen = true });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -50,8 +50,21 @@ public sealed class InstanceHttpHost : IAsyncDisposable
|
||||
_instancePath = instancePath;
|
||||
Endpoint = endpoint;
|
||||
Url = url;
|
||||
|
||||
ShutdownSignal = app.Services.GetRequiredService<ShutdownSignal>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der Instanzmanager hat gebeten, diesen Prozess zu beenden
|
||||
/// (<c>POST /api/instanz/beenden</c>, nur über den Socket).
|
||||
///
|
||||
/// <para>Der Wirtsprozess hängt sich hier an und beendet sich: kopflos die
|
||||
/// Warteschleife, mit Fenster die Avalonia-Anwendung. Diese Klasse tut es nicht
|
||||
/// selbst — sie kennt nur ihren Kestrel und wüsste gar nicht, was sonst noch zu
|
||||
/// beenden wäre.</para>
|
||||
/// </summary>
|
||||
public ShutdownSignal ShutdownSignal { get; }
|
||||
|
||||
/// <summary>Socket oder Named Pipe — der Weg, den der Instanzmanager nimmt.</summary>
|
||||
public string Endpoint { get; }
|
||||
|
||||
@@ -171,6 +184,7 @@ public sealed class InstanceHttpHost : IAsyncDisposable
|
||||
builder.WebHost.ConfigureKestrel(kestrel => Listen(kestrel, host.InstancePath, options));
|
||||
|
||||
builder.Services.AddSingleton(host);
|
||||
builder.Services.AddSingleton<ShutdownSignal>();
|
||||
|
||||
if (options is not null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Manager.Tests</RootNamespace>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Shouldly" Version="4.2.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Manager\ClawdDotNet.Manager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,106 @@
|
||||
namespace ClawdDotNet.Manager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ein Instanzprozess, den der Test steuert.
|
||||
///
|
||||
/// <para>Ein echter Prozess ließe die Aufsicht nur oberflächlich prüfen: Ein Absturz zum
|
||||
/// gewünschten Zeitpunkt, ein Prozess, der lebt und schweigt, eine wiederverwendete
|
||||
/// Prozesskennung — das alles ist mit einem laufenden Programm entweder nicht oder nur
|
||||
/// mit Wartezeiten herzustellen, und Tests mit Wartezeiten sind Tests, die man
|
||||
/// irgendwann abschaltet.</para>
|
||||
/// </summary>
|
||||
internal sealed class FakeProcess(int id, DateTimeOffset startedAt) : IInstanceProcess
|
||||
{
|
||||
public int Id { get; } = id;
|
||||
|
||||
public DateTimeOffset StartedAt { get; } = startedAt;
|
||||
|
||||
public bool HasExited { get; private set; }
|
||||
|
||||
public int? ExitCode { get; private set; }
|
||||
|
||||
public bool WasKilled { get; private set; }
|
||||
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
/// <summary>Der Prozess endet — mit oder ohne Zutun.</summary>
|
||||
public void Exit(int code)
|
||||
{
|
||||
HasExited = true;
|
||||
ExitCode = code;
|
||||
}
|
||||
|
||||
public void Kill()
|
||||
{
|
||||
WasKilled = true;
|
||||
if (!HasExited) Exit(-1);
|
||||
}
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
internal sealed class FakeLauncher : IInstanceLauncher
|
||||
{
|
||||
private int _nextId = 1000;
|
||||
|
||||
public FakeProcess? Last { get; private set; }
|
||||
|
||||
public int Starts { get; private set; }
|
||||
|
||||
/// <summary>Wenn gesetzt, scheitert der Start damit.</summary>
|
||||
public Exception? FailStartWith { get; set; }
|
||||
|
||||
/// <summary>Was <see cref="Attach"/> liefern soll — bereits laufende Prozesse.</summary>
|
||||
public Dictionary<int, FakeProcess> Running { get; } = [];
|
||||
|
||||
public DateTimeOffset Now { get; set; } = DateTimeOffset.UnixEpoch;
|
||||
|
||||
public IInstanceProcess Start(string instanceDirectory)
|
||||
{
|
||||
Starts++;
|
||||
|
||||
if (FailStartWith is not null) throw FailStartWith;
|
||||
|
||||
Last = new FakeProcess(_nextId++, Now);
|
||||
Running[Last.Id] = Last;
|
||||
return Last;
|
||||
}
|
||||
|
||||
public IInstanceProcess? Attach(int processId, DateTimeOffset startedAt)
|
||||
{
|
||||
if (!Running.TryGetValue(processId, out var process))
|
||||
return null;
|
||||
|
||||
// Dieselbe Pruefung wie im Echtbetrieb: gleiche Kennung, aber andere Startzeit
|
||||
// heisst anderer Prozess.
|
||||
return (process.StartedAt - startedAt).Duration() > TimeSpan.FromSeconds(2)
|
||||
? null
|
||||
: process;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Der Socket, den der Test antworten oder schweigen lässt.</summary>
|
||||
internal sealed class FakeChannel : IInstanceChannel
|
||||
{
|
||||
public InstanceIdentity? Answer { get; set; }
|
||||
|
||||
public int Calls { get; private set; }
|
||||
|
||||
/// <summary>Wie oft die Instanz gebeten wurde, sich zu beenden.</summary>
|
||||
public int StopRequests { get; private set; }
|
||||
|
||||
/// <summary>Ob die Bitte ankommt. Falsch heisst: Die Instanz hoert gar nicht mehr zu.</summary>
|
||||
public bool StopRequestArrives { get; set; } = true;
|
||||
|
||||
public Task<InstanceIdentity?> AskAsync(string instanceDirectory, CancellationToken ct = default)
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult(Answer);
|
||||
}
|
||||
|
||||
public Task<bool> RequestStopAsync(string instanceDirectory, CancellationToken ct = default)
|
||||
{
|
||||
StopRequests++;
|
||||
return Task.FromResult(StopRequestArrives);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,387 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Manager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Die Aufsicht über eine Instanz (#528, Stufe 3).
|
||||
///
|
||||
/// <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,
|
||||
/// kein Bugtracker-Eintrag, kein Watchdog-Alarm — weil Meldeweg und Watchdog in genau dem
|
||||
/// Prozess lebten, über den sie hätten berichten sollen (#529). Die Abnahmebedingung für
|
||||
/// diese Stufe lautet deshalb: <b>eine Instanz abschießen, und der Manager merkt es und
|
||||
/// meldet es.</b></para>
|
||||
///
|
||||
/// <para>Der schwierigere Fall steht darunter: ein Prozess, der <em>lebt</em> und nicht
|
||||
/// mehr arbeitet. Den sieht keine Prozessliste — nur der Socket.</para>
|
||||
/// </summary>
|
||||
public sealed class InstanceSupervisorTests : IDisposable
|
||||
{
|
||||
private readonly string _dir = Path.Combine(
|
||||
Path.GetTempPath(), "clawd-tests", "sup-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
|
||||
private readonly FakeLauncher _launcher = new();
|
||||
private readonly FakeChannel _kanal = new();
|
||||
private readonly List<SupervisionEvent> _ereignisse = [];
|
||||
|
||||
private static readonly InstanceIdentity Test = new("i-1", "Test", "0.1.2");
|
||||
|
||||
/// <summary>
|
||||
/// Kurze Fristen: Der Test soll die Aufsicht prüfen, nicht die Geduld.
|
||||
///
|
||||
/// <para>Knapp über dem Abfragetakt von 250 ms, damit die Warteschleifen je einen
|
||||
/// Durchgang schaffen — es geht um die Entscheidung am Ende der Frist, nicht darum,
|
||||
/// wie oft dazwischen gefragt wurde.</para>
|
||||
/// </summary>
|
||||
private static readonly SupervisionOptions Options = new(
|
||||
ProbeInterval: TimeSpan.FromMilliseconds(50),
|
||||
StartTimeout: TimeSpan.FromMilliseconds(600),
|
||||
SilentChecksBeforeAlarm: 3,
|
||||
StopGrace: TimeSpan.FromMilliseconds(300));
|
||||
|
||||
public InstanceSupervisorTests() => Directory.CreateDirectory(_dir);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); }
|
||||
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
||||
}
|
||||
|
||||
private InstanceSupervisor Aufsicht()
|
||||
{
|
||||
var supervisor = new InstanceSupervisor(
|
||||
_dir, _launcher, _kanal, NullLogger.Instance, Options);
|
||||
|
||||
supervisor.Observed += _ereignisse.Add;
|
||||
return supervisor;
|
||||
}
|
||||
|
||||
private IEnumerable<InstanceEventKind> Arten => _ereignisse.Select(e => e.Kind);
|
||||
|
||||
// ─── Starten ───
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Start_gilt_erst_wenn_die_Instanz_antwortet()
|
||||
{
|
||||
_kanal.Answer = Test;
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
(await aufsicht.StartAsync()).ShouldBeTrue();
|
||||
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
|
||||
aufsicht.InstanceName.ShouldBe("Test");
|
||||
Arten.ShouldBe([InstanceEventKind.Started]);
|
||||
|
||||
var state = InstanceStateStore.Read(_dir).ShouldNotBeNull();
|
||||
state.InstanceId.ShouldBe("i-1");
|
||||
state.ProcessId.ShouldBe(_launcher.Last!.Id);
|
||||
state.ManagerProcessId.ShouldBe(Environment.ProcessId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// „Der Prozess läuft" ist nicht dasselbe wie „die Instanz läuft".
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ein_Prozess_der_sich_nie_meldet_gilt_nicht_als_gestartet()
|
||||
{
|
||||
_kanal.Answer = null; // laeuft, sagt aber nichts
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
(await aufsicht.StartAsync()).ShouldBeFalse();
|
||||
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
|
||||
Arten.ShouldBe([InstanceEventKind.StartFailed]);
|
||||
|
||||
// Nicht stehen lassen: Ein Prozess, der laeuft und schweigt, haelt den Socket
|
||||
// besetzt und blockiert damit den naechsten Versuch.
|
||||
_launcher.Last!.WasKilled.ShouldBeTrue();
|
||||
InstanceStateStore.Read(_dir).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Prozess_der_gar_nicht_startet_meldet_das()
|
||||
{
|
||||
_launcher.FailStartWith = new InvalidOperationException("Programmdatei fehlt");
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
(await aufsicht.StartAsync()).ShouldBeFalse();
|
||||
|
||||
Arten.ShouldBe([InstanceEventKind.StartFailed]);
|
||||
_ereignisse[0].Message.ShouldContain("Programmdatei fehlt");
|
||||
}
|
||||
|
||||
// ─── Der Fall, um den es geht ───
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_abgeschossener_Prozess_wird_bemerkt_und_gemeldet()
|
||||
{
|
||||
_kanal.Answer = Test;
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
await aufsicht.StartAsync();
|
||||
|
||||
// Jemand schiesst die Instanz ab. Niemand hat darum gebeten.
|
||||
_launcher.Last!.Exit(code: 137);
|
||||
_kanal.Answer = null;
|
||||
|
||||
await aufsicht.CheckAsync();
|
||||
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Crashed);
|
||||
|
||||
var absturz = _ereignisse.Single(e => e.Kind == InstanceEventKind.Crashed);
|
||||
absturz.ExitCode.ShouldBe(137);
|
||||
absturz.InstanceName.ShouldBe("Test");
|
||||
absturz.WorthReporting.ShouldBeTrue("Das ist die Meldung, die es 2026-09-08 nicht gab.");
|
||||
|
||||
// Und die Zustandsdatei zeigt nicht mehr auf einen Prozess, den es nicht gibt.
|
||||
InstanceStateStore.Read(_dir).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der schwierigere Fall: Der Prozess lebt und arbeitet nicht mehr.
|
||||
///
|
||||
/// <para>Genau das war der Zustand am 2026-09-08 — die Anwendung stand einwandfrei in
|
||||
/// der Prozessliste, bis Windows sie als „reagiert nicht mehr" beendet hat. Eine
|
||||
/// Aufsicht, die nur Prozesse zählt, sieht davon nichts.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ein_lebender_aber_stummer_Prozess_faellt_auf()
|
||||
{
|
||||
_kanal.Answer = Test;
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
await aufsicht.StartAsync();
|
||||
|
||||
_kanal.Answer = null; // haengt - der Prozess lebt weiter
|
||||
|
||||
// Nicht beim ersten Aussetzer: Der waere Alltag, und ein Alarm bei jedem
|
||||
// Aussetzer ist nach einer Woche der Alarm, den niemand mehr liest.
|
||||
await aufsicht.CheckAsync();
|
||||
await aufsicht.CheckAsync();
|
||||
Arten.ShouldNotContain(InstanceEventKind.Unresponsive);
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
|
||||
|
||||
await aufsicht.CheckAsync();
|
||||
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Silent);
|
||||
var alarm = _ereignisse.Single(e => e.Kind == InstanceEventKind.Unresponsive);
|
||||
alarm.WorthReporting.ShouldBeTrue();
|
||||
_launcher.Last!.HasExited.ShouldBeFalse("Der Prozess laeuft ja - das ist der Punkt.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_stummer_Prozess_wird_einmal_gemeldet_und_nicht_bei_jedem_Durchgang()
|
||||
{
|
||||
_kanal.Answer = Test;
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
await aufsicht.StartAsync();
|
||||
|
||||
_kanal.Answer = null;
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
await aufsicht.CheckAsync();
|
||||
|
||||
_ereignisse.Count(e => e.Kind == InstanceEventKind.Unresponsive).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wer_wieder_antwortet_wird_wieder_als_laufend_gefuehrt()
|
||||
{
|
||||
_kanal.Answer = Test;
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
await aufsicht.StartAsync();
|
||||
|
||||
_kanal.Answer = null;
|
||||
for (var i = 0; i < 3; i++) await aufsicht.CheckAsync();
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Silent);
|
||||
|
||||
_kanal.Answer = Test;
|
||||
await aufsicht.CheckAsync();
|
||||
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
|
||||
Arten.ShouldContain(InstanceEventKind.Recovered);
|
||||
}
|
||||
|
||||
// ─── Anhalten ───
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_erbetenes_Ende_ist_kein_Absturz()
|
||||
{
|
||||
_kanal.Answer = Test;
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
await aufsicht.StartAsync();
|
||||
|
||||
// Die Instanz endet auf die Bitte hin - so wie im Betrieb.
|
||||
_launcher.Last!.Exit(code: 0);
|
||||
_kanal.Answer = null;
|
||||
|
||||
(await aufsicht.StopAsync()).ShouldBeTrue();
|
||||
|
||||
_kanal.StopRequests.ShouldBe(1, "Erst bitten, nicht schiessen.");
|
||||
_launcher.Last.WasKilled.ShouldBeFalse();
|
||||
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
|
||||
Arten.ShouldNotContain(InstanceEventKind.Crashed);
|
||||
_ereignisse.Last().Kind.ShouldBe(InstanceEventKind.StoppedOrderly);
|
||||
_ereignisse.Last().WorthReporting.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wer_nicht_hoert_wird_nach_der_Frist_beendet()
|
||||
{
|
||||
_kanal.Answer = Test;
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
await aufsicht.StartAsync();
|
||||
|
||||
// Antwortet weiter und endet nicht - die Bitte verhallt.
|
||||
_kanal.StopRequestArrives = false;
|
||||
|
||||
(await aufsicht.StopAsync()).ShouldBeTrue();
|
||||
|
||||
_kanal.StopRequests.ShouldBe(1);
|
||||
_launcher.Last!.WasKilled.ShouldBeTrue();
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine Instanz, die der Manager nicht selbst gestartet hat und die nicht hört, kann
|
||||
/// er auch nicht beenden — und das muss er sagen, statt Erfolg zu melden.
|
||||
///
|
||||
/// <para>Der Fall entsteht nach einem Managerneustart, wenn die Prozesskennung nicht
|
||||
/// mehr zu greifen ist (siehe <see cref="Eine_wiederverwendete_Prozesskennung_wird_nicht_fuer_die_Instanz_gehalten"/>).</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Was_nicht_zu_fassen_ist_wird_nicht_als_beendet_gemeldet()
|
||||
{
|
||||
_kanal.Answer = Test;
|
||||
_kanal.StopRequestArrives = false;
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
await aufsicht.ReattachAsync(); // uebernommen, aber ohne Prozess
|
||||
aufsicht.ProcessId.ShouldBeNull();
|
||||
|
||||
(await aufsicht.StopAsync()).ShouldBeFalse();
|
||||
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
|
||||
Arten.ShouldNotContain(InstanceEventKind.StoppedOrderly);
|
||||
}
|
||||
|
||||
// ─── Wiederfinden (E-M6) ───
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_laufende_Instanz_wird_nach_einem_Managerneustart_uebernommen()
|
||||
{
|
||||
_kanal.Answer = Test;
|
||||
|
||||
// Erster Manager startet sie.
|
||||
await using (var erster = Aufsicht())
|
||||
await erster.StartAsync();
|
||||
|
||||
_ereignisse.Clear();
|
||||
|
||||
// Zweiter Manager, derselbe Rechner, dieselbe laufende Instanz.
|
||||
await using var zweiter = Aufsicht();
|
||||
(await zweiter.ReattachAsync()).ShouldBeTrue();
|
||||
|
||||
zweiter.Condition.ShouldBe(InstanceCondition.Running);
|
||||
zweiter.InstanceName.ShouldBe("Test");
|
||||
zweiter.ProcessId.ShouldBe(_launcher.Last!.Id);
|
||||
|
||||
// Uebernehmen ist kein Starten - es gibt nichts zu melden.
|
||||
_ereignisse.ShouldBeEmpty();
|
||||
_launcher.Starts.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine wiederverwendete Prozesskennung darf nicht zur Übernahme eines fremden
|
||||
/// Programms führen.
|
||||
///
|
||||
/// <para>Nach einem Neustart der Maschine gehört 4711 mit einiger Wahrscheinlichkeit
|
||||
/// etwas ganz anderem. Es dann zu beaufsichtigen — und beim Anhalten zu beenden —
|
||||
/// wäre deutlich schlimmer, als die Instanz gar nicht wiederzufinden.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Eine_wiederverwendete_Prozesskennung_wird_nicht_fuer_die_Instanz_gehalten()
|
||||
{
|
||||
_kanal.Answer = Test;
|
||||
|
||||
await using (var erster = Aufsicht())
|
||||
await erster.StartAsync();
|
||||
|
||||
var alte = _launcher.Last!;
|
||||
|
||||
// Dieselbe Kennung, viel spaeter gestartet: ein anderer Prozess.
|
||||
_launcher.Running[alte.Id] = new FakeProcess(alte.Id, alte.StartedAt.AddHours(3));
|
||||
|
||||
await using var zweiter = Aufsicht();
|
||||
(await zweiter.ReattachAsync()).ShouldBeTrue("Der Socket antwortet ja.");
|
||||
|
||||
zweiter.ProcessId.ShouldBeNull(
|
||||
"Der fremde Prozess darf nicht als der eigene uebernommen werden.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_Zustandsdatei_ohne_laufende_Instanz_wird_weggeraeumt()
|
||||
{
|
||||
_kanal.Answer = Test;
|
||||
|
||||
await using (var erster = Aufsicht())
|
||||
await erster.StartAsync();
|
||||
|
||||
InstanceStateStore.Read(_dir).ShouldNotBeNull();
|
||||
|
||||
// Der Manager war weg, die Instanz ist inzwischen auch weg.
|
||||
_kanal.Answer = null;
|
||||
|
||||
await using var zweiter = Aufsicht();
|
||||
(await zweiter.ReattachAsync()).ShouldBeFalse();
|
||||
|
||||
zweiter.Condition.ShouldBe(InstanceCondition.Stopped);
|
||||
InstanceStateStore.Read(_dir).ShouldBeNull(
|
||||
"Eine Datei, die auf niemanden zeigt, laesst es aussehen, als liefe etwas.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ohne_Zustandsdatei_wird_eine_antwortende_Instanz_trotzdem_uebernommen()
|
||||
{
|
||||
// Der Fall nach einem Absturz des Managers, bevor er schreiben konnte - oder
|
||||
// nachdem jemand die Datei geloescht hat. Der Socket antwortet, also laeuft sie.
|
||||
_kanal.Answer = Test;
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
(await aufsicht.ReattachAsync()).ShouldBeTrue();
|
||||
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
|
||||
aufsicht.ProcessId.ShouldBeNull("Es gibt keinen Prozess, den dieser Manager kennt.");
|
||||
InstanceStateStore.Read(_dir).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// ─── Was gemeldet wird und was nicht ───
|
||||
|
||||
/// <summary>
|
||||
/// Der Manager meldet, was aus seiner Sicht relevant ist — und nur das (Konzept,
|
||||
/// Abschnitt 4). Was die Instanz betrifft, bleibt in der Instanz und geht mit ihrem
|
||||
/// eigenen Sub-Token raus.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(InstanceEventKind.Crashed, true)]
|
||||
[InlineData(InstanceEventKind.Unresponsive, true)]
|
||||
[InlineData(InstanceEventKind.StartFailed, true)]
|
||||
[InlineData(InstanceEventKind.Started, false)]
|
||||
[InlineData(InstanceEventKind.StoppedOrderly, false)]
|
||||
[InlineData(InstanceEventKind.Recovered, false)]
|
||||
public void Gemeldet_wird_nur_was_niemand_bestellt_hat(InstanceEventKind kind, bool erwartet)
|
||||
{
|
||||
new SupervisionEvent(kind, _dir, "Test", 1, null, "", DateTimeOffset.UtcNow)
|
||||
.WorthReporting.ShouldBe(erwartet);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user