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))) +)