diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index b7274fa..4e7a51f 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -97,6 +97,8 @@ import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.Technical import de.gecheckt.pdf.umbenenner.bootstrap.adapter.AiModelCatalogDispatcher; import de.gecheckt.pdf.umbenenner.bootstrap.adapter.GuiConfigurationPropertiesWriter; import de.gecheckt.pdf.umbenenner.bootstrap.adapter.Log4jProcessingLogger; +import de.gecheckt.pdf.umbenenner.bootstrap.singleinstance.AnotherInstanceRunningException; +import de.gecheckt.pdf.umbenenner.bootstrap.singleinstance.SingleInstanceGuard; import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments; import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupMode; import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext; @@ -189,6 +191,7 @@ public class BootstrapRunner { private final UseCaseFactory useCaseFactory; private final CommandFactory commandFactory; private final GuiAdapterFactory guiAdapterFactory; + private final SingleInstanceGuardFactory singleInstanceGuardFactory; /** * Functional interface encapsulating the legacy configuration migration step. @@ -319,6 +322,23 @@ public class BootstrapRunner { GuiAdapter create(); } + /** + * Factory für den prozessweiten Einzelinstanz-Schutz. + *
+ * Die Produktionsimplementierung liefert {@code new SingleInstanceGuard()}. + * In Tests kann eine No-Op-Lambda injiziert werden, damit kein Test den + * Produktionsport belegt und parallele Testläufe einander nicht behindern. + */ + @FunctionalInterface + public interface SingleInstanceGuardFactory { + /** + * Erstellt eine neue {@link SingleInstanceGuard}-Instanz. + * + * @return eine neue {@link SingleInstanceGuard}-Instanz; niemals {@code null} + */ + SingleInstanceGuard create(); + } + /** * Creates the BootstrapRunner with default factories for production use. *
@@ -352,6 +372,7 @@ public class BootstrapRunner { de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken.neverCancelled()); this.commandFactory = SchedulerBatchCommand::new; this.guiAdapterFactory = GuiAdapter::new; + this.singleInstanceGuardFactory = SingleInstanceGuard::new; } /** @@ -450,7 +471,8 @@ public class BootstrapRunner { this(path -> { /* no-op: tests inject mock ConfigurationPort directly */ }, configPortFactory, runLockPortFactory, validatorFactory, schemaInitPortFactory, useCaseFactory, commandFactory, - GuiAdapter::new); + GuiAdapter::new, + noOpSingleInstanceGuardFactory()); } /** @@ -475,7 +497,8 @@ public class BootstrapRunner { UseCaseFactory useCaseFactory, CommandFactory commandFactory) { this(migrationStep, configPortFactory, runLockPortFactory, validatorFactory, - schemaInitPortFactory, useCaseFactory, commandFactory, GuiAdapter::new); + schemaInitPortFactory, useCaseFactory, commandFactory, GuiAdapter::new, + noOpSingleInstanceGuardFactory()); } /** @@ -502,6 +525,37 @@ public class BootstrapRunner { UseCaseFactory useCaseFactory, CommandFactory commandFactory, GuiAdapterFactory guiAdapterFactory) { + this(migrationStep, configPortFactory, runLockPortFactory, validatorFactory, + schemaInitPortFactory, useCaseFactory, commandFactory, guiAdapterFactory, + noOpSingleInstanceGuardFactory()); + } + + /** + * Erstellt den BootstrapRunner mit vollständiger Kontrolle über alle Factories, + * einschließlich der Einzelinstanz-Guard-Factory. + *
+ * Dieser Konstruktor ist für Tests gedacht, die den Guard-Pfad oder das + * Zusammenspiel aller Factories prüfen müssen. + * + * @param migrationStep der Migrationsschritt vor dem Laden der Konfiguration + * @param configPortFactory Factory für den ConfigurationPort + * @param runLockPortFactory Factory für den RunLockPort + * @param validatorFactory Factory für den StartConfigurationValidator + * @param schemaInitPortFactory Factory für den PersistenceSchemaInitializationPort + * @param useCaseFactory Factory für den BatchRunProcessingUseCase + * @param commandFactory Factory für den SchedulerBatchCommand + * @param guiAdapterFactory Factory für den GuiAdapter + * @param singleInstanceGuardFactory Factory für den SingleInstanceGuard + */ + public BootstrapRunner(MigrationStep migrationStep, + ConfigurationPortFactory configPortFactory, + RunLockPortFactory runLockPortFactory, + ValidatorFactory validatorFactory, + SchemaInitializationPortFactory schemaInitPortFactory, + UseCaseFactory useCaseFactory, + CommandFactory commandFactory, + GuiAdapterFactory guiAdapterFactory, + SingleInstanceGuardFactory singleInstanceGuardFactory) { this.migrationStep = migrationStep; this.configPortFactory = configPortFactory; this.runLockPortFactory = runLockPortFactory; @@ -510,11 +564,39 @@ public class BootstrapRunner { this.useCaseFactory = useCaseFactory; this.commandFactory = commandFactory; this.guiAdapterFactory = guiAdapterFactory; + this.singleInstanceGuardFactory = singleInstanceGuardFactory; + } + + /** + * Liefert eine No-Op-{@link SingleInstanceGuardFactory}, die einen Guard erzeugt, + * der beim Aufruf von {@code acquire()} nichts tut. + *
+ * Wird in Test-Konstruktoren verwendet, damit parallele Testläufe keine Portkonflikte + * auf dem Produktionsport erzeugen. + * + * @return eine No-Op-Guard-Factory; niemals {@code null} + */ + private static SingleInstanceGuardFactory noOpSingleInstanceGuardFactory() { + return () -> new SingleInstanceGuard() { + @Override + public void acquire() { /* kein Port belegen in Tests */ } + @Override + public void close() { /* keine Aktion */ } + }; } /** * Runs the complete application startup sequence for the given startup arguments. *
+ * Vor dem Start beider Modus-Pfade wird die prozessweite Einzelinstanz-Sicherung + * aktiviert. Ist bereits eine andere Instanz der Anwendung aktiv, wird der Start + * sofort abgebrochen: + *
* Dispatches to the GUI or headless batch adapter based on the startup mode carried * in {@code startupArguments}: *
+ * Im GUI-Modus wird ein modaler Swing-Dialog angezeigt (JavaFX ist zu diesem Zeitpunkt + * noch nicht initialisiert). Im Headless-Modus wird eine Fehlermeldung geloggt. + * In beiden Fällen wird Exit-Code 1 zurückgegeben. + * + * @param mode der aktive Startmodus; nicht {@code null} + * @param cause die ausgelöste Ausnahme; nicht {@code null} + * @return immer 1 + */ + private int behandleWeitereInstanz(StartupMode mode, AnotherInstanceRunningException cause) { + String meldung = "Es läuft bereits eine Instanz des PDF-Umbenenners. Diese Instanz wird beendet."; + LOG.error("Einzelinstanz-Schutz: {}. Startmodus: {}", meldung, mode); + if (mode == StartupMode.GUI) { + javax.swing.JOptionPane.showMessageDialog( + null, + meldung, + "PDF-Umbenenner", + javax.swing.JOptionPane.WARNING_MESSAGE); + } + return 1; } /** diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/singleinstance/AnotherInstanceRunningException.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/singleinstance/AnotherInstanceRunningException.java new file mode 100644 index 0000000..0e0de28 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/singleinstance/AnotherInstanceRunningException.java @@ -0,0 +1,30 @@ +package de.gecheckt.pdf.umbenenner.bootstrap.singleinstance; + +/** + * Wird geworfen, wenn beim Versuch, die Einzelinstanz-Sicherung zu aktivieren, festgestellt wird, + * dass bereits eine andere Instanz der Anwendung läuft. + *
+ * Der Aufrufer soll die Anwendung geordnet beenden, ohne einen weiteren Verarbeitungslauf + * zu starten. + */ +public class AnotherInstanceRunningException extends RuntimeException { + + /** + * Erstellt eine neue {@code AnotherInstanceRunningException} mit der angegebenen Nachricht. + * + * @param message die Fehlernachricht; darf {@code null} sein + */ + public AnotherInstanceRunningException(String message) { + super(message); + } + + /** + * Erstellt eine neue {@code AnotherInstanceRunningException} mit Nachricht und Ursache. + * + * @param message die Fehlernachricht; darf {@code null} sein + * @param cause die ursächliche Ausnahme; darf {@code null} sein + */ + public AnotherInstanceRunningException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/singleinstance/SingleInstanceGuard.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/singleinstance/SingleInstanceGuard.java new file mode 100644 index 0000000..8a04a93 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/singleinstance/SingleInstanceGuard.java @@ -0,0 +1,122 @@ +package de.gecheckt.pdf.umbenenner.bootstrap.singleinstance; + +import java.io.IOException; +import java.net.BindException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Prozessweiter Einzelinstanz-Schutz auf Basis eines Loopback-ServerSocket-Binds. + *
+ * Beim Aufruf von {@link #acquire()} wird versucht, einen {@link ServerSocket} an die + * Loopback-Adresse ({@code 127.0.0.1}) auf dem konfigurierten Port zu binden. Gelingt das + * Binden, läuft noch keine weitere Instanz der Anwendung und die Guard-Instanz hält den + * Port für die Lebensdauer des Prozesses. Schlägt das Binden mit einer + * {@link BindException} fehl, ist bereits eine andere Instanz aktiv und es wird eine + * {@link AnotherInstanceRunningException} geworfen. + *
+ * Vorteile gegenüber dateibasiertem Locking: + *
+ * Die Klasse ist {@link AutoCloseable}. Im Normalfall ist ein explizites {@link #close()} + * nicht erforderlich, da ein registrierter Shutdown-Hook den Socket beim JVM-Ende + * automatisch schließt. Innerhalb eines {@code try}-with-resources oder an einem + * programmatischen Endpunkt kann {@code close()} jedoch vorzeitig aufgerufen werden, + * damit der Port sofort wieder verfügbar ist. Mehrfaches Aufrufen von {@code close()} + * ist sicher und ohne Effekt. + *
+ * Der Guard ist nicht für parallele Aufrufe ausgelegt; er soll genau einmal pro Prozess + * aufgerufen werden. + */ +public class SingleInstanceGuard implements AutoCloseable { + + /** + * Standardport für die Einzelinstanz-Sicherung ({@value}). + *
+ * Nur auf der Loopback-Adresse gebunden und damit nicht extern sichtbar. + */ + public static final int DEFAULT_PORT = 47832; + + private final int port; + private volatile ServerSocket socket; + private final AtomicBoolean closed = new AtomicBoolean(false); + + /** + * Erstellt einen Guard, der den Standard-Port {@link #DEFAULT_PORT} verwendet. + */ + public SingleInstanceGuard() { + this(DEFAULT_PORT); + } + + /** + * Erstellt einen Guard, der den angegebenen Port verwendet. + *
+ * Dieser Konstruktor ist primär für Tests gedacht, damit parallele Testläufe + * keine Portkonflikte erzeugen. + * + * @param port der zu verwendende TCP-Port; muss im gültigen Bereich 0–65535 liegen + */ + public SingleInstanceGuard(int port) { + this.port = port; + } + + /** + * Aktiviert die Einzelinstanz-Sicherung. + *
+ * Bindet einen {@link ServerSocket} an {@code 127.0.0.1} auf dem konfigurierten Port. + * Registriert einen Shutdown-Hook, der den Socket beim JVM-Ende automatisch schließt, + * damit der Port auch bei einem unerwarteten Prozessende freigegeben wird. + * + * @throws AnotherInstanceRunningException wenn der Port bereits belegt ist, d. h. eine + * weitere Instanz der Anwendung läuft + * @throws IllegalStateException wenn {@code acquire()} auf einer bereits + * geschlossenen Guard-Instanz aufgerufen wird + */ + public void acquire() { + if (closed.get()) { + throw new IllegalStateException("SingleInstanceGuard wurde bereits geschlossen."); + } + try { + InetAddress loopback = InetAddress.getLoopbackAddress(); + ServerSocket serverSocket = new ServerSocket(port, 1, loopback); + this.socket = serverSocket; + Runtime.getRuntime().addShutdownHook(new Thread(() -> schliesseSilent(serverSocket), + "single-instance-guard-shutdown")); + } catch (BindException e) { + throw new AnotherInstanceRunningException( + "Es läuft bereits eine Instanz der Anwendung (Port " + port + " belegt).", e); + } catch (IOException e) { + throw new AnotherInstanceRunningException( + "Einzelinstanz-Prüfung fehlgeschlagen: " + e.getMessage(), e); + } + } + + /** + * Gibt die Einzelinstanz-Sicherung frei und schließt den gebundenen Socket. + *
+ * Nach dem Schließen ist der Port sofort wieder für eine neue Instanz verfügbar. + * Mehrfaches Aufrufen hat keinen weiteren Effekt. + */ + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + schliesseSilent(socket); + } + } + + private static void schliesseSilent(ServerSocket s) { + if (s != null && !s.isClosed()) { + try { + s.close(); + } catch (IOException ignored) { + // Fehler beim Schließen werden bewusst ignoriert + } + } + } +} diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/singleinstance/package-info.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/singleinstance/package-info.java new file mode 100644 index 0000000..9bd1f09 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/singleinstance/package-info.java @@ -0,0 +1,9 @@ +/** + * Prozessweiter Einzelinstanz-Schutz via Loopback-Socket-Bind. + *
+ * Stellt sicher, dass immer nur eine Instanz der Anwendung gleichzeitig läuft. + * Die Implementierung bindet einen {@link java.net.ServerSocket} an die Loopback-Adresse, + * sodass das Betriebssystem den Port beim Prozessende automatisch freigibt und kein + * veralteter Lockzustand zurückbleiben kann. + */ +package de.gecheckt.pdf.umbenenner.bootstrap.singleinstance; diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/singleinstance/SingleInstanceGuardTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/singleinstance/SingleInstanceGuardTest.java new file mode 100644 index 0000000..fe67206 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/singleinstance/SingleInstanceGuardTest.java @@ -0,0 +1,78 @@ +package de.gecheckt.pdf.umbenenner.bootstrap.singleinstance; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.ServerSocket; + +import org.junit.jupiter.api.Test; + +/** + * Unit-Tests für {@link SingleInstanceGuard}. + *
+ * Alle Tests verwenden einen dynamisch gebundenen freien Port, damit parallele + * Testläufe keine Konflikte auf dem Produktions-Standardport erzeugen. + */ +class SingleInstanceGuardTest { + + /** + * Ermittelt einen freien lokalen Port. + * + * @return ein freier TCP-Port + * @throws Exception wenn kein freier Port ermittelt werden kann + */ + private static int freienPortErmitteln() throws Exception { + try (ServerSocket s = new ServerSocket(0)) { + return s.getLocalPort(); + } + } + + @Test + void ersterAcquireErfolgtOhneFehler() throws Exception { + int port = freienPortErmitteln(); + try (SingleInstanceGuard guard = new SingleInstanceGuard(port)) { + assertDoesNotThrow(guard::acquire, + "Der erste acquire() soll den Port erfolgreich belegen."); + } + } + + @Test + void zweiterAcquireWirftAnotherInstanceRunningException() throws Exception { + int port = freienPortErmitteln(); + try (SingleInstanceGuard ersterGuard = new SingleInstanceGuard(port)) { + ersterGuard.acquire(); + + SingleInstanceGuard zweiterGuard = new SingleInstanceGuard(port); + assertThrows(AnotherInstanceRunningException.class, zweiterGuard::acquire, + "Während der erste Guard aktiv ist, soll der zweite acquire() eine " + + "AnotherInstanceRunningException werfen."); + } + } + + @Test + void closeErlaubtNeuesBinden() throws Exception { + int port = freienPortErmitteln(); + SingleInstanceGuard ersterGuard = new SingleInstanceGuard(port); + ersterGuard.acquire(); + ersterGuard.close(); + + // Nach close() soll ein neuer Guard den Port wieder belegen können + try (SingleInstanceGuard neuerGuard = new SingleInstanceGuard(port)) { + assertDoesNotThrow(neuerGuard::acquire, + "Nach close() des ersten Guards soll ein neuer Guard erfolgreich binden können."); + } + } + + @Test + void closeIstIdempotent() throws Exception { + int port = freienPortErmitteln(); + SingleInstanceGuard guard = new SingleInstanceGuard(port); + guard.acquire(); + + assertDoesNotThrow(() -> { + guard.close(); + guard.close(); + guard.close(); + }, "Mehrfaches close() soll keine Ausnahme werfen."); + } +}