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
+65 -1
View File
@@ -32,6 +32,16 @@ public partial class App : Application
/// <summary>Vorgewaehlte Instanz aus der Befehlszeile; wird genau einmal verbraucht.</summary>
private string? _preselectedInstancePath;
/// <summary>
/// Vom Instanzmanager gestartet (<c>--managed</c>).
///
/// <para>Dann gehoert der Instanzwechsel dorthin: Der Manager haelt die Fenster aller
/// Instanzen im Hintergrund und holt das gewuenschte nach vorn (E-M2). Ein Fenster,
/// das sich selbst mit einer anderen Instanz neu startet, naehme genau die Instanz
/// mit, aus der heraus jemand wechselt.</para>
/// </summary>
private bool _managed;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
@@ -44,6 +54,7 @@ public partial class App : Application
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
_preselectedInstancePath = CommandLine.ReadInstancePath(desktop.Args);
_managed = CommandLine.IsManaged(desktop.Args);
ApplySavedThemeEarly();
@@ -127,7 +138,11 @@ public partial class App : Application
// Instanzverzeichnis, ueber den der Instanzmanager die Instanz erreicht (#528).
_web = InstanceHttpHost.TryStart(_host, _host.LoggerFactory);
var mainViewModel = new MainWindowViewModel(_host);
var mainViewModel = new MainWindowViewModel(_host)
{
// Wer beaufsichtigt wird, wechselt nicht selbst - siehe _managed.
CanSwitchInstance = !_managed
};
// Der Wechsel gehoert hierher und nicht ins Fenster: Er braucht den Aufbau und
// die Lebensdauer der Anwendung, und beides kennt nur diese Klasse.
@@ -135,6 +150,10 @@ public partial class App : Application
desktop.MainWindow = new MainWindow { DataContext = mainViewModel };
// Der Manager bittet ueber den Socket, dieses Fenster nach vorn zu holen (E-M2).
if (_web is not null)
_web.Control.FocusRequested += () => BringToFront(desktop);
desktop.MainWindow.Show();
desktop.MainWindow.Closed += (_, _) => desktop.Shutdown();
@@ -311,6 +330,38 @@ public partial class App : Application
return await completion.Task;
});
/// <summary>
/// Holt das Fenster dieser Instanz nach vorn — auf Bitte des Instanzmanagers.
///
/// <para><b>Die Instanz macht das selbst, statt dass jemand von aussen an ihrem
/// Fenster zieht.</b> Der Manager kennt kein Fenstersystem und soll keines kennen; er
/// bittet über den Socket, und wer ein Fenster hat, holt es nach vorn. Kopflos läuft
/// die Bitte ins Leere, und das ist die richtige Antwort.</para>
///
/// <para><b>Das letzte Wort hat der Fenstermanager.</b> Windows lässt einen Prozess
/// im Hintergrund den Vordergrund nicht einfach an sich reissen — <c>Activate</c>
/// blinkt dann nur in der Leiste. Der kurze Ausflug über <c>Topmost</c> bringt es in
/// den meisten Fällen trotzdem nach vorn; unter Linux entscheidet der Fenstermanager,
/// und manche sind da streng. Mehr als bitten kann eine Anwendung nicht, und mehr
/// sollte sie auch nicht tun.</para>
/// </summary>
private static void BringToFront(IClassicDesktopStyleApplicationLifetime desktop)
=> Dispatcher.UIThread.Post(() =>
{
if (desktop.MainWindow is not { } fenster) return;
if (fenster.WindowState == WindowState.Minimized)
fenster.WindowState = WindowState.Normal;
fenster.Show();
var vorher = fenster.Topmost;
fenster.Topmost = true;
fenster.Topmost = vorher;
fenster.Activate();
});
/// <summary>
/// Instanzwechsel aus der laufenden Anwendung.
///
@@ -326,6 +377,19 @@ public partial class App : Application
{
if (_host is null) return;
if (_managed)
{
// Sollte gar nicht erst aufrufbar sein (CanSwitchInstance), aber ein zweites
// Schloss an der Tuer, hinter der ein Selbst-Neustart wartet, ist billig.
await new AvaloniaLicensePrompt().ShowInfoAsync(
"ClawdDotNet – Instanzwechsel",
"Diese Instanz wird vom Instanzmanager geführt. Gewechselt wird dort — "
+ "er holt das Fenster der gewünschten Instanz nach vorn, statt eine "
+ "laufende zu beenden.");
return;
}
var viewModel = new InstancePickerViewModel(_host.Directories, _host.InstancePath);
var window = new InstancePickerWindow { DataContext = viewModel };
+19 -1
View File
@@ -16,6 +16,17 @@ internal static class CommandLine
/// <summary>Läuft ohne Fenster: nur Aufbau, Socket und Warten (#528, Stufe 2).</summary>
public const string HeadlessArgument = "--headless";
/// <summary>
/// Gestartet und beaufsichtigt vom Instanzmanager (#528, Stufe 5).
///
/// <para>Dann gehört der Instanzwechsel dorthin und nicht in dieses Fenster: Der
/// Manager hält die Fenster aller Instanzen im Hintergrund und holt das gewünschte
/// nach vorn. Ein Fenster, das sich selbst mit einer anderen Instanz neu startet,
/// nähme dabei genau die Instanz mit, aus der heraus jemand wechselt — und das ist
/// der Vorfall, mit dem dieses Vorhaben angefangen hat (#529).</para>
/// </summary>
public const string ManagedArgument = "--managed";
/// <summary>
/// Der Instanzpfad hinter <c>--instance</c>, oder <c>null</c>.
///
@@ -37,6 +48,13 @@ internal static class CommandLine
}
public static bool IsHeadless(IReadOnlyList<string>? args)
=> Has(args, HeadlessArgument);
/// <inheritdoc cref="ManagedArgument"/>
public static bool IsManaged(IReadOnlyList<string>? args)
=> Has(args, ManagedArgument);
private static bool Has(IReadOnlyList<string>? args, string flag)
=> args is not null
&& args.Any(a => string.Equals(a, HeadlessArgument, StringComparison.OrdinalIgnoreCase));
&& args.Any(a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase));
}
+3 -3
View File
@@ -90,7 +90,7 @@ internal static class HeadlessRun
"Instanz {Name} läuft ohne Fenster auf {Endpunkt}",
host.Instance.InstanceName, web.Endpoint);
await WaitForShutdownAsync(logger, web.ShutdownSignal);
await WaitForShutdownAsync(logger, web.Control);
logger.LogInformation("Instanz {Name} wird beendet.", host.Instance.InstanceName);
}
@@ -111,7 +111,7 @@ internal static class HeadlessRun
/// 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, ShutdownSignal signal)
private static async Task WaitForShutdownAsync(ILogger logger, InstanceControl steuerung)
{
var schluss = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -124,7 +124,7 @@ internal static class HeadlessRun
// 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");
steuerung.ShutdownRequested += () => Beenden("Instanzmanager");
using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
{
@@ -97,6 +97,16 @@ public sealed partial class MainWindowViewModel : ViewModelBase
public event Action? RequestExit;
public event Action? RequestInstanceManagerDialog;
/// <summary>
/// Ob der Instanzwechsel aus diesem Fenster heraus angeboten wird.
///
/// <para><c>false</c>, sobald der Instanzmanager diese Instanz führt (#528, E-M2):
/// Dann hält er die Fenster aller Instanzen im Hintergrund und holt das gewünschte
/// nach vorn. Ein Wechsel von hier aus wäre ein Selbst-Neustart und nähme genau die
/// Instanz mit, aus der heraus jemand wechselt.</para>
/// </summary>
public bool CanSwitchInstance { get; init; } = true;
/// <summary>Für den Entwurfsmodus des Editors — ohne laufenden Aufbau.</summary>
public MainWindowViewModel() : this(null) { }
@@ -52,9 +52,14 @@
IsVisible="{Binding IsSidebarExpanded}" />
<!-- Instanz-Schalter: der einzige Knopf mit Akzentrahmen, weil er als
einziger die ganze Anwendung umschaltet. -->
einziger die ganze Anwendung umschaltet.
Ausgegraut, sobald der Instanzmanager diese Instanz fuehrt (#528, E-M2):
Gewechselt wird dann dort, und er holt das Fenster der gewuenschten Instanz
nach vorn, statt eine laufende zu beenden. -->
<Button DockPanel.Dock="Top" Classes="instance" Margin="12,12,12,0"
Command="{Binding OpenInstanceManagerCommand}"
IsEnabled="{Binding CanSwitchInstance}"
ToolTip.Tip="Instanz wechseln">
<DockPanel>
<ctrl:StrokeIcon Data="{StaticResource IconLogo}" Width="15" Height="15"
+19
View File
@@ -85,6 +85,25 @@ internal static class ManagerEndpoints
};
});
// Der Wechsel, wie E-M2 ihn meint: Alle laufen, eine kommt nach vorn. Frueher
// hiess "Instanz wechseln", dass sich der laufende Prozess herunterfaehrt und mit
// einer anderen Instanz neu startet - wer wechselte, beendete zwangslaeufig die
// Instanz, aus der heraus er es tat.
verwaltung.MapPost("/instanzen/{id}/fenster", async (
string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) =>
{
if (Find(registry, id) is not { } instanz)
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
return await manager.FocusInstanceAsync(instanz.Directory, ct)
? Results.Ok(new OutcomeDto(true, $"Fenster von '{instanz.Name}' nach vorn geholt."))
: Results.Json(
new OutcomeDto(false,
$"Instanz '{instanz.Name}' hat kein Fenster nach vorn zu holen — "
+ "sie läuft nicht oder läuft kopflos."),
statusCode: StatusCodes.Status409Conflict);
});
verwaltung.MapPost("/instanzen/{id}/stop", async (
string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) =>
{
+17 -2
View File
@@ -32,6 +32,14 @@ public interface IInstanceChannel
/// Socket.</para>
/// </summary>
Task<bool> RequestStopAsync(string instanceDirectory, CancellationToken ct = default);
/// <summary>
/// Bittet die Instanz, ihr Fenster nach vorn zu holen (E-M2).
///
/// <para><c>false</c> heißt: Die Bitte kam nicht an, oder diese Instanz hat kein
/// Fenster — sie läuft kopflos. Beides ist kein Fehler, sondern eine Auskunft.</para>
/// </summary>
Task<bool> RequestFocusAsync(string instanceDirectory, CancellationToken ct = default);
}
/// <summary>
@@ -93,8 +101,15 @@ public sealed class SocketInstanceChannel(TimeSpan? timeout = null) : IInstanceC
}
}
public async Task<bool> RequestStopAsync(
public Task<bool> RequestFocusAsync(
string instanceDirectory, CancellationToken ct = default)
=> PostAsync(instanceDirectory, "/api/instanz/fenster", ct);
public Task<bool> RequestStopAsync(
string instanceDirectory, CancellationToken ct = default)
=> PostAsync(instanceDirectory, "/api/instanz/beenden", ct);
private async Task<bool> PostAsync(string instanceDirectory, string pfad, CancellationToken ct)
{
try
{
@@ -102,7 +117,7 @@ public sealed class SocketInstanceChannel(TimeSpan? timeout = null) : IInstanceC
client.Timeout = _timeout;
using var antwort = await client
.PostAsync("/api/instanz/beenden", content: null, ct)
.PostAsync(pfad, content: null, ct)
.ConfigureAwait(false);
return antwort.IsSuccessStatusCode;
+10 -6
View File
@@ -7,7 +7,7 @@ namespace ClawdDotNet.Manager;
///
/// <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
/// <see cref="SocketInstanceChannel"/>). 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>
@@ -50,11 +50,14 @@ public interface IInstanceLauncher
/// <summary>
/// Der echte Start: <c>ClawdDotNet --headless --instance &lt;Verzeichnis&gt;</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>
/// <para><b>Mit oder ohne Fenster.</b> Nach E-M2 behält jede Instanz ihr eigenes
/// Desktop-Fenster; sie laufen im Hintergrund, und beim Wechsel kommt eines nach vorn.
/// Auf einer Maschine ohne Bildschirmsitzung gibt es keines, und dann ist kopflos die
/// einzige Betriebsart, die trägt.</para>
///
/// <para><b><c>--managed</c> geht immer mit.</b> Daran erkennt die Instanz, dass sie
/// geführt wird, und bietet den Instanzwechsel nicht mehr selbst an: Der wäre ein
/// Selbst-Neustart und nähme genau die Instanz mit, aus der heraus jemand wechselt.</para>
/// </summary>
public sealed class ProcessInstanceLauncher(string executablePath, bool headless = true)
: IInstanceLauncher
@@ -85,6 +88,7 @@ public sealed class ProcessInstanceLauncher(string executablePath, bool headless
if (headless) start.ArgumentList.Add("--headless");
start.ArgumentList.Add("--managed");
start.ArgumentList.Add("--instance");
start.ArgumentList.Add(Path.GetFullPath(instanceDirectory));
@@ -201,6 +201,28 @@ public sealed class InstanceRegistry : IAsyncDisposable
public Task<bool> StopAsync(string instanceDirectory, CancellationToken ct = default)
=> Find(instanceDirectory).StopAsync(ct);
/// <summary>
/// Holt das Fenster einer Instanz nach vorn — der Wechsel, wie E-M2 ihn meint.
///
/// <para><b>Das ist der Ersatz für den Selbst-Neustart.</b> Vorher hiess „Instanz
/// wechseln": Der laufende Prozess fährt sich herunter und startet sich mit einer
/// anderen Instanz neu — wer wechselte, beendete zwangsläufig die Instanz, aus der
/// heraus er es tat. Jetzt laufen alle, und es kommt nur eine nach vorn.</para>
///
/// <para><c>false</c> heisst: Sie läuft nicht, sie hört nicht, oder sie hat kein
/// Fenster.</para>
/// </summary>
public async Task<bool> FocusAsync(string instanceDirectory, CancellationToken ct = default)
{
var aufsicht = Find(instanceDirectory);
if (aufsicht.Condition != InstanceCondition.Running)
return false;
return await _channel.RequestFocusAsync(aufsicht.InstanceDirectory, ct)
.ConfigureAwait(false);
}
/// <summary>
/// Hält alle laufenden Instanzen an. <b>Nicht</b> beim Beenden des Managers, sondern
/// nur, wenn es einen Grund gibt, der sie alle betrifft — ein Lizenzwiderruf.
+4
View File
@@ -189,6 +189,10 @@ public sealed class ManagerCore : IAsyncDisposable
public Task<bool> StopInstanceAsync(string instanceDirectory, CancellationToken ct = default)
=> Instances.StopAsync(instanceDirectory, ct);
/// <inheritdoc cref="InstanceRegistry.FocusAsync"/>
public Task<bool> FocusInstanceAsync(string instanceDirectory, CancellationToken ct = default)
=> Instances.FocusAsync(instanceDirectory, ct);
// ─── Das Urteil ───
/// <summary>
@@ -39,6 +39,18 @@ public sealed class ManagerSettings
[JsonPropertyName("instanceExecutable")]
public string InstanceExecutable { get; set; } = "";
/// <summary>
/// Ob gestartete Instanzen ein eigenes Fenster bekommen (E-M2).
///
/// <para>Vorbelegt auf <c>true</c>: Jede Instanz behält ihr Desktop-Fenster, sie laufen
/// im Hintergrund, und beim Wechsel kommt eines nach vorn. Auf einer Maschine ohne
/// Bildschirmsitzung — einem Server, einem Bauknecht — gehört hier <c>false</c> hin;
/// dann laufen die Instanzen kopflos und sind nur über die Weboberfläche zu
/// bedienen.</para>
/// </summary>
[JsonPropertyName("instanceWindows")]
public bool InstanceWindows { get; set; } = true;
// ─── Die eine Weboberfläche (E-M5) ───
[JsonPropertyName("webUiPort")]
+6 -1
View File
@@ -72,6 +72,10 @@ internal static class Program
? "(neben dem Manager)"
: settings.Settings.InstanceExecutable);
logger.LogInformation(
"Instanzen laufen {Art}.",
settings.Settings.InstanceWindows ? "mit eigenem Fenster" : "ohne Fenster");
await using var manager = Build(settings, loggerFactory, logger);
if (!await manager.StartAsync())
@@ -110,7 +114,8 @@ internal static class Program
var directories = new InstanceDirectoryManager(
Path.GetFullPath(s.InstancesDirectory));
var launcher = new ProcessInstanceLauncher(ResolveInstanceExecutable(s, logger));
var launcher = new ProcessInstanceLauncher(
ResolveInstanceExecutable(s, logger), headless: !s.InstanceWindows);
var channel = new SocketInstanceChannel();
var registry = new InstanceRegistry(
@@ -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)
{