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.