Compare commits
3 Commits
f204ad1f1e
...
7f2cccf317
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f2cccf317 | |||
| a5fae8cf55 | |||
| 191d398604 |
+2
-2
@@ -208,8 +208,8 @@ public record GuiBatchRunResultRow(
|
|||||||
case SUCCESS -> "\u2714"; // ✔ HEAVY CHECK MARK
|
case SUCCESS -> "\u2714"; // ✔ HEAVY CHECK MARK
|
||||||
case FAILED_RETRYABLE -> "\u26A0"; // ⚠ WARNING SIGN
|
case FAILED_RETRYABLE -> "\u26A0"; // ⚠ WARNING SIGN
|
||||||
case FAILED_PERMANENT -> "\u2718"; // ✘ HEAVY BALLOT X
|
case FAILED_PERMANENT -> "\u2718"; // ✘ HEAVY BALLOT X
|
||||||
case SKIPPED_ALREADY_PROCESSED,
|
case SKIPPED_ALREADY_PROCESSED -> "\u23ED"; // ⏭ NEXT TRACK BUTTON
|
||||||
SKIPPED_FINAL_FAILURE -> "\u25BA"; // ► BLACK RIGHT-POINTING POINTER
|
case SKIPPED_FINAL_FAILURE -> "\u2298"; // ⊘ CIRCLED DIVISION SLASH
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1425,7 +1425,8 @@ public final class GuiBatchRunTab {
|
|||||||
case SUCCESS -> "#2e7d32";
|
case SUCCESS -> "#2e7d32";
|
||||||
case FAILED_RETRYABLE -> "#e65100";
|
case FAILED_RETRYABLE -> "#e65100";
|
||||||
case FAILED_PERMANENT -> "#c62828";
|
case FAILED_PERMANENT -> "#c62828";
|
||||||
case SKIPPED_ALREADY_PROCESSED, SKIPPED_FINAL_FAILURE -> "#757575";
|
case SKIPPED_ALREADY_PROCESSED -> "#1565c0";
|
||||||
|
case SKIPPED_FINAL_FAILURE -> "#757575";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -290,7 +290,7 @@ class GuiBatchRunCoordinatorTest {
|
|||||||
assertEquals("\u2714", row(DocumentCompletionStatus.SUCCESS).statusIcon());
|
assertEquals("\u2714", row(DocumentCompletionStatus.SUCCESS).statusIcon());
|
||||||
assertEquals("\u26A0", row(DocumentCompletionStatus.FAILED_RETRYABLE).statusIcon());
|
assertEquals("\u26A0", row(DocumentCompletionStatus.FAILED_RETRYABLE).statusIcon());
|
||||||
assertEquals("\u2718", row(DocumentCompletionStatus.FAILED_PERMANENT).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
|
@Test
|
||||||
|
|||||||
+12
-4
@@ -109,13 +109,21 @@ class GuiBatchRunResultRowTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void statusIcon_skippedAlreadyProcessed_isPointer() {
|
void statusIcon_skippedAlreadyProcessed_isNextTrack() {
|
||||||
assertEquals("\u25BA", row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon());
|
assertEquals("\u23ED", row(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED).statusIcon());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void statusIcon_skippedFinalFailure_isPointer() {
|
void statusIcon_skippedFinalFailure_isCircledDivisionSlash() {
|
||||||
assertEquals("\u25BA", row(DocumentCompletionStatus.SKIPPED_FINAL_FAILURE).statusIcon());
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
+2
-2
@@ -140,10 +140,10 @@ class GuiBatchRunTabSmokeTest {
|
|||||||
tab().resultTable().getSelectionModel().select(1);
|
tab().resultTable().getSelectionModel().select(1);
|
||||||
assertTrue(tab().detailArea().getText().contains(GuiBatchRunTab.NO_REASONING_TEXT));
|
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);
|
GuiBatchRunResultRow skippedRow = tab().resultTable().getItems().get(2);
|
||||||
assertEquals(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, skippedRow.status());
|
assertEquals(DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED, skippedRow.status());
|
||||||
assertEquals("\u25BA", skippedRow.statusIcon());
|
assertEquals("\u23ED", skippedRow.statusIcon());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
@@ -36,6 +36,8 @@ public final class DefaultPromptTemplate {
|
|||||||
* <li>Eine Rollenanweisung an die KI (deutsches Dokumentenverwaltungssystem)</li>
|
* <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>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>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>
|
* <li>Hinweis auf das Datumsformat ({@code YYYY-MM-DD})</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
@@ -75,6 +77,14 @@ public final class DefaultPromptTemplate {
|
|||||||
- Das Datumsformat ist YYYY-MM-DD (z.B. 2026-03-15).
|
- 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 ist auf Deutsch, verständlich und eindeutig für den Dokumentinhalt.
|
||||||
- Der Titel hat maximal {MAX_TITLE_LENGTH} Zeichen (Basistitel ohne Suffix).
|
- 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 generischen Bezeichner wie "Dokument", "Scan", "Datei", "PDF".
|
||||||
- Keine Sonderzeichen außer Leerzeichen im Titel.
|
- Keine Sonderzeichen außer Leerzeichen im Titel.
|
||||||
- Eigennamen bleiben unverändert.
|
- Eigennamen bleiben unverändert.
|
||||||
|
|||||||
+14
@@ -72,4 +72,18 @@ class DefaultPromptTemplateTest {
|
|||||||
.isThrownBy(() -> DefaultPromptTemplate.defaultContent(0))
|
.isThrownBy(() -> DefaultPromptTemplate.defaultContent(0))
|
||||||
.withMessageContaining("maxTitleLength must be >= 1");
|
.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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.AiModelCatalogDispatcher;
|
||||||
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.GuiConfigurationPropertiesWriter;
|
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.GuiConfigurationPropertiesWriter;
|
||||||
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.Log4jProcessingLogger;
|
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.StartupArguments;
|
||||||
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupMode;
|
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupMode;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
|
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
|
||||||
@@ -189,6 +191,7 @@ public class BootstrapRunner {
|
|||||||
private final UseCaseFactory useCaseFactory;
|
private final UseCaseFactory useCaseFactory;
|
||||||
private final CommandFactory commandFactory;
|
private final CommandFactory commandFactory;
|
||||||
private final GuiAdapterFactory guiAdapterFactory;
|
private final GuiAdapterFactory guiAdapterFactory;
|
||||||
|
private final SingleInstanceGuardFactory singleInstanceGuardFactory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Functional interface encapsulating the legacy configuration migration step.
|
* Functional interface encapsulating the legacy configuration migration step.
|
||||||
@@ -319,6 +322,23 @@ public class BootstrapRunner {
|
|||||||
GuiAdapter create();
|
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.
|
* Creates the BootstrapRunner with default factories for production use.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -352,6 +372,7 @@ public class BootstrapRunner {
|
|||||||
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken.neverCancelled());
|
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken.neverCancelled());
|
||||||
this.commandFactory = SchedulerBatchCommand::new;
|
this.commandFactory = SchedulerBatchCommand::new;
|
||||||
this.guiAdapterFactory = GuiAdapter::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 */ },
|
this(path -> { /* no-op: tests inject mock ConfigurationPort directly */ },
|
||||||
configPortFactory, runLockPortFactory, validatorFactory,
|
configPortFactory, runLockPortFactory, validatorFactory,
|
||||||
schemaInitPortFactory, useCaseFactory, commandFactory,
|
schemaInitPortFactory, useCaseFactory, commandFactory,
|
||||||
GuiAdapter::new);
|
GuiAdapter::new,
|
||||||
|
noOpSingleInstanceGuardFactory());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -475,7 +497,8 @@ public class BootstrapRunner {
|
|||||||
UseCaseFactory useCaseFactory,
|
UseCaseFactory useCaseFactory,
|
||||||
CommandFactory commandFactory) {
|
CommandFactory commandFactory) {
|
||||||
this(migrationStep, configPortFactory, runLockPortFactory, validatorFactory,
|
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,
|
UseCaseFactory useCaseFactory,
|
||||||
CommandFactory commandFactory,
|
CommandFactory commandFactory,
|
||||||
GuiAdapterFactory guiAdapterFactory) {
|
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.migrationStep = migrationStep;
|
||||||
this.configPortFactory = configPortFactory;
|
this.configPortFactory = configPortFactory;
|
||||||
this.runLockPortFactory = runLockPortFactory;
|
this.runLockPortFactory = runLockPortFactory;
|
||||||
@@ -510,11 +564,39 @@ public class BootstrapRunner {
|
|||||||
this.useCaseFactory = useCaseFactory;
|
this.useCaseFactory = useCaseFactory;
|
||||||
this.commandFactory = commandFactory;
|
this.commandFactory = commandFactory;
|
||||||
this.guiAdapterFactory = guiAdapterFactory;
|
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.
|
* Runs the complete application startup sequence for the given startup arguments.
|
||||||
* <p>
|
* <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
|
* Dispatches to the GUI or headless batch adapter based on the startup mode carried
|
||||||
* in {@code startupArguments}:
|
* in {@code startupArguments}:
|
||||||
* <ul>
|
* <ul>
|
||||||
@@ -538,10 +620,40 @@ public class BootstrapRunner {
|
|||||||
public int run(StartupArguments startupArguments) {
|
public int run(StartupArguments startupArguments) {
|
||||||
Objects.requireNonNull(startupArguments, "startupArguments must not be null");
|
Objects.requireNonNull(startupArguments, "startupArguments must not be null");
|
||||||
LOG.info("Bootstrap flow started. Startup mode: {}", startupArguments.mode());
|
LOG.info("Bootstrap flow started. Startup mode: {}", startupArguments.mode());
|
||||||
|
|
||||||
|
try (SingleInstanceGuard guard = singleInstanceGuardFactory.create()) {
|
||||||
|
guard.acquire();
|
||||||
return switch (startupArguments.mode()) {
|
return switch (startupArguments.mode()) {
|
||||||
case GUI -> startGuiMode(startupArguments.configPath());
|
case GUI -> startGuiMode(startupArguments.configPath());
|
||||||
case HEADLESS -> runHeadlessBatch(startupArguments);
|
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