diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index b8534928..c6e0c9db 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -4,6 +4,8 @@ requires com.fasterxml.jackson.databind; requires com.fasterxml.jackson.dataformat.xml; requires java.logging; + requires java.management; + requires jdk.management; requires javafx.base; requires javafx.controls; requires javafx.fxml; diff --git a/src/main/java/network/brightspots/rcv/ApplicationRestarter.java b/src/main/java/network/brightspots/rcv/ApplicationRestarter.java new file mode 100644 index 00000000..0dae8b0d --- /dev/null +++ b/src/main/java/network/brightspots/rcv/ApplicationRestarter.java @@ -0,0 +1,168 @@ +/* + * RCTab + * Copyright (c) 2017-2025 Bright Spots Developers. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +/* + * Purpose: Utility class for restarting the RCTab application with new JVM arguments. + * Design: Uses ProcessBuilder to launch a new instance with updated memory settings. + * Conditions: Always. + * Version history: see https://github.com/BrightSpots/rcv. + */ + +package network.brightspots.rcv; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import javafx.application.Platform; + +/** + * Utility class for restarting the RCTab application with new JVM arguments. + * Handles cross-platform restart mechanisms for Windows, macOS, and Linux. + */ +class ApplicationRestarter { + + /** + * Restart the application with specified max heap size. + * Launches a new process with the updated -Xmx parameter and exits the current instance. + * + * @param maxHeapMb maximum heap size in megabytes + * @return true if restart initiated successfully, false otherwise + */ + static boolean restartWithMemory(long maxHeapMb) { + try { + // Build command to restart application + List command = buildRestartCommand(maxHeapMb + "m"); + Logger.info("Restart command: %s", String.join(" ", command)); + + // Start new process + ProcessBuilder builder = new ProcessBuilder(command); + // Inherit the working directory from current process + builder.directory(new File(System.getProperty("user.dir"))); + // Redirect error stream to output for debugging + builder.redirectErrorStream(true); + + Process process = builder.start(); + Logger.info("New RCTab process started with PID " + process.pid()); + + // Give the new process a moment to start before we exit + Thread.sleep(500); + + // Exit current instance + Logger.info("Restarting RCTab with %d MB heap. Shutting down current instance...", + maxHeapMb); + Platform.exit(); + System.exit(0); + + return true; + } catch (IOException e) { + Logger.severe("Failed to restart application (IO error): %s", e.getMessage()); + return false; + } catch (InterruptedException e) { + Logger.severe("Restart interrupted: %s", e.getMessage()); + Thread.currentThread().interrupt(); + return false; + } catch (Exception e) { + Logger.severe("Failed to restart application: %s", e.getMessage()); + return false; + } + } + + /** + * Get path to java executable. + * Checks JAVA_HOME first, then uses 'java' from PATH as fallback. + * + * @return path to java executable + */ + private static String getJavaExecutablePath() { + String javaHome = System.getProperty("java.home"); + if (javaHome != null && !javaHome.isEmpty()) { + String javaBin = javaHome + File.separator + "bin" + File.separator + "java"; + if (isWindows()) { + javaBin += ".exe"; + } + File javaFile = new File(javaBin); + if (javaFile.exists()) { + return javaBin; + } + Logger.warning("Java executable not found at %s, falling back to 'java' from PATH", + javaBin); + } + // Fallback to PATH + return isWindows() ? "java.exe" : "java"; + } + + /** + * Build command line for restarting the application. + * Reconstructs: java -Xmx{mem}m --module-path {path} --module {module} + * + * @param maxHeapString maximum heap size string, e.g. "2048m" + * @return command as list of strings for ProcessBuilder + */ + public static List buildRestartCommand(String maxHeapString) { + String javaPath = getJavaExecutablePath(); + List command = new ArrayList<>(); + + // Java executable + command.add(javaPath); + + // Memory parameter + command.add("-Xmx" + maxHeapString); + + // Get module path from current runtime + String modulePath = System.getProperty("jdk.module.path"); + if (modulePath != null && !modulePath.isEmpty()) { + command.add("--module-path"); + command.add(modulePath); + Logger.info("Using module path: %s", modulePath); + } else { + Logger.warning("jdk.module.path not set. Restart may not work correctly."); + + // Try to use classpath as fallback + String classPath = System.getProperty("java.class.path"); + if (classPath != null && !classPath.isEmpty()) { + command.add("-cp"); + command.add(classPath); + Logger.info("Using classpath fallback: %s", classPath); + } + } + + // Add module and main class + if (modulePath != null && !modulePath.isEmpty()) { + command.add("--module"); + command.add("network.brightspots.rcv/network.brightspots.rcv.Main"); + } else { + // If no module path, use direct class launch + command.add("network.brightspots.rcv.Main"); + } + + return command; + } + + /** + * Check if running on Windows. + * + * @return true if OS is Windows + */ + private static boolean isWindows() { + String osName = System.getProperty("os.name"); + return osName != null && osName.toLowerCase().contains("win"); + } + + /** + * Check if running on macOS. + * + * @return true if OS is macOS + */ + @SuppressWarnings("unused") + private static boolean isMac() { + String osName = System.getProperty("os.name"); + return osName != null && osName.toLowerCase().contains("mac"); + } +} diff --git a/src/main/java/network/brightspots/rcv/GuiConfigController.java b/src/main/java/network/brightspots/rcv/GuiConfigController.java index 7cac988d..c3998773 100644 --- a/src/main/java/network/brightspots/rcv/GuiConfigController.java +++ b/src/main/java/network/brightspots/rcv/GuiConfigController.java @@ -591,6 +591,111 @@ public void menuItemConvertToCdfClicked() { } } + /** + * Action when "Increase RCTab Memory (Requires Restart)" menu item is clicked. + * Calculates optimal memory, shows confirmation dialog, and restarts the app with new -Xmx + * setting. + */ + public void menuItemIncreaseMemoryClicked() { + if (guiIsBusy) { + Logger.warning("Cannot change memory while an operation is in progress."); + showInfoDialog( + "Operation in Progress", + "Please wait for the current operation to complete before changing memory settings."); + return; + } + + // Calculate recommended memory + long recommendedMb = MemoryManager.calculateRecommendedMemoryMb(); + long currentMb = MemoryManager.getCurrentMaxHeapMb(); + + if (recommendedMb <= 0) { + String restartCommand = String.join(" ", + ApplicationRestarter.buildRestartCommand("12800m")); + showErrorDialog( + "Unable to Determine Memory", + "Could not determine your system's total RAM. Please restart RCTab manually " + + "with the -Xmx parameter to increase memory. Example: " + + restartCommand + + "\n\n"); + return; + } + + // Check if recommended is less than or equal to current + if (recommendedMb <= currentMb) { + showInfoDialog( + "Memory Already Optimized", + String.format( + "RCTab is already running with %s of memory.%n%n" + + "Recommended memory based on your system (80%% of total RAM): %s%n%n" + + "No restart needed.", + MemoryManager.formatMemorySize(currentMb), + MemoryManager.formatMemorySize(recommendedMb))); + return; + } + + // Show confirmation dialog + boolean confirmed = showMemoryIncreaseConfirmation(currentMb, recommendedMb); + if (!confirmed) { + return; + } + + // Check for unsaved changes + if (!checkForSaveAndContinue()) { + return; + } + + // Attempt restart + boolean success = ApplicationRestarter.restartWithMemory(recommendedMb); + if (!success) { + String restartCommand = String.join(" ", + ApplicationRestarter.buildRestartCommand(recommendedMb + "m")); + showErrorDialog( + "Restart Failed", + String.format( + "Unable to restart RCTab automatically. Please restart manually with: %s%n%n", + restartCommand)); + } + } + + private boolean showMemoryIncreaseConfirmation(long currentMb, long recommendedMb) { + ButtonType restartButton = new ButtonType("Restart Now", ButtonBar.ButtonData.YES); + ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); + + Alert alert = + new Alert( + AlertType.CONFIRMATION, + String.format( + "RCTab will restart with increased memory.%n%n" + + "Current memory: %s%n" + + "New memory: %s%n%n" + + "This will close RCTab and reopen it with the new memory settings.%n" + + "Any unsaved changes will be lost.", + MemoryManager.formatMemorySize(currentMb), + MemoryManager.formatMemorySize(recommendedMb)), + restartButton, + cancelButton); + alert.setTitle("Increase RCTab Memory"); + alert.setHeaderText("Confirm Application Restart"); + + Optional result = alert.showAndWait(); + return result.isPresent() && result.get() == restartButton; + } + + private void showErrorDialog(String title, String message) { + Alert alert = new Alert(AlertType.ERROR, message, ButtonType.OK); + alert.setTitle(title); + alert.setHeaderText(null); + alert.showAndWait(); + } + + private void showInfoDialog(String title, String message) { + Alert alert = new Alert(AlertType.INFORMATION, message, ButtonType.OK); + alert.setTitle(title); + alert.setHeaderText(null); + alert.showAndWait(); + } + private void sessionDone(GenericService service) { setGuiIsBusy(false); diff --git a/src/main/java/network/brightspots/rcv/Main.java b/src/main/java/network/brightspots/rcv/Main.java index b59cc509..3bde7d33 100644 --- a/src/main/java/network/brightspots/rcv/Main.java +++ b/src/main/java/network/brightspots/rcv/Main.java @@ -135,5 +135,6 @@ private static void logSystemInfo() { Logger.info( "Host system: %s version %s", System.getProperty("os.name"), System.getProperty("os.version")); + Logger.info("Max heap size: %d MB", Runtime.getRuntime().maxMemory() / (1024 * 1024)); } } diff --git a/src/main/java/network/brightspots/rcv/MemoryManager.java b/src/main/java/network/brightspots/rcv/MemoryManager.java new file mode 100644 index 00000000..8ac33be5 --- /dev/null +++ b/src/main/java/network/brightspots/rcv/MemoryManager.java @@ -0,0 +1,99 @@ +/* + * RCTab + * Copyright (c) 2017-2023 Bright Spots Developers. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +/* + * Purpose: Utility class for calculating system memory and determining optimal heap size. + * Design: Uses JMX to query system memory, applies 80% rule with 512MB rounding. + * Conditions: Always. + * Version history: see https://github.com/BrightSpots/rcv. + */ + +package network.brightspots.rcv; + +import com.sun.management.OperatingSystemMXBean; +import java.lang.management.ManagementFactory; + +/** + * Utility class for managing memory-related calculations and system queries. + * Provides methods to determine system RAM, current JVM heap size, and calculate + * optimal heap allocation based on available system resources. + */ +class MemoryManager { + + private static final long MEGABYTE = 1024L * 1024L; + private static final long CHUNK_SIZE_MB = 512L; + private static final double PERCENTAGE = 0.80; // 80% of total RAM + + /** + * Get total physical memory in MB. + * Uses com.sun.management.OperatingSystemMXBean for cross-platform compatibility. + * + * @return total physical RAM in MB, or -1 if cannot determine + */ + static long getTotalPhysicalMemoryMb() { + try { + OperatingSystemMXBean osBean = + (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean(); + long totalMemoryBytes = osBean.getTotalPhysicalMemorySize(); + if (totalMemoryBytes > 0) { + return totalMemoryBytes / MEGABYTE; + } + } catch (Exception e) { + Logger.warning("Unable to determine total physical memory: %s", e.getMessage()); + } + return -1; + } + + /** + * Get current max heap size the JVM is running with. + * + * @return current max heap in MB + */ + static long getCurrentMaxHeapMb() { + return Runtime.getRuntime().maxMemory() / MEGABYTE; + } + + /** + * Calculate recommended memory: 80% of RAM, rounded down to nearest 512MB. + * Examples: + * - 8GB (8192MB) RAM → 6144MB (6GB) + * - 16GB (16384MB) RAM → 12800MB (12.5GB) + * - 32GB (32768MB) RAM → 26112MB (25.5GB) + * + * @return recommended heap size in MB, or -1 if unable to determine + */ + static long calculateRecommendedMemoryMb() { + long totalMb = getTotalPhysicalMemoryMb(); + if (totalMb <= 0) { + Logger.warning("Cannot calculate recommended memory: total physical memory unknown"); + return -1; + } + + long eightyPercent = (long) (totalMb * PERCENTAGE); + // Round down to nearest 512MB chunk + long recommended = (eightyPercent / CHUNK_SIZE_MB) * CHUNK_SIZE_MB; + + Logger.info( + "Memory calculation: Total RAM = %d MB, 80%% = %d MB, Rounded = %d MB", + totalMb, eightyPercent, recommended); + + return recommended; + } + + /** + * Format memory size for display. + * + * @param memoryMb memory size in megabytes + * @return formatted string like "6144 MB (6.0 GB)" + */ + static String formatMemorySize(long memoryMb) { + double gb = memoryMb / 1024.0; + return String.format("%d MB (%.1f GB)", memoryMb, gb); + } +} diff --git a/src/main/resources/network/brightspots/rcv/GuiConfigLayout.fxml b/src/main/resources/network/brightspots/rcv/GuiConfigLayout.fxml index 6c6c1d0d..0e7dacf2 100644 --- a/src/main/resources/network/brightspots/rcv/GuiConfigLayout.fxml +++ b/src/main/resources/network/brightspots/rcv/GuiConfigLayout.fxml @@ -34,7 +34,9 @@ - + +