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:
Richard
2026-09-05 13:23:16 +02:00
co-authored by Claude Opus 5
parent 3bccd90d97
commit b618efa9ce
7 changed files with 105 additions and 24 deletions
+43 -10
View File
@@ -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>();