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
@@ -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.");
}
}