diff --git a/QORTIUM-CHANGELOG.md b/QORTIUM-CHANGELOG.md index 439b7c6cd..3d624b2cc 100644 --- a/QORTIUM-CHANGELOG.md +++ b/QORTIUM-CHANGELOG.md @@ -34,6 +34,14 @@ own chain. ## Change Entries +### 2026-06-04 - Stop auto-update restart after failed jar replacement + +Changed the auto-update apply helper so it only restarts Core after the verified replacement JAR has actually been copied into place. If the replacement JAR is missing or cannot be written after the retry window, the helper now stops instead of relaunching the existing old JAR, which prevents an approved update from cycling through the same download, failed apply, and restart path repeatedly. + +### 2026-06-02 - Preserve preview pid files across auto-updates + +Changed the preview launchers and auto-update restart path so Core keeps track of the replacement Java process after an approved update restarts the node. Preview `run.pid` files now get refreshed when the updated process starts, including for already-running preview nodes that still have an existing `run.pid`, which keeps operator scripts and status checks pointed at the live process instead of an old pre-update PID. + ### 2026-06-02 - Refresh tray sync progress from peer height Changed the tray tooltip so its syncing percentage and blocks-remaining count use the same current peer-height progress calculation as the node status API. This keeps the desktop tooltip moving while a preview node catches up, instead of leaving an older remaining-block estimate visible after the local height has advanced. diff --git a/preview/start.ps1 b/preview/start.ps1 index 672c0f5b9..e4e52269d 100644 --- a/preview/start.ps1 +++ b/preview/start.ps1 @@ -211,7 +211,8 @@ switch ($HeadlessMode) { $JavaArgs = @( "-Djava.net.preferIPv4Stack=false", "-Dlog4j.configurationFile=$Log4jConfig", - "-Dqortium.log.dir=$ScriptDir" + "-Dqortium.log.dir=$ScriptDir", + "-Dqortium.pid.file=$RunPid" ) + $JavaDisplayArgs + $JvmMemoryArgs + @("-jar", $JarPath, $SettingsLocal) $StartProcessArgs = @{ FilePath = "java" diff --git a/preview/start.sh b/preview/start.sh index e51fc0321..1bfe4618d 100755 --- a/preview/start.sh +++ b/preview/start.sh @@ -219,6 +219,7 @@ if command -v setsid >/dev/null 2>&1; then -Djava.net.preferIPv4Stack=false \ -Dlog4j.configurationFile="${LOG4J_CONFIG}" \ -Dqortium.log.dir="${SCRIPT_DIR}" \ + -Dqortium.pid.file="${RUN_PID}" \ "${JAVA_DISPLAY_ARGS[@]}" \ "${JVM_MEMORY_ARGS[@]}" \ -jar "${JAR_PATH}" \ @@ -229,6 +230,7 @@ elif command -v nohup >/dev/null 2>&1; then -Djava.net.preferIPv4Stack=false \ -Dlog4j.configurationFile="${LOG4J_CONFIG}" \ -Dqortium.log.dir="${SCRIPT_DIR}" \ + -Dqortium.pid.file="${RUN_PID}" \ "${JAVA_DISPLAY_ARGS[@]}" \ "${JVM_MEMORY_ARGS[@]}" \ -jar "${JAR_PATH}" \ @@ -239,6 +241,7 @@ else -Djava.net.preferIPv4Stack=false \ -Dlog4j.configurationFile="${LOG4J_CONFIG}" \ -Dqortium.log.dir="${SCRIPT_DIR}" \ + -Dqortium.pid.file="${RUN_PID}" \ "${JAVA_DISPLAY_ARGS[@]}" \ "${JVM_MEMORY_ARGS[@]}" \ -jar "${JAR_PATH}" \ diff --git a/src/main/java/org/qortium/ApplyUpdate.java b/src/main/java/org/qortium/ApplyUpdate.java index 3432b1fe8..4bfd7253d 100644 --- a/src/main/java/org/qortium/ApplyUpdate.java +++ b/src/main/java/org/qortium/ApplyUpdate.java @@ -11,7 +11,9 @@ import java.io.IOException; import java.lang.management.ManagementFactory; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; @@ -37,6 +39,7 @@ public class ApplyUpdate { private static final String JAR_FILENAME = AutoUpdate.JAR_FILENAME; private static final String NEW_JAR_FILENAME = AutoUpdate.NEW_JAR_FILENAME; private static final String WINDOWS_EXE_LAUNCHER = "qortium.exe"; + private static final String RUN_PID_FILENAME = "run.pid"; private static final String JAVA_TOOL_OPTIONS_NAME = "JAVA_TOOL_OPTIONS"; private static final String JAVA_TOOL_OPTIONS_VALUE = ""; @@ -60,7 +63,10 @@ public static void main(String[] args) { return; // Replace JAR - replaceJar(); + if (!replaceJar()) { + LOGGER.error("Update JAR replacement failed - not restarting node with existing JAR"); + return; + } // Restart node restartNode(args); @@ -143,24 +149,28 @@ private static void removeGeneratedApiKey() { } } - private static void replaceJar() { + private static boolean replaceJar() { + return replaceJar(Paths.get("")); + } + + static boolean replaceJar(Path workingDirectory) { // Assuming current working directory contains the JAR files - Path realJar = Paths.get(JAR_FILENAME); - Path newJar = Paths.get(NEW_JAR_FILENAME); + Path realJar = workingDirectory.resolve(JAR_FILENAME); + Path newJar = workingDirectory.resolve(NEW_JAR_FILENAME); if (!Files.exists(newJar)) { LOGGER.warn(() -> String.format("Replacement JAR '%s' not found?", newJar)); - return; + return false; } - int attempt; - for (attempt = 0; attempt < MAX_ATTEMPTS; ++attempt) { + for (int attempt = 0; attempt < MAX_ATTEMPTS; ++attempt) { final int attemptForLogging = attempt; LOGGER.info(() -> String.format("Attempt #%d out of %d to replace JAR", attemptForLogging + 1, MAX_ATTEMPTS)); try { Files.copy(newJar, realJar, StandardCopyOption.REPLACE_EXISTING); - break; + LOGGER.info(() -> String.format("Replaced JAR '%s' with '%s'", realJar, newJar)); + return true; } catch (IOException e) { LOGGER.info(() -> String.format("Unable to replace JAR: %s", e.getMessage())); @@ -175,8 +185,8 @@ private static void replaceJar() { } } - if (attempt == MAX_ATTEMPTS) - LOGGER.error("Failed to replace JAR - giving up"); + LOGGER.error("Failed to replace JAR - giving up"); + return false; } private static void restartNode(String[] args) { @@ -228,6 +238,8 @@ private static void restartNode(String[] args) { Process process = processBuilder.start(); + writePidFileForRestart(process); + // Nothing to pipe to new process, so close output stream (process's stdin) process.getOutputStream().close(); } catch (Exception e) { @@ -235,4 +247,37 @@ private static void restartNode(String[] args) { } } + private static void writePidFileForRestart(Process process) { + Path pidFile = null; + try { + pidFile = resolvePidFileForRestart(Paths.get("")); + if (pidFile == null) + return; + + writePidFile(pidFile, process.pid()); + } catch (IOException | InvalidPathException e) { + LOGGER.warn("Unable to update pid file {} after auto-update restart: {}", pidFile, e.getMessage()); + } + } + + static Path resolvePidFileForRestart(Path workingDirectory) { + String pidFile = System.getProperty(AutoUpdate.PID_FILE_PROPERTY); + if (pidFile != null && !pidFile.isBlank()) + return Paths.get(pidFile); + + Path fallbackPidFile = workingDirectory.resolve(RUN_PID_FILENAME); + if (Files.exists(fallbackPidFile)) + return fallbackPidFile; + + return null; + } + + static void writePidFile(Path pidFile, long pid) throws IOException { + Path parent = pidFile.getParent(); + if (parent != null) + Files.createDirectories(parent); + + Files.writeString(pidFile, Long.toString(pid) + System.lineSeparator(), StandardCharsets.UTF_8); + } + } diff --git a/src/main/java/org/qortium/controller/AutoUpdate.java b/src/main/java/org/qortium/controller/AutoUpdate.java index 4dc7724ad..0deca750a 100644 --- a/src/main/java/org/qortium/controller/AutoUpdate.java +++ b/src/main/java/org/qortium/controller/AutoUpdate.java @@ -54,8 +54,10 @@ public class AutoUpdate extends Thread { public static final String JAR_FILENAME = "qortium.jar"; public static final String NEW_JAR_FILENAME = "new-" + JAR_FILENAME; public static final String AGENTLIB_JVM_HOLDER_ARG = "-DQORTIUM_agentlib="; + public static final String PID_FILE_PROPERTY = "qortium.pid.file"; private static final Logger LOGGER = LogManager.getLogger(AutoUpdate.class); + private static final String PID_FILE_PROPERTY_ARG_PREFIX = "-D" + PID_FILE_PROPERTY + "="; static final long INITIAL_CHECK_DELAY = 30 * 1000L; // ms static final long CHECK_INTERVAL = 20 * 60 * 1000L; // ms static final long QDN_DOWNLOAD_RETRY_INTERVAL = 60 * 1000L; // ms @@ -1032,6 +1034,8 @@ static List buildApplyUpdateCommand(String javaExecutable, boolean inclu javaCmd.addAll(sanitizeJvmArguments(runtimeInputArgs)); } + addPidFileProperty(javaCmd); + // Call ApplyUpdate using new JAR javaCmd.addAll(Arrays.asList("-cp", newJarAbsolute.toString(), ApplyUpdate.class.getCanonicalName())); @@ -1042,6 +1046,18 @@ static List buildApplyUpdateCommand(String javaExecutable, boolean inclu return javaCmd; } + private static void addPidFileProperty(List javaCmd) { + String pidFile = System.getProperty(PID_FILE_PROPERTY); + if (pidFile == null || pidFile.isBlank()) + return; + + for (String arg : javaCmd) + if (arg.startsWith(PID_FILE_PROPERTY_ARG_PREFIX)) + return; + + javaCmd.add(PID_FILE_PROPERTY_ARG_PREFIX + pidFile); + } + private static void startApplyUpdateProcess(List javaCmd) throws IOException { ProcessBuilder processBuilder = new ProcessBuilder(javaCmd); diff --git a/src/test/java/org/qortium/ApplyUpdateTests.java b/src/test/java/org/qortium/ApplyUpdateTests.java new file mode 100644 index 000000000..f81a054b6 --- /dev/null +++ b/src/test/java/org/qortium/ApplyUpdateTests.java @@ -0,0 +1,82 @@ +package org.qortium; + +import org.junit.After; +import org.junit.Test; +import org.qortium.controller.AutoUpdate; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ApplyUpdateTests { + + @After + public void afterTest() { + System.clearProperty(AutoUpdate.PID_FILE_PROPERTY); + } + + @Test + public void testWritePidFileCreatesParentAndWritesPid() throws Exception { + Path tempDir = Files.createTempDirectory("qortium-pid-test"); + Path pidFile = tempDir.resolve("nested").resolve("run.pid"); + + ApplyUpdate.writePidFile(pidFile, 12345L); + + assertEquals("12345" + System.lineSeparator(), Files.readString(pidFile, StandardCharsets.UTF_8)); + } + + @Test + public void testResolvePidFilePrefersProperty() throws Exception { + System.setProperty(AutoUpdate.PID_FILE_PROPERTY, "/tmp/qortium-run.pid"); + Path tempDir = Files.createTempDirectory("qortium-pid-test"); + Files.writeString(tempDir.resolve("run.pid"), "old", StandardCharsets.UTF_8); + + assertEquals(Paths.get("/tmp/qortium-run.pid"), ApplyUpdate.resolvePidFileForRestart(tempDir)); + } + + @Test + public void testResolvePidFileUsesExistingRunPidFallback() throws Exception { + Path tempDir = Files.createTempDirectory("qortium-pid-test"); + Path pidFile = tempDir.resolve("run.pid"); + Files.writeString(pidFile, "old", StandardCharsets.UTF_8); + + assertEquals(pidFile, ApplyUpdate.resolvePidFileForRestart(tempDir)); + } + + @Test + public void testResolvePidFileSkipsMissingFallback() throws Exception { + Path tempDir = Files.createTempDirectory("qortium-pid-test"); + + assertNull(ApplyUpdate.resolvePidFileForRestart(tempDir)); + } + + @Test + public void testReplaceJarCopiesReplacementJar() throws Exception { + Path tempDir = Files.createTempDirectory("qortium-apply-update-test"); + Path realJar = tempDir.resolve(AutoUpdate.JAR_FILENAME); + Path newJar = tempDir.resolve(AutoUpdate.NEW_JAR_FILENAME); + + Files.writeString(realJar, "old jar", StandardCharsets.UTF_8); + Files.writeString(newJar, "new jar", StandardCharsets.UTF_8); + + assertTrue(ApplyUpdate.replaceJar(tempDir)); + assertEquals("new jar", Files.readString(realJar, StandardCharsets.UTF_8)); + } + + @Test + public void testReplaceJarFailsWhenReplacementJarIsMissing() throws Exception { + Path tempDir = Files.createTempDirectory("qortium-apply-update-test"); + Path realJar = tempDir.resolve(AutoUpdate.JAR_FILENAME); + + Files.writeString(realJar, "old jar", StandardCharsets.UTF_8); + + assertFalse(ApplyUpdate.replaceJar(tempDir)); + assertEquals("old jar", Files.readString(realJar, StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/org/qortium/controller/AutoUpdateTests.java b/src/test/java/org/qortium/controller/AutoUpdateTests.java index 49a165cae..da19c9505 100644 --- a/src/test/java/org/qortium/controller/AutoUpdateTests.java +++ b/src/test/java/org/qortium/controller/AutoUpdateTests.java @@ -29,6 +29,7 @@ public void beforeTest() { @After public void afterTest() { AutoUpdate.releaseUpdateInstall(); + System.clearProperty(AutoUpdate.PID_FILE_PROPERTY); } private Settings newSettingsInstance() throws ReflectiveOperationException { @@ -142,6 +143,39 @@ public void testBuildApplyUpdateCommandSkipsJvmArgsWhenDisabled() { assertTrue(command.contains(ApplyUpdate.class.getCanonicalName())); } + @Test + public void testBuildApplyUpdateCommandPreservesPidFilePropertyWithoutJvmArgs() { + System.setProperty(AutoUpdate.PID_FILE_PROPERTY, "/tmp/qortium-run.pid"); + List runtimeInputArgs = Arrays.asList("-Xmx2g", "-agentlib:test=foo"); + + List command = AutoUpdate.buildApplyUpdateCommand( + "java", + false, + runtimeInputArgs, + null, + Paths.get("/tmp/new-qortium.jar") + ); + + assertFalse(command.contains("-Xmx2g")); + assertTrue(command.contains("-Dqortium.pid.file=/tmp/qortium-run.pid")); + } + + @Test + public void testBuildApplyUpdateCommandDoesNotDuplicatePidFileProperty() { + System.setProperty(AutoUpdate.PID_FILE_PROPERTY, "/tmp/qortium-run.pid"); + List runtimeInputArgs = Arrays.asList("-Dqortium.pid.file=/tmp/qortium-run.pid"); + + List command = AutoUpdate.buildApplyUpdateCommand( + "java", + true, + runtimeInputArgs, + null, + Paths.get("/tmp/new-qortium.jar") + ); + + assertEquals(1, command.stream().filter(arg -> arg.startsWith("-Dqortium.pid.file=")).count()); + } + @Test public void testBuildJavaCandidatesIncludesPrimaryAndFallbackJava() { List candidates = AutoUpdate.buildJavaCandidates(Paths.get("/opt/jdk/bin/java"));