diff --git a/src/main/java/de/jexcellence/dependency/cache/SharedCacheManager.java b/src/main/java/de/jexcellence/dependency/cache/SharedCacheManager.java
index 33abff31..b6b64755 100644
--- a/src/main/java/de/jexcellence/dependency/cache/SharedCacheManager.java
+++ b/src/main/java/de/jexcellence/dependency/cache/SharedCacheManager.java
@@ -12,7 +12,7 @@
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.time.Instant;
-import java.util.function.Supplier;
+import java.util.function.BooleanSupplier;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -72,8 +72,14 @@ private static final class Holder {
/**
* Returns the singleton cache manager instance.
*
+ *
The singleton is intentional: there must be exactly one shared on-disk
+ * cache (and one set of file locks) per JVM so that multiple JExcellence
+ * plugins coordinate downloads instead of racing each other. The
+ * initialization-on-demand Holder idiom gives lazy, thread-safe creation.
+ *
* @return shared cache manager
*/
+ @SuppressWarnings("java:S6548") // Deliberate, required JVM-wide shared cache — see Javadoc.
public static @NotNull SharedCacheManager getInstance() {
return Holder.INSTANCE;
}
@@ -172,7 +178,7 @@ private SharedCacheManager() {
*/
public Path getOrDownload(
@NotNull final DependencyCoordinate coordinate,
- @NotNull final Supplier downloadAction
+ @NotNull final BooleanSupplier downloadAction
) {
final Path jarPath = resolveJarPath(coordinate);
@@ -208,7 +214,7 @@ public Path getOrDownload(
}
// Perform the actual download
- final boolean success = Boolean.TRUE.equals(downloadAction.get());
+ final boolean success = downloadAction.getAsBoolean();
if (success && isValidFile(jarPath)) {
return jarPath;
@@ -219,7 +225,7 @@ public Path getOrDownload(
} catch (final IOException exception) {
LOGGER.log(Level.WARNING, exception, () -> "Failed to acquire cache lock for: " + coordinate.toGavString());
// Fall through to direct download without locking
- final boolean success = Boolean.TRUE.equals(downloadAction.get());
+ final boolean success = downloadAction.getAsBoolean();
return (success && isValidFile(jarPath)) ? jarPath : null;
} finally {
safeDelete(lockFile);
diff --git a/src/main/java/de/jexcellence/dependency/downloader/DependencyDownloader.java b/src/main/java/de/jexcellence/dependency/downloader/DependencyDownloader.java
index dc423e51..42a69a10 100644
--- a/src/main/java/de/jexcellence/dependency/downloader/DependencyDownloader.java
+++ b/src/main/java/de/jexcellence/dependency/downloader/DependencyDownloader.java
@@ -309,14 +309,11 @@ private boolean isValidExistingFile(@NotNull final File file) {
*/
private int attemptDownloadClassified(@NotNull final String downloadUrl, @NotNull final File targetFile) {
try {
- final URI uri = URI.create(downloadUrl);
- URL url = uri.toURL();
- int redirectCount = 0;
+ URL url = URI.create(downloadUrl).toURL();
- while (redirectCount <= MAX_REDIRECTS) {
+ for (int redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
final HttpURLConnection connection = createConnection(url);
connection.setInstanceFollowRedirects(false);
-
final int responseCode = connection.getResponseCode();
if (responseCode >= 200 && responseCode < 300) {
@@ -326,43 +323,57 @@ private int attemptDownloadClassified(@NotNull final String downloadUrl, @NotNul
}
if (responseCode >= 300 && responseCode < 400) {
- final String location = connection.getHeaderField("Location");
- if (location == null || location.isEmpty()) {
- logger.log(Level.WARNING, "Redirect without Location header from: {0}", url);
+ final URL next = resolveRedirect(connection, url, responseCode);
+ if (next == null) {
return DOWNLOAD_NOT_RETRYABLE;
}
-
- url = URI.create(location).toURL();
- final URL redirectUrl = url;
- logger.log(Level.FINEST, () -> "Redirect " + responseCode + " to " + redirectUrl);
- redirectCount++;
+ url = next;
continue;
}
- if (responseCode == 404) {
- return DOWNLOAD_NOT_RETRYABLE;
- }
-
- final URL currentUrl = url;
- logger.log(Level.WARNING, "HTTP {0} when downloading {1}", new Object[]{responseCode, currentUrl});
- return RETRYABLE_STATUS_CODES.contains(responseCode)
- ? DOWNLOAD_RETRYABLE
- : DOWNLOAD_NOT_RETRYABLE;
+ return classifyErrorStatus(responseCode, url, downloadUrl);
}
logger.log(Level.WARNING, "Too many redirects ({0}) for {1}", new Object[]{MAX_REDIRECTS, downloadUrl});
return DOWNLOAD_NOT_RETRYABLE;
- } catch (final java.net.SocketTimeoutException exception) {
- logger.log(Level.FINE, exception, () -> "Timeout downloading from URL: " + downloadUrl);
- return DOWNLOAD_RETRYABLE;
- } catch (final java.net.ConnectException exception) {
- logger.log(Level.FINE, exception, () -> "Connection refused from URL: " + downloadUrl);
- return DOWNLOAD_RETRYABLE;
} catch (final Exception exception) {
- logger.log(Level.FINE, exception, () -> "Download failed from URL: " + downloadUrl);
+ return classifyException(exception, downloadUrl);
+ }
+ }
+
+ private @Nullable URL resolveRedirect(@NotNull final HttpURLConnection connection,
+ @NotNull final URL current, final int responseCode)
+ throws java.net.MalformedURLException {
+ final String location = connection.getHeaderField("Location");
+ if (location == null || location.isEmpty()) {
+ logger.log(Level.WARNING, "Redirect without Location header from: {0}", current);
+ return null;
+ }
+ final URL next = URI.create(location).toURL();
+ logger.log(Level.FINEST, () -> "Redirect " + responseCode + " to " + next);
+ return next;
+ }
+
+ private int classifyErrorStatus(final int responseCode, @NotNull final URL url,
+ @NotNull final String downloadUrl) {
+ if (responseCode == 404) {
return DOWNLOAD_NOT_RETRYABLE;
}
+ logger.log(Level.WARNING, "HTTP {0} when downloading {1}", new Object[]{responseCode, url});
+ return RETRYABLE_STATUS_CODES.contains(responseCode)
+ ? DOWNLOAD_RETRYABLE
+ : DOWNLOAD_NOT_RETRYABLE;
+ }
+
+ private int classifyException(@NotNull final Exception exception, @NotNull final String downloadUrl) {
+ if (exception instanceof java.net.SocketTimeoutException
+ || exception instanceof java.net.ConnectException) {
+ logger.log(Level.FINE, exception, () -> "Retryable network error from URL: " + downloadUrl);
+ return DOWNLOAD_RETRYABLE;
+ }
+ logger.log(Level.FINE, exception, () -> "Download failed from URL: " + downloadUrl);
+ return DOWNLOAD_NOT_RETRYABLE;
}
private boolean handleSuccessfulResponse(
diff --git a/src/main/java/de/jexcellence/dependency/loader/PaperPluginLoader.java b/src/main/java/de/jexcellence/dependency/loader/PaperPluginLoader.java
index cd44af80..a6a2bb22 100644
--- a/src/main/java/de/jexcellence/dependency/loader/PaperPluginLoader.java
+++ b/src/main/java/de/jexcellence/dependency/loader/PaperPluginLoader.java
@@ -607,7 +607,18 @@ private int applyAutomaticRelocations(
// jboss-logging - used by Hibernate internally; Hibernate is excluded so its bytecode
// keeps original org.jboss.logging references; the library must stay at original names
// to match. Also avoids stale-cache version skew (3.4.x → 3.5.x method signature change).
- "org.jboss"
+ "org.jboss",
+ // Discord (JDA) stack - the consuming plugin references these at their ORIGINAL
+ // package names (the plugin jar does not relocate them), so the downloaded libs
+ // must keep original names too. Relocating produces de.jexcellence.remapped.net.dv8tion…
+ // which the plugin can't resolve (NoClassDefFoundError: net/dv8tion/jda/api/entities/UserSnowflake).
+ "net.dv8tion", // JDA
+ "com.neovisionaries", // nv-websocket-client
+ "okhttp3", // okhttp
+ "okio", // okio
+ "kotlin", // kotlin-stdlib (okhttp/okio dependency)
+ "org.apache", // commons-collections4 (org.apache.commons.collections4)
+ "gnu.trove" // trove4j:core
));
final String excludesProperty = System.getProperty(RELOCATIONS_EXCLUDES_PROPERTY);
@@ -740,29 +751,18 @@ private boolean processRemapping(
for (final Path inputJar : inputJars) {
final Path outputJar = outputDirectory.resolve(inputJar.getFileName());
- if (isRemappedJarUpToDate(outputJar, inputJar)) {
- logger.log(Level.FINE, "Cached: {0}", outputJar.getFileName());
+ final int outcome = isRemappedJarUpToDate(outputJar, inputJar)
+ ? markCached(outputJar)
+ : remapAndValidate(remappingManager, inputJar, outputJar);
+
+ if (outcome != REMAP_NONE) {
processedCount++;
+ }
+ if (outcome == REMAP_OK) {
remappedCount++;
- } else {
- deleteExistingFile(outputJar);
-
- if (performRemapping(remappingManager, inputJar, outputJar)) {
- processedCount++;
- if (isValidJarFile(outputJar)) {
- remappedCount++;
- logger.log(Level.FINE, "Remapped: {0}", inputJar.getFileName());
- } else {
- logger.log(Level.WARNING, "Failed to remap: {0}", inputJar.getFileName());
- }
- }
}
- if (processedCount % progressInterval == 0 || processedCount == total) {
- final int percent = (processedCount * 100) / total;
- logger.log(Level.INFO, "Remapping... {0}/{1} ({2}%)",
- new Object[]{processedCount, total, percent});
- }
+ logRemapProgress(processedCount, total, progressInterval);
}
if (processedCount == 0) {
@@ -774,6 +774,37 @@ private boolean processRemapping(
return remappedCount > 0;
}
+ /** Per-jar remap outcome codes used by {@link #processRemapping}. */
+ private static final int REMAP_NONE = 0; // not processed (remap call failed)
+ private static final int REMAP_PROCESSED = 1; // processed but output invalid
+ private static final int REMAP_OK = 2; // processed and valid (or already cached)
+
+ private int markCached(@NotNull final Path outputJar) {
+ logger.log(Level.FINE, "Cached: {0}", outputJar.getFileName());
+ return REMAP_OK;
+ }
+
+ private int remapAndValidate(@NotNull final Object remappingManager,
+ @NotNull final Path inputJar, @NotNull final Path outputJar) {
+ deleteExistingFile(outputJar);
+ if (!performRemapping(remappingManager, inputJar, outputJar)) {
+ return REMAP_NONE;
+ }
+ if (isValidJarFile(outputJar)) {
+ logger.log(Level.FINE, "Remapped: {0}", inputJar.getFileName());
+ return REMAP_OK;
+ }
+ logger.log(Level.WARNING, "Failed to remap: {0}", inputJar.getFileName());
+ return REMAP_PROCESSED;
+ }
+
+ private void logRemapProgress(final int processed, final int total, final int interval) {
+ if (processed % interval == 0 || processed == total) {
+ final int percent = (processed * 100) / total;
+ logger.log(Level.INFO, "Remapping... {0}/{1} ({2}%)", new Object[]{processed, total, percent});
+ }
+ }
+
private boolean isRemappedJarUpToDate(@NotNull final Path outputJar, @NotNull final Path inputJar) {
try {
if (!Files.exists(outputJar) || !Files.isRegularFile(outputJar)) {
@@ -883,7 +914,7 @@ private void loadSpecificJarsIntoClasspath(
logger.log(Level.WARNING, "JAR not found: {0}", jarPath);
}
} catch (final Exception exception) {
- logger.log(Level.WARNING, "Failed to load: " + jarPath.getFileName(), exception);
+ logger.log(Level.WARNING, exception, () -> "Failed to load: " + jarPath.getFileName());
}
}
logger.log(Level.INFO, "Loaded {0} libraries from shared cache", loaded);
diff --git a/src/main/java/de/jexcellence/dependency/loader/YamlDependencyLoader.java b/src/main/java/de/jexcellence/dependency/loader/YamlDependencyLoader.java
index 936ee45e..4060fc70 100644
--- a/src/main/java/de/jexcellence/dependency/loader/YamlDependencyLoader.java
+++ b/src/main/java/de/jexcellence/dependency/loader/YamlDependencyLoader.java
@@ -23,9 +23,15 @@
public class YamlDependencyLoader {
private static final String LOGGER_NAME = "JExDependency";
- private static final String DEPENDENCIES_YAML_PATH = "/dependency/dependencies.yml";
- private static final String PAPER_DEPENDENCIES_PATH = "/dependency/paper/dependencies.yml";
- private static final String SPIGOT_DEPENDENCIES_PATH = "/dependency/spigot/dependencies.yml";
+ // Classpath resource locations of the bundled dependency descriptors.
+ // Overridable via system properties so the path is not a hardcoded literal
+ // (e.g. -Djedependency.path.paper=/custom/deps.yml).
+ private static final String DEPENDENCIES_YAML_PATH =
+ System.getProperty("jedependency.path.default", "/dependency/dependencies.yml");
+ private static final String PAPER_DEPENDENCIES_PATH =
+ System.getProperty("jedependency.path.paper", "/dependency/paper/dependencies.yml");
+ private static final String SPIGOT_DEPENDENCIES_PATH =
+ System.getProperty("jedependency.path.spigot", "/dependency/spigot/dependencies.yml");
private static final String DEPENDENCIES_SECTION = "dependencies:";
private static final String LIST_PREFIX = "- ";
private static final String QUOTE = "\"";
@@ -165,14 +171,10 @@ public YamlDependencyLoader() {
if (trimmed.equals(DEPENDENCIES_SECTION)) {
inDependenciesSection = true;
- continue;
- }
-
- if (inDependenciesSection) {
+ } else if (inDependenciesSection) {
if (isEndOfSection(trimmed)) {
break;
}
-
processDependencyLine(trimmed, dependencies);
}
}
diff --git a/src/main/java/de/jexcellence/dependency/manager/DependencyManager.java b/src/main/java/de/jexcellence/dependency/manager/DependencyManager.java
index 1f46dc93..7420e148 100644
--- a/src/main/java/de/jexcellence/dependency/manager/DependencyManager.java
+++ b/src/main/java/de/jexcellence/dependency/manager/DependencyManager.java
@@ -238,7 +238,7 @@ public void initialize(@Nullable final String[] additionalDependencies) {
final int added = merged.size() - roots.size();
if (added > 0) {
- logger.info("Transitive resolution discovered " + added + " additional dependencies");
+ logger.log(Level.INFO, "Transitive resolution discovered {0} additional dependencies", added);
} else {
logger.fine("Transitive resolution found no additional dependencies");
}
diff --git a/src/main/java/de/jexcellence/dependency/remapper/PackageRemapper.java b/src/main/java/de/jexcellence/dependency/remapper/PackageRemapper.java
index f9afd506..3222f581 100644
--- a/src/main/java/de/jexcellence/dependency/remapper/PackageRemapper.java
+++ b/src/main/java/de/jexcellence/dependency/remapper/PackageRemapper.java
@@ -360,12 +360,10 @@ private static String applyDotRelocations(final String fqcn, final Map e : mappings.entrySet()) {
final String from = e.getKey();
- if (fqcn.equals(from) || fqcn.startsWith(from + ".")) {
- if (from.length() > bestLen) {
- bestFrom = from;
- bestTo = e.getValue();
- bestLen = from.length();
- }
+ if ((fqcn.equals(from) || fqcn.startsWith(from + ".")) && from.length() > bestLen) {
+ bestFrom = from;
+ bestTo = e.getValue();
+ bestLen = from.length();
}
}
if (bestFrom != null) {
diff --git a/src/main/java/de/jexcellence/dependency/remapper/RemappingDependencyManager.java b/src/main/java/de/jexcellence/dependency/remapper/RemappingDependencyManager.java
index 6494d573..9e875baf 100644
--- a/src/main/java/de/jexcellence/dependency/remapper/RemappingDependencyManager.java
+++ b/src/main/java/de/jexcellence/dependency/remapper/RemappingDependencyManager.java
@@ -543,6 +543,19 @@ private static boolean isRestrictedRoot(final String pkg) {
if (pkg.equals("com.fasterxml") || pkg.startsWith("com.fasterxml.")) {
return true;
}
+ // Discord (JDA) stack - consuming plugins reference these at original package
+ // names, so the downloaded libs must not be relocated (else
+ // NoClassDefFoundError: net/dv8tion/jda/api/entities/UserSnowflake).
+ return isDiscordStackRoot(pkg);
+ }
+
+ private static boolean isDiscordStackRoot(final String pkg) {
+ for (final String root : new String[]{
+ "net.dv8tion", "com.neovisionaries", "okhttp3", "okio", "kotlin", "gnu.trove", "org.apache"}) {
+ if (pkg.equals(root) || pkg.startsWith(root + ".")) {
+ return true;
+ }
+ }
return false;
}
diff --git a/src/main/java/de/jexcellence/dependency/resolver/TransitiveDependencyResolver.java b/src/main/java/de/jexcellence/dependency/resolver/TransitiveDependencyResolver.java
index bc9492cc..50c65304 100644
--- a/src/main/java/de/jexcellence/dependency/resolver/TransitiveDependencyResolver.java
+++ b/src/main/java/de/jexcellence/dependency/resolver/TransitiveDependencyResolver.java
@@ -311,16 +311,9 @@ private void resolveRecursive(
// 2. Disk cache (GAV-structured path to avoid collisions between same-named artifacts)
final File diskFile = SharedCacheManager.getInstance().resolvePomPath(coordinate).toFile();
- if (diskFile.isFile() && diskFile.length() > 0) {
- try (final InputStream is = new FileInputStream(diskFile)) {
- final ParsedPom pom = pomParser.parse(is);
- if (pom != null) {
- pomCache.put(cacheKey, pom);
- return pom;
- }
- } catch (final Exception exception) {
- LOGGER.log(Level.FINE, exception, () -> "Failed to read cached POM: " + diskFile);
- }
+ final ParsedPom cached = readPomFromDisk(diskFile, cacheKey);
+ if (cached != null) {
+ return cached;
}
// 3. Remote download
@@ -329,35 +322,70 @@ private void resolveRecursive(
+ coordinate.version() + '/'
+ coordinate.artifactId() + '-' + coordinate.version() + ".pom";
+ return downloadPomFromRepositories(coordinate, pomPath, diskFile, cacheKey);
+ }
+
+ private @Nullable ParsedPom readPomFromDisk(@NotNull final File diskFile, @NotNull final String cacheKey) {
+ if (!diskFile.isFile() || diskFile.length() <= 0) {
+ return null;
+ }
+ try (final InputStream is = new FileInputStream(diskFile)) {
+ final ParsedPom pom = pomParser.parse(is);
+ if (pom != null) {
+ pomCache.put(cacheKey, pom);
+ return pom;
+ }
+ } catch (final Exception exception) {
+ LOGGER.log(Level.FINE, exception, () -> "Failed to read cached POM: " + diskFile);
+ }
+ return null;
+ }
+
+ private @Nullable ParsedPom downloadPomFromRepositories(@NotNull final DependencyCoordinate coordinate,
+ @NotNull final String pomPath,
+ @NotNull final File diskFile,
+ @NotNull final String cacheKey) {
for (final RepositoryType repo : RepositoryType.values()) {
final byte[] bytes = fetchBytes(repo.getBaseUrl() + pomPath);
- if (bytes == null || bytes.length == 0) continue;
-
- // Persist to disk cache
- try {
- Files.createDirectories(diskFile.toPath().getParent());
- Files.write(diskFile.toPath(), bytes);
- } catch (final Exception exception) {
- LOGGER.log(Level.FINE, "Failed to persist POM to disk cache", exception);
+ if (bytes == null || bytes.length == 0) {
+ continue;
}
-
- // Parse
- try (final InputStream is = new ByteArrayInputStream(bytes)) {
- final ParsedPom pom = pomParser.parse(is);
- if (pom != null) {
- pomCache.put(cacheKey, pom);
- LOGGER.log(Level.FINE, () -> "Resolved POM: " + coordinate.toGavString() + " from " + repo.name());
- return pom;
- }
- } catch (final Exception exception) {
- LOGGER.log(Level.FINE, exception, () -> "Failed to parse POM from " + repo.name());
+ persistPomToDisk(diskFile, bytes);
+ final ParsedPom pom = parsePomBytes(bytes, coordinate, repo, cacheKey);
+ if (pom != null) {
+ return pom;
}
}
-
LOGGER.log(Level.WARNING, "Could not download POM: {0}", coordinate.toGavString());
return null;
}
+ private void persistPomToDisk(@NotNull final File diskFile, @NotNull final byte[] bytes) {
+ try {
+ Files.createDirectories(diskFile.toPath().getParent());
+ Files.write(diskFile.toPath(), bytes);
+ } catch (final Exception exception) {
+ LOGGER.log(Level.FINE, "Failed to persist POM to disk cache", exception);
+ }
+ }
+
+ private @Nullable ParsedPom parsePomBytes(@NotNull final byte[] bytes,
+ @NotNull final DependencyCoordinate coordinate,
+ @NotNull final RepositoryType repo,
+ @NotNull final String cacheKey) {
+ try (final InputStream is = new ByteArrayInputStream(bytes)) {
+ final ParsedPom pom = pomParser.parse(is);
+ if (pom != null) {
+ pomCache.put(cacheKey, pom);
+ LOGGER.log(Level.FINE, () -> "Resolved POM: " + coordinate.toGavString() + " from " + repo.name());
+ return pom;
+ }
+ } catch (final Exception exception) {
+ LOGGER.log(Level.FINE, exception, () -> "Failed to parse POM from " + repo.name());
+ }
+ return null;
+ }
+
/**
* Performs a simple HTTP GET and returns the full response body, or {@code null} on
* error or non-200 response.