Zwei Befunde aus dem ersten Dauerbetrieb auf dem Testserver. #487: Der Fix zu #389 hatte nur den Core auf DatabaseEndpoint umgestellt. Alle vier Modulkontexte lasen die Konfiguration weiter selbst und landeten auf dem Platzhalter aus EffectiveConnectionString, sobald die Datenbank nur in settings.json steht - "Access denied for user 'root'@'localhost'", waehrend der Core im selben Prozess sauber arbeitete. Gefunden 30 Sekunden nach dem ersten Start des Dienstes; kein Test und kein --check hatte es gesehen, weil --check nur die Core-Verbindung oeffnet. Die Kontexte loesen DatabaseEndpoint jetzt ueber die IServiceProvider-Ueberladung von AddDbContextFactory auf. Der Platzhalter meldet sich ausserdem nicht mehr als root an, sondern als "KEINE-DATENBANK-KONFIGURIERT" - die alte Meldung sah nach einem Rechteproblem aus, obwohl gar keine Verbindung konfiguriert war. #396 traegt NICHT. Die Pruefung auf der VM zeigt: Das Signal kommt an, der Handler laeuft (Logzeile steht), aber der Prozess bleibt stehen. Zwei Ursachen sind dabei aufgedeckt und behoben worden, eine dritte ist offen: 1. Ueber Avalonia.Application.Current war die Lebensdauer aus dem Signal-Thread nicht erreichbar - der Handler lief ins Leere. Sie wird jetzt im Hauptthread erzeugt (SetupWithLifetime + lifetime.Start) und dem Handler mitgegeben. 2. Die Sicherheitsabfrage der Shell fing das Schliessen ab und wartete auf einen Klick, den bei einem Signal niemand macht. Ein Signal ist keine Frage; der Handler uebergeht die Abfrage jetzt ausdruecklich. 3. OFFEN: Der Dispatcher-Rueckruf wird nicht ausgefuehrt - weder die Erfolgs- noch die Fehlerzeile erscheint, obwohl beide protokolliert wuerden. Der UI-Thread nimmt die Nachricht offenbar nicht an. Die Messpunkte bleiben im Code: ohne sie war von aussen nicht zu unterscheiden, ob das Signal ankommt, ob die Lebensdauer fehlt oder ob der Rueckruf haengt. 404 Tests gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
6.8 KiB
C#
153 lines
6.8 KiB
C#
using System.Runtime.InteropServices;
|
||
using Avalonia;
|
||
using Avalonia.Controls.ApplicationLifetimes;
|
||
using Avalonia.Threading;
|
||
using IBKRTrader.App.Shell;
|
||
using IBKRTrader.Core.Logging;
|
||
using IBKRTrader.Core.Modularity;
|
||
using IBKRTrader.Hosting;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
using Microsoft.Extensions.Hosting;
|
||
|
||
namespace IBKRTrader.App;
|
||
|
||
/// <summary>
|
||
/// Einstiegspunkt der plattformneutralen Oberfläche.
|
||
///
|
||
/// <para>Bewusst zweigeteilt: <see cref="AppHostBuilder"/> stellt den Host mit Persistenz,
|
||
/// Diensten und Modulen zusammen – ohne jeden Bezug zur Oberfläche. Erst danach wird Avalonia
|
||
/// daran gehängt. Derselbe Host trägt den kopflosen Linux-Dienst
|
||
/// (<c>IBKRTrader.Daemon</c>).</para>
|
||
/// </summary>
|
||
internal static class Program
|
||
{
|
||
[STAThread]
|
||
public static int Main(string[] args)
|
||
{
|
||
// Konstruktionsprüfung aller Fenster ohne Message-Loop und ohne laufende Dienste.
|
||
if (HasFlag(args, "--smoke-ui")) return SmokeUi.Run();
|
||
|
||
var modules = AppHostBuilder.CreateModules();
|
||
using var host = AppHostBuilder.Build(modules, ShellServices.Register);
|
||
|
||
AppHostBuilder.RunStartupChecks(host.Services);
|
||
|
||
var logger = host.Services.GetRequiredService<LoggingService>();
|
||
logger.Info("Core", "=== IBKRTrader startet ===");
|
||
logger.Info("Core", $"Version: {IBKRTrader.Hosting.BuildInfo.Summary} | .NET {Environment.Version}");
|
||
|
||
host.Start();
|
||
StartModules(modules, logger);
|
||
|
||
try
|
||
{
|
||
App.Services = host.Services;
|
||
|
||
// Die Lebensdauer wird hier selbst erzeugt statt über StartWithClassicDesktopLifetime.
|
||
// Grund: Der Signal-Handler unten braucht sie, und über Avalonia.Application.Current
|
||
// ist sie aus einem fremden Thread nicht verlässlich zu bekommen. Am 2026-09-05 auf
|
||
// der Test-VM gemessen: Das SIGTERM kam an, aber der Handler fand keine Lebensdauer
|
||
// und lief ins Leere - die Shell blieb stehen wie vor dem Fix. Mit der Referenz aus
|
||
// dem Hauptthread ist der Zugriff eindeutig.
|
||
var lifetime = new ClassicDesktopStyleApplicationLifetime
|
||
{
|
||
Args = args,
|
||
ShutdownMode = Avalonia.Controls.ShutdownMode.OnLastWindowClose
|
||
};
|
||
|
||
// SIGTERM/SIGINT geordnet behandeln. Die Avalonia-Nachrichtenschleife endet sonst nur,
|
||
// wenn das letzte Fenster geschlossen wird: beim Abmelden, beim Herunterfahren des
|
||
// Rechners und bei jedem systemctl stop bekam die Shell ein SIGTERM, ignorierte es und
|
||
// wurde nach Ablauf der Frist mit SIGKILL abgeräumt – mitten im Worker-Lauf, ohne
|
||
// Modul- und Host-Stopp. Der Daemon behandelt beide Signale über den Host bereits.
|
||
var uiHost = host.Services.GetRequiredService<Shell.AvaloniaUiHost>();
|
||
using var sigterm = RegisterShutdownSignal(PosixSignal.SIGTERM, logger, lifetime, uiHost);
|
||
using var sigint = RegisterShutdownSignal(PosixSignal.SIGINT, logger, lifetime, uiHost);
|
||
|
||
BuildAvaloniaApp().SetupWithLifetime(lifetime);
|
||
return lifetime.Start(args);
|
||
}
|
||
finally
|
||
{
|
||
// Geordnetes Herunterfahren – erst nachdem die Oberfläche beendet ist.
|
||
StopModules(modules, logger);
|
||
host.StopAsync(TimeSpan.FromSeconds(30)).GetAwaiter().GetResult();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Beendet die Oberfläche auf ein Signal hin regulär, damit der finally-Zweig mit Modul- und
|
||
/// Host-Stopp greift. Steht die Oberfläche noch nicht, bleibt die Vorgabebehandlung des
|
||
/// Signals stehen; kennt die Plattform das Signal nicht, läuft die Shell wie bisher weiter.
|
||
/// </summary>
|
||
private static IDisposable? RegisterShutdownSignal(
|
||
PosixSignal signal, LoggingService logger, IClassicDesktopStyleApplicationLifetime lifetime,
|
||
Shell.AvaloniaUiHost uiHost)
|
||
{
|
||
try
|
||
{
|
||
return PosixSignalRegistration.Create(signal, ctx =>
|
||
{
|
||
// Die Vorgabebehandlung würde den Prozess beenden, ohne den finally-Zweig mit
|
||
// Modul- und Host-Stopp zu erreichen - und sie tut es bei einer laufenden
|
||
// Avalonia-Schleife ohnehin nicht zuverlässig (am 2026-09-05 nachgestellt: der
|
||
// Prozess blieb einfach stehen). Deshalb übernehmen wir das Signal ganz.
|
||
ctx.Cancel = true;
|
||
logger.Info("Core", $"{signal} empfangen – Oberfläche wird geordnet beendet.");
|
||
Dispatcher.UIThread.Post(() =>
|
||
{
|
||
// Fehler in diesem Rückruf verschwinden sonst spurlos - er läuft auf dem
|
||
// UI-Thread, und niemand wartet auf sein Ergebnis.
|
||
try
|
||
{
|
||
// Ohne diesen Schritt fängt die Sicherheitsabfrage der Shell das Schließen
|
||
// ab und wartet auf einen Klick, den bei einem Signal niemand macht.
|
||
uiHost.SkipShutdownConfirmation();
|
||
logger.Info("Core", "Sicherheitsabfrage übergangen, beende die Oberfläche.");
|
||
lifetime.Shutdown();
|
||
logger.Info("Core", "Shutdown angefordert.");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.Error("Core", $"Herunterfahren nach {signal} gescheitert: {ex.Message}", ex);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.Warn("Core", $"{signal} konnte nicht behandelt werden: {ex.Message}");
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>Von Avalonia erwartete Fabrikmethode (auch vom XAML-Previewer genutzt).</summary>
|
||
public static AppBuilder BuildAvaloniaApp() =>
|
||
AppBuilder.Configure<App>()
|
||
.UsePlatformDetect()
|
||
.WithInterFont()
|
||
.LogToTrace();
|
||
|
||
private static void StartModules(IReadOnlyList<IModule> modules, LoggingService logger)
|
||
{
|
||
foreach (var module in modules)
|
||
{
|
||
try { module.StartAsync(default).GetAwaiter().GetResult(); }
|
||
catch (Exception ex) { logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); }
|
||
}
|
||
logger.Info("Core", "IBKRTrader bereit.");
|
||
}
|
||
|
||
private static void StopModules(IReadOnlyList<IModule> modules, LoggingService logger)
|
||
{
|
||
foreach (var module in modules)
|
||
{
|
||
try { module.StopAsync(default).GetAwaiter().GetResult(); }
|
||
catch (Exception ex) { logger.Warn(module.Name, $"{module.Name}: Stopp fehlgeschlagen: {ex.Message}"); }
|
||
}
|
||
}
|
||
|
||
private static bool HasFlag(string[] args, string flag) =>
|
||
args.Any(a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase));
|
||
}
|