This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch quick-fix/quickjs-stale-result in repository https://gitbox.apache.org/repos/asf/camel.git
commit f28e8ec9bb3cba2f84963d68a7332d97cc961b9f Author: Claus Ibsen <[email protected]> AuthorDate: Sat Oct 24 13:28:32 2026 +0200 chore: camel-quickjs - fail a script result that cannot reach Java instead of returning the previous result A BigInt, a cyclic object, a toJSON that throws and a rejected Promise made the evaluation return the result of the previous evaluation on the engine. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../camel/catalog/docs/quickjs-language.adoc | 4 +- .../src/main/docs/quickjs-language.adoc | 4 +- .../camel/language/quickjs/QuickjsHelper.java | 54 +++++++++++++++++++++- .../camel/language/quickjs/QuickjsLanguage.java | 4 +- .../language/quickjs/QuickjsSerializationTest.java | 30 ++++++++++-- 5 files changed, 86 insertions(+), 10 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/quickjs-language.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/quickjs-language.adoc index d6b600a9e229..da76b27aab18 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/quickjs-language.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/quickjs-language.adoc @@ -164,7 +164,9 @@ As an expression, the JSON value of the script becomes the Camel result (then co with Camel type converters when a result type is requested): an object becomes a `Map` and an array a `List`, a `Date` becomes its ISO text, and `undefined`, `NaN` or a function becomes `null`. A `Set` becomes a `List`, a JavaScript `Map` a `Map` (its keys as text), and a typed array or an -`ArrayBuffer` a `List` of numbers, also when nested. In `setBody` and `transform` this result +`ArrayBuffer` a `List` of numbers, also when nested. A result that has no JSON form, such as a +`BigInt` or a cyclic object, fails the evaluation, as does a `Promise` that is rejected; a `Promise` +that resolves gives its value. In `setBody` and `transform` this result replaces a body the script set with `camel.setBody(...)`. As a predicate (`.when().quickjs(...)` or `.filter().quickjs(...)`), the result is converted to diff --git a/components/camel-quickjs/src/main/docs/quickjs-language.adoc b/components/camel-quickjs/src/main/docs/quickjs-language.adoc index d6b600a9e229..da76b27aab18 100644 --- a/components/camel-quickjs/src/main/docs/quickjs-language.adoc +++ b/components/camel-quickjs/src/main/docs/quickjs-language.adoc @@ -164,7 +164,9 @@ As an expression, the JSON value of the script becomes the Camel result (then co with Camel type converters when a result type is requested): an object becomes a `Map` and an array a `List`, a `Date` becomes its ISO text, and `undefined`, `NaN` or a function becomes `null`. A `Set` becomes a `List`, a JavaScript `Map` a `Map` (its keys as text), and a typed array or an -`ArrayBuffer` a `List` of numbers, also when nested. In `setBody` and `transform` this result +`ArrayBuffer` a `List` of numbers, also when nested. A result that has no JSON form, such as a +`BigInt` or a cyclic object, fails the evaluation, as does a `Promise` that is rejected; a `Promise` +that resolves gives its value. In `setBody` and `transform` this result replaces a body the script set with `camel.setBody(...)`. As a predicate (`.when().quickjs(...)` or `.filter().quickjs(...)`), the result is converted to diff --git a/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsHelper.java b/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsHelper.java index c9a37aad5a23..af70f837b086 100644 --- a/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsHelper.java +++ b/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsHelper.java @@ -71,6 +71,11 @@ final class QuickjsHelper { static final List<String> EXCHANGE_BINDINGS = List.of("body", "headers", "properties", "exchangeId", "variables", "exception"); + /** + * The result of an evaluation whose script result never reached Java, see {@link EvalFunction}. + */ + static final Object NO_RESULT = new Object(); + private static final Logger LOG = LoggerFactory.getLogger("org.apache.camel.language.quickjs.script"); private static final ObjectMapper MAPPER = Engine.DEFAULT_OBJECT_MAPPER; @@ -295,9 +300,22 @@ final class QuickjsHelper { .build(); } - @SuppressWarnings("rawtypes") private static GuestFunction evalFunction() { - return new GuestFunction(FUNCTION_NAME, List.of(Object.class, String.class), Object.class); + return new EvalFunction(); + } + + /** + * Fails an evaluation that gave {@link #NO_RESULT}, instead of letting it continue with no value. + */ + static Object checkResult(Object result, Exchange exchange) { + if (result == NO_RESULT) { + throw new ExpressionEvaluationException( + null, exchange, new IllegalStateException( + "The script gave no result: its result cannot be turned into JSON (a BigInt, a cyclic" + + " object or a toJSON that throws), or it is a Promise" + + " that was rejected")); + } + return result; } /** @@ -637,6 +655,38 @@ final class QuickjsHelper { } } + /** + * The script function of the module. QuickJS4J hands its result to Java in a promise callback, after the function + * returned. When that fails (JSON.stringify of a BigInt or a cyclic object, a toJSON that throws, a rejected + * promise) the rejection is dropped, and QuickJS4J returned the result of the previous evaluation. This function + * forgets its result once it is read, so such an evaluation gives {@link #NO_RESULT} instead. + */ + @SuppressWarnings("rawtypes") + private static final class EvalFunction extends GuestFunction { + private final HostFunction setResult; + private Object result = NO_RESULT; + + EvalFunction() { + super(FUNCTION_NAME, List.of(Object.class, String.class), Object.class); + setResult = new HostFunction(setResultFunName(), List.of(Object.class), Void.class, args -> { + result = args.get(0); + return null; + }); + } + + @Override + public HostFunction setResultHostFunction() { + return setResult; + } + + @Override + public Object getResult() { + Object answer = result; + result = NO_RESULT; + return answer; + } + } + /** * QuickJS4J caches every compiled library by a SHA-256 of its source in an unbounded map; camel-quickjs keeps its * own bounded per-engine cache of compiled scripts, so the engine's cache is disabled. diff --git a/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsLanguage.java b/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsLanguage.java index a54d1b0c244e..d24a8eafdfb0 100644 --- a/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsLanguage.java +++ b/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsLanguage.java @@ -169,7 +169,7 @@ public class QuickjsLanguage extends TypedLanguageSupport implements ScriptingLa discard(state); } } - return convert(result, resultType, getCamelContext(), null); + return convert(QuickjsHelper.checkResult(result, null), resultType, getCamelContext(), null); } Object evaluateExpression(String script, Exchange exchange) { @@ -199,7 +199,7 @@ public class QuickjsLanguage extends TypedLanguageSupport implements ScriptingLa discard(state); } } - return convert(result, Object.class, exchange.getContext(), exchange); + return convert(QuickjsHelper.checkResult(result, exchange), Object.class, exchange.getContext(), exchange); } finally { if (previous != null) { currentExchange.set(previous); diff --git a/components/camel-quickjs/src/test/java/org/apache/camel/language/quickjs/QuickjsSerializationTest.java b/components/camel-quickjs/src/test/java/org/apache/camel/language/quickjs/QuickjsSerializationTest.java index 539435b92bca..9eb01cf75255 100644 --- a/components/camel-quickjs/src/test/java/org/apache/camel/language/quickjs/QuickjsSerializationTest.java +++ b/components/camel-quickjs/src/test/java/org/apache/camel/language/quickjs/QuickjsSerializationTest.java @@ -36,7 +36,6 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; class QuickjsSerializationTest { @@ -200,9 +199,32 @@ class QuickjsSerializationTest { @Test void cyclicResultWithSetDoesNotRecurseForever() { Exchange exchange = new DefaultExchange(context); - // the cycle is left to JSON as before; the conversion must not follow it into a stack overflow - assertThatCode(() -> language().createExpression("const o = { s: new Set([1]) }; o.self = o; o;") + // the cycle is left to JSON, which fails it; the conversion must not follow it into a stack overflow + assertThatThrownBy(() -> language().createExpression("const o = { s: new Set([1]) }; o.self = o; o;") .evaluate(exchange, Object.class)) - .doesNotThrowAnyException(); + .isInstanceOf(ExpressionEvaluationException.class) + .hasMessageContaining("The script gave no result"); + } + + @Test + void resultThatCannotReachJavaFailsInsteadOfGivingThePreviousResult() { + Exchange exchange = new DefaultExchange(context); + Language language = language(); + for (String script : List.of("1n", "({ big: 1n })", "new BigInt64Array(2)", "const o = {}; o.self = o; o;", + "({ toJSON() { throw new Error('no JSON') } })", "Promise.reject(new Error('boom'))")) { + assertThat(language.createExpression("5").evaluate(exchange, Object.class)).isEqualTo(5); + assertThatThrownBy(() -> language.createExpression(script).evaluate(exchange, Object.class)) + .as(script) + .isInstanceOf(ExpressionEvaluationException.class) + .hasMessageContaining("The script gave no result"); + } + assertThat(language.createExpression("Promise.resolve(6)").evaluate(exchange, Object.class)).isEqualTo(6); + assertThat(language.createExpression("null").evaluate(exchange, Object.class)).isNull(); + + ScriptingLanguage scripting = (ScriptingLanguage) language; + assertThat(scripting.evaluate("x", Map.of("x", 7), Object.class)).isEqualTo(7); + assertThatThrownBy(() -> scripting.evaluate("BigInt(x)", Map.of("x", 7), Object.class)) + .isInstanceOf(ExpressionEvaluationException.class) + .hasMessageContaining("The script gave no result"); } }
