Files
wazero/tests/engine/hammer_test.go
Crypt Keeper 1e20fe3bfe Extracts hammer.Hammer and adds atomic release of goroutines (#416)
This extracts `hammer.Hammer` as a utility for re-use, notably adding a
feature that ensures all tests run concurrently. Before, tests start in
a loop that could be delayed due to goroutine sheduling.

Signed-off-by: Adrian Cole <adrian@tetrate.io>
2022-03-30 10:18:16 +08:00

226 lines
6.6 KiB
Go

package adhoc
import (
"errors"
"runtime"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/wasm"
)
var hammers = map[string]func(t *testing.T, r wazero.Runtime){
// Tests here are similar to what's described in /RATIONALE.md, but deviate as they involve blocking functions.
"close importing module while in use": closeImportingModuleWhileInUse,
"close imported module while in use": closeImportedModuleWhileInUse,
}
func TestEngineJIT_hammer(t *testing.T) {
if !wazero.JITSupported {
t.Skip()
}
runAllTests(t, hammers, wazero.NewRuntimeConfigJIT())
}
func TestEngineInterpreter_hammer(t *testing.T) {
runAllTests(t, hammers, wazero.NewRuntimeConfigInterpreter())
}
type blocker struct {
running chan bool
// unblocked uses a WaitGroup as it allows releasing all goroutines at the same time.
unblocked sync.WaitGroup
// closed should panic if fn is called when the value is 1.
//
// Note: Exclusively reading and updating this with atomics guarantees cross-goroutine observations.
// See /RATIONALE.md
closed uint32
}
func (b *blocker) close() {
atomic.StoreUint32(&b.closed, 1)
}
// fn sends into the running channel, then blocks until it can receive from unblocked one.
func (b *blocker) fn(input uint32) uint32 {
if atomic.LoadUint32(&b.closed) == 1 {
panic(errors.New("closed"))
}
b.running <- true // Signal the goroutine is running
b.unblocked.Wait() // Await until unblocked
return input
}
var blockAfterAddSource = []byte(`(module
(import "host" "block" (func $block (param i32) (result i32)))
(func $block_after_add (param i32) (param i32) (result i32)
local.get 0
local.get 1
i32.add
call $block
)
(export "block_after_add" (func $block_after_add))
)`)
func closeImportingModuleWhileInUse(t *testing.T, r wazero.Runtime) {
args := []uint64{1, 123}
exp := args[0] + args[1]
P := 8 // P+1 == max count of goroutines/in-flight function calls
if testing.Short() { // Adjust down if `-test.short`
P = 4
}
defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(P / 2)) // Ensure goroutines have to switch cores.
running := make(chan bool)
b := &blocker{running: running}
b.unblocked.Add(P)
imported, err := r.NewModuleBuilder("host").ExportFunction("block", b.fn).Instantiate()
require.NoError(t, err)
defer imported.Close()
importing, err := r.InstantiateModuleFromSource(blockAfterAddSource)
require.NoError(t, err)
defer importing.Close()
// Add channel that tracks P goroutines.
done := make(chan int)
for p := 0; p < P; p++ {
go func() { // Launch goroutine 'p'
defer completeGoroutine(t, done)
// As this is a blocking function, we can only run 1 function per goroutine.
requireFunctionCall(t, importing.ExportedFunction("block_after_add"), args, exp)
}()
}
// Wait until all functions are in-flight.
for i := 0; i < P; i++ {
<-running
}
// Close the module that exported blockAfterAdd (noting calls to this are in-flight).
require.NoError(t, importing.Close())
// Prove a module can be redefined even with in-flight calls.
importing, err = r.InstantiateModuleFromSource(blockAfterAddSource)
require.NoError(t, err)
defer importing.Close()
// If unloading worked properly, a new function call should route to the newly instantiated module.
b.unblocked.Add(1)
go func() {
defer completeGoroutine(t, done)
requireFunctionCall(t, importing.ExportedFunction("block_after_add"), args, exp)
}()
<-running // Wait for the above function to be in-flight
P++
// Unblock the functions to ensure they don't err on the return path of a closed module.
b.unblocked.Add(-P)
// Wait for all goroutines to complete
for i := 0; i < P; i++ {
<-done
}
}
func closeImportedModuleWhileInUse(t *testing.T, r wazero.Runtime) {
args := []uint64{1, 123}
exp := args[0] + args[1]
P := 8 // P+1 == max count of goroutines/in-flight function calls
if testing.Short() { // Adjust down if `-test.short`
P = 4
}
defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(P / 2)) // Ensure goroutines have to switch cores.
running := make(chan bool)
b := &blocker{running: running}
b.unblocked.Add(P)
imported, err := r.NewModuleBuilder("host").ExportFunction("block", b.fn).Instantiate()
require.NoError(t, err)
defer imported.Close()
importing, err := r.InstantiateModuleFromSource(blockAfterAddSource)
require.NoError(t, err)
defer importing.Close()
// Add channel that tracks P goroutines.
done := make(chan int)
for p := 0; p < P; p++ {
go func() { // Launch goroutine 'p'
defer completeGoroutine(t, done)
// As this is a blocking function, we can only run 1 function per goroutine.
requireFunctionCall(t, importing.ExportedFunction("block_after_add"), args, exp)
}()
}
// Wait until all functions are in-flight.
for i := 0; i < P; i++ {
<-running
}
// Close the module that exported the host function (noting calls to this are in-flight).
require.NoError(t, imported.Close())
// Close the underlying host function, which causes future calls to it to fail.
b.close()
require.Panics(t, func() {
b.fn(1) // validate it would fail if accidentally called
})
// Close the importing module
require.NoError(t, importing.Close())
// Prove a host module can be redefined even with in-flight calls.
b1 := &blocker{running: running} // New instance, so not yet closed!
imported, err = r.NewModuleBuilder("host").ExportFunction("block", b1.fn).Instantiate()
require.NoError(t, err)
defer imported.Close()
// Redefine the importing module, which should link to the redefined host module.
importing, err = r.InstantiateModuleFromSource(blockAfterAddSource)
require.NoError(t, err)
defer importing.Close()
// If unloading worked properly, a new function call should route to the newly instantiated module.
b1.unblocked.Add(1)
go func() {
defer completeGoroutine(t, done)
requireFunctionCall(t, importing.ExportedFunction("block_after_add"), args, exp)
}()
<-running // Wait for the above function to be in-flight
// Unblock the functions to ensure they don't err on the return path of a closed module.
b.unblocked.Add(-P)
b1.unblocked.Add(-1)
// Wait for all goroutines to complete
for i := 0; i < P+1; i++ {
<-done
}
}
func requireFunctionCall(t *testing.T, fn wasm.Function, args []uint64, exp uint64) {
res, err := fn.Call(nil, args...)
// We don't expect an error because there's currently no functionality to detect or fail on a closed module.
require.NoError(t, err)
require.Equal(t, exp, res[0])
}
func completeGoroutine(t *testing.T, c chan int) {
// Ensure each require.XX failure is visible on hammer test fail.
if recovered := recover(); recovered != nil {
t.Error(recovered.(string))
}
c <- 1
}