diff --git a/jdm-core/docs/benchmarks.md b/jdm-core/docs/benchmarks.md index 9265f6e4..9145236a 100644 --- a/jdm-core/docs/benchmarks.md +++ b/jdm-core/docs/benchmarks.md @@ -530,3 +530,51 @@ vs. CDM's pre-pinned buffer pool. - `jdm-core/src/main/java/jdiskmark/Benchmark.java` — CDM grid in `toResultString()` - `jdm-core/src/main/java/jdiskmark/RunBenchmarkCommand.java` — `--cdm` flag - `jdm-core/src/main/java/jdiskmark/BenchmarkCallable.java` — CLI CDM execution + +--- + +## 4. USB Drive Benchmarking (Linux / Ubuntu) + +USB flash drives present several constraints not encountered with NVMe or SATA +drives. The following findings were verified on Ubuntu with a Lexar USB 3.0 +flash drive formatted as vfat (FAT32). + +### 4.1 Direct IO Constraints + +Java's `ExtendedOpenOption.DIRECT` enforces alignment and I/O size requirements +based on `FileStore.getBlockSize()`. On vfat, this returns the **filesystem +cluster size** (e.g. 32 KB) rather than the device's logical sector size +(512 B). The Linux kernel itself only requires 512-byte sector alignment for +Direct IO — verified with `dd oflag=direct` at 4 KB and 512 B block sizes. + +Consequences of the Java NIO restriction: + +- **Block size ≥ cluster size**: Direct IO works when the benchmark block size + matches or exceeds the cluster size (e.g. 32 KB blocks on a 32 KB cluster + vfat volume). +- **Block size < cluster size**: Java rejects the I/O with + `"Number of remaining bytes is not a multiple of the block size"`. + The application auto-disables Direct IO for the run and notifies the user. +- **Buffer alignment**: The native memory address of the `ByteBuffer` must also + be aligned to the cluster size. `Arena.allocate(size, alignment)` with the + cluster size as alignment satisfies this. + +#### Future Improvement + +Bypassing Java's NIO restriction via the Foreign Function & Memory API (FFI) to call `open()` with `O_DIRECT` at the syscall level would allow Direct IO with the device's true 512-byte sector alignment, matching `dd` behaviour. + +### 4.2 Sector Alignment Auto-Adjustment + +When Direct IO is enabled and the filesystem cluster size exceeds the user-selected sector alignment, `BenchmarkRunner.resolveAlignment()` adjusts the effective alignment upward and updates the GUI badge to reflect the value actually used during the run. + +### 4.3 SMART Diagnostics + +USB flash drives do not support SMART. `smartctl` reports `"Unknown USB bridge"` and cannot query device health data. The application detects the USB bus type via `lsblk TRAN` and shows a message instead of launching the privileged `smartctl` shell. + +### 4.4 Drive Model Detection + +USB drives report VENDOR and MODEL as separate `lsblk` columns (e.g. VENDOR=`Lexar`, MODEL=`USB Flash Drive`), unlike NVMe drives where MODEL includes the manufacturer. `getVendorModelLinux()` combines both columns for display, while `getDeviceModelLinux()` returns MODEL only. + +### 4.5 Write Performance Characteristics + +With Direct IO enabled and per-sample file creation (the default benchmark pattern), write throughput on vfat is significantly lower than raw device capability. Each new file requires synchronous FAT table metadata updates that bypass the OS write cache. Verified with `dd oflag=direct` writing to a single file at 47 MB/s versus the benchmark pattern at ~0.5 MB/s. diff --git a/jdm-core/docs/design.md b/jdm-core/docs/design.md index 38e9a14a..3171c021 100644 --- a/jdm-core/docs/design.md +++ b/jdm-core/docs/design.md @@ -57,7 +57,7 @@ BenchmarkWorker, DiskUsageInfo, GcDetector, RenderFrequencyMode, Sample, Smart, SmartSnapshot, GcRetriedSamplesConverter, SampleAttributeConverter, LocalDateTimeAttributeConverter -**io** — DriveAccessChecker, UtilOs (abstract interface) +**io** — DriveChecker, UtilOs (abstract interface) **io.win / io.mac / io.linux** — platform implementations (split from UtilOs) **cli** — Cli, RunBenchmarkCommand, VersionProvider diff --git a/jdm-core/src/main/java/jdiskmark/App.java b/jdm-core/src/main/java/jdiskmark/App.java index c63609b2..ed729a3f 100644 --- a/jdm-core/src/main/java/jdiskmark/App.java +++ b/jdm-core/src/main/java/jdiskmark/App.java @@ -1,6 +1,6 @@ package jdiskmark; -import static jdiskmark.DriveAccessChecker.validateTargetDirectory; +import static jdiskmark.DriveChecker.validateTargetDirectory; import picocli.CommandLine; import java.io.File; @@ -106,6 +106,7 @@ public enum SectorAlignment { ALIGN_4K(4096, "4 KB (Standard)"), ALIGN_8K(8192, "8 KB (Enterprise)"), ALIGN_16K(16384, "16 KB (High-End)"), + ALIGN_32K(32768, "32 KB (FAT32)"), ALIGN_64K(65536, "64 KB (RAID/Stripe)"); public final int bytes; @@ -139,6 +140,7 @@ public String toString() { public static String arch; public static String processorName; public static String jdk; + public static String osLabel; // PII: OS username collection removed (#117 — use anonymous or a non-PII system // id instead). // public static String username; @@ -382,6 +384,8 @@ public static void init() { arch = System.getProperty("os.arch"); processorName = Util.getProcessorName(); jdk = Util.getJvmInfo(); + osLabel = isLinux() ? UtilOs.getLinuxDistroName() : null; + if (osLabel == null) osLabel = os; checkPermission(); if (!APP_CACHE_DIR.exists()) { @@ -974,16 +978,21 @@ public static void startBenchmark() { return; } - // 3. update state + // 3. check enough disk space for the configured benchmark + if (!DriveChecker.checkDiskSpace(locationDir)) { + return; + } + + // 4. update state state = State.DISK_TEST_STATE; if (mode == Mode.GUI) { Gui.mainFrame.adjustSensitivity(); } - // 4. create data dir reference + // 5. create data dir reference dataDir = new File(locationDir.getAbsolutePath() + File.separator + DATADIRNAME); - // 5. remove existing test data if present (recursive — File.delete() only + // 6. remove existing test data if present (recursive — File.delete() only // removes empty dirs) if (autoRemoveData && dataDir.exists()) { boolean removed = Util.deleteDirectory(dataDir); @@ -996,12 +1005,12 @@ public static void startBenchmark() { } } - // 6. create data dir if not already present + // 7. create data dir if not already present if (dataDir.exists() == false) { dataDir.mkdirs(); } - // 7. start benchmark job thread + // 8. start benchmark job thread switch (mode) { case GUI -> { worker = new BenchmarkWorker(); diff --git a/jdm-core/src/main/java/jdiskmark/Benchmark.java b/jdm-core/src/main/java/jdiskmark/Benchmark.java index ba2175da..31c322a5 100644 --- a/jdm-core/src/main/java/jdiskmark/Benchmark.java +++ b/jdm-core/src/main/java/jdiskmark/Benchmark.java @@ -308,15 +308,26 @@ static List findAll() { @JsonIgnore static int deleteAll() { EntityManager em = EM.getEntityManager(); - em.getTransaction().begin(); - int deletedOperationsCount = em.createQuery("DELETE FROM BenchmarkOperation").executeUpdate(); - int deletedBenchmarksCount = em.createQuery("DELETE FROM Benchmark").executeUpdate(); - if (App.verbose) { - App.msg("deletedOperations=" + deletedOperationsCount); - App.msg("deletedBenchmarks=" + deletedBenchmarksCount); + // If a prior operation left a transaction open, roll it back first. + if (em.getTransaction().isActive()) { + em.getTransaction().rollback(); + } + try { + em.getTransaction().begin(); + int deletedOperationsCount = em.createQuery("DELETE FROM BenchmarkOperation").executeUpdate(); + int deletedBenchmarksCount = em.createQuery("DELETE FROM Benchmark").executeUpdate(); + if (App.verbose) { + App.msg("deletedOperations=" + deletedOperationsCount); + App.msg("deletedBenchmarks=" + deletedBenchmarksCount); + } + em.getTransaction().commit(); + return deletedBenchmarksCount; + } catch (Exception e) { + if (em.getTransaction().isActive()) { + em.getTransaction().rollback(); + } + throw e; } - em.getTransaction().commit(); - return deletedBenchmarksCount; } @JsonIgnore diff --git a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java index 65e92d53..1043555a 100644 --- a/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java +++ b/jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java @@ -2,12 +2,16 @@ import static jdiskmark.GcDetector.MAX_GC_RETRIES; +import java.io.File; + import java.util.logging.Level; import java.util.logging.Logger; import java.time.LocalDateTime; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.lang.foreign.MemoryLayout; +import java.lang.foreign.ValueLayout; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -54,6 +58,7 @@ private interface IOAction { final LongAdder readUnitsComplete = new LongAdder(); long unitsTotal; long blockSize; + long effectiveAlignment; byte[] blockArr; // for legacy jdk io public static int[][] divideIntoRanges(int startIndex, int endIndex, int numThreads) { @@ -102,6 +107,7 @@ public Benchmark execute() throws Exception { unitsTotal = wUnitsTotal + rUnitsTotal; blockSize = config.blockSize; + effectiveAlignment = resolveAlignment(config); if (config.ioEngine == IoEngine.LEGACY) { blockArr = new byte[(int)blockSize]; @@ -110,10 +116,10 @@ public Benchmark execute() throws Exception { } } - //TODO: use config if possible - String driveModel = Util.getDriveModel(App.locationDir); - String partitionId = Util.getPartitionId(App.locationDir.toPath()); - DiskUsageInfo usageInfo = Util.getDiskUsage(App.locationDir.toString()); + File configDir = new File(config.testDir); + String driveModel = Util.getDriveModel(configDir); + String partitionId = Util.getPartitionId(configDir.toPath()); + DiskUsageInfo usageInfo = Util.getDiskUsage(configDir.getAbsolutePath()); // Initialize Benchmark @@ -381,6 +387,7 @@ private void mapEnvironment(Benchmark b, String model, String partId, DiskUsageI b.systemInfo.os = App.os; b.systemInfo.arch = App.arch; b.systemInfo.jdk = App.jdk; + b.systemInfo.osLabel = App.osLabel; b.systemInfo.locationDir = App.locationDir.toString(); b.driveInfo.driveModel = model; @@ -389,4 +396,66 @@ private void mapEnvironment(Benchmark b, String model, String partId, DiskUsageI b.driveInfo.usedGb = u.usedGb; b.driveInfo.totalGb = u.totalGb; } + + /** + * Determines the effective byte alignment for Direct IO. When Direct IO is + * enabled, the alignment must be at least the filesystem block size or the + * write/read will fail with an {@code IOException}. This is critical for + * vfat (FAT32) which typically uses 32 KB blocks. + */ + private static long resolveAlignment(BenchmarkConfig config) { + long userAlign = config.sectorAlignment.bytes; + if (userAlign <= 0) { + return MemoryLayout.sequenceLayout( + config.blockSize, ValueLayout.JAVA_BYTE).byteAlignment(); + } + if (config.getDirectIoEnabled() == null || !config.getDirectIoEnabled()) { + return userAlign; + } + try { + long fsBlockSize = java.nio.file.Files.getFileStore( + Path.of(config.testDir)).getBlockSize(); + + // Java's ExtendedOpenOption.DIRECT enforces that every I/O + // transfer is a multiple of FileStore.getBlockSize(), which on + // vfat returns the cluster size (e.g. 32 KB) rather than the + // device sector size (512 B). The Linux kernel itself only + // requires sector-size alignment, but Java's NIO layer is more + // restrictive. When the user-chosen block size is smaller than + // the cluster size, disable Direct IO upfront and inform the + // user rather than silently retrying on every sample. + if (config.blockSize < fsBlockSize) { + App.msg("Direct I/O disabled: block size (" + + (config.blockSize / 1024) + " KB) is smaller than " + + "the filesystem block size (" + + (fsBlockSize / 1024) + " KB)."); + config.setDirectIoEnabled(false); + App.directEnable = false; + Gui.refreshChartBadges(); + return userAlign; + } + + if (fsBlockSize > userAlign) { + App.msg("Direct I/O: adjusting alignment from " + + userAlign + " to " + fsBlockSize + + " (filesystem block size)"); + // Update config so the benchmark record reflects + // the actual alignment used during this run. + for (App.SectorAlignment sa : App.SectorAlignment.values()) { + if (sa.bytes == fsBlockSize) { + config.setSectorAlignment(sa); + // Also update the live global so the GUI badge + // shows the effective alignment during this run. + App.sectorAlignment = sa; + Gui.refreshChartBadges(); + break; + } + } + return fsBlockSize; + } + } catch (Exception e) { + logger.log(Level.WARNING, "Could not query filesystem block size", e); + } + return userAlign; + } } diff --git a/jdm-core/src/main/java/jdiskmark/BenchmarkSystemInfo.java b/jdm-core/src/main/java/jdiskmark/BenchmarkSystemInfo.java index 45a90e04..9e76ef0e 100644 --- a/jdm-core/src/main/java/jdiskmark/BenchmarkSystemInfo.java +++ b/jdm-core/src/main/java/jdiskmark/BenchmarkSystemInfo.java @@ -22,6 +22,9 @@ public class BenchmarkSystemInfo implements Serializable { @Column String locationDir; public String getLocationDir() { return locationDir; } + @Column + String osLabel; + public String getOsLabel() { return osLabel; } public BenchmarkSystemInfo() {} } diff --git a/jdm-core/src/main/java/jdiskmark/BenchmarkWorker.java b/jdm-core/src/main/java/jdiskmark/BenchmarkWorker.java index aeb914bf..0d7c4740 100644 --- a/jdm-core/src/main/java/jdiskmark/BenchmarkWorker.java +++ b/jdm-core/src/main/java/jdiskmark/BenchmarkWorker.java @@ -190,9 +190,17 @@ protected Benchmark doInBackground() throws Exception { if (App.autoSave) { EntityManager em = EM.getEntityManager(); - em.getTransaction().begin(); - em.persist(benchmark); - em.getTransaction().commit(); + try { + em.getTransaction().begin(); + em.persist(benchmark); + em.getTransaction().commit(); + } catch (Exception e) { + Logger.getLogger(BenchmarkWorker.class.getName()) + .log(Level.SEVERE, "Failed to save benchmark to DB", e); + if (em.getTransaction().isActive()) { + em.getTransaction().rollback(); + } + } } // #67 upload to community portal (in progress) // Run asynchronously so the benchmark result returns to the UI immediately diff --git a/jdm-core/src/main/java/jdiskmark/DriveAccessChecker.java b/jdm-core/src/main/java/jdiskmark/DriveAccessChecker.java deleted file mode 100644 index 23a929a1..00000000 --- a/jdm-core/src/main/java/jdiskmark/DriveAccessChecker.java +++ /dev/null @@ -1,62 +0,0 @@ -package jdiskmark; - -import javax.swing.JOptionPane; -import java.io.File; -import java.util.logging.Level; -import java.util.logging.Logger; - -public class DriveAccessChecker { - - /** - * Validates a target directory for benchmarking by checking if "jdm-data" - * folder is missing and checking read/write permissions. - * @param targetLocation location to validate - * @param showPopup use dialog popup - * @return true if valid - */ - public static boolean validateTargetDirectory(File targetLocation, boolean showPopup) { - - if (targetLocation == null) { - String msg = "Target location is null"; - Logger.getLogger(DriveAccessChecker.class.getName()).log(Level.SEVERE, msg); - - if (showPopup) { - JOptionPane.showMessageDialog( - Gui.mainFrame, msg, - "Target location access error", - JOptionPane.ERROR_MESSAGE); - } - return false; - } - - File dataDir = new File(targetLocation, App.DATADIRNAME); - - if (!dataDir.exists() && !dataDir.mkdirs()) { - String msg = "Cannot create data directory at: " + dataDir + - "\nCheck permissions and try again."; - Logger.getLogger(DriveAccessChecker.class.getName()).log(Level.SEVERE, msg); - if (showPopup) { - JOptionPane.showMessageDialog(Gui.mainFrame, msg, - "Target location access error", JOptionPane.ERROR_MESSAGE); - } - return false; - } - - if (!dataDir.canRead() || !dataDir.canWrite()) { - - String msg = """ - Target location does not allow drive access. - Read Permission : %b - Write Permission : %b - """.formatted(dataDir.canRead(), dataDir.canWrite()); - - Logger.getLogger(DriveAccessChecker.class.getName()).log(Level.SEVERE, msg); - if (showPopup) { - JOptionPane.showMessageDialog(Gui.mainFrame, msg, - "Target location access error", JOptionPane.ERROR_MESSAGE); - } - return false; - } - return true; - } -} diff --git a/jdm-core/src/main/java/jdiskmark/DriveChecker.java b/jdm-core/src/main/java/jdiskmark/DriveChecker.java new file mode 100644 index 00000000..39cd99bb --- /dev/null +++ b/jdm-core/src/main/java/jdiskmark/DriveChecker.java @@ -0,0 +1,118 @@ +package jdiskmark; + +import javax.swing.JOptionPane; +import java.io.File; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class DriveChecker { + + private static final Logger LOG = Logger.getLogger(DriveChecker.class.getName()); + + private static final double SPACE_MARGIN = 1.10; + private static final long MIN_MARGIN_BYTES = 10L * 1024 * 1024; // 10 MB + + /** + * Validates a target directory for benchmarking by checking if "jdm-data" + * folder is missing and checking read/write permissions. + * @param targetLocation location to validate + * @param showPopup use dialog popup + * @return true if valid + */ + public static boolean validateTargetDirectory(File targetLocation, boolean showPopup) { + + if (targetLocation == null) { + String msg = "Target location is null"; + LOG.log(Level.SEVERE, msg); + + if (showPopup) { + JOptionPane.showMessageDialog( + Gui.mainFrame, msg, + "Target location access error", + JOptionPane.ERROR_MESSAGE); + } + return false; + } + + File dataDir = new File(targetLocation, App.DATADIRNAME); + + if (!dataDir.exists() && !dataDir.mkdirs()) { + String msg = "Cannot create data directory at: " + dataDir + + "\nCheck permissions and try again."; + LOG.log(Level.SEVERE, msg); + if (showPopup) { + JOptionPane.showMessageDialog(Gui.mainFrame, msg, + "Target location access error", JOptionPane.ERROR_MESSAGE); + } + return false; + } + + if (!dataDir.canRead() || !dataDir.canWrite()) { + + String msg = """ + Target location does not allow drive access. + Read Permission : %b + Write Permission : %b + """.formatted(dataDir.canRead(), dataDir.canWrite()); + + LOG.log(Level.SEVERE, msg); + if (showPopup) { + JOptionPane.showMessageDialog(Gui.mainFrame, msg, + "Target location access error", JOptionPane.ERROR_MESSAGE); + } + return false; + } + return true; + } + + /** + * Checks whether the target location has enough usable disk space for the + * configured benchmark. Applies a 10% safety margin (minimum 10 MB) to + * account for filesystem metadata, journaling, and rounding. + * + * @param locationDir the benchmark target directory + * @return {@code true} if there is enough space, {@code false} otherwise + */ + public static boolean checkDiskSpace(File locationDir) { + long requiredBytes = App.multiFile + ? (long) App.blockSizeKb * App.numOfBlocks * App.numOfSamples * App.KILOBYTE + : (long) App.blockSizeKb * App.numOfBlocks * App.KILOBYTE; + + long margin = Math.max((long) (requiredBytes * (SPACE_MARGIN - 1.0)), + MIN_MARGIN_BYTES); + long requiredWithMargin = requiredBytes + margin; + + long usableSpace = locationDir.getUsableSpace(); + + if (usableSpace >= requiredWithMargin) { + return true; + } + + String msg = String.format( + "Not enough disk space to run benchmark.%n" + + "Required : %s (+ 10%% margin)%n" + + "Available: %s on %s", + formatBytes(requiredWithMargin), + formatBytes(usableSpace), + locationDir.getAbsolutePath()); + + LOG.log(Level.WARNING, msg); + App.err(msg); + + if (App.mode == App.Mode.GUI && Gui.mainFrame != null) { + JOptionPane.showMessageDialog(Gui.mainFrame, msg, + "Insufficient disk space", JOptionPane.WARNING_MESSAGE); + } + return false; + } + + private static String formatBytes(long bytes) { + if (bytes >= App.GIGABYTE) { + return String.format("%.1f GB", bytes / (double) App.GIGABYTE); + } else if (bytes >= App.MEGABYTE) { + return String.format("%.1f MB", bytes / (double) App.MEGABYTE); + } else { + return String.format("%.1f KB", bytes / (double) App.KILOBYTE); + } + } +} diff --git a/jdm-core/src/main/java/jdiskmark/DrivePanel.java b/jdm-core/src/main/java/jdiskmark/DrivePanel.java index 975154c3..3e0efc4b 100644 --- a/jdm-core/src/main/java/jdiskmark/DrivePanel.java +++ b/jdm-core/src/main/java/jdiskmark/DrivePanel.java @@ -10,6 +10,7 @@ import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; import javax.swing.BorderFactory; @@ -33,7 +34,7 @@ * *
  * ┌──────────────────────────────────────────────────────┐
- * │  Drive:  [combo box ──────────────────────────────]  │  ← NORTH
+ * │  Drive:  [combo box ─────────────────────────] [⟳]  │  ← NORTH
  * ├──────────────────────────────────────────────────────┤
  * │   Summary                                           │
  * │   Model: …                                          │
@@ -104,7 +105,8 @@ private static class DriveEntry {
     private JTable            allDrivesTable;
 
     private static final String[] ALL_DRIVES_COLUMNS = {
-        "Drive / Mount", "Model", "Total (GB)", "Used (GB)", "Free (GB)", "Usage"
+        "Drive / Mount", "Model", "Interface", "File System",
+        "Total (GB)", "Used (GB)", "Free (GB)", "Usage"
     };
 
     private static final Logger LOG = Logger.getLogger(DrivePanel.class.getName());
@@ -133,6 +135,12 @@ public DrivePanel() {
         driveCombo.setMaximumRowCount(12);
         populateCombo();
         selectorRow.add(driveCombo, BorderLayout.CENTER);
+
+        JButton refreshButton = new JButton("\u27F3");
+        refreshButton.setToolTipText("Refresh drive list");
+        refreshButton.setMargin(new Insets(2, 6, 2, 6));
+        refreshButton.addActionListener(e -> refresh());
+        selectorRow.add(refreshButton, BorderLayout.EAST);
         northPanel.add(selectorRow, BorderLayout.NORTH);
 
         // Test Directory row — directly below the drive selector
@@ -253,8 +261,8 @@ public JPanel buildAllDrivesPanel() {
             @Override public boolean isCellEditable(int r, int c) { return false; }
             @Override public Class getColumnClass(int col) {
                 return switch (col) {
-                    case 2, 3, 4 -> Double.class;
-                    case 5       -> Integer.class;  // Usage % for progress bar
+                    case 4, 5, 6 -> Double.class;
+                    case 7       -> Integer.class;  // Usage % for progress bar
                     default      -> String.class;
                 };
             }
@@ -272,16 +280,18 @@ public JPanel buildAllDrivesPanel() {
 
         DefaultTableCellRenderer centerR = new DefaultTableCellRenderer();
         centerR.setHorizontalAlignment(SwingConstants.CENTER);
-        for (int i = 2; i <= 4; i++) {
+        for (int i = 2; i <= 6; i++) {
             allDrivesTable.getColumnModel().getColumn(i).setCellRenderer(centerR);
         }
         // Usage column — render as a progress bar with percentage text
-        allDrivesTable.getColumnModel().getColumn(5).setCellRenderer(new ProgressBarRenderer());
+        allDrivesTable.getColumnModel().getColumn(7).setCellRenderer(new ProgressBarRenderer());
 
         allDrivesTable.getColumnModel().getColumn(0).setPreferredWidth(120);
         allDrivesTable.getColumnModel().getColumn(1).setPreferredWidth(200);
-        for (int i = 2; i <= 4; i++) allDrivesTable.getColumnModel().getColumn(i).setPreferredWidth(75);
-        allDrivesTable.getColumnModel().getColumn(5).setPreferredWidth(100);
+        allDrivesTable.getColumnModel().getColumn(2).setPreferredWidth(75);
+        allDrivesTable.getColumnModel().getColumn(3).setPreferredWidth(80);
+        for (int i = 4; i <= 6; i++) allDrivesTable.getColumnModel().getColumn(i).setPreferredWidth(75);
+        allDrivesTable.getColumnModel().getColumn(7).setPreferredWidth(100);
 
         JPanel panel = new JPanel(new BorderLayout());
         panel.add(new JScrollPane(allDrivesTable), BorderLayout.CENTER);
@@ -334,7 +344,7 @@ private void populateCombo() {
         suppressComboEvents = true;
         try {
             driveCombo.removeAllItems();
-            for (File root : File.listRoots()) {
+            for (File root : listDriveRoots()) {
                 if (root.getTotalSpace() == 0) continue;
                 DriveEntry entry = new DriveEntry(root);
                 driveCombo.addItem(entry);
@@ -363,35 +373,47 @@ protected void done() {
 
     private void syncComboToLocation() {
         if (App.locationDir == null) return;
-        java.nio.file.Path locRoot = App.locationDir.toPath().getRoot();
-        if (locRoot == null) return;
-
+        String locPath = App.locationDir.getAbsolutePath();
+
+        // Find the combo entry whose mount point is the longest prefix of
+        // the current location. On Windows this still matches by drive root
+        // (e.g. "C:\"); on Linux it correctly picks /run/media/user/Drive
+        // over / when both are present.
+        int bestIndex  = -1;
+        int bestLength = -1;
         for (int i = 0; i < driveCombo.getItemCount(); i++) {
             DriveEntry entry = driveCombo.getItemAt(i);
-            if (entry.root.toPath().equals(locRoot)
-                    || entry.root.getAbsolutePath().equalsIgnoreCase(locRoot.toString())) {
-                suppressComboEvents = true;
-                driveCombo.setSelectedIndex(i);
-                suppressComboEvents = false;
-                return;
+            String mountPath = entry.root.getAbsolutePath();
+            String mountPrefix = mountPath.endsWith(File.separator)
+                    ? mountPath
+                    : mountPath + File.separator;
+            if ((locPath.equals(mountPath) || locPath.startsWith(mountPrefix))
+                    && mountPath.length() > bestLength) {
+                bestIndex  = i;
+                bestLength = mountPath.length();
             }
         }
+        if (bestIndex >= 0) {
+            suppressComboEvents = true;
+            driveCombo.setSelectedIndex(bestIndex);
+            suppressComboEvents = false;
+        }
     }
 
     private void applySelectedDrive() {
         DriveEntry entry = (DriveEntry) driveCombo.getSelectedItem();
         if (entry == null) return;
 
+        // Always refresh the summary panel to show info for the selected
+        // drive, even when it is read-only or otherwise not usable.
+        refreshDriveInfo(entry.root);
+
         File resolved = resolveLocationForRoot(entry.root);
         if (resolved == null) {
-            accessLabel.setText("Access: ✗  No writable location found on this drive");
-            accessLabel.setForeground(java.awt.Color.RED);
             return;
         }
 
-        if (!DriveAccessChecker.validateTargetDirectory(resolved, true)) {
-            accessLabel.setText("Access: ✗  Cannot read/write test directory");
-            accessLabel.setForeground(java.awt.Color.RED);
+        if (!DriveChecker.validateTargetDirectory(resolved, true)) {
             return;
         }
 
@@ -424,7 +446,7 @@ private void refreshAllDrivesTable() {
         if (allDrivesTableModel == null) return;
         allDrivesTableModel.setRowCount(0);
 
-        for (File root : File.listRoots()) {
+        for (File root : listDriveRoots()) {
             long total = root.getTotalSpace();
             long free  = root.getFreeSpace();
             long used  = total - free;
@@ -435,38 +457,53 @@ private void refreshAllDrivesTable() {
             double freeGb  = free  / (double) App.GIGABYTE;
             double pct     = 100.0 * used / total;
 
-            // Add row with placeholder model — filled in asynchronously
+            // Add row with placeholders — filled in asynchronously
             int rowIndex = allDrivesTableModel.getRowCount();
             allDrivesTableModel.addRow(new Object[]{
                 root.getAbsolutePath(),
                 "loading…",
+                "loading…",
+                "loading…",
                 Math.round(totalGb * 10.0) / 10.0,
                 Math.round(usedGb  * 10.0) / 10.0,
                 Math.round(freeGb  * 10.0) / 10.0,
                 (int) Math.round(pct)
             });
 
-            // Fetch model in background
+            // Fetch model, interface, and filesystem in background
             final int row = rowIndex;
             final File driveRoot = root;
-            new SwingWorker() {
+            new SwingWorker() {
                 @Override
-                protected String doInBackground() {
-                    return Util.getDriveModel(driveRoot);
+                protected String[] doInBackground() {
+                    String model = Util.getDriveModel(driveRoot);
+                    String busType = Util.getBusType(driveRoot.toPath());
+                    String busDisplay = busType;
+                    if ("USB".equalsIgnoreCase(busType)) {
+                        String usbVer = Util.getUsbVersion(driveRoot.toPath());
+                        if (usbVer != null) busDisplay = busType + " " + usbVer;
+                    }
+                    String filesystem = Util.getFilesystem(driveRoot.toPath());
+                    return new String[]{ model, busDisplay, filesystem };
                 }
                 @Override
                 protected void done() {
                     try {
-                        String model = get();
+                        String[] r = get();
                         if (row < allDrivesTableModel.getRowCount()) {
                             allDrivesTableModel.setValueAt(
-                                    (model != null && !model.isBlank()) ? model : "—",
-                                    row, 1);
+                                    (r[0] != null && !r[0].isBlank()) ? r[0] : "—", row, 1);
+                            allDrivesTableModel.setValueAt(
+                                    (r[1] != null && !r[1].isBlank()) ? r[1] : "—", row, 2);
+                            allDrivesTableModel.setValueAt(
+                                    (r[2] != null && !r[2].isBlank()) ? r[2] : "—", row, 3);
                         }
                     } catch (Exception ex) {
-                        LOG.log(Level.WARNING, "drive model lookup failed for " + driveRoot, ex);
+                        LOG.log(Level.WARNING, "drive attribute lookup failed for " + driveRoot, ex);
                         if (row < allDrivesTableModel.getRowCount()) {
                             allDrivesTableModel.setValueAt("—", row, 1);
+                            allDrivesTableModel.setValueAt("—", row, 2);
+                            allDrivesTableModel.setValueAt("—", row, 3);
                         }
                     }
                 }
@@ -474,13 +511,30 @@ protected void done() {
         }
     }
 
+    /**
+     * Returns drive roots appropriate for the current OS. On Linux, reads
+     * {@code /proc/mounts} via {@link UtilOs#getMountedDrivesLinux()} to
+     * discover all mounted drives; on other platforms delegates to
+     * {@link File#listRoots()}.
+     */
+    private static List listDriveRoots() {
+        if (App.isLinux()) {
+            return UtilOs.getMountedDrivesLinux();
+        }
+        return List.of(File.listRoots());
+    }
+
     private void refreshDriveInfo() {
-        if (App.locationDir == null) return;
+        refreshDriveInfo(App.locationDir);
+    }
+
+    private void refreshDriveInfo(File dir) {
+        if (dir == null) return;
 
         // Update path field immediately on EDT
         String testPath = (App.dataDir != null)
                 ? App.dataDir.getAbsolutePath()
-                : App.locationDir.getAbsolutePath() + File.separator + App.DATADIRNAME;
+                : dir.getAbsolutePath() + File.separator + App.DATADIRNAME;
         pathField.setText(testPath);
 
         // Reset info labels while loading
@@ -494,8 +548,6 @@ private void refreshDriveInfo() {
         usageBar.setValue(0);
         usageBar.setString("…");
 
-        final File dir = App.locationDir;
-
         new SwingWorker() {
             @Override
             protected String[] doInBackground() {
@@ -511,6 +563,11 @@ protected String[] doInBackground() {
                 // Drive attributes — null on unsupported OS
                 String filesystem  = Util.getFilesystem(dir.toPath());
                 String busType     = Util.getBusType(dir.toPath());
+                String busDisplay  = busType;
+                if ("USB".equalsIgnoreCase(busType)) {
+                    String usbVer = Util.getUsbVersion(dir.toPath());
+                    if (usbVer != null) busDisplay = busType + " " + usbVer;
+                }
                 String sectorSize  = Util.getSectorSize(dir.toPath());
                 return new String[]{
                     model, partition,
@@ -518,7 +575,7 @@ protected String[] doInBackground() {
                     String.valueOf(usage.percentUsed),
                     dir.canRead()  ? "✓" : "✗",
                     dir.canWrite() ? "✓" : "✗",
-                    filesystem, busType, sectorSize
+                    filesystem, busDisplay, sectorSize
                 };
             }
 
diff --git a/jdm-core/src/main/java/jdiskmark/Gui.java b/jdm-core/src/main/java/jdiskmark/Gui.java
index 966a06b0..40a72243 100644
--- a/jdm-core/src/main/java/jdiskmark/Gui.java
+++ b/jdm-core/src/main/java/jdiskmark/Gui.java
@@ -521,7 +521,7 @@ public static void showAboutDialog() {
         String html = ""
                 + "" + App.APP_NAME + " " + App.VERSION + "
" + "JVM: " + App.jdk + "
" - + "OS:  " + App.os + "

" + + "OS:  " + App.osLabel + "

" + "" + "FlatLaf " + App.buildProp("lib.flatlaf") + " · JFreeChart " + App.buildProp("lib.jfreechart") @@ -1318,6 +1318,20 @@ static public void runSmart() { App.msg("smartPanel and locationDir must first be initialized"); return; } + + // USB-attached drives commonly fail SMART queries in the current Linux + // implementation. Detect early so we can give the user a clear + // message without launching a privileged shell and waiting for smartctl to fail. + if (App.isLinux()) { + String busType = UtilOs.getBusTypeLinux(App.locationDir.toPath()); + if ("USB".equalsIgnoreCase(busType)) { + smartPanel.clear(); + smartPanel.setStatus( + "SMART diagnostics are not available for USB-attached drives."); + return; + } + } + // A live query is starting — we are no longer viewing a stored snapshot. viewingSnapshot = false; final File locDir = App.locationDir; diff --git a/jdm-core/src/main/java/jdiskmark/Sample.java b/jdm-core/src/main/java/jdiskmark/Sample.java index 4bc052f9..3eb2496d 100644 --- a/jdm-core/src/main/java/jdiskmark/Sample.java +++ b/jdm-core/src/main/java/jdiskmark/Sample.java @@ -199,12 +199,7 @@ public void measureReadLegacy(long blockSize, int numOfBlocks, byte[] blockArr, public void measureWrite(long blockSize, int numOfBlocks, BenchmarkRunner bRunner) { long totalBytesWritten = 0; - long byteAlignment = bRunner.config.sectorAlignment.bytes; - if (byteAlignment <= 0) { - // if not selected use default layout alignment - MemoryLayout layout = MemoryLayout.sequenceLayout(blockSize, ValueLayout.JAVA_BYTE); - byteAlignment = layout.byteAlignment(); - } + long byteAlignment = bRunner.effectiveAlignment; File testFile = getTestFile(bRunner); long startTime = System.nanoTime(); @@ -239,20 +234,37 @@ public void measureWrite(long blockSize, int numOfBlocks, BenchmarkRunner bRunne } } - try (FileChannel fc = initialFc; Arena arena = Arena.ofConfined()) { - MemorySegment segment = arena.allocate(blockSize, byteAlignment); - for (int b = 0; b < numOfBlocks; b++) { - if (bRunner.listener.isCancelled()) break; - long blockIndex = (bRunner.config.blockOrder == RANDOM) ? - Util.randInt(0, numOfBlocks - 1) : b; - long byteOffset = blockIndex * blockSize; + boolean directRetried = false; + retry: + while (true) { + FileChannel retryFc = directRetried + ? openWithoutDirect(testFile, options) + : initialFc; + if (retryFc == null) break; + try (FileChannel fc = retryFc; Arena arena = Arena.ofConfined()) { + MemorySegment segment = arena.allocate(blockSize, byteAlignment); + totalBytesWritten = 0; + for (int b = 0; b < numOfBlocks; b++) { + if (bRunner.listener.isCancelled()) break; + long blockIndex = (bRunner.config.blockOrder == RANDOM) ? + Util.randInt(0, numOfBlocks - 1) : b; + long byteOffset = blockIndex * blockSize; - int written = fc.write(segment.asByteBuffer(), byteOffset); - totalBytesWritten += written; - bRunner.updateWriteProgress(); + int written = fc.write(segment.asByteBuffer(), byteOffset); + totalBytesWritten += written; + bRunner.updateWriteProgress(); + } + break retry; + } catch (IOException e) { + if (!directRetried && App.directEnable) { + App.err("Direct I/O write failed: " + e.getMessage() + + ". Retrying with buffered I/O."); + directRetried = true; + continue retry; + } + Logger.getLogger(Sample.class.getName()).log(Level.SEVERE, null, e); + break retry; } - } catch (IOException e) { - Logger.getLogger(Sample.class.getName()).log(Level.SEVERE, null, e); } long elapsedTimeNs = System.nanoTime() - startTime; accessTimeMs = (elapsedTimeNs / 1_000_000f) / (float) numOfBlocks; @@ -261,11 +273,7 @@ public void measureWrite(long blockSize, int numOfBlocks, BenchmarkRunner bRunne } public void prepareRead(long blockSize, int numOfBlocks, BenchmarkRunner bRunner) { - long byteAlignment = bRunner.config.sectorAlignment.bytes; - if (byteAlignment <= 0) { - MemoryLayout layout = MemoryLayout.sequenceLayout(blockSize, ValueLayout.JAVA_BYTE); - byteAlignment = layout.byteAlignment(); - } + long byteAlignment = bRunner.effectiveAlignment; File testFile = getTestFile(bRunner); Set options = new HashSet<>(); @@ -309,12 +317,7 @@ public void measureRead(long blockSize, int numOfBlocks, BenchmarkRunner bRunner long totalBytesRead = 0; File testFile = getTestFile(bRunner); long startTime = System.nanoTime(); - long byteAlignment = bRunner.config.sectorAlignment.bytes; - if (byteAlignment <= 0) { - // if not selected use default layout alignment - MemoryLayout layout = MemoryLayout.sequenceLayout(blockSize, ValueLayout.JAVA_BYTE); - byteAlignment = layout.byteAlignment(); - } + long byteAlignment = bRunner.effectiveAlignment; Set options = new HashSet<>(); options.add(StandardOpenOption.READ); @@ -344,22 +347,53 @@ public void measureRead(long blockSize, int numOfBlocks, BenchmarkRunner bRunner } } - try (FileChannel fc = initialFc; Arena arena = Arena.ofConfined()) { - MemorySegment segment = arena.allocate(blockSize, byteAlignment); - for (int b = 0; b < numOfBlocks; b++) { - if (bRunner.listener.isCancelled()) break; - long blockIndex = (bRunner.config.blockOrder == RANDOM) ? Util.randInt(0, (int)(numOfBlocks - 1)) : b; - long byteOffset = blockIndex * blockSize; - int read = fc.read(segment.asByteBuffer(), byteOffset); - totalBytesRead += read; - bRunner.updateReadProgress(); + boolean directRetried = false; + retry: + while (true) { + FileChannel retryFc = directRetried + ? openWithoutDirect(testFile, options) + : initialFc; + if (retryFc == null) break; + try (FileChannel fc = retryFc; Arena arena = Arena.ofConfined()) { + MemorySegment segment = arena.allocate(blockSize, byteAlignment); + totalBytesRead = 0; + for (int b = 0; b < numOfBlocks; b++) { + if (bRunner.listener.isCancelled()) break; + long blockIndex = (bRunner.config.blockOrder == RANDOM) ? Util.randInt(0, (int)(numOfBlocks - 1)) : b; + long byteOffset = blockIndex * blockSize; + int read = fc.read(segment.asByteBuffer(), byteOffset); + totalBytesRead += read; + bRunner.updateReadProgress(); + } + break retry; + } catch (IOException e) { + if (!directRetried && App.directEnable) { + App.err("Direct I/O read failed: " + e.getMessage() + + ". Retrying with buffered I/O."); + directRetried = true; + continue retry; + } + Logger.getLogger(Sample.class.getName()).log(Level.SEVERE, null, e); + break retry; } - } catch (IOException ex) { - Logger.getLogger(Sample.class.getName()).log(Level.SEVERE, null, ex); } long elapsedTimeNs = System.nanoTime() - startTime; accessTimeMs = (elapsedTimeNs / 1_000_000f) / (float) numOfBlocks; double sec = (double) elapsedTimeNs / 1_000_000_000d; bwMbSec = ((double) totalBytesRead / (double) MEGABYTE) / sec; } + + private static FileChannel openWithoutDirect(File testFile, + Set originalOptions) { + Set fallback = new HashSet<>(originalOptions); + fallback.remove(ExtendedOpenOption.DIRECT); + try { + return FileChannel.open(testFile.toPath(), fallback); + } catch (IOException e) { + Logger.getLogger(Sample.class.getName()).log(Level.SEVERE, + "Buffered I/O fallback failed", e); + App.err("Failed to open FileChannel for buffered I/O fallback"); + return null; + } + } } \ No newline at end of file diff --git a/jdm-core/src/main/java/jdiskmark/SelectDriveFrame.java b/jdm-core/src/main/java/jdiskmark/SelectDriveFrame.java index 21de1600..4f24d35c 100644 --- a/jdm-core/src/main/java/jdiskmark/SelectDriveFrame.java +++ b/jdm-core/src/main/java/jdiskmark/SelectDriveFrame.java @@ -2,7 +2,7 @@ import java.io.File; -import static jdiskmark.DriveAccessChecker.validateTargetDirectory; +import static jdiskmark.DriveChecker.validateTargetDirectory; /** * diff --git a/jdm-core/src/main/java/jdiskmark/Util.java b/jdm-core/src/main/java/jdiskmark/Util.java index ede8e812..c466d33c 100644 --- a/jdm-core/src/main/java/jdiskmark/Util.java +++ b/jdm-core/src/main/java/jdiskmark/Util.java @@ -133,7 +133,7 @@ public static String getDriveModel(File dataDir) { // handle single physical drive if (deviceNames.size() == 1) { String devicePath = "/dev/" + deviceNames.getFirst(); - return UtilOs.getDeviceModelLinux(devicePath); + return UtilOs.getVendorModelLinux(devicePath); } // GH-3 handle multiple drives (LVM or RAID partitions) @@ -141,7 +141,7 @@ public static String getDriveModel(File dataDir) { StringBuilder sb = new StringBuilder(); for (String dName : deviceNames) { String devicePath = "/dev/" + dName; - deviceModel = UtilOs.getDeviceModelLinux(devicePath); + deviceModel = UtilOs.getVendorModelLinux(devicePath); if (sb.length() > 0) { sb.append(":"); } @@ -302,6 +302,18 @@ public static String getBusType(Path path) { return null; } + /** + * Returns the USB version string for the device at {@code path} + * (e.g. "3.0", "3.2 Gen 2"). Linux only; returns {@code null} + * on other platforms or when the device is not USB-attached. + */ + public static String getUsbVersion(Path path) { + if (App.isLinux()) { + return UtilOs.getUsbVersionLinux(path); + } + return null; + } + /** * Returns the sector size for the volume containing {@code path} * (e.g. "512 B", "512 B / 4096 B"). Windows and Linux supported. diff --git a/jdm-core/src/main/java/jdiskmark/UtilOs.java b/jdm-core/src/main/java/jdiskmark/UtilOs.java index 54663372..d126dfc0 100644 --- a/jdm-core/src/main/java/jdiskmark/UtilOs.java +++ b/jdm-core/src/main/java/jdiskmark/UtilOs.java @@ -263,6 +263,60 @@ public static DiskUsageInfo getCapacityWindows(String driveLetter) { return usageInfo; } + /** + * Returns mount points for real block-device-backed filesystems on Linux + * by reading {@code /proc/mounts}. Virtual filesystems (procfs, sysfs, + * tmpfs, etc.), snap loopback mounts, and boot partitions are excluded. + * + *

This replaces {@code File.listRoots()} for Linux drive enumeration, + * since {@code listRoots()} only returns {@code /} on Linux and never + * discovers additional mounted drives. + * + * @return list of mount-point directories; always includes {@code /} if + * it was discovered and never empty on a running Linux system + */ + static public List getMountedDrivesLinux() { + List mounts = new ArrayList<>(); + try { + List lines = java.nio.file.Files.readAllLines( + java.nio.file.Path.of("/proc/mounts")); + for (String line : lines) { + String[] parts = line.split("\\s+"); + if (parts.length < 3) continue; + + String device = parts[0]; + String mountPoint = parts[1].replace("\\040", " ") + .replace("\\011", "\t") + .replace("\\012", "\n") + .replace("\\134", "\\"); + + // Only real block devices + if (!device.startsWith("/dev/")) continue; + + // Exclude snap loopback mounts (Ubuntu) + if (mountPoint.startsWith("/snap/")) continue; + + // Exclude boot partitions + if (mountPoint.startsWith("/boot/") || mountPoint.equals("/boot")) continue; + + File mountDir = new File(mountPoint); + if (mountDir.getTotalSpace() == 0) continue; + + mounts.add(mountDir); + } + } catch (IOException e) { + LOGGER.log(Level.WARNING, "Failed to read /proc/mounts", e); + } + + // Guarantee root is always present + File rootDir = new File("/"); + if (mounts.stream().noneMatch(f -> f.getAbsolutePath().equals("/"))) { + mounts.addFirst(rootDir); + } + + return mounts; + } + /** * On Linux OS get the device path when given a file path. * eg. filePath = /home/james/Desktop/jdm-data @@ -361,6 +415,61 @@ static public String getDeviceModelLinux(String devicePath) { } return null; } + + /** + * On Linux OS use the lsblk command to get the combined vendor and model + * for a specific device (e.g. /dev/sda, /dev/sdb). + * + *

For NVMe drives, the MODEL column already includes the manufacturer + * (e.g. "SAMSUNG MZVLB512HBJQ-000L7"), so the vendor is not prepended. + * For USB drives, the VENDOR and MODEL columns are separate + * (e.g. VENDOR="Lexar", MODEL="USB Flash Drive"), so they are combined + * into "Lexar USB Flash Drive". + * + * @param devicePath path of the device (e.g. "/dev/sdb") + * @return the combined vendor and model string, or null if unavailable + */ + static public String getVendorModelLinux(String devicePath) { + String result = null; + try { + ProcessBuilder pb = new ProcessBuilder( + "lsblk", devicePath, "--nodeps", "--output", "VENDOR,MODEL"); + Map env = pb.environment(); + env.put("LC_ALL", "C"); + pb.redirectErrorStream(true); + Process process = pb.start(); + try (BufferedReader reader = new BufferedReader(new + InputStreamReader(process.getInputStream()))) { + String headerLine = reader.readLine(); + int modelOffset = headerLine != null ? + headerLine.indexOf("MODEL") : -1; + if (modelOffset >= 0) { + String dataLine = reader.readLine(); + if (dataLine != null && !dataLine.trim().isEmpty()) { + String vendor = dataLine.length() > modelOffset + ? dataLine.substring(0, modelOffset).trim() : + ""; + String model = dataLine.length() > modelOffset + ? dataLine.substring(modelOffset).trim() : + dataLine.trim(); + if (!vendor.isEmpty() && !model.toUpperCase() + .startsWith(vendor.toUpperCase())) { + result = vendor + " " + model; + } else if (!model.isEmpty()) { + result = model; + } + } + } + } + process.waitFor(5, java.util.concurrent.TimeUnit.SECONDS); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + LOGGER.log(Level.SEVERE, null, e); + } + return result; + } /** * On Linux OS use the lsblk command to get the disk size for a @@ -1218,8 +1327,22 @@ static String getFilesystemLinux(Path path) { static String getBusTypeLinux(Path path) { String partition = getPartitionFromFilePathLinux(path); if (partition == null || partition.isBlank()) return null; + // TRAN is only reported on the parent disk device, not on partitions. + // Try the partition first; if empty, resolve the parent device and retry. + String tran = lsblkTran(partition); + if (tran != null) return tran; + + List parents = getDeviceNamesFromPartitionLinux(partition); + if (!parents.isEmpty()) { + tran = lsblkTran("/dev/" + parents.getFirst()); + if (tran != null) return tran; + } + return null; + } + + private static String lsblkTran(String device) { try { - ProcessBuilder pb = new ProcessBuilder("lsblk", "-no", "TRAN", partition); + ProcessBuilder pb = new ProcessBuilder("lsblk", "-no", "TRAN", device); pb.environment().put("LC_ALL", "C"); pb.redirectErrorStream(true); Process process = pb.start(); @@ -1229,13 +1352,88 @@ static String getBusTypeLinux(Path path) { while ((line = reader.readLine()) != null) { String trimmed = line.trim(); if (!trimmed.isEmpty()) { - return trimmed.toUpperCase(); // e.g. "NVME", "SATA" + return trimmed.toUpperCase(); } } } process.waitFor(10, java.util.concurrent.TimeUnit.SECONDS); } catch (IOException | InterruptedException e) { - LOGGER.log(Level.WARNING, "lsblk TRAN failed for " + partition, e); + LOGGER.log(Level.WARNING, "lsblk TRAN failed for " + device, e); + } + return null; + } + + /** + * Detects the negotiated USB link speed for a block device by reading + * the {@code speed} file from its sysfs USB ancestor. Returns a + * human-readable USB version string (e.g. {@code "3.0"}), or + * {@code null} if the device is not USB-attached or detection fails. + * + * @param path path on the target filesystem + * @return USB version string or {@code null} + */ + static String getUsbVersionLinux(Path path) { + String partition = getPartitionFromFilePathLinux(path); + if (partition == null || partition.isBlank()) return null; + + List parents = getDeviceNamesFromPartitionLinux(partition); + String devName = parents.isEmpty() + ? partition.replace("/dev/", "") + : parents.getFirst().trim(); + + try { + java.nio.file.Path sysPath = java.nio.file.Path.of("/sys/block", devName); + if (!java.nio.file.Files.exists(sysPath)) return null; + java.nio.file.Path realPath = sysPath.toRealPath(); + + java.nio.file.Path current = realPath; + while (current != null && current.getNameCount() > 0) { + java.nio.file.Path speedFile = current.resolve("speed"); + if (java.nio.file.Files.isRegularFile(speedFile)) { + String speed = java.nio.file.Files.readString(speedFile).trim(); + return mapUsbSpeed(speed); + } + current = current.getParent(); + } + } catch (IOException e) { + LOGGER.log(Level.WARNING, "USB version detection failed for " + devName, e); + } + return null; + } + + private static String mapUsbSpeed(String speedMbps) { + return switch (speedMbps) { + case "1.5" -> "1.0"; + case "12" -> "1.1"; + case "480" -> "2.0"; + case "5000" -> "3.0"; + case "10000" -> "3.2 Gen 2"; + case "20000" -> "3.2 Gen 2x2"; + default -> null; + }; + } + + /** + * Returns the Linux distribution name by reading {@code PRETTY_NAME} + * from {@code /etc/os-release}. Returns {@code null} if the file is + * missing or the field is absent. + */ + static String getLinuxDistroName() { + try { + java.nio.file.Path osRelease = java.nio.file.Path.of("/etc/os-release"); + if (!java.nio.file.Files.isReadable(osRelease)) return null; + for (String line : java.nio.file.Files.readAllLines(osRelease)) { + if (line.startsWith("PRETTY_NAME=")) { + String value = line.substring("PRETTY_NAME=".length()); + if (value.length() >= 2 + && value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + return value.isBlank() ? null : value; + } + } + } catch (IOException e) { + LOGGER.log(Level.WARNING, "Failed to read /etc/os-release", e); } return null; }