This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 0c2d47782501 CAMEL-26443: camel-launcher - The update check of the web 
installer never refreshes its cache from a quick command (#28778)
0c2d47782501 is described below

commit 0c2d47782501aad92415da72afecb8546c43bf0a
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Nov 1 08:43:34 2026 +0100

    CAMEL-26443: camel-launcher - The update check of the web installer never 
refreshes its cache from a quick command (#28778)
    
    Signed-off-by: Claus Ibsen <[email protected]>
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../dsl/jbang/launcher/CamelLauncherMain.java      |   6 +
 .../jbang/launcher/selfupdate/ManifestFetcher.java |  10 +-
 .../jbang/launcher/selfupdate/UpdateChecker.java   |  59 +++++++--
 .../dsl/jbang/launcher/UpdateCheckRefreshTest.java | 144 +++++++++++++++++++++
 4 files changed, 207 insertions(+), 12 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherMain.java
 
b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherMain.java
index 187a5d6b8c15..23c0e24db2ed 100644
--- 
a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherMain.java
+++ 
b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherMain.java
@@ -53,6 +53,12 @@ public class CamelLauncherMain extends CamelJBangMain {
         UpdateChecker.maybeNotify(args);
     }
 
+    @Override
+    public void postExecute(CommandLine commandLine, String[] args, int 
exitCode) {
+        // the check for a newer release that the command started, which most 
commands end before
+        UpdateChecker.awaitRefresh();
+    }
+
     @Override
     protected boolean isPluginCommand(String command) {
         return PLUGINS.stream().anyMatch(p -> p.commands().contains(command)) 
|| super.isPluginCommand(command);
diff --git 
a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcher.java
 
b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcher.java
index d8e2d52768e0..8c6c8abe585f 100644
--- 
a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcher.java
+++ 
b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcher.java
@@ -36,7 +36,7 @@ import java.util.Set;
  * neither {@code install.sh} nor {@code install.ps1} perform on their own -- 
the actual archive download, checksum
  * verification, and extraction happen inside whichever of those two scripts 
{@code SelfUpdateCommand} delegates to.
  */
-public final class ManifestFetcher {
+public final class ManifestFetcher implements AutoCloseable {
 
     private static final String DEFAULT_MANIFEST_BASE_URL = 
"https://camel.apache.org/camel-cli/releases";;
     private static final String DEFAULT_MAVEN_BASE_URL = 
"https://repo1.maven.org/maven2/org/apache/camel/camel-launcher";;
@@ -76,6 +76,14 @@ public final class ManifestFetcher {
         return new ManifestFetcher(manifestBaseUrl, mavenBaseUrl);
     }
 
+    /**
+     * Closes the HTTP client, whose selector thread would otherwise delay the 
exit of the JVM by up to 300 ms.
+     */
+    @Override
+    public void close() {
+        httpClient.close();
+    }
+
     public String manifestBaseUrl() {
         return manifestBaseUrl;
     }
diff --git 
a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/UpdateChecker.java
 
b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/UpdateChecker.java
index a11bddfaf3f9..88b2ae01ef5a 100644
--- 
a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/UpdateChecker.java
+++ 
b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/UpdateChecker.java
@@ -32,16 +32,22 @@ import org.apache.camel.dsl.jbang.run.common.VersionHelper;
 /**
  * Prints a one-line stderr notice when a background-cached check shows a 
newer launcher release than the one currently
  * running. Never blocks the command the user actually invoked: the network 
fetch that refreshes the cache runs on a
- * daemon thread and is simply retried on a later invocation if the JVM exits 
first.
+ * daemon thread, which the launcher waits for a little after the command, at 
most once a day (see
+ * {@link #awaitRefresh()}).
  */
 public final class UpdateChecker {
 
     private static final Duration STALE_AFTER = Duration.ofHours(24);
+    // how long the launcher waits after the command for the fetch of the day
+    private static final Duration REFRESH_WAIT = Duration.ofSeconds(2);
     // Cache key naming what latest_version was resolved from: absent (or 
"latest") for latest.properties, otherwise
     // the alias name ("lts" or "X.Y") of the install's selector pin. A cache 
resolved for another target is stale.
     private static final String TARGET_KEY = "target";
     private static final String LATEST_TARGET = "latest";
 
+    // the refresh that this process started, if any
+    private static volatile Thread refresh;
+
     private UpdateChecker() {
     }
 
@@ -65,9 +71,12 @@ public final class UpdateChecker {
         Properties cache = readCache(cacheFile);
 
         if (isStale(cache, System.currentTimeMillis(), target)) {
-            Thread refresh = new Thread(() -> refreshCache(cacheFile, pin), 
"camel-self-update-check");
-            refresh.setDaemon(true);
-            refresh.start();
+            // checked from now on, before the fetch, so that a failed or slow 
fetch is not tried by the next commands
+            Properties checked = markChecked(cacheFile, target);
+            Thread thread = new Thread(() -> refreshCache(cacheFile, checked, 
pin), "camel-self-update-check");
+            thread.setDaemon(true);
+            thread.start();
+            refresh = thread;
         }
 
         CamelCatalog catalog = new DefaultCamelCatalog();
@@ -77,6 +86,22 @@ public final class UpdateChecker {
         }
     }
 
+    /**
+     * Waits a little (at most 2 seconds) for the refresh of the cache that 
{@link #maybeNotify(String[])} started in
+     * this process. Most commands end before the fetch over the network 
would, and the JVM then stops it, so the cache
+     * would never be refreshed by them. Called after the command, which is 
then done.
+     */
+    public static void awaitRefresh() {
+        Thread thread = refresh;
+        if (thread != null) {
+            try {
+                thread.join(REFRESH_WAIT.toMillis());
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+        }
+    }
+
     // Package-visible pure helpers, unit tested directly in UpdateCheckerTest 
without any file/network I/O.
 
     static boolean isEligible(String[] args, String checkEnvVar) {
@@ -156,22 +181,34 @@ public final class UpdateChecker {
         return props;
     }
 
-    // Runs on a daemon thread; any failure (network, timeout, parse) is 
swallowed, but last_checked is still
-    // updated so a persistent failure doesn't retry on every single 
invocation.
-    private static void refreshCache(Path cacheFile, Pin pin) {
+    // Writes last_checked (and the target) before the fetch, so that a 
persistent failure, or a fetch that the JVM
+    // stops, doesn't retry on every single invocation.
+    private static Properties markChecked(Path cacheFile, String target) {
         Properties props = readCache(cacheFile);
-        String target = targetOf(pin);
         if (!target.equals(props.getProperty(TARGET_KEY, LATEST_TARGET))) {
             // The cached version was resolved for another target (the pin 
changed): never compare against it.
             props.remove("latest_version");
         }
         props.setProperty(TARGET_KEY, target);
         props.setProperty("last_checked", 
Long.toString(System.currentTimeMillis()));
-        try {
-            props.setProperty("latest_version", 
resolve(ManifestFetcher.fromEnvironment(), pin));
+        writeCache(cacheFile, props);
+        return props;
+    }
+
+    // Runs on a daemon thread; any failure (network, timeout, parse) is 
swallowed, keeping whatever latest_version (if
+    // any) was already cached.
+    private static void refreshCache(Path cacheFile, Properties props, Pin 
pin) {
+        try (ManifestFetcher fetcher = ManifestFetcher.fromEnvironment()) {
+            props.setProperty("latest_version", resolve(fetcher, pin));
         } catch (Exception e) {
-            // Network/timeout/parse failure: keep whatever latest_version (if 
any) was already cached.
+            // Network/timeout/parse failure: keep whatever latest_version (if 
any) was already cached. markChecked
+            // already wrote last_checked, so the next run doesn't retry.
+            return;
         }
+        writeCache(cacheFile, props);
+    }
+
+    private static void writeCache(Path cacheFile, Properties props) {
         try {
             Files.createDirectories(cacheFile.getParent());
             try (OutputStream out = Files.newOutputStream(cacheFile)) {
diff --git 
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/UpdateCheckRefreshTest.java
 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/UpdateCheckRefreshTest.java
new file mode 100644
index 000000000000..5c79a1d826c5
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/UpdateCheckRefreshTest.java
@@ -0,0 +1,144 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.launcher;
+
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.sun.net.httpserver.HttpServer;
+import org.apache.camel.dsl.jbang.core.common.InstallDetector;
+import org.apache.camel.dsl.jbang.core.common.LauncherHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The check for a newer release of a launcher of the web installer, once a 
day: a command ends before the fetch over
+ * the network would, so the launcher waits for it a little after the command, 
and marks the check before the fetch, so
+ * that a failed fetch is not tried again by every command. Each command runs 
in a new JVM, against a manifest server of
+ * the test.
+ */
+class UpdateCheckRefreshTest {
+
+    private static final String MANIFEST = 
"format=1\nversion=4.99.0\ntar_sha256=" + "a".repeat(64) + "\nzip_sha256="
+                                           + "b".repeat(64) + "\n";
+
+    @TempDir
+    Path dir;
+
+    private HttpServer server;
+    private final AtomicInteger requests = new AtomicInteger();
+    private volatile int status = 200;
+
+    @BeforeEach
+    void startServer() throws Exception {
+        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/", exchange -> {
+            requests.incrementAndGet();
+            byte[] body = MANIFEST.getBytes(StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(status, body.length);
+            try (OutputStream out = exchange.getResponseBody()) {
+                out.write(body);
+            }
+        });
+        server.start();
+    }
+
+    @AfterEach
+    void stopServer() {
+        server.stop(0);
+    }
+
+    @Test
+    void aQuickCommandRefreshesAStaleCache() throws Exception {
+        long start = System.currentTimeMillis();
+
+        Properties cache = run("stale");
+
+        assertThat(requests).hasValue(1);
+        assertThat(cache.getProperty("latest_version")).isEqualTo("4.99.0");
+        
assertThat(Long.parseLong(cache.getProperty("last_checked"))).isGreaterThanOrEqualTo(start);
+    }
+
+    @Test
+    void aFailedFetchIsNotTriedAgainByTheNextCommand() throws Exception {
+        status = 500;
+
+        run("stale");
+        Properties cache = run("as-is");
+
+        assertThat(requests).hasValue(1);
+        // the version of the check before
+        assertThat(cache.getProperty("latest_version")).isEqualTo("4.0.0");
+    }
+
+    // runs camel version in a new JVM, and returns the cache of the check 
after it
+    private Properties run(String cache) throws Exception {
+        String java = Path.of(System.getProperty("java.home"), "bin", 
"java").toString();
+        ProcessBuilder builder = new ProcessBuilder(
+                List.of(java, "-cp", System.getProperty("java.class.path"),
+                        "-Duser.home=" + dir, 
WebInstallerVersion.class.getName(), cache))
+                .directory(dir.toFile())
+                .redirectErrorStream(true)
+                .redirectOutput(dir.resolve("out.txt").toFile());
+        Map<String, String> env = builder.environment();
+        env.remove("CAMEL_SELF_UPDATE_CHECK");
+        // where the web installer installs (POSIX and Windows)
+        env.put("XDG_DATA_HOME", dir.toString());
+        env.put("LOCALAPPDATA", dir.toString());
+        env.put("CAMEL_SELF_UPDATE_MANIFEST_BASE_URL", "http://127.0.0.1:"; + 
server.getAddress().getPort());
+        Process process = builder.start();
+        assertThat(process.waitFor(60, TimeUnit.SECONDS)).isTrue();
+        assertThat(process.exitValue()).as("exit code, output: " + 
Files.readString(dir.resolve("out.txt"))).isZero();
+        Properties properties = new Properties();
+        try (var in = 
Files.newInputStream(Path.of(Files.readString(dir.resolve("cache-file.txt"))))) 
{
+            properties.load(in);
+        }
+        return properties;
+    }
+
+    /**
+     * camel version of a launcher installed by the web installer.
+     */
+    public static final class WebInstallerVersion {
+
+        public static void main(String... args) throws Exception {
+            Path root = InstallDetector.webInstallerVersionsRoot();
+            Path cache = root.getParent().resolve("update-check.properties");
+            if ("stale".equals(args[0])) {
+                // checked a day and more ago, which found 4.0.0
+                Files.createDirectories(cache.getParent());
+                Files.writeString(cache, 
"last_checked=1\nlatest_version=4.0.0\n");
+            }
+            Files.writeString(Path.of("cache-file.txt"), cache.toString());
+            Path jar = 
root.resolve("4.0.0").resolve("lib").resolve("camel-launcher.jar");
+            System.setProperty(LauncherHelper.CAMEL_LAUNCHER_JAR_PROPERTY, 
jar.toString());
+            new CamelLauncherMain().execute("version");
+        }
+    }
+}

Reply via email to