diff --git a/benchmark/src/commonMain/kotlin/io/github/charlietap/chasm/benchmark/coremark/CoremarkBenchmark.kt b/benchmark/src/commonMain/kotlin/io/github/charlietap/chasm/benchmark/coremark/CoremarkBenchmark.kt index 97af592ea..52487f523 100644 --- a/benchmark/src/commonMain/kotlin/io/github/charlietap/chasm/benchmark/coremark/CoremarkBenchmark.kt +++ b/benchmark/src/commonMain/kotlin/io/github/charlietap/chasm/benchmark/coremark/CoremarkBenchmark.kt @@ -3,6 +3,8 @@ package io.github.charlietap.chasm.benchmark.coremark import com.goncalossilva.resources.Resource import io.github.charlietap.chasm.benchmark.BenchmarkMode import io.github.charlietap.chasm.benchmark.BenchmarkStabilizer +import io.github.charlietap.chasm.config.StoreConfig +import io.github.charlietap.chasm.embedding.addFuel import io.github.charlietap.chasm.embedding.dsl.imports import io.github.charlietap.chasm.embedding.instance import io.github.charlietap.chasm.embedding.invoke @@ -15,7 +17,15 @@ import io.github.charlietap.chasm.runtime.value.ExecutionValue import io.github.charlietap.chasm.runtime.value.NumberValue import kotlin.time.Clock -fun main() { +/** Takes an optional store configuration: `plain` (the default), `fuel`, `interrupt`, or `fuel+interrupt`. */ +fun main(args: Array) { + val config = when (val name = args.firstOrNull() ?: "plain") { + "plain" -> StoreConfig() + "fuel" -> StoreConfig(meterFuel = true) + "interrupt" -> StoreConfig(interruptible = true) + "fuel+interrupt" -> StoreConfig(meterFuel = true, interruptible = true) + else -> error("Unknown store configuration: $name") + } val topology = BenchmarkStabilizer.topology() val application = BenchmarkStabilizer.enter(BenchmarkMode.PREFER_FASTEST, topology) val effectiveTopology = if (application.isSupported) topology else topology.copy(fastestCpuIds = emptySet()) @@ -27,7 +37,7 @@ fun main() { "CoreMark started on logical CPU ${start.cpuId}, outside the fastest class" } - val benchmark = CoremarkBenchmark() + val benchmark = CoremarkBenchmark(config) benchmark.run() val placement = BenchmarkStabilizer.finishTrial(start, effectiveTopology) @@ -36,11 +46,12 @@ fun main() { } } -class CoremarkBenchmark { +class CoremarkBenchmark(private val config: StoreConfig = StoreConfig()) { fun run() { val bytes = Resource(FILE_DIR + "coremark.wasm").readBytes() - val store = store() + val store = store(config) + if (config.meterFuel) addFuel(store, Long.MAX_VALUE) val imports = imports(store) { function { diff --git a/chasm/api/chasm.klib.api b/chasm/api/chasm.klib.api index e64a58ce2..ed8a78cfb 100644 --- a/chasm/api/chasm.klib.api +++ b/chasm/api/chasm.klib.api @@ -511,6 +511,7 @@ final fun io.github.charlietap.chasm.embedding/function(io.github.charlietap.cha final fun io.github.charlietap.chasm.embedding/gc(io.github.charlietap.chasm.embedding.shapes/Store): io.github.charlietap.chasm.embedding.shapes/ChasmResult // io.github.charlietap.chasm.embedding/gc|gc(io.github.charlietap.chasm.embedding.shapes.Store){}[0] final fun io.github.charlietap.chasm.embedding/global(io.github.charlietap.chasm.embedding.shapes/Store, io.github.charlietap.chasm.type/GlobalType, io.github.charlietap.chasm.runtime.value/ExecutionValue): io.github.charlietap.chasm.embedding.shapes/Global // io.github.charlietap.chasm.embedding/global|global(io.github.charlietap.chasm.embedding.shapes.Store;io.github.charlietap.chasm.type.GlobalType;io.github.charlietap.chasm.runtime.value.ExecutionValue){}[0] final fun io.github.charlietap.chasm.embedding/instance(io.github.charlietap.chasm.embedding.shapes/Store, io.github.charlietap.chasm.embedding.shapes/Module, kotlin.collections/List, io.github.charlietap.chasm.config/RuntimeConfig = ...): io.github.charlietap.chasm.embedding.shapes/ChasmResult // io.github.charlietap.chasm.embedding/instance|instance(io.github.charlietap.chasm.embedding.shapes.Store;io.github.charlietap.chasm.embedding.shapes.Module;kotlin.collections.List;io.github.charlietap.chasm.config.RuntimeConfig){}[0] +final fun io.github.charlietap.chasm.embedding/interrupt(io.github.charlietap.chasm.embedding.shapes/Store): io.github.charlietap.chasm.embedding.shapes/ChasmResult // io.github.charlietap.chasm.embedding/interrupt|interrupt(io.github.charlietap.chasm.embedding.shapes.Store){}[0] final fun io.github.charlietap.chasm.embedding/invoke(io.github.charlietap.chasm.embedding.shapes/Store, io.github.charlietap.chasm.embedding.shapes/Instance, io.github.charlietap.chasm.embedding.shapes/Function, kotlin.collections/List = ...): io.github.charlietap.chasm.embedding.shapes/ChasmResult, io.github.charlietap.chasm.embedding.error/ChasmError.ExecutionError> // io.github.charlietap.chasm.embedding/invoke|invoke(io.github.charlietap.chasm.embedding.shapes.Store;io.github.charlietap.chasm.embedding.shapes.Instance;io.github.charlietap.chasm.embedding.shapes.Function;kotlin.collections.List){}[0] final fun io.github.charlietap.chasm.embedding/invoke(io.github.charlietap.chasm.embedding.shapes/Store, io.github.charlietap.chasm.embedding.shapes/Instance, kotlin/String, kotlin.collections/List = ...): io.github.charlietap.chasm.embedding.shapes/ChasmResult, io.github.charlietap.chasm.embedding.error/ChasmError.ExecutionError> // io.github.charlietap.chasm.embedding/invoke|invoke(io.github.charlietap.chasm.embedding.shapes.Store;io.github.charlietap.chasm.embedding.shapes.Instance;kotlin.String;kotlin.collections.List){}[0] final fun io.github.charlietap.chasm.embedding/memory(io.github.charlietap.chasm.embedding.shapes/Store, io.github.charlietap.chasm.type/MemoryType): io.github.charlietap.chasm.embedding.shapes/Memory // io.github.charlietap.chasm.embedding/memory|memory(io.github.charlietap.chasm.embedding.shapes.Store;io.github.charlietap.chasm.type.MemoryType){}[0] diff --git a/chasm/api/jvm/chasm.api b/chasm/api/jvm/chasm.api index fb5049fc5..1805dacaa 100644 --- a/chasm/api/jvm/chasm.api +++ b/chasm/api/jvm/chasm.api @@ -43,6 +43,10 @@ public final class io/github/charlietap/chasm/embedding/InstanceKt { public static synthetic fun instance$default (Lio/github/charlietap/chasm/embedding/shapes/Store;Lio/github/charlietap/chasm/embedding/shapes/Module;Ljava/util/List;Lio/github/charlietap/chasm/config/RuntimeConfig;ILjava/lang/Object;)Lio/github/charlietap/chasm/embedding/shapes/ChasmResult; } +public final class io/github/charlietap/chasm/embedding/InterruptKt { + public static final fun interrupt (Lio/github/charlietap/chasm/embedding/shapes/Store;)Lio/github/charlietap/chasm/embedding/shapes/ChasmResult; +} + public final class io/github/charlietap/chasm/embedding/InvokeKt { public static final fun invoke (Lio/github/charlietap/chasm/embedding/shapes/Store;Lio/github/charlietap/chasm/embedding/shapes/Instance;Lio/github/charlietap/chasm/embedding/shapes/Function;Ljava/util/List;)Lio/github/charlietap/chasm/embedding/shapes/ChasmResult; public static final fun invoke (Lio/github/charlietap/chasm/embedding/shapes/Store;Lio/github/charlietap/chasm/embedding/shapes/Instance;Ljava/lang/String;Ljava/util/List;)Lio/github/charlietap/chasm/embedding/shapes/ChasmResult; diff --git a/chasm/src/commonMain/kotlin/io/github/charlietap/chasm/embedding/Interrupt.kt b/chasm/src/commonMain/kotlin/io/github/charlietap/chasm/embedding/Interrupt.kt new file mode 100644 index 000000000..aeb236194 --- /dev/null +++ b/chasm/src/commonMain/kotlin/io/github/charlietap/chasm/embedding/Interrupt.kt @@ -0,0 +1,25 @@ +package io.github.charlietap.chasm.embedding + +import io.github.charlietap.chasm.embedding.error.ChasmError +import io.github.charlietap.chasm.embedding.shapes.ChasmResult +import io.github.charlietap.chasm.embedding.shapes.Store + +/** + * Stops the call running on a store created with `StoreConfig(interruptible = true)`. Safe to call + * from any thread: Wasm code in the call traps with `Interrupted` at its next function entry or loop + * iteration. A host function the call is running is not stopped itself; Wasm code it calls back into + * traps, as does its Wasm caller once it returns. + * + * Returns `true` if a call was running to receive the interrupt, and `false` if none was, in which + * case the interrupt is dropped and the next call runs normally. + */ +fun interrupt(store: Store): ChasmResult { + val interrupt = store.store.interrupt + if (!interrupt.enabled) { + return ChasmResult.Error(ChasmError.ExecutionError("Interrupts are not enabled for this store")) + } + if (interrupt.depth == 0) return ChasmResult.Success(false) + + interrupt.requested = true + return ChasmResult.Success(true) +} diff --git a/chasm/src/commonMain/kotlin/io/github/charlietap/chasm/embedding/Store.kt b/chasm/src/commonMain/kotlin/io/github/charlietap/chasm/embedding/Store.kt index 03fb2900d..0984374a8 100644 --- a/chasm/src/commonMain/kotlin/io/github/charlietap/chasm/embedding/Store.kt +++ b/chasm/src/commonMain/kotlin/io/github/charlietap/chasm/embedding/Store.kt @@ -3,8 +3,14 @@ package io.github.charlietap.chasm.embedding import io.github.charlietap.chasm.config.StoreConfig import io.github.charlietap.chasm.embedding.shapes.Store import io.github.charlietap.chasm.runtime.store.Fuel +import io.github.charlietap.chasm.runtime.store.Interrupt import io.github.charlietap.chasm.runtime.store.Store as InternalStore fun store(): Store = Store(InternalStore()) -fun store(config: StoreConfig): Store = Store(InternalStore(fuel = Fuel(metered = config.meterFuel))) +fun store(config: StoreConfig): Store = Store( + InternalStore( + fuel = Fuel(metered = config.meterFuel), + interrupt = Interrupt(enabled = config.interruptible), + ), +) diff --git a/chasm/src/commonTest/kotlin/io/github/charlietap/chasm/integration/InterruptTest.kt b/chasm/src/commonTest/kotlin/io/github/charlietap/chasm/integration/InterruptTest.kt new file mode 100644 index 000000000..cbc403644 --- /dev/null +++ b/chasm/src/commonTest/kotlin/io/github/charlietap/chasm/integration/InterruptTest.kt @@ -0,0 +1,173 @@ +package io.github.charlietap.chasm.integration + +import com.goncalossilva.resources.Resource +import io.github.charlietap.chasm.config.StoreConfig +import io.github.charlietap.chasm.embedding.addFuel +import io.github.charlietap.chasm.embedding.error.ChasmError +import io.github.charlietap.chasm.embedding.fixture.publicImport +import io.github.charlietap.chasm.embedding.function +import io.github.charlietap.chasm.embedding.instance +import io.github.charlietap.chasm.embedding.interrupt +import io.github.charlietap.chasm.embedding.invoke +import io.github.charlietap.chasm.embedding.module +import io.github.charlietap.chasm.embedding.shapes.ChasmResult +import io.github.charlietap.chasm.embedding.shapes.Instance +import io.github.charlietap.chasm.embedding.shapes.Store +import io.github.charlietap.chasm.embedding.shapes.expect +import io.github.charlietap.chasm.embedding.store +import io.github.charlietap.chasm.fixture.type.functionType +import io.github.charlietap.chasm.fixture.type.i32ValueType +import io.github.charlietap.chasm.fixture.type.resultType +import io.github.charlietap.chasm.host.HostFunction +import io.github.charlietap.chasm.host.writeI32 +import io.github.charlietap.chasm.runtime.error.InvocationError +import io.github.charlietap.chasm.runtime.value.NumberValue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class InterruptTest { + + @Test + fun `interrupting a store that is not interruptible returns an error`() { + val (store, _) = instantiate(StoreConfig()) + + assertIs>(interrupt(store)) + } + + @Test + fun `an interrupt stops the running call at its next check`() { + val (store, instance) = instantiate(StoreConfig(interruptible = true)) + + assertEquals(interrupted(), invoke(store, instance, "interrupt_then_spin")) + } + + @Test + fun `a call after an interrupted one runs`() { + val (store, instance) = instantiate(StoreConfig(interruptible = true)) + assertEquals(interrupted(), invoke(store, instance, "interrupt_then_spin")) + + assertEquals(ChasmResult.Success(listOf(NumberValue.I32(0))), count(store, instance, 3)) + } + + @Test + fun `an interrupt made while no call runs reports it and has no effect`() { + val (store, instance) = instantiate(StoreConfig(interruptible = true)) + + assertEquals(ChasmResult.Success(false), interrupt(store)) + assertEquals(ChasmResult.Success(listOf(NumberValue.I32(0))), count(store, instance, 3)) + } + + @Test + fun `an interruptible store compiles a check per function and loop and a plain store compiles none`() { + val (interruptible, _) = instantiate(StoreConfig(interruptible = true)) + val (plain, _) = instantiate(StoreConfig()) + + // Five functions and five loops. + assertEquals(10, interruptible.store.program.size - plain.store.program.size) + } + + @Test + fun `a metered interruptible store compiles one combined check per function and loop`() { + val (both, _) = instantiate(StoreConfig(meterFuel = true, interruptible = true)) + val (plain, _) = instantiate(StoreConfig()) + + assertEquals(10, both.store.program.size - plain.store.program.size) + } + + @Test + fun `a metered interruptible store traps on an interrupt or on running out of fuel`() { + val (store, instance) = instantiate(StoreConfig(meterFuel = true, interruptible = true)) + addFuel(store, Long.MAX_VALUE) + assertEquals(interrupted(), invoke(store, instance, "interrupt_then_spin")) + + val (metered, meteredInstance) = instantiate(StoreConfig(meterFuel = true, interruptible = true)) + addFuel(metered, 100) + assertEquals( + ChasmResult.Error(ChasmError.ExecutionError(InvocationError.FuelExhausted.toString())), + invoke(metered, meteredInstance, "spin"), + ) + } + + @Test + fun `a metered interruptible store reports an interrupt over running out of fuel`() { + val (store, instance) = instantiate(StoreConfig(meterFuel = true, interruptible = true)) + // Enough for the interrupting function's entry check, none for its loop's. + addFuel(store, 1) + + assertEquals(interrupted(), invoke(store, instance, "interrupt_then_spin")) + } + + @Test + fun `an interrupt made during a call reports that it landed`() { + var interruptResult: ChasmResult<*, *>? = null + val (store, instance) = instantiate(StoreConfig(interruptible = true)) { store, _ -> + interruptResult = interrupt(store) + } + + assertEquals(interrupted(), invoke(store, instance, "callback_then_spin")) + assertEquals(ChasmResult.Success(true), interruptResult) + } + + @Test + fun `an interrupt raised in a call back into the store also stops its caller`() { + var callbackResult: ChasmResult<*, *>? = null + val (store, instance) = instantiate(StoreConfig(interruptible = true)) { store, instance -> + callbackResult = invoke(store, instance, "interrupt_then_spin") + } + + assertEquals(interrupted(), invoke(store, instance, "callback_then_spin")) + assertEquals(interrupted(), callbackResult) + } + + @Test + fun `an interrupt raised in a host function invoked directly stops its calls back into the store`() { + var callbackResult: ChasmResult<*, *>? = null + val (store, instance) = instantiate(StoreConfig(interruptible = true)) { store, instance -> + interrupt(store) + callbackResult = count(store, instance, 3) + } + + assertEquals(ChasmResult.Success(emptyList()), invoke(store, instance, "callback")) + assertEquals(interrupted(), callbackResult) + } + + private fun count(store: Store, instance: Instance, times: Int) = + invoke(store, instance, "count", listOf(NumberValue.I32(times))) + + companion object { + private const val FIXTURE = "integration/interrupt.wasm" + + internal fun interrupted() = + ChasmResult.Error(ChasmError.ExecutionError(InvocationError.Interrupted.toString())) + + /** + * Instantiates the fixture. Its `env.interrupt` import interrupts the store it runs on, its + * `env.callback` import, which it also exports, runs [callback] with the store and the instance + * being created, and its `env.keep_running` import returns [keepRunning]. + */ + internal fun instantiate( + config: StoreConfig, + keepRunning: () -> Boolean = { false }, + callback: (Store, Instance) -> Unit = { _, _ -> }, + ): Pair { + val store = store(config) + lateinit var instance: Instance + val hostInterrupt = function(store, functionType(), HostFunction { _, _ -> interrupt(store) }) + val hostCallback = function(store, functionType(), HostFunction { _, _ -> callback(store, instance) }) + val hostKeepRunning = function( + store, + functionType(results = resultType(listOf(i32ValueType()))), + HostFunction { _, results -> results.writeI32(0, if (keepRunning()) 1 else 0) }, + ) + val module = module(Resource(FIXTURE).readBytes()).expect("module decodes") + val imports = listOf( + publicImport("env", "interrupt", hostInterrupt), + publicImport("env", "callback", hostCallback), + publicImport("env", "keep_running", hostKeepRunning), + ) + instance = instance(store, module, imports).expect("module instantiates") + return store to instance + } + } +} diff --git a/chasm/src/commonTest/resources/integration/interrupt.wasm b/chasm/src/commonTest/resources/integration/interrupt.wasm new file mode 100644 index 000000000..b70719faf Binary files /dev/null and b/chasm/src/commonTest/resources/integration/interrupt.wasm differ diff --git a/chasm/src/commonTest/resources/integration/interrupt.wat b/chasm/src/commonTest/resources/integration/interrupt.wat new file mode 100644 index 000000000..ee5b44c89 --- /dev/null +++ b/chasm/src/commonTest/resources/integration/interrupt.wat @@ -0,0 +1,29 @@ +(module + (import "env" "interrupt" (func $interrupt)) + (import "env" "callback" (func $callback)) + (import "env" "keep_running" (func $keep_running (result i32))) + (export "callback" (func $callback)) + + (func $spin (export "spin") + (loop $continue + (br $continue))) + + (func $interrupt_then_spin (export "interrupt_then_spin") + (call $interrupt) + (loop $continue + (br $continue))) + + (func $callback_then_spin (export "callback_then_spin") + (call $callback) + (loop $continue + (br $continue))) + + (func $spin_while_host_allows (export "spin_while_host_allows") + (loop $continue + (br_if $continue (call $keep_running)))) + + (func $count (export "count") (param $n i32) (result i32) + (loop $continue + (local.set $n (i32.sub (local.get $n) (i32.const 1))) + (br_if $continue (local.get $n))) + (local.get $n))) diff --git a/chasm/src/jvmTest/kotlin/io/github/charlietap/chasm/integration/InterruptThreadTest.kt b/chasm/src/jvmTest/kotlin/io/github/charlietap/chasm/integration/InterruptThreadTest.kt new file mode 100644 index 000000000..99b4f8762 --- /dev/null +++ b/chasm/src/jvmTest/kotlin/io/github/charlietap/chasm/integration/InterruptThreadTest.kt @@ -0,0 +1,56 @@ +package io.github.charlietap.chasm.integration + +import io.github.charlietap.chasm.config.StoreConfig +import io.github.charlietap.chasm.embedding.interrupt +import io.github.charlietap.chasm.embedding.invoke +import io.github.charlietap.chasm.embedding.shapes.ChasmResult +import io.github.charlietap.chasm.integration.InterruptTest.Companion.interrupted +import io.github.charlietap.chasm.runtime.value.NumberValue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.FutureTask +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class InterruptThreadTest { + + @Test + fun `another thread stops a running loop, and the next call runs`() { + val started = CountDownLatch(1) + val abandoned = AtomicBoolean(false) + val (store, instance) = InterruptTest.instantiate( + StoreConfig(interruptible = true), + keepRunning = { + started.countDown() + !abandoned.get() + }, + ) + + val invocation = FutureTask { + invoke(store, instance, "spin_while_host_allows") + } + // A daemon worker and timed waits let a broken interrupt fail the test rather than hang the JVM. + thread(isDaemon = true, name = "interrupt-test") { + invocation.run() + } + + try { + assertTrue(started.await(5, TimeUnit.SECONDS), "Wasm execution did not reach the host callback") + + assertEquals(ChasmResult.Success(true), interrupt(store)) + + assertEquals(interrupted(), invocation.get(5, TimeUnit.SECONDS)) + } finally { + // Ends the loop if the interrupt did not, so a failure leaves no thread spinning. + abandoned.set(true) + } + + assertEquals( + ChasmResult.Success(listOf(NumberValue.I32(0))), + invoke(store, instance, "count", listOf(NumberValue.I32(3))), + ) + } +} diff --git a/compiler/src/commonMain/kotlin/io/github/charlietap/chasm/compiler/ControlInstructionCompiler.kt b/compiler/src/commonMain/kotlin/io/github/charlietap/chasm/compiler/ControlInstructionCompiler.kt index e3164391a..53ac3ddf9 100644 --- a/compiler/src/commonMain/kotlin/io/github/charlietap/chasm/compiler/ControlInstructionCompiler.kt +++ b/compiler/src/commonMain/kotlin/io/github/charlietap/chasm/compiler/ControlInstructionCompiler.kt @@ -22,8 +22,8 @@ import io.github.charlietap.chasm.compiler.instruction.emitBranchTable import io.github.charlietap.chasm.compiler.instruction.emitCall import io.github.charlietap.chasm.compiler.instruction.emitCallIndirect import io.github.charlietap.chasm.compiler.instruction.emitCallRef +import io.github.charlietap.chasm.compiler.instruction.emitCheckpoint import io.github.charlietap.chasm.compiler.instruction.emitCopies -import io.github.charlietap.chasm.compiler.instruction.emitFuelCheck import io.github.charlietap.chasm.compiler.instruction.emitFunctionReturn import io.github.charlietap.chasm.compiler.instruction.emitJump import io.github.charlietap.chasm.compiler.instruction.emitReturnCallIndirect @@ -204,8 +204,8 @@ private fun enterBlock( } if (kind == BlockKind.Loop) { state.bind(branchTarget) - // At the loop's branch target, so every iteration spends fuel. - state.emitFuelCheck() + // At the loop's branch target, so every iteration passes the checkpoint. + state.emitCheckpoint() } state.controls.push( diff --git a/compiler/src/commonMain/kotlin/io/github/charlietap/chasm/compiler/FunctionCompiler.kt b/compiler/src/commonMain/kotlin/io/github/charlietap/chasm/compiler/FunctionCompiler.kt index 90f29fc02..0fe9d87db 100644 --- a/compiler/src/commonMain/kotlin/io/github/charlietap/chasm/compiler/FunctionCompiler.kt +++ b/compiler/src/commonMain/kotlin/io/github/charlietap/chasm/compiler/FunctionCompiler.kt @@ -21,11 +21,11 @@ import io.github.charlietap.chasm.compiler.context.CompilerContext import io.github.charlietap.chasm.compiler.context.FunctionCompilationContext import io.github.charlietap.chasm.compiler.context.FunctionCompilerWorkspace import io.github.charlietap.chasm.compiler.context.global +import io.github.charlietap.chasm.compiler.instruction.emitCheckpoint import io.github.charlietap.chasm.compiler.instruction.emitCopy import io.github.charlietap.chasm.compiler.instruction.emitDeferredBranchPaths import io.github.charlietap.chasm.compiler.instruction.emitF32Constant import io.github.charlietap.chasm.compiler.instruction.emitF64Constant -import io.github.charlietap.chasm.compiler.instruction.emitFuelCheck import io.github.charlietap.chasm.compiler.instruction.emitGlobalSet import io.github.charlietap.chasm.compiler.instruction.emitI32Constant import io.github.charlietap.chasm.compiler.instruction.emitI64Constant @@ -72,7 +72,7 @@ internal fun FunctionCompiler( program = programBuilder, ) beginFunctionControl(state) - state.emitFuelCheck() + state.emitCheckpoint() var index = 0 while (index < function.body.instructions.size) { diff --git a/compiler/src/commonMain/kotlin/io/github/charlietap/chasm/compiler/context/CompilerContext.kt b/compiler/src/commonMain/kotlin/io/github/charlietap/chasm/compiler/context/CompilerContext.kt index f2da504bc..92702e3be 100644 --- a/compiler/src/commonMain/kotlin/io/github/charlietap/chasm/compiler/context/CompilerContext.kt +++ b/compiler/src/commonMain/kotlin/io/github/charlietap/chasm/compiler/context/CompilerContext.kt @@ -17,6 +17,7 @@ import io.github.charlietap.chasm.runtime.instance.ModuleInstance import io.github.charlietap.chasm.runtime.instance.TableInstance import io.github.charlietap.chasm.runtime.instance.TagInstance import io.github.charlietap.chasm.runtime.store.Fuel +import io.github.charlietap.chasm.runtime.store.Interrupt import io.github.charlietap.chasm.runtime.store.Store import io.github.charlietap.chasm.runtime.type.ModuleTypeResolver import io.github.charlietap.chasm.runtime.type.RuntimeTypeMap @@ -39,6 +40,8 @@ internal class CompilerContext( val data: Array = emptyArray(), /** A metered store's fuel, which the checks emitted at function entries and loop headers take from. */ val fuel: Fuel? = null, + /** An interruptible store's interrupt, which the same checks read. */ + val interrupt: Interrupt? = null, ) { val emptyBlockType: FunctionType = types.blockType(BlockType.Empty) val instructionObserver = diagnostics?.instructionObserver @@ -70,4 +73,5 @@ internal fun createCompilerContext( elements = Array(instance.elemAddresses.size) { index -> store.element(instance.elemAddresses[index]) }, data = Array(instance.dataAddresses.size) { index -> store.data(instance.dataAddresses[index]) }, fuel = store.fuel.takeIf { it.metered }, + interrupt = store.interrupt.takeIf { it.enabled }, ) diff --git a/compiler/src/commonMain/kotlin/io/github/charlietap/chasm/compiler/instruction/AdminInstructionEmitter.kt b/compiler/src/commonMain/kotlin/io/github/charlietap/chasm/compiler/instruction/AdminInstructionEmitter.kt index c737a9a29..11698e70b 100644 --- a/compiler/src/commonMain/kotlin/io/github/charlietap/chasm/compiler/instruction/AdminInstructionEmitter.kt +++ b/compiler/src/commonMain/kotlin/io/github/charlietap/chasm/compiler/instruction/AdminInstructionEmitter.kt @@ -3,7 +3,9 @@ package io.github.charlietap.chasm.compiler.instruction import io.github.charlietap.chasm.compiler.context.FunctionCompilationContext import io.github.charlietap.chasm.executor.invoker.dispatch.admin.CopySlotDispatcher import io.github.charlietap.chasm.executor.invoker.dispatch.admin.CopySlotsDispatcher +import io.github.charlietap.chasm.executor.invoker.dispatch.admin.FuelAndInterruptCheckDispatcher import io.github.charlietap.chasm.executor.invoker.dispatch.admin.FuelCheckDispatcher +import io.github.charlietap.chasm.executor.invoker.dispatch.admin.InterruptCheckDispatcher import io.github.charlietap.chasm.executor.invoker.dispatch.numeric.F32ConstDispatcher import io.github.charlietap.chasm.executor.invoker.dispatch.numeric.F64ConstDispatcher import io.github.charlietap.chasm.executor.invoker.dispatch.numeric.I32ConstDispatcher @@ -65,9 +67,18 @@ internal fun FunctionCompilationContext.emitF64Constant(bits: Long, destinationS emit(instruction, ::F64ConstDispatcher) } -internal fun FunctionCompilationContext.emitFuelCheck() { - val fuel = compiler.fuel ?: return - emit(AdminInstruction.FuelCheck) { - FuelCheckDispatcher(it, fuel) +internal fun FunctionCompilationContext.emitCheckpoint() { + val fuel = compiler.fuel + val interrupt = compiler.interrupt + when { + fuel != null && interrupt != null -> emit(AdminInstruction.FuelAndInterruptCheck) { + FuelAndInterruptCheckDispatcher(it, fuel, interrupt) + } + fuel != null -> emit(AdminInstruction.FuelCheck) { + FuelCheckDispatcher(it, fuel) + } + interrupt != null -> emit(AdminInstruction.InterruptCheck) { + InterruptCheckDispatcher(it, interrupt) + } } } diff --git a/config/api/config.klib.api b/config/api/config.klib.api index 9cfb5af22..8712569e7 100644 --- a/config/api/config.klib.api +++ b/config/api/config.klib.api @@ -182,13 +182,16 @@ final class io.github.charlietap.chasm.config/RuntimeConfig { // io.github.charl } final class io.github.charlietap.chasm.config/StoreConfig { // io.github.charlietap.chasm.config/StoreConfig|null[0] - constructor (kotlin/Boolean = ...) // io.github.charlietap.chasm.config/StoreConfig.|(kotlin.Boolean){}[0] + constructor (kotlin/Boolean = ..., kotlin/Boolean = ...) // io.github.charlietap.chasm.config/StoreConfig.|(kotlin.Boolean;kotlin.Boolean){}[0] + final val interruptible // io.github.charlietap.chasm.config/StoreConfig.interruptible|{}interruptible[0] + final fun (): kotlin/Boolean // io.github.charlietap.chasm.config/StoreConfig.interruptible.|(){}[0] final val meterFuel // io.github.charlietap.chasm.config/StoreConfig.meterFuel|{}meterFuel[0] final fun (): kotlin/Boolean // io.github.charlietap.chasm.config/StoreConfig.meterFuel.|(){}[0] final fun component1(): kotlin/Boolean // io.github.charlietap.chasm.config/StoreConfig.component1|component1(){}[0] - final fun copy(kotlin/Boolean = ...): io.github.charlietap.chasm.config/StoreConfig // io.github.charlietap.chasm.config/StoreConfig.copy|copy(kotlin.Boolean){}[0] + final fun component2(): kotlin/Boolean // io.github.charlietap.chasm.config/StoreConfig.component2|component2(){}[0] + final fun copy(kotlin/Boolean = ..., kotlin/Boolean = ...): io.github.charlietap.chasm.config/StoreConfig // io.github.charlietap.chasm.config/StoreConfig.copy|copy(kotlin.Boolean;kotlin.Boolean){}[0] final fun equals(kotlin/Any?): kotlin/Boolean // io.github.charlietap.chasm.config/StoreConfig.equals|equals(kotlin.Any?){}[0] final fun hashCode(): kotlin/Int // io.github.charlietap.chasm.config/StoreConfig.hashCode|hashCode(){}[0] final fun toString(): kotlin/String // io.github.charlietap.chasm.config/StoreConfig.toString|toString(){}[0] diff --git a/config/api/jvm/config.api b/config/api/jvm/config.api index 443e812f2..bd8d70a80 100644 --- a/config/api/jvm/config.api +++ b/config/api/jvm/config.api @@ -160,12 +160,14 @@ public final class io/github/charlietap/chasm/config/RuntimeConfig { public final class io/github/charlietap/chasm/config/StoreConfig { public fun ()V - public fun (Z)V - public synthetic fun (ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZZ)V + public synthetic fun (ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Z - public final fun copy (Z)Lio/github/charlietap/chasm/config/StoreConfig; - public static synthetic fun copy$default (Lio/github/charlietap/chasm/config/StoreConfig;ZILjava/lang/Object;)Lio/github/charlietap/chasm/config/StoreConfig; + public final fun component2 ()Z + public final fun copy (ZZ)Lio/github/charlietap/chasm/config/StoreConfig; + public static synthetic fun copy$default (Lio/github/charlietap/chasm/config/StoreConfig;ZZILjava/lang/Object;)Lio/github/charlietap/chasm/config/StoreConfig; public fun equals (Ljava/lang/Object;)Z + public final fun getInterruptible ()Z public final fun getMeterFuel ()Z public fun hashCode ()I public fun toString ()Ljava/lang/String; diff --git a/config/src/commonMain/kotlin/io/github/charlietap/chasm/config/StoreConfig.kt b/config/src/commonMain/kotlin/io/github/charlietap/chasm/config/StoreConfig.kt index 761ac60b1..749a79bdd 100644 --- a/config/src/commonMain/kotlin/io/github/charlietap/chasm/config/StoreConfig.kt +++ b/config/src/commonMain/kotlin/io/github/charlietap/chasm/config/StoreConfig.kt @@ -6,7 +6,11 @@ package io.github.charlietap.chasm.config * @property meterFuel compiles a fuel check at every function entry and loop iteration of the * store's code. A metered store starts with no fuel, so add some before running any of its code, * a module's start function included. + * @property interruptible compiles an interrupt check at every function entry and loop iteration + * of the store's code, so another thread can stop a running call. A store with both settings + * makes one combined check at each site. */ data class StoreConfig( val meterFuel: Boolean = false, + val interruptible: Boolean = false, ) diff --git a/executor/invoker/src/commonMain/kotlin/io/github/charlietap/chasm/executor/invoker/FunctionInvoker.kt b/executor/invoker/src/commonMain/kotlin/io/github/charlietap/chasm/executor/invoker/FunctionInvoker.kt index 91028fe7b..7e998cd31 100644 --- a/executor/invoker/src/commonMain/kotlin/io/github/charlietap/chasm/executor/invoker/FunctionInvoker.kt +++ b/executor/invoker/src/commonMain/kotlin/io/github/charlietap/chasm/executor/invoker/FunctionInvoker.kt @@ -81,6 +81,29 @@ internal inline fun FunctionInvoker( crossinline threadExecutor: ThreadExecutor, ): Result, InvocationError> { store.heap.clearPendingException() + val interrupt = store.interrupt + if (!interrupt.enabled) return invokeFunction(config, store, instance, function, values, threadExecutor) + + val depth = interrupt.depth + // Clear before publishing the new depth; see Interrupt.depth. Reading first avoids a volatile write per call. + if (depth == 0 && interrupt.requested) interrupt.requested = false + interrupt.depth = depth + 1 + return try { + invokeFunction(config, store, instance, function, values, threadExecutor) + } finally { + interrupt.depth = depth + } +} + +@OptIn(UnsafeHostApi::class) +private inline fun invokeFunction( + config: RuntimeConfig, + store: Store, + instance: ModuleInstance, + function: FunctionInstance, + values: List, + crossinline threadExecutor: ThreadExecutor, +): Result, InvocationError> { return when (function) { is FunctionInstance.HostFunction -> { val resultCount = function.functionType.results.types.size diff --git a/executor/invoker/src/commonMain/kotlin/io/github/charlietap/chasm/executor/invoker/dispatch/admin/FuelAndInterruptCheckDispatcher.kt b/executor/invoker/src/commonMain/kotlin/io/github/charlietap/chasm/executor/invoker/dispatch/admin/FuelAndInterruptCheckDispatcher.kt new file mode 100644 index 000000000..173cde25b --- /dev/null +++ b/executor/invoker/src/commonMain/kotlin/io/github/charlietap/chasm/executor/invoker/dispatch/admin/FuelAndInterruptCheckDispatcher.kt @@ -0,0 +1,25 @@ +package io.github.charlietap.chasm.executor.invoker.dispatch.admin + +import io.github.charlietap.chasm.runtime.dispatch.DispatchableInstruction +import io.github.charlietap.chasm.runtime.error.InvocationError +import io.github.charlietap.chasm.runtime.exception.InvocationException +import io.github.charlietap.chasm.runtime.instruction.AdminInstruction +import io.github.charlietap.chasm.runtime.store.Fuel +import io.github.charlietap.chasm.runtime.store.Interrupt + +/** [fuel] and [interrupt] are the compiling store's, captured so a check does not look them up on every loop iteration. */ +fun FuelAndInterruptCheckDispatcher( + @Suppress("UNUSED_PARAMETER") instruction: AdminInstruction.FuelAndInterruptCheck, + fuel: Fuel, + interrupt: Interrupt, +): DispatchableInstruction = DispatchableInstruction { _, _, nextIp -> + // An explicit interrupt takes precedence over running out of fuel. + if (interrupt.requested) { + throw InvocationException(InvocationError.Interrupted) + } + if (fuel.remaining <= 0L) { + throw InvocationException(InvocationError.FuelExhausted) + } + fuel.remaining-- + nextIp +} diff --git a/executor/invoker/src/commonMain/kotlin/io/github/charlietap/chasm/executor/invoker/dispatch/admin/InterruptCheckDispatcher.kt b/executor/invoker/src/commonMain/kotlin/io/github/charlietap/chasm/executor/invoker/dispatch/admin/InterruptCheckDispatcher.kt new file mode 100644 index 000000000..11abc5141 --- /dev/null +++ b/executor/invoker/src/commonMain/kotlin/io/github/charlietap/chasm/executor/invoker/dispatch/admin/InterruptCheckDispatcher.kt @@ -0,0 +1,18 @@ +package io.github.charlietap.chasm.executor.invoker.dispatch.admin + +import io.github.charlietap.chasm.runtime.dispatch.DispatchableInstruction +import io.github.charlietap.chasm.runtime.error.InvocationError +import io.github.charlietap.chasm.runtime.exception.InvocationException +import io.github.charlietap.chasm.runtime.instruction.AdminInstruction +import io.github.charlietap.chasm.runtime.store.Interrupt + +/** [interrupt] is the compiling store's, captured so a check does not look it up on every loop iteration. */ +fun InterruptCheckDispatcher( + @Suppress("UNUSED_PARAMETER") instruction: AdminInstruction.InterruptCheck, + interrupt: Interrupt, +): DispatchableInstruction = DispatchableInstruction { _, _, nextIp -> + if (interrupt.requested) { + throw InvocationException(InvocationError.Interrupted) + } + nextIp +} diff --git a/runtime/core/src/commonMain/kotlin/io/github/charlietap/chasm/runtime/error/InvocationError.kt b/runtime/core/src/commonMain/kotlin/io/github/charlietap/chasm/runtime/error/InvocationError.kt index c96bda0a4..626e9fe0e 100644 --- a/runtime/core/src/commonMain/kotlin/io/github/charlietap/chasm/runtime/error/InvocationError.kt +++ b/runtime/core/src/commonMain/kotlin/io/github/charlietap/chasm/runtime/error/InvocationError.kt @@ -172,6 +172,8 @@ sealed interface InvocationError : ModuleTrapError { data object FuelExhausted : InvocationError + data object Interrupted : InvocationError + @JvmInline value class UnimplementedInstruction(val instruction: LinkedInstruction) : InvocationError diff --git a/runtime/core/src/commonMain/kotlin/io/github/charlietap/chasm/runtime/instruction/AdminInstruction.kt b/runtime/core/src/commonMain/kotlin/io/github/charlietap/chasm/runtime/instruction/AdminInstruction.kt index 79ffed925..d98901099 100644 --- a/runtime/core/src/commonMain/kotlin/io/github/charlietap/chasm/runtime/instruction/AdminInstruction.kt +++ b/runtime/core/src/commonMain/kotlin/io/github/charlietap/chasm/runtime/instruction/AdminInstruction.kt @@ -24,6 +24,12 @@ sealed interface AdminInstruction : LinkedInstruction { /** Takes a unit of the store's [io.github.charlietap.chasm.runtime.store.Fuel]; emitted only for metered stores. */ data object FuelCheck : AdminInstruction + /** Traps if the store's [io.github.charlietap.chasm.runtime.store.Interrupt] is requested; emitted only for interruptible stores. */ + data object InterruptCheck : AdminInstruction + + /** A [FuelCheck] and an [InterruptCheck] in one dispatch, for stores that are both metered and interruptible. */ + data object FuelAndInterruptCheck : AdminInstruction + data class JumpCopies( val operands: OperandTransfer, val destinationSlotBase: Int, diff --git a/runtime/core/src/commonMain/kotlin/io/github/charlietap/chasm/runtime/store/Interrupt.kt b/runtime/core/src/commonMain/kotlin/io/github/charlietap/chasm/runtime/store/Interrupt.kt new file mode 100644 index 000000000..e84d3921c --- /dev/null +++ b/runtime/core/src/commonMain/kotlin/io/github/charlietap/chasm/runtime/store/Interrupt.kt @@ -0,0 +1,27 @@ +package io.github.charlietap.chasm.runtime.store + +import kotlin.concurrent.Volatile + +/** + * A request to stop the code a store runs. When [enabled], code compiled into the store checks + * [requested] at every function entry and loop iteration, and traps with + * [io.github.charlietap.chasm.runtime.error.InvocationError.Interrupted] once it is set. Stores + * without it compile no checks and pay nothing. + */ +class Interrupt(val enabled: Boolean = false) { + /** + * May be set from any thread. Stays set until the next outermost call starts, so a call made + * back into the store from a host function cannot swallow an interrupt meant for its caller. + */ + @Volatile + var requested: Boolean = false + + /** + * Calls running on the store, a host function's calls back into it included. Written only by + * the thread running them, and read by other threads to tell whether an interrupt will land. + * An outermost call clears [requested] before raising this, so an interrupt that sees a + * running call is never cleared by that call's start. + */ + @Volatile + var depth: Int = 0 +} diff --git a/runtime/core/src/commonMain/kotlin/io/github/charlietap/chasm/runtime/store/Store.kt b/runtime/core/src/commonMain/kotlin/io/github/charlietap/chasm/runtime/store/Store.kt index 22b0bd5e0..3a1fef6a9 100644 --- a/runtime/core/src/commonMain/kotlin/io/github/charlietap/chasm/runtime/store/Store.kt +++ b/runtime/core/src/commonMain/kotlin/io/github/charlietap/chasm/runtime/store/Store.kt @@ -19,4 +19,5 @@ class Store( val program: Program = Program(), val heap: WasmHeap = WasmHeap(), val fuel: Fuel = Fuel(), + val interrupt: Interrupt = Interrupt(), ) diff --git a/tools/compiler-baseline/src/main/kotlin/io/github/charlietap/chasm/tools/compilerbaseline/CompilerInstructionTagTranslator.kt b/tools/compiler-baseline/src/main/kotlin/io/github/charlietap/chasm/tools/compilerbaseline/CompilerInstructionTagTranslator.kt index 1931d64fd..e473d5b28 100644 --- a/tools/compiler-baseline/src/main/kotlin/io/github/charlietap/chasm/tools/compilerbaseline/CompilerInstructionTagTranslator.kt +++ b/tools/compiler-baseline/src/main/kotlin/io/github/charlietap/chasm/tools/compilerbaseline/CompilerInstructionTagTranslator.kt @@ -54,6 +54,8 @@ class CompilerInstructionTagTranslator { is AdminInstruction.CopySlot -> "admin.copy_slot" is AdminInstruction.CopySlots -> "admin.copy_slots" AdminInstruction.FuelCheck -> "admin.fuel_check" + AdminInstruction.InterruptCheck -> "admin.interrupt_check" + AdminInstruction.FuelAndInterruptCheck -> "admin.fuel_and_interrupt_check" is AdminInstruction.Jump -> "admin.jump" is AdminInstruction.JumpCopies -> "admin.jump_copies" is AdminInstruction.JumpIfI -> "admin.jump_if.i"