Files
wazero/imports/wasi_snapshot_preview1/wasi_stdlib_test.go
2023-07-06 16:19:24 +08:00

516 lines
14 KiB
Go

package wasi_snapshot_preview1_test
import (
"bytes"
"context"
_ "embed"
"io"
"net"
"net/http"
"os"
"path"
"runtime"
"sort"
"strconv"
"strings"
"testing"
gofstest "testing/fstest"
"time"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
experimentalsock "github.com/tetratelabs/wazero/experimental/sock"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
"github.com/tetratelabs/wazero/internal/fsapi"
"github.com/tetratelabs/wazero/internal/fstest"
internalsys "github.com/tetratelabs/wazero/internal/sys"
"github.com/tetratelabs/wazero/internal/testing/require"
"github.com/tetratelabs/wazero/sys"
)
// sleepALittle directly slows down test execution. So, use this sparingly and
// only when so where proper signals are unavailable.
var sleepALittle = func() { time.Sleep(500 * time.Millisecond) }
// This file ensures that the behavior we've implemented not only the wasi
// spec, but also at least two compilers use of sdks.
// wasmCargoWasi was compiled from testdata/cargo-wasi/wasi.rs
//
//go:embed testdata/cargo-wasi/wasi.wasm
var wasmCargoWasi []byte
// wasmGotip is conditionally compiled from testdata/gotip/wasi.go
var wasmGotip []byte
// wasmTinyGo was compiled from testdata/tinygo/wasi.go
//
//go:embed testdata/tinygo/wasi.wasm
var wasmTinyGo []byte
// wasmZigCc was compiled from testdata/zig-cc/wasi.c
//
//go:embed testdata/zig-cc/wasi.wasm
var wasmZigCc []byte
// wasmZig was compiled from testdata/zig/wasi.c
//
//go:embed testdata/zig/wasi.wasm
var wasmZig []byte
func Test_fdReaddir_ls(t *testing.T) {
toolchains := map[string][]byte{
"cargo-wasi": wasmCargoWasi,
"tinygo": wasmTinyGo,
"zig-cc": wasmZigCc,
"zig": wasmZig,
}
if wasmGotip != nil {
toolchains["gotip"] = wasmGotip
}
tmpDir := t.TempDir()
require.NoError(t, fstest.WriteTestFiles(tmpDir))
tons := path.Join(tmpDir, "tons")
require.NoError(t, os.Mkdir(tons, 0o0777))
for i := 0; i < direntCountTons; i++ {
require.NoError(t, os.WriteFile(path.Join(tons, strconv.Itoa(i)), nil, 0o0666))
}
for toolchain, bin := range toolchains {
toolchain := toolchain
bin := bin
t.Run(toolchain, func(t *testing.T) {
var expectDots int
if toolchain == "zig-cc" {
expectDots = 1
}
testFdReaddirLs(t, bin, toolchain, tmpDir, expectDots)
})
}
}
const direntCountTons = 8096
func testFdReaddirLs(t *testing.T, bin []byte, toolchain, rootDir string, expectDots int) {
t.Helper()
moduleConfig := wazero.NewModuleConfig().
WithFSConfig(wazero.NewFSConfig().
WithReadOnlyDirMount(path.Join(rootDir, "dir"), "/"))
t.Run("empty directory", func(t *testing.T) {
console := compileAndRun(t, testCtx, moduleConfig.WithArgs("wasi", "ls", "./a-"), bin)
requireLsOut(t, nil, expectDots, console)
})
t.Run("not a directory", func(t *testing.T) {
console := compileAndRun(t, testCtx, moduleConfig.WithArgs("wasi", "ls", "-"), bin)
require.Equal(t, `
ENOTDIR
`, "\n"+console)
})
t.Run("directory with entries", func(t *testing.T) {
console := compileAndRun(t, testCtx, moduleConfig.WithArgs("wasi", "ls", "."), bin)
requireLsOut(t, []string{
"./-",
"./a-",
"./ab-",
}, expectDots, console)
})
t.Run("directory with entries - read twice", func(t *testing.T) {
if toolchain == "tinygo" {
t.Skip("https://github.com/tinygo-org/tinygo/issues/3823")
}
console := compileAndRun(t, testCtx, moduleConfig.WithArgs("wasi", "ls", ".", "repeat"), bin)
requireLsOut(t, []string{
"./-",
"./a-",
"./ab-",
"./-",
"./a-",
"./ab-",
}, expectDots*2, console)
})
t.Run("directory with tons of entries", func(t *testing.T) {
moduleConfig = wazero.NewModuleConfig().
WithFSConfig(wazero.NewFSConfig().
WithReadOnlyDirMount(path.Join(rootDir, "tons"), "/")).
WithArgs("wasi", "ls", ".")
console := compileAndRun(t, testCtx, moduleConfig, bin)
lines := strings.Split(console, "\n")
expected := direntCountTons + 1 /* trailing newline */
expected += expectDots * 2
require.Equal(t, expected, len(lines))
})
}
func requireLsOut(t *testing.T, expected []string, expectDots int, console string) {
for i := 0; i < expectDots; i++ {
expected = append(expected, "./.", "./..")
}
actual := strings.Split(console, "\n")
sort.Strings(actual) // os directories are not lexicographic order
actual = actual[1:] // trailing newline
sort.Strings(expected)
if len(actual) == 0 {
require.Nil(t, expected)
} else {
require.Equal(t, expected, actual)
}
}
func Test_fdReaddir_stat(t *testing.T) {
toolchains := map[string][]byte{
"cargo-wasi": wasmCargoWasi,
"tinygo": wasmTinyGo,
"zig-cc": wasmZigCc,
"zig": wasmZig,
}
if wasmGotip != nil {
toolchains["gotip"] = wasmGotip
}
for toolchain, bin := range toolchains {
toolchain := toolchain
bin := bin
t.Run(toolchain, func(t *testing.T) {
testFdReaddirStat(t, bin)
})
}
}
func testFdReaddirStat(t *testing.T, bin []byte) {
moduleConfig := wazero.NewModuleConfig().WithArgs("wasi", "stat")
console := compileAndRun(t, testCtx, moduleConfig.WithFS(gofstest.MapFS{}), bin)
// TODO: switch this to a real stat test
require.Equal(t, `
stdin isatty: false
stdout isatty: false
stderr isatty: false
/ isatty: false
`, "\n"+console)
}
func Test_preopen(t *testing.T) {
for toolchain, bin := range map[string][]byte{
"zig": wasmZig,
} {
toolchain := toolchain
bin := bin
t.Run(toolchain, func(t *testing.T) {
testPreopen(t, bin)
})
}
}
func testPreopen(t *testing.T, bin []byte) {
moduleConfig := wazero.NewModuleConfig().WithArgs("wasi", "preopen")
console := compileAndRun(t, testCtx, moduleConfig.
WithFSConfig(wazero.NewFSConfig().
WithDirMount(".", "/").
WithFSMount(gofstest.MapFS{}, "/tmp")), bin)
require.Equal(t, `
0: stdin
1: stdout
2: stderr
3: /
4: /tmp
`, "\n"+console)
}
func compileAndRun(t *testing.T, ctx context.Context, config wazero.ModuleConfig, bin []byte) (console string) {
return compileAndRunWithPreStart(t, ctx, config, bin, nil)
}
func compileAndRunWithPreStart(t *testing.T, ctx context.Context, config wazero.ModuleConfig, bin []byte, preStart func(t *testing.T, mod api.Module)) (console string) {
// same for console and stderr as sometimes the stack trace is in one or the other.
var consoleBuf bytes.Buffer
r := wazero.NewRuntime(ctx)
defer r.Close(ctx)
_, err := wasi_snapshot_preview1.Instantiate(ctx, r)
require.NoError(t, err)
mod, err := r.InstantiateWithConfig(ctx, bin, config.
WithStdout(&consoleBuf).
WithStderr(&consoleBuf).
WithStartFunctions()) // clear
require.NoError(t, err)
if preStart != nil {
preStart(t, mod)
}
_, err = mod.ExportedFunction("_start").Call(ctx)
if exitErr, ok := err.(*sys.ExitError); ok {
require.Zero(t, exitErr.ExitCode(), consoleBuf.String())
} else {
require.NoError(t, err, consoleBuf.String())
}
console = consoleBuf.String()
return
}
func Test_Poll(t *testing.T) {
// The following test cases replace Stdin with a custom reader.
// For more precise coverage, see poll_test.go.
tests := []struct {
name string
args []string
stdin fsapi.File
expectedOutput string
expectedTimeout time.Duration
}{
{
name: "custom reader, data ready, not tty",
args: []string{"wasi", "poll"},
stdin: &internalsys.StdinFile{Reader: strings.NewReader("test")},
expectedOutput: "STDIN",
expectedTimeout: 0 * time.Millisecond,
},
{
name: "custom reader, data ready, not tty, .5sec",
args: []string{"wasi", "poll", "0", "500"},
stdin: &internalsys.StdinFile{Reader: strings.NewReader("test")},
expectedOutput: "STDIN",
expectedTimeout: 0 * time.Millisecond,
},
{
name: "custom reader, data ready, tty, .5sec",
args: []string{"wasi", "poll", "0", "500"},
stdin: &ttyStdinFile{StdinFile: internalsys.StdinFile{Reader: strings.NewReader("test")}},
expectedOutput: "STDIN",
expectedTimeout: 0 * time.Millisecond,
},
{
name: "custom, blocking reader, no data, tty, .5sec",
args: []string{"wasi", "poll", "0", "500"},
stdin: &neverReadyTtyStdinFile{StdinFile: internalsys.StdinFile{Reader: newBlockingReader(t)}},
expectedOutput: "NOINPUT",
expectedTimeout: 500 * time.Millisecond, // always timeouts
},
{
name: "eofReader, not tty, .5sec",
args: []string{"wasi", "poll", "0", "500"},
stdin: &ttyStdinFile{StdinFile: internalsys.StdinFile{Reader: eofReader{}}},
expectedOutput: "STDIN",
expectedTimeout: 0 * time.Millisecond,
},
}
for _, tt := range tests {
tc := tt
t.Run(tc.name, func(t *testing.T) {
start := time.Now()
console := compileAndRunWithPreStart(t, testCtx, wazero.NewModuleConfig().WithArgs(tc.args...), wasmZigCc,
func(t *testing.T, mod api.Module) {
setStdin(t, mod, tc.stdin)
})
elapsed := time.Since(start)
require.True(t, elapsed >= tc.expectedTimeout)
require.Equal(t, tc.expectedOutput+"\n", console)
})
}
}
// eofReader is safer than reading from os.DevNull as it can never overrun operating system file descriptors.
type eofReader struct{}
// Read implements io.Reader
// Note: This doesn't use a pointer reference as it has no state and an empty struct doesn't allocate.
func (eofReader) Read([]byte) (int, error) {
return 0, io.EOF
}
func Test_Sleep(t *testing.T) {
moduleConfig := wazero.NewModuleConfig().WithArgs("wasi", "sleepmillis", "100").WithSysNanosleep()
start := time.Now()
console := compileAndRun(t, testCtx, moduleConfig, wasmZigCc)
require.True(t, time.Since(start) >= 100*time.Millisecond)
require.Equal(t, "OK\n", console)
}
func Test_Open(t *testing.T) {
for toolchain, bin := range map[string][]byte{
"zig-cc": wasmZigCc,
} {
toolchain := toolchain
bin := bin
t.Run(toolchain, func(t *testing.T) {
testOpenReadOnly(t, bin)
testOpenWriteOnly(t, bin)
})
}
}
func testOpenReadOnly(t *testing.T, bin []byte) {
testOpen(t, "rdonly", bin)
}
func testOpenWriteOnly(t *testing.T, bin []byte) {
testOpen(t, "wronly", bin)
}
func testOpen(t *testing.T, cmd string, bin []byte) {
t.Run(cmd, func(t *testing.T) {
moduleConfig := wazero.NewModuleConfig().
WithArgs("wasi", "open-"+cmd).
WithFSConfig(wazero.NewFSConfig().WithDirMount(t.TempDir(), "/"))
console := compileAndRun(t, testCtx, moduleConfig, bin)
require.Equal(t, "OK", strings.TrimSpace(console))
})
}
func Test_Sock(t *testing.T) {
toolchains := map[string][]byte{
"cargo-wasi": wasmCargoWasi,
"zig-cc": wasmZigCc,
}
if wasmGotip != nil {
toolchains["gotip"] = wasmGotip
}
for toolchain, bin := range toolchains {
toolchain := toolchain
bin := bin
t.Run(toolchain, func(t *testing.T) {
testSock(t, bin)
})
}
}
func testSock(t *testing.T, bin []byte) {
sockCfg := experimentalsock.NewConfig().WithTCPListener("127.0.0.1", 0)
ctx := experimentalsock.WithConfig(testCtx, sockCfg)
moduleConfig := wazero.NewModuleConfig().WithArgs("wasi", "sock")
tcpAddrCh := make(chan *net.TCPAddr, 1)
ch := make(chan string, 1)
go func() {
ch <- compileAndRunWithPreStart(t, ctx, moduleConfig, bin, func(t *testing.T, mod api.Module) {
tcpAddrCh <- requireTCPListenerAddr(t, mod)
})
}()
tcpAddr := <-tcpAddrCh
// Give a little time for _start to complete
sleepALittle()
// Now dial to the initial address, which should be now held by wazero.
conn, err := net.Dial("tcp", tcpAddr.String())
require.NoError(t, err)
defer conn.Close()
n, err := conn.Write([]byte("wazero"))
console := <-ch
require.NotEqual(t, 0, n)
require.NoError(t, err)
require.Equal(t, "wazero\n", console)
}
func Test_HTTP(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("syscall.Nonblocking() is not supported on wasip1+windows.")
}
toolchains := map[string][]byte{}
if wasmGotip != nil {
toolchains["gotip"] = wasmGotip
}
for toolchain, bin := range toolchains {
toolchain := toolchain
bin := bin
t.Run(toolchain, func(t *testing.T) {
testHTTP(t, bin)
})
}
}
func testHTTP(t *testing.T, bin []byte) {
sockCfg := experimentalsock.NewConfig().WithTCPListener("127.0.0.1", 0)
ctx := experimentalsock.WithConfig(testCtx, sockCfg)
moduleConfig := wazero.NewModuleConfig().
WithSysWalltime().WithSysNanotime(). // HTTP middleware uses both clocks
WithArgs("wasi", "http")
tcpAddrCh := make(chan *net.TCPAddr, 1)
ch := make(chan string, 1)
go func() {
ch <- compileAndRunWithPreStart(t, ctx, moduleConfig, bin, func(t *testing.T, mod api.Module) {
tcpAddrCh <- requireTCPListenerAddr(t, mod)
})
}()
tcpAddr := <-tcpAddrCh
// Give a little time for _start to complete
sleepALittle()
// Now, send a POST to the address which we had pre-opened.
body := bytes.NewReader([]byte("wazero"))
req, err := http.NewRequest(http.MethodPost, "http://"+tcpAddr.String(), body)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "wazero\n", string(b))
console := <-ch
require.Equal(t, "", console)
}
func Test_Stdin(t *testing.T) {
toolchains := map[string][]byte{}
if wasmGotip != nil {
toolchains["gotip"] = wasmGotip
}
for toolchain, bin := range toolchains {
toolchain := toolchain
bin := bin
t.Run(toolchain, func(t *testing.T) {
testStdin(t, bin)
})
}
}
func testStdin(t *testing.T, bin []byte) {
r, w, err := os.Pipe()
require.NoError(t, err)
moduleConfig := wazero.NewModuleConfig().
WithSysNanotime(). // poll_oneoff requires nanotime.
WithArgs("wasi", "stdin").
WithStdin(r)
ch := make(chan string, 1)
go func() {
ch <- compileAndRun(t, testCtx, moduleConfig, bin)
}()
time.Sleep(1 * time.Second)
_, _ = w.WriteString("foo")
s := <-ch
require.Equal(t, "waiting for stdin...\nfoo", s)
}