Modulkontexte an die maßgebliche Datenbank; Shell-Shutdown weiter untersucht
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>
This commit is contained in:
@@ -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<Shell.AvaloniaUiHost>();
|
||||
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.
|
||||
/// </summary>
|
||||
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)
|
||||
|
||||
@@ -120,6 +120,21 @@ public sealed class AvaloniaUiHost : IModuleUiHost
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Übergeht die Sicherheitsabfrage – für ein Herunterfahren, das nicht vom Bediener kommt.
|
||||
///
|
||||
/// <para><b>Ein Signal ist keine Frage.</b> 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 <c>systemctl stop</c>, 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).</para>
|
||||
/// </summary>
|
||||
public void SkipShutdownConfirmation()
|
||||
{
|
||||
ShutdownConfirmed = true;
|
||||
CloseAllViews();
|
||||
}
|
||||
|
||||
/// <summary>Schließt alle offenen View-Fenster (beim Herunterfahren).</summary>
|
||||
public void CloseAllViews()
|
||||
{
|
||||
|
||||
@@ -20,12 +20,23 @@ public static class ServiceCollectionExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
///
|
||||
/// <para><b>Der Platzhalter meldet sich bewusst nicht mehr als <c>root</c> an.</b> 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.</para>
|
||||
///
|
||||
/// <para>Wer die maßgebliche Verbindung braucht, nimmt <see cref="Configuration.DatabaseEndpoint"/>;
|
||||
/// diese Methode ist nur der Notnagel für den Optionsbau.</para>
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<AccountingDbContext>(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<AccountingDbContext>((sp, o) =>
|
||||
o.UseMySql(sp.GetRequiredService<DatabaseEndpoint>().ConnectionString,
|
||||
DatabaseServerVersion.Value));
|
||||
|
||||
services.AddSingleton<ILedgerRepository, EfLedgerRepository>();
|
||||
services.AddSingleton<IIngestRunRepository, EfIngestRunRepository>();
|
||||
|
||||
@@ -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<CongressTradingDbContext>(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<CongressTradingDbContext>((sp, o) =>
|
||||
o.UseMySql(sp.GetRequiredService<DatabaseEndpoint>().ConnectionString,
|
||||
DatabaseServerVersion.Value));
|
||||
|
||||
services.AddSingleton<CongressRepository>();
|
||||
services.AddSingleton<CapitolTradesScraper>();
|
||||
|
||||
@@ -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<OptionsWheelDbContext>(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<OptionsWheelDbContext>((sp, o) =>
|
||||
o.UseMySql(sp.GetRequiredService<DatabaseEndpoint>().ConnectionString,
|
||||
DatabaseServerVersion.Value));
|
||||
|
||||
// Reine Strategie-Logik. Zustandslos und ohne Aussenwelt – deshalb als Singleton
|
||||
// unbedenklich und in den Tests direkt konstruierbar.
|
||||
|
||||
@@ -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<SupervisorDbContext>(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<SupervisorDbContext>((sp, o) =>
|
||||
o.UseMySql(sp.GetRequiredService<DatabaseEndpoint>().ConnectionString,
|
||||
DatabaseServerVersion.Value));
|
||||
services.AddSingleton<ISupervisorReportRepository, EfSupervisorReportRepository>();
|
||||
services.AddSingleton<ISupervisorCounterfactualRepository, EfSupervisorCounterfactualRepository>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user