Instanzmanager Stufe 5: Wechseln heisst nach vorn holen (#528, E-M2)

Vorher hiess "Instanz wechseln": Der laufende Prozess faehrt sich herunter
und startet sich mit einer anderen Instanz neu. Wer wechselte, beendete
zwangslaeufig die Instanz, aus der heraus er es tat - und genau dabei hat
sich die Anwendung am 2026-09-08 aufgehaengt (#529). Jetzt laufen alle, und
es kommt eine nach vorn.

Neu: POST /api/instanz/fenster, wieder ausschliesslich ueber den Socket.
Die Instanz holt ihr Fenster SELBST nach vorn; der Manager kennt kein
Fenstersystem und soll keines kennen. Kopflos laeuft die Bitte ins Leere,
und das ist die richtige Antwort. Das letzte Wort hat der Fenstermanager -
Windows laesst einen Hintergrundprozess den Vordergrund nicht einfach an
sich reissen; der kurze Ausflug ueber Topmost half im Versuch, unter Linux
entscheidet der Fenstermanager. Mehr als bitten kann eine Anwendung nicht.

ShutdownSignal ist zu InstanceControl geworden: beenden und Fenster sind
zwei Bitten desselben Absenders ueber denselben Weg. Beenden gilt einmal,
das Fenster beliebig oft - jeder Wechsel auf diese Instanz ist einer.

--managed schaltet den Wechsel im Instanzfenster ab. Der Manager gibt es
jedem Instanzprozess mit; das Fenster graut seinen Instanz-Schalter aus und
erklaert auf Nachfrage, wo gewechselt wird. Zwei Schloesser an derselben
Tuer: Der Knopf ist aus, und der Weg dahinter prueft es noch einmal - denn
hinter ihm wartet ein Selbst-Neustart.

instanceWindows in manager.json, vorbelegt auf true: Jede Instanz behaelt
ihr Fenster. Auf einer Maschine ohne Bildschirmsitzung gehoert false hin.

ABNAHME an echten Fenstern: Instanz ueber den Manager gestartet, PID 18128,
Fenstertitel "ClawdDotNet", Befehlszeile mit --managed. Fenster von aussen
minimiert (IsIconic True), dann POST /instanzen/Instance-Test/fenster:
IsIconic False, im Vordergrund - und PID unveraendert 18128. Nichts neu
gestartet. Das ist die Abnahmebedingung dieser Stufe.

Build ohne Warnungen, 780 Tests gruen, 8 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-08 23:39:50 +02:00
co-authored by Claude Opus 5
parent a511480b77
commit db7abf699f
18 changed files with 496 additions and 42 deletions
@@ -6,31 +6,46 @@ using Microsoft.AspNetCore.Routing;
namespace ClawdDotNet.Web.Endpoints;
/// <summary>
/// Der Wunsch, dass dieser Instanzprozess endet.
/// Was der Instanzmanager diesem Prozess sagen kann — und was der Prozess damit macht.
///
/// <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>
/// <para>Ein eigener Typ und keine Rückrufe am Endpunkt, weil zwei Seiten ihn brauchen:
/// Der Endpunkt löst aus, der Wirtsprozess reagiert. Und wer reagiert, ist verschieden —
/// kopflos die Warteschleife in <c>HeadlessRun</c>, mit Fenster die
/// Avalonia-Anwendung.</para>
/// </summary>
public sealed class ShutdownSignal
public sealed class InstanceControl
{
/// <summary>Wird höchstens einmal ausgelöst.</summary>
public event Action? Requested;
private int _shutdownRaised;
private int _raised;
/// <summary>Der Manager bittet, diesen Prozess zu beenden.</summary>
public event Action? ShutdownRequested;
/// <summary>
/// Meldet den Wunsch an. Mehrfaches Aufrufen tut nichts — ein zweiter Aufruf würde
/// sonst mitten im Herunterfahren ein zweites Herunterfahren anstoßen.
/// Der Manager bittet, das Fenster dieser Instanz nach vorn zu holen (E-M2).
///
/// <para>Kopflos gibt es nichts nach vorn zu holen; dann läuft dieses Ereignis ins
/// Leere, und das ist die richtige Antwort.</para>
/// </summary>
public bool Raise()
public event Action? FocusRequested;
/// <summary>
/// Meldet den Wunsch zu enden an. Ein zweiter Aufruf tut nichts — sonst stiesse er
/// mitten im Herunterfahren ein zweites Herunterfahren an.
/// </summary>
public bool RequestShutdown()
{
if (Interlocked.Exchange(ref _raised, 1) != 0)
if (Interlocked.Exchange(ref _shutdownRaised, 1) != 0)
return false;
Requested?.Invoke();
ShutdownRequested?.Invoke();
return true;
}
/// <summary>
/// Holt das Fenster nach vorn. Beliebig oft — anders als beim Beenden ist die
/// Wiederholung hier der Normalfall: Jeder Wechsel auf diese Instanz ist einer.
/// </summary>
public void RequestFocus() => FocusRequested?.Invoke();
}
/// <summary>
@@ -40,8 +55,8 @@ public sealed class ShutdownSignal
/// 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>
/// soll den Prozess, der sie gerade bedient, nicht beenden und auch nicht sein Fenster
/// aufreissen können; der Manager soll es, und er kommt über den Socket.</para>
/// </summary>
internal static class ControlEndpoints
{
@@ -49,7 +64,7 @@ internal static class ControlEndpoints
{
var control = app.MapGroup("/api/instanz");
control.MapPost("/beenden", (HttpContext context, ShutdownSignal signal) =>
control.MapPost("/beenden", (HttpContext context, InstanceControl steuerung) =>
{
if (!LocalTransport.Applies(context))
return Results.NotFound();
@@ -61,12 +76,25 @@ internal static class ControlEndpoints
// haengt aber ab, ob er anschliessend geduldig wartet oder abschiesst.
context.Response.OnCompleted(() =>
{
signal.Raise();
steuerung.RequestShutdown();
return Task.CompletedTask;
});
// 202 und nicht 200: Beim Antworten laeuft der Prozess noch.
return Results.Accepted(value: new { angenommen = true });
});
control.MapPost("/fenster", (HttpContext context, InstanceControl steuerung) =>
{
if (!LocalTransport.Applies(context))
return Results.NotFound();
// Hier ohne OnCompleted: Das Fenster nach vorn zu holen beendet nichts, die
// Antwort geht ohnehin raus. Und der Manager soll gleich erfahren, ob dieser
// Prozess ueberhaupt eines hat.
steuerung.RequestFocus();
return Results.Ok(new { angenommen = true });
});
}
}
+10 -9
View File
@@ -51,19 +51,20 @@ public sealed class InstanceHttpHost : IAsyncDisposable
Endpoint = endpoint;
Url = url;
ShutdownSignal = app.Services.GetRequiredService<ShutdownSignal>();
Control = app.Services.GetRequiredService<InstanceControl>();
}
/// <summary>
/// Der Instanzmanager hat gebeten, diesen Prozess zu beenden
/// (<c>POST /api/instanz/beenden</c>, nur über den Socket).
/// Was der Instanzmanager diesem Prozess über den Socket sagen kann: sich beenden
/// (<c>POST /api/instanz/beenden</c>) oder sein Fenster nach vorn holen
/// (<c>POST /api/instanz/fenster</c>).
///
/// <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>
/// <para>Der Wirtsprozess hängt sich hier an und tut es: kopflos beendet sich die
/// Warteschleife, mit Fenster reagiert die Avalonia-Anwendung. Diese Klasse tut es
/// nicht selbst — sie kennt nur ihren Kestrel und wüsste gar nicht, was sonst noch zu
/// beenden oder anzuzeigen wäre.</para>
/// </summary>
public ShutdownSignal ShutdownSignal { get; }
public InstanceControl Control { get; }
/// <summary>Socket oder Named Pipe — der Weg, den der Instanzmanager nimmt.</summary>
public string Endpoint { get; }
@@ -184,7 +185,7 @@ public sealed class InstanceHttpHost : IAsyncDisposable
builder.WebHost.ConfigureKestrel(kestrel => Listen(kestrel, host.InstancePath, options));
builder.Services.AddSingleton(host);
builder.Services.AddSingleton<ShutdownSignal>();
builder.Services.AddSingleton<InstanceControl>();
if (options is not null)
{