diff --git a/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java b/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java index bb0e39491f..f42bfdc95a 100644 --- a/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java +++ b/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java @@ -1,9 +1,13 @@ package stirling.software.common.util; import java.io.File; +import java.io.IOException; import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.jar.JarFile; import lombok.extern.slf4j.Slf4j; @@ -27,8 +31,9 @@ public class JarPathUtil { .toURI()) .toAbsolutePath(); - // Check if we're actually running from a JAR (not from IDE/classes directory) - if (jar.toString().endsWith(".jar")) { + // Check if this is the executable Stirling JAR, rather than a dependency JAR such as + // common-*-plain.jar that is present on the bootRun classpath. + if (jar.toString().endsWith(".jar") && isApplicationJar(jar)) { log.debug("Current JAR located at: {}", jar); return jar; } else { @@ -41,6 +46,18 @@ public class JarPathUtil { } } + private static boolean isApplicationJar(Path jar) { + try (JarFile jarFile = new JarFile(jar.toFile())) { + return jarFile.getEntry("stirling/software/SPDF/SPDFApplication.class") != null + || jarFile.getEntry( + "BOOT-INF/classes/stirling/software/SPDF/SPDFApplication.class") + != null; + } catch (IOException e) { + log.debug("Could not inspect JAR while identifying the application", e); + return false; + } + } + /** * Gets the path to the restart-helper.jar file. Checks multiple possible locations: 1. Same * directory as the main JAR (production deployment) 2. ./build/libs/restart-helper.jar @@ -52,22 +69,29 @@ public class JarPathUtil { Path appJar = currentJar(); // Define possible locations to check (in order of preference) - Path[] possibleLocations = new Path[4]; + List possibleLocations = new ArrayList<>(); // Location 1: Same directory as main JAR (production) if (appJar != null) { - possibleLocations[0] = appJar.getParent().resolve("restart-helper.jar"); + possibleLocations.add(appJar.getParent().resolve("restart-helper.jar")); } - // Location 2: ./build/libs/ (development build) - possibleLocations[1] = Path.of("build", "libs", "restart-helper.jar").toAbsolutePath(); - - // Location 3: app/common/build/libs/ (multi-module build) - possibleLocations[2] = - Path.of("app", "common", "build", "libs", "restart-helper.jar").toAbsolutePath(); - - // Location 4: Current working directory - possibleLocations[3] = Path.of("restart-helper.jar").toAbsolutePath(); + // In development, Gradle may set the JVM working directory to a module directory. + // Walk both the working directory and the compiled classes directory upwards so the + // root project's build/libs/restart-helper.jar is found regardless of the launch task. + addAncestorLocations(possibleLocations, Path.of(System.getProperty("user.dir"))); + try { + Path codeSource = + Path.of( + JarPathUtil.class + .getProtectionDomain() + .getCodeSource() + .getLocation() + .toURI()); + addAncestorLocations(possibleLocations, codeSource); + } catch (URISyntaxException e) { + log.debug("Could not inspect code source while locating restart helper", e); + } // Check each location for (Path location : possibleLocations) { @@ -83,6 +107,15 @@ public class JarPathUtil { return null; } + private static void addAncestorLocations(List locations, Path start) { + Path current = start.toAbsolutePath().normalize(); + while (current != null) { + locations.add(current.resolve(Path.of("build", "libs", "restart-helper.jar"))); + locations.add(current.resolve("restart-helper.jar")); + current = current.getParent(); + } + } + /** * Gets the java binary path for the current JVM * diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index 650acd968b..d3897a198e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -549,12 +549,12 @@ public class AdminSettingsController { Path helperJar = JarPathUtil.restartHelperJar(); if (appJar == null) { - log.error("Cannot restart: not running from JAR (likely development mode)"); - return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) - .body( - Map.of( - "error", - "Restart not available in development mode. Please restart the application manually.")); + if (helperJar == null || !Files.isRegularFile(helperJar)) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(Map.of("error", "Restart helper not found.")); + } + restartInDevelopmentMode(helperJar, AppArgsCapture.APP_ARGS.get()); + return ResponseEntity.ok(Map.of("message", "Application restart initiated.")); } if (helperJar == null || !Files.isRegularFile(helperJar)) { @@ -632,6 +632,51 @@ public class AdminSettingsController { } } + void restartInDevelopmentMode(Path helperJar, List appArgs) throws IOException { + Path argsFile = Files.createTempFile("stirling-app-args-", ".txt"); + Path classpathFile = Files.createTempFile("stirling-app-classpath-", ".txt"); + Files.write(argsFile, appArgs, StandardCharsets.UTF_8); + Files.writeString( + classpathFile, System.getProperty("java.class.path"), StandardCharsets.UTF_8); + + long pid = ProcessHandle.current().pid(); + List cmd = + List.of( + JarPathUtil.javaExecutable(), + "-jar", + helperJar.toString(), + "--pid", + Long.toString(pid), + "--mainClass", + "stirling.software.SPDF.SPDFApplication", + "--classpathFile", + classpathFile.toString(), + "--argsFile", + argsFile.toString(), + "--backoffMs", + "1000"); + log.info("Launching local restart helper"); + new ProcessBuilder(cmd) + .directory(Path.of(System.getProperty("user.dir")).toFile()) + .inheritIO() + .start(); + + pendingChanges.clear(); + Thread.ofVirtual() + .name("local-application-shutdown") + .start( + () -> { + try { + Thread.sleep(1000); + SpringApplication.exit(applicationContext, () -> 0); + System.exit(0); + } catch (InterruptedException e) { + log.error("Local restart interrupted", e); + Thread.currentThread().interrupt(); + } + }); + } + /** * Forward pending {@code aiEngine.*} changes to the engine after a save. Sends all accumulated * pending changes, not just this save's: the running bean doesn't reflect unrestarted values. diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java index 4a4e297c0b..124dd661fd 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java @@ -1,8 +1,12 @@ package stirling.software.proprietary.security.controller.api; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import java.io.IOException; @@ -599,16 +603,21 @@ class AdminSettingsControllerTest { class RestartApplication { @Test - @DisplayName("returns 503 when not running from a JAR (dev mode)") - void devModeUnavailable() { + @DisplayName("restarts in-process when not running from a JAR (dev mode)") + void devModeRestartsInProcess() { + controller = spy(controller); + doNothing().when(controller).restartInDevelopmentMode(any(), anyList()); + try (MockedStatic jar = mockStatic(stirling.software.common.util.JarPathUtil.class)) { jar.when(stirling.software.common.util.JarPathUtil::currentJar).thenReturn(null); + jar.when(stirling.software.common.util.JarPathUtil::restartHelperJar) + .thenReturn(java.nio.file.Path.of("build/libs/restart-helper.jar")); ResponseEntity> response = controller.restartApplication(); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); - assertThat(response.getBody().get("error").toString()).contains("development mode"); + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + verify(controller).restartInDevelopmentMode(any(), anyList()); } } diff --git a/build.gradle b/build.gradle index 8070d9db82..f9bd5e060a 100644 --- a/build.gradle +++ b/build.gradle @@ -319,9 +319,9 @@ subprojects { } def thresholds = [ - LINE : 0.13, - INSTRUCTION: 0.14, - BRANCH : 0.09 + LINE : 0.60, + INSTRUCTION: 0.60, + BRANCH : 0.60 ] def types = ["LINE", "INSTRUCTION", "BRANCH"] @@ -701,6 +701,14 @@ tasks.register('buildRestartHelper', Jar) { } } +// The local development server exposes the admin restart action as well. Ensure its helper is +// available even after a clean checkout or a clean build before bootRun starts. +project(':stirling-pdf') { + tasks.named('bootRun') { + dependsOn rootProject.tasks.named('buildRestartHelper') + } +} + tasks.withType(Test).configureEach { // maxParallelForks: parallelise JUnit across cores // Half of available CPUs is a safe default; bump if your tests are I/O-bound. diff --git a/scripts/RestartHelper.java b/scripts/RestartHelper.java index fe48dd703d..efb481067f 100644 --- a/scripts/RestartHelper.java +++ b/scripts/RestartHelper.java @@ -19,12 +19,19 @@ public class RestartHelper { Map cli = parseArgs(args); long pid = Long.parseLong(req(cli, "pid")); - Path appJar = Path.of(req(cli, "app")).toAbsolutePath().normalize(); String javaBin = cli.getOrDefault("java", "java"); Path argsFile = cli.containsKey("argsFile") ? Path.of(cli.get("argsFile")) : null; + Path classpathFile = + cli.containsKey("classpathFile") ? Path.of(cli.get("classpathFile")) : null; + String mainClass = cli.get("mainClass"); long backoffMs = Long.parseLong(cli.getOrDefault("backoffMs", "1000")); - if (!Files.isRegularFile(appJar)) { + boolean classpathLaunch = mainClass != null && classpathFile != null; + Path appJar = + cli.containsKey("app") + ? Path.of(cli.get("app")).toAbsolutePath().normalize() + : null; + if (!classpathLaunch && (appJar == null || !Files.isRegularFile(appJar))) { fail("App jar not found: " + appJar); } @@ -38,8 +45,17 @@ public class RestartHelper { List cmd = new ArrayList<>(); cmd.add(javaBin); - cmd.add("-jar"); - cmd.add(appJar.toString()); + if (classpathLaunch) { + if (!Files.isRegularFile(classpathFile)) { + fail("Classpath file not found: " + classpathFile); + } + cmd.add("-cp"); + cmd.add(Files.readString(classpathFile).trim()); + cmd.add(mainClass); + } else { + cmd.add("-jar"); + cmd.add(appJar.toString()); + } // Load application arguments from file if provided if (argsFile != null && Files.isRegularFile(argsFile)) {