Compare commits

..

3 Commits

Author SHA1 Message Date
marcus 7f2cccf317 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>
2026-04-27 16:11:54 +02:00
marcus a5fae8cf55 Fix #42: KI-Prompt weist explizit zur Kuerzung bei Zeichenlimit an
Wenn die KI einen Titel nahe am Zeichenlimit erzeugt, kam es regelmaessig
zur Limit-Ueberschreitung um wenige Zeichen, was den Lauf in
FAILED_RETRYABLE und nach Wiederholung in FAILED_FINAL trieb.

Der Default-Prompt enthaelt jetzt eine explizite Kuerzungsanweisung
(Abkuerzungen, kompaktere Datumsformate, Weglassen unwichtiger Details)
mit dynamisch eingesetztem Zeichenlimit. Die bestehende Validierung
in AiResponseValidator bleibt als Sicherheitsnetz unveraendert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:11:42 +02:00
marcus 191d398604 Fix #44: Differenzierte Icons fuer alle Verarbeitungsstatus
Die beiden SKIPPED-Statuswerte teilten sich bisher Icon und Farbe.
Jeder Status erhaelt jetzt ein eigenes Unicode-Icon und passende Farbe:
SUCCESS gruen, FAILED_RETRYABLE orange, FAILED_PERMANENT rot,
SKIPPED_ALREADY_PROCESSED blau (Naechster-Track), SKIPPED_FINAL_FAILURE
grau (Durchgestrichener Kreis).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:11:32 +02:00
12 changed files with 403 additions and 19 deletions
@@ -205,11 +205,11 @@ public record GuiBatchRunResultRow(
return RESET_PENDING_ICON;
}
return switch (status) {
case SUCCESS -> "\u2714"; // ✔ HEAVY CHECK MARK
case FAILED_RETRYABLE -> "\u26A0"; // ⚠ WARNING SIGN
case FAILED_PERMANENT -> "\u2718"; // ✘ HEAVY BALLOT X
case SKIPPED_ALREADY_PROCESSED,
SKIPPED_FINAL_FAILURE -> "\u25BA"; // ► BLACK RIGHT-POINTING POINTER
case SUCCESS -> "\u2714"; // ✔ HEAVY CHECK MARK
case FAILED_RETRYABLE -> "\u26A0"; // ⚠ WARNING SIGN
case FAILED_PERMANENT -> "\u2718"; // ✘ HEAVY BALLOT X
case SKIPPED_ALREADY_PROCESSED -> "\u23ED"; // ⏭ NEXT TRACK BUTTON
case SKIPPED_FINAL_FAILURE -> "\u2298"; // ⊘ CIRCLED DIVISION SLASH
};
}
@@ -1425,7 +1425,8 @@ public final class GuiBatchRunTab {
case SUCCESS -> "#2e7d32";
case FAILED_RETRYABLE -> "#e65100";
case FAILED_PERMANENT -> "#c62828";
case SKIPPED_ALREADY_PROCESSED, SKIPPED_FINAL_FAILURE -> "#757575";
case SKIPPED_ALREADY_PROCESSED -> "#1565c0";
case SKIPPED_FINAL_FAILURE -> "#757575";
};
}
@@ -290,7 +290,7 @@ class GuiBatchRunCoordinatorTest {
assertEquals("\u2714", row(DocumentCompletionStatus.SUCCESS).statusIcon());
assertEquals("\u26A0", row(DocumentCompletionStatus.FAILED_RETRYABLE).statusIcon());
assertEquals("\u2718", row(DocumentCompletionStatus.FAILED_PERMANENT).statusIcon());
assertEquals("\u25BA", row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon());
assertEquals("\u23ED", row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon());
}
@Test
@@ -109,13 +109,21 @@ class GuiBatchRunResultRowTest {
}
@Test
void statusIcon_skippedAlreadyProcessed_isPointer() {
assertEquals("\u25BA", row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon());
void statusIcon_skippedAlreadyProcessed_isNextTrack() {
assertEquals("\u23ED", row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon());
}
@Test
void statusIcon_skippedFinalFailure_isPointer() {
assertEquals("\u25BA", row(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE).statusIcon());
void statusIcon_skippedFinalFailure_isCircledDivisionSlash() {
assertEquals("\u2298", row(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE).statusIcon());
}
@Test
void statusIcon_skippedValues_areDifferentFromEachOther() {
String alreadyProcessed = row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon();
String finalFailure = row(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE).statusIcon();
assertFalse(alreadyProcessed.equals(finalFailure),
"SKIPPED_ALREADY_PROCESSED und SKIPPED_FINAL_FAILURE müssen unterschiedliche Icons haben");
}
// -------------------------------------------------------------------------
@@ -140,10 +140,10 @@ class GuiBatchRunTabSmokeTest {
tab().resultTable().getSelectionModel().select(1);
assertTrue(tab().detailArea().getText().contains(GuiBatchRunTab.NO_REASONING_TEXT));
// SKIPPED row must carry the ► icon, not ✘.
// SKIPPED_ALREADY_PROCESSED muss das Weiterspulen-Icon ⏭ tragen, nicht ✘.
GuiBatchRunResultRow skippedRow = tab().resultTable().getItems().get(2);
assertEquals(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, skippedRow.status());
assertEquals("\u25BA", skippedRow.statusIcon());
assertEquals("\u23ED", skippedRow.statusIcon());
});
}
@@ -36,6 +36,8 @@ public final class DefaultPromptTemplate {
* <li>Eine Rollenanweisung an die KI (deutsches Dokumentenverwaltungssystem)</li>
* <li>Das erwartete JSON-Ausgabeformat mit den Feldern {@code date}, {@code title} und {@code reasoning}</li>
* <li>Benennungsregeln für Titel (maximal {@code maxTitleLength} Zeichen, deutsch, keine Sonderzeichen)</li>
* <li>Eine explizite Kürzungsanweisung: die KI muss Titel, die das Zeichenlimit überschreiten würden,
* eigenständig kürzen (Abkürzungen, kompaktere Formate, Weglassen unwichtiger Details)</li>
* <li>Hinweis auf das Datumsformat ({@code YYYY-MM-DD})</li>
* </ul>
* <p>
@@ -75,6 +77,14 @@ public final class DefaultPromptTemplate {
- Das Datumsformat ist YYYY-MM-DD (z.B. 2026-03-15).
- Der Titel ist auf Deutsch, verständlich und eindeutig für den Dokumentinhalt.
- Der Titel hat maximal {MAX_TITLE_LENGTH} Zeichen (Basistitel ohne Suffix).
- Wenn der ideale Titel das Zeichenlimit überschreiten würde, kürze den Titel
eigenständig auf maximal {MAX_TITLE_LENGTH} Zeichen, ohne dass der Inhalt
unverständlich wird. Erlaubte Mittel: Abkürzungen (z. B. "Nov." statt
"November", "Int." statt "Internationale"), kompaktere Datumsformate
(z. B. "11-2022" statt "November 2022"), Weglassen unwichtiger Details und
Kürzen von Adressen oder Absenderfloskeln.
- Das Zeichenlimit von {MAX_TITLE_LENGTH} Zeichen ist verbindlich und MUSS
eingehalten werden.
- Keine generischen Bezeichner wie "Dokument", "Scan", "Datei", "PDF".
- Keine Sonderzeichen außer Leerzeichen im Titel.
- Eigennamen bleiben unverändert.
@@ -72,4 +72,18 @@ class DefaultPromptTemplateTest {
.isThrownBy(() -> DefaultPromptTemplate.defaultContent(0))
.withMessageContaining("maxTitleLength must be >= 1");
}
@Test
void defaultContent_containsTruncationInstruction() {
String content = DefaultPromptTemplate.defaultContent(TEST_MAX_TITLE_LENGTH);
// Die Kürzungsanweisung muss explizit vorhanden sein
assertThat(content).contains("kürze");
assertThat(content).contains("Abkürzungen");
// Der Platzhalter wurde ersetzt, daher muss der numerische Wert mindestens zweimal erscheinen:
// einmal in der Längenregel und einmal in der Kürzungsanweisung
assertThat(content.split(String.valueOf(TEST_MAX_TITLE_LENGTH), -1).length - 1)
.as("Die konfigurierte Titellänge (%d) muss mindestens zweimal im Prompt vorkommen",
TEST_MAX_TITLE_LENGTH)
.isGreaterThanOrEqualTo(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());
return switch (startupArguments.mode()) {
case GUI -> startGuiMode(startupArguments.configPath());
case HEADLESS -> runHeadlessBatch(startupArguments);
};
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.");
}
}