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