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:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user