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 ec4c24261046 CAMEL-23703: camel-launcher shared Java 17+ runtime
discovery
ec4c24261046 is described below
commit ec4c24261046a33ac3acbde104b692154adac2f2
Author: Adriano Machado <[email protected]>
AuthorDate: Tue Jul 14 02:39:27 2026 -0400
CAMEL-23703: camel-launcher shared Java 17+ runtime discovery
Give camel.sh and camel.bat a unified Java runtime discovery contract.
Candidates are evaluated in order: JAVACMD, JAVA_HOME/bin/java, java
on PATH, CAMEL_FALLBACK_JAVA. Each must exist, be executable, and
report Java >= 17 to qualify. If none qualify, the launcher exits with
a diagnostic listing sources checked.
Removes the obsolete macOS JavaVM.framework default and legacy Gentoo/
AIX auto-detection paths. Includes FakeJava test harness and behavioral
discovery tests for both POSIX and Windows scripts. Adds upgrade guide
note documenting the changes.
Co-Authored-By: Codex <[email protected]>
Co-Authored-By: Cursor <[email protected]>
---
.../ROOT/pages/camel-4x-upgrade-guide-4_22.adoc | 24 +
.../src/main/resources/bin/camel.bat | 121 +++--
.../camel-launcher/src/main/resources/bin/camel.sh | 79 +--
.../dsl/jbang/launcher/CamelBatDiscoveryTest.java | 564 +++++++++++++++++++++
.../dsl/jbang/launcher/CamelShDiscoveryTest.java | 404 +++++++++++++++
.../apache/camel/dsl/jbang/launcher/FakeJava.java | 213 ++++++++
.../camel/dsl/jbang/launcher/FakeJavaTest.java | 93 ++++
7 files changed, 1434 insertions(+), 64 deletions(-)
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
index e6825039dc1f..0454ac8a835a 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
@@ -56,6 +56,30 @@ run the CLI automatically renames a pre-existing global
`~/.camel-jbang-user.pro
`~/.camel-cli.properties` and a pre-existing local
`./camel-jbang-user.properties` to
`./camel-cli.properties`; an existing new file is never overwritten.
+==== Camel CLI launcher Java runtime discovery
+
+The self-contained Camel CLI launcher scripts (`bin/camel.sh` and
`bin/camel.bat` in the
+`camel-launcher` distribution) now share a single Java runtime discovery
contract. Candidates
+are evaluated in this fixed order, and the first one that exists, is
executable, and reports a
+Java major version of at least 17 is used:
+
+1. `JAVACMD` (explicit override, unchanged).
+2. `$JAVA_HOME/bin/java` (`%JAVA_HOME%\bin\java.exe` on Windows). This is also
how the SDKMAN
+ `java` candidate is honored, since SDKMAN exports `JAVA_HOME`.
+3. The first `java` (`java.exe`) found on `PATH`.
+4. `CAMEL_FALLBACK_JAVA`, a new variable set by package-manager installs when
the JDK location
+ is deterministic.
+
+Candidates older than Java 17, missing, non-executable, or with unparseable
version output are
+skipped. If none qualify, the launcher now exits nonzero with a diagnostic
listing the checked
+sources instead of attempting to run an unsuitable Java.
+
+The obsolete macOS `JAVA_HOME=/System/Library/Frameworks/JavaVM.framework/...`
default was
+removed from `camel.sh`; set `JAVA_HOME` or `JAVACMD`, or ensure a Java 17+
`java` is on `PATH`.
+The Gentoo `java-config` auto-detection and the IBM AIX
`$JAVA_HOME/jre/sh/java` probe were also
+removed; set `JAVA_HOME` or `JAVACMD` explicitly on those platforms.
+`JAVA_OPTS` handling is unchanged: when unset, the default `-Xmx512m` heap is
applied.
+
=== camel-langchain4j-agent
The `Agent.chat()` method return type has changed from `String` to
`Result<String>` (from `dev.langchain4j.service.Result`).
diff --git a/dsl/camel-jbang/camel-launcher/src/main/resources/bin/camel.bat
b/dsl/camel-jbang/camel-launcher/src/main/resources/bin/camel.bat
index 1f6a7d38b441..692dbea4de26 100644
--- a/dsl/camel-jbang/camel-launcher/src/main/resources/bin/camel.bat
+++ b/dsl/camel-jbang/camel-launcher/src/main/resources/bin/camel.bat
@@ -28,45 +28,106 @@ set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set BASEDIR=%DIRNAME%
-@REM Java command to use
-if "%JAVA_HOME%" == "" goto noJavaHome
-set JAVACMD=%JAVA_HOME%\bin\java.exe
-goto checkJavaCmd
+@REM --- Shared Java 17+ discovery contract ---
+@REM Candidates in order: JAVACMD, %JAVA_HOME%\bin\java.exe, java.exe on PATH,
CAMEL_FALLBACK_JAVA.
+set CAMEL_MIN_JAVA=17
-:noJavaHome
-set JAVACMD=java.exe
+set "CAMEL_JAVACMD="
+call :tryJava "%JAVACMD%"
+if defined CAMEL_JAVACMD goto haveJava
+if defined JAVA_HOME call :tryJava "%JAVA_HOME%\bin\java.exe"
+if defined CAMEL_JAVACMD goto haveJava
+for %%p in (java.exe) do call :tryJava "%%~$PATH:p"
+if defined CAMEL_JAVACMD goto haveJava
+call :tryJava "%CAMEL_FALLBACK_JAVA%"
+if defined CAMEL_JAVACMD goto haveJava
+goto noJava
-:checkJavaCmd
-if exist "%JAVACMD%" goto init
+:haveJava
+set "JAVACMD=%CAMEL_JAVACMD%"
-echo.
-echo Error: JAVA_HOME is not defined correctly. >&2
-echo Cannot execute "%JAVACMD%" >&2
-echo.
-goto error
-
-:init
@REM Set JVM options if specified
if "%JAVA_OPTS%" == "" set JAVA_OPTS=-Xmx512m
-@REM Find the JAR file
-dir /b "%BASEDIR%\camel-launcher-*.jar" > nul 2>&1
-if not errorlevel 1 (
- for /f "tokens=*" %%j in ('dir /b "%BASEDIR%\camel-launcher-*.jar"') do set
LAUNCHER_JAR=%BASEDIR%\%%j
-) else (
- for %%i in ("%BASEDIR%\camel-launcher-*.jar") do set LAUNCHER_JAR=%%i
-)
+@REM Find the launcher JAR
+set "LAUNCHER_JAR="
+for %%i in ("%BASEDIR%\camel-launcher-*.jar") do set "LAUNCHER_JAR=%%i"
-@REM Execute Camel CLI
-"%JAVACMD%" %JAVA_OPTS% -jar "%LAUNCHER_JAR%" %*
-if ERRORLEVEL 1 goto error
-goto end
+@REM Execute Camel CLI, preserving arguments and the child exit code
+for %%e in ("%JAVACMD%") do set "_JAVACMD_EXT=%%~xe"
+if /i "%_JAVACMD_EXT%"==".bat" goto executeJavaBatch
+if /i "%_JAVACMD_EXT%"==".cmd" goto executeJavaBatch
+goto executeJavaNative
-:error
-set ERROR_CODE=1
+:executeJavaBatch
+call "%JAVACMD%" %JAVA_OPTS% -jar "%LAUNCHER_JAR%" %*
+set ERROR_CODE=%ERRORLEVEL%
+goto javaExecuted
-:end
+:executeJavaNative
+"%JAVACMD%" %JAVA_OPTS% -jar "%LAUNCHER_JAR%" %*
+set ERROR_CODE=%ERRORLEVEL%
+
+:javaExecuted
@REM End local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" endlocal & set ERROR_CODE=%ERROR_CODE%
+exit /B %ERROR_CODE%
+
+:noJava
+echo. 1>&2
+echo Error: no suitable Java runtime found. Camel CLI requires Java
%CAMEL_MIN_JAVA% or newer. 1>&2
+echo Checked: JAVACMD, %%JAVA_HOME%%\bin\java.exe, java.exe on PATH,
CAMEL_FALLBACK_JAVA. 1>&2
+echo Install a Java %CAMEL_MIN_JAVA%+ runtime and set JAVA_HOME or JAVACMD, or
add java.exe to PATH. 1>&2
if "%OS%"=="Windows_NT" endlocal
+exit /B 1
-exit /B %ERROR_CODE%
+:tryJava
+@REM %1 = candidate path (quoted). On success sets CAMEL_JAVACMD.
+set "_CAND=%~1"
+if "%_CAND%"=="" exit /b 1
+if not exist "%_CAND%" exit /b 1
+for %%e in ("%_CAND%") do set "_CAND_EXT=%%~xe"
+set "_PROBE_DIR="
+if defined TEMP set "_PROBE_DIR=%TEMP%"
+if not defined _PROBE_DIR if defined TMP set "_PROBE_DIR=%TMP%"
+if not defined _PROBE_DIR set "_PROBE_DIR=%BASEDIR%"
+
+:tryJavaProbePath
+set "_PROBE=%_PROBE_DIR%\camel-java-probe-%RANDOM%-%RANDOM%.tmp"
+if exist "%_PROBE%" goto tryJavaProbePath
+if /i "%_CAND_EXT%"==".bat" goto tryJavaProbeBatch
+if /i "%_CAND_EXT%"==".cmd" goto tryJavaProbeBatch
+goto tryJavaProbeNative
+
+:tryJavaProbeBatch
+call "%_CAND%" -version <NUL >"%_PROBE%" 2>&1
+set "_PROBE_RC=%ERRORLEVEL%"
+goto tryJavaProbeComplete
+
+:tryJavaProbeNative
+"%_CAND%" -version <NUL >"%_PROBE%" 2>&1
+set "_PROBE_RC=%ERRORLEVEL%"
+
+:tryJavaProbeComplete
+if "%_PROBE_RC%"=="0" goto tryJavaParseProbe
+del /q "%_PROBE%" >NUL 2>&1
+set "_PROBE="
+exit /b 1
+
+:tryJavaParseProbe
+set "_RAW="
+for /f "tokens=3" %%v in ('findstr /b /l /i /c:"java version " /c:"openjdk
version " "%_PROBE%"') do if not defined _RAW set "_RAW=%%v"
+del /q "%_PROBE%" >NUL 2>&1
+set "_PROBE="
+if not defined _RAW exit /b 1
+set "_RAW=%_RAW:"=%"
+set "_MAJOR="
+for /f "tokens=1,2 delims=." %%a in ("%_RAW%") do (
+ if "%%a"=="1" (set "_MAJOR=%%b") else (set "_MAJOR=%%a")
+)
+if not defined _MAJOR exit /b 1
+@REM Reject non-numeric majors (e.g. early-access "17-ea") so the numeric
compare below is safe.
+for /f "delims=0123456789" %%z in ("%_MAJOR%") do exit /b 1
+if %_MAJOR% LSS %CAMEL_MIN_JAVA% exit /b 1
+set "CAMEL_JAVACMD=%_CAND%"
+exit /b 0
diff --git a/dsl/camel-jbang/camel-launcher/src/main/resources/bin/camel.sh
b/dsl/camel-jbang/camel-launcher/src/main/resources/bin/camel.sh
index 9e72387549bd..430e7a0fc1a8 100644
--- a/dsl/camel-jbang/camel-launcher/src/main/resources/bin/camel.sh
+++ b/dsl/camel-jbang/camel-launcher/src/main/resources/bin/camel.sh
@@ -37,50 +37,61 @@ REPO=
# OS specific support. $var _must_ be set to either true or false.
cygwin=false;
-darwin=false;
case "`uname`" in
CYGWIN*) cygwin=true ;;
- Darwin*) darwin=true
- if [ -z "$JAVA_VERSION" ] ; then
- JAVA_VERSION="CurrentJDK"
- else
- echo "Using Java version: $JAVA_VERSION"
- fi
- if [ -z "$JAVA_HOME" ] ; then
-
JAVA_HOME=/System/Library/Frameworks/JavaVM.framework/Versions/${JAVA_VERSION}/Home
- fi
- ;;
esac
-if [ -z "$JAVA_HOME" ] ; then
- if [ -r /etc/gentoo-release ] ; then
- JAVA_HOME=`java-config --jre-home`
- fi
-fi
-
-# For Cygwin, ensure paths are in UNIX format before anything is touched
+# For Cygwin, ensure JAVA_HOME is in UNIX format before it is probed.
if $cygwin ; then
[ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"`
[ -n "$CLASSPATH" ] && CLASSPATH=`cygpath --path --unix "$CLASSPATH"`
fi
-# If a specific java binary isn't specified search for the standard 'java'
binary
-if [ -z "$JAVACMD" ] ; then
- if [ -n "$JAVA_HOME" ] ; then
- if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
- # IBM's JDK on AIX uses strange locations for the executables
- JAVACMD="$JAVA_HOME/jre/sh/java"
- else
- JAVACMD="$JAVA_HOME/bin/java"
- fi
- else
- JAVACMD=`which java`
- fi
-fi
+# --- Shared Java 17+ discovery contract ---
+# Candidates are tried in order: JAVACMD, $JAVA_HOME/bin/java, java on PATH,
CAMEL_FALLBACK_JAVA.
+# ($JAVA_HOME is also how SDKMAN's java candidate exposes its runtime.)
+# Each must exist, be executable, and report a Java major version >=
CAMEL_MIN_JAVA.
+# Probe output is captured and never printed during a normal invocation.
+CAMEL_MIN_JAVA=17
+
+# Echo the Java major version of the launcher in $1, or nothing if it cannot
be determined.
+camel_java_major() {
+ _cjm_out=`"$1" -version </dev/null 2>&1` || return 1
+ _cjm_ver=`echo "$_cjm_out" | sed -n 's/.*version "\([0-9][0-9.]*\).*/\1/p' |
head -n 1`
+ [ -n "$_cjm_ver" ] || return 1
+ case "$_cjm_ver" in
+ 1.*) echo "$_cjm_ver" | cut -d. -f2 ;;
+ *) echo "$_cjm_ver" | cut -d. -f1 ;;
+ esac
+}
+
+# Return 0 and set JAVACMD when $1 is an existing, executable, Java >=
CAMEL_MIN_JAVA launcher.
+camel_try_java() {
+ [ -n "$1" ] || return 1
+ [ -x "$1" ] || return 1
+ _ctj_major=`camel_java_major "$1"` || return 1
+ [ -n "$_ctj_major" ] || return 1
+ { [ "$_ctj_major" -ge "$CAMEL_MIN_JAVA" ]; } 2>/dev/null || return 1
+ JAVACMD="$1"
+ return 0
+}
-if [ ! -x "$JAVACMD" ] ; then
- echo "Error: JAVA_HOME is not defined correctly." 1>&2
- echo " We cannot execute $JAVACMD" 1>&2
+if camel_try_java "$JAVACMD"; then
+ :
+elif [ -n "$JAVA_HOME" ] && camel_try_java "$JAVA_HOME/bin/java"; then
+ :
+elif _camel_path_java=`command -v java 2>/dev/null` && camel_try_java
"$_camel_path_java"; then
+ :
+elif camel_try_java "$CAMEL_FALLBACK_JAVA"; then
+ :
+else
+ echo "Error: no suitable Java runtime found. Camel CLI requires Java
$CAMEL_MIN_JAVA or newer." 1>&2
+ echo "Checked the following sources (in order):" 1>&2
+ echo " 1. JAVACMD = ${JAVACMD:-<unset>}" 1>&2
+ echo " 2. JAVA_HOME/bin/java = ${JAVA_HOME:+$JAVA_HOME/bin/java}" 1>&2
+ echo " 3. java on PATH = ${_camel_path_java:-<not found>}" 1>&2
+ echo " 4. CAMEL_FALLBACK_JAVA = ${CAMEL_FALLBACK_JAVA:-<unset>}" 1>&2
+ echo "Install a Java $CAMEL_MIN_JAVA+ runtime and either add it to PATH, set
JAVA_HOME, or set JAVACMD." 1>&2
exit 1
fi
diff --git
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelBatDiscoveryTest.java
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelBatDiscoveryTest.java
new file mode 100644
index 000000000000..424311576275
--- /dev/null
+++
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelBatDiscoveryTest.java
@@ -0,0 +1,564 @@
+/*
+ * 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.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.jar.Attributes;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Windows equivalent of {@link CamelShDiscoveryTest}: verifies camel.bat
implements the same shared Java 17+ discovery
+ * contract with observably equivalent behavior.
+ */
+@EnabledOnOs(OS.WINDOWS)
+class CamelBatDiscoveryTest {
+
+ private static final Path SCRIPT =
Paths.get("src/main/resources/bin/camel.bat");
+
+ private static final String JAVA17 = "openjdk version \"17.0.2\"
2022-01-18";
+ private static final String JAVA21 = "openjdk version \"21.0.3\"
2024-04-16";
+ private static final String JAVA11 = "openjdk version \"11.0.20\"
2023-07-18";
+ private static final String JAVA8 = "java version \"1.8.0_202\"";
+ private static final String JAVA17_EA = "openjdk version \"17-ea\"";
+ private static final String GARBAGE = "totally not a java banner";
+
+ private Path home(Path base) throws Exception {
+ return FakeJava.newLauncherHome(base, SCRIPT);
+ }
+
+ private Map<String, String> isolatedEnvironment() {
+ String systemRoot = System.getenv("SystemRoot");
+ assertTrue(systemRoot != null && !systemRoot.isBlank(), "Windows test
fixture requires SystemRoot");
+ Path system32 = Path.of(systemRoot, "System32");
+ assertTrue(Files.isDirectory(system32), "Windows test fixture requires
SystemRoot\\System32: " + system32);
+ Map<String, String> env = new HashMap<>();
+ env.put("PATH", system32.toString());
+ return env;
+ }
+
+ private Path currentJavaHome() {
+ Path javaHome = Path.of(System.getProperty("java.home"));
+ assertTrue(Files.isRegularFile(javaHome.resolve("bin/java.exe")),
+ "Windows test fixture requires the current JDK java.exe: " +
javaHome);
+ return javaHome;
+ }
+
+ @Test
+ void acceptsJava17ViaJavacmd(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "JAVACMD");
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", java.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=JAVACMD"), r.stdout());
+ assertFalse(r.stdout().contains("openjdk version"), "probe banner
leaked to stdout");
+ assertFalse(r.stderr().contains("openjdk version"), "probe banner
leaked to stderr");
+ }
+
+ @Test
+ void acceptsJava21ViaJavacmd(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path java = FakeJava.writeFakeJava(base, "java", JAVA21, 0, "JAVA21");
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", java.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=JAVA21"), r.stdout());
+ }
+
+ @Test
+ void precedenceJavacmdBeatsJavaHome(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0,
"JAVACMD-FIRST");
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", java.toString());
+ env.put("JAVA_HOME", currentJavaHome().toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=JAVACMD-FIRST"), r.stdout());
+ assertFalse(r.stdout().contains("RAN=NATIVE-JAVA"), r.stdout());
+ }
+
+ @Test
+ void candidateSourceOrderIsJavacmdJavaHomePathThenFallback(@TempDir Path
base) throws Exception {
+ Path h = home(base);
+ String launcher = Files.readString(h.resolve("camel.bat"),
StandardCharsets.UTF_8);
+ List<String> expected = expectedDiscoveryLines();
+
+ assertEquals(expected, executableDiscoveryLines(launcher),
+ "active discovery order must be JAVACMD, JAVA_HOME, PATH
java.exe, CAMEL_FALLBACK_JAVA");
+
+ String commentedCandidates = String.join("\n",
expected.stream().map(line -> "REM " + line).toList());
+ String commentedDecoy = "set \"CAMEL_JAVACMD=\"\n" +
commentedCandidates + "\n"
+ + ":haveJava\n"
+ + String.join("\n", expected);
+ assertFalse(expected.equals(executableDiscoveryLines(commentedDecoy)),
+ "commented or out-of-region candidate text must not satisfy
the active discovery contract");
+ }
+
+ @Test
+ void honorsJavaHomeWhenOnlySource(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ writeRealJavaMarkerJar(h);
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVA_HOME", currentJavaHome().toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=NATIVE-JAVA"), r.stdout());
+ assertTrue(r.stdout().contains("version"), r.stdout());
+ }
+
+ @Test
+ void missingJavacmdContinuesToJavaHome(@TempDir Path base) throws
Exception {
+ Path h = home(base);
+ writeRealJavaMarkerJar(h);
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", base.resolve("missing-java.bat").toString());
+ env.put("JAVA_HOME", currentJavaHome().toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=NATIVE-JAVA"), r.stdout());
+ }
+
+ @Test
+ void presentButNonRunnableJavacmdContinuesToJavaHome(@TempDir Path base)
throws Exception {
+ Path h = home(base);
+ writeRealJavaMarkerJar(h);
+ Path nonRunnable = base.resolve("not-a-program.exe");
+ Files.writeString(nonRunnable, "not a Windows executable",
StandardCharsets.UTF_8);
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", nonRunnable.toString());
+ env.put("JAVA_HOME", currentJavaHome().toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=NATIVE-JAVA"), r.stdout());
+ }
+
+ @Test
+ void acceptsNativeJavaFromPath(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ writeRealJavaMarkerJar(h);
+ Map<String, String> env = isolatedEnvironment();
+ env.put("PATH", currentJavaHome().resolve("bin") + ";" +
env.get("PATH"));
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=NATIVE-JAVA"), r.stdout());
+ }
+
+ @Test
+ void precedenceNativePathBeatsCamelFallbackJava(@TempDir Path base) throws
Exception {
+ Path h = home(base);
+ writeRealJavaMarkerJar(h);
+ Path fallback = FakeJava.writeFakeJava(base.resolve("fallback"),
"java", JAVA17, 0, "FALLBACK");
+ Map<String, String> env = isolatedEnvironment();
+ env.put("PATH", currentJavaHome().resolve("bin") + ";" +
env.get("PATH"));
+ env.put("CAMEL_FALLBACK_JAVA", fallback.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=NATIVE-JAVA"), r.stdout());
+ assertFalse(r.stdout().contains("RAN=FALLBACK"), r.stdout());
+ }
+
+ @Test
+ void skipsOldJavacmdFallsBackToFallback(@TempDir Path base) throws
Exception {
+ Path h = home(base);
+ Path old = FakeJava.writeFakeJava(base.resolve("old"), "java", JAVA11,
0, "OLD");
+ Path fallback = FakeJava.writeFakeJava(base.resolve("fallback"),
"java", JAVA17, 0, "FALLBACK");
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", old.toString());
+ env.put("JAVA_HOME", base.resolve("invalid-java-home").toString());
+ env.put("CAMEL_FALLBACK_JAVA", fallback.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=FALLBACK"), r.stdout());
+ assertFalse(r.stdout().contains("RAN=OLD"), r.stdout());
+ }
+
+ @Test
+ void rejectsWhenNoCandidateQualifies(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path old = FakeJava.writeFakeJava(base.resolve("old"), "java", JAVA8,
0, "OLD");
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", old.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(1, r.exitCode());
+ assertTrue(r.stderr().contains("17"), "diagnostic must state the Java
17 minimum: " + r.stderr());
+ assertTrue(r.stderr().toLowerCase().contains("java"), r.stderr());
+ }
+
+ @Test
+ void rejectsUnparseableCandidate(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path garbage = FakeJava.writeFakeJava(base.resolve("garbage"), "java",
GARBAGE, 0, "GARBAGE");
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", garbage.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(1, r.exitCode());
+ }
+
+ @Test
+ void rejectsNonnumericMajorAndContinuesToFallback(@TempDir Path base)
throws Exception {
+ Path h = home(base);
+ Path earlyAccess =
FakeJava.writeFakeJava(base.resolve("early-access"), "java", JAVA17_EA, 0,
"EARLY");
+ Path fallback = FakeJava.writeFakeJava(base.resolve("fallback"),
"java", JAVA17, 0, "FALLBACK");
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", earlyAccess.toString());
+ env.put("CAMEL_FALLBACK_JAVA", fallback.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=FALLBACK"), r.stdout());
+ assertFalse(r.stdout().contains("RAN=EARLY"), r.stdout());
+ }
+
+ @Test
+ void handlesSpacesAndUnicodeInJavaPath(@TempDir Path base) throws
Exception {
+ Path h = home(base);
+ Path java = FakeJava.writeFakeJava(base.resolve("spa ce/über"),
"java", JAVA17, 0, "UNICODE");
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", java.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=UNICODE"), r.stdout());
+ }
+
+ @Test
+ void defaultsHeapWhenJavaOptsUnset(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "HEAP");
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", java.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("-Xmx512m"), "default heap must be
applied: " + r.stdout());
+ }
+
+ @Test
+ void preservesJavaOptsWhenSet(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "OPTS");
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", java.toString());
+ env.put("JAVA_OPTS", "-Dcamel.test=1 -Xmx256m");
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("-Dcamel.test=1"), r.stdout());
+ assertTrue(r.stdout().contains("-Xmx256m"), r.stdout());
+ assertFalse(r.stdout().contains("-Xmx512m"), "default heap must not be
added when JAVA_OPTS is set");
+ }
+
+ @Test
+ void
javacmdBatchWrapperReturnsForProbeAndLaunchAndPreservesBehavior(@TempDir Path
base) throws Exception {
+ Path h = home(base);
+ Path wrapper = writeBatch(base.resolve("wrapper"), "java",
+ "if \"%~1\"==\"-version\" (",
+ " echo " + JAVA17 + " 1>&2",
+ " exit /b 0",
+ ")",
+ "set ARG_COUNT=0",
+ "for %%a in (%*) do set /a ARG_COUNT+=1 >NUL",
+ "echo ARG_COUNT=%ARG_COUNT%",
+ "echo ARG1=[%~1]",
+ "echo ARG2=[%~2]",
+ "echo ARG3=[%~3]",
+ "echo ARG4=[%~4]",
+ "echo ARG5=[%~5]",
+ "echo ARG6=[%~6]",
+ "echo STDERR=JAVACMD-WRAPPER 1>&2",
+ "more",
+ "exit /b 37");
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", wrapper.toString());
+
+ FakeJava.Result r = FakeJava.runWithInput(h.resolve("camel.bat"), env,
"route input\r\n", "run",
+ "my route.yaml");
+
+ assertEquals(37, r.exitCode(), "batch child exit code must be
propagated");
+ assertTrue(r.stdout().contains("ARG_COUNT=5"), r.stdout());
+ assertTrue(r.stdout().contains("ARG1=[-Xmx512m]"), r.stdout());
+ assertTrue(r.stdout().contains("ARG2=[-jar]"), r.stdout());
+ assertTrue(r.stdout().contains("ARG3=[" +
h.resolve("camel-launcher-9.9.9.jar") + "]"), r.stdout());
+ assertTrue(r.stdout().contains("ARG4=[run]"), r.stdout());
+ assertTrue(r.stdout().contains("ARG5=[my route.yaml]"), r.stdout());
+ assertTrue(r.stdout().contains("ARG6=[]"), r.stdout());
+ assertTrue(r.stdout().contains("route input"), "stdin did not reach
batch child stdout: " + r.stdout());
+ assertTrue(r.stderr().contains("STDERR=JAVACMD-WRAPPER"), "batch child
stderr was not preserved: " + r.stderr());
+ }
+
+ @Test
+ void finalBatchWrapperReturnsToLauncherStatementAfterCall(@TempDir Path
base) throws Exception {
+ Path sentinel = base.resolve("launcher-resumed.txt");
+ Path h = instrumentBatchLaunchReturn(home(base), sentinel);
+ Path wrapper = FakeJava.writeFakeJava(base.resolve("wrapper"), "java",
JAVA17, 41, "RETURN");
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", wrapper.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(41, r.exitCode(), "captured batch child exit code must
survive sentinel instrumentation");
+ assertEquals("LAUNCHER-RESUMED", Files.readString(sentinel).trim(),
+ "launcher did not resume after the final batch wrapper call");
+ }
+
+ @Test
+ void
fallbackBatchWrapperIsReachedAfterInvalidCandidatesAndPropagatesExit(@TempDir
Path base) throws Exception {
+ Path h = home(base);
+ Path old = FakeJava.writeFakeJava(base.resolve("old"), "java", JAVA11,
0, "OLD");
+ Path fallback = FakeJava.writeFakeJava(base.resolve("fallback"),
"java", JAVA17, 29, "FALLBACK-WRAPPER");
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", old.toString());
+ env.put("JAVA_HOME", base.resolve("missing-home").toString());
+ env.put("CAMEL_FALLBACK_JAVA", fallback.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(29, r.exitCode(), "fallback batch child exit code must be
propagated");
+ assertTrue(r.stdout().contains("RAN=FALLBACK-WRAPPER"), r.stdout());
+ }
+
+ @Test
+ void baseDirProbeFallbackCleansArtifactWhenTempAndTmpAreAbsent(@TempDir
Path base) throws Exception {
+ Path h = home(base);
+ Path probePathMarker = base.resolve("probe-path.txt");
+ Path java = writeBatch(base.resolve("wrapper"), "java",
+ "if \"%~1\"==\"-version\" (",
+ " echo %_PROBE% >\"" + probePathMarker + "\"",
+ " echo " + JAVA17 + " 1>&2",
+ " exit /b 0",
+ ")",
+ "echo RAN=BASEDIR-PROBE",
+ "exit /b 0");
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", java.toString());
+ assertFalse(env.containsKey("TEMP"));
+ assertFalse(env.containsKey("TMP"));
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ Path actualProbe =
Path.of(Files.readString(probePathMarker).trim()).toAbsolutePath().normalize();
+ assertEquals(h.toAbsolutePath().normalize(), actualProbe.getParent(),
"probe did not use BASEDIR fallback");
+ try (var probes = Files.list(h)) {
+ assertFalse(probes.anyMatch(path ->
path.getFileName().toString().startsWith("camel-java-probe-")
+ && path.getFileName().toString().endsWith(".tmp")), "probe
artifact remained in BASEDIR");
+ }
+ }
+
+ @Test
+ void skipsValidLookingBannerWhenProbeExitsNonzero(@TempDir Path base)
throws Exception {
+ Path h = home(base);
+ Path rejected = writeBatch(base.resolve("rejected"), "java",
+ "if \"%~1\"==\"-version\" (",
+ " echo " + JAVA17 + " 1>&2",
+ " exit /b 7",
+ ")",
+ "echo RAN=REJECTED");
+ Path fallback = FakeJava.writeFakeJava(base.resolve("fallback"),
"java", JAVA17, 0, "FALLBACK");
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", rejected.toString());
+ env.put("CAMEL_FALLBACK_JAVA", fallback.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=FALLBACK"), r.stdout());
+ assertFalse(r.stdout().contains("RAN=REJECTED"), r.stdout());
+ }
+
+ @Test
+ void acceptsValidBannerAfterPreBannerLineContainingVersion(@TempDir Path
base) throws Exception {
+ Path h = home(base);
+ Path java = writeBatch(base.resolve("banner"), "java",
+ "if \"%~1\"==\"-version\" (",
+ " echo wrapper version metadata 1>&2",
+ " echo " + JAVA17 + " 1>&2",
+ " exit /b 0",
+ ")",
+ "echo RAN=VALID-BANNER",
+ "exit /b 0");
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", java.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=VALID-BANNER"), r.stdout());
+ assertFalse(r.stdout().contains("wrapper version metadata"), "probe
output leaked: " + r.stdout());
+ assertFalse(r.stderr().contains("wrapper version metadata"), "probe
output leaked: " + r.stderr());
+ }
+
+ @Test
+ void probeCannotConsumeChildStdin(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path java = writeBatch(base.resolve("stdin"), "java",
+ "if \"%~1\"==\"-version\" (",
+ " set /p \"ignored=\"",
+ " echo " + JAVA17 + " 1>&2",
+ " exit /b 0",
+ ")",
+ "echo RAN=PROBE-STDIN",
+ "more",
+ "exit /b 0");
+ Map<String, String> env = isolatedEnvironment();
+ env.put("JAVACMD", java.toString());
+
+ FakeJava.Result r = FakeJava.runWithInput(h.resolve("camel.bat"), env,
"route input\r\n", "version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=PROBE-STDIN"), r.stdout());
+ assertTrue(r.stdout().contains("route input"), "version probe consumed
child stdin: " + r.stdout());
+ }
+
+ private Path writeBatch(Path dir, String name, String... lines) throws
Exception {
+ Files.createDirectories(dir);
+ Path script = dir.resolve(name + ".bat");
+ Files.writeString(script, "@echo off\r\n" + String.join("\r\n", lines)
+ "\r\n", StandardCharsets.UTF_8);
+ return script;
+ }
+
+ private List<String> expectedDiscoveryLines() {
+ return List.of(
+ "call :tryJava \"%JAVACMD%\"",
+ "if defined CAMEL_JAVACMD goto haveJava",
+ "if defined JAVA_HOME call :tryJava
\"%JAVA_HOME%\\bin\\java.exe\"",
+ "if defined CAMEL_JAVACMD goto haveJava",
+ "for %%p in (java.exe) do call :tryJava \"%%~$PATH:p\"",
+ "if defined CAMEL_JAVACMD goto haveJava",
+ "call :tryJava \"%CAMEL_FALLBACK_JAVA%\"",
+ "if defined CAMEL_JAVACMD goto haveJava",
+ "goto noJava");
+ }
+
+ private List<String> executableDiscoveryLines(String source) {
+ List<String> lines = source.lines()
+ .map(String::trim)
+ .map(line -> line.replaceAll("\\s+", " "))
+ .toList();
+ assertEquals(1, lines.stream().filter("set
\"CAMEL_JAVACMD=\""::equals).count(),
+ "active discovery start must be unique");
+ assertEquals(1, lines.stream().filter(":haveJava"::equals).count(),
+ "active discovery end must be unique");
+ int start = lines.indexOf("set \"CAMEL_JAVACMD=\"");
+ int end = lines.indexOf(":haveJava");
+ assertTrue(start >= 0, "active discovery start was not found");
+ assertTrue(end > start, "active discovery end was not found after its
start");
+ return lines.subList(start + 1, end).stream()
+ .filter(line -> !line.isBlank())
+ .filter(line -> !isBatchComment(line))
+ .toList();
+ }
+
+ private boolean isBatchComment(String line) {
+ String command = line.startsWith("@") ?
line.substring(1).stripLeading() : line;
+ return command.equalsIgnoreCase("REM")
+ || command.regionMatches(true, 0, "REM ", 0, 4)
+ || command.startsWith("::");
+ }
+
+ private Path instrumentBatchLaunchReturn(Path launcherHome, Path sentinel)
throws Exception {
+ Path launcher = launcherHome.resolve("camel.bat");
+ String source = Files.readString(launcher, StandardCharsets.UTF_8);
+ String newline = source.contains("\r\n") ? "\r\n" : "\n";
+ String launchBlock = "call \"%JAVACMD%\" %JAVA_OPTS% -jar
\"%LAUNCHER_JAR%\" %*" + newline
+ + "set ERROR_CODE=%ERRORLEVEL%" + newline
+ + "goto javaExecuted";
+ String instrumentedBlock = "call \"%JAVACMD%\" %JAVA_OPTS% -jar
\"%LAUNCHER_JAR%\" %*" + newline
+ + "set ERROR_CODE=%ERRORLEVEL%" + newline
+ + "echo LAUNCHER-RESUMED>\"" + sentinel +
"\"" + newline
+ + "goto javaExecuted";
+ String instrumented = source.replace(launchBlock, instrumentedBlock);
+ assertFalse(instrumented.equals(source), "could not instrument copied
batch-launch block");
+ Files.writeString(launcher, instrumented, StandardCharsets.UTF_8);
+ return launcherHome;
+ }
+
+ private void writeRealJavaMarkerJar(Path launcherHome) throws Exception {
+ Manifest manifest = new Manifest();
+ manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION,
"1.0");
+ manifest.getMainAttributes().put(Attributes.Name.MAIN_CLASS,
CamelBatRealJavaMarker.class.getName());
+ String entryName = CamelBatRealJavaMarker.class.getName().replace('.',
'/') + ".class";
+ Path jar = launcherHome.resolve("camel-launcher-9.9.9.jar");
+ try (InputStream classBytes =
CamelBatRealJavaMarker.class.getResourceAsStream("/" + entryName);
+ JarOutputStream out = new
JarOutputStream(Files.newOutputStream(jar), manifest)) {
+ assertTrue(classBytes != null, "could not load marker class
bytes");
+ out.putNextEntry(new JarEntry(entryName));
+ classBytes.transferTo(out);
+ out.closeEntry();
+ }
+ }
+
+}
+
+final class CamelBatRealJavaMarker {
+
+ private CamelBatRealJavaMarker() {
+ }
+
+ public static void main(String[] args) {
+ System.out.println("RAN=NATIVE-JAVA");
+ System.out.println("ARGS=" + Arrays.toString(args));
+ }
+}
diff --git
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelShDiscoveryTest.java
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelShDiscoveryTest.java
new file mode 100644
index 000000000000..ad2735ed362c
--- /dev/null
+++
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelShDiscoveryTest.java
@@ -0,0 +1,404 @@
+/*
+ * 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.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies the shared Java 17+ discovery contract implemented in camel.sh.
POSIX only; the Windows batch equivalent
+ * lives in {@link CamelBatDiscoveryTest}.
+ */
+@DisabledOnOs(OS.WINDOWS)
+class CamelShDiscoveryTest {
+
+ private static final Path SCRIPT =
Paths.get("src/main/resources/bin/camel.sh");
+
+ private static final String JAVA17 = "openjdk version \"17.0.2\"
2022-01-18";
+ private static final String JAVA21 = "openjdk version \"21.0.3\"
2024-04-16";
+ private static final String JAVA11 = "openjdk version \"11.0.20\"
2023-07-18";
+ private static final String JAVA8 = "java version \"1.8.0_202\"";
+ private static final String GARBAGE = "totally not a java banner";
+
+ private Path home(Path base) throws Exception {
+ return FakeJava.newLauncherHome(base, SCRIPT);
+ }
+
+ private String pathWithNonqualifyingJava(Path base) throws Exception {
+ Path pathDir = base.resolve("path-shadow");
+ FakeJava.writeFakeJava(pathDir, "java", GARBAGE, 0, "PATH-REJECTED");
+ return pathDir + ":/usr/bin:/bin";
+ }
+
+ @Test
+ void acceptsJava17ViaJavacmd(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "JAVACMD");
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVACMD", java.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=JAVACMD"), r.stdout());
+ // Probe output must not leak to the user during a normal invocation.
+ assertFalse(r.stdout().contains("openjdk version"), "probe banner
leaked to stdout");
+ assertFalse(r.stderr().contains("openjdk version"), "probe banner
leaked to stderr");
+ }
+
+ @Test
+ void acceptsJava21(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path java = FakeJava.writeFakeJava(base, "java", JAVA21, 0, "JAVACMD");
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVACMD", java.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=JAVACMD"));
+ }
+
+ @Test
+ void precedenceJavacmdBeatsJavaHomeAndPath(@TempDir Path base) throws
Exception {
+ Path h = home(base);
+ Path first = FakeJava.writeFakeJava(base.resolve("j1"), "java",
JAVA17, 0, "JAVACMD");
+ Path jhomeBin = base.resolve("jh/bin");
+ FakeJava.writeFakeJava(jhomeBin, "java", JAVA21, 0, "JAVAHOME");
+ Path pathDir = base.resolve("pd");
+ FakeJava.writeFakeJava(pathDir, "java", JAVA21, 0, "PATH");
+
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVACMD", first.toString());
+ env.put("JAVA_HOME", base.resolve("jh").toString());
+ env.put("PATH", pathDir + ":/usr/bin:/bin");
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=JAVACMD"), r.stdout());
+ }
+
+ @Test
+ void honorsJavaHomeWhenOnlySourceLikeSdkman(@TempDir Path base) throws
Exception {
+ // SDKMAN provides Java by exporting JAVA_HOME (candidate #2) with no
JAVACMD set.
+ Path h = home(base);
+ Path jhomeBin = base.resolve("jh/bin");
+ FakeJava.writeFakeJava(jhomeBin, "java", JAVA17, 0, "JAVAHOME");
+
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVA_HOME", base.resolve("jh").toString());
+ env.put("PATH", pathWithNonqualifyingJava(base));
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=JAVAHOME"), r.stdout());
+ }
+
+ @Test
+ void skipsInvalidJavacmdFallsBackToJavaHome(@TempDir Path base) throws
Exception {
+ Path h = home(base);
+ Path jhomeBin = base.resolve("jh/bin");
+ FakeJava.writeFakeJava(jhomeBin, "java", JAVA17, 0, "JAVAHOME");
+
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVACMD", base.resolve("does-not-exist").toString());
+ env.put("JAVA_HOME", base.resolve("jh").toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=JAVAHOME"), r.stdout());
+ }
+
+ @Test
+ void skipsNonExecutableJavacmdFallsBackToJavaHome(@TempDir Path base)
throws Exception {
+ Path h = home(base);
+ Path nonExecutable = base.resolve("non-executable-java");
+ Files.writeString(nonExecutable, "#!/bin/sh\nexit 0\n");
+ assertFalse(Files.isExecutable(nonExecutable), "test candidate must
not be executable");
+ Path jhomeBin = base.resolve("jh/bin");
+ FakeJava.writeFakeJava(jhomeBin, "java", JAVA17, 0, "JAVAHOME");
+
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVACMD", nonExecutable.toString());
+ env.put("JAVA_HOME", base.resolve("jh").toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=JAVAHOME"), r.stdout());
+ }
+
+ @Test
+ void skipsOldJavaHomeFallsBackToPath(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path jhomeBin = base.resolve("jh/bin");
+ FakeJava.writeFakeJava(jhomeBin, "java", JAVA11, 0, "JAVAHOME"); //
too old
+ Path pathDir = base.resolve("pd");
+ FakeJava.writeFakeJava(pathDir, "java", JAVA17, 0, "PATH");
+
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVA_HOME", base.resolve("jh").toString());
+ env.put("PATH", pathDir + ":/usr/bin:/bin");
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=PATH"), r.stdout());
+ assertFalse(r.stdout().contains(JAVA11), "rejected probe banner leaked
to stdout");
+ assertFalse(r.stderr().contains(JAVA11), "rejected probe banner leaked
to stderr");
+ }
+
+ @Test
+ void precedenceJavaHomeBeatsPath(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path jhomeBin = base.resolve("jh/bin");
+ FakeJava.writeFakeJava(jhomeBin, "java", JAVA17, 0, "JAVAHOME");
+ Path pathDir = base.resolve("pd");
+ FakeJava.writeFakeJava(pathDir, "java", JAVA21, 0, "PATH");
+
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVA_HOME", base.resolve("jh").toString());
+ env.put("PATH", pathDir + ":/usr/bin:/bin");
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=JAVAHOME"), r.stdout());
+ }
+
+ @Test
+ void precedencePathBeatsCamelFallbackJava(@TempDir Path base) throws
Exception {
+ Path h = home(base);
+ Path pathDir = base.resolve("pd");
+ FakeJava.writeFakeJava(pathDir, "java", JAVA17, 0, "PATH");
+ Path fallback = FakeJava.writeFakeJava(base.resolve("fb"), "java",
JAVA21, 0, "FALLBACK");
+
+ Map<String, String> env = new HashMap<>();
+ env.put("PATH", pathDir + ":/usr/bin:/bin");
+ env.put("CAMEL_FALLBACK_JAVA", fallback.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=PATH"), r.stdout());
+ }
+
+ @Test
+ void usesCamelFallbackJavaWhenNothingElseQualifies(@TempDir Path base)
throws Exception {
+ Path h = home(base);
+ Path fb = FakeJava.writeFakeJava(base.resolve("fb"), "java", JAVA17,
0, "FALLBACK");
+
+ Map<String, String> env = new HashMap<>();
+ env.put("PATH", pathWithNonqualifyingJava(base));
+ env.put("CAMEL_FALLBACK_JAVA", fb.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=FALLBACK"), r.stdout());
+ }
+
+ @Test
+ void rejectsWhenNoCandidateQualifies(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path old = FakeJava.writeFakeJava(base.resolve("old"), "java", JAVA8,
0, "OLD");
+
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVACMD", old.toString());
+ env.put("PATH", pathWithNonqualifyingJava(base));
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env,
"version");
+
+ assertEquals(1, r.exitCode());
+ assertTrue(r.stderr().contains("17"), "diagnostic must state the Java
17 minimum: " + r.stderr());
+ assertTrue(r.stderr().toLowerCase().contains("java"), r.stderr());
+ }
+
+ @Test
+ void rejectsUnparseableCandidate(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path garbage = FakeJava.writeFakeJava(base.resolve("g"), "java",
GARBAGE, 0, "GARBAGE");
+
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVACMD", garbage.toString());
+ env.put("PATH", pathWithNonqualifyingJava(base));
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env,
"version");
+
+ assertEquals(1, r.exitCode());
+ }
+
+ @Test
+ void skipsUnparseableJavacmdFallsBackToJavaHome(@TempDir Path base) throws
Exception {
+ Path h = home(base);
+ Path garbage = FakeJava.writeFakeJava(base.resolve("g"), "java",
GARBAGE, 0, "GARBAGE");
+ Path jhomeBin = base.resolve("jh/bin");
+ FakeJava.writeFakeJava(jhomeBin, "java", JAVA17, 0, "JAVAHOME");
+
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVACMD", garbage.toString());
+ env.put("JAVA_HOME", base.resolve("jh").toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=JAVAHOME"), r.stdout());
+ assertFalse(r.stdout().contains(GARBAGE), "rejected probe banner
leaked to stdout");
+ assertFalse(r.stderr().contains(GARBAGE), "rejected probe banner
leaked to stderr");
+ }
+
+ @Test
+ void parsesLegacyOneDotEightAsMajorEight(@TempDir Path base) throws
Exception {
+ // 1.8.0_202 must resolve to major 8 and therefore be rejected.
+ Path h = home(base);
+ Path java8 = FakeJava.writeFakeJava(base.resolve("j8"), "java", JAVA8,
0, "J8");
+
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVACMD", java8.toString());
+ env.put("PATH", pathWithNonqualifyingJava(base));
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env,
"version");
+
+ assertEquals(1, r.exitCode(), "Java 8 must be rejected: " +
r.stdout());
+ }
+
+ @Test
+ void handlesSpacesAndUnicodeInJavaPath(@TempDir Path base) throws
Exception {
+ Path h = home(base);
+ Path dir = base.resolve("spa ce/über");
+ Path java = FakeJava.writeFakeJava(dir, "java", JAVA17, 0, "UNICODE");
+
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVACMD", java.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env,
"version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=UNICODE"), r.stdout());
+ }
+
+ @Test
+ void preservesArgumentsWithSpaces(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "ARGS");
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVACMD", java.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "run",
"my route.yaml");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("|run|my route.yaml|"), r.stdout());
+ }
+
+ @Test
+ void defaultsHeapWhenJavaOptsUnset(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "HEAP");
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVACMD", java.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env,
"version");
+
+ assertTrue(r.stdout().contains("-Xmx512m"), "default heap must be
applied: " + r.stdout());
+ }
+
+ @Test
+ void preservesJavaOptsWhenSet(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "OPTS");
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVACMD", java.toString());
+ env.put("JAVA_OPTS", "-Dcamel.test=1 -Xmx256m");
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env,
"version");
+
+ assertTrue(r.stdout().contains("-Dcamel.test=1"), r.stdout());
+ assertTrue(r.stdout().contains("-Xmx256m"), r.stdout());
+ assertFalse(r.stdout().contains("-Xmx512m"), "default heap must not be
added when JAVA_OPTS set");
+ }
+
+ @Test
+ void preservesChildExitCode(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 42, "EXIT");
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVACMD", java.toString());
+
+ FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env,
"version");
+
+ assertEquals(42, r.exitCode(), "child exit code must be propagated");
+ }
+
+ @Test
+ void preservesChildStdinStdoutAndStderr(@TempDir Path base) throws
Exception {
+ Path h = home(base);
+ Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "STDIO");
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVACMD", java.toString());
+
+ FakeJava.Result r = FakeJava.runWithInput(h.resolve("camel.sh"), env,
"route input\n", "version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=STDIO"), r.stdout());
+ assertTrue(r.stdout().contains("route input"), "stdin did not reach
child stdout: " + r.stdout());
+ assertTrue(r.stderr().contains("STDERR=STDIO"), "child stderr was not
preserved: " + r.stderr());
+ }
+
+ @Test
+ void probeCannotConsumeChildStdin(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0,
"PROBE-STDIN");
+ Files.writeString(java, "#!/bin/sh\n"
+ + "if [ \"$1\" = \"-version\" ]; then\n"
+ + " IFS= read -r ignored\n"
+ + " echo '" + JAVA17 + "' 1>&2\n"
+ + " exit 0\n"
+ + "fi\n"
+ + "echo 'RAN=PROBE-STDIN'\n"
+ + "cat\n");
+ Map<String, String> env = new HashMap<>();
+ env.put("JAVACMD", java.toString());
+
+ FakeJava.Result r = FakeJava.runWithInput(h.resolve("camel.sh"), env,
"route input\n", "version");
+
+ assertEquals(0, r.exitCode(), r.stderr());
+ assertTrue(r.stdout().contains("RAN=PROBE-STDIN"), r.stdout());
+ assertTrue(r.stdout().contains("route input"), "version probe consumed
child stdin: " + r.stdout());
+ }
+
+ @Test
+ void scriptItselfIsExecutableFixture(@TempDir Path base) throws Exception {
+ Path h = home(base);
+ assertTrue(Files.exists(h.resolve("camel.sh")));
+ assertTrue(Files.isExecutable(h.resolve("camel.sh")));
+ }
+}
diff --git
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/FakeJava.java
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/FakeJava.java
new file mode 100644
index 000000000000..9ad3612ca1ea
--- /dev/null
+++
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/FakeJava.java
@@ -0,0 +1,213 @@
+/*
+ * 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.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import static java.nio.file.attribute.PosixFilePermission.OWNER_EXECUTE;
+import static java.nio.file.attribute.PosixFilePermission.OWNER_READ;
+import static java.nio.file.attribute.PosixFilePermission.OWNER_WRITE;
+
+/**
+ * Test helper that materializes fake {@code java} executables and a throwaway
launcher home so the shared
+ * Java-discovery contract in camel.sh / camel.bat can be exercised without a
real JDK.
+ */
+final class FakeJava {
+
+ private static final long PROCESS_TIMEOUT_SECONDS = 60;
+ private static final long CLEANUP_TIMEOUT_SECONDS = 10;
+
+ static final boolean WINDOWS =
System.getProperty("os.name").toLowerCase().contains("win");
+
+ private FakeJava() {
+ }
+
+ record Result(int exitCode, String stdout, String stderr) {
+ }
+
+ static Path writeFakeJava(Path dir, String name, String versionBanner, int
normalExit, String marker)
+ throws IOException {
+ Files.createDirectories(dir);
+ if (WINDOWS) {
+ Path exe = dir.resolve(name + ".bat");
+ String body = "@echo off\r\n"
+ + "if \"%~1\"==\"-version\" (\r\n"
+ + " echo " + versionBanner + " 1>&2\r\n"
+ + " exit /b 0\r\n"
+ + ")\r\n"
+ + "echo RAN=" + marker + "\r\n"
+ + "echo ARGS=%*\r\n"
+ + "echo STDERR=" + marker + " 1>&2\r\n"
+ + "more\r\n"
+ + "exit /b " + normalExit + "\r\n";
+ Files.writeString(exe, body, StandardCharsets.UTF_8);
+ return exe;
+ }
+ Path exe = dir.resolve(name);
+ String body = "#!/bin/sh\n"
+ + "if [ \"$1\" = \"-version\" ]; then\n"
+ + " echo '" + versionBanner + "' 1>&2\n"
+ + " exit 0\n"
+ + "fi\n"
+ + "echo \"RAN=" + marker + "\"\n"
+ + "printf 'ARGS='; for a in \"$@\"; do printf '%s|'
\"$a\"; done; echo\n"
+ + "echo \"STDERR=" + marker + "\" 1>&2\n"
+ + "cat\n"
+ + "exit " + normalExit + "\n";
+ Files.writeString(exe, body, StandardCharsets.UTF_8);
+ Files.setPosixFilePermissions(exe, Set.of(OWNER_READ, OWNER_WRITE,
OWNER_EXECUTE));
+ return exe;
+ }
+
+ static Path newLauncherHome(Path base, Path scriptResource) throws
IOException {
+ Path home = Files.createTempDirectory(base, "launcher-home");
+ Path script = home.resolve(scriptResource.getFileName().toString());
+ Files.copy(scriptResource, script);
+ if (!WINDOWS) {
+ Files.setPosixFilePermissions(script, Set.of(OWNER_READ,
OWNER_WRITE, OWNER_EXECUTE));
+ }
+ // A stub jar so the launcher's "camel-launcher-*.jar" glob resolves;
fake java never reads it.
+ Files.writeString(home.resolve("camel-launcher-9.9.9.jar"), "stub");
+ return home;
+ }
+
+ static Result run(Path script, Map<String, String> env, String... args)
throws Exception {
+ return runWithInput(script, env, null, args);
+ }
+
+ static Result runWithInput(Path script, Map<String, String> env, String
input, String... args) throws Exception {
+ List<String> cmd = new ArrayList<>();
+ if (WINDOWS) {
+ cmd.add("cmd.exe");
+ cmd.add("/c");
+ cmd.add(script.toString());
+ } else {
+ cmd.add("/bin/sh");
+ cmd.add(script.toString());
+ }
+ for (String a : args) {
+ cmd.add(a);
+ }
+ ProcessBuilder pb = new ProcessBuilder(cmd);
+ pb.environment().clear();
+ // Keep a minimal PATH so /bin/sh and cmd.exe internals resolve; tests
override as needed.
+ pb.environment().put("PATH", WINDOWS ? System.getenv("PATH") :
"/usr/bin:/bin");
+ if (WINDOWS) {
+ pb.environment().put("SystemRoot", System.getenv("SystemRoot"));
+ pb.environment().put("OS", "Windows_NT");
+ pb.environment().put("PATHEXT", ".COM;.EXE;.BAT;.CMD");
+ }
+ pb.environment().putAll(env);
+ Process p = pb.start();
+ ExecutorService collectors = Executors.newFixedThreadPool(2);
+ Throwable failure = null;
+ try {
+ Future<byte[]> stdout = collectors.submit(() ->
p.getInputStream().readAllBytes());
+ Future<byte[]> stderr = collectors.submit(() ->
p.getErrorStream().readAllBytes());
+ if (input != null) {
+
p.getOutputStream().write(input.getBytes(StandardCharsets.UTF_8));
+ }
+ p.getOutputStream().close();
+ if (!p.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
+ terminate(p);
+ throw new IllegalStateException("launcher did not exit in
time");
+ }
+ String out = new String(await(stdout, "stdout"),
StandardCharsets.UTF_8);
+ String err = new String(await(stderr, "stderr"),
StandardCharsets.UTF_8);
+ return new Result(p.exitValue(), out, err);
+ } catch (Exception | Error e) {
+ failure = e;
+ throw e;
+ } finally {
+ try {
+ cleanup(p, collectors);
+ } catch (Exception | Error cleanupFailure) {
+ if (failure != null) {
+ failure.addSuppressed(cleanupFailure);
+ } else {
+ throw cleanupFailure;
+ }
+ }
+ }
+ }
+
+ private static void cleanup(Process process, ExecutorService collectors)
throws Exception {
+ Throwable failure = null;
+ try {
+ terminate(process);
+ } catch (Exception | Error e) {
+ failure = e;
+ }
+ collectors.shutdownNow();
+ try {
+ if (!collectors.awaitTermination(CLEANUP_TIMEOUT_SECONDS,
TimeUnit.SECONDS)) {
+ throw new IllegalStateException("launcher stream collectors
did not terminate");
+ }
+ } catch (Exception | Error e) {
+ if (failure != null) {
+ failure.addSuppressed(e);
+ } else {
+ failure = e;
+ }
+ }
+ if (failure instanceof Exception exception) {
+ throw exception;
+ }
+ if (failure instanceof Error error) {
+ throw error;
+ }
+ }
+
+ private static byte[] await(Future<byte[]> collector, String stream)
throws Exception {
+ try {
+ return collector.get(CLEANUP_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause();
+ if (cause instanceof Exception exception) {
+ throw exception;
+ }
+ if (cause instanceof Error error) {
+ throw error;
+ }
+ throw new IllegalStateException("failed to collect launcher " +
stream, cause);
+ } catch (TimeoutException e) {
+ throw new IllegalStateException("timed out collecting launcher " +
stream, e);
+ }
+ }
+
+ private static void terminate(Process process) throws InterruptedException
{
+ if (process.isAlive()) {
+ process.destroyForcibly();
+ if (!process.waitFor(CLEANUP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
+ throw new IllegalStateException("launcher could not be
terminated");
+ }
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/FakeJavaTest.java
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/FakeJavaTest.java
new file mode 100644
index 000000000000..fb99336cc922
--- /dev/null
+++
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/FakeJavaTest.java
@@ -0,0 +1,93 @@
+/*
+ * 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.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FakeJavaTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void drainsStdoutAndStderrWithoutDeadlock() throws Exception {
+ Path script = tempDir.resolve(FakeJava.WINDOWS ? "large-output.bat" :
"large-output.sh");
+ Files.writeString(script, largeOutputScript(), StandardCharsets.UTF_8);
+
+ FakeJava.Result result =
assertTimeoutPreemptively(Duration.ofSeconds(10),
+ () -> FakeJava.run(script, Map.of()));
+
+ assertEquals(0, result.exitCode());
+ assertTrue(result.stdout().contains("stdout-20000"));
+ assertTrue(result.stderr().contains("stderr-20000"));
+ }
+
+ @Test
+ void fakeJavaPreservesInputAndNormalStderr() throws Exception {
+ Path java = FakeJava.writeFakeJava(tempDir, "java", "openjdk version
\"17.0.2\"", 0, "STDIO");
+
+ FakeJava.Result result = FakeJava.runWithInput(java, Map.of(), "route
input\n", "version");
+
+ assertEquals(0, result.exitCode());
+ assertTrue(result.stdout().contains("RAN=STDIO"), result.stdout());
+ assertTrue(result.stdout().contains("route input"), result.stdout());
+ assertTrue(result.stderr().contains("STDERR=STDIO"), result.stderr());
+ }
+
+ @Test
+ @EnabledOnOs(OS.WINDOWS)
+ void windowsEnvironmentExercisesSetlocalEndlocalAndPreservesExit() throws
Exception {
+ Path script = tempDir.resolve("setlocal-exit.bat");
+ Files.writeString(script, "@echo off\r\n"
+ + "if not \"%OS%\"==\"Windows_NT\" exit /b
91\r\n"
+ + "if \"%OS%\"==\"Windows_NT\" setlocal\r\n"
+ + "set ERROR_CODE=43\r\n"
+ + "if \"%OS%\"==\"Windows_NT\" endlocal &
set ERROR_CODE=%ERROR_CODE%\r\n"
+ + "exit /b %ERROR_CODE%\r\n",
+ StandardCharsets.UTF_8);
+
+ FakeJava.Result result = FakeJava.run(script, Map.of());
+
+ assertEquals(43, result.exitCode(), "Windows_NT setlocal/endlocal path
did not preserve exit status");
+ }
+
+ private static String largeOutputScript() {
+ if (FakeJava.WINDOWS) {
+ return "@echo off\r\n"
+ + "for /L %%i in (1,1,20000) do echo stderr-%%i 1>&2\r\n"
+ + "for /L %%i in (1,1,20000) do echo stdout-%%i\r\n";
+ }
+ return "#!/bin/sh\n"
+ + "i=1\n"
+ + "while [ $i -le 20000 ]; do echo stderr-$i 1>&2; i=$((i +
1)); done\n"
+ + "i=1\n"
+ + "while [ $i -le 20000 ]; do echo stdout-$i; i=$((i + 1));
done\n";
+ }
+}