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:
2026-04-27 16:11:54 +02:00
parent a5fae8cf55
commit 7f2cccf317
5 changed files with 357 additions and 6 deletions
@@ -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;
}
/**
@@ -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);
}
}
@@ -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 065535 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
}
}
}
}
@@ -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;
@@ -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.");
}
}