Instanzmanager Stufe 2: Kestrel auf dem Socket, Instanz kopflos (#528)
Eine Instanz ist jetzt ohne Port ansprechbar. InstanceEndpoint liefert die Adresse: unter Linux <Instanz>/manager.sock, unter Windows eine Named Pipe, deren Name aus einem SHA-256-Fingerabdruck des Instanzpfades entsteht. Zwei Instanzen bekommen damit zwangslaeufig zwei Adressen, ohne dass jemand etwas vergeben muss - genau der Unterschied zur Vorbelegung 7001, um die sich zwei Instanzen stillschweigend streiten. Der Socket ist immer da, auch ohne Weboberflaeche. Er gehoert nicht zu #494, sondern ist die Steuerflaeche der Instanz: webUiEnabled schaltet den TCP-Zugang, nicht den Socket. WebUiHost heisst deshalb jetzt InstanceHttpHost - ein Kestrel, zwei Zugaenge. Ueber TCP gilt Token-Pflicht, ueber den Socket nicht: Dort steht die Grenze am Dateisystem (0600 unter Linux, CurrentUserOnly bei der Named Pipe, explizit gesetzt statt auf die Vorgabe vertraut). Wer die Socketdatei oeffnen darf, sieht ohnehin in das Verzeichnis, in dem alles liegt, was ein Token schuetzen wuerde. Woran die beiden Zugaenge auseinandergehalten werden, ist der Punkt, an dem ein Irrtum teuer waere. Bei Socket-Verbindungen stehen in HttpContext.Connection keine IP-Adressen - daraus zu schliessen "keine IP, also Socket" waere eine Vermutung an der Stelle, an der ein Torwaechter steht, und sie irrt in die falsche Richtung: Im Zweifel liesse sie eine TCP-Anfrage ohne Token durch. Der Socket-Zuhoerer haengt deshalb jeder Verbindung selbst eine Marke an (LocalTransport, Verbindungs- Zwischenschicht ueber ListenOptions.Use); wo sie fehlt, gilt Token-Pflicht. Ein belegter Port kostet nur noch die Weboberflaeche. Vorher gab TryStart in dem Fall null zurueck und die ganze HTTP-Flaeche war weg. Seit dem Socket ist das die falsche Rangfolge: Ihn zu verlieren, weil eine zweite Instanz laeuft, hiesse die Instanz unerreichbar zu machen - in genau dem Fall, fuer den es den Manager gibt. Kopfloser Betrieb ueber --headless --instance <Pfad>. Die Verzweigung sitzt in Program.Main vor BuildAvaloniaApp, damit auf einem Server ohne Bildschirmsitzung kein Fenstersystem angefasst wird. Gefragt wird nichts: Instanz aus dem Argument, Lizenz ueber das bereits vorhandene NonInteractiveLicensePrompt, Telegram-Anmeldung aus. Nachgemessen, nicht angenommen: Kopflose Instanz auf Instance-Test gestartet, kein Fenster, und eine rohe HTTP/1.1-Anfrage aus einem fremden Prozess (PowerShell, direkt auf die Pipe) bekam 200 OK auf /api/info, ohne Token. Das ist die Abnahmebedingung fuer Stufe 2. Korrektur zur eigenen Ankuendigung: webServerPort entfaellt nicht mit Stufe 2, sondern mit Stufe 4. Solange kein Manager die Weboberflaeche traegt, hiesse Wegnehmen, sie ersatzlos abzuschalten. Zwei Luecken bleiben offen und gehoeren auf die Linux-Testmaschine: Die Socketdatei-Faelle sind unter Windows gegenstandslos und werden dort mit sichtbarer Begruendung uebersprungen - der Unix-Domain-Socket selbst ist auf der Entwicklungsmaschine also ungeprueft, obwohl er die Zielplattform ist. Und das geordnete Ende auf SIGTERM liess sich hier nicht ausloesen: Windows kennt das Signal fuer einen fensterlosen Prozess nicht. Build ohne Warnungen, 714 Tests gruen, 8 uebersprungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,12 +21,12 @@ public partial class App : Application
|
||||
/// starten laesst. Gesetzt wird er nur von der Anwendung selbst, wenn der
|
||||
/// Instanzdialog einen Wechsel verlangt — deshalb ist er nirgends dokumentiert.
|
||||
/// </summary>
|
||||
private const string InstanceArgument = "--instance";
|
||||
private const string InstanceArgument = CommandLine.InstanceArgument;
|
||||
|
||||
private AppHost? _host;
|
||||
|
||||
/// <summary>Die Weboberflaeche, falls sie eingeschaltet ist. Sonst null.</summary>
|
||||
private WebUiHost? _web;
|
||||
private InstanceHttpHost? _web;
|
||||
|
||||
/// <summary>Vorgewaehlte Instanz aus der Befehlszeile; wird genau einmal verbraucht.</summary>
|
||||
private string? _preselectedInstancePath;
|
||||
@@ -42,7 +42,7 @@ public partial class App : Application
|
||||
// in genau dieser Lücke aussteigen.
|
||||
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||
|
||||
_preselectedInstancePath = ReadInstanceArgument(desktop.Args);
|
||||
_preselectedInstancePath = CommandLine.ReadInstancePath(desktop.Args);
|
||||
|
||||
ApplySavedThemeEarly();
|
||||
|
||||
@@ -118,10 +118,13 @@ public partial class App : Application
|
||||
HookErrorReporting(_host);
|
||||
HookLicenseWatch(_host, desktop);
|
||||
|
||||
// Nach dem Aufbau und vor dem Fenster: Die Weboberflaeche braucht den fertigen
|
||||
// Nach dem Aufbau und vor dem Fenster: Die HTTP-Flaeche braucht den fertigen
|
||||
// AppHost, und ein belegter Port darf das Fenster nicht aufhalten — TryStart
|
||||
// gibt in jedem Fehlerfall null zurueck und schreibt den Grund ins Protokoll.
|
||||
_web = WebUiHost.TryStart(_host, _host.LoggerFactory);
|
||||
//
|
||||
// Sie ist mehr als die Weboberflaeche: Immer dabei ist der Socket im
|
||||
// Instanzverzeichnis, ueber den der Instanzmanager die Instanz erreicht (#528).
|
||||
_web = InstanceHttpHost.TryStart(_host, _host.LoggerFactory);
|
||||
|
||||
var mainViewModel = new MainWindowViewModel(_host);
|
||||
|
||||
@@ -388,19 +391,6 @@ public partial class App : Application
|
||||
await web.DisposeAsync();
|
||||
}
|
||||
|
||||
private static string? ReadInstanceArgument(IReadOnlyList<string>? args)
|
||||
{
|
||||
if (args is null) return null;
|
||||
|
||||
for (var i = 0; i < args.Count - 1; i++)
|
||||
{
|
||||
if (string.Equals(args[i], InstanceArgument, StringComparison.OrdinalIgnoreCase))
|
||||
return args[i + 1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Instanzauswahl beim Start.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace ClawdDotNet.Desktop;
|
||||
|
||||
/// <summary>
|
||||
/// Die Argumente, die vor allem anderen gelesen werden müssen.
|
||||
///
|
||||
/// <para>Eigene Klasse, weil sie zwei Aufrufer hat, die sich nicht kennen dürfen:
|
||||
/// <see cref="Program"/> entscheidet damit, ob überhaupt ein Fenster entsteht, und
|
||||
/// <see cref="App"/> übernimmt die vorgewählte Instanz in den Startvorgang. Lägen sie in
|
||||
/// <c>App</c>, müsste <c>Program</c> die Oberfläche anfassen, um zu erfahren, dass keine
|
||||
/// gebraucht wird.</para>
|
||||
/// </summary>
|
||||
internal static class CommandLine
|
||||
{
|
||||
public const string InstanceArgument = "--instance";
|
||||
|
||||
/// <summary>Läuft ohne Fenster: nur Aufbau, Socket und Warten (#528, Stufe 2).</summary>
|
||||
public const string HeadlessArgument = "--headless";
|
||||
|
||||
/// <summary>
|
||||
/// Der Instanzpfad hinter <c>--instance</c>, oder <c>null</c>.
|
||||
///
|
||||
/// <para>Bewusst ohne Prüfung, ob es das Verzeichnis gibt — das entscheidet der
|
||||
/// Aufrufer, und er entscheidet es unterschiedlich: Mit Fenster erscheint dann der
|
||||
/// Auswahldialog, ohne Fenster ist es ein Abbruch mit Meldung.</para>
|
||||
/// </summary>
|
||||
public static string? ReadInstancePath(IReadOnlyList<string>? args)
|
||||
{
|
||||
if (args is null) return null;
|
||||
|
||||
for (var i = 0; i < args.Count - 1; i++)
|
||||
{
|
||||
if (string.Equals(args[i], InstanceArgument, StringComparison.OrdinalIgnoreCase))
|
||||
return args[i + 1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool IsHeadless(IReadOnlyList<string>? args)
|
||||
=> args is not null
|
||||
&& args.Any(a => string.Equals(a, HeadlessArgument, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using ClawdDotNet.App;
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Web;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Desktop;
|
||||
|
||||
/// <summary>
|
||||
/// Eine Instanz ohne Fenster (#528, Stufe 2).
|
||||
///
|
||||
/// <para><b>Wozu, wenn doch jede Instanz ihr Fenster behält (E-M2).</b> Weil der
|
||||
/// Instanzprozess damit beweist, dass er keines <em>braucht</em>. Ein Server ohne
|
||||
/// Bildschirmsitzung, ein systemd-Dienst, ein Prüflauf im Bau — alle drei brauchen genau
|
||||
/// das, und jede versehentliche Abhängigkeit von der Oberfläche fällt hier sofort auf,
|
||||
/// statt erst auf der Zielmaschine.</para>
|
||||
///
|
||||
/// <para><b>Was hier nicht gefragt wird, wird gar nicht gefragt.</b> Ein Dienst ohne
|
||||
/// Sitzung, der beim Start ein Eingabefenster öffnet, hängt für immer, ohne dass jemand
|
||||
/// die Frage je zu sehen bekommt. Die Instanz kommt deshalb vom Argument, die Lizenz
|
||||
/// über <see cref="NonInteractiveLicensePrompt"/>, und die Telegram-Anmeldung bleibt
|
||||
/// aus — <c>Callbacks.TelegramLogin</c> ist null, und das ist hier die Antwort und kein
|
||||
/// Versäumnis.</para>
|
||||
///
|
||||
/// <para><b>Unter Windows sieht man davon wenig.</b> Die Programmdatei ist eine
|
||||
/// <c>WinExe</c> und hat keine Konsole; die Meldungen unten landen dann im Nichts. Was
|
||||
/// zählt, steht ohnehin im Instanzprotokoll. Der Zielbetrieb ist Linux, und dort ist es
|
||||
/// eine gewöhnliche Konsolenanwendung.</para>
|
||||
/// </summary>
|
||||
internal static class HeadlessRun
|
||||
{
|
||||
private const int ExitOk = 0;
|
||||
private const int ExitNoInstance = 2;
|
||||
private const int ExitStartFailed = 3;
|
||||
private const int ExitNoEndpoint = 4;
|
||||
|
||||
public static async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
var instancePath = CommandLine.ReadInstancePath(args);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(instancePath) || !Directory.Exists(instancePath))
|
||||
{
|
||||
await Console.Error.WriteLineAsync(
|
||||
$"Ohne Fenster gibt es keinen Auswahldialog. Erwartet wird "
|
||||
+ $"{CommandLine.InstanceArgument} <Instanzverzeichnis>"
|
||||
+ (instancePath is null ? "." : $" — '{instancePath}' gibt es nicht."));
|
||||
|
||||
return ExitNoInstance;
|
||||
}
|
||||
|
||||
var (host, error) = await AppHost.StartAsync(new AppHost.Callbacks
|
||||
{
|
||||
SelectInstance = _ => Task.FromResult<string?>(instancePath),
|
||||
|
||||
// Fragt nicht, sondern meldet, was zu tun ist. Mit E-M3 wandert die
|
||||
// Lizenzpruefung ohnehin in den Instanzmanager; bis dahin ist das hier der
|
||||
// ehrliche Weg: Ein Schluessel wird vorab hinterlegt, nicht erfragt.
|
||||
License = new NonInteractiveLicensePrompt(Console.Error.WriteLine)
|
||||
});
|
||||
|
||||
if (host is null)
|
||||
{
|
||||
// Kein Fehlertext heisst Abbruch mit Ansage - die Lizenzabfrage hat ihre
|
||||
// Meldung schon selbst geschrieben.
|
||||
if (error is not null) await Console.Error.WriteLineAsync(error);
|
||||
return ExitStartFailed;
|
||||
}
|
||||
|
||||
await using (host)
|
||||
{
|
||||
var logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Headless");
|
||||
|
||||
var web = InstanceHttpHost.TryStart(host, host.LoggerFactory);
|
||||
|
||||
if (web is null)
|
||||
{
|
||||
// Ohne Socket ist eine kopflose Instanz nicht ansprechbar - also nichts,
|
||||
// was man laufen lassen sollte. Mit Fenster waere das anders: Dort bleibt
|
||||
// die Oberflaeche als Zugang.
|
||||
logger.LogError("Kein Zugang: Die HTTP-Fläche ist nicht hochgekommen.");
|
||||
return ExitNoEndpoint;
|
||||
}
|
||||
|
||||
await using (web)
|
||||
{
|
||||
Console.WriteLine(web.Endpoint);
|
||||
logger.LogInformation(
|
||||
"Instanz {Name} läuft ohne Fenster auf {Endpunkt}",
|
||||
host.Instance.InstanceName, web.Endpoint);
|
||||
|
||||
await WaitForShutdownAsync(logger);
|
||||
|
||||
logger.LogInformation("Instanz {Name} wird beendet.", host.Instance.InstanceName);
|
||||
}
|
||||
}
|
||||
|
||||
return ExitOk;
|
||||
}
|
||||
|
||||
/// <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><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)
|
||||
{
|
||||
var schluss = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
void Beenden(string grund)
|
||||
{
|
||||
if (schluss.TrySetResult())
|
||||
logger.LogInformation("Beendigung angefordert ({Grund}).", grund);
|
||||
}
|
||||
|
||||
using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
|
||||
{
|
||||
context.Cancel = true;
|
||||
Beenden("SIGTERM");
|
||||
});
|
||||
|
||||
using var sigint = PosixSignalRegistration.Create(PosixSignal.SIGINT, context =>
|
||||
{
|
||||
context.Cancel = true;
|
||||
Beenden("SIGINT");
|
||||
});
|
||||
|
||||
AppDomain.CurrentDomain.ProcessExit += (_, _) => Beenden("ProcessExit");
|
||||
|
||||
await schluss.Task;
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,15 @@ internal static class Program
|
||||
/// Kein <c>[STAThread]</c> mehr: Das war eine COM-Anforderung von WinForms. Avalonia
|
||||
/// braucht es nicht, und unter Linux hätte es ohnehin keine Bedeutung.
|
||||
/// </summary>
|
||||
public static int Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
/// <remarks>
|
||||
/// Die Verzweigung steht vor <see cref="BuildAvaloniaApp"/> und nicht darin: Mit
|
||||
/// <c>--headless</c> soll gar kein Fenstersystem angefasst werden — auf einem Server
|
||||
/// ohne Bildschirmsitzung gäbe es keines (#528, Stufe 2).
|
||||
/// </remarks>
|
||||
public static int Main(string[] args)
|
||||
=> CommandLine.IsHeadless(args)
|
||||
? HeadlessRun.RunAsync(args).GetAwaiter().GetResult()
|
||||
: BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
|
||||
/// <summary>
|
||||
/// Auch vom Vorschau-Werkzeug des Editors aufgerufen — deshalb öffentlich und
|
||||
|
||||
Reference in New Issue
Block a user