diff --git a/wasm-core/test/wasm/core/GcEhExecutionTest.kt b/wasm-core/test/wasm/core/GcEhExecutionTest.kt index df3b34b..3b3f90d 100644 --- a/wasm-core/test/wasm/core/GcEhExecutionTest.kt +++ b/wasm-core/test/wasm/core/GcEhExecutionTest.kt @@ -354,6 +354,43 @@ class GcEhExecutionTest { assertEquals(hostFailure, escaped) } + @Test + fun tryTableCatchAllCannotCatchHostExceptions(): Unit = runBlocking { + val hostFailure = IllegalStateException("host bug") + val module = validatedModule { + types += FuncType(emptyList(), emptyList()) + imports += Import("host", "fail", ImportDesc.Function(0)) + functions += Function( + 0, + emptyList(), + listOf( + TryTable( + blockType = BlockType.Empty, + catches = listOf(CatchClause.All(depth = 0, withReference = false)), + body = listOf(Call(0)), + ), + ), + ) + exports += Export("run", ExportDesc.Function(1)) + } + val instance = Instance( + Store(), + module, + ResolvedImports( + functions = listOf( + HostImport(FuncType(emptyList(), emptyList())) { + throw hostFailure + }, + ), + ), + ) + + val escaped = assertFailsWith { + instance.invoke("run") + } + assertEquals(hostFailure, escaped) + } + private fun exceptionModule(body: (Int) -> Instr): Module = validatedModule { types += FuncType(listOf(ValType.I32), emptyList()) types += FuncType(emptyList(), listOf(ValType.I32)) diff --git a/wasm-core/test/wasm/core/SuspensionSemanticsTest.kt b/wasm-core/test/wasm/core/SuspensionSemanticsTest.kt index 5bde536..d377b68 100644 --- a/wasm-core/test/wasm/core/SuspensionSemanticsTest.kt +++ b/wasm-core/test/wasm/core/SuspensionSemanticsTest.kt @@ -2,14 +2,23 @@ package io.heapy.kwasm import io.heapy.kwasm.Instr.Br import io.heapy.kwasm.Instr.Call +import io.heapy.kwasm.Instr.FcIndex +import io.heapy.kwasm.Instr.I32Const +import io.heapy.kwasm.Instr.If import io.heapy.kwasm.Instr.Loop import io.heapy.kwasm.Instr.Nop +import io.heapy.kwasm.Instr.Simple +import io.heapy.kwasm.Instr.TryTable import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -52,6 +61,116 @@ class SuspensionSemanticsTest { ) } + @Test + fun withTimeoutCancelsDeepRecursionAndPoisonsTheStore(): Unit = runBlocking { + val store = Store() + val instance = Instance(store, fibModule(), ResolvedImports()) + + assertFailsWith { + withTimeout(50) { instance.invoke("fib", listOf(Value.I32(40))) } + } + assertTrue(store.poisoned, "deep-recursion cancellation must poison the store") + assertEquals(StoreStatus.Poisoned, store.status.value) + assertFailsWith { + instance.invoke("fib", listOf(Value.I32(1))) + } + } + + @Test + fun cancellationWhileParkedWaitingForFuelPoisonsTheStore(): Unit = runBlocking { + val store = Store( + StoreConfig( + fuelEnabled = true, + initialFuel = 0, + fuelExhaustionPolicy = FuelExhaustionPolicy.Suspend, + ), + ) + val instance = Instance(store, constantModule(), ResolvedImports()) + + val invocation = async { instance.invoke("value") } + store.status.first { it == StoreStatus.WaitingForFuel } + invocation.cancel(CancellationException("cancelled while waiting for fuel")) + + assertFailsWith { invocation.await() } + assertTrue(store.poisoned, "fuel-parked cancellation must poison the store") + assertEquals(StoreStatus.Poisoned, store.status.value) + assertFailsWith { instance.invoke("value") } + } + + @Test + fun cancellationWhileExplicitlyPausedPoisonsTheStore(): Unit = runBlocking { + val store = Store() + val instance = Instance(store, constantModule(), ResolvedImports()) + val pause = store.requestPause() + val invocation = async { instance.invoke("value") } + + pause.awaitPaused() + assertEquals(StoreStatus.Paused, store.status.value) + invocation.cancel(CancellationException("cancelled while paused")) + + assertFailsWith { invocation.await() } + assertTrue(store.poisoned, "pause-parked cancellation must poison the store") + assertEquals(StoreStatus.Poisoned, store.status.value) + assertFailsWith { instance.invoke("value") } + } + + @Test + fun cancellationWhileParkedInHostImportPoisonsTheStore(): Unit = runBlocking { + val gate = CompletableDeferred() + val type = FuncType(emptyList(), listOf(ValType.I32)) + val store = Store() + val instance = Instance( + store, + importedFunctionModule(type), + ResolvedImports( + functions = listOf( + HostImport(type) { + listOf(Value.I32(gate.await())) + }, + ), + ), + ) + + val invocation = async { instance.invoke("host") } + store.status.first { it == StoreStatus.InHostImport } + invocation.cancel(CancellationException("cancelled inside a suspended host import")) + + assertFailsWith { invocation.await() } + assertTrue( + store.poisoned, + "cancellation inside a parked host import must poison the store", + ) + assertEquals(StoreStatus.Poisoned, store.status.value) + assertFailsWith { instance.invoke("host") } + } + + @Test + fun cancellationBypassesGuestCatchAllHandlers(): Unit = runBlocking { + val module = validatedModule { + types += FuncType(emptyList(), emptyList()) + functions += Function( + 0, + emptyList(), + listOf( + TryTable( + blockType = BlockType.Empty, + catches = listOf(CatchClause.All(depth = 0, withReference = false)), + body = listOf(Loop(BlockType.Empty, listOf(Br(0)))), + ), + ), + ) + exports += Export("spin", ExportDesc.Function(0)) + } + val store = Store(StoreConfig(checkpointInterval = 1)) + val instance = Instance(store, module, ResolvedImports()) + + assertFailsWith { + withTimeout(50) { instance.invoke("spin") } + } + assertTrue(store.poisoned, "cancellation must bypass guest catch_all handlers") + assertEquals(StoreStatus.Poisoned, store.status.value) + } + private suspend fun assertCancellationAtNextCheckpoint( checkpointClass: String, checkpointInterval: Int, @@ -98,6 +217,47 @@ class SuspensionSemanticsTest { assertEquals(StoreStatus.Poisoned, store.status.value) } + private fun fibModule(): Module = validatedModule { + types += FuncType(listOf(ValType.I32), listOf(ValType.I32)) + functions += Function( + typeIndex = 0, + locals = emptyList(), + body = listOf( + FcIndex(0x20, 0), + I32Const(2), + Simple(0x48), + If( + BlockType.Single(ValType.I32), + thenBody = listOf(FcIndex(0x20, 0)), + elseBody = listOf( + FcIndex(0x20, 0), + I32Const(1), + Simple(0x6B), + Call(0), + FcIndex(0x20, 0), + I32Const(2), + Simple(0x6B), + Call(0), + Simple(0x6A), + ), + ), + ), + ) + exports += Export("fib", ExportDesc.Function(0)) + } + + private fun constantModule(): Module = validatedModule { + types += FuncType(emptyList(), listOf(ValType.I32)) + functions += Function(0, emptyList(), listOf(I32Const(7))) + exports += Export("value", ExportDesc.Function(0)) + } + + private fun importedFunctionModule(type: FuncType): Module = validatedModule { + types += type + imports += Import("host", "function", ImportDesc.Function(0)) + exports += Export("host", ExportDesc.Function(0)) + } + private fun validatedModule(configure: ModuleBuilder.() -> Unit): Module = ModuleBuilder() .apply(configure)