diff --git a/build-time-compiler/src/main/java/run/endive/build/time/compiler/Generator.java b/build-time-compiler/src/main/java/run/endive/build/time/compiler/Generator.java
index 1e47938c7..9ac9bfbf7 100644
--- a/build-time-compiler/src/main/java/run/endive/build/time/compiler/Generator.java
+++ b/build-time-compiler/src/main/java/run/endive/build/time/compiler/Generator.java
@@ -37,17 +37,22 @@
import run.endive.codegen.ModuleInterfaceCodegen;
import run.endive.compiler.internal.ByteClassCollector;
import run.endive.compiler.internal.Compiler;
+import run.endive.runtime.ByteBufferMemory;
import run.endive.runtime.CompiledModule;
import run.endive.runtime.Instance;
import run.endive.runtime.Machine;
+import run.endive.runtime.Memory;
+import run.endive.runtime.TableInstance;
import run.endive.wasm.MalformedException;
import run.endive.wasm.Parser;
import run.endive.wasm.WasmModule;
import run.endive.wasm.WasmWriter;
import run.endive.wasm.types.ExternalType;
+import run.endive.wasm.types.MemoryLimits;
import run.endive.wasm.types.OpCode;
import run.endive.wasm.types.RawSection;
import run.endive.wasm.types.SectionId;
+import run.endive.wasm.types.Table;
public class Generator {
@@ -110,6 +115,9 @@ public void generateSources() throws IOException {
generateLoadMethod(cu, type);
generateMachineFactoryMethod(cu, type, moduleName);
generateWasmModuleMethod(cu, type, moduleName);
+ generateBuilderMethod(cu, type, moduleName);
+ generateSafeBuilderMethod(cu, type, moduleName);
+ generateImportFactoryMethods(cu, type);
dest.add(packageName, moduleName + ".java", cu);
dest.saveAll();
@@ -233,6 +241,105 @@ private static void generateCreateMethod(
method.addStatement(new ReturnStmt(constructorInvocation));
}
+ /**
+ * Generates:
+ *
+ * public static Instance.Builder builder() {
+ * return Instance.builder(load()).withMachineFactory(<moduleName>::create);
+ * }
+ *
+ *
+ *
Every generated module has this, so the same user code compiles whether or
+ * not a backend that replaces it is in play. Redline overwrites the body to pick
+ * native code when it can, and leaves this one as the fallback.
+ */
+ private static void generateBuilderMethod(
+ CompilationUnit cu, ClassOrInterfaceDeclaration type, String moduleName) {
+ cu.addImport(Instance.class);
+ type.addMethod("builder", Modifier.Keyword.PUBLIC, Modifier.Keyword.STATIC)
+ .setType(parseClassOrInterfaceType("Instance.Builder"))
+ .createBody()
+ .addStatement(
+ new ReturnStmt(compiledBuilder(new MethodCallExpr("load"), moduleName)));
+ }
+
+ /**
+ * Generates the same builder under a name no backend replaces, so there is always
+ * a way to ask for the bytecode specifically.
+ */
+ private static void generateSafeBuilderMethod(
+ CompilationUnit cu, ClassOrInterfaceDeclaration type, String moduleName) {
+ cu.addImport(Instance.class);
+ type.addMethod("safeBuilder", Modifier.Keyword.PUBLIC, Modifier.Keyword.STATIC)
+ .setType(parseClassOrInterfaceType("Instance.Builder"))
+ .createBody()
+ .addStatement(
+ new ReturnStmt(compiledBuilder(new MethodCallExpr("load"), moduleName)));
+ }
+
+ /** {@code Instance.builder().withMachineFactory(::create)} */
+ private static MethodCallExpr compiledBuilder(
+ com.github.javaparser.ast.expr.Expression module, String moduleName) {
+ return new MethodCallExpr(
+ new MethodCallExpr(new NameExpr("Instance"), "builder", new NodeList<>(module)),
+ "withMachineFactory",
+ new NodeList<>(
+ new MethodReferenceExpr()
+ .setScope(new NameExpr(moduleName))
+ .setIdentifier("create")));
+ }
+
+ /**
+ * Generates:
+ *
+ * public static Memory createMemory(MemoryLimits limits) {
+ * return new ByteBufferMemory(limits);
+ * }
+ *
+ * public static TableInstance createTable(Table table, int initValue) {
+ * return new TableInstance(table, initValue);
+ * }
+ *
+ *
+ * A backend whose compiled code reaches into a memory or table directly
+ * cannot accept one built any other way, so it replaces these. Going through
+ * the module rather than naming a type keeps the calling code the same either
+ * way.
+ */
+ private static void generateImportFactoryMethods(
+ CompilationUnit cu, ClassOrInterfaceDeclaration type) {
+ cu.addImport(Memory.class);
+ cu.addImport(ByteBufferMemory.class);
+ cu.addImport(TableInstance.class);
+ cu.addImport(MemoryLimits.class);
+ cu.addImport(Table.class);
+
+ type.addMethod("createMemory", Modifier.Keyword.PUBLIC, Modifier.Keyword.STATIC)
+ .addParameter(parseType("MemoryLimits"), "limits")
+ .setType(Memory.class)
+ .createBody()
+ .addStatement(
+ new ReturnStmt(
+ new ObjectCreationExpr(
+ null,
+ parseClassOrInterfaceType("ByteBufferMemory"),
+ NodeList.nodeList(new NameExpr("limits")))));
+
+ type.addMethod("createTable", Modifier.Keyword.PUBLIC, Modifier.Keyword.STATIC)
+ .addParameter(parseType("Table"), "table")
+ .addParameter(parseType("int"), "initValue")
+ .setType(TableInstance.class)
+ .createBody()
+ .addStatement(
+ new ReturnStmt(
+ new ObjectCreationExpr(
+ null,
+ parseClassOrInterfaceType("TableInstance"),
+ NodeList.nodeList(
+ new NameExpr("table"),
+ new NameExpr("initValue")))));
+ }
+
private static void generateWasmModuleHolderInnerClass(
ClassOrInterfaceDeclaration type, String moduleName, String wasmName) {
diff --git a/redline/api/src/main/java/run/endive/redline/experimental/api/internal/CtxBuffer.java b/redline/api/src/main/java/run/endive/redline/experimental/api/internal/CtxBuffer.java
index 7d37d076a..7462e7896 100644
--- a/redline/api/src/main/java/run/endive/redline/experimental/api/internal/CtxBuffer.java
+++ b/redline/api/src/main/java/run/endive/redline/experimental/api/internal/CtxBuffer.java
@@ -73,6 +73,12 @@ private CtxBuffer() {}
public static final int TRAP_UNALIGNED_ATOMIC = 12;
public static final int TRAP_INTERRUPTED = 13;
+ /**
+ * A host function threw. The throwable itself is held by the runner; this only
+ * marks the context so compiled code unwinds instead of running on.
+ */
+ public static final int TRAP_HOST_EXCEPTION = 14;
+
public static final int TABLE_SIZE_OFFSET = 0;
public static final int TABLE_MAX_OFFSET = 4;
public static final int TABLE_ENTRIES_OFFSET = 8;
diff --git a/redline/build-time-compiler/src/main/java/run/endive/redline/experimental/build/RedlineGenerator.java b/redline/build-time-compiler/src/main/java/run/endive/redline/experimental/build/RedlineGenerator.java
index 4e6357fd4..4505231f5 100644
--- a/redline/build-time-compiler/src/main/java/run/endive/redline/experimental/build/RedlineGenerator.java
+++ b/redline/build-time-compiler/src/main/java/run/endive/redline/experimental/build/RedlineGenerator.java
@@ -5,6 +5,7 @@
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.Modifier;
+import com.github.javaparser.ast.Node;
import com.github.javaparser.ast.NodeList;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.Parameter;
@@ -103,7 +104,7 @@ public void extendGeneratedSources() throws IOException {
generateLoadNativeCodeMethod(type);
generateNativeProviderMethod(type);
generateBuilderMethod(type, baseName);
- generateSafeBuilderMethod(type, baseName);
+ generateImportFactoryMethods(type);
Files.writeString(sourceFile, cu.toString());
}
@@ -310,6 +311,88 @@ private static void generateNativeProviderMethod(ClassOrInterfaceDeclaration typ
new NameExpr("NativeMachineFactoryProvider"), "discover")));
}
+ /**
+ * Replaces the plain import factories with ones that go through the native
+ * provider when there is one. Compiled code reaches into a memory or table
+ * through a raw base address, so an imported one has to come from the same
+ * backend that will run the module. Falling back leaves the plain types,
+ * which is what the bytecode path expects.
+ *
+ *
Generates:
+ *
+ * public static Memory createMemory(MemoryLimits limits) {
+ * var provider = nativeProvider();
+ * if (provider.isPresent()) {
+ * return provider.get().createMemory(limits);
+ * }
+ * return new ByteBufferMemory(limits);
+ * }
+ *
+ * and the same shape for createTable.
+ */
+ private static void generateImportFactoryMethods(ClassOrInterfaceDeclaration type) {
+ // addMethod appends, so the base generator's versions have to go first.
+ type.getMethodsByName("createMemory").forEach(Node::remove);
+ type.getMethodsByName("createTable").forEach(Node::remove);
+
+ var memory =
+ type.addMethod("createMemory", Modifier.Keyword.PUBLIC, Modifier.Keyword.STATIC)
+ .addParameter(parseType("MemoryLimits"), "limits")
+ .setType(parseType("Memory"));
+ memory.createBody()
+ .addStatement(providerVar())
+ .addStatement(
+ ifProviderPresent(
+ new MethodCallExpr(
+ new MethodCallExpr(new NameExpr("provider"), "get"),
+ "createMemory",
+ new NodeList<>(new NameExpr("limits")))))
+ .addStatement(
+ new ReturnStmt(
+ new com.github.javaparser.ast.expr.ObjectCreationExpr(
+ null,
+ parseClassOrInterfaceType("ByteBufferMemory"),
+ new NodeList<>(new NameExpr("limits")))));
+
+ var table =
+ type.addMethod("createTable", Modifier.Keyword.PUBLIC, Modifier.Keyword.STATIC)
+ .addParameter(parseType("Table"), "table")
+ .addParameter(parseType("int"), "initValue")
+ .setType(parseType("TableInstance"));
+ table.createBody()
+ .addStatement(providerVar())
+ .addStatement(
+ ifProviderPresent(
+ new MethodCallExpr(
+ new MethodCallExpr(new NameExpr("provider"), "get"),
+ "createImportTable",
+ new NodeList<>(
+ new NameExpr("table"), new NameExpr("initValue")))))
+ .addStatement(
+ new ReturnStmt(
+ new com.github.javaparser.ast.expr.ObjectCreationExpr(
+ null,
+ parseClassOrInterfaceType("TableInstance"),
+ new NodeList<>(
+ new NameExpr("table"),
+ new NameExpr("initValue")))));
+ }
+
+ /** {@code var provider = nativeProvider();} */
+ private static ExpressionStmt providerVar() {
+ return new ExpressionStmt(
+ new VariableDeclarationExpr(
+ new VariableDeclarator(
+ new VarType(), "provider", new MethodCallExpr("nativeProvider"))));
+ }
+
+ /** {@code if (provider.isPresent()) { return ; }} */
+ private static IfStmt ifProviderPresent(MethodCallExpr call) {
+ return new IfStmt()
+ .setCondition(new MethodCallExpr(new NameExpr("provider"), "isPresent"))
+ .setThenStmt(new BlockStmt(new NodeList<>(new ReturnStmt(call))));
+ }
+
private static void generateBuilderMethod(ClassOrInterfaceDeclaration type, String moduleName) {
// Generates:
//
@@ -326,6 +409,9 @@ private static void generateBuilderMethod(ClassOrInterfaceDeclaration type, Stri
// The native path is selected through nativeProvider() so that callers
// checking it see exactly the decision this method makes. Falling back means
// the build-time compiled bytecode, not the interpreter.
+ // The base generator already emitted a builder(); addMethod appends rather
+ // than replaces, so it has to go before this one is added.
+ type.getMethodsByName("builder").forEach(Node::remove);
var method =
type.addMethod("builder", Modifier.Keyword.PUBLIC, Modifier.Keyword.STATIC)
.setType(parseClassOrInterfaceType("Instance.Builder"));
@@ -368,23 +454,6 @@ private static void generateBuilderMethod(ClassOrInterfaceDeclaration type, Stri
body.addStatement(new ReturnStmt(compiledBuilder(new NameExpr("module"), moduleName)));
}
- private static void generateSafeBuilderMethod(
- ClassOrInterfaceDeclaration type, String moduleName) {
- // Generates:
- //
- // public static Instance.Builder safeBuilder() {
- // return Instance.builder(load()).withMachineFactory(::create);
- // }
- //
- var method =
- type.addMethod("safeBuilder", Modifier.Keyword.PUBLIC, Modifier.Keyword.STATIC)
- .setType(parseClassOrInterfaceType("Instance.Builder"));
-
- method.createBody()
- .addStatement(
- new ReturnStmt(compiledBuilder(new MethodCallExpr("load"), moduleName)));
- }
-
/**
* {@code Instance.builder().withMachineFactory(::create)}
*
diff --git a/redline/compiler/src/main/java/run/endive/redline/experimental/compiler/internal/EmitContext.java b/redline/compiler/src/main/java/run/endive/redline/experimental/compiler/internal/EmitContext.java
index a89be5daf..4245469ac 100644
--- a/redline/compiler/src/main/java/run/endive/redline/experimental/compiler/internal/EmitContext.java
+++ b/redline/compiler/src/main/java/run/endive/redline/experimental/compiler/internal/EmitContext.java
@@ -140,16 +140,14 @@ ValType resolveGlobalType(int globalIdx) {
return module.globalSection().getGlobal(moduleGlobalIdx).valueType();
}
+ /**
+ * Widens a value for the argument buffer a host import reads. i32 is
+ * sign-extended so a host handed -1 sees -1, matching the interpreter, rather
+ * than 4294967295.
+ */
int widenToI64(int valId, ValType type) {
if (type.equals(ValType.I32)) {
- return bridge.exports().emitUextendI64(valId);
- }
- return valId;
- }
-
- int narrowFromI64(int valId, ValType type) {
- if (type.equals(ValType.I32)) {
- return bridge.exports().emitIreduceI32(valId);
+ return bridge.exports().emitSextendI64(valId);
}
return valId;
}
diff --git a/redline/compiler/src/main/java/run/endive/redline/experimental/compiler/internal/NativeEmitters.java b/redline/compiler/src/main/java/run/endive/redline/experimental/compiler/internal/NativeEmitters.java
index a03df5ef1..9e336a93f 100644
--- a/redline/compiler/src/main/java/run/endive/redline/experimental/compiler/internal/NativeEmitters.java
+++ b/redline/compiler/src/main/java/run/endive/redline/experimental/compiler/internal/NativeEmitters.java
@@ -361,6 +361,24 @@ static void fillTrapBlock(EmitContext ctx, int trapBlock, int trapCode) {
ctx.emitReturnForFuncType();
}
+ /**
+ * A callee that traps records its code and returns like any other call, so the
+ * caller has to look for it. Without this check the caller runs on to
+ * completion after the trap: its stores land and its host imports fire.
+ */
+ static void emitTrapCheck(EmitContext ctx) {
+ var b = ctx.bridge.exports();
+ int zero = b.emitIconst32(0);
+ int trapCode = b.emitLoadI32(b.useVar(ctx.ctxPtrVar), zero, CtxBuffer.TRAP_CODE);
+ int trapped = b.emitIcmp(1, trapCode, b.emitIconst32(0));
+ int propagateBlock = b.createBlock();
+ int continueBlock = b.createBlock();
+ b.emitBrif(trapped, propagateBlock, continueBlock);
+ b.switchToBlock(propagateBlock);
+ ctx.emitReturnForFuncType();
+ b.switchToBlock(continueBlock);
+ }
+
// --- Extensions ---
static void emitI32Extend8S(EmitContext ctx) {
@@ -768,6 +786,7 @@ static void emitCall(EmitContext ctx, AnnotatedInstruction ins) {
}
int rawResult = ctx.bridge.exports().emitCallIndirect(sigRef, funcPtr);
+ emitTrapCheck(ctx);
if (calleeMultiReturn) {
// Read return values from argsBuffer
@@ -887,6 +906,7 @@ static void emitCallIndirect(EmitContext ctx, AnnotatedInstruction ins) {
}
int rawResult = b.emitCallIndirect(sigRef, funcPtr);
+ emitTrapCheck(ctx);
// 9. Handle results
if (calleeMultiReturn) {
diff --git a/redline/runner-jffi-tests/pom.xml b/redline/runner-jffi-tests/pom.xml
index c31225208..3c4a56fcc 100644
--- a/redline/runner-jffi-tests/pom.xml
+++ b/redline/runner-jffi-tests/pom.xml
@@ -186,8 +186,7 @@
SpecV1ImportsTest.test118, SpecV1ImportsTest.test119, SpecV1ImportsTest.test120,
SpecV1ImportsTest.test123, SpecV1ImportsTest.test124, SpecV1ImportsTest.test125,
SpecV1ImportsTest.test127, SpecV1ImportsTest.test128, SpecV1ImportsTest.test129,
- SpecV1LinkingTest.test129, SpecV1LinkingTest.test130, SpecV1LinkingTest.test131,
- SpecV1StartTest.test18,
+ SpecV1LinkingTest.test130, SpecV1LinkingTest.test131,
SpecV1FuncTest.test85,
SpecV1ThreadsImportsTest.test64, SpecV1ThreadsImportsTest.test65,
SpecV1ThreadsImportsTest.test66,
diff --git a/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/HostImportRoundTripTest.java b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/HostImportRoundTripTest.java
new file mode 100644
index 000000000..1ff78e08a
--- /dev/null
+++ b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/HostImportRoundTripTest.java
@@ -0,0 +1,115 @@
+package run.endive.redline.experimental.runner.jffi.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import run.endive.corpus.CorpusResources;
+import run.endive.redline.experimental.api.internal.RedlineTarget;
+import run.endive.redline.experimental.compiler.internal.NativeCompiler;
+import run.endive.redline.experimental.runner.jffi.JffiNativeMachineFactory;
+import run.endive.runtime.HostFunction;
+import run.endive.runtime.ImportValues;
+import run.endive.runtime.Instance;
+import run.endive.wasm.Parser;
+import run.endive.wasm.types.FunctionType;
+import run.endive.wasm.types.ValType;
+import run.endive.wasm.types.Value;
+
+/**
+ * Values crossing the host boundary are marshalled by hand in each runner, so each
+ * conversion is a place they can be mangled. The spec suite does not reach these:
+ * it drives modules that are self-contained rather than calling back into Java.
+ */
+public class HostImportRoundTripTest {
+
+ @Test
+ public void floatResultKeepsItsBitPattern() {
+ try (var instance = buildInstance()) {
+ assertEquals(
+ 1.5f,
+ Value.longToFloat(instance.export("callRetF32").apply()[0]),
+ "a float result must be reinterpreted, not converted numerically");
+ }
+ }
+
+ @Test
+ public void doubleResultKeepsItsBitPattern() {
+ try (var instance = buildInstance()) {
+ assertEquals(
+ 2.5d,
+ Value.longToDouble(instance.export("callRetF64").apply()[0]),
+ "a double result must be reinterpreted, not converted numerically");
+ }
+ }
+
+ @Test
+ public void negativeI32ArgumentArrivesSignExtended() {
+ try (var instance = buildInstance()) {
+ assertEquals(
+ 1,
+ (int) instance.export("callTakeI32").apply()[0],
+ "the host must be handed -1, not 4294967295");
+ }
+ }
+
+ @Test
+ public void multiValueResultKeepsEveryValue() {
+ try (var instance = buildInstance()) {
+ assertEquals(
+ 30,
+ (int) instance.export("callRetPairSum").apply()[0],
+ "both results of a multi-value host import must arrive");
+ }
+ }
+
+ private static Instance buildInstance() {
+ var module =
+ Parser.parse(
+ CorpusResources.getResource("compiled/host-import-roundtrip.wat.wasm"));
+
+ var imports =
+ ImportValues.builder()
+ .addFunction(
+ new HostFunction(
+ "host",
+ "retF32",
+ FunctionType.of(
+ java.util.List.of(),
+ java.util.List.of(ValType.F32)),
+ (inst, args) -> new long[] {Value.floatToLong(1.5f)}),
+ new HostFunction(
+ "host",
+ "retF64",
+ FunctionType.of(
+ java.util.List.of(),
+ java.util.List.of(ValType.F64)),
+ (inst, args) -> new long[] {Value.doubleToLong(2.5d)}),
+ new HostFunction(
+ "host",
+ "takeI32",
+ FunctionType.of(
+ java.util.List.of(ValType.I32),
+ java.util.List.of(ValType.I32)),
+ // Reports on the raw long it was handed rather
+ // than echoing it: an echo would be truncated
+ // back to -1 on the way out and hide a
+ // zero-extended argument.
+ (inst, args) -> new long[] {args[0] == -1L ? 1 : 0}),
+ new HostFunction(
+ "host",
+ "retPair",
+ FunctionType.of(
+ java.util.List.of(),
+ java.util.List.of(ValType.I32, ValType.I32)),
+ (inst, args) -> new long[] {10, 20}))
+ .build();
+
+ return JffiNativeMachineFactory.builder(module)
+ .withImportValues(imports)
+ .withCompilerFunction(
+ m ->
+ NativeCompiler.compileAll(
+ RedlineTarget.detectHost().orElseThrow().triple(), m))
+ .build();
+ }
+}
diff --git a/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/HostThrowPropagationTest.java b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/HostThrowPropagationTest.java
new file mode 100644
index 000000000..be14266a4
--- /dev/null
+++ b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/HostThrowPropagationTest.java
@@ -0,0 +1,63 @@
+package run.endive.redline.experimental.runner.jffi.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+import run.endive.corpus.CorpusResources;
+import run.endive.redline.experimental.api.internal.RedlineTarget;
+import run.endive.redline.experimental.compiler.internal.NativeCompiler;
+import run.endive.redline.experimental.runner.jffi.JffiNativeMachineFactory;
+import run.endive.runtime.HostFunction;
+import run.endive.runtime.ImportValues;
+import run.endive.wasm.Parser;
+import run.endive.wasm.types.FunctionType;
+
+/**
+ * An exception from a host function has to abandon the module the same way a trap
+ * does, otherwise the module keeps running on state the host has already rejected.
+ */
+public class HostThrowPropagationTest {
+
+ private static final class Boom extends RuntimeException {
+ Boom() {
+ super("boom");
+ }
+ }
+
+ @Test
+ public void moduleStopsWhenAHostFunctionThrows() {
+ var module =
+ Parser.parse(
+ CorpusResources.getResource(
+ "compiled/host-throw-stops-execution.wat.wasm"));
+
+ var imports =
+ ImportValues.builder()
+ .addFunction(
+ new HostFunction(
+ "host",
+ "boom",
+ FunctionType.of(java.util.List.of(), java.util.List.of()),
+ (inst, args) -> {
+ throw new Boom();
+ }))
+ .build();
+
+ try (var instance =
+ JffiNativeMachineFactory.builder(module)
+ .withImportValues(imports)
+ .withCompilerFunction(
+ m ->
+ NativeCompiler.compileAll(
+ RedlineTarget.detectHost().orElseThrow().triple(),
+ m))
+ .build()) {
+ assertThrows(Boom.class, () -> instance.export("callBoom").apply());
+ assertEquals(
+ 0,
+ instance.memory().readInt(0),
+ "the store after the throwing host call must never run");
+ }
+ }
+}
diff --git a/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/InterruptFlagTest.java b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/InterruptFlagTest.java
new file mode 100644
index 000000000..55c8fdf3c
--- /dev/null
+++ b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/InterruptFlagTest.java
@@ -0,0 +1,72 @@
+package run.endive.redline.experimental.runner.jffi.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import run.endive.corpus.CorpusResources;
+import run.endive.redline.experimental.api.internal.RedlineTarget;
+import run.endive.redline.experimental.compiler.internal.NativeCompiler;
+import run.endive.redline.experimental.runner.jffi.JffiNativeMachineFactory;
+import run.endive.runtime.HostFunction;
+import run.endive.runtime.ImportValues;
+import run.endive.wasm.Parser;
+import run.endive.wasm.types.FunctionType;
+
+/**
+ * The watchdog raises the interrupt flag from another thread, so it can land after
+ * the call it was meant to stop has passed its last check. The flag must not then
+ * sit in the context and stop a later call that nobody interrupted.
+ */
+public class InterruptFlagTest {
+
+ @AfterEach
+ public void clearInterruptStatus() {
+ // Keeps a failure from leaking an interrupt into the rest of the suite.
+ Thread.interrupted();
+ }
+
+ @Test
+ public void aFlagRaisedMidCallDoesNotStopTheNextCall() {
+ var module =
+ Parser.parse(CorpusResources.getResource("compiled/interrupt-midcall.wat.wasm"));
+
+ var machineRef = new JffiNativeMachine[1];
+ var imports =
+ ImportValues.builder()
+ .addFunction(
+ new HostFunction(
+ "host",
+ "raiseFlag",
+ FunctionType.of(java.util.List.of(), java.util.List.of()),
+ (inst, args) -> {
+ machineRef[0].requestInterrupt();
+ return null;
+ }))
+ .build();
+
+ try (var instance =
+ JffiNativeMachineFactory.builder(module)
+ .withImportValues(imports)
+ .withCompilerFunction(
+ m ->
+ NativeCompiler.compileAll(
+ RedlineTarget.detectHost().orElseThrow().triple(),
+ m))
+ .build()) {
+ machineRef[0] = (JffiNativeMachine) instance.getMachine();
+
+ // Returns normally: the entry check ran before the flag was raised.
+ instance.export("callHost").apply();
+
+ assertEquals(
+ 42,
+ (int) instance.export("answer").apply()[0],
+ "a flag left over from the previous call must not stop this one");
+ assertFalse(
+ Thread.currentThread().isInterrupted(),
+ "no interrupt happened, so the caller must not be left interrupted");
+ }
+ }
+}
diff --git a/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/LifecycleTest.java b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/LifecycleTest.java
new file mode 100644
index 000000000..a160367b2
--- /dev/null
+++ b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/LifecycleTest.java
@@ -0,0 +1,47 @@
+package run.endive.redline.experimental.runner.jffi.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import run.endive.corpus.CorpusResources;
+import run.endive.redline.experimental.api.internal.RedlineTarget;
+import run.endive.redline.experimental.compiler.internal.NativeCompiler;
+import run.endive.redline.experimental.runner.jffi.JffiNativeMachineFactory;
+import run.endive.wasm.Parser;
+import run.endive.wasm.WasmModule;
+
+/**
+ * Everything a machine releases on close is an off-heap free, so closing twice
+ * has to be a no-op rather than a double free, and a memory the instance only
+ * borrowed has to survive it.
+ */
+public class LifecycleTest {
+
+ @Test
+ public void closingTwiceIsSafe() {
+ var instance = build(parse());
+ instance.close();
+ instance.close();
+ }
+
+ @Test
+ public void aMemoryTheModuleDefinesStillWorksBeforeClose() {
+ try (var instance = build(parse())) {
+ instance.memory().writeI32(0, 0x5A5A5A5A);
+ assertEquals(0x5A5A5A5A, instance.memory().readInt(0));
+ }
+ }
+
+ private static WasmModule parse() {
+ return Parser.parse(CorpusResources.getResource("compiled/trap-stops-execution.wat.wasm"));
+ }
+
+ private static run.endive.runtime.Instance build(WasmModule module) {
+ return JffiNativeMachineFactory.builder(module)
+ .withCompilerFunction(
+ m ->
+ NativeCompiler.compileAll(
+ RedlineTarget.detectHost().orElseThrow().triple(), m))
+ .build();
+ }
+}
diff --git a/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/MemoryBoundsTest.java b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/MemoryBoundsTest.java
new file mode 100644
index 000000000..b567d70aa
--- /dev/null
+++ b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/MemoryBoundsTest.java
@@ -0,0 +1,44 @@
+package run.endive.redline.experimental.runner.jffi.internal;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+import run.endive.redline.experimental.runner.jffi.JffiNativeMachineFactory;
+import run.endive.runtime.WasmRuntimeException;
+import run.endive.wasm.types.MemoryLimits;
+
+/**
+ * A host reading past the end of a Wasm memory has to trap the same way it would
+ * on any other backend. The spec suite drives memory from inside the module, so it
+ * never exercises these accessors.
+ */
+public class MemoryBoundsTest {
+
+ private static final int PAGE = 65536;
+
+ @Test
+ public void readPastTheEndTraps() {
+ var memory = JffiNativeMachineFactory.createMemory(new MemoryLimits(1, 2));
+ assertThrows(WasmRuntimeException.class, () -> memory.readInt(PAGE));
+ assertThrows(WasmRuntimeException.class, () -> memory.readLong(PAGE - 4));
+ assertThrows(WasmRuntimeException.class, () -> memory.read(PAGE));
+ assertThrows(WasmRuntimeException.class, () -> memory.readShort(PAGE - 1));
+ assertThrows(WasmRuntimeException.class, () -> memory.readBytes(PAGE - 1, 8));
+ }
+
+ @Test
+ public void writePastTheEndTraps() {
+ var memory = JffiNativeMachineFactory.createMemory(new MemoryLimits(1, 2));
+ assertThrows(WasmRuntimeException.class, () -> memory.writeI32(PAGE, 1));
+ assertThrows(WasmRuntimeException.class, () -> memory.writeLong(PAGE - 4, 1L));
+ assertThrows(WasmRuntimeException.class, () -> memory.writeByte(PAGE, (byte) 1));
+ assertThrows(WasmRuntimeException.class, () -> memory.writeShort(PAGE - 1, (short) 1));
+ }
+
+ @Test
+ public void insideTheMemoryIsUntouched() {
+ var memory = JffiNativeMachineFactory.createMemory(new MemoryLimits(1, 2));
+ memory.writeI32(PAGE - 4, 0x11223344);
+ org.junit.jupiter.api.Assertions.assertEquals(0x11223344, memory.readInt(PAGE - 4));
+ }
+}
diff --git a/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/ReentrantStackGuardTest.java b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/ReentrantStackGuardTest.java
new file mode 100644
index 000000000..527adceec
--- /dev/null
+++ b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/ReentrantStackGuardTest.java
@@ -0,0 +1,95 @@
+package run.endive.redline.experimental.runner.jffi.internal;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import run.endive.corpus.CorpusResources;
+import run.endive.redline.experimental.api.internal.RedlineTarget;
+import run.endive.redline.experimental.compiler.internal.NativeCompiler;
+import run.endive.redline.experimental.runner.jffi.JffiNativeMachineFactory;
+import run.endive.runtime.HostFunction;
+import run.endive.runtime.ImportValues;
+import run.endive.wasm.Parser;
+import run.endive.wasm.WasmEngineException;
+import run.endive.wasm.types.FunctionType;
+
+/**
+ * Recursion that goes back through the host re-enters the machine from the top
+ * every time. The stack guard has to stay anchored where the outermost call
+ * started: re-anchoring per call moves the limit deeper on every level, so it
+ * never fires and the JVM raises StackOverflowError instead. That is an Error,
+ * which callers guarding against runaway modules do not catch.
+ */
+public class ReentrantStackGuardTest {
+
+ /** Only a backstop: the guard is expected to fire long before this. */
+ private static final int CAP = 20_000;
+
+ @Test
+ public void theGuardStillFiresWhenRecursionGoesThroughTheHost() {
+ var thrown = assertThrows(Throwable.class, () -> recurseThroughHost(true));
+ assertInstanceOf(
+ WasmEngineException.class,
+ thrown,
+ "must trap rather than let the JVM raise StackOverflowError");
+ assertTrue(
+ String.valueOf(thrown.getMessage()).contains("call stack exhausted"),
+ "expected a call stack exhausted trap, got: " + thrown.getMessage());
+ }
+
+ @Test
+ public void matchesTheInterpreter() {
+ var reference = assertThrows(Throwable.class, () -> recurseThroughHost(false));
+ assertInstanceOf(WasmEngineException.class, reference);
+
+ var actual = assertThrows(Throwable.class, () -> recurseThroughHost(true));
+ assertInstanceOf(
+ reference.getClass(),
+ actual,
+ "redline must end this the same way the interpreter does");
+ }
+
+ private static void recurseThroughHost(boolean native_) {
+ var module =
+ Parser.parse(CorpusResources.getResource("compiled/reentrant-recursion.wat.wasm"));
+
+ int[] depth = {0};
+ var imports =
+ ImportValues.builder()
+ .addFunction(
+ new HostFunction(
+ "host",
+ "reenter",
+ FunctionType.of(java.util.List.of(), java.util.List.of()),
+ (inst, args) -> {
+ if (depth[0]++ < CAP) {
+ inst.export("recurse").apply();
+ }
+ return null;
+ }))
+ .build();
+
+ if (!native_) {
+ run.endive.runtime.Instance.builder(module)
+ .withImportValues(imports)
+ .build()
+ .export("recurse")
+ .apply();
+ return;
+ }
+
+ try (var instance =
+ JffiNativeMachineFactory.builder(module)
+ .withImportValues(imports)
+ .withCompilerFunction(
+ m ->
+ NativeCompiler.compileAll(
+ RedlineTarget.detectHost().orElseThrow().triple(),
+ m))
+ .build()) {
+ instance.export("recurse").apply();
+ }
+ }
+}
diff --git a/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/TableInitExprTest.java b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/TableInitExprTest.java
new file mode 100644
index 000000000..b593ccf5b
--- /dev/null
+++ b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/TableInitExprTest.java
@@ -0,0 +1,37 @@
+package run.endive.redline.experimental.runner.jffi.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import run.endive.corpus.CorpusResources;
+import run.endive.redline.experimental.api.internal.RedlineTarget;
+import run.endive.redline.experimental.compiler.internal.NativeCompiler;
+import run.endive.redline.experimental.runner.jffi.JffiNativeMachineFactory;
+import run.endive.wasm.Parser;
+
+/**
+ * A table declared with an initialiser has to come up holding it. The spec suite
+ * does not cover this: its tables are filled by element segments, which take a
+ * different path.
+ */
+public class TableInitExprTest {
+
+ @Test
+ public void tableComesUpHoldingItsInitialiser() {
+ var module = Parser.parse(CorpusResources.getResource("compiled/table-init-expr.wat.wasm"));
+
+ try (var instance =
+ JffiNativeMachineFactory.builder(module)
+ .withCompilerFunction(
+ m ->
+ NativeCompiler.compileAll(
+ RedlineTarget.detectHost().orElseThrow().triple(),
+ m))
+ .build()) {
+ assertEquals(
+ 42,
+ (int) instance.export("callInitialised").apply()[0],
+ "a slot filled only by the table initialiser must be callable");
+ }
+ }
+}
diff --git a/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/TableReleaseTest.java b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/TableReleaseTest.java
new file mode 100644
index 000000000..d571fb732
--- /dev/null
+++ b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/TableReleaseTest.java
@@ -0,0 +1,77 @@
+package run.endive.redline.experimental.runner.jffi.internal;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import run.endive.corpus.CorpusResources;
+import run.endive.redline.experimental.api.internal.RedlineTarget;
+import run.endive.redline.experimental.compiler.internal.NativeCompiler;
+import run.endive.redline.experimental.runner.jffi.JffiNativeMachineFactory;
+import run.endive.runtime.ImportTable;
+import run.endive.runtime.ImportValues;
+import run.endive.wasm.Parser;
+import run.endive.wasm.WasmModule;
+import run.endive.wasm.types.Table;
+import run.endive.wasm.types.TableLimits;
+import run.endive.wasm.types.ValType;
+import run.endive.wasm.types.Value;
+
+/**
+ * A table's buffer is off-heap, so the garbage collector never reclaims it and
+ * closing the instance has to. Which tables that covers is the whole question: one
+ * the module declares belongs to the instance, and one it borrowed through an
+ * import belongs to whoever created it and may still back something else.
+ */
+public class TableReleaseTest {
+
+ @Test
+ public void closingReleasesATableTheModuleDeclares() {
+ var module = Parser.parse(CorpusResources.getResource("compiled/big-table.wat.wasm"));
+
+ JffiNativeTable table;
+ try (var instance = build(module, null)) {
+ instance.export("noop").apply();
+ table = (JffiNativeTable) instance.table(0);
+ assertFalse(table.isFreed(), "still in use");
+ }
+
+ assertTrue(table.isFreed(), "a table the module declares dies with the instance");
+ }
+
+ @Test
+ public void closingKeepsATableTheModuleImported() {
+ var module = Parser.parse(CorpusResources.getResource("compiled/imported-table.wat.wasm"));
+
+ var borrowed =
+ (JffiNativeTable)
+ JffiNativeMachineFactory.createImportTable(
+ new Table(ValType.FuncRef, new TableLimits(4, 4)),
+ Value.REF_NULL_VALUE);
+ var imports =
+ ImportValues.builder().addTable(new ImportTable("env", "table", borrowed)).build();
+
+ try (var instance = build(module, imports)) {
+ instance.export("noop").apply();
+ }
+
+ assertFalse(
+ borrowed.isFreed(),
+ "an imported table belongs to its creator and may back other instances");
+ borrowed.free();
+ }
+
+ private static run.endive.runtime.Instance build(WasmModule module, ImportValues imports) {
+ var builder =
+ JffiNativeMachineFactory.builder(module)
+ .withCompilerFunction(
+ m ->
+ NativeCompiler.compileAll(
+ RedlineTarget.detectHost().orElseThrow().triple(),
+ m));
+ if (imports != null) {
+ builder.withImportValues(imports);
+ }
+ return builder.build();
+ }
+}
diff --git a/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/TrapPropagationTest.java b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/TrapPropagationTest.java
new file mode 100644
index 000000000..2fc4e480a
--- /dev/null
+++ b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/TrapPropagationTest.java
@@ -0,0 +1,60 @@
+package run.endive.redline.experimental.runner.jffi.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+import run.endive.corpus.CorpusResources;
+import run.endive.redline.experimental.api.internal.RedlineTarget;
+import run.endive.redline.experimental.compiler.internal.NativeCompiler;
+import run.endive.redline.experimental.runner.jffi.JffiNativeMachineFactory;
+import run.endive.wasm.Parser;
+import run.endive.wasm.WasmEngineException;
+import run.endive.wasm.WasmModule;
+
+/**
+ * A trap has to abandon the caller, not just the frame it happened in. The spec
+ * suite asserts on the exception a call ends with, which is reported correctly
+ * either way, so it never noticed execution carrying on past the trap.
+ */
+public class TrapPropagationTest {
+
+ @Test
+ public void callerStopsWhenItsCalleeTraps() {
+ var module = parse();
+ try (var instance = build(module)) {
+ assertThrows(
+ WasmEngineException.class, () -> instance.export("storeAfterTrap").apply());
+ assertEquals(
+ 0,
+ instance.memory().readInt(0),
+ "the store after the trapping call must never run");
+ }
+ }
+
+ @Test
+ public void matchesTheInterpreter() {
+ var module = parse();
+ var interpreter = run.endive.runtime.Instance.builder(module).build();
+ assertThrows(WasmEngineException.class, () -> interpreter.export("loopAfterTrap").apply());
+ int reference = interpreter.memory().readInt(4);
+
+ try (var instance = build(module)) {
+ assertThrows(WasmEngineException.class, () -> instance.export("loopAfterTrap").apply());
+ assertEquals(reference, instance.memory().readInt(4), "must match the interpreter");
+ }
+ }
+
+ private static WasmModule parse() {
+ return Parser.parse(CorpusResources.getResource("compiled/trap-stops-execution.wat.wasm"));
+ }
+
+ private static run.endive.runtime.Instance build(WasmModule module) {
+ return JffiNativeMachineFactory.builder(module)
+ .withCompilerFunction(
+ m ->
+ NativeCompiler.compileAll(
+ RedlineTarget.detectHost().orElseThrow().triple(), m))
+ .build();
+ }
+}
diff --git a/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/JffiNativeMachineFactory.java b/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/JffiNativeMachineFactory.java
index 979d0e9c0..c64febb42 100644
--- a/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/JffiNativeMachineFactory.java
+++ b/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/JffiNativeMachineFactory.java
@@ -62,13 +62,13 @@ public static Builder builder(WasmModule module) {
}
public TableInstance createTable(Table table, int initValue) {
- var nativeTable = new JffiNativeTable(table);
+ var nativeTable = new JffiNativeTable(table, initValue);
nativeTables.add(nativeTable);
return nativeTable;
}
public static TableInstance createImportTable(Table table, int initValue) {
- return new JffiNativeTable(table);
+ return new JffiNativeTable(table, initValue);
}
public GlobalInstance createGlobal(
diff --git a/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/internal/JffiNativeGlobalInstance.java b/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/internal/JffiNativeGlobalInstance.java
index 0506ee620..d7eeb6c6c 100644
--- a/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/internal/JffiNativeGlobalInstance.java
+++ b/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/internal/JffiNativeGlobalInstance.java
@@ -47,6 +47,7 @@ public void setValue(long value) {
@Override
public void setValue(Value value) {
+ checkType(value);
MEM.putLong(bufferAddress + offset, value.raw());
}
diff --git a/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/internal/JffiNativeMachine.java b/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/internal/JffiNativeMachine.java
index 40ed18b63..1d5b84e0d 100644
--- a/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/internal/JffiNativeMachine.java
+++ b/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/internal/JffiNativeMachine.java
@@ -22,6 +22,8 @@
import run.endive.redline.experimental.bridge.internal.CraneliftBridge;
import run.endive.runtime.Instance;
import run.endive.runtime.Machine;
+import run.endive.runtime.TrapException;
+import run.endive.runtime.WasmRuntimeException;
import run.endive.wasm.WasmEngineException;
import run.endive.wasm.types.FunctionType;
import run.endive.wasm.types.ValType;
@@ -99,7 +101,10 @@ public final class JffiNativeMachine implements Machine {
private final long funcTypesArraySize; // byte size
private long tablePtrsArrayAddr;
private JffiNativeTable[] nativeTables;
+ private boolean[] ownsTable;
private boolean tablesInitialized;
+ private boolean ownsMemory;
+ private boolean closed;
private final int numImports;
private final int globalCount;
private boolean importGlobalsInitialized;
@@ -107,6 +112,7 @@ public final class JffiNativeMachine implements Machine {
private boolean memBaseInitialized;
private JffiNativeMemory nativeMemory;
private volatile Throwable pendingException;
+ private int callDepth;
// Keep closure handles alive to prevent GC
private final Closure.Handle trampolineHandle;
@@ -384,11 +390,27 @@ public JffiNativeMachine(
instance.memory() instanceof JffiNativeMemory
? (JffiNativeMemory) instance.memory()
: null;
+ // An imported memory outlives this instance and may back others, so only
+ // a memory this module defines is ours to close.
+ this.ownsMemory = instance.imports().memoryCount() == 0;
}
@Override
public void close() {
- if (nativeMemory != null) {
+ if (closed) {
+ // Every free below is a native one, so a second close would be a
+ // double free rather than a no-op.
+ return;
+ }
+ closed = true;
+ if (ownsTable != null) {
+ for (int i = 0; i < ownsTable.length; i++) {
+ if (ownsTable[i]) {
+ nativeTables[i].free();
+ }
+ }
+ }
+ if (nativeMemory != null && ownsMemory) {
nativeMemory.close();
}
if (tablePtrsArrayAddr != 0) {
@@ -474,6 +496,10 @@ private Closure.Handle createImportStub(int funcId, FunctionType funcType) {
Type returnType;
if (funcType.returns().isEmpty()) {
returnType = Type.VOID;
+ } else if (funcType.returns().size() > 1) {
+ // Multi-return follows the compiled convention: results go through
+ // argsBuffer and the call itself returns a dummy i64.
+ returnType = Type.SINT64;
} else {
returnType = valTypeToJffiType(funcType.returns().get(0));
}
@@ -500,6 +526,10 @@ private static void setClosureReturn(Closure.Buffer buf, long result, FunctionTy
if (funcType.returns().isEmpty()) {
return;
}
+ if (funcType.returns().size() > 1) {
+ buf.setLongReturn(result);
+ return;
+ }
ValType retType = funcType.returns().get(0);
if (retType.equals(ValType.I32)) {
buf.setIntReturn((int) result);
@@ -522,11 +552,22 @@ private long importDispatchDirect(int funcId) {
if (funcId < numImports) {
var importFunc = instance.imports().function(funcId);
long[] result = importFunc.handle().apply(instance, args);
- return (result != null && result.length > 0) ? result[0] : 0L;
+ if (result == null || result.length == 0) {
+ return 0L;
+ }
+ if (importFunc.functionType().returns().size() > 1) {
+ // Multi-return convention: the caller reads the results back
+ // out of argsBuffer and ignores the returned value.
+ for (int i = 0; i < result.length; i++) {
+ MEM.putLong(argsBufferAddr + CtxBuffer.argOffset(i), result[i]);
+ }
+ return 0L;
+ }
+ return result[0];
}
throw new WasmEngineException("Function " + funcId + " not compiled");
} catch (Throwable t) {
- pendingException = t;
+ recordHostException(t);
return 0L;
}
}
@@ -554,28 +595,9 @@ private long callIndirectTrampoline(long ctxAddr) {
if (argCount < 0) {
return handleTableOperation(argCount);
}
-
- // Normal call_indirect path
- int typeId = MEM.getInt(ctxAddr + CtxBuffer.TYPE_ID);
- int tableIdx = MEM.getInt(ctxAddr + CtxBuffer.TABLE_IDX);
- int elemIdx = MEM.getInt(ctxAddr + CtxBuffer.ELEM_IDX);
-
- int funcId = nativeTables[tableIdx].requiredRef(elemIdx);
-
- int actualTypeIdx = instance.functionType(funcId);
- if (actualTypeIdx != typeId) {
- throw new WasmEngineException("indirect call type mismatch");
- }
-
- long[] args = new long[argCount];
- for (int i = 0; i < argCount; i++) {
- args[i] = MEM.getLong(argsBufferAddr + CtxBuffer.argOffset(i));
- }
-
- long[] result = this.call(funcId, args);
- return result.length > 0 ? result[0] : 0L;
+ throw new WasmEngineException("Unexpected trampoline call: argCount " + argCount);
} catch (Throwable t) {
- pendingException = t;
+ recordHostException(t);
return 0L;
}
}
@@ -754,7 +776,7 @@ private long memoryGrowHandler(long ctxAddr) {
}
return oldPages;
} catch (Throwable t) {
- pendingException = t;
+ recordHostException(t);
return -1L;
}
}
@@ -800,16 +822,22 @@ private void initializeNativeTables() {
this.nativeTables = new JffiNativeTable[tableCount];
boolean[] owned = new boolean[tableCount];
+ this.ownsTable = owned;
this.tablePtrsArrayAddr = MEM.allocateMemory((long) tableCount * 8, true);
for (int i = 0; i < tableCount; i++) {
var table = instance.table(i);
if (table instanceof JffiNativeTable) {
- nativeTables[i] = (JffiNativeTable) table;
+ var nt = (JffiNativeTable) table;
+ nt.resolvePendingRefs(instance);
+ nativeTables[i] = nt;
+ // A table this module defines came from our factory and dies with
+ // the instance. An imported one belongs to whoever created it.
+ owned[i] = i >= importedTableCount;
} else {
// Imported table not created by our factory — wrap it
var tableDef = new run.endive.wasm.types.Table(table.elementType(), table.limits());
- var nt = new JffiNativeTable(tableDef);
+ var nt = new JffiNativeTable(tableDef, run.endive.wasm.types.Value.REF_NULL_VALUE);
for (int j = 0; j < table.size(); j++) {
nt.setRef(j, table.ref(j), instance);
}
@@ -832,45 +860,57 @@ long getFuncTypesArrayAddress() {
return funcTypesArrayAddr;
}
+ /**
+ * Marks the context so compiled code unwinds at its next trap check rather
+ * than running on. The first throwable wins: it is the one that stopped
+ * execution, so a later one would be a symptom of it.
+ */
+ private void recordHostException(Throwable t) {
+ if (pendingException == null) {
+ pendingException = t;
+ }
+ MEM.putInt(ctxBufferAddr + CtxBuffer.TRAP_CODE, CtxBuffer.TRAP_HOST_EXCEPTION);
+ }
+
private static WasmEngineException trapException(int trapCode) {
if (trapCode == CtxBuffer.TRAP_DIV_BY_ZERO) {
- return new WasmEngineException("integer divide by zero");
+ return new TrapException("integer divide by zero");
}
if (trapCode == CtxBuffer.TRAP_INT_OVERFLOW) {
- return new WasmEngineException("integer overflow");
+ return new TrapException("integer overflow");
}
if (trapCode == CtxBuffer.TRAP_UNREACHABLE) {
- return new WasmEngineException("unreachable");
+ return new TrapException("unreachable");
}
if (trapCode == CtxBuffer.TRAP_TRUNC_OVERFLOW) {
- return new WasmEngineException("integer overflow");
+ return new TrapException("integer overflow");
}
if (trapCode == CtxBuffer.TRAP_TRUNC_NAN) {
- return new WasmEngineException("invalid conversion to integer");
+ return new TrapException("invalid conversion to integer");
}
if (trapCode == CtxBuffer.TRAP_OOB) {
- return new WasmEngineException("out of bounds memory access");
+ return new WasmRuntimeException("out of bounds memory access");
}
if (trapCode == CtxBuffer.TRAP_CALL_STACK_EXHAUSTED) {
- return new WasmEngineException("call stack exhausted");
+ return new TrapException("call stack exhausted");
}
if (trapCode == CtxBuffer.TRAP_TABLE_OOB) {
- return new WasmEngineException("out of bounds table access");
+ return new TrapException("out of bounds table access");
}
if (trapCode == CtxBuffer.TRAP_UNDEFINED_ELEMENT) {
- return new WasmEngineException("undefined element");
+ return new TrapException("undefined element");
}
if (trapCode == CtxBuffer.TRAP_UNINITIALIZED_ELEMENT) {
- return new WasmEngineException("uninitialized element");
+ return new TrapException("uninitialized element");
}
if (trapCode == CtxBuffer.TRAP_INDIRECT_CALL_TYPE_MISMATCH) {
- return new WasmEngineException("indirect call type mismatch");
+ return new TrapException("indirect call type mismatch");
}
if (trapCode == CtxBuffer.TRAP_UNALIGNED_ATOMIC) {
- return new WasmEngineException("unaligned atomic");
+ return new TrapException("unaligned atomic");
}
if (trapCode == CtxBuffer.TRAP_INTERRUPTED) {
- return new WasmEngineException("interrupted");
+ return new TrapException("interrupted");
}
return new WasmEngineException("trap: unknown code " + trapCode);
}
@@ -985,11 +1025,17 @@ public long[] call(int funcId, long[] args) throws WasmEngineException {
var trampolineCallCtx = entryTrampolineCallCtxs[funcId];
try {
+ boolean outermostCall = callDepth++ == 0;
initializeImportGlobals();
initializeNativeTables();
- // Reset stack limit so native code re-initializes from calling thread's RSP
- MEM.putLong(ctxBufferAddr + CtxBuffer.STACK_LIMIT, 0L);
+ // Re-anchor the stack guard only for a call that starts on this
+ // stack. A host function calling back in has to keep measuring
+ // against where the outer call began, or every level moves the
+ // limit deeper and the guard stops firing.
+ if (outermostCall) {
+ MEM.putLong(ctxBufferAddr + CtxBuffer.STACK_LIMIT, 0L);
+ }
if (!memBaseInitialized) {
var mem = instance.memory();
@@ -1014,17 +1060,19 @@ public long[] call(int funcId, long[] args) throws WasmEngineException {
}
if (Thread.interrupted()) {
- throw new WasmEngineException("interrupted");
+ throw new TrapException("interrupted");
}
Thread caller = Thread.currentThread();
Thread watchdog =
new Thread(
() -> {
+ // Keeps raising rather than returning after the
+ // first: a nested call clears the flag when it
+ // finishes, and the outer call still needs it.
while (!Thread.currentThread().isInterrupted()) {
if (caller.isInterrupted()) {
requestInterrupt();
- return;
}
try {
Thread.sleep(1);
@@ -1048,6 +1096,9 @@ public long[] call(int funcId, long[] args) throws WasmEngineException {
args);
} finally {
watchdog.interrupt();
+ // The flag only ever means "stop this call". Left set it would
+ // trap the next one on a thread nobody interrupted.
+ clearInterrupt();
}
// Check for exceptions from upcall stubs first — a host function
@@ -1093,6 +1144,7 @@ public long[] call(int funcId, long[] args) throws WasmEngineException {
sneakyThrow(e);
throw new AssertionError("unreachable");
} finally {
+ callDepth--;
// Prevent the JIT from considering this machine unreachable during
// the native call, which would let GC collect and close() free
// native memory while code is executing.
diff --git a/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/internal/JffiNativeMemory.java b/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/internal/JffiNativeMemory.java
index a79677275..5db8bc26b 100644
--- a/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/internal/JffiNativeMemory.java
+++ b/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/internal/JffiNativeMemory.java
@@ -342,6 +342,16 @@ private int sizeInBytes() {
return PAGE_SIZE * nPages;
}
+ /**
+ * jffi dereferences the address without checking it, so an out of bounds
+ * host read would take the JVM down with a SIGSEGV rather than trap.
+ */
+ private void checkBounds(int addr, int size) {
+ if (Integer.toUnsignedLong(addr) + Integer.toUnsignedLong(size) > sizeInBytes()) {
+ throw new run.endive.runtime.WasmRuntimeException("out of bounds memory access");
+ }
+ }
+
@Override
public void write(int addr, byte[] data, int offset, int size) {
long limit = sizeInBytes();
@@ -354,11 +364,13 @@ public void write(int addr, byte[] data, int offset, int size) {
@Override
public byte read(int addr) {
+ checkBounds(addr, 1);
return MEM.getByte(reservedAddress + addr);
}
@Override
public byte[] readBytes(int addr, int len) {
+ checkBounds(addr, len);
byte[] result = new byte[len];
MEM.getByteArray(reservedAddress + addr, result, 0, len);
return result;
@@ -366,31 +378,37 @@ public byte[] readBytes(int addr, int len) {
@Override
public void writeI32(int addr, int data) {
+ checkBounds(addr, 4);
MEM.putInt(reservedAddress + addr, data);
}
@Override
public int readInt(int addr) {
+ checkBounds(addr, 4);
return MEM.getInt(reservedAddress + addr);
}
@Override
public void writeLong(int addr, long data) {
+ checkBounds(addr, 8);
MEM.putLong(reservedAddress + addr, data);
}
@Override
public long readLong(int addr) {
+ checkBounds(addr, 8);
return MEM.getLong(reservedAddress + addr);
}
@Override
public void writeShort(int addr, short data) {
+ checkBounds(addr, 2);
MEM.putShort(reservedAddress + addr, data);
}
@Override
public short readShort(int addr) {
+ checkBounds(addr, 2);
return MEM.getShort(reservedAddress + addr);
}
@@ -401,6 +419,7 @@ public long readU16(int addr) {
@Override
public void writeByte(int addr, byte data) {
+ checkBounds(addr, 1);
MEM.putByte(reservedAddress + addr, data);
}
diff --git a/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/internal/JffiNativeTable.java b/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/internal/JffiNativeTable.java
index bf7ec52a1..765f38064 100644
--- a/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/internal/JffiNativeTable.java
+++ b/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/internal/JffiNativeTable.java
@@ -33,8 +33,8 @@ public final class JffiNativeTable extends TableInstance {
private final boolean isExternRef;
private boolean freed;
- public JffiNativeTable(Table table) {
- super(table, REF_NULL_VALUE);
+ public JffiNativeTable(Table table, int initValue) {
+ super(table, initValue);
this.isExternRef = table.elementType().equals(ValType.ExternRef);
int initial = (int) table.limits().min();
int max = (int) table.limits().max();
@@ -47,10 +47,47 @@ public JffiNativeTable(Table table) {
MEM.putInt(bufferAddress + CtxBuffer.TABLE_SIZE_OFFSET, initial);
MEM.putInt(bufferAddress + CtxBuffer.TABLE_MAX_OFFSET, max > 0 ? max : capacity);
- // Fill all entries with null (funcId=-1, funcPtr=0, typeIdx=0)
- for (int i = 0; i < capacity; i++) {
+ // Only the entries below size are reachable: every access bounds-checks
+ // against the size field, and grow fills the slots it exposes. Filling
+ // the whole capacity here would make the entire pre-allocation resident.
+ for (int i = 0; i < initial; i++) {
writeNullEntry(i);
}
+
+ if (initValue != REF_NULL_VALUE) {
+ // The instance has no machine yet, so funcPtr cannot be resolved
+ // here. resolvePendingRefs fills it in once there is one.
+ for (int i = 0; i < initial; i++) {
+ writeUnresolvedEntry(i, initValue);
+ }
+ }
+ }
+
+ /** A funcref whose native address is not known yet. */
+ private void writeUnresolvedEntry(int index, int value) {
+ long base = bufferAddress + entryBase(index);
+ MEM.putInt(base + CtxBuffer.ENTRY_TYPE_IDX_OFFSET, 0);
+ MEM.putInt(base + CtxBuffer.ENTRY_FUNC_ID_OFFSET, value);
+ MEM.putLong(base + CtxBuffer.ENTRY_FUNC_PTR_OFFSET, 0L);
+ }
+
+ /**
+ * Fills in the native address of entries written before the instance had a
+ * machine, which is the case for a table initialiser.
+ */
+ void resolvePendingRefs(Instance instance) {
+ if (isExternRef) {
+ return;
+ }
+ int sz = size();
+ for (int i = 0; i < sz; i++) {
+ long base = bufferAddress + entryBase(i);
+ int funcId = MEM.getInt(base + CtxBuffer.ENTRY_FUNC_ID_OFFSET);
+ long funcPtr = MEM.getLong(base + CtxBuffer.ENTRY_FUNC_PTR_OFFSET);
+ if (funcId != REF_NULL_VALUE && funcPtr == 0L) {
+ resolveFromInstance(i, funcId, instance);
+ }
+ }
}
private long entryBase(int index) {
@@ -103,6 +140,11 @@ boolean isExternRef() {
return isExternRef;
}
+ /** Whether the off-heap buffer has been released. */
+ boolean isFreed() {
+ return freed;
+ }
+
/** Free the off-heap buffer. Idempotent — safe to call multiple times. */
public void free() {
if (!freed && bufferAddress != 0) {
@@ -151,14 +193,8 @@ public void setRef(int index, int value, Instance instance) {
}
if (value == REF_NULL_VALUE) {
writeNullEntry(index);
- } else if (resolveFromInstance(index, value, instance)) {
- // Resolved using the calling module's NativeMachine
- } else {
- // No NativeMachine available — store funcId only (externref or non-native)
- long base = bufferAddress + entryBase(index);
- MEM.putInt(base + CtxBuffer.ENTRY_TYPE_IDX_OFFSET, 0);
- MEM.putInt(base + CtxBuffer.ENTRY_FUNC_ID_OFFSET, value);
- MEM.putLong(base + CtxBuffer.ENTRY_FUNC_PTR_OFFSET, 0L);
+ } else if (!resolveFromInstance(index, value, instance)) {
+ writeUnresolvedEntry(index, value);
}
}
@@ -175,10 +211,7 @@ public int grow(int delta, int value, Instance instance) {
if (value == REF_NULL_VALUE) {
writeNullEntry(i);
} else if (!resolveFromInstance(i, value, instance)) {
- long base = bufferAddress + entryBase(i);
- MEM.putInt(base + CtxBuffer.ENTRY_TYPE_IDX_OFFSET, 0);
- MEM.putInt(base + CtxBuffer.ENTRY_FUNC_ID_OFFSET, value);
- MEM.putLong(base + CtxBuffer.ENTRY_FUNC_PTR_OFFSET, 0L);
+ writeUnresolvedEntry(i, value);
}
}
// Update size
diff --git a/redline/runner-tests/pom.xml b/redline/runner-tests/pom.xml
index f191c42de..95a6b06be 100644
--- a/redline/runner-tests/pom.xml
+++ b/redline/runner-tests/pom.xml
@@ -187,8 +187,7 @@
SpecV1ImportsTest.test118, SpecV1ImportsTest.test119, SpecV1ImportsTest.test120,
SpecV1ImportsTest.test123, SpecV1ImportsTest.test124, SpecV1ImportsTest.test125,
SpecV1ImportsTest.test127, SpecV1ImportsTest.test128, SpecV1ImportsTest.test129,
- SpecV1LinkingTest.test129, SpecV1LinkingTest.test130, SpecV1LinkingTest.test131,
- SpecV1StartTest.test18,
+ SpecV1LinkingTest.test130, SpecV1LinkingTest.test131,
SpecV1FuncTest.test85,
SpecV1ThreadsImportsTest.test64, SpecV1ThreadsImportsTest.test65,
SpecV1ThreadsImportsTest.test66,
diff --git a/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/HostImportRoundTripTest.java b/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/HostImportRoundTripTest.java
new file mode 100644
index 000000000..35c7c06ac
--- /dev/null
+++ b/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/HostImportRoundTripTest.java
@@ -0,0 +1,115 @@
+package run.endive.redline.experimental.runner.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import run.endive.corpus.CorpusResources;
+import run.endive.redline.experimental.api.internal.RedlineTarget;
+import run.endive.redline.experimental.compiler.internal.NativeCompiler;
+import run.endive.redline.experimental.runner.NativeMachineFactory;
+import run.endive.runtime.HostFunction;
+import run.endive.runtime.ImportValues;
+import run.endive.runtime.Instance;
+import run.endive.wasm.Parser;
+import run.endive.wasm.types.FunctionType;
+import run.endive.wasm.types.ValType;
+import run.endive.wasm.types.Value;
+
+/**
+ * Values crossing the host boundary are marshalled by hand in each runner, so each
+ * conversion is a place they can be mangled. The spec suite does not reach these:
+ * it drives modules that are self-contained rather than calling back into Java.
+ */
+public class HostImportRoundTripTest {
+
+ @Test
+ public void floatResultKeepsItsBitPattern() {
+ try (var instance = buildInstance()) {
+ assertEquals(
+ 1.5f,
+ Value.longToFloat(instance.export("callRetF32").apply()[0]),
+ "a float result must be reinterpreted, not converted numerically");
+ }
+ }
+
+ @Test
+ public void doubleResultKeepsItsBitPattern() {
+ try (var instance = buildInstance()) {
+ assertEquals(
+ 2.5d,
+ Value.longToDouble(instance.export("callRetF64").apply()[0]),
+ "a double result must be reinterpreted, not converted numerically");
+ }
+ }
+
+ @Test
+ public void negativeI32ArgumentArrivesSignExtended() {
+ try (var instance = buildInstance()) {
+ assertEquals(
+ 1,
+ (int) instance.export("callTakeI32").apply()[0],
+ "the host must be handed -1, not 4294967295");
+ }
+ }
+
+ @Test
+ public void multiValueResultKeepsEveryValue() {
+ try (var instance = buildInstance()) {
+ assertEquals(
+ 30,
+ (int) instance.export("callRetPairSum").apply()[0],
+ "both results of a multi-value host import must arrive");
+ }
+ }
+
+ private static Instance buildInstance() {
+ var module =
+ Parser.parse(
+ CorpusResources.getResource("compiled/host-import-roundtrip.wat.wasm"));
+
+ var imports =
+ ImportValues.builder()
+ .addFunction(
+ new HostFunction(
+ "host",
+ "retF32",
+ FunctionType.of(
+ java.util.List.of(),
+ java.util.List.of(ValType.F32)),
+ (inst, args) -> new long[] {Value.floatToLong(1.5f)}),
+ new HostFunction(
+ "host",
+ "retF64",
+ FunctionType.of(
+ java.util.List.of(),
+ java.util.List.of(ValType.F64)),
+ (inst, args) -> new long[] {Value.doubleToLong(2.5d)}),
+ new HostFunction(
+ "host",
+ "takeI32",
+ FunctionType.of(
+ java.util.List.of(ValType.I32),
+ java.util.List.of(ValType.I32)),
+ // Reports on the raw long it was handed rather
+ // than echoing it: an echo would be truncated
+ // back to -1 on the way out and hide a
+ // zero-extended argument.
+ (inst, args) -> new long[] {args[0] == -1L ? 1 : 0}),
+ new HostFunction(
+ "host",
+ "retPair",
+ FunctionType.of(
+ java.util.List.of(),
+ java.util.List.of(ValType.I32, ValType.I32)),
+ (inst, args) -> new long[] {10, 20}))
+ .build();
+
+ return NativeMachineFactory.builder(module)
+ .withImportValues(imports)
+ .withCompilerFunction(
+ m ->
+ NativeCompiler.compileAll(
+ RedlineTarget.detectHost().orElseThrow().triple(), m))
+ .build();
+ }
+}
diff --git a/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/HostThrowPropagationTest.java b/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/HostThrowPropagationTest.java
new file mode 100644
index 000000000..fc519f1d1
--- /dev/null
+++ b/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/HostThrowPropagationTest.java
@@ -0,0 +1,63 @@
+package run.endive.redline.experimental.runner.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+import run.endive.corpus.CorpusResources;
+import run.endive.redline.experimental.api.internal.RedlineTarget;
+import run.endive.redline.experimental.compiler.internal.NativeCompiler;
+import run.endive.redline.experimental.runner.NativeMachineFactory;
+import run.endive.runtime.HostFunction;
+import run.endive.runtime.ImportValues;
+import run.endive.wasm.Parser;
+import run.endive.wasm.types.FunctionType;
+
+/**
+ * An exception from a host function has to abandon the module the same way a trap
+ * does, otherwise the module keeps running on state the host has already rejected.
+ */
+public class HostThrowPropagationTest {
+
+ private static final class Boom extends RuntimeException {
+ Boom() {
+ super("boom");
+ }
+ }
+
+ @Test
+ public void moduleStopsWhenAHostFunctionThrows() {
+ var module =
+ Parser.parse(
+ CorpusResources.getResource(
+ "compiled/host-throw-stops-execution.wat.wasm"));
+
+ var imports =
+ ImportValues.builder()
+ .addFunction(
+ new HostFunction(
+ "host",
+ "boom",
+ FunctionType.of(java.util.List.of(), java.util.List.of()),
+ (inst, args) -> {
+ throw new Boom();
+ }))
+ .build();
+
+ try (var instance =
+ NativeMachineFactory.builder(module)
+ .withImportValues(imports)
+ .withCompilerFunction(
+ m ->
+ NativeCompiler.compileAll(
+ RedlineTarget.detectHost().orElseThrow().triple(),
+ m))
+ .build()) {
+ assertThrows(Boom.class, () -> instance.export("callBoom").apply());
+ assertEquals(
+ 0,
+ instance.memory().readInt(0),
+ "the store after the throwing host call must never run");
+ }
+ }
+}
diff --git a/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/InterruptFlagTest.java b/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/InterruptFlagTest.java
new file mode 100644
index 000000000..d81115e90
--- /dev/null
+++ b/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/InterruptFlagTest.java
@@ -0,0 +1,72 @@
+package run.endive.redline.experimental.runner.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import run.endive.corpus.CorpusResources;
+import run.endive.redline.experimental.api.internal.RedlineTarget;
+import run.endive.redline.experimental.compiler.internal.NativeCompiler;
+import run.endive.redline.experimental.runner.NativeMachineFactory;
+import run.endive.runtime.HostFunction;
+import run.endive.runtime.ImportValues;
+import run.endive.wasm.Parser;
+import run.endive.wasm.types.FunctionType;
+
+/**
+ * The watchdog raises the interrupt flag from another thread, so it can land after
+ * the call it was meant to stop has passed its last check. The flag must not then
+ * sit in the context and stop a later call that nobody interrupted.
+ */
+public class InterruptFlagTest {
+
+ @AfterEach
+ public void clearInterruptStatus() {
+ // Keeps a failure from leaking an interrupt into the rest of the suite.
+ Thread.interrupted();
+ }
+
+ @Test
+ public void aFlagRaisedMidCallDoesNotStopTheNextCall() {
+ var module =
+ Parser.parse(CorpusResources.getResource("compiled/interrupt-midcall.wat.wasm"));
+
+ var machineRef = new NativeMachine[1];
+ var imports =
+ ImportValues.builder()
+ .addFunction(
+ new HostFunction(
+ "host",
+ "raiseFlag",
+ FunctionType.of(java.util.List.of(), java.util.List.of()),
+ (inst, args) -> {
+ machineRef[0].requestInterrupt();
+ return null;
+ }))
+ .build();
+
+ try (var instance =
+ NativeMachineFactory.builder(module)
+ .withImportValues(imports)
+ .withCompilerFunction(
+ m ->
+ NativeCompiler.compileAll(
+ RedlineTarget.detectHost().orElseThrow().triple(),
+ m))
+ .build()) {
+ machineRef[0] = (NativeMachine) instance.getMachine();
+
+ // Returns normally: the entry check ran before the flag was raised.
+ instance.export("callHost").apply();
+
+ assertEquals(
+ 42,
+ (int) instance.export("answer").apply()[0],
+ "a flag left over from the previous call must not stop this one");
+ assertFalse(
+ Thread.currentThread().isInterrupted(),
+ "no interrupt happened, so the caller must not be left interrupted");
+ }
+ }
+}
diff --git a/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/LifecycleTest.java b/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/LifecycleTest.java
new file mode 100644
index 000000000..202b3b871
--- /dev/null
+++ b/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/LifecycleTest.java
@@ -0,0 +1,47 @@
+package run.endive.redline.experimental.runner.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import run.endive.corpus.CorpusResources;
+import run.endive.redline.experimental.api.internal.RedlineTarget;
+import run.endive.redline.experimental.compiler.internal.NativeCompiler;
+import run.endive.redline.experimental.runner.NativeMachineFactory;
+import run.endive.wasm.Parser;
+import run.endive.wasm.WasmModule;
+
+/**
+ * Everything a machine releases on close is an off-heap free, so closing twice
+ * has to be a no-op rather than a double free, and a memory the instance only
+ * borrowed has to survive it.
+ */
+public class LifecycleTest {
+
+ @Test
+ public void closingTwiceIsSafe() {
+ var instance = build(parse());
+ instance.close();
+ instance.close();
+ }
+
+ @Test
+ public void aMemoryTheModuleDefinesStillWorksBeforeClose() {
+ try (var instance = build(parse())) {
+ instance.memory().writeI32(0, 0x5A5A5A5A);
+ assertEquals(0x5A5A5A5A, instance.memory().readInt(0));
+ }
+ }
+
+ private static WasmModule parse() {
+ return Parser.parse(CorpusResources.getResource("compiled/trap-stops-execution.wat.wasm"));
+ }
+
+ private static run.endive.runtime.Instance build(WasmModule module) {
+ return NativeMachineFactory.builder(module)
+ .withCompilerFunction(
+ m ->
+ NativeCompiler.compileAll(
+ RedlineTarget.detectHost().orElseThrow().triple(), m))
+ .build();
+ }
+}
diff --git a/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/MemoryBoundsTest.java b/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/MemoryBoundsTest.java
new file mode 100644
index 000000000..855e18d25
--- /dev/null
+++ b/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/MemoryBoundsTest.java
@@ -0,0 +1,44 @@
+package run.endive.redline.experimental.runner.internal;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+import run.endive.redline.experimental.runner.NativeMachineFactory;
+import run.endive.runtime.WasmRuntimeException;
+import run.endive.wasm.types.MemoryLimits;
+
+/**
+ * A host reading past the end of a Wasm memory has to trap the same way it would
+ * on any other backend. The spec suite drives memory from inside the module, so it
+ * never exercises these accessors.
+ */
+public class MemoryBoundsTest {
+
+ private static final int PAGE = 65536;
+
+ @Test
+ public void readPastTheEndTraps() {
+ var memory = NativeMachineFactory.createMemory(new MemoryLimits(1, 2));
+ assertThrows(WasmRuntimeException.class, () -> memory.readInt(PAGE));
+ assertThrows(WasmRuntimeException.class, () -> memory.readLong(PAGE - 4));
+ assertThrows(WasmRuntimeException.class, () -> memory.read(PAGE));
+ assertThrows(WasmRuntimeException.class, () -> memory.readShort(PAGE - 1));
+ assertThrows(WasmRuntimeException.class, () -> memory.readBytes(PAGE - 1, 8));
+ }
+
+ @Test
+ public void writePastTheEndTraps() {
+ var memory = NativeMachineFactory.createMemory(new MemoryLimits(1, 2));
+ assertThrows(WasmRuntimeException.class, () -> memory.writeI32(PAGE, 1));
+ assertThrows(WasmRuntimeException.class, () -> memory.writeLong(PAGE - 4, 1L));
+ assertThrows(WasmRuntimeException.class, () -> memory.writeByte(PAGE, (byte) 1));
+ assertThrows(WasmRuntimeException.class, () -> memory.writeShort(PAGE - 1, (short) 1));
+ }
+
+ @Test
+ public void insideTheMemoryIsUntouched() {
+ var memory = NativeMachineFactory.createMemory(new MemoryLimits(1, 2));
+ memory.writeI32(PAGE - 4, 0x11223344);
+ org.junit.jupiter.api.Assertions.assertEquals(0x11223344, memory.readInt(PAGE - 4));
+ }
+}
diff --git a/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/ReentrantStackGuardTest.java b/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/ReentrantStackGuardTest.java
new file mode 100644
index 000000000..3e766bc5a
--- /dev/null
+++ b/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/ReentrantStackGuardTest.java
@@ -0,0 +1,95 @@
+package run.endive.redline.experimental.runner.internal;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import run.endive.corpus.CorpusResources;
+import run.endive.redline.experimental.api.internal.RedlineTarget;
+import run.endive.redline.experimental.compiler.internal.NativeCompiler;
+import run.endive.redline.experimental.runner.NativeMachineFactory;
+import run.endive.runtime.HostFunction;
+import run.endive.runtime.ImportValues;
+import run.endive.wasm.Parser;
+import run.endive.wasm.WasmEngineException;
+import run.endive.wasm.types.FunctionType;
+
+/**
+ * Recursion that goes back through the host re-enters the machine from the top
+ * every time. The stack guard has to stay anchored where the outermost call
+ * started: re-anchoring per call moves the limit deeper on every level, so it
+ * never fires and the JVM raises StackOverflowError instead. That is an Error,
+ * which callers guarding against runaway modules do not catch.
+ */
+public class ReentrantStackGuardTest {
+
+ /** Only a backstop: the guard is expected to fire long before this. */
+ private static final int CAP = 20_000;
+
+ @Test
+ public void theGuardStillFiresWhenRecursionGoesThroughTheHost() {
+ var thrown = assertThrows(Throwable.class, () -> recurseThroughHost(true));
+ assertInstanceOf(
+ WasmEngineException.class,
+ thrown,
+ "must trap rather than let the JVM raise StackOverflowError");
+ assertTrue(
+ String.valueOf(thrown.getMessage()).contains("call stack exhausted"),
+ "expected a call stack exhausted trap, got: " + thrown.getMessage());
+ }
+
+ @Test
+ public void matchesTheInterpreter() {
+ var reference = assertThrows(Throwable.class, () -> recurseThroughHost(false));
+ assertInstanceOf(WasmEngineException.class, reference);
+
+ var actual = assertThrows(Throwable.class, () -> recurseThroughHost(true));
+ assertInstanceOf(
+ reference.getClass(),
+ actual,
+ "redline must end this the same way the interpreter does");
+ }
+
+ private static void recurseThroughHost(boolean native_) {
+ var module =
+ Parser.parse(CorpusResources.getResource("compiled/reentrant-recursion.wat.wasm"));
+
+ int[] depth = {0};
+ var imports =
+ ImportValues.builder()
+ .addFunction(
+ new HostFunction(
+ "host",
+ "reenter",
+ FunctionType.of(java.util.List.of(), java.util.List.of()),
+ (inst, args) -> {
+ if (depth[0]++ < CAP) {
+ inst.export("recurse").apply();
+ }
+ return null;
+ }))
+ .build();
+
+ if (!native_) {
+ run.endive.runtime.Instance.builder(module)
+ .withImportValues(imports)
+ .build()
+ .export("recurse")
+ .apply();
+ return;
+ }
+
+ try (var instance =
+ NativeMachineFactory.builder(module)
+ .withImportValues(imports)
+ .withCompilerFunction(
+ m ->
+ NativeCompiler.compileAll(
+ RedlineTarget.detectHost().orElseThrow().triple(),
+ m))
+ .build()) {
+ instance.export("recurse").apply();
+ }
+ }
+}
diff --git a/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/TableInitExprTest.java b/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/TableInitExprTest.java
new file mode 100644
index 000000000..4657db2d2
--- /dev/null
+++ b/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/TableInitExprTest.java
@@ -0,0 +1,37 @@
+package run.endive.redline.experimental.runner.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import run.endive.corpus.CorpusResources;
+import run.endive.redline.experimental.api.internal.RedlineTarget;
+import run.endive.redline.experimental.compiler.internal.NativeCompiler;
+import run.endive.redline.experimental.runner.NativeMachineFactory;
+import run.endive.wasm.Parser;
+
+/**
+ * A table declared with an initialiser has to come up holding it. The spec suite
+ * does not cover this: its tables are filled by element segments, which take a
+ * different path.
+ */
+public class TableInitExprTest {
+
+ @Test
+ public void tableComesUpHoldingItsInitialiser() {
+ var module = Parser.parse(CorpusResources.getResource("compiled/table-init-expr.wat.wasm"));
+
+ try (var instance =
+ NativeMachineFactory.builder(module)
+ .withCompilerFunction(
+ m ->
+ NativeCompiler.compileAll(
+ RedlineTarget.detectHost().orElseThrow().triple(),
+ m))
+ .build()) {
+ assertEquals(
+ 42,
+ (int) instance.export("callInitialised").apply()[0],
+ "a slot filled only by the table initialiser must be callable");
+ }
+ }
+}
diff --git a/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/TrapPropagationTest.java b/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/TrapPropagationTest.java
new file mode 100644
index 000000000..f6a7677bb
--- /dev/null
+++ b/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/TrapPropagationTest.java
@@ -0,0 +1,60 @@
+package run.endive.redline.experimental.runner.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+import run.endive.corpus.CorpusResources;
+import run.endive.redline.experimental.api.internal.RedlineTarget;
+import run.endive.redline.experimental.compiler.internal.NativeCompiler;
+import run.endive.redline.experimental.runner.NativeMachineFactory;
+import run.endive.wasm.Parser;
+import run.endive.wasm.WasmEngineException;
+import run.endive.wasm.WasmModule;
+
+/**
+ * A trap has to abandon the caller, not just the frame it happened in. The spec
+ * suite asserts on the exception a call ends with, which is reported correctly
+ * either way, so it never noticed execution carrying on past the trap.
+ */
+public class TrapPropagationTest {
+
+ @Test
+ public void callerStopsWhenItsCalleeTraps() {
+ var module = parse();
+ try (var instance = build(module)) {
+ assertThrows(
+ WasmEngineException.class, () -> instance.export("storeAfterTrap").apply());
+ assertEquals(
+ 0,
+ instance.memory().readInt(0),
+ "the store after the trapping call must never run");
+ }
+ }
+
+ @Test
+ public void matchesTheInterpreter() {
+ var module = parse();
+ var interpreter = run.endive.runtime.Instance.builder(module).build();
+ assertThrows(WasmEngineException.class, () -> interpreter.export("loopAfterTrap").apply());
+ int reference = interpreter.memory().readInt(4);
+
+ try (var instance = build(module)) {
+ assertThrows(WasmEngineException.class, () -> instance.export("loopAfterTrap").apply());
+ assertEquals(reference, instance.memory().readInt(4), "must match the interpreter");
+ }
+ }
+
+ private static WasmModule parse() {
+ return Parser.parse(CorpusResources.getResource("compiled/trap-stops-execution.wat.wasm"));
+ }
+
+ private static run.endive.runtime.Instance build(WasmModule module) {
+ return NativeMachineFactory.builder(module)
+ .withCompilerFunction(
+ m ->
+ NativeCompiler.compileAll(
+ RedlineTarget.detectHost().orElseThrow().triple(), m))
+ .build();
+ }
+}
diff --git a/redline/runner/src/main/java/run/endive/redline/experimental/runner/NativeMachineFactory.java b/redline/runner/src/main/java/run/endive/redline/experimental/runner/NativeMachineFactory.java
index b5370f03d..8c67a82a6 100644
--- a/redline/runner/src/main/java/run/endive/redline/experimental/runner/NativeMachineFactory.java
+++ b/redline/runner/src/main/java/run/endive/redline/experimental/runner/NativeMachineFactory.java
@@ -62,13 +62,13 @@ public static Builder builder(WasmModule module) {
}
public TableInstance createTable(Table table, int initValue) {
- var nativeTable = new NativeTable(table, arena);
+ var nativeTable = new NativeTable(table, initValue, arena);
nativeTables.add(nativeTable);
return nativeTable;
}
public static TableInstance createImportTable(Table table, int initValue) {
- return new NativeTable(table, Arena.ofAuto());
+ return new NativeTable(table, initValue, Arena.ofAuto());
}
public GlobalInstance createGlobal(
diff --git a/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/NativeGlobalInstance.java b/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/NativeGlobalInstance.java
index 3a97f0b93..3910d5048 100644
--- a/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/NativeGlobalInstance.java
+++ b/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/NativeGlobalInstance.java
@@ -46,6 +46,7 @@ public void setValue(long value) {
@Override
public void setValue(Value value) {
+ checkType(value);
buffer.set(ValueLayout.JAVA_LONG, offset, value.raw());
}
diff --git a/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/NativeMachine.java b/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/NativeMachine.java
index adbb53e39..4578b2f1d 100644
--- a/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/NativeMachine.java
+++ b/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/NativeMachine.java
@@ -19,6 +19,8 @@
import run.endive.redline.experimental.bridge.internal.CraneliftBridge;
import run.endive.runtime.Instance;
import run.endive.runtime.Machine;
+import run.endive.runtime.TrapException;
+import run.endive.runtime.WasmRuntimeException;
import run.endive.wasm.WasmEngineException;
import run.endive.wasm.types.FunctionType;
import run.endive.wasm.types.ValType;
@@ -99,6 +101,9 @@ public final class NativeMachine implements Machine {
private boolean memBaseInitialized;
private NativeMemory nativeMemory;
private volatile Throwable pendingException;
+ private int callDepth;
+ private boolean ownsMemory;
+ private boolean closed;
public NativeMachine(
Instance instance,
@@ -351,11 +356,20 @@ public NativeMachine(
}
this.nativeMemory = instance.memory() instanceof NativeMemory nm ? nm : null;
+ // An imported memory outlives this instance and may back others, so only
+ // a memory this module defines is ours to close.
+ this.ownsMemory = instance.imports().memoryCount() == 0;
}
@Override
public void close() {
- if (nativeMemory != null) {
+ if (closed) {
+ // munmap below is a native free, so a second close would unmap a
+ // region that may already have been handed back out.
+ return;
+ }
+ closed = true;
+ if (nativeMemory != null && ownsMemory) {
nativeMemory.close();
}
try {
@@ -488,8 +502,14 @@ private MemorySegment createImportStub(int funcId, FunctionType funcType) {
layouts.add(valTypeToLayout(param));
}
+ // Multi-return follows the compiled convention: results go through
+ // argsBuffer and the call itself returns a dummy i64.
+ boolean multiReturn = funcType.returns().size() > 1;
+
ValueLayout returnLayout = null;
- if (!funcType.returns().isEmpty()) {
+ if (multiReturn) {
+ returnLayout = ValueLayout.JAVA_LONG;
+ } else if (!funcType.returns().isEmpty()) {
returnLayout = valTypeToLayout(funcType.returns().get(0));
}
@@ -529,9 +549,17 @@ private MemorySegment createImportStub(int funcId, FunctionType funcType) {
var voidType =
MethodType.methodType(void.class, targetParamTypes.toArray(new Class[0]));
dropper = dropper.asType(voidType);
- } else if (!funcType.returns().isEmpty()) {
+ } else if (!multiReturn && !funcType.returns().isEmpty()) {
var retClass = valTypeToJavaClass(funcType.returns().get(0));
- if (!retClass.equals(long.class)) {
+ if (retClass.equals(float.class)) {
+ // The long carries the f32 bit pattern, so it has to be
+ // reinterpreted. A cast would convert numerically and turn the
+ // bits of 1.5f into 1.06954752E9f.
+ dropper = MethodHandles.filterReturnValue(dropper, LONG_TO_FLOAT);
+ } else if (retClass.equals(double.class)) {
+ dropper = MethodHandles.filterReturnValue(dropper, LONG_TO_DOUBLE);
+ } else if (!retClass.equals(long.class)) {
+ // i32: the value is the low 32 bits, so truncation is correct.
dropper =
MethodHandles.explicitCastArguments(
dropper,
@@ -561,11 +589,22 @@ private long importDispatchDirect(int funcId) {
if (funcId < numImports) {
var importFunc = instance.imports().function(funcId);
long[] result = importFunc.handle().apply(instance, args);
- return (result != null && result.length > 0) ? result[0] : 0L;
+ if (result == null || result.length == 0) {
+ return 0L;
+ }
+ if (importFunc.functionType().returns().size() > 1) {
+ // Multi-return convention: the caller reads the results back out
+ // of argsBuffer and ignores the returned value.
+ for (int i = 0; i < result.length; i++) {
+ argsBuffer.set(ValueLayout.JAVA_LONG, CtxBuffer.argOffset(i), result[i]);
+ }
+ return 0L;
+ }
+ return result[0];
}
throw new WasmEngineException("Function " + funcId + " not compiled");
} catch (Throwable t) {
- pendingException = t;
+ recordHostException(t);
return 0L;
}
}
@@ -587,39 +626,23 @@ private MemorySegment createTrampolineStub() {
}
}
+ /**
+ * Compiled code only reaches this with a table operation sentinel: it emits
+ * call_indirect inline and never writes TYPE_ID, TABLE_IDX or ELEM_IDX, so the
+ * call_indirect path this used to carry could only ever have dispatched on
+ * whatever those fields happened to hold.
+ */
@SuppressWarnings("unused")
private long callIndirectTrampoline(long ctxAddr) {
try {
var ctx = MemorySegment.ofAddress(ctxAddr).reinterpret(CTX_SIZE);
int argCount = ctx.get(ValueLayout.JAVA_INT, CtxBuffer.ARG_COUNT);
-
- // Negative argCount = table operation sentinel
if (argCount < 0) {
return handleTableOperation(argCount);
}
-
- // Normal call_indirect path (fallback, rarely used now)
- int typeId = ctx.get(ValueLayout.JAVA_INT, CtxBuffer.TYPE_ID);
- int tableIdx = ctx.get(ValueLayout.JAVA_INT, CtxBuffer.TABLE_IDX);
- int elemIdx = ctx.get(ValueLayout.JAVA_INT, CtxBuffer.ELEM_IDX);
-
- int funcId = nativeTables[tableIdx].requiredRef(elemIdx);
-
- // Type check
- int actualTypeIdx = instance.functionType(funcId);
- if (actualTypeIdx != typeId) {
- throw new WasmEngineException("indirect call type mismatch");
- }
-
- long[] args = new long[argCount];
- for (int i = 0; i < argCount; i++) {
- args[i] = argsBuffer.get(ValueLayout.JAVA_LONG, CtxBuffer.argOffset(i));
- }
-
- long[] result = this.call(funcId, args);
- return result.length > 0 ? result[0] : 0L;
+ throw new WasmEngineException("Unexpected trampoline call: argCount " + argCount);
} catch (Throwable t) {
- pendingException = t;
+ recordHostException(t);
return 0L;
}
}
@@ -809,7 +832,7 @@ private long memoryGrowHandler(long ctxAddr) {
}
return oldPages;
} catch (Throwable t) {
- pendingException = t;
+ recordHostException(t);
return -1L;
}
}
@@ -871,11 +894,14 @@ private void initializeNativeTables() {
for (int i = 0; i < tableCount; i++) {
var table = instance.table(i);
if (table instanceof NativeTable nt) {
+ nt.resolvePendingRefs(instance);
nativeTables[i] = nt;
} else {
// Imported table not created by our factory — wrap it
var tableDef = new run.endive.wasm.types.Table(table.elementType(), table.limits());
- var nt = new NativeTable(tableDef, arena);
+ var nt =
+ new NativeTable(
+ tableDef, run.endive.wasm.types.Value.REF_NULL_VALUE, arena);
for (int j = 0; j < table.size(); j++) {
nt.setRef(j, table.ref(j), instance);
}
@@ -899,25 +925,36 @@ MemorySegment getFuncTypesArray() {
return funcTypesArray;
}
+ /**
+ * Marks the context so compiled code unwinds at its next trap check rather
+ * than running on. The first throwable wins: it is the one that stopped
+ * execution, so a later one would be a symptom of it.
+ */
+ private void recordHostException(Throwable t) {
+ if (pendingException == null) {
+ pendingException = t;
+ }
+ ctxBuffer.set(ValueLayout.JAVA_INT, CtxBuffer.TRAP_CODE, CtxBuffer.TRAP_HOST_EXCEPTION);
+ }
+
private static WasmEngineException trapException(int trapCode) {
+ // TrapException, not the WasmEngineException parent: Instance catches
+ // TrapException to report a trapping start function as uninstantiable.
return switch (trapCode) {
- case CtxBuffer.TRAP_DIV_BY_ZERO -> new WasmEngineException("integer divide by zero");
- case CtxBuffer.TRAP_INT_OVERFLOW -> new WasmEngineException("integer overflow");
- case CtxBuffer.TRAP_UNREACHABLE -> new WasmEngineException("unreachable");
- case CtxBuffer.TRAP_TRUNC_OVERFLOW -> new WasmEngineException("integer overflow");
- case CtxBuffer.TRAP_TRUNC_NAN ->
- new WasmEngineException("invalid conversion to integer");
- case CtxBuffer.TRAP_OOB -> new WasmEngineException("out of bounds memory access");
- case CtxBuffer.TRAP_CALL_STACK_EXHAUSTED ->
- new WasmEngineException("call stack exhausted");
- case CtxBuffer.TRAP_TABLE_OOB -> new WasmEngineException("out of bounds table access");
- case CtxBuffer.TRAP_UNDEFINED_ELEMENT -> new WasmEngineException("undefined element");
- case CtxBuffer.TRAP_UNINITIALIZED_ELEMENT ->
- new WasmEngineException("uninitialized element");
+ case CtxBuffer.TRAP_DIV_BY_ZERO -> new TrapException("integer divide by zero");
+ case CtxBuffer.TRAP_INT_OVERFLOW -> new TrapException("integer overflow");
+ case CtxBuffer.TRAP_UNREACHABLE -> new TrapException("unreachable");
+ case CtxBuffer.TRAP_TRUNC_OVERFLOW -> new TrapException("integer overflow");
+ case CtxBuffer.TRAP_TRUNC_NAN -> new TrapException("invalid conversion to integer");
+ case CtxBuffer.TRAP_OOB -> new WasmRuntimeException("out of bounds memory access");
+ case CtxBuffer.TRAP_CALL_STACK_EXHAUSTED -> new TrapException("call stack exhausted");
+ case CtxBuffer.TRAP_TABLE_OOB -> new TrapException("out of bounds table access");
+ case CtxBuffer.TRAP_UNDEFINED_ELEMENT -> new TrapException("undefined element");
+ case CtxBuffer.TRAP_UNINITIALIZED_ELEMENT -> new TrapException("uninitialized element");
case CtxBuffer.TRAP_INDIRECT_CALL_TYPE_MISMATCH ->
- new WasmEngineException("indirect call type mismatch");
- case CtxBuffer.TRAP_UNALIGNED_ATOMIC -> new WasmEngineException("unaligned atomic");
- case CtxBuffer.TRAP_INTERRUPTED -> new WasmEngineException("interrupted");
+ new TrapException("indirect call type mismatch");
+ case CtxBuffer.TRAP_UNALIGNED_ATOMIC -> new TrapException("unaligned atomic");
+ case CtxBuffer.TRAP_INTERRUPTED -> new TrapException("interrupted");
default -> new WasmEngineException("trap: unknown code " + trapCode);
};
}
@@ -989,13 +1026,19 @@ public long[] call(int funcId, long[] args) throws WasmEngineException {
var handle = downcalls[funcId];
try {
+ boolean outermostCall = callDepth++ == 0;
var funcType = (FunctionType) instance.type(instance.functionType(funcId));
initializeImportGlobals();
initializeNativeTables();
- // Reset stack limit so native code re-initializes from calling thread's RSP
- ctxBuffer.set(ValueLayout.JAVA_LONG, CtxBuffer.STACK_LIMIT, 0L);
+ // Re-anchor the stack guard only for a call that starts on this
+ // stack. A host function calling back in has to keep measuring
+ // against where the outer call began, or every level moves the
+ // limit deeper and the guard stops firing.
+ if (outermostCall) {
+ ctxBuffer.set(ValueLayout.JAVA_LONG, CtxBuffer.STACK_LIMIT, 0L);
+ }
if (!memBaseInitialized) {
var mem = instance.memory();
@@ -1023,17 +1066,19 @@ public long[] call(int funcId, long[] args) throws WasmEngineException {
}
if (Thread.interrupted()) {
- throw new WasmEngineException("interrupted");
+ throw new TrapException("interrupted");
}
Thread caller = Thread.currentThread();
Thread watchdog =
new Thread(
() -> {
+ // Keeps raising rather than returning after the
+ // first: a nested call clears the flag when it
+ // finishes, and the outer call still needs it.
while (!Thread.currentThread().isInterrupted()) {
if (caller.isInterrupted()) {
requestInterrupt();
- return;
}
try {
Thread.sleep(1);
@@ -1049,6 +1094,9 @@ public long[] call(int funcId, long[] args) throws WasmEngineException {
result = (long) handle.invokeExact(cachedMemBase, ctxBuffer, args);
} finally {
watchdog.interrupt();
+ // The flag only ever means "stop this call". Left set it would
+ // trap the next one on a thread nobody interrupted.
+ clearInterrupt();
}
// Check for exceptions from upcall stubs first — a host function
@@ -1094,6 +1142,7 @@ public long[] call(int funcId, long[] args) throws WasmEngineException {
sneakyThrow(e);
throw new AssertionError("unreachable");
} finally {
+ callDepth--;
// Prevent the JIT from considering this machine unreachable during
// the native call, which would let GC collect and close() free
// native memory while code is executing.
diff --git a/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/NativeMemory.java b/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/NativeMemory.java
index 4c3828e1f..cb907bafa 100644
--- a/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/NativeMemory.java
+++ b/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/NativeMemory.java
@@ -250,47 +250,94 @@ public void write(int addr, byte[] data, int offset, int size) {
MemorySegment.copy(MemorySegment.ofArray(data), offset, segment, addr, size);
}
+ /**
+ * A MemorySegment reports an out of bounds access its own way, but a host
+ * reading past the end of a Wasm memory has to see the same trap it would
+ * from any other backend.
+ */
+ private static run.endive.runtime.WasmRuntimeException outOfBounds(int addr) {
+ return new run.endive.runtime.WasmRuntimeException(
+ "out of bounds memory access: attempted to access address: " + addr);
+ }
+
@Override
public byte read(int addr) {
- return segment.get(ValueLayout.JAVA_BYTE, addr);
+ try {
+ return segment.get(ValueLayout.JAVA_BYTE, addr);
+ } catch (IndexOutOfBoundsException e) {
+ throw outOfBounds(addr);
+ }
}
@Override
public byte[] readBytes(int addr, int len) {
- return segment.asSlice(addr, len).toArray(ValueLayout.JAVA_BYTE);
+ try {
+ return segment.asSlice(addr, len).toArray(ValueLayout.JAVA_BYTE);
+ } catch (IndexOutOfBoundsException e) {
+ throw outOfBounds(addr);
+ }
}
@Override
public void writeI32(int addr, int data) {
- segment.set(ValueLayout.JAVA_INT_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN), addr, data);
+ try {
+ segment.set(
+ ValueLayout.JAVA_INT_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN), addr, data);
+ } catch (IndexOutOfBoundsException e) {
+ throw outOfBounds(addr);
+ }
}
@Override
public int readInt(int addr) {
- return segment.get(ValueLayout.JAVA_INT_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN), addr);
+ try {
+ return segment.get(
+ ValueLayout.JAVA_INT_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN), addr);
+ } catch (IndexOutOfBoundsException e) {
+ throw outOfBounds(addr);
+ }
}
@Override
public void writeLong(int addr, long data) {
- segment.set(ValueLayout.JAVA_LONG_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN), addr, data);
+ try {
+ segment.set(
+ ValueLayout.JAVA_LONG_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN), addr, data);
+ } catch (IndexOutOfBoundsException e) {
+ throw outOfBounds(addr);
+ }
}
@Override
public long readLong(int addr) {
- return segment.get(
- ValueLayout.JAVA_LONG_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN), addr);
+ try {
+ return segment.get(
+ ValueLayout.JAVA_LONG_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN), addr);
+ } catch (IndexOutOfBoundsException e) {
+ throw outOfBounds(addr);
+ }
}
@Override
public void writeShort(int addr, short data) {
- segment.set(
- ValueLayout.JAVA_SHORT_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN), addr, data);
+ try {
+ segment.set(
+ ValueLayout.JAVA_SHORT_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN),
+ addr,
+ data);
+ } catch (IndexOutOfBoundsException e) {
+ throw outOfBounds(addr);
+ }
}
@Override
public short readShort(int addr) {
- return segment.get(
- ValueLayout.JAVA_SHORT_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN), addr);
+ try {
+ return segment.get(
+ ValueLayout.JAVA_SHORT_UNALIGNED.withOrder(ByteOrder.LITTLE_ENDIAN), addr);
+ } catch (IndexOutOfBoundsException e) {
+ throw outOfBounds(addr);
+ }
}
@Override
@@ -300,7 +347,11 @@ public long readU16(int addr) {
@Override
public void writeByte(int addr, byte data) {
- segment.set(ValueLayout.JAVA_BYTE, addr, data);
+ try {
+ segment.set(ValueLayout.JAVA_BYTE, addr, data);
+ } catch (IndexOutOfBoundsException e) {
+ throw outOfBounds(addr);
+ }
}
@Override
diff --git a/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/NativeTable.java b/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/NativeTable.java
index bdb1de3cb..2831ec6a3 100644
--- a/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/NativeTable.java
+++ b/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/NativeTable.java
@@ -45,8 +45,8 @@ public final class NativeTable extends TableInstance {
private final int capacity;
private final boolean isExternRef;
- public NativeTable(Table table, Arena arena) {
- super(table, REF_NULL_VALUE);
+ public NativeTable(Table table, int initValue, Arena arena) {
+ super(table, initValue);
this.isExternRef = table.elementType().equals(ValType.ExternRef);
int initial = (int) table.limits().min();
int max = (int) table.limits().max();
@@ -60,10 +60,20 @@ public NativeTable(Table table, Arena arena) {
buffer.set(ValueLayout.JAVA_INT, CtxBuffer.TABLE_SIZE_OFFSET, initial);
buffer.set(ValueLayout.JAVA_INT, CtxBuffer.TABLE_MAX_OFFSET, max > 0 ? max : capacity);
- // Fill all entries with null (funcId=-1, funcPtr=0, typeIdx=0)
- for (int i = 0; i < capacity; i++) {
+ // Only the entries below size are reachable: every access bounds-checks
+ // against the size field, and grow fills the slots it exposes. Filling
+ // the whole capacity here would make the entire pre-allocation resident.
+ for (int i = 0; i < initial; i++) {
writeNullEntry(i);
}
+
+ if (initValue != REF_NULL_VALUE) {
+ // The instance has no machine yet, so funcPtr cannot be resolved
+ // here. resolvePendingRefs fills it in once there is one.
+ for (int i = 0; i < initial; i++) {
+ writeUnresolvedEntry(i, initValue);
+ }
+ }
}
private long entryBase(int index) {
@@ -77,6 +87,34 @@ private void writeNullEntry(int index) {
buffer.set(ValueLayout.JAVA_LONG, base + CtxBuffer.ENTRY_FUNC_PTR_OFFSET, 0L);
}
+ /** A funcref whose native address is not known yet. */
+ private void writeUnresolvedEntry(int index, int value) {
+ long base = entryBase(index);
+ buffer.set(ValueLayout.JAVA_INT, base + CtxBuffer.ENTRY_TYPE_IDX_OFFSET, 0);
+ buffer.set(ValueLayout.JAVA_INT, base + CtxBuffer.ENTRY_FUNC_ID_OFFSET, value);
+ buffer.set(ValueLayout.JAVA_LONG, base + CtxBuffer.ENTRY_FUNC_PTR_OFFSET, 0L);
+ }
+
+ /**
+ * Fills in the native address of entries written before the instance had a
+ * machine, which is the case for a table initialiser.
+ */
+ void resolvePendingRefs(Instance instance) {
+ if (isExternRef) {
+ return;
+ }
+ int sz = size();
+ for (int i = 0; i < sz; i++) {
+ long base = entryBase(i);
+ int funcId = buffer.get(ValueLayout.JAVA_INT, base + CtxBuffer.ENTRY_FUNC_ID_OFFSET);
+ long funcPtr =
+ buffer.get(ValueLayout.JAVA_LONG, base + CtxBuffer.ENTRY_FUNC_PTR_OFFSET);
+ if (funcId != REF_NULL_VALUE && funcPtr == 0L) {
+ resolveFromInstance(i, funcId, instance);
+ }
+ }
+ }
+
private void writeOpaqueEntry(int index, int value) {
long base = entryBase(index);
buffer.set(ValueLayout.JAVA_INT, base + CtxBuffer.ENTRY_TYPE_IDX_OFFSET, 0);
@@ -159,14 +197,8 @@ public void setRef(int index, int value, Instance instance) {
}
if (value == REF_NULL_VALUE) {
writeNullEntry(index);
- } else if (resolveFromInstance(index, value, instance)) {
- // Resolved using the calling module's NativeMachine
- } else {
- // No NativeMachine available — store funcId only (externref or non-native)
- long base = entryBase(index);
- buffer.set(ValueLayout.JAVA_INT, base + CtxBuffer.ENTRY_TYPE_IDX_OFFSET, 0);
- buffer.set(ValueLayout.JAVA_INT, base + CtxBuffer.ENTRY_FUNC_ID_OFFSET, value);
- buffer.set(ValueLayout.JAVA_LONG, base + CtxBuffer.ENTRY_FUNC_PTR_OFFSET, 0L);
+ } else if (!resolveFromInstance(index, value, instance)) {
+ writeUnresolvedEntry(index, value);
}
}
@@ -183,10 +215,7 @@ public int grow(int delta, int value, Instance instance) {
if (value == REF_NULL_VALUE) {
writeNullEntry(i);
} else if (!resolveFromInstance(i, value, instance)) {
- long base = entryBase(i);
- buffer.set(ValueLayout.JAVA_INT, base + CtxBuffer.ENTRY_TYPE_IDX_OFFSET, 0);
- buffer.set(ValueLayout.JAVA_INT, base + CtxBuffer.ENTRY_FUNC_ID_OFFSET, value);
- buffer.set(ValueLayout.JAVA_LONG, base + CtxBuffer.ENTRY_FUNC_PTR_OFFSET, 0L);
+ writeUnresolvedEntry(i, value);
}
}
// Update size
diff --git a/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/PanamaExecutor.java b/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/PanamaExecutor.java
index 16ace607d..fca696c58 100644
--- a/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/PanamaExecutor.java
+++ b/redline/runner/src/main/java/run/endive/redline/experimental/runner/internal/PanamaExecutor.java
@@ -35,6 +35,7 @@ private PanamaExecutor() {}
private static final int PROT_WRITE = 0x2;
private static final int PROT_EXEC = 0x4;
private static final int MAP_PRIVATE = 0x02;
+ private static final long MAP_FAILED = -1L;
private static final int MAP_ANONYMOUS;
// --- Windows handles (null on POSIX) ---
@@ -152,9 +153,21 @@ static MemorySegment mmapCode(long size) throws Throwable {
MAP_PRIVATE | MAP_ANONYMOUS,
-1,
0L);
+ checkMapped(addr);
return addr.reinterpret(size);
}
+ /**
+ * mmap reports failure by returning MAP_FAILED, not null, so an unchecked
+ * result would be reinterpreted as a segment at 0xFFFF...FFFF and crash on
+ * first use rather than throw.
+ */
+ private static void checkMapped(MemorySegment addr) {
+ if (addr.address() == MAP_FAILED) {
+ throw new OutOfMemoryError("mmap failed");
+ }
+ }
+
/** Make a previously mmapped region executable (and remove write). */
static void mprotectExec(MemorySegment addr, long size) throws Throwable {
if (IS_WINDOWS) {
@@ -200,6 +213,7 @@ static MemorySegment mmapNoAccess(long size) throws Throwable {
MAP_PRIVATE | MAP_ANONYMOUS,
-1,
0L);
+ checkMapped(addr);
return addr.reinterpret(size);
}
diff --git a/runtime/src/main/java/run/endive/runtime/GlobalInstance.java b/runtime/src/main/java/run/endive/runtime/GlobalInstance.java
index aec4ca19e..81d256f30 100644
--- a/runtime/src/main/java/run/endive/runtime/GlobalInstance.java
+++ b/runtime/src/main/java/run/endive/runtime/GlobalInstance.java
@@ -89,10 +89,7 @@ public ValType getType() {
}
public void setValue(Value value) {
- if (value.type() != valType) {
- throw new IllegalArgumentException(
- "Value has wrong type; expected " + valType + " got " + value.type());
- }
+ checkType(value);
this.valueLow = value.raw();
}
@@ -100,6 +97,14 @@ public void setValue(long value) {
this.valueLow = value;
}
+ /** For subclasses that store the value elsewhere but still owe the same check. */
+ protected final void checkType(Value value) {
+ if (value.type() != valType) {
+ throw new IllegalArgumentException(
+ "Value has wrong type; expected " + valType + " got " + value.type());
+ }
+ }
+
public void setValueLow(long value) {
this.valueLow = value;
}
diff --git a/wasm-corpus/src/main/resources/compiled/big-table.wat.wasm b/wasm-corpus/src/main/resources/compiled/big-table.wat.wasm
new file mode 100644
index 000000000..87056af4b
Binary files /dev/null and b/wasm-corpus/src/main/resources/compiled/big-table.wat.wasm differ
diff --git a/wasm-corpus/src/main/resources/compiled/host-import-roundtrip.wat.wasm b/wasm-corpus/src/main/resources/compiled/host-import-roundtrip.wat.wasm
new file mode 100644
index 000000000..51d697fb0
Binary files /dev/null and b/wasm-corpus/src/main/resources/compiled/host-import-roundtrip.wat.wasm differ
diff --git a/wasm-corpus/src/main/resources/compiled/host-throw-stops-execution.wat.wasm b/wasm-corpus/src/main/resources/compiled/host-throw-stops-execution.wat.wasm
new file mode 100644
index 000000000..90d17a5ea
Binary files /dev/null and b/wasm-corpus/src/main/resources/compiled/host-throw-stops-execution.wat.wasm differ
diff --git a/wasm-corpus/src/main/resources/compiled/imported-table.wat.wasm b/wasm-corpus/src/main/resources/compiled/imported-table.wat.wasm
new file mode 100644
index 000000000..f2fe78cb6
Binary files /dev/null and b/wasm-corpus/src/main/resources/compiled/imported-table.wat.wasm differ
diff --git a/wasm-corpus/src/main/resources/compiled/interrupt-midcall.wat.wasm b/wasm-corpus/src/main/resources/compiled/interrupt-midcall.wat.wasm
new file mode 100644
index 000000000..90d3e3e6a
Binary files /dev/null and b/wasm-corpus/src/main/resources/compiled/interrupt-midcall.wat.wasm differ
diff --git a/wasm-corpus/src/main/resources/compiled/reentrant-recursion.wat.wasm b/wasm-corpus/src/main/resources/compiled/reentrant-recursion.wat.wasm
new file mode 100644
index 000000000..68a16bdb3
Binary files /dev/null and b/wasm-corpus/src/main/resources/compiled/reentrant-recursion.wat.wasm differ
diff --git a/wasm-corpus/src/main/resources/compiled/table-init-expr.wat.wasm b/wasm-corpus/src/main/resources/compiled/table-init-expr.wat.wasm
new file mode 100644
index 000000000..75346f064
Binary files /dev/null and b/wasm-corpus/src/main/resources/compiled/table-init-expr.wat.wasm differ
diff --git a/wasm-corpus/src/main/resources/compiled/trap-stops-execution.wat.wasm b/wasm-corpus/src/main/resources/compiled/trap-stops-execution.wat.wasm
new file mode 100644
index 000000000..0117e4d91
Binary files /dev/null and b/wasm-corpus/src/main/resources/compiled/trap-stops-execution.wat.wasm differ
diff --git a/wasm-corpus/src/main/resources/wat/big-table.wat b/wasm-corpus/src/main/resources/wat/big-table.wat
new file mode 100644
index 000000000..87b20b5ea
--- /dev/null
+++ b/wasm-corpus/src/main/resources/wat/big-table.wat
@@ -0,0 +1,7 @@
+;; A table large enough that failing to release it shows up as real memory.
+;; Bounded so the whole thing is allocated and touched up front.
+(module
+ (table 100000 100000 funcref)
+
+ (func (export "noop"))
+)
diff --git a/wasm-corpus/src/main/resources/wat/host-import-roundtrip.wat b/wasm-corpus/src/main/resources/wat/host-import-roundtrip.wat
new file mode 100644
index 000000000..da00b4ae2
--- /dev/null
+++ b/wasm-corpus/src/main/resources/wat/host-import-roundtrip.wat
@@ -0,0 +1,24 @@
+;; Exercises what crosses the boundary to and from a host function: float bit
+;; patterns, negative i32 arguments, and multi-value results. Compiled backends
+;; marshal these by hand, so each one is a place the value can be mangled.
+(module
+ (import "host" "retF32" (func $retF32 (result f32)))
+ (import "host" "retF64" (func $retF64 (result f64)))
+ (import "host" "takeI32" (func $takeI32 (param i32) (result i32)))
+ (import "host" "retPair" (func $retPair (result i32 i32)))
+
+ (func (export "callRetF32") (result f32)
+ (call $retF32))
+
+ (func (export "callRetF64") (result f64)
+ (call $retF64))
+
+ ;; passes -1 straight through to the host
+ (func (export "callTakeI32") (result i32)
+ (call $takeI32 (i32.const -1)))
+
+ ;; the host returns (10, 20); summing proves both results arrived
+ (func (export "callRetPairSum") (result i32)
+ (call $retPair)
+ (i32.add))
+)
diff --git a/wasm-corpus/src/main/resources/wat/host-throw-stops-execution.wat b/wasm-corpus/src/main/resources/wat/host-throw-stops-execution.wat
new file mode 100644
index 000000000..6a09a2cd2
--- /dev/null
+++ b/wasm-corpus/src/main/resources/wat/host-throw-stops-execution.wat
@@ -0,0 +1,10 @@
+;; A host function that throws has to abandon the module the same way a trap
+;; does. mem[0] stays 0 unless execution carried on after the exception.
+(module
+ (import "host" "boom" (func $boom))
+ (memory (export "mem") 1)
+
+ (func (export "callBoom")
+ (call $boom)
+ (i32.store (i32.const 0) (i32.const 42)))
+)
diff --git a/wasm-corpus/src/main/resources/wat/imported-table.wat b/wasm-corpus/src/main/resources/wat/imported-table.wat
new file mode 100644
index 000000000..41a29a15c
--- /dev/null
+++ b/wasm-corpus/src/main/resources/wat/imported-table.wat
@@ -0,0 +1,6 @@
+;; Borrows its table from the host, so the instance must not release it on close.
+(module
+ (import "env" "table" (table 4 funcref))
+
+ (func (export "noop"))
+)
diff --git a/wasm-corpus/src/main/resources/wat/interrupt-midcall.wat b/wasm-corpus/src/main/resources/wat/interrupt-midcall.wat
new file mode 100644
index 000000000..5d6b83cfe
--- /dev/null
+++ b/wasm-corpus/src/main/resources/wat/interrupt-midcall.wat
@@ -0,0 +1,13 @@
+;; The host function is called after the entry interrupt check has already
+;; passed, and nothing loops afterwards, so a flag raised from inside it is
+;; still set when the call returns normally. That is what the watchdog thread
+;; does when it observes an interrupt near the end of a call.
+(module
+ (import "host" "raiseFlag" (func $raiseFlag))
+
+ (func (export "callHost")
+ (call $raiseFlag))
+
+ (func (export "answer") (result i32)
+ (i32.const 42))
+)
diff --git a/wasm-corpus/src/main/resources/wat/reentrant-recursion.wat b/wasm-corpus/src/main/resources/wat/reentrant-recursion.wat
new file mode 100644
index 000000000..25944a886
--- /dev/null
+++ b/wasm-corpus/src/main/resources/wat/reentrant-recursion.wat
@@ -0,0 +1,10 @@
+;; Recurses back into itself through the host rather than through a wasm call,
+;; so every level re-enters the machine from the top. The stack guard has to
+;; keep measuring against where the first call started, not where the latest
+;; one did, or the budget grows by a frame on every level.
+(module
+ (import "host" "reenter" (func $reenter))
+
+ (func (export "recurse")
+ (call $reenter))
+)
diff --git a/wasm-corpus/src/main/resources/wat/table-init-expr.wat b/wasm-corpus/src/main/resources/wat/table-init-expr.wat
new file mode 100644
index 000000000..acc3a5488
--- /dev/null
+++ b/wasm-corpus/src/main/resources/wat/table-init-expr.wat
@@ -0,0 +1,15 @@
+;; A table declared with a non-null initialiser: every slot starts out holding
+;; $f rather than null. Nothing writes to the table afterwards, so a backend
+;; that null-fills instead comes up empty and traps on call_indirect.
+(module
+ (type $ft (func (result i32)))
+
+ (func $f (type $ft)
+ (i32.const 42))
+
+ (table $t 2 2 funcref (ref.func $f))
+
+ ;; index 1 is only reachable through the initialiser
+ (func (export "callInitialised") (result i32)
+ (call_indirect (type $ft) (i32.const 1)))
+)
diff --git a/wasm-corpus/src/main/resources/wat/trap-stops-execution.wat b/wasm-corpus/src/main/resources/wat/trap-stops-execution.wat
new file mode 100644
index 000000000..3ae5bca53
--- /dev/null
+++ b/wasm-corpus/src/main/resources/wat/trap-stops-execution.wat
@@ -0,0 +1,19 @@
+;; A trap in a callee has to abandon the caller too. Each export below leaves an
+;; observable mark after the call that traps, so if execution carried on past the
+;; trap the mark is still there once the exception surfaces.
+(module
+ (memory (export "mem") 1)
+
+ (func $trapper (result i32)
+ (i32.div_s (i32.const 1) (i32.const 0)))
+
+ ;; mem[0] stays 0 unless execution continued past the trap
+ (func (export "storeAfterTrap")
+ (drop (call $trapper))
+ (i32.store (i32.const 0) (i32.const 42)))
+
+ ;; mem[4] counts loop iterations that ran after the trap
+ (func (export "loopAfterTrap")
+ (drop (call $trapper))
+ (i32.store (i32.const 4) (i32.const 7)))
+)