From b618efa9ce315feb7da333d5d441e2c9590b2626 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 5 Sep 2026 13:23:16 +0200 Subject: [PATCH] =?UTF-8?q?Modulkontexte=20an=20die=20ma=C3=9Fgebliche=20D?= =?UTF-8?q?atenbank;=20Shell-Shutdown=20weiter=20untersucht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/IBKRTrader.App/Program.cs | 53 +++++++++++++++---- src/IBKRTrader.App/Shell/AvaloniaUiHost.cs | 15 ++++++ .../ServiceCollectionExtensions.cs | 19 +++++-- .../AccountingModule.cs | 10 +++- .../CongressTradingModule.cs | 11 ++-- .../OptionsWheelModule.cs | 11 ++-- .../SupervisorModule.cs | 10 +++- 7 files changed, 105 insertions(+), 24 deletions(-) diff --git a/src/IBKRTrader.App/Program.cs b/src/IBKRTrader.App/Program.cs index ec5c4bb..71bcec6 100644 --- a/src/IBKRTrader.App/Program.cs +++ b/src/IBKRTrader.App/Program.cs @@ -43,16 +43,29 @@ internal static class Program { 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. - using var sigterm = RegisterShutdownSignal(PosixSignal.SIGTERM, logger); - using var sigint = RegisterShutdownSignal(PosixSignal.SIGINT, logger); + var uiHost = host.Services.GetRequiredService(); + using var sigterm = RegisterShutdownSignal(PosixSignal.SIGTERM, logger, lifetime, uiHost); + using var sigint = RegisterShutdownSignal(PosixSignal.SIGINT, logger, lifetime, uiHost); - BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); - return 0; + BuildAvaloniaApp().SetupWithLifetime(lifetime); + return lifetime.Start(args); } finally { @@ -67,18 +80,38 @@ internal static class Program /// 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. /// - private static IDisposable? RegisterShutdownSignal(PosixSignal signal, LoggingService logger) + private static IDisposable? RegisterShutdownSignal( + PosixSignal signal, LoggingService logger, IClassicDesktopStyleApplicationLifetime lifetime, + Shell.AvaloniaUiHost uiHost) { try { return PosixSignalRegistration.Create(signal, ctx => { - if (Avalonia.Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) - return; - - ctx.Cancel = true; // sonst endet der Prozess sofort – ohne Modul- und Host-Stopp + // 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(() => desktop.Shutdown()); + 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) diff --git a/src/IBKRTrader.App/Shell/AvaloniaUiHost.cs b/src/IBKRTrader.App/Shell/AvaloniaUiHost.cs index ccea614..4b08f42 100644 --- a/src/IBKRTrader.App/Shell/AvaloniaUiHost.cs +++ b/src/IBKRTrader.App/Shell/AvaloniaUiHost.cs @@ -120,6 +120,21 @@ public sealed class AvaloniaUiHost : IModuleUiHost } } + /// + /// Übergeht die Sicherheitsabfrage – für ein Herunterfahren, das nicht vom Bediener kommt. + /// + /// Ein Signal ist keine Frage. Die Abfrage schützt vor einem versehentlichen + /// Klick auf das Schließen-X; sie darf nicht zwischen dem Betriebssystem und dem Ende des + /// Prozesses stehen. Bei systemctl stop, beim Abmelden und beim Herunterfahren des + /// Rechners wartet niemand auf einen Dialog – die Shell hing dort und wurde nach Ablauf der + /// Frist mit SIGKILL abgeräumt, mitten im Worker-Lauf (am 2026-09-05 nachgestellt). + /// + public void SkipShutdownConfirmation() + { + ShutdownConfirmed = true; + CloseAllViews(); + } + /// Schließt alle offenen View-Fenster (beim Herunterfahren). public void CloseAllViews() { diff --git a/src/IBKRTrader.Core/DependencyInjection/ServiceCollectionExtensions.cs b/src/IBKRTrader.Core/DependencyInjection/ServiceCollectionExtensions.cs index a1c4503..cb475dc 100644 --- a/src/IBKRTrader.Core/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/IBKRTrader.Core/DependencyInjection/ServiceCollectionExtensions.cs @@ -20,12 +20,23 @@ public static class ServiceCollectionExtensions } /// - /// Liefert den Connection-String oder – bei leerem Wert (z. B. fehlende appsettings.Local.json) – - /// einen unschädlichen Platzhalter, damit der DbContext-Optionsbau nicht wirft. Die App startet so - /// unabhängig von der DB; echte Queries schlagen erst bei tatsächlichem Zugriff fehl. + /// Liefert den Connection-String oder – bei leerem Wert – einen Platzhalter, damit der + /// DbContext-Optionsbau nicht wirft. Die App startet so unabhängig von der Datenbank; echte + /// Abfragen scheitern erst beim tatsächlichen Zugriff. + /// + /// Der Platzhalter meldet sich bewusst nicht mehr als root an. Vorher + /// stand dort „User ID=root;Password=;", und eine fehlende Konfiguration führte damit zu + /// „Access denied for user 'root'@'localhost'" – einer Meldung, die nach einem Rechteproblem + /// aussieht, während in Wahrheit gar keine Verbindung konfiguriert war. Der Nutzername sagt + /// jetzt, was los ist. Ein Anmeldeversuch als Administrator des Datenbankservers ist ohnehin + /// das Letzte, was ein Platzhalter tun sollte. + /// + /// Wer die maßgebliche Verbindung braucht, nimmt ; + /// diese Methode ist nur der Notnagel für den Optionsbau. /// public static string EffectiveConnectionString(string? connectionString) => string.IsNullOrWhiteSpace(connectionString) - ? "Server=localhost;Port=3306;Database=ibkrtrader;User ID=root;Password=;" + ? "Server=localhost;Port=3306;Database=ibkrtrader;" + + "User ID=KEINE-DATENBANK-KONFIGURIERT;Password=;" : connectionString; } diff --git a/src/IBKRTrader.Modules.Accounting/AccountingModule.cs b/src/IBKRTrader.Modules.Accounting/AccountingModule.cs index 40e1b91..1e74ba8 100644 --- a/src/IBKRTrader.Modules.Accounting/AccountingModule.cs +++ b/src/IBKRTrader.Modules.Accounting/AccountingModule.cs @@ -27,8 +27,14 @@ public sealed class AccountingModule : IModule public void RegisterServices(IServiceCollection services, IConfiguration configuration) { - var conn = ServiceCollectionExtensions.EffectiveConnectionString(configuration["Database:MySqlConnectionString"]); - services.AddDbContextFactory(o => o.UseMySql(conn, DatabaseServerVersion.Value)); + // Modul-Persistenz: eigener DbContext in DERSELBEN Datenbank wie der Core. Die Verbindung + // kommt aus DatabaseEndpoint und wird erst beim Erzeugen des Kontexts aufgelöst - beim + // Registrieren gibt es den Dienst noch nicht. Vorher las jedes Modul die Konfiguration + // selbst und landete auf dem root-Platzhalter, sobald die Datenbank nur in settings.json + // stand; der Core arbeitete daneben sauber weiter (#487). + services.AddDbContextFactory((sp, o) => + o.UseMySql(sp.GetRequiredService().ConnectionString, + DatabaseServerVersion.Value)); services.AddSingleton(); services.AddSingleton(); diff --git a/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs b/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs index d20a9eb..3c89c3b 100644 --- a/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs +++ b/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs @@ -39,9 +39,14 @@ public sealed class CongressTradingModule : IModule private void Register(IServiceCollection services, IConfiguration configuration, ModuleExecutionFactory? execution) { - // Modul-Persistenz: eigener DbContext (ct_-Tabellen) in derselben MariaDB. - var conn = ServiceCollectionExtensions.EffectiveConnectionString(configuration["Database:MySqlConnectionString"]); - services.AddDbContextFactory(o => o.UseMySql(conn, DatabaseServerVersion.Value)); + // Modul-Persistenz: eigener DbContext in DERSELBEN Datenbank wie der Core. Die Verbindung + // kommt aus DatabaseEndpoint und wird erst beim Erzeugen des Kontexts aufgelöst - beim + // Registrieren gibt es den Dienst noch nicht. Vorher las jedes Modul die Konfiguration + // selbst und landete auf dem root-Platzhalter, sobald die Datenbank nur in settings.json + // stand; der Core arbeitete daneben sauber weiter (#487). + services.AddDbContextFactory((sp, o) => + o.UseMySql(sp.GetRequiredService().ConnectionString, + DatabaseServerVersion.Value)); services.AddSingleton(); services.AddSingleton(); diff --git a/src/IBKRTrader.Modules.OptionsWheel/OptionsWheelModule.cs b/src/IBKRTrader.Modules.OptionsWheel/OptionsWheelModule.cs index 078fcbb..e85f71e 100644 --- a/src/IBKRTrader.Modules.OptionsWheel/OptionsWheelModule.cs +++ b/src/IBKRTrader.Modules.OptionsWheel/OptionsWheelModule.cs @@ -35,9 +35,14 @@ public sealed class OptionsWheelModule : IModule public void RegisterServices(IServiceCollection services, IConfiguration configuration) { // Modul-Persistenz: eigener DbContext (ow_-Tabellen) in derselben MariaDB. - var conn = ServiceCollectionExtensions.EffectiveConnectionString( - configuration["Database:MySqlConnectionString"]); - services.AddDbContextFactory(o => o.UseMySql(conn, DatabaseServerVersion.Value)); + // Modul-Persistenz: eigener DbContext in DERSELBEN Datenbank wie der Core. Die Verbindung + // kommt aus DatabaseEndpoint und wird erst beim Erzeugen des Kontexts aufgelöst - beim + // Registrieren gibt es den Dienst noch nicht. Vorher las jedes Modul die Konfiguration + // selbst und landete auf dem root-Platzhalter, sobald die Datenbank nur in settings.json + // stand; der Core arbeitete daneben sauber weiter (#487). + services.AddDbContextFactory((sp, o) => + o.UseMySql(sp.GetRequiredService().ConnectionString, + DatabaseServerVersion.Value)); // Reine Strategie-Logik. Zustandslos und ohne Aussenwelt – deshalb als Singleton // unbedenklich und in den Tests direkt konstruierbar. diff --git a/src/IBKRTrader.Modules.Supervisor/SupervisorModule.cs b/src/IBKRTrader.Modules.Supervisor/SupervisorModule.cs index ebc6a60..4e90c11 100644 --- a/src/IBKRTrader.Modules.Supervisor/SupervisorModule.cs +++ b/src/IBKRTrader.Modules.Supervisor/SupervisorModule.cs @@ -28,8 +28,14 @@ public sealed class SupervisorModule : IModule public void RegisterServices(IServiceCollection services, IConfiguration configuration) { // sup_-Persistenz (gespeicherte Analysen/Berichte + Counterfactuals). - var conn = ServiceCollectionExtensions.EffectiveConnectionString(configuration["Database:MySqlConnectionString"]); - services.AddDbContextFactory(o => o.UseMySql(conn, DatabaseServerVersion.Value)); + // Modul-Persistenz: eigener DbContext in DERSELBEN Datenbank wie der Core. Die Verbindung + // kommt aus DatabaseEndpoint und wird erst beim Erzeugen des Kontexts aufgelöst - beim + // Registrieren gibt es den Dienst noch nicht. Vorher las jedes Modul die Konfiguration + // selbst und landete auf dem root-Platzhalter, sobald die Datenbank nur in settings.json + // stand; der Core arbeitete daneben sauber weiter (#487). + services.AddDbContextFactory((sp, o) => + o.UseMySql(sp.GetRequiredService().ConnectionString, + DatabaseServerVersion.Value)); services.AddSingleton(); services.AddSingleton();