From c2b8b0ca059df748e953d3caf8492ce960ff416c Mon Sep 17 00:00:00 2001 From: Wavesonics Date: Sat, 26 Sep 2026 00:21:08 -0700 Subject: [PATCH] runtime: interrupt running calls in interruptible stores An embedder had no way to stop a call from outside it. Fuel bounds how long a call runs, but a watchdog thread or a host-side cancel cannot end a call early without it. StoreConfig(interruptible = true) compiles a check at each function entry and loop branch target, the same sites fuel uses. interrupt() sets a volatile flag from any thread, and the running call traps with Interrupted at its next check. A store that is also metered emits one combined check per site rather than two, which reports an interrupt ahead of running out of fuel. A store with neither setting emits none. FunctionInvoker tracks the calls running on the store, so host functions invoked directly are covered as well as Wasm functions. The flag is cleared only when an outermost call starts, and before that call is published as running. interrupt() returns whether a call was running to receive it, so an interrupt made between calls is reported as dropped rather than lost silently. Calls made back into the store from a host function never clear it, so an interrupt raised during one also stops its caller. CoreMark on the JVM (Ryzen 9 8945HS), 5 alternating single runs per store configuration, median score with range: plain 993.5 (955.9 to 1033.5) fuel 947.9 (922.4 to 972.7) -4.6% interrupt 953.9 (943.7 to 981.8) -4.0% fuel+interrupt 926.9 (919.2 to 962.4) -6.7% The ranges overlap, so treat these as approximate. The coremark task takes the store configuration as an argument to reproduce them. --- .../benchmark/coremark/CoremarkBenchmark.kt | 19 +- chasm/api/chasm.klib.api | 1 + chasm/api/jvm/chasm.api | 4 + .../charlietap/chasm/embedding/Interrupt.kt | 25 +++ .../charlietap/chasm/embedding/Store.kt | 8 +- .../chasm/integration/InterruptTest.kt | 173 ++++++++++++++++++ .../resources/integration/interrupt.wasm | Bin 0 -> 241 bytes .../resources/integration/interrupt.wat | 29 +++ .../chasm/integration/InterruptThreadTest.kt | 56 ++++++ .../compiler/ControlInstructionCompiler.kt | 6 +- .../chasm/compiler/FunctionCompiler.kt | 4 +- .../chasm/compiler/context/CompilerContext.kt | 4 + .../instruction/AdminInstructionEmitter.kt | 19 +- config/api/config.klib.api | 7 +- config/api/jvm/config.api | 10 +- .../charlietap/chasm/config/StoreConfig.kt | 4 + .../chasm/executor/invoker/FunctionInvoker.kt | 23 +++ .../admin/FuelAndInterruptCheckDispatcher.kt | 25 +++ .../admin/InterruptCheckDispatcher.kt | 18 ++ .../chasm/runtime/error/InvocationError.kt | 2 + .../runtime/instruction/AdminInstruction.kt | 6 + .../chasm/runtime/store/Interrupt.kt | 27 +++ .../charlietap/chasm/runtime/store/Store.kt | 1 + .../CompilerInstructionTagTranslator.kt | 2 + 24 files changed, 453 insertions(+), 20 deletions(-) create mode 100644 chasm/src/commonMain/kotlin/io/github/charlietap/chasm/embedding/Interrupt.kt create mode 100644 chasm/src/commonTest/kotlin/io/github/charlietap/chasm/integration/InterruptTest.kt create mode 100644 chasm/src/commonTest/resources/integration/interrupt.wasm create mode 100644 chasm/src/commonTest/resources/integration/interrupt.wat create mode 100644 chasm/src/jvmTest/kotlin/io/github/charlietap/chasm/integration/InterruptThreadTest.kt create mode 100644 executor/invoker/src/commonMain/kotlin/io/github/charlietap/chasm/executor/invoker/dispatch/admin/FuelAndInterruptCheckDispatcher.kt create mode 100644 executor/invoker/src/commonMain/kotlin/io/github/charlietap/chasm/executor/invoker/dispatch/admin/InterruptCheckDispatcher.kt create mode 100644 runtime/core/src/commonMain/kotlin/io/github/charlietap/chasm/runtime/store/Interrupt.kt 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 0000000000000000000000000000000000000000..b70719faf65fb0210eb387f09536b907a2a16afb GIT binary patch literal 241 zcmYj~K@Ng26h!A!3L=mgOkCp`+_}P&6eNu_U?EUs$<-|y#ND6wCi77YPXa(O%Yhs; z=QPvoTo{x36)0_F-;bREmb@#Arnwho{f$vAWheSkYo#9`=5f0O#~0pKBzNeP20Q+y zgsG$!AL=IS;2-3t#fhm>jTBWo7-65b(*Tc`?Whg>{gwM*S%feQaM&(d8Osh%Y;9s) NiW}8uq_7}G_y*!8IKTh^ literal 0 HcmV?d00001 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"