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

Reply via email to