Fix #35: Einzelinstanz-Schutz ueber Loopback-ServerSocket
Eine zweite parallele Instanz wird beim Start abgewiesen. Der Schutz greift fuer GUI- und Headless-Pfad gleichermassen vor der Modusweiche in BootstrapRunner. Umsetzung als ServerSocket-Bind auf 127.0.0.1:47832: stale-lock-frei, da das Betriebssystem den Port beim Prozessende automatisch freigibt, robust unter Windows mit gemappten Laufwerken und UNC-Pfaden, und ohne Konflikt mit dem bestehenden RunLockPort, der nur den Batch-Lauf schuetzt. Bei kollidierender Bindung erscheint im GUI-Modus ein Swing-Dialog (JavaFX ist hier noch nicht initialisiert) und im Headless-Modus eine Logmeldung; beide Pfade enden mit Exit-Code 1. Ein ShutdownHook und try-with-resources geben den Port deterministisch wieder frei. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+114
-2
@@ -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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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:
|
||||
* <ul>
|
||||
* <li>Im GUI-Modus erscheint ein Hinweisdialog (Swing, da JavaFX noch nicht
|
||||
* initialisiert ist) und die Methode gibt Exit-Code 1 zurück.</li>
|
||||
* <li>Im Headless-Modus wird eine Fehlermeldung geloggt und Exit-Code 1 zurückgegeben.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Dispatches to the GUI or headless batch adapter based on the startup mode carried
|
||||
* in {@code startupArguments}:
|
||||
* <ul>
|
||||
@@ -538,10 +620,40 @@ public class BootstrapRunner {
|
||||
public int run(StartupArguments startupArguments) {
|
||||
Objects.requireNonNull(startupArguments, "startupArguments must not be null");
|
||||
LOG.info("Bootstrap flow started. Startup mode: {}", startupArguments.mode());
|
||||
|
||||
try (SingleInstanceGuard guard = singleInstanceGuardFactory.create()) {
|
||||
guard.acquire();
|
||||
return switch (startupArguments.mode()) {
|
||||
case GUI -> startGuiMode(startupArguments.configPath());
|
||||
case HEADLESS -> runHeadlessBatch(startupArguments);
|
||||
};
|
||||
} catch (AnotherInstanceRunningException e) {
|
||||
return behandleWeitereInstanz(startupArguments.mode(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Behandelt den Fall, dass beim Start bereits eine andere Instanz läuft.
|
||||
* <p>
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+30
@@ -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.
|
||||
* <p>
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
+122
@@ -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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* Vorteile gegenüber dateibasiertem Locking:
|
||||
* <ul>
|
||||
* <li>Kein veralteter Lockzustand: Das Betriebssystem gibt den Port automatisch frei,
|
||||
* sobald der Prozess endet.</li>
|
||||
* <li>Keine Dateisystem-Abhängigkeit: funktioniert zuverlässig auf Windows mit
|
||||
* gemappten Laufwerken und UNC-Pfaden.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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}).
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Prozessweiter Einzelinstanz-Schutz via Loopback-Socket-Bind.
|
||||
* <p>
|
||||
* 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;
|
||||
+78
@@ -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}.
|
||||
* <p>
|
||||
* 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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user