diff --git a/redline/api/src/main/java/run/endive/redline/experimental/api/ImportFactory.java b/redline/api/src/main/java/run/endive/redline/experimental/api/ImportFactory.java new file mode 100644 index 000000000..34fa05aec --- /dev/null +++ b/redline/api/src/main/java/run/endive/redline/experimental/api/ImportFactory.java @@ -0,0 +1,73 @@ +package run.endive.redline.experimental.api; + +import java.util.Objects; +import run.endive.runtime.ByteBufferMemory; +import run.endive.runtime.GlobalInstance; +import run.endive.runtime.Memory; +import run.endive.runtime.TableInstance; +import run.endive.wasm.types.MemoryLimits; +import run.endive.wasm.types.MutabilityType; +import run.endive.wasm.types.Table; +import run.endive.wasm.types.Value; + +/** + * Creates the memories, tables and globals a module imports. + * + *

Natively compiled code reaches an import through a raw address, so it can only + * use one the running backend built, while the bytecode path wants the ordinary + * runtime types. Which of those applies is decided by the platform the code lands + * on, not by anything the caller can see, so the choice is made here instead of at + * every import site: + * + *

+ *     var f = MyModule.imports();
+ *     var memory = f.memory(new MemoryLimits(1, 2));
+ * 
+ * + *

The same source then runs on a platform redline supports and on one it does + * not, with no branch of its own. + */ +public final class ImportFactory { + + private final NativeMachineFactoryProvider provider; + + private ImportFactory(NativeMachineFactoryProvider provider) { + this.provider = provider; + } + + /** Builds imports the given native backend can use. */ + public static ImportFactory forNative(NativeMachineFactoryProvider provider) { + return new ImportFactory(Objects.requireNonNull(provider, "provider")); + } + + /** Builds the ordinary runtime imports, for when there is no native backend. */ + public static ImportFactory forBytecode() { + return new ImportFactory(null); + } + + /** Whether these imports are being built for natively compiled code. */ + public boolean isNative() { + return provider != null; + } + + public Memory memory(MemoryLimits limits) { + if (provider != null) { + return provider.createMemory(limits); + } + return new ByteBufferMemory(limits); + } + + public TableInstance table(Table table, int initValue) { + if (provider != null) { + return provider.createImportTable(table, initValue); + } + return new TableInstance(table, initValue); + } + + public GlobalInstance global(Value value, MutabilityType mutability) { + if (provider != null) { + return provider.createImportGlobal(value, mutability); + } + return GlobalInstance.builder().value(value).mutabilityType(mutability).build(); + } +} diff --git a/redline/api/src/main/java/run/endive/redline/experimental/api/NativeMachineFactoryProvider.java b/redline/api/src/main/java/run/endive/redline/experimental/api/NativeMachineFactoryProvider.java index c3d85ae34..31add1e34 100644 --- a/redline/api/src/main/java/run/endive/redline/experimental/api/NativeMachineFactoryProvider.java +++ b/redline/api/src/main/java/run/endive/redline/experimental/api/NativeMachineFactoryProvider.java @@ -3,12 +3,15 @@ import java.util.Optional; import java.util.ServiceConfigurationError; import java.util.ServiceLoader; +import run.endive.runtime.GlobalInstance; import run.endive.runtime.Instance; import run.endive.runtime.Memory; import run.endive.runtime.TableInstance; import run.endive.wasm.WasmModule; import run.endive.wasm.types.MemoryLimits; +import run.endive.wasm.types.MutabilityType; import run.endive.wasm.types.Table; +import run.endive.wasm.types.Value; public interface NativeMachineFactoryProvider { @@ -18,6 +21,8 @@ public interface NativeMachineFactoryProvider { TableInstance createImportTable(Table table, int initValue); + GlobalInstance createImportGlobal(Value value, MutabilityType mutability); + int priority(); static Optional discover() { diff --git a/redline/api/src/test/java/run/endive/redline/experimental/api/ImportFactoryTest.java b/redline/api/src/test/java/run/endive/redline/experimental/api/ImportFactoryTest.java new file mode 100644 index 000000000..f440c2887 --- /dev/null +++ b/redline/api/src/test/java/run/endive/redline/experimental/api/ImportFactoryTest.java @@ -0,0 +1,53 @@ +package run.endive.redline.experimental.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +import org.junit.jupiter.api.Test; +import run.endive.runtime.ByteBufferMemory; +import run.endive.runtime.TableInstance; +import run.endive.wasm.types.MemoryLimits; +import run.endive.wasm.types.MutabilityType; +import run.endive.wasm.types.Table; +import run.endive.wasm.types.TableLimits; +import run.endive.wasm.types.ValType; +import run.endive.wasm.types.Value; + +/** + * With no native provider the factory has to hand back the ordinary runtime types, + * which is what a platform redline does not compile for ends up running. + */ +public class ImportFactoryTest { + + private static final ImportFactory BYTECODE = ImportFactory.forBytecode(); + + @Test + public void withoutAProviderItBuildsTheBytecodeTypes() { + assertFalse(BYTECODE.isNative()); + assertInstanceOf(ByteBufferMemory.class, BYTECODE.memory(new MemoryLimits(1, 2))); + assertInstanceOf( + TableInstance.class, + BYTECODE.table( + new Table(ValType.FuncRef, new TableLimits(1, 1)), Value.REF_NULL_VALUE)); + } + + @Test + public void aBytecodeGlobalKeepsItsValueAndMutability() { + var global = BYTECODE.global(Value.i32(7), MutabilityType.Var); + + assertEquals(7, global.getValue()); + assertEquals(MutabilityType.Var, global.getMutabilityType()); + assertEquals(ValType.I32, global.getType()); + } + + @Test + public void aBytecodeTableStartsOnItsInitialiser() { + var table = + BYTECODE.table( + new Table(ValType.FuncRef, new TableLimits(2, 2)), Value.REF_NULL_VALUE); + + assertEquals(2, table.size()); + assertEquals(Value.REF_NULL_VALUE, table.ref(0)); + } +} 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..f8da772f4 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 @@ -92,6 +92,7 @@ public void extendGeneratedSources() throws IOException { var type = cu.getClassByName(baseName).orElseThrow(); cu.addImport("run.endive.redline.experimental.api.NativeCodeSerializer"); + cu.addImport("run.endive.redline.experimental.api.ImportFactory"); cu.addImport("run.endive.redline.experimental.api.NativeMachineFactoryProvider"); cu.addImport("run.endive.redline.experimental.api.internal.RedlineTarget"); cu.addImport("java.io.InputStream"); @@ -103,6 +104,7 @@ public void extendGeneratedSources() throws IOException { generateLoadNativeCodeMethod(type); generateNativeProviderMethod(type); generateBuilderMethod(type, baseName); + generateImportsMethod(type); generateSafeBuilderMethod(type, baseName); Files.writeString(sourceFile, cu.toString()); @@ -310,6 +312,58 @@ private static void generateNativeProviderMethod(ClassOrInterfaceDeclaration typ new NameExpr("NativeMachineFactoryProvider"), "discover"))); } + private static void generateImportsMethod(ClassOrInterfaceDeclaration type) { + // Generates: + // + // public static ImportFactory imports() { + // var provider = nativeProvider(); + // if (provider.isPresent()) { + // return ImportFactory.forNative(provider.get()); + // } + // return ImportFactory.forBytecode(); + // } + // + // + // Compiled code reaches an imported memory, table or global through a raw + // address, so it can only use ones the running backend built, while the + // bytecode path wants the ordinary runtime types. Going through this factory + // lets the same calling code do both, which is what makes one jar work on a + // platform redline compiles for and on one it does not. + // + // The shape mirrors builder() above, so both generated methods decide the + // same way. + var method = + type.addMethod("imports", Modifier.Keyword.PUBLIC, Modifier.Keyword.STATIC) + .setType(parseClassOrInterfaceType("ImportFactory")); + + var providerVar = + new ExpressionStmt( + new VariableDeclarationExpr( + new VariableDeclarator( + new VarType(), + "provider", + new MethodCallExpr("nativeProvider")))); + + var returnNative = + new ReturnStmt( + new MethodCallExpr( + new NameExpr("ImportFactory"), + "forNative", + new NodeList<>( + new MethodCallExpr(new NameExpr("provider"), "get")))); + + var ifProviderPresent = + new IfStmt() + .setCondition(new MethodCallExpr(new NameExpr("provider"), "isPresent")) + .setThenStmt(new BlockStmt(new NodeList<>(returnNative))); + + var body = method.createBody(); + body.addStatement(providerVar); + body.addStatement(ifProviderPresent); + body.addStatement( + new ReturnStmt(new MethodCallExpr(new NameExpr("ImportFactory"), "forBytecode"))); + } + private static void generateBuilderMethod(ClassOrInterfaceDeclaration type, String moduleName) { // Generates: // diff --git a/redline/it/src/it/redline-e2e-panama/pom.xml b/redline/it/src/it/redline-e2e-panama/pom.xml index 65632de54..3304c290c 100644 --- a/redline/it/src/it/redline-e2e-panama/pom.xml +++ b/redline/it/src/it/redline-e2e-panama/pom.xml @@ -57,6 +57,17 @@ true + + compile-imports + + compile + + + endive.test.ImportsModule + src/test/resources/imports.wat.wasm + true + + diff --git a/redline/it/src/it/redline-e2e-panama/src/test/java/endive/test/RedlinePanamaE2eTest.java b/redline/it/src/it/redline-e2e-panama/src/test/java/endive/test/RedlinePanamaE2eTest.java index 7a9074a6b..0edc35cbd 100644 --- a/redline/it/src/it/redline-e2e-panama/src/test/java/endive/test/RedlinePanamaE2eTest.java +++ b/redline/it/src/it/redline-e2e-panama/src/test/java/endive/test/RedlinePanamaE2eTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -9,6 +10,16 @@ import org.junit.jupiter.api.Test; import run.endive.redline.experimental.api.NativeMachineFactoryProvider; import run.endive.redline.experimental.api.internal.RedlineTarget; +import run.endive.runtime.ImportGlobal; +import run.endive.runtime.ImportMemory; +import run.endive.runtime.ImportTable; +import run.endive.runtime.ImportValues; +import run.endive.wasm.types.MemoryLimits; +import run.endive.wasm.types.MutabilityType; +import run.endive.wasm.types.Table; +import run.endive.wasm.types.TableLimits; +import run.endive.wasm.types.ValType; +import run.endive.wasm.types.Value; class RedlinePanamaE2eTest { @@ -49,6 +60,59 @@ public void nativeBuilderProducesCorrectResults() { } } + /** + * The whole point of the factory: build the imports, hand them to the module, and + * read back through the very same objects what the module wrote to them. This is + * the shape the documentation shows, and it has to hold whether this platform got + * native code or fell back to the build-time compiled bytecode. + */ + @Test + public void theModuleSharesTheImportsItWasGiven() { + var imports = ImportsModule.imports(); + + var memory = imports.memory(new MemoryLimits(1, 1)); + var table = + imports.table( + new Table(ValType.FuncRef, new TableLimits(2, 2)), Value.REF_NULL_VALUE); + var counter = imports.global(Value.i32(10), MutabilityType.Var); + + var importValues = + ImportValues.builder() + .addMemory(new ImportMemory("env", "memory", memory)) + .addTable(new ImportTable("env", "table", table)) + .addGlobal(new ImportGlobal("env", "counter", counter)) + .build(); + + try (var instance = ImportsModule.builder().withImportValues(importValues).build()) { + instance.export("run").apply(); + + assertEquals(11, counter.getValue(), "the caller's global must carry the increment"); + assertEquals(23130, memory.readInt(0), "the caller's memory must carry the write"); + assertNotEquals( + Value.REF_NULL_VALUE, + table.ref(0), + "the caller's table must carry the stored funcref"); + + // and the other direction: what the caller writes, the module reads + memory.writeI32(16, 21); + instance.export("doubleAt").apply(16); + assertEquals( + 42, memory.readInt(20), "the module must read what the caller wrote to memory"); + } + } + + /** + * imports() must agree with builder() about which backend is in play. Getting this + * wrong is silent: the module still runs, and only the caller's view goes stale. + */ + @Test + public void importsFactoryMatchesTheBackendInUse() { + assertEquals( + AddModule.nativeProvider().isPresent(), + AddModule.imports().isNative(), + "imports() must build for the same backend builder() runs on"); + } + @Test public void nativeCodeIsAvailable() { assumeTrue( diff --git a/redline/it/src/it/redline-e2e-panama/src/test/resources/imports.wat.wasm b/redline/it/src/it/redline-e2e-panama/src/test/resources/imports.wat.wasm new file mode 100644 index 000000000..aa63c7f05 Binary files /dev/null and b/redline/it/src/it/redline-e2e-panama/src/test/resources/imports.wat.wasm differ diff --git a/redline/it/src/it/redline-e2e/pom.xml b/redline/it/src/it/redline-e2e/pom.xml index 628f2c420..b6b1a7a47 100644 --- a/redline/it/src/it/redline-e2e/pom.xml +++ b/redline/it/src/it/redline-e2e/pom.xml @@ -57,6 +57,17 @@ true + + compile-imports + + compile + + + endive.test.ImportsModule + src/test/resources/imports.wat.wasm + true + + diff --git a/redline/it/src/it/redline-e2e/src/test/java/endive/test/RedlineE2eTest.java b/redline/it/src/it/redline-e2e/src/test/java/endive/test/RedlineE2eTest.java index 31dff4161..735848570 100644 --- a/redline/it/src/it/redline-e2e/src/test/java/endive/test/RedlineE2eTest.java +++ b/redline/it/src/it/redline-e2e/src/test/java/endive/test/RedlineE2eTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -9,6 +10,16 @@ import org.junit.jupiter.api.Test; import run.endive.redline.experimental.api.NativeMachineFactoryProvider; import run.endive.redline.experimental.api.internal.RedlineTarget; +import run.endive.runtime.ImportGlobal; +import run.endive.runtime.ImportMemory; +import run.endive.runtime.ImportTable; +import run.endive.runtime.ImportValues; +import run.endive.wasm.types.MemoryLimits; +import run.endive.wasm.types.MutabilityType; +import run.endive.wasm.types.Table; +import run.endive.wasm.types.TableLimits; +import run.endive.wasm.types.ValType; +import run.endive.wasm.types.Value; class RedlineE2eTest { @@ -62,6 +73,59 @@ public void safeBuilderProducesCorrectResults() { } } + /** + * The whole point of the factory: build the imports, hand them to the module, and + * read back through the very same objects what the module wrote to them. This is + * the shape the documentation shows, and it has to hold whether this platform got + * native code or fell back to the build-time compiled bytecode. + */ + @Test + public void theModuleSharesTheImportsItWasGiven() { + var imports = ImportsModule.imports(); + + var memory = imports.memory(new MemoryLimits(1, 1)); + var table = + imports.table( + new Table(ValType.FuncRef, new TableLimits(2, 2)), Value.REF_NULL_VALUE); + var counter = imports.global(Value.i32(10), MutabilityType.Var); + + var importValues = + ImportValues.builder() + .addMemory(new ImportMemory("env", "memory", memory)) + .addTable(new ImportTable("env", "table", table)) + .addGlobal(new ImportGlobal("env", "counter", counter)) + .build(); + + try (var instance = ImportsModule.builder().withImportValues(importValues).build()) { + instance.export("run").apply(); + + assertEquals(11, counter.getValue(), "the caller's global must carry the increment"); + assertEquals(23130, memory.readInt(0), "the caller's memory must carry the write"); + assertNotEquals( + Value.REF_NULL_VALUE, + table.ref(0), + "the caller's table must carry the stored funcref"); + + // and the other direction: what the caller writes, the module reads + memory.writeI32(16, 21); + instance.export("doubleAt").apply(16); + assertEquals( + 42, memory.readInt(20), "the module must read what the caller wrote to memory"); + } + } + + /** + * imports() must agree with builder() about which backend is in play. Getting this + * wrong is silent: the module still runs, and only the caller's view goes stale. + */ + @Test + public void importsFactoryMatchesTheBackendInUse() { + assertEquals( + AddModule.nativeProvider().isPresent(), + AddModule.imports().isNative(), + "imports() must build for the same backend builder() runs on"); + } + @Test public void nativeCodeIsAvailable() { assumeTrue( diff --git a/redline/it/src/it/redline-e2e/src/test/resources/imports.wat.wasm b/redline/it/src/it/redline-e2e/src/test/resources/imports.wat.wasm new file mode 100644 index 000000000..aa63c7f05 Binary files /dev/null and b/redline/it/src/it/redline-e2e/src/test/resources/imports.wat.wasm differ diff --git a/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/ImportedMutablesTest.java b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/ImportedMutablesTest.java new file mode 100644 index 000000000..12babcb4b --- /dev/null +++ b/redline/runner-jffi-tests/src/test/java/run/endive/redline/experimental/runner/jffi/internal/ImportedMutablesTest.java @@ -0,0 +1,151 @@ +package run.endive.redline.experimental.runner.jffi.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +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.GlobalInstance; +import run.endive.runtime.ImportGlobal; +import run.endive.runtime.ImportMemory; +import run.endive.runtime.ImportTable; +import run.endive.runtime.ImportValues; +import run.endive.runtime.Memory; +import run.endive.runtime.TableInstance; +import run.endive.wasm.Parser; +import run.endive.wasm.WasmModule; +import run.endive.wasm.types.MemoryLimits; +import run.endive.wasm.types.MutabilityType; +import run.endive.wasm.types.Table; +import run.endive.wasm.types.TableLimits; +import run.endive.wasm.types.ValType; +import run.endive.wasm.types.Value; + +/** + * An import belongs to the caller, so what the module writes to it has to be + * visible through the caller's own object. Copying the value in instead leaves the + * two to drift, which makes the same jar answer differently depending on whether + * it got native code on this platform. + */ +public class ImportedMutablesTest { + + private static final int WRITTEN = 23130; + + @Test + public void theCallerSeesWhatTheModuleWroteToItsGlobal() { + var counter = nativeGlobal(); + try (var instance = build(nativeMemory(), nativeTable(), counter)) { + instance.export("run").apply(); + assertEquals( + 11L, + counter.getValue(), + "the caller's own global must carry what the module wrote"); + } + } + + @Test + public void theCallerSeesWhatTheModuleWroteToItsMemory() { + var memory = nativeMemory(); + try (var instance = build(memory, nativeTable(), nativeGlobal())) { + instance.export("run").apply(); + assertEquals( + WRITTEN, memory.readInt(0), "the caller's own memory must carry the write"); + } + } + + @Test + public void theModuleReadsWhatTheCallerWroteToItsMemory() { + var memory = nativeMemory(); + try (var instance = build(memory, nativeTable(), nativeGlobal())) { + memory.writeI32(16, 21); + instance.export("doubleAt").apply(16); + assertEquals( + 42, + memory.readInt(20), + "the module must read back what the caller put in its memory"); + } + } + + @Test + public void theCallerSeesWhatTheModuleWroteToItsTable() { + var table = nativeTable(); + try (var instance = build(nativeMemory(), table, nativeGlobal())) { + instance.export("run").apply(); + assertNotEquals( + Value.REF_NULL_VALUE, + table.ref(0), + "the caller's own table must carry the funcref the module stored"); + } + } + + @Test + public void matchesTheInterpreter() { + var counter = + GlobalInstance.builder() + .value(Value.i32(10)) + .mutabilityType(MutabilityType.Var) + .build(); + var reference = + run.endive.runtime.Instance.builder(module()) + .withImportValues( + importsFor( + new run.endive.runtime.ByteBufferMemory( + new MemoryLimits(1, 1)), + new TableInstance(table(), Value.REF_NULL_VALUE), + counter)) + .build(); + reference.export("run").apply(); + + var native_ = nativeGlobal(); + try (var instance = build(nativeMemory(), nativeTable(), native_)) { + instance.export("run").apply(); + assertEquals( + counter.getValue(), + native_.getValue(), + "redline must leave the caller's global where the interpreter does"); + } + } + + private static Memory nativeMemory() { + return JffiNativeMachineFactory.createMemory(new MemoryLimits(1, 1)); + } + + private static TableInstance nativeTable() { + return JffiNativeMachineFactory.createImportTable(table(), Value.REF_NULL_VALUE); + } + + private static GlobalInstance nativeGlobal() { + return JffiNativeMachineFactory.createImportGlobal(10L, ValType.I32, MutabilityType.Var); + } + + private static Table table() { + return new Table(ValType.FuncRef, new TableLimits(2, 2)); + } + + private static WasmModule module() { + return Parser.parse(CorpusResources.getResource("compiled/imported-mutables.wat.wasm")); + } + + private static ImportValues importsFor( + Memory memory, TableInstance table, GlobalInstance counter) { + return ImportValues.builder() + .addMemory(new ImportMemory("env", "memory", memory)) + .addTable(new ImportTable("env", "table", table)) + .addGlobal(new ImportGlobal("env", "counter", counter)) + .build(); + } + + private static run.endive.runtime.Instance build( + Memory memory, TableInstance table, GlobalInstance counter) { + return JffiNativeMachineFactory.builder(module()) + .withImportValues(importsFor(memory, table, counter)) + .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/JffiMachineFactoryProvider.java b/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/JffiMachineFactoryProvider.java index 17640d46c..d28d4ecbf 100644 --- a/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/JffiMachineFactoryProvider.java +++ b/redline/runner-jffi/src/main/java/run/endive/redline/experimental/runner/jffi/JffiMachineFactoryProvider.java @@ -1,12 +1,15 @@ package run.endive.redline.experimental.runner.jffi; import run.endive.redline.experimental.api.NativeMachineFactoryProvider; +import run.endive.runtime.GlobalInstance; import run.endive.runtime.Instance; import run.endive.runtime.Memory; import run.endive.runtime.TableInstance; import run.endive.wasm.WasmModule; import run.endive.wasm.types.MemoryLimits; +import run.endive.wasm.types.MutabilityType; import run.endive.wasm.types.Table; +import run.endive.wasm.types.Value; public final class JffiMachineFactoryProvider implements NativeMachineFactoryProvider { @@ -27,6 +30,11 @@ public TableInstance createImportTable(Table table, int initValue) { return JffiNativeMachineFactory.createImportTable(table, initValue); } + @Override + public GlobalInstance createImportGlobal(Value value, MutabilityType mutability) { + return JffiNativeMachineFactory.createImportGlobal(value.raw(), value.type(), mutability); + } + @Override public int priority() { return 50; 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 c64febb42..afc67eb02 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 @@ -79,8 +79,7 @@ public GlobalInstance createGlobal( public static GlobalInstance createImportGlobal( long value, ValType type, MutabilityType mutability) { - long addr = MEM.allocateMemory(8, true); - return new JffiNativeGlobalInstance(addr, 0, value, type, mutability); + return JffiNativeGlobalInstance.standalone(value, type, mutability); } public static Memory createMemory(MemoryLimits limits) { 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 d7eeb6c6c..59f0e2591 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 @@ -14,8 +14,18 @@ public final class JffiNativeGlobalInstance extends GlobalInstance { private static final MemoryIO MEM = MemoryIO.getInstance(); - private final long bufferAddress; - private final long offset; + // Not final: an imported global is built before the instance that will use it + // exists, so it starts out on its own buffer and is moved onto the machine's + // globals buffer by rebind() once there is one. See JffiNativeMachine. + private long bufferAddress; + private long offset; + + /** + * True while this global sits on a buffer of its own rather than inside some + * machine's globals buffer. Only such a global may be moved: one a module + * exports is already where that module's compiled code reads it. + */ + private boolean standalone; public JffiNativeGlobalInstance( long bufferAddress, @@ -30,6 +40,36 @@ public JffiNativeGlobalInstance( MEM.putLong(bufferAddress + offset, initialValue); } + /** + * A global for the host to pass in as an import, on storage of its own until the + * instance that receives it adopts it. + */ + public static JffiNativeGlobalInstance standalone( + long initialValue, ValType valType, MutabilityType mutabilityType) { + var global = + new JffiNativeGlobalInstance( + MEM.allocateMemory(8, true), 0, initialValue, valType, mutabilityType); + global.standalone = true; + return global; + } + + boolean isStandalone() { + return standalone; + } + + /** + * Moves this global onto the buffer compiled code reads, carrying its current + * value across. Both sides then share the same eight bytes, so neither can go + * stale while the other writes. + */ + void rebind(long target, int index) { + long current = getValue(); + this.bufferAddress = target; + this.offset = (long) index * 8; + this.standalone = false; + MEM.putLong(target + offset, current); + } + @Override public long getValue() { return MEM.getLong(bufferAddress + offset); 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 1d5b84e0d..4adfb8d2b 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 @@ -783,6 +783,20 @@ private long memoryGrowHandler(long ctxAddr) { // --- Globals initialization --- + /** + * Compiled code reaches an import through a raw address, so it can only use one + * this backend built. The message points at the module's own factory rather than + * a backend-specific one, because that is what works on every platform. + */ + private static String foreignImportMessage(String kind, Object actual) { + return "this module is running natively compiled code, which can only use an imported " + + kind + + " created by the same backend, but got " + + actual.getClass().getName() + + ". Create it through the generated module's imports() factory, which picks" + + " the right one whether or not native code is available on this platform."; + } + private void initializeImportGlobals() { if (importGlobalsInitialized || globalCount == 0) { return; @@ -800,7 +814,21 @@ private void initializeImportGlobals() { .count(); for (int i = 0; i < importGlobalCount; i++) { - MEM.putLong(globalsBufferAddr + (long) i * 8, instance.global(i).getValue()); + var global = instance.global(i); + if (!(global instanceof JffiNativeGlobalInstance)) { + throw new WasmEngineException(foreignImportMessage("global", global)); + } + var nativeGlobal = (JffiNativeGlobalInstance) global; + if (nativeGlobal.isStandalone()) { + // Passed in by the host: adopt it, so what this module writes stays + // visible through the caller's own object. + nativeGlobal.rebind(globalsBufferAddr, i); + } else { + // Exported by another module, and already sitting where that + // module's compiled code reads it. Its storage cannot move, so this + // module starts from its current value. + MEM.putLong(globalsBufferAddr + (long) i * 8, nativeGlobal.getValue()); + } } } @@ -835,14 +863,7 @@ private void initializeNativeTables() { // 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, run.endive.wasm.types.Value.REF_NULL_VALUE); - for (int j = 0; j < table.size(); j++) { - nt.setRef(j, table.ref(j), instance); - } - nativeTables[i] = nt; - owned[i] = true; + throw new WasmEngineException(foreignImportMessage("table", table)); } MEM.putLong(tablePtrsArrayAddr + (long) i * 8, nativeTables[i].nativeBufferAddress()); @@ -1043,11 +1064,7 @@ public long[] call(int funcId, long[] args) throws WasmEngineException { cachedMemBase = ((JffiNativeMemory) mem).nativeAddress(); MEM.putLong(ctxBufferAddr + CtxBuffer.MEM_BASE_ADDR, cachedMemBase); } else if (mem != null) { - throw new WasmEngineException( - "JffiNativeMachine requires JffiNativeMemory but got " - + mem.getClass().getName() - + ". Use JffiNativeMachineFactory.createMemory() for all" - + " memories, including imports."); + throw new WasmEngineException(foreignImportMessage("memory", mem)); } else { cachedMemBase = 0L; } diff --git a/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/ImportedMutablesTest.java b/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/ImportedMutablesTest.java new file mode 100644 index 000000000..d155499d6 --- /dev/null +++ b/redline/runner-tests/src/test/java/run/endive/redline/experimental/runner/internal/ImportedMutablesTest.java @@ -0,0 +1,151 @@ +package run.endive.redline.experimental.runner.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +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.GlobalInstance; +import run.endive.runtime.ImportGlobal; +import run.endive.runtime.ImportMemory; +import run.endive.runtime.ImportTable; +import run.endive.runtime.ImportValues; +import run.endive.runtime.Memory; +import run.endive.runtime.TableInstance; +import run.endive.wasm.Parser; +import run.endive.wasm.WasmModule; +import run.endive.wasm.types.MemoryLimits; +import run.endive.wasm.types.MutabilityType; +import run.endive.wasm.types.Table; +import run.endive.wasm.types.TableLimits; +import run.endive.wasm.types.ValType; +import run.endive.wasm.types.Value; + +/** + * An import belongs to the caller, so what the module writes to it has to be + * visible through the caller's own object. Copying the value in instead leaves the + * two to drift, which makes the same jar answer differently depending on whether + * it got native code on this platform. + */ +public class ImportedMutablesTest { + + private static final int WRITTEN = 23130; + + @Test + public void theCallerSeesWhatTheModuleWroteToItsGlobal() { + var counter = nativeGlobal(); + try (var instance = build(nativeMemory(), nativeTable(), counter)) { + instance.export("run").apply(); + assertEquals( + 11L, + counter.getValue(), + "the caller's own global must carry what the module wrote"); + } + } + + @Test + public void theCallerSeesWhatTheModuleWroteToItsMemory() { + var memory = nativeMemory(); + try (var instance = build(memory, nativeTable(), nativeGlobal())) { + instance.export("run").apply(); + assertEquals( + WRITTEN, memory.readInt(0), "the caller's own memory must carry the write"); + } + } + + @Test + public void theModuleReadsWhatTheCallerWroteToItsMemory() { + var memory = nativeMemory(); + try (var instance = build(memory, nativeTable(), nativeGlobal())) { + memory.writeI32(16, 21); + instance.export("doubleAt").apply(16); + assertEquals( + 42, + memory.readInt(20), + "the module must read back what the caller put in its memory"); + } + } + + @Test + public void theCallerSeesWhatTheModuleWroteToItsTable() { + var table = nativeTable(); + try (var instance = build(nativeMemory(), table, nativeGlobal())) { + instance.export("run").apply(); + assertNotEquals( + Value.REF_NULL_VALUE, + table.ref(0), + "the caller's own table must carry the funcref the module stored"); + } + } + + @Test + public void matchesTheInterpreter() { + var counter = + GlobalInstance.builder() + .value(Value.i32(10)) + .mutabilityType(MutabilityType.Var) + .build(); + var reference = + run.endive.runtime.Instance.builder(module()) + .withImportValues( + importsFor( + new run.endive.runtime.ByteBufferMemory( + new MemoryLimits(1, 1)), + new TableInstance(table(), Value.REF_NULL_VALUE), + counter)) + .build(); + reference.export("run").apply(); + + var native_ = nativeGlobal(); + try (var instance = build(nativeMemory(), nativeTable(), native_)) { + instance.export("run").apply(); + assertEquals( + counter.getValue(), + native_.getValue(), + "redline must leave the caller's global where the interpreter does"); + } + } + + private static Memory nativeMemory() { + return NativeMachineFactory.createMemory(new MemoryLimits(1, 1)); + } + + private static TableInstance nativeTable() { + return NativeMachineFactory.createImportTable(table(), Value.REF_NULL_VALUE); + } + + private static GlobalInstance nativeGlobal() { + return NativeMachineFactory.createImportGlobal(10L, ValType.I32, MutabilityType.Var); + } + + private static Table table() { + return new Table(ValType.FuncRef, new TableLimits(2, 2)); + } + + private static WasmModule module() { + return Parser.parse(CorpusResources.getResource("compiled/imported-mutables.wat.wasm")); + } + + private static ImportValues importsFor( + Memory memory, TableInstance table, GlobalInstance counter) { + return ImportValues.builder() + .addMemory(new ImportMemory("env", "memory", memory)) + .addTable(new ImportTable("env", "table", table)) + .addGlobal(new ImportGlobal("env", "counter", counter)) + .build(); + } + + private static run.endive.runtime.Instance build( + Memory memory, TableInstance table, GlobalInstance counter) { + return NativeMachineFactory.builder(module()) + .withImportValues(importsFor(memory, table, counter)) + .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 8c67a82a6..bc6af6140 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 @@ -78,8 +78,7 @@ public GlobalInstance createGlobal( public static GlobalInstance createImportGlobal( long value, ValType type, MutabilityType mutability) { - var buf = Arena.ofAuto().allocate(8, 8); - return new NativeGlobalInstance(buf, 0, value, type, mutability); + return NativeGlobalInstance.standalone(value, type, mutability); } public static Memory createMemory(MemoryLimits limits) { diff --git a/redline/runner/src/main/java/run/endive/redline/experimental/runner/PanamaMachineFactoryProvider.java b/redline/runner/src/main/java/run/endive/redline/experimental/runner/PanamaMachineFactoryProvider.java index 0ed78e10c..2a775904d 100644 --- a/redline/runner/src/main/java/run/endive/redline/experimental/runner/PanamaMachineFactoryProvider.java +++ b/redline/runner/src/main/java/run/endive/redline/experimental/runner/PanamaMachineFactoryProvider.java @@ -1,12 +1,15 @@ package run.endive.redline.experimental.runner; import run.endive.redline.experimental.api.NativeMachineFactoryProvider; +import run.endive.runtime.GlobalInstance; import run.endive.runtime.Instance; import run.endive.runtime.Memory; import run.endive.runtime.TableInstance; import run.endive.wasm.WasmModule; import run.endive.wasm.types.MemoryLimits; +import run.endive.wasm.types.MutabilityType; import run.endive.wasm.types.Table; +import run.endive.wasm.types.Value; public final class PanamaMachineFactoryProvider implements NativeMachineFactoryProvider { @@ -27,6 +30,11 @@ public TableInstance createImportTable(Table table, int initValue) { return NativeMachineFactory.createImportTable(table, initValue); } + @Override + public GlobalInstance createImportGlobal(Value value, MutabilityType mutability) { + return NativeMachineFactory.createImportGlobal(value.raw(), value.type(), mutability); + } + @Override public int priority() { return 100; 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 3910d5048..d9f3ef916 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 @@ -1,5 +1,6 @@ package run.endive.redline.experimental.runner.internal; +import java.lang.foreign.Arena; import java.lang.foreign.MemorySegment; import java.lang.foreign.ValueLayout; import run.endive.runtime.GlobalInstance; @@ -13,8 +14,18 @@ */ public final class NativeGlobalInstance extends GlobalInstance { - private final MemorySegment buffer; - private final long offset; + // Not final: an imported global is built before the instance that will use it + // exists, so it starts out on its own buffer and is moved onto the machine's + // globals buffer by rebind() once there is one. See NativeMachine. + private MemorySegment buffer; + private long offset; + + /** + * True while this global sits on a buffer of its own rather than inside some + * machine's globals buffer. Only such a global may be moved: one a module + * exports is already where that module's compiled code reads it. + */ + private boolean standalone; public NativeGlobalInstance( MemorySegment buffer, @@ -29,6 +40,36 @@ public NativeGlobalInstance( buffer.set(ValueLayout.JAVA_LONG, offset, initialValue); } + /** + * A global for the host to pass in as an import, on storage of its own until the + * instance that receives it adopts it. + */ + public static NativeGlobalInstance standalone( + long initialValue, ValType valType, MutabilityType mutabilityType) { + var global = + new NativeGlobalInstance( + Arena.ofAuto().allocate(8, 8), 0, initialValue, valType, mutabilityType); + global.standalone = true; + return global; + } + + boolean isStandalone() { + return standalone; + } + + /** + * Moves this global onto the buffer compiled code reads, carrying its current + * value across. Both sides then share the same eight bytes, so neither can go + * stale while the other writes. + */ + void rebind(MemorySegment target, int index) { + long current = getValue(); + this.buffer = target; + this.offset = (long) index * 8; + this.standalone = false; + target.set(ValueLayout.JAVA_LONG, offset, current); + } + @Override public long getValue() { return buffer.get(ValueLayout.JAVA_LONG, offset); 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 4578b2f1d..b9da6235b 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 @@ -839,13 +839,27 @@ private long memoryGrowHandler(long ctxAddr) { // --- Globals initialization --- + /** + * Compiled code reaches an import through a raw address, so it can only use one + * this backend built. The message points at the module's own factory rather than + * a backend-specific one, because that is what works on every platform. + */ + private static String foreignImportMessage(String kind, Object actual) { + return "this module is running natively compiled code, which can only use an imported " + + kind + + " created by the same backend, but got " + + actual.getClass().getName() + + ". Create it through the generated module's imports() factory, which picks" + + " the right one whether or not native code is available on this platform."; + } + /** * Lazily replace module-defined GlobalInstance objects with NativeGlobalInstance * backed by the off-heap globalsBuffer. Called once on first native call, * after Instance.initialize() has created the original GlobalInstance objects. * - * For imported globals, we copy their current value into the buffer (read-only - * from native code's perspective — imported mutable globals are rare). + * An imported global is moved onto the same buffer rather than copied into it, + * so that what the module writes stays visible through the caller's own object. */ private void initializeImportGlobals() { if (importGlobalsInitialized || globalCount == 0) { @@ -854,7 +868,6 @@ private void initializeImportGlobals() { importGlobalsInitialized = true; // Module-defined globals are already NativeGlobalInstance (created by globalFactory). - // Only need to copy imported global values into the shared buffer. int importGlobalCount = (int) instance.module().importSection().stream() @@ -866,7 +879,20 @@ private void initializeImportGlobals() { .count(); for (int i = 0; i < importGlobalCount; i++) { - globalsBuffer.set(ValueLayout.JAVA_LONG, (long) i * 8, instance.global(i).getValue()); + var global = instance.global(i); + if (!(global instanceof NativeGlobalInstance nativeGlobal)) { + throw new WasmEngineException(foreignImportMessage("global", global)); + } + if (nativeGlobal.isStandalone()) { + // Passed in by the host: adopt it, so what this module writes stays + // visible through the caller's own object. + nativeGlobal.rebind(globalsBuffer, i); + } else { + // Exported by another module, and already sitting where that + // module's compiled code reads it. Its storage cannot move, so this + // module starts from its current value. + globalsBuffer.set(ValueLayout.JAVA_LONG, (long) i * 8, nativeGlobal.getValue()); + } } } @@ -897,15 +923,7 @@ private void initializeNativeTables() { 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, run.endive.wasm.types.Value.REF_NULL_VALUE, arena); - for (int j = 0; j < table.size(); j++) { - nt.setRef(j, table.ref(j), instance); - } - nativeTables[i] = nt; + throw new WasmEngineException(foreignImportMessage("table", table)); } tablePtrsArray.set( @@ -1049,11 +1067,7 @@ public long[] call(int funcId, long[] args) throws WasmEngineException { CtxBuffer.MEM_BASE_ADDR, cachedMemBase.address()); } else if (mem != null) { - throw new WasmEngineException( - "NativeMachine requires NativeMemory but got " - + mem.getClass().getName() - + ". Use NativeMachineFactory.createMemory() for all" - + " memories, including imports."); + throw new WasmEngineException(foreignImportMessage("memory", mem)); } else { cachedMemBase = MemorySegment.NULL; } diff --git a/wasm-corpus/src/main/resources/compiled/imported-mutables.wat.wasm b/wasm-corpus/src/main/resources/compiled/imported-mutables.wat.wasm new file mode 100644 index 000000000..aa63c7f05 Binary files /dev/null and b/wasm-corpus/src/main/resources/compiled/imported-mutables.wat.wasm differ diff --git a/wasm-corpus/src/main/resources/wat/imported-mutables.wat b/wasm-corpus/src/main/resources/wat/imported-mutables.wat new file mode 100644 index 000000000..6f30274fa --- /dev/null +++ b/wasm-corpus/src/main/resources/wat/imported-mutables.wat @@ -0,0 +1,25 @@ +;; Mutates every kind of import it was given, so the caller can check that its +;; own objects observe the change. A backend that copies an import instead of +;; sharing it comes apart here: wasm sees the new value and the host does not. +(module + (import "env" "memory" (memory 1)) + (import "env" "table" (table 2 funcref)) + (import "env" "counter" (global $counter (mut i32))) + + (elem declare func $answer) + + (func $answer (result i32) + (i32.const 42)) + + (func (export "run") + (global.set $counter (i32.add (global.get $counter) (i32.const 1))) + (i32.store (i32.const 0) (i32.const 23130)) + (table.set (i32.const 0) (ref.func $answer))) + + ;; reads what the host left at addr and writes twice that to addr+4, so a + ;; round trip through the imported memory is observable from both ends + (func (export "doubleAt") (param $addr i32) + (i32.store + (i32.add (local.get $addr) (i32.const 4)) + (i32.mul (i32.load (local.get $addr)) (i32.const 2)))) +)