Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions QORTIUM-CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion preview/start.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions preview/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}" \
Expand All @@ -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}" \
Expand All @@ -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}" \
Expand Down
65 changes: 55 additions & 10 deletions src/main/java/org/qortium/ApplyUpdate.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = "";

Expand All @@ -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);
Expand Down Expand Up @@ -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()));

Expand All @@ -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) {
Expand Down Expand Up @@ -228,11 +238,46 @@ 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) {
LOGGER.error(String.format("Failed to restart node (BAD): %s", e.getMessage()));
}
}

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);
}

}
16 changes: 16 additions & 0 deletions src/main/java/org/qortium/controller/AutoUpdate.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1032,6 +1034,8 @@ static List<String> 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()));

Expand All @@ -1042,6 +1046,18 @@ static List<String> buildApplyUpdateCommand(String javaExecutable, boolean inclu
return javaCmd;
}

private static void addPidFileProperty(List<String> 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<String> javaCmd) throws IOException {
ProcessBuilder processBuilder = new ProcessBuilder(javaCmd);

Expand Down
82 changes: 82 additions & 0 deletions src/test/java/org/qortium/ApplyUpdateTests.java
Original file line number Diff line number Diff line change
@@ -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));
}
}
34 changes: 34 additions & 0 deletions src/test/java/org/qortium/controller/AutoUpdateTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public void beforeTest() {
@After
public void afterTest() {
AutoUpdate.releaseUpdateInstall();
System.clearProperty(AutoUpdate.PID_FILE_PROPERTY);
}

private Settings newSettingsInstance() throws ReflectiveOperationException {
Expand Down Expand Up @@ -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<String> runtimeInputArgs = Arrays.asList("-Xmx2g", "-agentlib:test=foo");

List<String> 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<String> runtimeInputArgs = Arrays.asList("-Dqortium.pid.file=/tmp/qortium-run.pid");

List<String> 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<String> candidates = AutoUpdate.buildJavaCandidates(Paths.get("/opt/jdk/bin/java"));
Expand Down