This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new a08483cc21a6 CAMEL-25456: camel-wasm - discard the Wasm instance after 
a failed call (#27593)
a08483cc21a6 is described below

commit a08483cc21a67ea063b2102489427c59ad73de6d
Author: allthingssecurity <[email protected]>
AuthorDate: Sat Oct 10 01:20:41 2026 +0530

    CAMEL-25456: camel-wasm - discard the Wasm instance after a failed call 
(#27593)
    
    When the guest function trapped or the thread was interrupted, WasmFunction
    kept the instance, so guest allocations leaked on every failed message and 
the
    cleanup could replace the original exception. A call that does not complete 
now
    makes no further call into the instance, closes and discards it and rethrows
    the original exception; the next call creates a new instance. The docs now 
say
    the module is instantiated per producer (or per wasm expression).
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../apache/camel/catalog/docs/wasm-component.adoc  |  15 ++
 .../apache/camel/catalog/docs/wasm-language.adoc   |  14 ++
 components/camel-wasm/pom.xml                      |   6 +
 .../camel-wasm/src/main/docs/wasm-component.adoc   |  15 ++
 .../camel-wasm/src/main/docs/wasm-language.adoc    |  14 ++
 .../apache/camel/component/wasm/WasmProducer.java  |   3 +
 .../java/org/apache/camel/wasm/WasmFunction.java   | 116 ++++++++++----
 .../org/apache/camel/wasm/WasmFunctionTest.java    | 177 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |   6 +
 9 files changed, 338 insertions(+), 28 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/wasm-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/wasm-component.adoc
index 5b09ac8f5c33..ad9aa3daf956 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/wasm-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/wasm-component.adoc
@@ -152,6 +152,21 @@ pub extern fn process(ptr: u32, len: u32) -> u64 {
 }
 ----
 
+==== Errors and module state
+
+The module is instantiated once per producer (each `to` of a `wasm` endpoint 
in a route has its own instance) and that
+instance handles every message, one at a time, so data the module keeps in its 
memory or globals carries over from one
+message to the next.
+
+* When the function returns with the error bit set, the error data is released 
with `dealloc` using its size without
+the error bit, the error data becomes the message of the exception, and the 
instance is kept.
+* When the call fails without returning, for example on a Rust `panic` or any 
other Wasm trap, the instance is
+discarded and a new one is created from the module for the next message. 
Whatever the failed call allocated can no
+longer be released, so keeping the instance would grow its memory with every 
failure. Any state the module kept in
+the discarded instance is lost.
+On a route where every call fails this way, the module is instantiated again 
for every message (its data segments
+are copied and any start function runs), which costs more than a call on a 
kept instance.
+
 == Examples
 
 Supposing we have compiled a Wasm module containing the function above, then 
it can be called in a Camel Route by its name and module resource location:
diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/wasm-language.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/wasm-language.adoc
index 72208ba9c90e..340d82b8157b 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/wasm-language.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/wasm-language.adoc
@@ -126,6 +126,20 @@ pub extern fn transform(ptr: u32, len: u32) -> u64 {
 }
 ----
 
+=== Errors and module state
+
+The module is instantiated once per `wasm` expression and that instance 
handles every message, one at a time, so data
+the module keeps in its memory or globals carries over from one message to the 
next.
+
+* When the function returns with the error bit set, the error data is released 
with `dealloc` using its size without
+the error bit, the error data becomes the message of the exception, and the 
instance is kept.
+* When the call fails without returning, for example on a Rust `panic` or any 
other Wasm trap, the instance is
+discarded and a new one is created from the module for the next message. 
Whatever the failed call allocated can no
+longer be released, so keeping the instance would grow its memory with every 
failure. Any state the module kept in
+the discarded instance is lost.
+On a route where every call fails this way, the module is instantiated again 
for every message (its data segments
+are copied and any start function runs), which costs more than a call on a 
kept instance.
+
 == Examples
 
 Supposing we have compiled a Wasm module containing the function above, then 
it can be called in a Camel Route by its name and module resource location:
diff --git a/components/camel-wasm/pom.xml b/components/camel-wasm/pom.xml
index 7cf013987204..d340565ec437 100644
--- a/components/camel-wasm/pom.xml
+++ b/components/camel-wasm/pom.xml
@@ -74,6 +74,12 @@
             <artifactId>assertj-core</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>run.endive</groupId>
+            <artifactId>wabt</artifactId>
+            <version>${endive-version}</version>
+            <scope>test</scope>
+        </dependency>
 
     </dependencies>
 
diff --git a/components/camel-wasm/src/main/docs/wasm-component.adoc 
b/components/camel-wasm/src/main/docs/wasm-component.adoc
index 5b09ac8f5c33..ad9aa3daf956 100644
--- a/components/camel-wasm/src/main/docs/wasm-component.adoc
+++ b/components/camel-wasm/src/main/docs/wasm-component.adoc
@@ -152,6 +152,21 @@ pub extern fn process(ptr: u32, len: u32) -> u64 {
 }
 ----
 
+==== Errors and module state
+
+The module is instantiated once per producer (each `to` of a `wasm` endpoint 
in a route has its own instance) and that
+instance handles every message, one at a time, so data the module keeps in its 
memory or globals carries over from one
+message to the next.
+
+* When the function returns with the error bit set, the error data is released 
with `dealloc` using its size without
+the error bit, the error data becomes the message of the exception, and the 
instance is kept.
+* When the call fails without returning, for example on a Rust `panic` or any 
other Wasm trap, the instance is
+discarded and a new one is created from the module for the next message. 
Whatever the failed call allocated can no
+longer be released, so keeping the instance would grow its memory with every 
failure. Any state the module kept in
+the discarded instance is lost.
+On a route where every call fails this way, the module is instantiated again 
for every message (its data segments
+are copied and any start function runs), which costs more than a call on a 
kept instance.
+
 == Examples
 
 Supposing we have compiled a Wasm module containing the function above, then 
it can be called in a Camel Route by its name and module resource location:
diff --git a/components/camel-wasm/src/main/docs/wasm-language.adoc 
b/components/camel-wasm/src/main/docs/wasm-language.adoc
index 72208ba9c90e..340d82b8157b 100644
--- a/components/camel-wasm/src/main/docs/wasm-language.adoc
+++ b/components/camel-wasm/src/main/docs/wasm-language.adoc
@@ -126,6 +126,20 @@ pub extern fn transform(ptr: u32, len: u32) -> u64 {
 }
 ----
 
+=== Errors and module state
+
+The module is instantiated once per `wasm` expression and that instance 
handles every message, one at a time, so data
+the module keeps in its memory or globals carries over from one message to the 
next.
+
+* When the function returns with the error bit set, the error data is released 
with `dealloc` using its size without
+the error bit, the error data becomes the message of the exception, and the 
instance is kept.
+* When the call fails without returning, for example on a Rust `panic` or any 
other Wasm trap, the instance is
+discarded and a new one is created from the module for the next message. 
Whatever the failed call allocated can no
+longer be released, so keeping the instance would grow its memory with every 
failure. Any state the module kept in
+the discarded instance is lost.
+On a route where every call fails this way, the module is instantiated again 
for every message (its data segments
+are copied and any start function runs), which costs more than a call on a 
kept instance.
+
 == Examples
 
 Supposing we have compiled a Wasm module containing the function above, then 
it can be called in a Camel Route by its name and module resource location:
diff --git 
a/components/camel-wasm/src/main/java/org/apache/camel/component/wasm/WasmProducer.java
 
b/components/camel-wasm/src/main/java/org/apache/camel/component/wasm/WasmProducer.java
index c81f0f0669fb..4f37664da67b 100644
--- 
a/components/camel-wasm/src/main/java/org/apache/camel/component/wasm/WasmProducer.java
+++ 
b/components/camel-wasm/src/main/java/org/apache/camel/component/wasm/WasmProducer.java
@@ -67,6 +67,9 @@ public class WasmProducer extends DefaultProducer {
     public void doStop() throws Exception {
         super.doStop();
 
+        if (this.function != null) {
+            this.function.close();
+        }
         this.function = null;
     }
 
diff --git 
a/components/camel-wasm/src/main/java/org/apache/camel/wasm/WasmFunction.java 
b/components/camel-wasm/src/main/java/org/apache/camel/wasm/WasmFunction.java
index d7890c743e0b..2c3ef70f1ed4 100644
--- 
a/components/camel-wasm/src/main/java/org/apache/camel/wasm/WasmFunction.java
+++ 
b/components/camel-wasm/src/main/java/org/apache/camel/wasm/WasmFunction.java
@@ -16,24 +16,30 @@
  */
 package org.apache.camel.wasm;
 
+import java.nio.charset.StandardCharsets;
 import java.util.Objects;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import run.endive.runtime.ExportFunction;
 import run.endive.runtime.Instance;
 import run.endive.wasm.WasmModule;
 
 public class WasmFunction implements AutoCloseable {
+    private static final Logger LOG = 
LoggerFactory.getLogger(WasmFunction.class);
+
     private final Lock lock;
 
     private final WasmModule module;
     private final String functionName;
 
-    private final Instance instance;
-    private final ExportFunction function;
-    private final ExportFunction alloc;
-    private final ExportFunction dealloc;
+    // guarded by lock; null after a failed call until the next call creates a 
new instance
+    private Instance instance;
+    private ExportFunction function;
+    private ExportFunction alloc;
+    private ExportFunction dealloc;
 
     public WasmFunction(WasmModule module, String functionName) {
         this.lock = new ReentrantLock();
@@ -41,19 +47,22 @@ public class WasmFunction implements AutoCloseable {
         this.module = Objects.requireNonNull(module);
         this.functionName = Objects.requireNonNull(functionName);
 
-        this.instance = Instance.builder(this.module).build();
-        this.function = this.instance.export(this.functionName);
-        this.alloc = this.instance.export(Wasm.FN_ALLOC);
-        this.dealloc = this.instance.export(Wasm.FN_DEALLOC);
+        // the instance fields are not final: create the first instance under 
the lock, so that a first run() on
+        // another thread sees them
+        lock.lock();
+        try {
+            createInstance();
+        } finally {
+            lock.unlock();
+        }
     }
 
     public byte[] run(byte[] in) throws Exception {
         Objects.requireNonNull(in);
 
-        int inPtr = -1;
-        int inSize = in.length;
-        int outPtr = -1;
-        int outSize = 0;
+        final int inSize = in.length;
+        final byte[] out;
+        final boolean error;
 
         //
         // Wasm execution is not thread safe so we must put a
@@ -61,41 +70,92 @@ public class WasmFunction implements AutoCloseable {
         //
         lock.lock();
         try {
+            if (instance == null) {
+                createInstance();
+            }
+
             try {
-                inPtr = (int) alloc.apply(inSize)[0];
+                int inPtr = (int) alloc.apply(inSize)[0];
                 instance.memory().write(inPtr, in);
 
                 long[] results = function.apply(inPtr, inSize);
                 long ptrAndSize = results[0];
 
-                outPtr = (int) (ptrAndSize >> 32);
-                outSize = (int) ptrAndSize;
+                int outPtr = (int) (ptrAndSize >> 32);
+                int outSize = (int) ptrAndSize;
 
                 // assume the max output is 31 bit, leverage the first bit for
                 // error detection
-                if (isError(outSize)) {
-                    int errSize = errSize(outSize);
-                    String errData = instance.memory().readString(outPtr, 
errSize);
-
-                    throw new RuntimeException(errData);
+                error = isError(outSize);
+                if (error) {
+                    outSize = errSize(outSize);
                 }
 
-                return instance.memory().readBytes(outPtr, outSize);
-            } finally {
-                if (inPtr != -1) {
-                    dealloc.apply(inPtr, inSize);
-                }
-                if (outPtr != -1) {
-                    dealloc.apply(outPtr, outSize);
-                }
+                out = instance.memory().readBytes(outPtr, outSize);
+
+                dealloc.apply(inPtr, inSize);
+                dealloc.apply(outPtr, outSize);
+            } catch (RuntimeException | Error e) {
+                // The guest trapped, was interrupted or failed otherwise: its 
memory and globals are left as the
+                // failed call left them, and whatever it allocated before the 
failure can never be released.
+                // Discard the instance instead of calling into it again, so 
the original exception is propagated
+                // as is, and create a new one on the next call.
+                discardInstance(e);
+                throw e;
             }
         } finally {
             lock.unlock();
         }
+
+        if (error) {
+            throw new RuntimeException(new String(out, 
StandardCharsets.UTF_8));
+        }
+
+        return out;
     }
 
     @Override
     public void close() throws Exception {
+        lock.lock();
+        try {
+            if (instance != null) {
+                instance.close();
+            }
+            clearInstance();
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    private void createInstance() {
+        Instance newInstance = Instance.builder(this.module).build();
+        ExportFunction newFunction = newInstance.export(this.functionName);
+        ExportFunction newAlloc = newInstance.export(Wasm.FN_ALLOC);
+        ExportFunction newDealloc = newInstance.export(Wasm.FN_DEALLOC);
+
+        // all four fields refer to the same instance, or are left as they 
were if an export is missing
+        this.instance = newInstance;
+        this.function = newFunction;
+        this.alloc = newAlloc;
+        this.dealloc = newDealloc;
+    }
+
+    private void discardInstance(Throwable cause) {
+        LOG.debug("Discarding the Wasm instance of function {} after a failed 
call: {}", functionName, cause.getMessage());
+
+        try {
+            instance.close();
+        } catch (RuntimeException e) {
+            cause.addSuppressed(e);
+        }
+        clearInstance();
+    }
+
+    private void clearInstance() {
+        this.instance = null;
+        this.function = null;
+        this.alloc = null;
+        this.dealloc = null;
     }
 
     private static boolean isError(int number) {
diff --git 
a/components/camel-wasm/src/test/java/org/apache/camel/wasm/WasmFunctionTest.java
 
b/components/camel-wasm/src/test/java/org/apache/camel/wasm/WasmFunctionTest.java
new file mode 100644
index 000000000000..3df5ad17aa00
--- /dev/null
+++ 
b/components/camel-wasm/src/test/java/org/apache/camel/wasm/WasmFunctionTest.java
@@ -0,0 +1,177 @@
+/*
+ * 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.wasm;
+
+import java.util.Arrays;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.Test;
+import run.endive.runtime.TrapException;
+import run.endive.runtime.WasmInterruptedException;
+import run.endive.wabt.Wat2Wasm;
+import run.endive.wasm.Parser;
+import run.endive.wasm.WasmModule;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.awaitility.Awaitility.await;
+
+public class WasmFunctionTest {
+
+    /**
+     * A guest with one page of memory (64 KiB) and an arena allocator that 
only gets its memory back when every
+     * allocation has been released. The process function looks at the first 
byte of its input:
+     * <ul>
+     * <li>{@code t} allocates 32 KiB and traps before returning, like a Rust 
panic on bad input</li>
+     * <li>{@code e} returns the error message {@code boom} with the error bit 
set on its size</li>
+     * <li>{@code s} spins until the calling thread is interrupted</li>
+     * <li>anything else is echoed back</li>
+     * </ul>
+     * The dealloc function traps when it is given a size that does not fit in 
31 bits.
+     */
+    private static final String GUEST = """
+            (module
+              (memory (export "memory") 1 1)
+              (data (i32.const 16) "boom")
+              (global $base i32 (i32.const 1024))
+              (global $next (mut i32) (i32.const 1024))
+              (global $live (mut i32) (i32.const 0))
+
+              (func $alloc (export "alloc") (param $size i32) (result i32)
+                (local $ptr i32)
+                (local.set $ptr (global.get $next))
+                (if (i32.gt_u (i32.add (local.get $ptr) (local.get $size)) 
(i32.const 65536))
+                  (then unreachable))
+                (global.set $next (i32.add (local.get $ptr) (local.get $size)))
+                (global.set $live (i32.add (global.get $live) (i32.const 1)))
+                (local.get $ptr))
+
+              (func (export "dealloc") (param $ptr i32) (param $len i32)
+                (if (i32.lt_s (local.get $len) (i32.const 0))
+                  (then unreachable))
+                (global.set $live (i32.sub (global.get $live) (i32.const 1)))
+                (if (i32.eqz (global.get $live))
+                  (then (global.set $next (global.get $base)))))
+
+              (func (export "process") (param $ptr i32) (param $len i32) 
(result i64)
+                (local $op i32)
+                (local $out i32)
+                (if (i32.gt_u (local.get $len) (i32.const 0))
+                  (then (local.set $op (i32.load8_u (local.get $ptr)))))
+                (if (i32.eq (local.get $op) (i32.const 116))
+                  (then
+                    (drop (call $alloc (i32.const 32768)))
+                    unreachable))
+                (if (i32.eq (local.get $op) (i32.const 101))
+                  (then
+                    (local.set $out (call $alloc (i32.const 4)))
+                    (memory.copy (local.get $out) (i32.const 16) (i32.const 4))
+                    (return (i64.or
+                      (i64.shl (i64.extend_i32_u (local.get $out)) (i64.const 
32))
+                      (i64.const 0x80000004)))))
+                (if (i32.eq (local.get $op) (i32.const 115))
+                  (then (loop $spin (br $spin))))
+                (local.set $out (call $alloc (local.get $len)))
+                (memory.copy (local.get $out) (local.get $ptr) (local.get 
$len))
+                (i64.or
+                  (i64.shl (i64.extend_i32_u (local.get $out)) (i64.const 32))
+                  (i64.extend_i32_u (local.get $len))))
+            )
+            """;
+
+    private static WasmFunction newFunction() {
+        WasmModule module = Parser.parse(Wat2Wasm.parse(GUEST));
+        return new WasmFunction(module, "process");
+    }
+
+    private static byte[] data(char first, int size) {
+        byte[] data = new byte[size];
+        Arrays.fill(data, (byte) 'a');
+        data[0] = (byte) first;
+        return data;
+    }
+
+    @Test
+    public void testTrapDoesNotLeakGuestMemory() throws Exception {
+        WasmFunction function = newFunction();
+
+        // each trap leaves 32 KiB allocated in the guest, more than half of 
its memory
+        for (int i = 0; i < 3; i++) {
+            assertThatThrownBy(() -> function.run(data('t', 1)))
+                    .isInstanceOf(TrapException.class);
+        }
+
+        // needs 2 x 16 KiB: only fits if the memory of the failed calls was 
given back
+        byte[] in = data('a', 16 * 1024);
+        assertThat(function.run(in)).isEqualTo(in);
+    }
+
+    @Test
+    public void testErrorIsDeallocatedWithItsSize() throws Exception {
+        WasmFunction function = newFunction();
+
+        // dealloc traps on a size with the error bit set, which would replace 
the error of the function
+        assertThatThrownBy(() -> function.run(data('e', 1)))
+                .isExactlyInstanceOf(RuntimeException.class)
+                .hasMessage("boom");
+
+        byte[] in = data('a', 16);
+        assertThat(function.run(in)).isEqualTo(in);
+    }
+
+    @Test
+    public void testInterruptDoesNotLeakGuestMemory() throws Exception {
+        WasmFunction function = newFunction();
+        AtomicReference<Throwable> failure = new AtomicReference<>();
+
+        // the input of the interrupted call takes 40 KiB of the guest memory 
until it is released
+        Thread caller = new Thread(() -> {
+            try {
+                function.run(data('s', 40 * 1024));
+            } catch (Throwable e) {
+                failure.set(e);
+            }
+        }, "wasm-caller");
+        // a daemon, so that a guest that is never interrupted cannot keep the 
test JVM from exiting
+        caller.setDaemon(true);
+        caller.start();
+
+        try {
+            await().atMost(10, TimeUnit.SECONDS).until(() -> 
isRunningGuestCode(caller));
+            caller.interrupt();
+            caller.join(TimeUnit.SECONDS.toMillis(10));
+
+            assertThat(caller.isAlive()).isFalse();
+            
assertThat(failure.get()).isInstanceOf(WasmInterruptedException.class);
+
+            // needs 2 x 16 KiB: only fits if the input of the interrupted 
call was given back
+            byte[] in = data('a', 16 * 1024);
+            assertThat(function.run(in)).isEqualTo(in);
+        } finally {
+            // stop the spinning guest even when an assertion above failed
+            caller.interrupt();
+            caller.join(TimeUnit.SECONDS.toMillis(10));
+        }
+    }
+
+    private static boolean isRunningGuestCode(Thread thread) {
+        // the package rather than a class, so that an engine switch or an 
internal rename does not break the probe
+        return Arrays.stream(thread.getStackTrace())
+                .anyMatch(e -> e.getClassName().startsWith("run.endive."));
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 77e4031403e2..0652619a5217 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -3015,6 +3015,12 @@ Routes that deliberately read a `Camel`-prefixed header 
produced by the Tika par
 themselves after the `tika:parse` step, for example with a `setHeader` reading 
the corresponding
 non-prefixed metadata name.
 
+=== camel-wasm - a failed call discards the Wasm instance
+
+When a Wasm function fails without returning (a trap, such as a Rust `panic`), 
the `wasm` producer or `wasm` expression
+now discards its module instance and creates a new one for the next message, 
instead of reusing an instance whose memory
+kept everything the failed call had allocated. State that the module keeps 
between messages is reset at that point.
+
 === camel-xpath
 
 The XPath language now parses the message with the same hardened XML parser 
for every `documentType`.

Reply via email to