This is an automated email from the ASF dual-hosted git repository.
Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git
The following commit(s) were added to refs/heads/master by this push:
new ad89a887ad fix(wasm): isolate concurrent executions with instance pool
(#7582)
ad89a887ad is described below
commit ad89a887ad25fbfee6bc050432395324ab3ffd18
Author: eye-gu <[email protected]>
AuthorDate: Thu Oct 15 14:07:11 2026 +0800
fix(wasm): isolate concurrent executions with instance pool (#7582)
* fix(wasm): isolate concurrent executions with instance pool
Fixes #7558
* fix(wasm): isolate concurrent executions with instance pool
* fix(wasm): fix checkstyle, harden nesting, add echo fixture
* fix(wasm): address review: cap default pool size at 8, lease via
BlockingQueue, isolate test state
* fix review
* fix
* fix(wasm): address review: fail fast on nested export calls during
construction, use Math.toIntExact in echo test
* fix(wasm): address review: bounded lease wait with diagnostics, fail-fast
getMemory, suppress release failures
---
.github/scripts/verify-wasm-fixtures.py | 33 +-
.../shenyu/plugin/wasm/api/loader/WasmLoader.java | 271 ++++++++++++++--
.../plugin/wasm/api/WasmLoaderConcurrentTest.java | 341 +++++++++++++++++++++
...sm.api.WasmLoaderConcurrentTest$EchoPlugin.wasm | Bin 0 -> 124 bytes
.../src/test/wasm-concurrent/Makefile | 23 ++
.../src/test/wasm-concurrent/echo.wat | 27 ++
6 files changed, 649 insertions(+), 46 deletions(-)
diff --git a/.github/scripts/verify-wasm-fixtures.py
b/.github/scripts/verify-wasm-fixtures.py
index 9e8cd729a5..7238e0decb 100644
--- a/.github/scripts/verify-wasm-fixtures.py
+++ b/.github/scripts/verify-wasm-fixtures.py
@@ -43,21 +43,26 @@ def verify_wat():
version = subprocess.check_output(["wat2wasm", "--version"],
text=True).strip()
if version != "1.0.42":
raise RuntimeError("WAT verification requires WABT 1.0.42, found " +
version)
+ families = [
+ (BASE, ["wasm-bridge", "wasm-gateway"]),
+ (API, ["wasm-concurrent"]),
+ ]
count = 0
- for family in ["wasm-bridge", "wasm-gateway"]:
- directory = BASE / family
- if not directory.exists():
- continue
- with tempfile.TemporaryDirectory(prefix="shenyu-wat-") as temporary:
- run(["make", "-C", str(directory), "build", "RESOURCES=" +
temporary])
- binaries = list(Path(temporary).glob("*.wasm"))
- if not binaries:
- raise RuntimeError("No fixtures generated for " + family)
- for rebuilt in binaries:
- committed = BASE / "resources" / rebuilt.name
- if rebuilt.read_bytes() != committed.read_bytes():
- raise RuntimeError("WAT fixture differs from committed
binary: " + rebuilt.name)
- count += 1
+ for source_root, names in families:
+ for family in names:
+ directory = source_root / family
+ if not directory.exists():
+ continue
+ with tempfile.TemporaryDirectory(prefix="shenyu-wat-") as
temporary:
+ run(["make", "-C", str(directory), "build", "RESOURCES=" +
temporary])
+ binaries = list(Path(temporary).glob("*.wasm"))
+ if not binaries:
+ raise RuntimeError("No fixtures generated for " + family)
+ for rebuilt in binaries:
+ committed = source_root / "resources" / rebuilt.name
+ if rebuilt.read_bytes() != committed.read_bytes():
+ raise RuntimeError("WAT fixture differs from committed
binary: " + rebuilt.name)
+ count += 1
if not count:
raise RuntimeError("No WAT fixture families found")
print("Verified " + str(count) + " byte-identical WAT fixtures",
flush=True)
diff --git
a/shenyu-plugin/shenyu-plugin-wasm-api/src/main/java/org/apache/shenyu/plugin/wasm/api/loader/WasmLoader.java
b/shenyu-plugin/shenyu-plugin-wasm-api/src/main/java/org/apache/shenyu/plugin/wasm/api/loader/WasmLoader.java
index 14fcf64b07..2871ba67f8 100644
---
a/shenyu-plugin/shenyu-plugin-wasm-api/src/main/java/org/apache/shenyu/plugin/wasm/api/loader/WasmLoader.java
+++
b/shenyu-plugin/shenyu-plugin-wasm-api/src/main/java/org/apache/shenyu/plugin/wasm/api/loader/WasmLoader.java
@@ -20,7 +20,9 @@ package org.apache.shenyu.plugin.wasm.api.loader;
import com.dylibso.chicory.compiler.MachineFactoryCompiler;
import com.dylibso.chicory.runtime.ExportFunction;
import com.dylibso.chicory.runtime.HostFunction;
+import com.dylibso.chicory.runtime.ImportValues;
import com.dylibso.chicory.runtime.Instance;
+import com.dylibso.chicory.runtime.Machine;
import com.dylibso.chicory.runtime.Memory;
import com.dylibso.chicory.runtime.Store;
import com.dylibso.chicory.wasm.Parser;
@@ -34,11 +36,17 @@ import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.URL;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
+import java.util.function.Function;
/**
* The WasmLoader aims to load wasm file and provide the wasm function to java,
@@ -46,6 +54,12 @@ import java.util.function.Consumer;
*
* <p>Powered by <a href="https://chicory.dev/">Chicory</a> — a pure-Java
WebAssembly
* runtime with zero native dependencies.
+ *
+ * <p>Concurrency model: a Chicory {@link Instance} is not thread-safe
(interpreter
+ * stacks, guest heap, linear memory), so every exported call leases one
pooled instance
+ * exclusively, waiting at most {@link #leaseTimeoutMillis()} for a free one;
host
+ * callbacks run inside the lease and {@link #getMemory()} resolves to the
instance
+ * executing on the calling thread, failing fast outside a call.
*/
public class WasmLoader implements AutoCloseable {
@@ -53,20 +67,41 @@ public class WasmLoader implements AutoCloseable {
private static final String IMPORT_WASM_MODULE_NAME = "shenyu";
+ // Upper bound on parallel executions; capped so worst-case memory (grown
guest
+ // heaps persist per instance) stays bounded.
+ private static final int DEFAULT_POOL_SIZE =
Math.min(Runtime.getRuntime().availableProcessors(), 8);
+
+ // Bounded wait for a pooled instance: pool exhaustion (or a nested-call
circular
+ // wait) fails the request after this timeout instead of hanging it
forever.
+ private static final long DEFAULT_LEASE_TIMEOUT_MILLIS = 30_000L;
+
+ // Instances currently executing on this thread (a stack: nested calls
push/pop);
+ // getMemory() in host callbacks resolves against the top entry.
+ private final ThreadLocal<ArrayDeque<Instance>> bindings =
ThreadLocal.withInitial(ArrayDeque::new);
+
private final AtomicBoolean closed = new AtomicBoolean(false);
-
+
+ // becomes true after the instance pool is fully populated
+ private volatile boolean ready;
+
private final String wasmName;
private final Store store;
- private Instance instance;
+ // first created instance; probes export existence for getWasmExtern (all
pooled
+ // instances share one module, so any of them answers for all)
+ private final Instance primary;
+
+ private final BlockingQueue<Instance> freeInstances;
+
+ private final int poolSize;
public WasmLoader() {
this(null, null);
}
-
+
/**
- * Constructs a WasmLoader, locates the {@code .wasm} resource, and
instantiates the module.
+ * Constructs a WasmLoader with the default pool size (at most 8
instances).
*
* <p>Initialization order:
* <ol>
@@ -81,6 +116,19 @@ public class WasmLoader implements AutoCloseable {
* host functions and {@link
#initWasmCallJavaFunc(Store)} are skipped
*/
public WasmLoader(final Class<?> wasmClass, final Consumer<Store>
initializer) {
+ this(wasmClass, initializer, DEFAULT_POOL_SIZE);
+ }
+
+ /**
+ * Constructs a WasmLoader with an explicit instance pool size.
+ *
+ * @param wasmClass the class whose name determines the .wasm resource
path; if null, uses
+ * {@code this.getClass()} (inheritance mode)
+ * @param initializer optional consumer to register host functions; when
non-null the built-in
+ * host functions and {@link
#initWasmCallJavaFunc(Store)} are skipped
+ * @param poolSize the number of WASM instances to instantiate (at
least 1)
+ */
+ public WasmLoader(final Class<?> wasmClass, final Consumer<Store>
initializer, final int poolSize) {
final Class<?> clazz = Objects.nonNull(wasmClass) ? wasmClass :
this.getClass();
this.wasmName = clazz.getName() + ".wasm";
try {
@@ -117,44 +165,91 @@ public class WasmLoader implements AutoCloseable {
}
WasmModule module = Parser.parse(wasmBytes);
- // Try runtime compiler (WASM → JVM bytecode) for better execution
speed.
- // Falls back gracefully to interpreter if something goes wrong.
+ final ImportValues importValues = store.toImportValues();
+ // Try the runtime compiler (factory shared by all pooled
instances);
+ // fall back to the interpreter if compilation or first
instantiation fails.
+ Function<Instance, Machine> machineFactory;
+ Instance firstInstance;
try {
- this.instance = Instance.builder(module)
- .withImportValues(store.toImportValues())
-
.withMachineFactory(MachineFactoryCompiler.compile(module))
- .withStart(false)
- .build();
- LOG.debug("Using runtime compiler for {}", wasmName);
+ machineFactory = compileMachineFactory(module);
+ if (Objects.isNull(machineFactory)) {
+ firstInstance = createInstance(module, importValues, null);
+ LOG.debug("Using interpreter for {}", wasmName);
+ } else {
+ firstInstance = createInstance(module, importValues,
machineFactory);
+ LOG.debug("Using runtime compiler for {}", wasmName);
+ }
} catch (LinkageError | RuntimeException compilerError) {
LOG.warn("Runtime compiler unavailable, falling back to
interpreter for {}: {}",
wasmName, compilerError.getMessage());
- this.instance = Instance.builder(module)
- .withImportValues(store.toImportValues())
- .withStart(false)
- .build();
+ machineFactory = null;
+ firstInstance = createInstance(module, importValues, null);
}
- // Call _initialize if present (required by TinyGo -target
wasm-unknown).
- findWasmExtern("_initialize").ifPresent(initFn -> {
- initFn.apply();
- LOG.debug("Called _initialize for {}", wasmName);
- });
-
- if (Objects.isNull(instance.memory())) {
- throw new ShenyuWasmInitException("memory not available in
wasm file: " + wasmName);
+ final int size = Math.max(1, poolSize);
+ final List<Instance> instances = new ArrayList<>(size);
+ instances.add(firstInstance);
+ for (int i = 1; i < size; i++) {
+ instances.add(createInstance(module, importValues,
machineFactory));
+ }
+ this.poolSize = size;
+ this.primary = firstInstance;
+ this.freeInstances = new ArrayBlockingQueue<>(size);
+ // guest initialization runs after the execution mode is fixed,
+ // so its failures are not retried as compiler failures
+ for (Instance instance : instances) {
+ initialize(instance);
+ freeInstances.offer(instance);
}
+ this.ready = true;
} catch (IOException e) {
throw new ShenyuWasmInitException(e);
}
}
+ /**
+ * Compiles the module for JVM-bytecode execution. Overriding to return
null
+ * forces the interpreter machine (used by tests to cover the fallback
path).
+ *
+ * @param module the parsed WASM module
+ * @return the compiled machine factory, or null for the interpreter
+ */
+ protected Function<Instance, Machine> compileMachineFactory(final
WasmModule module) {
+ return MachineFactoryCompiler.compile(module);
+ }
+
+ private Instance createInstance(final WasmModule module, final
ImportValues importValues,
+ final Function<Instance, Machine>
machineFactory) {
+ Instance.Builder builder = Instance.builder(module)
+ .withImportValues(importValues)
+ .withStart(false);
+ if (Objects.nonNull(machineFactory)) {
+ builder = builder.withMachineFactory(machineFactory);
+ }
+ Instance instance = builder.build();
+ if (Objects.isNull(instance.memory())) {
+ throw new ShenyuWasmInitException("memory not available in wasm
file: " + wasmName);
+ }
+ return instance;
+ }
+
+ private void initialize(final Instance instance) {
+ // Call _initialize if present (required by TinyGo -target
wasm-unknown),
+ // bound to this instance so init-time host callbacks see its own
memory.
+ findExport(instance, "_initialize").ifPresent(initFn -> {
+ executeBound(instance, initFn, new long[0]);
+ LOG.debug("Called _initialize for {}", wasmName);
+ });
+ }
+
private void registerWasiStubs(final Store store) {
// Use Chicory's built-in full WASI preview1 implementation for
comprehensive
// WASI function coverage, matching the previous wasmtime-java
behavior.
// Only inherit stdout/stderr so WASM guest output (e.g. Go println,
// Rust eprintln!) is visible for debugging. Avoid inheriting the full
- // system context (env/args/filesystem) for security hardening.
+ // system context (env/args/filesystem): besides security hardening,
+ // preopened files would add mutable state to the WASI object shared
+ // by all pooled instances.
var options = com.dylibso.chicory.wasi.WasiOptions.builder()
.withStdout(System.out)
.withStderr(System.err)
@@ -192,7 +287,7 @@ public class WasmLoader implements AutoCloseable {
(instance, args) -> new long[]{onPutResult(args[0], args[1],
(int) args[2])}
));
}
-
+
/**
* Override in subclasses to register additional host functions exposed to
the
* WASM module. This is called during construction after the built-in host
@@ -266,6 +361,13 @@ public class WasmLoader implements AutoCloseable {
/**
* Get the WASM exported function.
*
+ * <p>The returned function leases a pooled instance for the whole call,
+ * making concurrent invocations safe.
+ *
+ * <p>Reentrancy constraint: exports must not be invoked from a host
callback
+ * that runs during construction (e.g. triggered by {@code _initialize}) —
+ * the pool is not populated yet, so such calls fail fast.
+ *
* @param funcName the name of the WASM exported function
* @return an Optional containing the ExportFunction if found, otherwise
empty
*/
@@ -274,6 +376,14 @@ public class WasmLoader implements AutoCloseable {
}
private Optional<ExportFunction> findWasmExtern(final String funcName) {
+ if (findExport(primary, funcName).isPresent()) {
+ return Optional.of(args -> executeOnPool(funcName, args));
+ }
+ LOG.debug("WASM export function '{}' not found in {}", funcName,
wasmName);
+ return Optional.empty();
+ }
+
+ private static Optional<ExportFunction> findExport(final Instance
instance, final String funcName) {
// Chicory throws InvalidException for an absent export. Check the
names first
// so optional hooks remain optional without hiding invalid export
types.
var exports = instance.module().exportSection();
@@ -282,17 +392,114 @@ public class WasmLoader implements AutoCloseable {
return Optional.of(instance.export(funcName));
}
}
- LOG.debug("WASM export function '{}' not found in {}", funcName,
wasmName);
return Optional.empty();
}
-
+
+ private long[] executeOnPool(final String funcName, final long[] args) {
+ final Instance lease = acquire(funcName);
+ Throwable failure = null;
+ try {
+ return executeBound(lease, lease.export(funcName), args);
+ } catch (final Throwable t) {
+ // remembered so a release failure below cannot mask the original
failure
+ failure = t;
+ throw t;
+ } finally {
+ try {
+ release(lease);
+ } catch (final RuntimeException e) {
+ if (Objects.nonNull(failure)) {
+ failure.addSuppressed(e);
+ } else {
+ throw e;
+ }
+ }
+ }
+ }
+
+ // binds the thread to the instance so guest host callbacks resolve
getMemory() to it;
+ // pops on exit (nested calls each push their own entry)
+ private long[] executeBound(final Instance instance, final ExportFunction
export, final long[] args) {
+ final ArrayDeque<Instance> stack = bindings.get();
+ stack.push(instance);
+ try {
+ return export.apply(args);
+ } finally {
+ stack.pop();
+ }
+ }
+
/**
- * Get the WASM module's linear memory.
+ * Max time to wait for a free pooled instance before failing the call.
+ * Overridable so tests can exercise the timeout path quickly.
*
- * @return the WASM linear memory
+ * @return the lease timeout in milliseconds
+ */
+ protected long leaseTimeoutMillis() {
+ return DEFAULT_LEASE_TIMEOUT_MILLIS;
+ }
+
+ private Instance acquire(final String funcName) {
+ if (!ready) {
+ // reached only from a host callback during construction (e.g. a
nested
+ // export call from _initialize): no initialized instance is free
yet,
+ // so waiting could never succeed
+ throw new IllegalStateException(
+ "Nested export call during construction of " + wasmName
+ + "; the instance pool is not ready yet");
+ }
+ final ArrayDeque<Instance> active = bindings.get();
+ if (active.size() >= poolSize) {
+ // nesting deeper than the pool: this thread already leases every
instance,
+ // so waiting could never succeed
+ throw new IllegalStateException(
+ "Nested export calls exceed the WASM instance pool size
for " + wasmName);
+ }
+ final long timeoutMillis = leaseTimeoutMillis();
+ final Instance lease;
+ try {
+ lease = freeInstances.poll(timeoutMillis, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new ShenyuWasmException(
+ "Interrupted while waiting for a pooled WASM instance of "
+ wasmName, e);
+ }
+ if (Objects.isNull(lease)) {
+ final int inUse = poolSize - freeInstances.size();
+ throw new ShenyuWasmException(
+ "Timed out after " + timeoutMillis + "ms waiting for a
pooled WASM instance of "
+ + wasmName + " for export '" + funcName + "' (pool
size " + poolSize
+ + ", " + inUse + " in use, " + active.size() + "
held by this thread)");
+ }
+ return lease;
+ }
+
+ private void release(final Instance lease) {
+ if (!freeInstances.offer(lease)) {
+ throw new IllegalStateException(
+ "Failed to return pooled WASM instance for " + wasmName
+ + "; queue is full, which indicates a
double-release or pool corruption");
+ }
+ }
+
+ /**
+ * Get the WASM linear memory of the instance executing on the current
thread.
+ *
+ * <p>Only valid inside an exported call or a host callback on the calling
thread
+ * (where it resolves to the leased instance): pooled instances each own
their
+ * memory, so there is no safe fallback and unbound callers fail fast.
+ *
+ * @return the WASM linear memory of the currently executing instance
+ * @throws IllegalStateException when no instance is executing on this
thread
*/
public Memory getMemory() {
- return instance.memory();
+ final Instance current = bindings.get().peek();
+ if (Objects.isNull(current)) {
+ throw new IllegalStateException(
+ "getMemory() called outside an exported call or host
callback of " + wasmName
+ + "; pooled instances have per-instance memory");
+ }
+ return current.memory();
}
/**
@@ -303,7 +510,7 @@ public class WasmLoader implements AutoCloseable {
public String getWasmName() {
return wasmName;
}
-
+
/**
* Get the Chicory Store.
*
diff --git
a/shenyu-plugin/shenyu-plugin-wasm-api/src/test/java/org/apache/shenyu/plugin/wasm/api/WasmLoaderConcurrentTest.java
b/shenyu-plugin/shenyu-plugin-wasm-api/src/test/java/org/apache/shenyu/plugin/wasm/api/WasmLoaderConcurrentTest.java
new file mode 100644
index 0000000000..b9fd95cc04
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-wasm-api/src/test/java/org/apache/shenyu/plugin/wasm/api/WasmLoaderConcurrentTest.java
@@ -0,0 +1,341 @@
+/*
+ * 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.shenyu.plugin.wasm.api;
+
+import com.dylibso.chicory.runtime.ExportFunction;
+import com.dylibso.chicory.runtime.Instance;
+import com.dylibso.chicory.runtime.Machine;
+import com.dylibso.chicory.wasm.WasmModule;
+import org.apache.shenyu.plugin.wasm.api.exception.ShenyuWasmException;
+import org.apache.shenyu.plugin.wasm.api.loader.WasmLoader;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Function;
+import java.util.function.LongFunction;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Regression tests for issue #7558: pooled instances must isolate concurrent
calls
+ * while still allowing executions to overlap.
+ */
+public class WasmLoaderConcurrentTest {
+
+ private final String expectedResult = "go result";
+
+ private final Map<Long, String> results = new ConcurrentHashMap<>();
+
+ private final AtomicLong ids = new AtomicLong();
+
+ @Test
+ public void concurrentExportsAreIsolatedTest() throws Exception {
+ assertConcurrentCallsAreIsolated(new TestLoader(4, 0L, null, null),
argId -> expectedResult);
+ }
+
+ @Test
+ public void concurrentExportsAreIsolatedOnInterpreterTest() throws
Exception {
+ assertConcurrentCallsAreIsolated(new InterpretedTestLoader(4), argId
-> expectedResult);
+ }
+
+ @Test
+ public void concurrentEchoPayloadsAreIsolatedTest() throws Exception {
+ // the echo guest returns exactly its own input, so input-side swaps
between calls are visible
+ assertConcurrentCallsAreIsolated(new EchoTestLoader(4), argId ->
"hello from java " + argId);
+ }
+
+ @Test
+ public void pooledInstancesExecuteInParallelTest() throws Exception {
+ final long blockedArgId = -1L;
+ final CountDownLatch blockEntered = new CountDownLatch(1);
+ final CountDownLatch releaseBlock = new CountDownLatch(1);
+ try (WasmLoader loader = new TestLoader(2, blockedArgId, blockEntered,
releaseBlock)) {
+ final ExportFunction execute = loader.getWasmExtern("execute")
+ .orElseThrow(() -> new IllegalStateException("execute
export not found"));
+ final ExecutorService pool = Executors.newFixedThreadPool(2);
+ try {
+ final Future<?> blockedCall = pool.submit(() ->
execute.apply(blockedArgId));
+ assertTrue(blockEntered.await(10, TimeUnit.SECONDS), "first
call never reached its host callback");
+
+ // must complete on a free instance, not queue behind the
parked one
+ final Future<String> secondCall = pool.submit(() -> {
+ final long argumentId = ids.incrementAndGet();
+ execute.apply(argumentId);
+ return results.remove(argumentId);
+ });
+ assertEquals(expectedResult, secondCall.get(10,
TimeUnit.SECONDS),
+ "second call should complete on a free pooled
instance, not queue behind the parked one");
+
+ releaseBlock.countDown();
+ blockedCall.get(10, TimeUnit.SECONDS);
+ assertEquals(expectedResult, results.remove(blockedArgId));
+ assertTrue(results.isEmpty(), "stray results indicate
cross-call payload leakage");
+ } finally {
+ releaseBlock.countDown();
+ pool.shutdownNow();
+ }
+ }
+ }
+
+ @Test
+ public void nestedExportsUseDistinctInstancesTest() throws Exception {
+ try (WasmLoader loader = new RecursiveTestLoader(3, 3)) {
+ final ExportFunction execute = loader.getWasmExtern("execute")
+ .orElseThrow(() -> new IllegalStateException("execute
export not found"));
+ execute.apply(-1L);
+ for (long argId = -1L; argId >= -3L; argId--) {
+ assertEquals(expectedResult, results.remove(argId), "nested
call " + argId + " lost its payload");
+ }
+ assertTrue(results.isEmpty(), "stray results indicate cross-call
payload leakage");
+ }
+ }
+
+ @Test
+ public void nestingBeyondPoolSizeFailsExplicitlyTest() throws Exception {
+ try (WasmLoader loader = new RecursiveTestLoader(2, 3)) {
+ final ExportFunction execute = loader.getWasmExtern("execute")
+ .orElseThrow(() -> new IllegalStateException("execute
export not found"));
+ final IllegalStateException ex =
assertThrows(IllegalStateException.class, () -> execute.apply(-1L));
+ assertTrue(ex.getMessage().contains("Nested export calls exceed
the WASM instance pool size"),
+ "unexpected message: " + ex.getMessage());
+ assertTrue(results.isEmpty(), "failed nesting must not leave stray
payloads");
+ }
+ }
+
+ @Test
+ public void poolExhaustionTimesOutInsteadOfHangingTest() throws Exception {
+ final long blockedArgId = -1L;
+ final CountDownLatch blockEntered = new CountDownLatch(1);
+ final CountDownLatch releaseBlock = new CountDownLatch(1);
+ try (WasmLoader loader = new TimeoutTestLoader(1, blockedArgId,
blockEntered, releaseBlock)) {
+ final ExportFunction execute = loader.getWasmExtern("execute")
+ .orElseThrow(() -> new IllegalStateException("execute
export not found"));
+ final ExecutorService pool = Executors.newFixedThreadPool(1);
+ try {
+ final Future<?> blockedCall = pool.submit(() ->
execute.apply(blockedArgId));
+ assertTrue(blockEntered.await(10, TimeUnit.SECONDS), "first
call never reached its host callback");
+
+ // the only instance is parked in the blocked callback: the
pool must
+ // fail this call after the (shortened) timeout instead of
hanging it
+ final ShenyuWasmException ex =
assertThrows(ShenyuWasmException.class,
+ () -> execute.apply(ids.incrementAndGet()));
+ assertTrue(ex.getMessage().contains("Timed out"), "unexpected
message: " + ex.getMessage());
+ // the parked call deterministically holds the only instance,
so the
+ // diagnostics must report the pool as fully in use
+ assertTrue(ex.getMessage().contains("pool size 1, 1 in use"),
+ "unexpected message: " + ex.getMessage());
+
+ releaseBlock.countDown();
+ blockedCall.get(10, TimeUnit.SECONDS);
+ assertEquals(expectedResult, results.remove(blockedArgId));
+ } finally {
+ releaseBlock.countDown();
+ pool.shutdownNow();
+ }
+ }
+ }
+
+ @Test
+ public void getMemoryOutsideExecutionFailsFastTest() {
+ try (WasmLoader loader = new TestLoader(1, 0L, null, null)) {
+ final IllegalStateException ex =
assertThrows(IllegalStateException.class, loader::getMemory);
+ assertTrue(ex.getMessage().contains("outside an exported call or
host callback"),
+ "unexpected message: " + ex.getMessage());
+ }
+ }
+
+ private void assertConcurrentCallsAreIsolated(final WasmLoader loader,
+ final LongFunction<String>
expectedFor) throws Exception {
+ try (loader) {
+ final ExportFunction execute = loader.getWasmExtern("execute")
+ .orElseThrow(() -> new IllegalStateException("execute
export not found"));
+ final int threads = 8;
+ final int iterations = 100;
+ final ExecutorService pool = Executors.newFixedThreadPool(threads);
+ try {
+ final List<Future<List<String>>> futures = new ArrayList<>();
+ for (int i = 0; i < threads; i++) {
+ futures.add(pool.submit(() -> runIterations(execute,
iterations, expectedFor)));
+ }
+ final List<String> failures = new ArrayList<>();
+ for (Future<List<String>> future : futures) {
+ failures.addAll(future.get());
+ }
+ assertTrue(failures.isEmpty(), () ->
String.join(System.lineSeparator(), failures));
+ assertTrue(results.isEmpty(), "stray results indicate
cross-call payload leakage");
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+ }
+
+ private List<String> runIterations(final ExportFunction execute, final int
iterations,
+ final LongFunction<String> expectedFor)
{
+ final List<String> failures = new ArrayList<>();
+ for (int i = 0; i < iterations; i++) {
+ final long argumentId = ids.incrementAndGet();
+ try {
+ execute.apply(argumentId);
+ final String result = results.remove(argumentId);
+ final String expected = expectedFor.apply(argumentId);
+ if (!expected.equals(result)) {
+ failures.add(String.format("%s argId=%d expected '%s' but
got '%s'",
+ Thread.currentThread().getName(), argumentId,
expected, result));
+ }
+ } catch (RuntimeException e) {
+ failures.add(String.format("%s argId=%d threw %s: %s",
+ Thread.currentThread().getName(), argumentId,
e.getClass().getSimpleName(), e.getMessage()));
+ }
+ }
+ return failures;
+ }
+
+ /**
+ * Mirrors real plugin subclasses: built-in callbacks reach guest memory
through
+ * {@code getMemory()}. A non-zero {@code blockArgId} parks the matching
call
+ * inside its {@code get_args} callback until {@code releaseBlock} fires.
+ */
+ class TestLoader extends WasmLoader {
+
+ private final long blockArgId;
+
+ private final CountDownLatch blockEntered;
+
+ private final CountDownLatch releaseBlock;
+
+ TestLoader(final int poolSize, final long blockArgId,
+ final CountDownLatch blockEntered, final CountDownLatch
releaseBlock) {
+ super(AbstractWasmPluginTest.GoWasmPlugin.class, null, poolSize);
+ this.blockArgId = blockArgId;
+ this.blockEntered = blockEntered;
+ this.releaseBlock = releaseBlock;
+ }
+
+ @Override
+ protected long onGetArgs(final long argId, final long addr, final int
len) {
+ if (blockArgId != 0L && argId == blockArgId) {
+ blockEntered.countDown();
+ try {
+ assertTrue(releaseBlock.await(10, TimeUnit.SECONDS),
"blocked callback was never released");
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ final byte[] data = ("hello from java " +
argId).getBytes(StandardCharsets.UTF_8);
+ final int writeLen = Math.min(len, data.length);
+ getMemory().write(Math.toIntExact(addr), data, 0, writeLen);
+ return writeLen;
+ }
+
+ @Override
+ protected long onPutResult(final long argId, final long addr, final
int len) {
+ results.put(argId, new
String(getMemory().readBytes(Math.toIntExact(addr), len),
StandardCharsets.UTF_8));
+ return 0;
+ }
+ }
+
+ /** Forces the interpreter machine to cover the fallback execution path. */
+ final class InterpretedTestLoader extends TestLoader {
+
+ InterpretedTestLoader(final int poolSize) {
+ super(poolSize, 0L, null, null);
+ }
+
+ @Override
+ protected Function<Instance, Machine> compileMachineFactory(final
WasmModule module) {
+ return null;
+ }
+ }
+
+ /**
+ * Drives nested export calls from the {@code get_args} callback: a
negative
+ * argId {@code -n} recurses to level {@code n + 1} until {@code depth} is
reached.
+ */
+ class RecursiveTestLoader extends TestLoader {
+
+ private final int depth;
+
+ RecursiveTestLoader(final int poolSize, final int depth) {
+ super(poolSize, 0L, null, null);
+ this.depth = depth;
+ }
+
+ @Override
+ protected long onGetArgs(final long argId, final long addr, final int
len) {
+ if (argId < 0L && -argId < depth) {
+ getWasmExtern("execute").orElseThrow().apply(argId - 1L);
+ }
+ return super.onGetArgs(argId, addr, len);
+ }
+ }
+
+ /** Shortens the lease wait so pool-exhaustion timeouts surface in
milliseconds. */
+ final class TimeoutTestLoader extends TestLoader {
+
+ TimeoutTestLoader(final int poolSize, final long blockArgId,
+ final CountDownLatch blockEntered, final
CountDownLatch releaseBlock) {
+ super(poolSize, blockArgId, blockEntered, releaseBlock);
+ }
+
+ @Override
+ protected long leaseTimeoutMillis() {
+ return 200L;
+ }
+ }
+
+ /** Name carrier for the echo fixture built from wasm-concurrent/echo.wat.
*/
+ static class EchoPlugin {
+ }
+
+ /**
+ * Loads the echo guest: {@code put_result} returns exactly the bytes
+ * {@code get_args} delivered, so results must match each call's own input.
+ */
+ class EchoTestLoader extends WasmLoader {
+
+ EchoTestLoader(final int poolSize) {
+ super(EchoPlugin.class, null, poolSize);
+ }
+
+ @Override
+ protected long onGetArgs(final long argId, final long addr, final int
len) {
+ final byte[] data = ("hello from java " +
argId).getBytes(StandardCharsets.UTF_8);
+ final int writeLen = Math.min(len, data.length);
+ getMemory().write(Math.toIntExact(addr), data, 0, writeLen);
+ return writeLen;
+ }
+
+ @Override
+ protected long onPutResult(final long argId, final long addr, final
int len) {
+ results.put(argId, new
String(getMemory().readBytes(Math.toIntExact(addr), len),
StandardCharsets.UTF_8));
+ return 0;
+ }
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-wasm-api/src/test/resources/org.apache.shenyu.plugin.wasm.api.WasmLoaderConcurrentTest$EchoPlugin.wasm
b/shenyu-plugin/shenyu-plugin-wasm-api/src/test/resources/org.apache.shenyu.plugin.wasm.api.WasmLoaderConcurrentTest$EchoPlugin.wasm
new file mode 100644
index 0000000000..2e44f49fdd
Binary files /dev/null and
b/shenyu-plugin/shenyu-plugin-wasm-api/src/test/resources/org.apache.shenyu.plugin.wasm.api.WasmLoaderConcurrentTest$EchoPlugin.wasm
differ
diff --git
a/shenyu-plugin/shenyu-plugin-wasm-api/src/test/wasm-concurrent/Makefile
b/shenyu-plugin/shenyu-plugin-wasm-api/src/test/wasm-concurrent/Makefile
new file mode 100644
index 0000000000..cbc36b957d
--- /dev/null
+++ b/shenyu-plugin/shenyu-plugin-wasm-api/src/test/wasm-concurrent/Makefile
@@ -0,0 +1,23 @@
+#
+# 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.
+#
+
+WAT2WASM ?= wat2wasm
+RESOURCES := ../resources
+
+.PHONY: build
+build:
+ $(WAT2WASM) echo.wat -o
'$(RESOURCES)/org.apache.shenyu.plugin.wasm.api.WasmLoaderConcurrentTest$$EchoPlugin.wasm'
diff --git
a/shenyu-plugin/shenyu-plugin-wasm-api/src/test/wasm-concurrent/echo.wat
b/shenyu-plugin/shenyu-plugin-wasm-api/src/test/wasm-concurrent/echo.wat
new file mode 100644
index 0000000000..f62d47eeaf
--- /dev/null
+++ b/shenyu-plugin/shenyu-plugin-wasm-api/src/test/wasm-concurrent/echo.wat
@@ -0,0 +1,27 @@
+(;
+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.
+;)
+
+;; Pure echo guest: put_result returns exactly the bytes get_args delivered,
+;; so each call's result is bound to its own request payload.
+(module
+ (import "shenyu" "get_args" (func $get_args (param i64 i64 i32) (result
i32)))
+ (import "shenyu" "put_result" (func $put_result (param i64 i64 i32) (result
i32)))
+ (memory (export "memory") 1)
+ (func (export "execute") (param $argId i64)
+ (local $len i32)
+ (local.set $len (call $get_args (local.get $argId) (i64.const 1024)
(i32.const 4096)))
+ (drop (call $put_result (local.get $argId) (i64.const 1024) (local.get
$len)))))