From 07b61be25780ab72c917af15559b88143d21b013 Mon Sep 17 00:00:00 2001 From: Denny Sheirer Date: Fri, 13 Nov 2026 01:59:38 -0500 Subject: [PATCH] #2632 Detect on startup if multiple versions of any library are on the classpath and warn user to cleanup and reinstall (#2633) Co-authored-by: denny --- .../java/io/github/dsheirer/gui/SDRTrunk.java | 42 +++++- .../util/DuplicateLibraryDetector.java | 140 ++++++++++++++++++ .../util/DuplicateLibraryDetectorTest.java | 78 ++++++++++ 3 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 src/main/java/io/github/dsheirer/util/DuplicateLibraryDetector.java create mode 100644 src/test/java/io/github/dsheirer/util/DuplicateLibraryDetectorTest.java diff --git a/src/main/java/io/github/dsheirer/gui/SDRTrunk.java b/src/main/java/io/github/dsheirer/gui/SDRTrunk.java index d3adc4cc..09b52b65 100644 --- a/src/main/java/io/github/dsheirer/gui/SDRTrunk.java +++ b/src/main/java/io/github/dsheirer/gui/SDRTrunk.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2025 Dennis Sheirer + * Copyright (C) 2014-2026 Dennis Sheirer * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -60,6 +60,7 @@ import io.github.dsheirer.source.tuner.ui.TunerSpectralDisplayManager; import io.github.dsheirer.spectrum.DisableSpectrumWaterfallMenuItem; import io.github.dsheirer.spectrum.ShowTunerMenuItem; import io.github.dsheirer.spectrum.SpectralDisplayPanel; +import io.github.dsheirer.util.DuplicateLibraryDetector; import io.github.dsheirer.util.ThreadPool; import io.github.dsheirer.util.TimeStamp; import io.github.dsheirer.vector.calibrate.CalibrationManager; @@ -81,6 +82,7 @@ import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.prefs.Preferences; import javafx.application.Platform; @@ -153,6 +155,17 @@ public class SDRTrunk implements Listener mApplicationLog = new ApplicationLog(mUserPreferences); mApplicationLog.start(); + Map> duplicateLibraries = DuplicateLibraryDetector.findDuplicates(); + + if(!duplicateLibraries.isEmpty()) + { + mLog.warn("More than one version of these libraries is in the library folder [" + + DuplicateLibraryDetector.getFolder(duplicateLibraries) + "]. This happens when a new version of " + + "sdrtrunk is unzipped over an older one, and it can load the older library code. Delete the " + + "sdrtrunk folder and unzip the release into an empty folder.\n" + + DuplicateLibraryDetector.describe(duplicateLibraries)); + } + mResourceMonitor = new ResourceMonitor(mUserPreferences); ThreadPool.logSettings(); @@ -253,6 +266,11 @@ public class SDRTrunk implements Listener { updateTitle(tuner.getPreferredName()); } + + if(!duplicateLibraries.isEmpty()) + { + showDuplicateLibrariesDialog(duplicateLibraries); + } } if(calibrating && !GraphicsEnvironment.isHeadless()) @@ -909,6 +927,28 @@ public class SDRTrunk implements Listener } + /** + * Warns the user that the library folder holds more than one version of some libraries, which happens when a new + * release is unzipped over an older one. The launch scripts put every jar in the library folder on the class + * path, so the older library code can load instead of the newer code. + * @param duplicates library name to jar paths, from DuplicateLibraryDetector + */ + private void showDuplicateLibrariesDialog(Map> duplicates) + { + String message = "" + + "

The sdrtrunk library folder has more than one version of these libraries:


" + + "
" + DuplicateLibraryDetector.describe(duplicates) + "
" + + "

This happens when a new version of sdrtrunk is unzipped over an older one. sdrtrunk can load " + + "the older library code, which can cause crashes or errors that the newer version fixed.


" + + "

Close sdrtrunk, delete the sdrtrunk folder that contains this library folder and unzip the " + + "release into a new, empty folder:


" + + "

" + DuplicateLibraryDetector.getFolder(duplicates) + "


" + + "

Your playlist and settings are stored in your user folder and won't be affected.

" + + ""; + + JOptionPane.showMessageDialog(mMainGui, message, "Duplicate Library Versions", JOptionPane.WARNING_MESSAGE); + } + /** * Launch the application. */ diff --git a/src/main/java/io/github/dsheirer/util/DuplicateLibraryDetector.java b/src/main/java/io/github/dsheirer/util/DuplicateLibraryDetector.java new file mode 100644 index 00000000..7d29bbb0 --- /dev/null +++ b/src/main/java/io/github/dsheirer/util/DuplicateLibraryDetector.java @@ -0,0 +1,140 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ +package io.github.dsheirer.util; + +import java.io.File; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Detects more than one version of the same library jar on the class path. + * + * The release launch scripts use a 'lib/*' wildcard class path, so when a user unzips a new release over an older one, + * the stale jars from the older release stay on the class path next to the new ones. The JVM loads classes (and the + * tuner libraries extract their native libraries) from whichever jar sorts first, which can silently run an older + * library version, including native code with bugs that the newer version fixed. + */ +public class DuplicateLibraryDetector +{ + /** + * Splits a jar file name into the library name and version. The version starts at the first dash that is + * followed by a digit, a 'v' and a digit, or 'nightly' (e.g. guava-33.8.0-jre, api-v2.1.1, sdr-trunk-nightly). + */ + private static final Pattern JAR_NAME = Pattern.compile("^(.+?)-(v?\\d.*|nightly)\\.jar$"); + + private DuplicateLibraryDetector() + { + } + + /** + * Finds the libraries that have more than one version on this JVM's class path. + * @return map of library name to the sorted jar paths for that library, empty when there are no duplicates. + */ + public static Map> findDuplicates() + { + String classPath = System.getProperty("java.class.path", ""); + return findDuplicates(List.of(classPath.split(Pattern.quote(File.pathSeparator)))); + } + + /** + * Finds the libraries that have more than one version in the class path entries. Entries that are not jar files + * or that don't have a version in the file name are ignored. + * @param classPathEntries to check + * @return map of library name to the sorted jar paths for that library, empty when there are no duplicates. + */ + public static Map> findDuplicates(List classPathEntries) + { + Map> versions = new TreeMap<>(); + Map> paths = new TreeMap<>(); + + for(String entry: classPathEntries) + { + if(entry == null || entry.isBlank()) + { + continue; + } + + Path fileName = Path.of(entry).getFileName(); + + if(fileName == null) + { + continue; + } + + Matcher matcher = JAR_NAME.matcher(fileName.toString()); + + if(matcher.matches()) + { + versions.computeIfAbsent(matcher.group(1), k -> new TreeSet<>()).add(matcher.group(2)); + paths.computeIfAbsent(matcher.group(1), k -> new TreeSet<>()).add(entry); + } + } + + Map> duplicates = new TreeMap<>(); + + for(Map.Entry> entry: versions.entrySet()) + { + if(entry.getValue().size() > 1) + { + duplicates.put(entry.getKey(), new ArrayList<>(paths.get(entry.getKey()))); + } + } + + return duplicates; + } + + /** + * Creates a description of the duplicate libraries for a log entry or a dialog. + * @param duplicates from findDuplicates() + * @return description, one line per library, listing the jar file names for each library. + */ + public static String describe(Map> duplicates) + { + StringBuilder sb = new StringBuilder(); + + for(Map.Entry> entry: duplicates.entrySet()) + { + List names = entry.getValue().stream().map(p -> Path.of(p).getFileName().toString()).toList(); + sb.append(" ").append(entry.getKey()).append(": ").append(String.join(", ", names)).append("\n"); + } + + return sb.toString(); + } + + /** + * Folder that holds the duplicate jars, for telling the user which folder to replace. + * @param duplicates from findDuplicates() + * @return folder of the first duplicate jar, or null if there are no duplicates or the path has no parent. + */ + public static Path getFolder(Map> duplicates) + { + if(duplicates.isEmpty()) + { + return null; + } + + return Path.of(duplicates.values().iterator().next().getFirst()).toAbsolutePath().normalize().getParent(); + } +} diff --git a/src/test/java/io/github/dsheirer/util/DuplicateLibraryDetectorTest.java b/src/test/java/io/github/dsheirer/util/DuplicateLibraryDetectorTest.java new file mode 100644 index 00000000..426cf97b --- /dev/null +++ b/src/test/java/io/github/dsheirer/util/DuplicateLibraryDetectorTest.java @@ -0,0 +1,78 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ +package io.github.dsheirer.util; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class DuplicateLibraryDetectorTest +{ + private static final String LIB = "/opt/sdr-trunk/bin/../lib/"; + + /** + * Jar names from a clean release image. + */ + private static final List CLEAN = List.of("JLargeArrays-1.7.jar", "JTransforms-3.2.jar", "api-v2.1.1.jar", + "commons-lang3-3.21.0.jar", "guava-33.8.0-jre.jar", "jackson-annotations-2.23.jar", + "jackson-core-2.23.0.jar", "java-lame-v3.98.4.jar", "javafx-swt.jar", "jhackrf-2026.01.3.1.jar", + "jiconfont-1.0.0.jar", "jiconfont-font_awesome-4.7.0.1.jar", "jiconfont-javafx-1.0.0.jar", + "jiconfont-swing-1.0.1.jar", "jrt-fs.jar", "jrtl-1.4.0.3.jar", "libresample4j-master.jar", + "listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar", "logback-classic-1.6.6.jar", + "logback-core-1.6.6.jar", "sdr-trunk-nightly.jar"); + + private static List paths(List names) + { + return names.stream().map(n -> LIB + n).toList(); + } + + @Test + void cleanReleaseHasNoDuplicates() + { + assertTrue(DuplicateLibraryDetector.findDuplicates(paths(CLEAN)).isEmpty()); + assertNull(DuplicateLibraryDetector.getFolder(Map.of())); + } + + @Test + void newReleaseUnzippedOverOldReleaseIsDetected() + { + List names = new java.util.ArrayList<>(CLEAN); + names.addAll(List.of("api-v2.1.0.jar", "guava-33.6.0-jre.jar", "jrtl-1.4.0.2.jar", "sdr-trunk-0.6.1.jar")); + + Map> duplicates = DuplicateLibraryDetector.findDuplicates(paths(names)); + + assertEquals(List.of("api", "guava", "jrtl", "sdr-trunk"), List.copyOf(duplicates.keySet())); + assertEquals(List.of(LIB + "jrtl-1.4.0.2.jar", LIB + "jrtl-1.4.0.3.jar"), duplicates.get("jrtl")); + assertTrue(DuplicateLibraryDetector.describe(duplicates).contains("jrtl: jrtl-1.4.0.2.jar, jrtl-1.4.0.3.jar")); + //Windows resolves a rooted path without a drive letter against the current drive, e.g. C:\opt\sdr-trunk\lib + assertEquals(Path.of("/opt/sdr-trunk/lib").toAbsolutePath(), DuplicateLibraryDetector.getFolder(duplicates)); + } + + @Test + void sameJarListedTwiceIsNotADuplicate() + { + assertTrue(DuplicateLibraryDetector.findDuplicates(List.of(LIB + "jrtl-1.4.0.3.jar", LIB + "jrtl-1.4.0.3.jar", + "", "/some/classes/dir")).isEmpty()); + } +}