From f69c070253172ab8af45891a5326f8ea25558e08 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:29:11 -0400 Subject: [PATCH 01/15] CAMEL-23703: camel-launcher - test helper for Java-discovery contract Co-authored-by: Codex --- .../camel/dsl/jbang/launcher/FakeJava.java | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/FakeJava.java 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 0000000000000..6b2179f37a5bf --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/FakeJava.java @@ -0,0 +1,120 @@ +/* + * 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.TimeUnit; + +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 { + + 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" + + "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" + + "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 env, String... args) throws Exception { + List 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("PATHEXT", ".COM;.EXE;.BAT;.CMD"); + } + pb.environment().putAll(env); + Process p = pb.start(); + String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String err = new String(p.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + if (!p.waitFor(60, TimeUnit.SECONDS)) { + p.destroyForcibly(); + throw new IllegalStateException("launcher did not exit in time"); + } + return new Result(p.exitValue(), out, err); + } +} From 43377ed78e3ebae023ee02978d4a8357b01f811b Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:41:56 -0400 Subject: [PATCH 02/15] CAMEL-23703: prevent launcher test process deadlocks Co-authored-by: Codex --- .../camel/dsl/jbang/launcher/FakeJava.java | 92 +++++++++++++++++-- .../dsl/jbang/launcher/FakeJavaTest.java | 62 +++++++++++++ 2 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/FakeJavaTest.java 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 index 6b2179f37a5bf..5adb43893d279 100644 --- 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 @@ -24,7 +24,12 @@ 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; @@ -36,6 +41,9 @@ */ 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() { @@ -109,12 +117,84 @@ static Result run(Path script, Map env, String... args) throws E } pb.environment().putAll(env); Process p = pb.start(); - String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); - String err = new String(p.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); - if (!p.waitFor(60, TimeUnit.SECONDS)) { - p.destroyForcibly(); - throw new IllegalStateException("launcher did not exit in time"); + ExecutorService collectors = Executors.newFixedThreadPool(2); + Throwable failure = null; + try { + Future stdout = collectors.submit(() -> p.getInputStream().readAllBytes()); + Future stderr = collectors.submit(() -> p.getErrorStream().readAllBytes()); + 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 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"); + } } - return new Result(p.exitValue(), out, err); } } 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 0000000000000..aa03b0cf2af09 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/FakeJavaTest.java @@ -0,0 +1,62 @@ +/* + * 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.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")); + } + + 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"; + } +} From 6a13467052e6e28adab541b93cfb2f6900b96bff Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:46:53 -0400 Subject: [PATCH 03/15] CAMEL-23703: camel-launcher - failing POSIX Java-discovery contract test Co-authored-by: Codex --- .../jbang/launcher/CamelShDiscoveryTest.java | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelShDiscoveryTest.java 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 0000000000000..5e17869b97122 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelShDiscoveryTest.java @@ -0,0 +1,285 @@ +/* + * 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); + } + + @Test + void acceptsJava17ViaJavacmd(@TempDir Path base) throws Exception { + Path h = home(base); + Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "JAVACMD"); + Map 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 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 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 env = new HashMap<>(); + env.put("JAVA_HOME", base.resolve("jh").toString()); + env.put("PATH", "/usr/bin:/bin"); // no java on PATH + + 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 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 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 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()); + } + + @Test + void usesCamelFallbackJavaWhenNothingElseQualifies(@TempDir Path base) throws Exception { + Path h = home(base); + Path fb = FakeJava.writeFakeJava(base.resolve("fb"), "java", JAVA17, 0, "FALLBACK"); + + Map env = new HashMap<>(); + env.put("PATH", "/usr/bin:/bin"); // no java on PATH + 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 env = new HashMap<>(); + env.put("JAVACMD", old.toString()); + env.put("PATH", "/usr/bin:/bin"); + + 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 env = new HashMap<>(); + env.put("JAVACMD", garbage.toString()); + env.put("PATH", "/usr/bin:/bin"); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); + + assertEquals(1, r.exitCode()); + } + + @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 env = new HashMap<>(); + env.put("JAVACMD", java8.toString()); + env.put("PATH", "/usr/bin:/bin"); + + 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 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 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 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 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 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 scriptItselfIsExecutableFixture(@TempDir Path base) throws Exception { + Path h = home(base); + assertTrue(Files.exists(h.resolve("camel.sh"))); + } +} From 8627d346d013a7782aa80e69dacc27afb72b5180 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:05:53 -0400 Subject: [PATCH 04/15] CAMEL-23703: address POSIX discovery contract review Co-authored-by: Codex --- .../jbang/launcher/CamelShDiscoveryTest.java | 107 +++++++++++++++++- .../camel/dsl/jbang/launcher/FakeJava.java | 12 ++ .../dsl/jbang/launcher/FakeJavaTest.java | 12 ++ 3 files changed, 126 insertions(+), 5 deletions(-) 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 index 5e17869b97122..6583e8ae3823a 100644 --- 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 @@ -50,6 +50,12 @@ 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); @@ -108,7 +114,7 @@ void honorsJavaHomeWhenOnlySourceLikeSdkman(@TempDir Path base) throws Exception Map env = new HashMap<>(); env.put("JAVA_HOME", base.resolve("jh").toString()); - env.put("PATH", "/usr/bin:/bin"); // no java on PATH + env.put("PATH", pathWithNonqualifyingJava(base)); FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); @@ -132,6 +138,25 @@ void skipsInvalidJavacmdFallsBackToJavaHome(@TempDir Path base) throws Exception 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 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); @@ -146,6 +171,43 @@ void skipsOldJavaHomeFallsBackToPath(@TempDir Path base) throws Exception { 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 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 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()); } @@ -156,7 +218,7 @@ void usesCamelFallbackJavaWhenNothingElseQualifies(@TempDir Path base) throws Ex Path fb = FakeJava.writeFakeJava(base.resolve("fb"), "java", JAVA17, 0, "FALLBACK"); Map env = new HashMap<>(); - env.put("PATH", "/usr/bin:/bin"); // no java on PATH + env.put("PATH", pathWithNonqualifyingJava(base)); env.put("CAMEL_FALLBACK_JAVA", fb.toString()); FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); @@ -172,7 +234,7 @@ void rejectsWhenNoCandidateQualifies(@TempDir Path base) throws Exception { Map env = new HashMap<>(); env.put("JAVACMD", old.toString()); - env.put("PATH", "/usr/bin:/bin"); + env.put("PATH", pathWithNonqualifyingJava(base)); FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); @@ -188,13 +250,32 @@ void rejectsUnparseableCandidate(@TempDir Path base) throws Exception { Map env = new HashMap<>(); env.put("JAVACMD", garbage.toString()); - env.put("PATH", "/usr/bin:/bin"); + 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 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. @@ -203,7 +284,7 @@ void parsesLegacyOneDotEightAsMajorEight(@TempDir Path base) throws Exception { Map env = new HashMap<>(); env.put("JAVACMD", java8.toString()); - env.put("PATH", "/usr/bin:/bin"); + env.put("PATH", pathWithNonqualifyingJava(base)); FakeJava.Result r = FakeJava.run(h.resolve("camel.sh"), env, "version"); @@ -277,9 +358,25 @@ void preservesChildExitCode(@TempDir Path base) throws Exception { 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 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 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 index 5adb43893d279..680235dbe169b 100644 --- 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 @@ -64,6 +64,8 @@ static Path writeFakeJava(Path dir, String name, String versionBanner, int norma + ")\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; @@ -76,6 +78,8 @@ static Path writeFakeJava(Path dir, String name, String versionBanner, int norma + "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)); @@ -95,6 +99,10 @@ static Path newLauncherHome(Path base, Path scriptResource) throws IOException { } static Result run(Path script, Map env, String... args) throws Exception { + return runWithInput(script, env, null, args); + } + + static Result runWithInput(Path script, Map env, String input, String... args) throws Exception { List cmd = new ArrayList<>(); if (WINDOWS) { cmd.add("cmd.exe"); @@ -122,6 +130,10 @@ static Result run(Path script, Map env, String... args) throws E try { Future stdout = collectors.submit(() -> p.getInputStream().readAllBytes()); Future 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"); 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 index aa03b0cf2af09..a53e8f61ee8e8 100644 --- 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 @@ -47,6 +47,18 @@ void drainsStdoutAndStderrWithoutDeadlock() throws Exception { 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()); + } + private static String largeOutputScript() { if (FakeJava.WINDOWS) { return "@echo off\r\n" From c7a63ca5443f9391edac9e049a2a3ef8bae0913e Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:10:24 -0400 Subject: [PATCH 05/15] CAMEL-23703: camel-launcher - shared Java 17+ discovery contract in camel.sh Replaces ad-hoc JAVACMD/JAVA_HOME logic with the four-candidate precedence (JAVACMD, JAVA_HOME/bin/java, java on PATH, CAMEL_FALLBACK_JAVA), enforcing a Java 17 minimum via captured version probing. JAVA_HOME remains how SDKMAN's java candidate is honored. Intentionally removes the obsolete Darwin JavaVM.framework JAVA_HOME default and its JAVA_VERSION=CurrentJDK handling because it conflicts with the shared candidate precedence; retains Cygwin path handling. Co-authored-by: Codex --- .../src/main/resources/bin/camel.sh | 79 +++++++++++-------- 1 file changed, 45 insertions(+), 34 deletions(-) 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 9e72387549bd8..1add8bd778623 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 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:-}" 1>&2 + echo " 2. JAVA_HOME/bin/java = ${JAVA_HOME:+$JAVA_HOME/bin/java}" 1>&2 + echo " 3. java on PATH = ${_camel_path_java:-}" 1>&2 + echo " 4. CAMEL_FALLBACK_JAVA = ${CAMEL_FALLBACK_JAVA:-}" 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 From df6d603eb8d99cbe2c6ce5759de25c44840087c4 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:16:20 -0400 Subject: [PATCH 06/15] CAMEL-23703: preserve stdin across Java version probes Isolate candidate version checks from launcher stdin so wrappers cannot consume piped input before the final Camel process executes. Adds a POSIX regression test that proves the original input reaches normal invocation. Co-authored-by: Codex --- .../src/main/resources/bin/camel.sh | 2 +- .../jbang/launcher/CamelShDiscoveryTest.java | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) 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 1add8bd778623..430e7a0fc1a84 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 @@ -56,7 +56,7 @@ 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 2>&1` || return 1 + _cjm_out=`"$1" -version &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 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 index 6583e8ae3823a..ad2735ed362ce 100644 --- 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 @@ -373,6 +373,28 @@ void preservesChildStdinStdoutAndStderr(@TempDir Path base) throws Exception { 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 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); From 4940ba2777ac7e8615d18a517f03e7f6e162d4d8 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:21:28 -0400 Subject: [PATCH 07/15] CAMEL-23703: camel-launcher - shared Java 17+ discovery contract in camel.bat Co-authored-by: Codex --- .../src/main/resources/bin/camel.bat | 78 ++++++++++++------- 1 file changed, 48 insertions(+), 30 deletions(-) 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 1f6a7d38b4411..29883585461df 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,63 @@ 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 +@REM Execute Camel CLI, preserving arguments and the child exit code "%JAVACMD%" %JAVA_OPTS% -jar "%LAUNCHER_JAR%" %* -if ERRORLEVEL 1 goto error -goto end +set ERROR_CODE=%ERRORLEVEL% -:error -set ERROR_CODE=1 - -:end @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 +set "_RAW=" +for /f "tokens=3" %%v in ('""%_CAND%" -version" ^^&1 ^| findstr /i "version"') do if not defined _RAW set "_RAW=%%v" +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 From eee59cf225b6627164cab47a472b34b90c7766af Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:28:36 -0400 Subject: [PATCH 08/15] CAMEL-23703: harden camel.bat Java version probe Co-authored-by: Codex --- .../src/main/resources/bin/camel.bat | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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 29883585461df..b836f2bfec604 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 @@ -74,8 +74,22 @@ exit /B 1 set "_CAND=%~1" if "%_CAND%"=="" exit /b 1 if not exist "%_CAND%" exit /b 1 + +:tryJavaProbePath +set "_PROBE=%TEMP%\camel-java-probe-%RANDOM%-%RANDOM%.tmp" +if exist "%_PROBE%" goto tryJavaProbePath +"%_CAND%" -version "%_PROBE%" 2>&1 +set "_PROBE_RC=%ERRORLEVEL%" +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 ('""%_CAND%" -version" ^^&1 ^| findstr /i "version"') do if not defined _RAW set "_RAW=%%v" +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=" From 7729eb7fd7b408f76e9df52d3ea7bb22caae857b Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:33:45 -0400 Subject: [PATCH 09/15] CAMEL-23703: support batch Java wrappers in camel.bat Co-authored-by: Codex --- .../src/main/resources/bin/camel.bat | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) 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 b836f2bfec604..692dbea4de267 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 @@ -54,9 +54,21 @@ set "LAUNCHER_JAR=" for %%i in ("%BASEDIR%\camel-launcher-*.jar") do set "LAUNCHER_JAR=%%i" @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 + +:executeJavaBatch +call "%JAVACMD%" %JAVA_OPTS% -jar "%LAUNCHER_JAR%" %* +set ERROR_CODE=%ERRORLEVEL% +goto javaExecuted + +: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% @@ -74,12 +86,29 @@ exit /B 1 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=%TEMP%\camel-java-probe-%RANDOM%-%RANDOM%.tmp" +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 "%_PROBE%" 2>&1 +set "_PROBE_RC=%ERRORLEVEL%" +goto tryJavaProbeComplete + +:tryJavaProbeNative "%_CAND%" -version "%_PROBE%" 2>&1 set "_PROBE_RC=%ERRORLEVEL%" + +:tryJavaProbeComplete if "%_PROBE_RC%"=="0" goto tryJavaParseProbe del /q "%_PROBE%" >NUL 2>&1 set "_PROBE=" From 8d3a6d699ec1ddfc5b93dd06b28469a73fe71e5b Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:46:56 -0400 Subject: [PATCH 10/15] CAMEL-23703: camel-launcher - Windows batch Java-discovery test Co-authored-by: Codex --- .../jbang/launcher/CamelBatDiscoveryTest.java | 338 ++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelBatDiscoveryTest.java 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 0000000000000..c9c2f242cd597 --- /dev/null +++ b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/CamelBatDiscoveryTest.java @@ -0,0 +1,338 @@ +/* + * 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.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 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 Map isolatedEnvironment() { + Map env = new HashMap<>(); + env.put("PATH", Path.of(System.getenv("SystemRoot"), "System32").toString()); + return env; + } + + @Test + void acceptsJava17ViaJavacmd(@TempDir Path base) throws Exception { + Path h = home(base); + Path java = FakeJava.writeFakeJava(base, "java", JAVA17, 0, "JAVACMD"); + Map 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 honorsJavaHomeWhenOnlySource(@TempDir Path base) throws Exception { + Path h = home(base); + writeRealJavaMarkerJar(h); + Map env = isolatedEnvironment(); + env.put("JAVA_HOME", System.getProperty("java.home")); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(0, r.exitCode(), r.stderr()); + assertTrue(r.stdout().contains("RAN=JAVAHOME"), r.stdout()); + assertTrue(r.stdout().contains("version"), 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 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 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 env = isolatedEnvironment(); + env.put("JAVACMD", garbage.toString()); + + FakeJava.Result r = FakeJava.run(h.resolve("camel.bat"), env, "version"); + + assertEquals(1, r.exitCode()); + } + + @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 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 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 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 = FakeJava.writeFakeJava(base.resolve("wrapper"), "java", JAVA17, 37, "JAVACMD-WRAPPER"); + Map 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("RAN=JAVACMD-WRAPPER"), r.stdout()); + assertTrue(r.stdout().contains("run"), r.stdout()); + assertTrue(r.stdout().contains("my route.yaml"), 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 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 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 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 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 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.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 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 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=JAVAHOME"); + System.out.println("ARGS=" + Arrays.toString(args)); + } +} From e650b28e9bd4e3dd57d72864c34a4838d65f8c13 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:56:22 -0400 Subject: [PATCH 11/15] CAMEL-23703: strengthen Windows batch discovery tests Co-authored-by: Codex --- .../jbang/launcher/CamelBatDiscoveryTest.java | 185 +++++++++++++++++- .../camel/dsl/jbang/launcher/FakeJava.java | 1 + .../dsl/jbang/launcher/FakeJavaTest.java | 19 ++ 3 files changed, 197 insertions(+), 8 deletions(-) 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 index c9c2f242cd597..4c398b939bb99 100644 --- 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 @@ -48,8 +48,10 @@ 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 { @@ -57,11 +59,22 @@ private Path home(Path base) throws Exception { } private Map 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 env = new HashMap<>(); - env.put("PATH", Path.of(System.getenv("SystemRoot"), "System32").toString()); + 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); @@ -77,20 +90,107 @@ void acceptsJava17ViaJavacmd(@TempDir Path base) throws Exception { 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 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 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 honorsJavaHomeWhenOnlySource(@TempDir Path base) throws Exception { Path h = home(base); writeRealJavaMarkerJar(h); Map env = isolatedEnvironment(); - env.put("JAVA_HOME", System.getProperty("java.home")); + 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=JAVAHOME"), r.stdout()); + 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 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 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 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 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); @@ -134,6 +234,22 @@ void rejectsUnparseableCandidate(@TempDir Path base) throws Exception { 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 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); @@ -179,7 +295,23 @@ void preservesJavaOptsWhenSet(@TempDir Path base) throws Exception { @Test void javacmdBatchWrapperReturnsForProbeAndLaunchAndPreservesBehavior(@TempDir Path base) throws Exception { Path h = home(base); - Path wrapper = FakeJava.writeFakeJava(base.resolve("wrapper"), "java", JAVA17, 37, "JAVACMD-WRAPPER"); + 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 env = isolatedEnvironment(); env.put("JAVACMD", wrapper.toString()); @@ -187,13 +319,32 @@ void javacmdBatchWrapperReturnsForProbeAndLaunchAndPreservesBehavior(@TempDir Pa "my route.yaml"); assertEquals(37, r.exitCode(), "batch child exit code must be propagated"); - assertTrue(r.stdout().contains("RAN=JAVACMD-WRAPPER"), r.stdout()); - assertTrue(r.stdout().contains("run"), r.stdout()); - assertTrue(r.stdout().contains("my route.yaml"), r.stdout()); + 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 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); @@ -277,6 +428,7 @@ void acceptsValidBannerAfterPreBannerLineContainingVersion(@TempDir Path base) t 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()); } @@ -309,6 +461,23 @@ private Path writeBatch(Path dir, String name, String... lines) throws Exception return script; } + 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"); @@ -332,7 +501,7 @@ private CamelBatRealJavaMarker() { } public static void main(String[] args) { - System.out.println("RAN=JAVAHOME"); + 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/FakeJava.java b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/FakeJava.java index 680235dbe169b..9ad3612ca1eac 100644 --- 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 @@ -121,6 +121,7 @@ static Result runWithInput(Path script, Map env, String input, S 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); 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 index a53e8f61ee8e8..fb99336cc9228 100644 --- 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 @@ -23,6 +23,8 @@ 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; @@ -59,6 +61,23 @@ void fakeJavaPreservesInputAndNormalStderr() throws Exception { 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" From 07c281802945ea5b06b0801fc0f6b23435b47314 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:03:59 -0400 Subject: [PATCH 12/15] CAMEL-23703: pin batch Java candidate order Co-authored-by: Codex --- .../jbang/launcher/CamelBatDiscoveryTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 index 4c398b939bb99..bd0c447b6ce83 100644 --- 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 @@ -28,6 +28,7 @@ import java.util.jar.JarEntry; import java.util.jar.JarOutputStream; import java.util.jar.Manifest; +import java.util.regex.Pattern; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledOnOs; @@ -118,6 +119,23 @@ void precedenceJavacmdBeatsJavaHome(@TempDir Path base) throws Exception { 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); + String candidateOrder = String.join("", + "call\\s+:tryJava\\s+\"%JAVACMD%\"", + ".*?if\\s+defined\\s+JAVA_HOME\\s+call\\s+:tryJava\\s+", + "\"%JAVA_HOME%\\\\bin\\\\java\\.exe\"", + ".*?for\\s+%%p\\s+in\\s*\\(\\s*java\\.exe\\s*\\)\\s+do\\s+", + "call\\s+:tryJava\\s+\"%%~\\$PATH:p\"", + ".*?call\\s+:tryJava\\s+\"%CAMEL_FALLBACK_JAVA%\""); + Pattern orderedCandidates = Pattern.compile(candidateOrder, Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + + assertTrue(orderedCandidates.matcher(launcher).find(), + "candidate order must be JAVACMD, JAVA_HOME, PATH java.exe, CAMEL_FALLBACK_JAVA"); + } + @Test void honorsJavaHomeWhenOnlySource(@TempDir Path base) throws Exception { Path h = home(base); From eade4e354d744daba5bcdf02b33481850950cc01 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:08:38 -0400 Subject: [PATCH 13/15] CAMEL-23703: scope batch candidate order assertion Co-authored-by: Codex --- .../jbang/launcher/CamelBatDiscoveryTest.java | 61 +++++++++++++++---- 1 file changed, 50 insertions(+), 11 deletions(-) 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 index bd0c447b6ce83..4243115762756 100644 --- 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 @@ -23,12 +23,12 @@ 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 java.util.regex.Pattern; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledOnOs; @@ -123,17 +123,17 @@ void precedenceJavacmdBeatsJavaHome(@TempDir Path base) throws Exception { void candidateSourceOrderIsJavacmdJavaHomePathThenFallback(@TempDir Path base) throws Exception { Path h = home(base); String launcher = Files.readString(h.resolve("camel.bat"), StandardCharsets.UTF_8); - String candidateOrder = String.join("", - "call\\s+:tryJava\\s+\"%JAVACMD%\"", - ".*?if\\s+defined\\s+JAVA_HOME\\s+call\\s+:tryJava\\s+", - "\"%JAVA_HOME%\\\\bin\\\\java\\.exe\"", - ".*?for\\s+%%p\\s+in\\s*\\(\\s*java\\.exe\\s*\\)\\s+do\\s+", - "call\\s+:tryJava\\s+\"%%~\\$PATH:p\"", - ".*?call\\s+:tryJava\\s+\"%CAMEL_FALLBACK_JAVA%\""); - Pattern orderedCandidates = Pattern.compile(candidateOrder, Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + List expected = expectedDiscoveryLines(); - assertTrue(orderedCandidates.matcher(launcher).find(), - "candidate order must be JAVACMD, JAVA_HOME, PATH java.exe, CAMEL_FALLBACK_JAVA"); + 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 @@ -479,6 +479,45 @@ private Path writeBatch(Path dir, String name, String... lines) throws Exception return script; } + private List 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 executableDiscoveryLines(String source) { + List 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); From b94fb777388fbd0762d008b0d7028e5eec44ef43 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:47:10 -0400 Subject: [PATCH 14/15] CAMEL-23703: docs - launcher Java 17+ discovery in 4.22 upgrade guide --- .../pages/camel-4x-upgrade-guide-4_22.adoc | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 44014f2466d3a..2e45574837c60 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,28 @@ 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`. +`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` (from `dev.langchain4j.service.Result`). From 50c646cf78ee3194a4c3950ca1d32ed53466db82 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:11:48 -0400 Subject: [PATCH 15/15] CAMEL-23703: docs - note Gentoo and AIX launcher removals Document the removed java-config and jre/sh/java auto-detection paths alongside the macOS JavaVM.framework note in the 4.22 upgrade guide. Co-authored-by: Cursor --- .../modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc | 2 ++ 1 file changed, 2 insertions(+) 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 2e45574837c60..a12492d29a6c4 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 @@ -76,6 +76,8 @@ 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