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:
@@ -323,7 +323,7 @@ Verbindung und führt sie zusammen, mit der Instanzkennung an jedem Ereignis.
|
|||||||
| 2 ✔ | Instanzprozess kopflos lauffähig, Kestrel auf dem Socket | Eine Instanz läuft ohne Fenster, der Socket antwortet |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 6 | Sicherungen im Manager (falls Abschnitt 7 dafür entscheidet) | Zurückspielen hält die Instanz an und startet sie wieder |
|
||||||
|
|
||||||
**Stufe 1 ist die, die man überspringen möchte und nicht überspringen darf.**
|
**Stufe 1 ist die, die man überspringen möchte und nicht überspringen darf.**
|
||||||
@@ -611,3 +611,40 @@ spricht `/api/…` an und bekommt die eine laufende. Instanzauswahl, `X-Instanz`
|
|||||||
Verwaltungsansicht sind Arbeit an `wwwroot` und gehören damit in Geminis Spur. Und
|
Verwaltungsansicht sind Arbeit an `wwwroot` und gehören damit in Geminis Spur. Und
|
||||||
`webServerPort` steht noch in der Instanz: Ihr TCP-Zugang ist erst überflüssig, wenn die
|
`webServerPort` steht noch in der Instanz: Ihr TCP-Zugang ist erst überflüssig, wenn die
|
||||||
Oberfläche im Manager vollständig ist.
|
Oberfläche im Manager vollständig ist.
|
||||||
|
|
||||||
|
### 9.6 Was Stufe 5 gebracht hat
|
||||||
|
|
||||||
|
**Der Wechsel ist kein Neustart mehr.** Vorher hieß „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, und genau dabei
|
||||||
|
hat sich die Anwendung am 2026-09-08 aufgehängt. Jetzt laufen alle, und es kommt eine nach
|
||||||
|
vorn.
|
||||||
|
|
||||||
|
**Die Instanz holt ihr Fenster selbst.** Neu ist `POST /api/instanz/fenster`, wieder
|
||||||
|
ausschließlich über den Socket. Der Manager kennt kein Fenstersystem und soll keines
|
||||||
|
kennen; er bittet, und wer ein Fenster hat, holt es nach vorn. Kopflos läuft die Bitte ins
|
||||||
|
Leere, und das ist die richtige Antwort. **Das letzte Wort hat der Fenstermanager** —
|
||||||
|
Windows lässt einen Hintergrundprozess den Vordergrund nicht einfach an sich reißen; der
|
||||||
|
kurze Ausflug über `Topmost` half im Versuch, unter Linux entscheidet der Fenstermanager.
|
||||||
|
Mehr als bitten kann eine Anwendung nicht.
|
||||||
|
|
||||||
|
**`--managed` schaltet den Wechsel im Instanzfenster ab.** Der Manager gibt es jedem
|
||||||
|
Instanzprozess mit; das Fenster gräut seinen Instanz-Schalter aus und erklärt auf Nachfrage,
|
||||||
|
wo gewechselt wird. Zwei Schlösser an derselben Tür: Der Knopf ist aus, und der Weg
|
||||||
|
dahinter prüft es noch einmal — hinter ihm wartet ein Selbst-Neustart.
|
||||||
|
|
||||||
|
**`instanceWindows` in `manager.json`**, vorbelegt auf `true`: Jede Instanz behält ihr
|
||||||
|
Fenster (E-M2). Auf einer Maschine ohne Bildschirmsitzung gehört `false` hin.
|
||||||
|
|
||||||
|
**Abnahme, an echten Fenstern gemessen:**
|
||||||
|
|
||||||
|
| Geprüft | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| Instanz über den Manager gestartet | PID 18128, Fenstertitel „ClawdDotNet" |
|
||||||
|
| Befehlszeile | `--managed --instance …` |
|
||||||
|
| Fenster von außen minimiert | `IsIconic = True` |
|
||||||
|
| `POST /instanzen/Instance-Test/fenster` | „Fenster von 'Test' nach vorn geholt" |
|
||||||
|
| Danach | `IsIconic = False`, **im Vordergrund** |
|
||||||
|
| Prozess danach | **PID 18128, unverändert** — nichts neu gestartet |
|
||||||
|
|
||||||
|
Die letzte Zeile ist die Abnahmebedingung: kein Neustart mehr beim Wechseln.
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ public partial class App : Application
|
|||||||
/// <summary>Vorgewaehlte Instanz aus der Befehlszeile; wird genau einmal verbraucht.</summary>
|
/// <summary>Vorgewaehlte Instanz aus der Befehlszeile; wird genau einmal verbraucht.</summary>
|
||||||
private string? _preselectedInstancePath;
|
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 Initialize() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted()
|
public override void OnFrameworkInitializationCompleted()
|
||||||
@@ -44,6 +54,7 @@ public partial class App : Application
|
|||||||
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||||
|
|
||||||
_preselectedInstancePath = CommandLine.ReadInstancePath(desktop.Args);
|
_preselectedInstancePath = CommandLine.ReadInstancePath(desktop.Args);
|
||||||
|
_managed = CommandLine.IsManaged(desktop.Args);
|
||||||
|
|
||||||
ApplySavedThemeEarly();
|
ApplySavedThemeEarly();
|
||||||
|
|
||||||
@@ -127,7 +138,11 @@ public partial class App : Application
|
|||||||
// Instanzverzeichnis, ueber den der Instanzmanager die Instanz erreicht (#528).
|
// Instanzverzeichnis, ueber den der Instanzmanager die Instanz erreicht (#528).
|
||||||
_web = InstanceHttpHost.TryStart(_host, _host.LoggerFactory);
|
_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
|
// Der Wechsel gehoert hierher und nicht ins Fenster: Er braucht den Aufbau und
|
||||||
// die Lebensdauer der Anwendung, und beides kennt nur diese Klasse.
|
// 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 };
|
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.Show();
|
||||||
desktop.MainWindow.Closed += (_, _) => desktop.Shutdown();
|
desktop.MainWindow.Closed += (_, _) => desktop.Shutdown();
|
||||||
|
|
||||||
@@ -311,6 +330,38 @@ public partial class App : Application
|
|||||||
return await completion.Task;
|
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>
|
/// <summary>
|
||||||
/// Instanzwechsel aus der laufenden Anwendung.
|
/// Instanzwechsel aus der laufenden Anwendung.
|
||||||
///
|
///
|
||||||
@@ -326,6 +377,19 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
if (_host is null) return;
|
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 viewModel = new InstancePickerViewModel(_host.Directories, _host.InstancePath);
|
||||||
var window = new InstancePickerWindow { DataContext = viewModel };
|
var window = new InstancePickerWindow { DataContext = viewModel };
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ internal static class CommandLine
|
|||||||
/// <summary>Läuft ohne Fenster: nur Aufbau, Socket und Warten (#528, Stufe 2).</summary>
|
/// <summary>Läuft ohne Fenster: nur Aufbau, Socket und Warten (#528, Stufe 2).</summary>
|
||||||
public const string HeadlessArgument = "--headless";
|
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>
|
/// <summary>
|
||||||
/// Der Instanzpfad hinter <c>--instance</c>, oder <c>null</c>.
|
/// 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)
|
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 is not null
|
||||||
&& args.Any(a => string.Equals(a, HeadlessArgument, StringComparison.OrdinalIgnoreCase));
|
&& args.Any(a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ internal static class HeadlessRun
|
|||||||
"Instanz {Name} läuft ohne Fenster auf {Endpunkt}",
|
"Instanz {Name} läuft ohne Fenster auf {Endpunkt}",
|
||||||
host.Instance.InstanceName, web.Endpoint);
|
host.Instance.InstanceName, web.Endpoint);
|
||||||
|
|
||||||
await WaitForShutdownAsync(logger, web.ShutdownSignal);
|
await WaitForShutdownAsync(logger, web.Control);
|
||||||
|
|
||||||
logger.LogInformation("Instanz {Name} wird beendet.", host.Instance.InstanceName);
|
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
|
/// 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>
|
/// des Protokolls geleert. Ein Ende ohne diese Zeilen ist der Zustand aus #529.</para>
|
||||||
/// </summary>
|
/// </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);
|
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:
|
// Der Instanzmanager, ueber den Socket. Der uebliche Weg, sobald es ihn gibt:
|
||||||
// Er bittet, statt zu schiessen - ein Instanzprozess haelt eine offene Datenbank
|
// Er bittet, statt zu schiessen - ein Instanzprozess haelt eine offene Datenbank
|
||||||
// und laufende Agenten, und beides will zu Ende gebracht werden.
|
// 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 =>
|
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? RequestExit;
|
||||||
public event Action? RequestInstanceManagerDialog;
|
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>
|
/// <summary>Für den Entwurfsmodus des Editors — ohne laufenden Aufbau.</summary>
|
||||||
public MainWindowViewModel() : this(null) { }
|
public MainWindowViewModel() : this(null) { }
|
||||||
|
|
||||||
|
|||||||
@@ -52,9 +52,14 @@
|
|||||||
IsVisible="{Binding IsSidebarExpanded}" />
|
IsVisible="{Binding IsSidebarExpanded}" />
|
||||||
|
|
||||||
<!-- Instanz-Schalter: der einzige Knopf mit Akzentrahmen, weil er als
|
<!-- 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"
|
<Button DockPanel.Dock="Top" Classes="instance" Margin="12,12,12,0"
|
||||||
Command="{Binding OpenInstanceManagerCommand}"
|
Command="{Binding OpenInstanceManagerCommand}"
|
||||||
|
IsEnabled="{Binding CanSwitchInstance}"
|
||||||
ToolTip.Tip="Instanz wechseln">
|
ToolTip.Tip="Instanz wechseln">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<ctrl:StrokeIcon Data="{StaticResource IconLogo}" Width="15" Height="15"
|
<ctrl:StrokeIcon Data="{StaticResource IconLogo}" Width="15" Height="15"
|
||||||
|
|||||||
@@ -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 (
|
verwaltung.MapPost("/instanzen/{id}/stop", async (
|
||||||
string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) =>
|
string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ public interface IInstanceChannel
|
|||||||
/// Socket.</para>
|
/// Socket.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<bool> RequestStopAsync(string instanceDirectory, CancellationToken ct = default);
|
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>
|
/// <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)
|
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
|
try
|
||||||
{
|
{
|
||||||
@@ -102,7 +117,7 @@ public sealed class SocketInstanceChannel(TimeSpan? timeout = null) : IInstanceC
|
|||||||
client.Timeout = _timeout;
|
client.Timeout = _timeout;
|
||||||
|
|
||||||
using var antwort = await client
|
using var antwort = await client
|
||||||
.PostAsync("/api/instanz/beenden", content: null, ct)
|
.PostAsync(pfad, content: null, ct)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
return antwort.IsSuccessStatusCode;
|
return antwort.IsSuccessStatusCode;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace ClawdDotNet.Manager;
|
|||||||
///
|
///
|
||||||
/// <para>Bewusst schmal. Die Aufsicht fragt <b>nicht</b> die Prozessliste, ob eine Instanz
|
/// <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
|
/// 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
|
/// 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>
|
/// beenden, wenn er nicht mehr auf Bitten hört.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -50,11 +50,14 @@ public interface IInstanceLauncher
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Der echte Start: <c>ClawdDotNet --headless --instance <Verzeichnis></c>.
|
/// Der echte Start: <c>ClawdDotNet --headless --instance <Verzeichnis></c>.
|
||||||
///
|
///
|
||||||
/// <para><b>Vorerst ohne Fenster.</b> Nach E-M2 behält jede Instanz ihr eigenes
|
/// <para><b>Mit oder 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.
|
/// Desktop-Fenster; sie laufen im Hintergrund, und beim Wechsel kommt eines nach vorn.
|
||||||
/// Bis dahin ist kopflos die Betriebsart, die sich vollständig beobachten lässt — und
|
/// Auf einer Maschine ohne Bildschirmsitzung gibt es keines, und dann ist kopflos die
|
||||||
/// eine Instanz, die auf einem Server ohne Bildschirmsitzung gestartet wird, braucht sie
|
/// einzige Betriebsart, die trägt.</para>
|
||||||
/// ohnehin.</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>
|
/// </summary>
|
||||||
public sealed class ProcessInstanceLauncher(string executablePath, bool headless = true)
|
public sealed class ProcessInstanceLauncher(string executablePath, bool headless = true)
|
||||||
: IInstanceLauncher
|
: IInstanceLauncher
|
||||||
@@ -85,6 +88,7 @@ public sealed class ProcessInstanceLauncher(string executablePath, bool headless
|
|||||||
|
|
||||||
if (headless) start.ArgumentList.Add("--headless");
|
if (headless) start.ArgumentList.Add("--headless");
|
||||||
|
|
||||||
|
start.ArgumentList.Add("--managed");
|
||||||
start.ArgumentList.Add("--instance");
|
start.ArgumentList.Add("--instance");
|
||||||
start.ArgumentList.Add(Path.GetFullPath(instanceDirectory));
|
start.ArgumentList.Add(Path.GetFullPath(instanceDirectory));
|
||||||
|
|
||||||
|
|||||||
@@ -201,6 +201,28 @@ public sealed class InstanceRegistry : IAsyncDisposable
|
|||||||
public Task<bool> StopAsync(string instanceDirectory, CancellationToken ct = default)
|
public Task<bool> StopAsync(string instanceDirectory, CancellationToken ct = default)
|
||||||
=> Find(instanceDirectory).StopAsync(ct);
|
=> 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>
|
/// <summary>
|
||||||
/// Hält alle laufenden Instanzen an. <b>Nicht</b> beim Beenden des Managers, sondern
|
/// 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.
|
/// nur, wenn es einen Grund gibt, der sie alle betrifft — ein Lizenzwiderruf.
|
||||||
|
|||||||
@@ -189,6 +189,10 @@ public sealed class ManagerCore : IAsyncDisposable
|
|||||||
public Task<bool> StopInstanceAsync(string instanceDirectory, CancellationToken ct = default)
|
public Task<bool> StopInstanceAsync(string instanceDirectory, CancellationToken ct = default)
|
||||||
=> Instances.StopAsync(instanceDirectory, ct);
|
=> Instances.StopAsync(instanceDirectory, ct);
|
||||||
|
|
||||||
|
/// <inheritdoc cref="InstanceRegistry.FocusAsync"/>
|
||||||
|
public Task<bool> FocusInstanceAsync(string instanceDirectory, CancellationToken ct = default)
|
||||||
|
=> Instances.FocusAsync(instanceDirectory, ct);
|
||||||
|
|
||||||
// ─── Das Urteil ───
|
// ─── Das Urteil ───
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ public sealed class ManagerSettings
|
|||||||
[JsonPropertyName("instanceExecutable")]
|
[JsonPropertyName("instanceExecutable")]
|
||||||
public string InstanceExecutable { get; set; } = "";
|
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) ───
|
// ─── Die eine Weboberfläche (E-M5) ───
|
||||||
|
|
||||||
[JsonPropertyName("webUiPort")]
|
[JsonPropertyName("webUiPort")]
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ internal static class Program
|
|||||||
? "(neben dem Manager)"
|
? "(neben dem Manager)"
|
||||||
: settings.Settings.InstanceExecutable);
|
: settings.Settings.InstanceExecutable);
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Instanzen laufen {Art}.",
|
||||||
|
settings.Settings.InstanceWindows ? "mit eigenem Fenster" : "ohne Fenster");
|
||||||
|
|
||||||
await using var manager = Build(settings, loggerFactory, logger);
|
await using var manager = Build(settings, loggerFactory, logger);
|
||||||
|
|
||||||
if (!await manager.StartAsync())
|
if (!await manager.StartAsync())
|
||||||
@@ -110,7 +114,8 @@ internal static class Program
|
|||||||
var directories = new InstanceDirectoryManager(
|
var directories = new InstanceDirectoryManager(
|
||||||
Path.GetFullPath(s.InstancesDirectory));
|
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 channel = new SocketInstanceChannel();
|
||||||
|
|
||||||
var registry = new InstanceRegistry(
|
var registry = new InstanceRegistry(
|
||||||
|
|||||||
@@ -6,31 +6,46 @@ using Microsoft.AspNetCore.Routing;
|
|||||||
namespace ClawdDotNet.Web.Endpoints;
|
namespace ClawdDotNet.Web.Endpoints;
|
||||||
|
|
||||||
/// <summary>
|
/// <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:
|
/// <para>Ein eigener Typ und keine Rückrufe am Endpunkt, weil zwei Seiten ihn brauchen:
|
||||||
/// Der Endpunkt löst ihn aus, der Wirtsprozess wartet darauf. Wer wartet, ist verschieden
|
/// Der Endpunkt löst aus, der Wirtsprozess reagiert. Und wer reagiert, ist verschieden —
|
||||||
/// — kopflos die Schleife in <c>HeadlessRun</c>, mit Fenster die Avalonia-Anwendung.</para>
|
/// kopflos die Warteschleife in <c>HeadlessRun</c>, mit Fenster die
|
||||||
|
/// Avalonia-Anwendung.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ShutdownSignal
|
public sealed class InstanceControl
|
||||||
{
|
{
|
||||||
/// <summary>Wird höchstens einmal ausgelöst.</summary>
|
private int _shutdownRaised;
|
||||||
public event Action? Requested;
|
|
||||||
|
|
||||||
private int _raised;
|
/// <summary>Der Manager bittet, diesen Prozess zu beenden.</summary>
|
||||||
|
public event Action? ShutdownRequested;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Meldet den Wunsch an. Mehrfaches Aufrufen tut nichts — ein zweiter Aufruf würde
|
/// Der Manager bittet, das Fenster dieser Instanz nach vorn zu holen (E-M2).
|
||||||
/// sonst mitten im Herunterfahren ein zweites Herunterfahren anstoßen.
|
///
|
||||||
|
/// <para>Kopflos gibt es nichts nach vorn zu holen; dann läuft dieses Ereignis ins
|
||||||
|
/// Leere, und das ist die richtige Antwort.</para>
|
||||||
/// </summary>
|
/// </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;
|
return false;
|
||||||
|
|
||||||
Requested?.Invoke();
|
ShutdownRequested?.Invoke();
|
||||||
return true;
|
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>
|
/// <summary>
|
||||||
@@ -40,8 +55,8 @@ public sealed class ShutdownSignal
|
|||||||
/// Endpunkte nicht, auch mit gültigem Token nicht, und sie antworten dort mit 404 statt
|
/// 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
|
/// 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
|
/// #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,
|
/// soll den Prozess, der sie gerade bedient, nicht beenden und auch nicht sein Fenster
|
||||||
/// und er kommt über den Socket.</para>
|
/// aufreissen können; der Manager soll es, und er kommt über den Socket.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class ControlEndpoints
|
internal static class ControlEndpoints
|
||||||
{
|
{
|
||||||
@@ -49,7 +64,7 @@ internal static class ControlEndpoints
|
|||||||
{
|
{
|
||||||
var control = app.MapGroup("/api/instanz");
|
var control = app.MapGroup("/api/instanz");
|
||||||
|
|
||||||
control.MapPost("/beenden", (HttpContext context, ShutdownSignal signal) =>
|
control.MapPost("/beenden", (HttpContext context, InstanceControl steuerung) =>
|
||||||
{
|
{
|
||||||
if (!LocalTransport.Applies(context))
|
if (!LocalTransport.Applies(context))
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
@@ -61,12 +76,25 @@ internal static class ControlEndpoints
|
|||||||
// haengt aber ab, ob er anschliessend geduldig wartet oder abschiesst.
|
// haengt aber ab, ob er anschliessend geduldig wartet oder abschiesst.
|
||||||
context.Response.OnCompleted(() =>
|
context.Response.OnCompleted(() =>
|
||||||
{
|
{
|
||||||
signal.Raise();
|
steuerung.RequestShutdown();
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 202 und nicht 200: Beim Antworten laeuft der Prozess noch.
|
// 202 und nicht 200: Beim Antworten laeuft der Prozess noch.
|
||||||
return Results.Accepted(value: new { angenommen = true });
|
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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,19 +51,20 @@ public sealed class InstanceHttpHost : IAsyncDisposable
|
|||||||
Endpoint = endpoint;
|
Endpoint = endpoint;
|
||||||
Url = url;
|
Url = url;
|
||||||
|
|
||||||
ShutdownSignal = app.Services.GetRequiredService<ShutdownSignal>();
|
Control = app.Services.GetRequiredService<InstanceControl>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Der Instanzmanager hat gebeten, diesen Prozess zu beenden
|
/// Was der Instanzmanager diesem Prozess über den Socket sagen kann: sich beenden
|
||||||
/// (<c>POST /api/instanz/beenden</c>, nur über den Socket).
|
/// (<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
|
/// <para>Der Wirtsprozess hängt sich hier an und tut es: kopflos beendet sich die
|
||||||
/// Warteschleife, mit Fenster die Avalonia-Anwendung. Diese Klasse tut es nicht
|
/// Warteschleife, mit Fenster reagiert die Avalonia-Anwendung. Diese Klasse tut es
|
||||||
/// selbst — sie kennt nur ihren Kestrel und wüsste gar nicht, was sonst noch zu
|
/// nicht selbst — sie kennt nur ihren Kestrel und wüsste gar nicht, was sonst noch zu
|
||||||
/// beenden wäre.</para>
|
/// beenden oder anzuzeigen wäre.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ShutdownSignal ShutdownSignal { get; }
|
public InstanceControl Control { get; }
|
||||||
|
|
||||||
/// <summary>Socket oder Named Pipe — der Weg, den der Instanzmanager nimmt.</summary>
|
/// <summary>Socket oder Named Pipe — der Weg, den der Instanzmanager nimmt.</summary>
|
||||||
public string Endpoint { get; }
|
public string Endpoint { get; }
|
||||||
@@ -184,7 +185,7 @@ public sealed class InstanceHttpHost : IAsyncDisposable
|
|||||||
builder.WebHost.ConfigureKestrel(kestrel => Listen(kestrel, host.InstancePath, options));
|
builder.WebHost.ConfigureKestrel(kestrel => Listen(kestrel, host.InstancePath, options));
|
||||||
|
|
||||||
builder.Services.AddSingleton(host);
|
builder.Services.AddSingleton(host);
|
||||||
builder.Services.AddSingleton<ShutdownSignal>();
|
builder.Services.AddSingleton<InstanceControl>();
|
||||||
|
|
||||||
if (options is not null)
|
if (options is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -118,4 +118,16 @@ internal sealed class FakeChannel : IInstanceChannel
|
|||||||
StopRequests++;
|
StopRequests++;
|
||||||
return Task.FromResult(StopRequestArrives);
|
return Task.FromResult(StopRequestArrives);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Welche Instanzen gebeten wurden, ihr Fenster nach vorn zu holen.</summary>
|
||||||
|
public List<string> FocusRequests { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Ob die Instanz ein Fenster hat, das nach vorn kommen kann.</summary>
|
||||||
|
public bool HasWindow { get; set; } = true;
|
||||||
|
|
||||||
|
public Task<bool> RequestFocusAsync(string instanceDirectory, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
FocusRequests.Add(Path.GetFullPath(instanceDirectory));
|
||||||
|
return Task.FromResult(HasWindow);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,3 +217,93 @@ public sealed class ManagerCoreTests : IAsyncDisposable
|
|||||||
(await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.DidNotStart);
|
(await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.DidNotStart);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Der Wechsel, wie E-M2 ihn meint (#528, Stufe 5).
|
||||||
|
///
|
||||||
|
/// <para><b>Vorher hiess „Instanz wechseln":</b> 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 — und genau dabei hat sich am 2026-09-08 die
|
||||||
|
/// Anwendung aufgehängt (#529). Jetzt laufen alle, und es kommt nur eine nach vorn.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InstanceFocusTests : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root = Path.Combine(
|
||||||
|
Path.GetTempPath(), "clawd-tests", "focus-" + Guid.NewGuid().ToString("N")[..8]);
|
||||||
|
|
||||||
|
private readonly FakeLauncher _launcher = new();
|
||||||
|
private readonly FakeChannel _kanal = new();
|
||||||
|
private readonly InstanceDirectoryManager _verzeichnisse;
|
||||||
|
|
||||||
|
private static readonly SupervisionOptions Options = new(
|
||||||
|
ProbeInterval: TimeSpan.FromMilliseconds(50),
|
||||||
|
StartTimeout: TimeSpan.FromMilliseconds(600),
|
||||||
|
SilentChecksBeforeAlarm: 3,
|
||||||
|
StopGrace: TimeSpan.FromMilliseconds(300));
|
||||||
|
|
||||||
|
public InstanceFocusTests()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_root);
|
||||||
|
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private InstanceRegistry Register() => new(
|
||||||
|
_verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options);
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
try { Directory.Delete(_root, recursive: true); }
|
||||||
|
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Eine_laufende_Instanz_wird_nach_vorn_geholt()
|
||||||
|
{
|
||||||
|
await using var register = Register();
|
||||||
|
var pfad = register.Create("Alpha");
|
||||||
|
|
||||||
|
_kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", "Alpha", "0.1.2");
|
||||||
|
await register.StartAsync(pfad);
|
||||||
|
|
||||||
|
(await register.FocusAsync(pfad)).ShouldBeTrue();
|
||||||
|
|
||||||
|
_kanal.FocusRequests.ShouldBe([Path.GetFullPath(pfad)]);
|
||||||
|
|
||||||
|
// Und vor allem: Es wurde nichts beendet und nichts neu gestartet.
|
||||||
|
_launcher.Starts.ShouldBe(1);
|
||||||
|
_kanal.StopRequests.ShouldBe(0);
|
||||||
|
_launcher.Last!.HasExited.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Eine_Instanz_die_nicht_laeuft_hat_kein_Fenster()
|
||||||
|
{
|
||||||
|
await using var register = Register();
|
||||||
|
var pfad = register.Create("Alpha");
|
||||||
|
|
||||||
|
(await register.FocusAsync(pfad)).ShouldBeFalse();
|
||||||
|
|
||||||
|
// Nicht einmal gefragt: Wer nicht laeuft, antwortet auch nicht.
|
||||||
|
_kanal.FocusRequests.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Eine kopflose Instanz hat nichts nach vorn zu holen — und sagt das, statt Erfolg
|
||||||
|
/// zu melden.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Eine_kopflose_Instanz_meldet_dass_sie_kein_Fenster_hat()
|
||||||
|
{
|
||||||
|
await using var register = Register();
|
||||||
|
var pfad = register.Create("Alpha");
|
||||||
|
|
||||||
|
_kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", "Alpha", "0.1.2");
|
||||||
|
await register.StartAsync(pfad);
|
||||||
|
|
||||||
|
_kanal.HasWindow = false;
|
||||||
|
|
||||||
|
(await register.FocusAsync(pfad)).ShouldBeFalse();
|
||||||
|
_kanal.FocusRequests.ShouldHaveSingleItem();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using ClawdDotNet.App.Settings;
|
|||||||
using ClawdDotNet.Core.Config;
|
using ClawdDotNet.Core.Config;
|
||||||
using ClawdDotNet.Core.Instances;
|
using ClawdDotNet.Core.Instances;
|
||||||
using ClawdDotNet.Core.Tools;
|
using ClawdDotNet.Core.Tools;
|
||||||
|
using ClawdDotNet.Web.Endpoints;
|
||||||
using ClawdDotNet.Web.Tests.Infrastructure;
|
using ClawdDotNet.Web.Tests.Infrastructure;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
@@ -187,3 +188,110 @@ public sealed class InstanceSocketTests : IDisposable
|
|||||||
File.Exists(InstanceEndpoint.SocketPath(host.InstancePath)).ShouldBeFalse();
|
File.Exists(InstanceEndpoint.SocketPath(host.InstancePath)).ShouldBeFalse();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Die Steuerung des Instanzprozesses: beenden und Fenster nach vorn holen
|
||||||
|
/// (#528, Stufen 3 und 5).
|
||||||
|
///
|
||||||
|
/// <para><b>Beides gibt es ausschließlich über den Socket.</b> Über TCP existieren die
|
||||||
|
/// 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. Die Lebensdauer einer Instanz
|
||||||
|
/// gehört dem Instanzmanager; eine Browsersitzung soll den Prozess, der sie bedient, weder
|
||||||
|
/// beenden noch sein Fenster aufreißen können.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InstanceControlTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root = Path.Combine(
|
||||||
|
Path.GetTempPath(), "clawd-tests", "ctl-" + Guid.NewGuid().ToString("N")[..8]);
|
||||||
|
|
||||||
|
private const string Token = "ein-hinreichend-langes-testtoken";
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_root, recursive: true); }
|
||||||
|
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppHost NeuerHost(int port)
|
||||||
|
{
|
||||||
|
var instancesDir = Path.Combine(_root, "Instances");
|
||||||
|
var instancePath = Path.Combine(instancesDir, "Test");
|
||||||
|
Directory.CreateDirectory(instancePath);
|
||||||
|
|
||||||
|
return new AppHost
|
||||||
|
{
|
||||||
|
Settings = new SettingsManager(_root),
|
||||||
|
Directories = new InstanceDirectoryManager(instancesDir),
|
||||||
|
Instance = new InstanceConfig
|
||||||
|
{
|
||||||
|
InstanceName = "Test",
|
||||||
|
WorkingDirectory = instancePath,
|
||||||
|
WebUiEnabled = true,
|
||||||
|
WebUiBinding = "loopback",
|
||||||
|
WebUiToken = Token,
|
||||||
|
WebServerPort = port
|
||||||
|
},
|
||||||
|
InstancePath = instancePath,
|
||||||
|
LogDirectory = Path.Combine(instancePath, "Logs"),
|
||||||
|
LoggerFactory = NullLoggerFactory.Instance,
|
||||||
|
Tools = new ToolRegistry()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int FreierPort()
|
||||||
|
{
|
||||||
|
using var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||||
|
probe.Start();
|
||||||
|
var port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port;
|
||||||
|
probe.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ueber_den_Socket_kommt_die_Bitte_um_das_Fenster_an()
|
||||||
|
{
|
||||||
|
await using var host = NeuerHost(FreierPort());
|
||||||
|
await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
|
||||||
|
|
||||||
|
web.ShouldNotBeNull();
|
||||||
|
|
||||||
|
var gebeten = 0;
|
||||||
|
web!.Control.FocusRequested += () => Interlocked.Increment(ref gebeten);
|
||||||
|
|
||||||
|
using var client = InstanceEndpoint.CreateClient(host.InstancePath);
|
||||||
|
(await client.PostAsync("/api/instanz/fenster", content: null)).EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
gebeten.ShouldBe(1);
|
||||||
|
|
||||||
|
// Und beliebig oft: Jeder Wechsel auf diese Instanz ist einer.
|
||||||
|
(await client.PostAsync("/api/instanz/fenster", content: null)).EnsureSuccessStatusCode();
|
||||||
|
gebeten.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ueber_TCP_gibt_es_die_Steuerung_nicht()
|
||||||
|
{
|
||||||
|
var port = FreierPort();
|
||||||
|
|
||||||
|
await using var host = NeuerHost(port);
|
||||||
|
await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
|
||||||
|
|
||||||
|
web.ShouldNotBeNull();
|
||||||
|
web!.Url.ShouldNotBeNull();
|
||||||
|
|
||||||
|
var gebeten = false;
|
||||||
|
web.Control.FocusRequested += () => gebeten = true;
|
||||||
|
|
||||||
|
using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") };
|
||||||
|
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}");
|
||||||
|
|
||||||
|
// Mit gueltigem Token - und trotzdem nicht vorhanden.
|
||||||
|
(await client.PostAsync("/api/instanz/fenster", content: null)).StatusCode
|
||||||
|
.ShouldBe(System.Net.HttpStatusCode.NotFound);
|
||||||
|
|
||||||
|
(await client.PostAsync("/api/instanz/beenden", content: null)).StatusCode
|
||||||
|
.ShouldBe(System.Net.HttpStatusCode.NotFound);
|
||||||
|
|
||||||
|
gebeten.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user