fix(admin): restart local bootRun with a fresh JVM

This commit is contained in:
Ludy87
2026-07-26 13:53:25 +02:00
parent 3813ca360e
commit 1012f929a1
5 changed files with 141 additions and 30 deletions
@@ -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<Path> 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<Path> 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
*
@@ -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<String> 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<String> 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.
@@ -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<stirling.software.common.util.JarPathUtil> 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<Map<String, Object>> 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());
}
}
+11 -3
View File
@@ -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.
+20 -4
View File
@@ -19,12 +19,19 @@ public class RestartHelper {
Map<String, String> 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<String> 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)) {