diff --git a/app/common/src/main/java/stirling/software/common/config/CleanupAsyncConfig.java b/app/common/src/main/java/stirling/software/common/config/CleanupAsyncConfig.java new file mode 100644 index 000000000..a82899bdc --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/config/CleanupAsyncConfig.java @@ -0,0 +1,83 @@ +package stirling.software.common.config; + +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionHandler; +import java.util.concurrent.ThreadPoolExecutor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import lombok.extern.slf4j.Slf4j; + +@Configuration +@EnableAsync +@Slf4j +public class CleanupAsyncConfig { + + @Bean(name = "cleanupExecutor") + public Executor cleanupExecutor() { + ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor(); + exec.setCorePoolSize(1); + exec.setMaxPoolSize(1); + exec.setQueueCapacity(100); + exec.setThreadNamePrefix("cleanup-"); + + // Set custom rejection handler to log when queue is full + exec.setRejectedExecutionHandler( + new RejectedExecutionHandler() { + private volatile long lastRejectionTime = 0; + private volatile int rejectionCount = 0; + + @Override + public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { + long currentTime = System.currentTimeMillis(); + rejectionCount++; + + // Rate-limit logging to avoid spam + if (currentTime - lastRejectionTime + > 60000) { // Log at most once per minute + log.warn( + "Cleanup task rejected #{} - queue full! Active: {}, Queue size: {}, Pool size: {}", + rejectionCount, + executor.getActiveCount(), + executor.getQueue().size(), + executor.getPoolSize()); + lastRejectionTime = currentTime; + } + + // Try to discard oldest task and add this one + if (executor.getQueue().poll() != null) { + log.debug("Discarded oldest queued cleanup task to make room"); + try { + executor.execute(r); + return; + } catch (Exception e) { + // If still rejected, fall back to caller-runs + } + } + + // Last resort: caller-runs with timeout protection + log.warn( + "Executing cleanup task #{} on scheduler thread as last resort", + rejectionCount); + long startTime = System.currentTimeMillis(); + try { + r.run(); + long duration = System.currentTimeMillis() - startTime; + if (duration > 30000) { // Warn if cleanup blocks scheduler for >30s + log.warn( + "Cleanup task on scheduler thread took {}ms - consider tuning", + duration); + } + } catch (Exception e) { + log.error("Cleanup task failed on scheduler thread", e); + } + } + }); + + exec.initialize(); + return exec; + } +} diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 91b328759..c7e4ee9fe 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -329,6 +329,8 @@ public class ApplicationProperties { private long cleanupIntervalMinutes = 30; private boolean startupCleanup = true; private boolean cleanupSystemTemp = false; + private int batchSize = 1000; + private long pauseBetweenBatchesMs = 50; public String getBaseTmpDir() { return baseTmpDir != null && !baseTmpDir.isEmpty() diff --git a/app/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java b/app/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java index df85a016b..dd505a106 100644 --- a/app/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java +++ b/app/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java @@ -5,14 +5,18 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; import java.util.function.Predicate; -import java.util.stream.Stream; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -46,6 +50,15 @@ public class TempFileCleanupService { // Maximum recursion depth for directory traversal private static final int MAX_RECURSION_DEPTH = 5; + // Maximum consecutive failures before aborting batch cleanup + private static final int MAX_CONSECUTIVE_FAILURES = 10; + + // Cleanup state management + private final AtomicBoolean cleanupRunning = new AtomicBoolean(false); + private final AtomicLong lastCleanupDuration = new AtomicLong(0); + private final AtomicLong cleanupCount = new AtomicLong(0); + private final AtomicLong lastCleanupTimestamp = new AtomicLong(0); + // File patterns that identify our temp files private static final Predicate IS_OUR_TEMP_FILE = fileName -> @@ -121,12 +134,78 @@ public class TempFileCleanupService { } /** Scheduled task to clean up old temporary files. Runs at the configured interval. */ + @Async("cleanupExecutor") @Scheduled( fixedDelayString = "#{applicationProperties.system.tempFileManagement.cleanupIntervalMinutes}", timeUnit = TimeUnit.MINUTES) - public void scheduledCleanup() { - log.info("Running scheduled temporary file cleanup"); + public CompletableFuture scheduledCleanup() { + // Check if cleanup is already running + if (!cleanupRunning.compareAndSet(false, true)) { + log.warn( + "Cleanup already in progress (running for {}ms), skipping this cycle", + System.currentTimeMillis() - lastCleanupTimestamp.get()); + return CompletableFuture.completedFuture(null); + } + + // Calculate timeout as 2x cleanup interval + long timeoutMinutes = + applicationProperties + .getSystem() + .getTempFileManagement() + .getCleanupIntervalMinutes() + * 2; + + CompletableFuture cleanupFuture = + CompletableFuture.runAsync( + () -> { + long startTime = System.currentTimeMillis(); + lastCleanupTimestamp.set(startTime); + long cleanupNumber = cleanupCount.incrementAndGet(); + + try { + log.info( + "Starting cleanup #{} with {}min timeout", + cleanupNumber, + timeoutMinutes); + doScheduledCleanup(); + + long duration = System.currentTimeMillis() - startTime; + lastCleanupDuration.set(duration); + log.info( + "Cleanup #{} completed successfully in {}ms", + cleanupNumber, + duration); + } catch (Exception e) { + long duration = System.currentTimeMillis() - startTime; + lastCleanupDuration.set(duration); + log.error( + "Cleanup #{} failed after {}ms", + cleanupNumber, + duration, + e); + } finally { + cleanupRunning.set(false); + } + }); + + return cleanupFuture + .orTimeout(timeoutMinutes, TimeUnit.MINUTES) + .exceptionally( + throwable -> { + if (throwable.getCause() instanceof TimeoutException) { + log.error( + "Cleanup #{} timed out after {}min - forcing cleanup state reset", + cleanupCount.get(), + timeoutMinutes); + cleanupRunning.set(false); + } + return null; + }); + } + + /** Internal method that performs the actual cleanup work */ + private void doScheduledCleanup() { long maxAgeMillis = tempFileManager.getMaxAgeMillis(); // Clean up registered temp files (managed by TempFileRegistry) @@ -310,44 +389,81 @@ public class TempFileCleanupService { } java.util.List subdirectories = new java.util.ArrayList<>(); + int batchSize = applicationProperties.getSystem().getTempFileManagement().getBatchSize(); + long pauseMs = + applicationProperties + .getSystem() + .getTempFileManagement() + .getPauseBetweenBatchesMs(); + int processed = 0; + int consecutiveFailures = 0; - try (Stream pathStream = Files.list(directory)) { - pathStream.forEach( - path -> { + try (java.nio.file.DirectoryStream stream = Files.newDirectoryStream(directory)) { + for (Path path : stream) { + try { + String fileName = path.getFileName().toString(); + + if (SHOULD_SKIP.test(fileName)) { + continue; + } + + if (Files.isDirectory(path)) { + subdirectories.add(path); + continue; + } + + if (registry.contains(path.toFile())) { + continue; + } + + if (shouldDeleteFile(path, fileName, containerMode, maxAgeMillis)) { try { - String fileName = path.getFileName().toString(); - - if (SHOULD_SKIP.test(fileName)) { - return; + Files.deleteIfExists(path); + onDeleteCallback.accept(path); + consecutiveFailures = 0; // Reset failure count on success + } catch (IOException e) { + consecutiveFailures++; + if (e.getMessage() != null + && e.getMessage().contains("being used by another process")) { + log.debug("File locked, skipping delete: {}", path); + } else { + log.warn("Failed to delete temp file: {}", path, e); } - if (Files.isDirectory(path)) { - subdirectories.add(path); - return; + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + log.error( + "Aborting directory cleanup after {} consecutive failures in: {}", + consecutiveFailures, + directory); + return; // Early exit from cleanup } - - if (registry.contains(path.toFile())) { - return; - } - - if (shouldDeleteFile(path, fileName, containerMode, maxAgeMillis)) { - try { - Files.deleteIfExists(path); - onDeleteCallback.accept(path); - } catch (IOException e) { - if (e.getMessage() != null - && e.getMessage() - .contains("being used by another process")) { - log.debug("File locked, skipping delete: {}", path); - } else { - log.warn("Failed to delete temp file: {}", path, e); - } - } - } - } catch (Exception e) { - log.warn("Error processing path: {}", path, e); } - }); + } + } catch (Exception e) { + consecutiveFailures++; + log.warn("Error processing path: {}", path, e); + + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + log.error( + "Aborting directory cleanup after {} consecutive failures in: {}", + consecutiveFailures, + directory); + return; // Early exit from cleanup + } + } + + processed++; + if (batchSize > 0 && processed >= batchSize) { + if (pauseMs > 0) { + try { + Thread.sleep(pauseMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + processed = 0; + } + } } for (Path subdirectory : subdirectories) { @@ -446,4 +562,41 @@ public class TempFileCleanupService { log.warn("Failed to clean up PDFBox cache file", e); } } + + /** Get cleanup status and metrics for monitoring */ + public String getCleanupStatus() { + if (cleanupRunning.get()) { + long runningTime = System.currentTimeMillis() - lastCleanupTimestamp.get(); + return String.format("Running for %dms (cleanup #%d)", runningTime, cleanupCount.get()); + } else { + long lastDuration = lastCleanupDuration.get(); + long lastTime = lastCleanupTimestamp.get(); + if (lastTime > 0) { + long timeSinceLastRun = System.currentTimeMillis() - lastTime; + return String.format( + "Last cleanup #%d: %dms duration, %dms ago", + cleanupCount.get(), lastDuration, timeSinceLastRun); + } else { + return "No cleanup runs yet"; + } + } + } + + /** Check if cleanup is currently running */ + public boolean isCleanupRunning() { + return cleanupRunning.get(); + } + + /** Get cleanup metrics */ + public CleanupMetrics getMetrics() { + return new CleanupMetrics( + cleanupCount.get(), + lastCleanupDuration.get(), + lastCleanupTimestamp.get(), + cleanupRunning.get()); + } + + /** Simple record for cleanup metrics */ + public record CleanupMetrics( + long totalRuns, long lastDurationMs, long lastRunTimestamp, boolean currentlyRunning) {} } diff --git a/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java b/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java index 34c471227..a63513cbb 100644 --- a/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java +++ b/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java @@ -15,7 +15,7 @@ import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; -import java.util.stream.Stream; +import java.nio.file.DirectoryStream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -142,20 +142,27 @@ public class TempFileCleanupServiceTest { // Use MockedStatic to mock Files operations try (MockedStatic mockedFiles = mockStatic(Files.class)) { - // Mock Files.list for each directory we'll process - mockedFiles.when(() -> Files.list(eq(systemTempDir))) - .thenReturn(Stream.of( - ourTempFile1, ourTempFile2, oldTempFile, sysTempFile1, - jettyFile1, jettyFile2, regularFile, emptyFile, nestedDir)); + // Mock Files.newDirectoryStream for each directory we'll process + mockedFiles.when(() -> Files.newDirectoryStream(eq(systemTempDir))) + .thenReturn(directoryStreamOf( + ourTempFile1, + ourTempFile2, + oldTempFile, + sysTempFile1, + jettyFile1, + jettyFile2, + regularFile, + emptyFile, + nestedDir)); - mockedFiles.when(() -> Files.list(eq(customTempDir))) - .thenReturn(Stream.of(ourTempFile3, ourTempFile4, sysTempFile2, sysTempFile3)); + mockedFiles.when(() -> Files.newDirectoryStream(eq(customTempDir))) + .thenReturn(directoryStreamOf(ourTempFile3, ourTempFile4, sysTempFile2, sysTempFile3)); - mockedFiles.when(() -> Files.list(eq(libreOfficeTempDir))) - .thenReturn(Stream.of(ourTempFile5)); + mockedFiles.when(() -> Files.newDirectoryStream(eq(libreOfficeTempDir))) + .thenReturn(directoryStreamOf(ourTempFile5)); - mockedFiles.when(() -> Files.list(eq(nestedDir))) - .thenReturn(Stream.of(nestedTempFile)); + mockedFiles.when(() -> Files.newDirectoryStream(eq(nestedDir))) + .thenReturn(directoryStreamOf(nestedTempFile)); // Configure Files.isDirectory for each path mockedFiles.when(() -> Files.isDirectory(eq(nestedDir))).thenReturn(true); @@ -175,7 +182,7 @@ public class TempFileCleanupServiceTest { return FileTime.fromMillis(System.currentTimeMillis() - 5000000); } // For empty.tmp file, return a timestamp older than 5 minutes (for empty file test) - else if (fileName.equals("empty.tmp")) { + else if ("empty.tmp".equals(fileName)) { return FileTime.fromMillis(System.currentTimeMillis() - 6 * 60 * 1000); } // For all other files, return a recent timestamp @@ -191,7 +198,7 @@ public class TempFileCleanupServiceTest { String fileName = path.getFileName().toString(); // Return 0 bytes for the empty file - if (fileName.equals("empty.tmp")) { + if ("empty.tmp".equals(fileName)) { return 0L; } // Return normal size for all other files @@ -251,9 +258,10 @@ public class TempFileCleanupServiceTest { // Use MockedStatic to mock Files operations try (MockedStatic mockedFiles = mockStatic(Files.class)) { - // Mock Files.list for systemTempDir - mockedFiles.when(() -> Files.list(eq(systemTempDir))) - .thenReturn(Stream.of(ourTempFile, sysTempFile, regularFile)); + // Mock Files.newDirectoryStream for systemTempDir + mockedFiles + .when(() -> Files.newDirectoryStream(eq(systemTempDir))) + .thenReturn(directoryStreamOf(ourTempFile, sysTempFile, regularFile)); // Configure Files.isDirectory mockedFiles.when(() -> Files.isDirectory(any(Path.class))).thenReturn(false); @@ -302,9 +310,10 @@ public class TempFileCleanupServiceTest { // Use MockedStatic to mock Files operations try (MockedStatic mockedFiles = mockStatic(Files.class)) { - // Mock Files.list for systemTempDir - mockedFiles.when(() -> Files.list(eq(systemTempDir))) - .thenReturn(Stream.of(emptyFile, recentEmptyFile)); + // Mock Files.newDirectoryStream for systemTempDir + mockedFiles + .when(() -> Files.newDirectoryStream(eq(systemTempDir))) + .thenReturn(directoryStreamOf(emptyFile, recentEmptyFile)); // Configure Files.isDirectory mockedFiles.when(() -> Files.isDirectory(any(Path.class))).thenReturn(false); @@ -318,7 +327,7 @@ public class TempFileCleanupServiceTest { Path path = invocation.getArgument(0); String fileName = path.getFileName().toString(); - if (fileName.equals("empty.tmp")) { + if ("empty.tmp".equals(fileName)) { // More than 5 minutes old return FileTime.fromMillis(System.currentTimeMillis() - 6 * 60 * 1000); } else { @@ -369,18 +378,22 @@ public class TempFileCleanupServiceTest { // Use MockedStatic to mock Files operations try (MockedStatic mockedFiles = mockStatic(Files.class)) { - // Mock Files.list for each directory - mockedFiles.when(() -> Files.list(eq(systemTempDir))) - .thenReturn(Stream.of(dir1)); + // Mock Files.newDirectoryStream for each directory + mockedFiles + .when(() -> Files.newDirectoryStream(eq(systemTempDir))) + .thenReturn(directoryStreamOf(dir1)); - mockedFiles.when(() -> Files.list(eq(dir1))) - .thenReturn(Stream.of(tempFile1, dir2)); + mockedFiles + .when(() -> Files.newDirectoryStream(eq(dir1))) + .thenReturn(directoryStreamOf(tempFile1, dir2)); - mockedFiles.when(() -> Files.list(eq(dir2))) - .thenReturn(Stream.of(tempFile2, dir3)); + mockedFiles + .when(() -> Files.newDirectoryStream(eq(dir2))) + .thenReturn(directoryStreamOf(tempFile2, dir3)); - mockedFiles.when(() -> Files.list(eq(dir3))) - .thenReturn(Stream.of(tempFile3)); + mockedFiles + .when(() -> Files.newDirectoryStream(eq(dir3))) + .thenReturn(directoryStreamOf(tempFile3)); // Configure Files.isDirectory for each path mockedFiles.when(() -> Files.isDirectory(eq(dir1))).thenReturn(true); @@ -461,4 +474,16 @@ public class TempFileCleanupServiceTest { private static Path eq(Path path) { return argThat(arg -> arg != null && arg.equals(path)); } + + private static DirectoryStream directoryStreamOf(Path... paths) { + return new DirectoryStream<>() { + @Override + public java.util.Iterator iterator() { + return java.util.Arrays.asList(paths).iterator(); + } + + @Override + public void close() {} + }; + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/UI/impl/DesktopBrowser.java b/app/core/src/main/java/stirling/software/SPDF/UI/impl/DesktopBrowser.java index 959e7f354..5e665ed7d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/UI/impl/DesktopBrowser.java +++ b/app/core/src/main/java/stirling/software/SPDF/UI/impl/DesktopBrowser.java @@ -11,7 +11,11 @@ import java.awt.TrayIcon; import java.awt.event.WindowEvent; import java.awt.event.WindowStateListener; import java.io.File; +import java.io.IOException; import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Objects; import java.util.concurrent.CompletableFuture; @@ -30,6 +34,7 @@ import org.cef.callback.CefDownloadItem; import org.cef.callback.CefDownloadItemCallback; import org.cef.handler.CefDownloadHandlerAdapter; import org.cef.handler.CefLoadHandlerAdapter; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -62,7 +67,11 @@ public class DesktopBrowser implements WebBrowser { private static TrayIcon trayIcon; private static SystemTray systemTray; - public DesktopBrowser() { + private final String appVersion; + private static final String VERSION_FILE = "last_version.txt"; + + public DesktopBrowser(@Qualifier("appVersion") String appVersion) { + this.appVersion = appVersion; SwingUtilities.invokeLater( () -> { loadingWindow = new LoadingWindow(null, "Initializing..."); @@ -120,6 +129,10 @@ public class DesktopBrowser implements WebBrowser { CefSettings settings = builder.getCefSettings(); String basePath = InstallationPathConfig.getClientWebUIPath(); log.info("basePath " + basePath); + + // Check if version has changed and reset cache if needed + checkVersionAndResetCache(basePath); + settings.cache_path = new File(basePath + "cache").getAbsolutePath(); settings.root_cache_path = new File(basePath + "root_cache").getAbsolutePath(); // settings.browser_subprocess_path = new File(basePath + @@ -424,6 +437,87 @@ public class DesktopBrowser implements WebBrowser { } } + private void checkVersionAndResetCache(String basePath) { + try { + Path versionFilePath = Paths.get(basePath, VERSION_FILE); + String currentVersion = appVersion != null ? appVersion : "0.0.0"; + + // Read last stored version + String lastVersion = "0.0.0"; + if (Files.exists(versionFilePath)) { + lastVersion = new String(Files.readAllBytes(versionFilePath)).trim(); + } + + log.info("Current version: {}, Last version: {}", currentVersion, lastVersion); + + // Compare major and minor versions + if (shouldResetCache(currentVersion, lastVersion)) { + log.info("Version change detected, resetting cache"); + resetCache(basePath); + + // Store current version + Files.createDirectories(versionFilePath.getParent()); + Files.write(versionFilePath, currentVersion.getBytes()); + log.info("Version file updated to: {}", currentVersion); + } + } catch (Exception e) { + log.error("Error checking version and resetting cache", e); + } + } + + private boolean shouldResetCache(String currentVersion, String lastVersion) { + try { + String[] currentParts = currentVersion.split("\\."); + String[] lastParts = lastVersion.split("\\."); + + if (currentParts.length < 2 || lastParts.length < 2) { + return true; // Reset if version format is unexpected + } + + int currentMajor = Integer.parseInt(currentParts[0]); + int currentMinor = Integer.parseInt(currentParts[1]); + int lastMajor = Integer.parseInt(lastParts[0]); + int lastMinor = Integer.parseInt(lastParts[1]); + + return currentMajor != lastMajor || currentMinor != lastMinor; + } catch (Exception e) { + log.warn("Error comparing versions, will reset cache: {}", e.getMessage()); + return true; + } + } + + private void resetCache(String basePath) { + try { + Path cachePath = Paths.get(basePath, "cache"); + Path rootCachePath = Paths.get(basePath, "root_cache"); + + if (Files.exists(cachePath)) { + deleteDirectoryRecursively(cachePath); + log.info("Deleted cache directory: {}", cachePath); + } + + if (Files.exists(rootCachePath)) { + deleteDirectoryRecursively(rootCachePath); + log.info("Deleted root cache directory: {}", rootCachePath); + } + } catch (Exception e) { + log.error("Error resetting cache directories", e); + } + } + + private void deleteDirectoryRecursively(Path path) throws IOException { + Files.walk(path) + .sorted((a, b) -> b.compareTo(a)) // Delete files before directories + .forEach( + p -> { + try { + Files.delete(p); + } catch (IOException e) { + log.warn("Could not delete: {}", p, e); + } + }); + } + @PreDestroy public void cleanup() { if (browser != null) browser.close(true); diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index cf22262e4..d5c1ffd8b 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -145,6 +145,8 @@ system: cleanupIntervalMinutes: 30 # How often to run cleanup (in minutes) startupCleanup: true # Clean up old temp files on startup cleanupSystemTemp: false # Whether to clean broader system temp directory + batchSize: 1000 # Number of entries processed before optional pause (0 = unlimited) + pauseBetweenBatchesMs: 50 # Pause duration in milliseconds between batches ui: appName: '' # application's visible name