Skip to content

Commit

Permalink
Panics caller on exit error
Browse files Browse the repository at this point in the history
This changes the AssemblyScript abort handler and WASI proc_exit
implementation to panic the caller which eventually invoked close.

This ensures no code executes afterwards, For example, LLVM inserts
unreachable instructions after calls to exit.

See emscripten-core/emscripten#12322
See #601

Signed-off-by: Adrian Cole <adrian@tetrate.io>
  • Loading branch information
Adrian Cole committed Jul 6, 2022
1 parent 273013d commit 16b6ccc
Show file tree
Hide file tree
Showing 25 changed files with 404 additions and 140 deletions.
16 changes: 15 additions & 1 deletion .github/workflows/examples.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ on:
- '.github/workflows/examples.yaml'
- 'examples/*/testdata/*.go'
- 'examples/*/*/testdata/*.go'
- 'examples/*/testdata/*/*.go'
- 'examples/*/testdata/*/*.c'
- 'examples/*/testdata/*.ts'
- 'Makefile'
push:
Expand All @@ -14,6 +16,9 @@ on:
- '.github/workflows/examples.yaml'
- 'examples/*/testdata/*.go'
- 'examples/*/*/testdata/*.go'
- 'examples/*/testdata/*/*.go'
- 'examples/*/testdata/*/*.c'
- 'examples/*/testdata/*.ts'
- 'Makefile'

jobs:
Expand All @@ -28,17 +33,26 @@ jobs:
echo "TINYGOROOT=/usr/local/tinygo" >> $GITHUB_ENV
echo "/usr/local/tinygo/bin" >> $GITHUB_PATH
- name: Install latest Zig
uses: goto-bus-stop/setup-zig@v1
with: # on laptop, use `brew install zig`
version: 0.9.1

- name: Checkout
uses: actions/checkout@v3

# TinyGo -> Wasm is not idempotent, so we only check things build.
- name: Build TinyGO examples
run: make build.examples
run: make build.examples.tinygo

# AssemblyScript -> Wasm is not idempotent, so we only check things build.
- name: Build AssemblyScript examples
run: make build.examples.as

# zig-cc -> Wasm is not idempotent, so we only check things build.
- name: Build zig-cc examples
run: make build.examples.zig-cc

# TinyGo -> Wasm is not idempotent, so we only check things build.
- name: Build bench cases
run: make build.bench
14 changes: 11 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,21 @@ build.bench:
build.examples.as:
@cd ./examples/assemblyscript/testdata && npm install && npm run build

tinygo_sources := $(wildcard examples/*/testdata/*.go examples/*/*/testdata/*.go)
.PHONY: build.examples
build.examples: $(tinygo_sources)
tinygo_sources := $(wildcard examples/*/testdata/*.go examples/*/*/testdata/*.go examples/*/testdata/*/*.go)
.PHONY: build.examples.tinygo
build.examples.tinygo: $(tinygo_sources)
@for f in $^; do \
tinygo build -o $$(echo $$f | sed -e 's/\.go/\.wasm/') -scheduler=none --no-debug --target=wasi $$f; \
done

# We use zig to build C as it is easy to install and embeds a copy of zig-cc.
c_sources := $(wildcard examples/*/testdata/*.c examples/*/*/testdata/*.c examples/*/testdata/*/*.c)
.PHONY: build.examples.zig-cc
build.examples.zig-cc: $(c_sources)
@for f in $^; do \
zig cc $$f -o $$(echo $$f | sed -e 's/\.c/\.wasm/') --target=wasm32-wasi -O3; \
done

spectest_base_dir := internal/integration_test/spectest
spectest_v1_dir := $(spectest_base_dir)/v1
spectest_v1_testdata_dir := $(spectest_v1_dir)/testdata
Expand Down
34 changes: 34 additions & 0 deletions RATIONALE.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,40 @@ and a closer to assembly representation for used by our compiler.
Note: `microwasm` was never specified formally, and only exists in a historical codebase of wasmtime:
https://github.com/bytecodealliance/wasmtime/blob/v0.29.0/crates/lightbeam/src/microwasm.rs

## Exit

### Wny do we return a `sys.ExitError` on exit code zero?

It may be surprising to find an error returned on success (exit code zero).
This can be explained easier when you think of function returns: When results
aren't empty, then you must return an error. This is trickier to explain when
results are empty, such as the case in the "_start" function in WASI.

The main rationale for returning an exit error even if the code is success is
that the module is no longer functional. For example, function exports would
error later. In cases like these, it is better to handle errors where they
occur.

Luckily, it is not common to exit a module during the "_start" function. For
example, the only known compilation target that does this is Emscripten. Most,
such as Rust, TinyGo, or normal wasi-libc, don't. If they did, it would
invalidate their function exports. This means it is unlikely most compilers
will change this behavior.

In summary, we return a `sys.ExitError` to the caller whenever we get it, as it
properly reflects the state of the module, which would be closed on this error.

### Why panic with `sys.ExitError` after a host function exits?

Currently, the only portable way to stop processing code is via panic. For
example, WebAssembly "trap" instructions, such as divide by zero, are
implemented via panic. This ensures code isn't executed after it.

When code reaches the WASI `proc_exit` instruction, we need to stop processing.
Regardless of the exit code, any code invoked after exit would be in an
inconsistent state. This is likely why unreachable instructions are sometimes
inserted after exit: https://github.com/emscripten-core/emscripten/issues/12322

## WASI

Unfortunately, (WASI Snapshot Preview 1)[https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md] is not formally defined enough, and has APIs with ambiguous semantics.
Expand Down
12 changes: 11 additions & 1 deletion assemblyscript/assemblyscript.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/internal/ieee754"
"github.com/tetratelabs/wazero/internal/wasm"
"github.com/tetratelabs/wazero/sys"
)

// Instantiate instantiates the "env" module used by AssemblyScript into the runtime default namespace.
Expand Down Expand Up @@ -166,7 +167,16 @@ func (a *assemblyscript) abort(
}
_, _ = fmt.Fprintf(sysCtx.Stderr(), "%s at %s:%d:%d\n", msg, fn, lineNumber, columnNumber)
}
_ = mod.CloseWithExitCode(ctx, 255)

// AssemblyScript expects the exit code to be 255
// See https://github.com/AssemblyScript/assemblyscript/blob/v0.20.13/tests/compiler/wasi/abort.js#L14
exitCode := uint32(255)

// Ensure other callers see the exit code.
_ = mod.CloseWithExitCode(ctx, exitCode)

// Prevent any code from executing after this function.
panic(sys.NewExitError(mod.Name(), exitCode))
}

// trace implements the same named function in AssemblyScript (ex. trace('Hello World!'))
Expand Down
29 changes: 29 additions & 0 deletions assemblyscript/assemblyscript_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,35 @@ func TestAbort(t *testing.T) {
}
}

var unreachableAfterAbort = `(module
(import "env" "abort" (func $~lib/builtins/abort (param i32 i32 i32 i32)))
(func $main
i32.const 0
i32.const 0
i32.const 0
i32.const 0
call $~lib/builtins/abort
unreachable ;; If abort doesn't panic, this code is reached.
)
(start $main)
)`

// TestAbort_StopsExecution ensures code that follows an abort isn't invoked.
func TestAbort_StopsExecution(t *testing.T) {
r := wazero.NewRuntime()
defer r.Close(testCtx)

_, err := NewBuilder(r).WithAbortMessageDisabled().Instantiate(testCtx, r)
require.NoError(t, err)

abortWasm, err := watzero.Wat2Wasm(unreachableAfterAbort)
require.NoError(t, err)

_, err = r.InstantiateModuleFromBinary(testCtx, abortWasm)
require.Error(t, err)
require.Equal(t, uint32(255), err.(*sys.ExitError).ExitCode())
}

func TestSeed(t *testing.T) {
r := wazero.NewRuntime()
defer r.Close(testCtx)
Expand Down
10 changes: 8 additions & 2 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -428,9 +428,15 @@ type ModuleConfig interface {
// WithName configures the module name. Defaults to what was decoded or overridden via CompileConfig.WithModuleName.
WithName(string) ModuleConfig

// WithStartFunctions configures the functions to call after the module is instantiated. Defaults to "_start".
// WithStartFunctions configures the functions to call after the module is
// instantiated. Defaults to "_start".
//
// Note: If any function doesn't exist, it is skipped. However, all functions that do exist are called in order.
// Notes
//
// * If any function doesn't exist, it is skipped. However, all functions
// that do exist are called in order.
// * Some start functions may exit the module during instantiate with a
// sys.ExitError (ex. emscripten), preventing use of exported functions.
WithStartFunctions(...string) ModuleConfig

// WithStderr configures where standard error (file descriptor 2) is written. Defaults to io.Discard.
Expand Down
43 changes: 19 additions & 24 deletions examples/allocation/rust/testdata/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ extern crate core;
extern crate wee_alloc;

use alloc::vec::Vec;
use std::slice;
use std::mem::MaybeUninit;
use std::slice;

/// Prints a greeting to the console using [`log`].
fn greet(name: &String) {
Expand Down Expand Up @@ -40,10 +40,7 @@ extern "C" {
///
/// Note: The input parameters were returned by [`allocate`]. This is not an
/// ownership transfer, so the inputs can be reused after this call.
#[cfg_attr(
all(target_arch = "wasm32", target_os = "unknown"),
export_name = "greet"
)]
#[cfg_attr(all(target_arch = "wasm32"), export_name = "greet")]
#[no_mangle]
pub unsafe extern "C" fn _greet(ptr: u32, len: u32) {
greet(&ptr_to_string(ptr, len));
Expand All @@ -56,10 +53,7 @@ pub unsafe extern "C" fn _greet(ptr: u32, len: u32) {
/// [`deallocate`] when finished.
/// Note: This uses a u64 instead of two result values for compatibility with
/// WebAssembly 1.0.
#[cfg_attr(
all(target_arch = "wasm32", target_os = "unknown"),
export_name = "greeting"
)]
#[cfg_attr(all(target_arch = "wasm32"), export_name = "greeting")]
#[no_mangle]
pub unsafe extern "C" fn _greeting(ptr: u32, len: u32) -> u64 {
let name = &ptr_to_string(ptr, len);
Expand Down Expand Up @@ -98,14 +92,16 @@ static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
///
/// This is an ownership transfer, which means the caller must call
/// [`deallocate`] when finished.
#[cfg_attr(
all(target_arch = "wasm32", target_os = "unknown"),
export_name = "allocate"
)]
#[cfg_attr(all(target_arch = "wasm32"), export_name = "allocate")]
#[no_mangle]
pub extern "C" fn allocate(size: u32) -> *mut u8 {
pub extern "C" fn _allocate(size: u32) -> *mut u8 {
allocate(size as usize)
}

/// Allocates size bytes and leaks the pointer where they start.
fn allocate(size: usize) -> *mut u8 {
// Allocate the amount of bytes needed.
let vec: Vec<MaybeUninit<u8>> = Vec::with_capacity(size as usize);
let vec: Vec<MaybeUninit<u8>> = Vec::with_capacity(size);

// into_raw leaks the memory to the caller.
Box::into_raw(vec.into_boxed_slice()) as *mut u8
Expand All @@ -114,14 +110,13 @@ pub extern "C" fn allocate(size: u32) -> *mut u8 {

/// WebAssembly export that deallocates a pointer of the given size (linear
/// memory offset, byteCount) allocated by [`allocate`].
#[cfg_attr(
all(target_arch = "wasm32", target_os = "unknown"),
export_name = "deallocate"
)]
#[cfg_attr(all(target_arch = "wasm32"), export_name = "deallocate")]
#[no_mangle]
pub extern "C" fn deallocate(ptr: u32, size: u32) {
unsafe {
// Retake the pointer which allows its memory to be freed.
let _ = Vec::from_raw_parts(ptr as *mut u8, 0, size as usize);
}
pub unsafe extern "C" fn _deallocate(ptr: u32, size: u32) {
deallocate(ptr as *mut u8, size as usize);
}

/// Retakes the pointer which allows its memory to be freed.
unsafe fn deallocate(ptr: *mut u8, size: usize) {
let _ = Vec::from_raw_parts(ptr, 0, size);
}
Binary file modified examples/allocation/tinygo/testdata/greet.wasm
Binary file not shown.
23 changes: 23 additions & 0 deletions examples/wasi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,26 @@

This example shows how to use I/O in your WebAssembly modules using WASI
(WebAssembly System Interface).

```bash
$ go run cat.go /test.txt
greet filesystem
```

If you do not set the environment variable `WASM_COMPILER`, main defaults
to use Wasm built with "tinygo". Here are the included examples:

* [tjnygo](testdata/tinygo) - Built via `tinygo build -o cat.wasm -scheduler=none --no-debug -target=wasi cat.go`
* [zig-cc](testdata/zig-cc) - Built via `zig cc cat.c -o cat.wasm --target=wasm32-wasi -O3`

Ex. To run the same example with zig-cc:
```bash
$ WASM_COMPILER=zig-cc go run cat.go /test.txt
greet filesystem
```

Note: While WASI attempts to be portable, there are no specification tests and
some compilers only partially implement features.

For example, Emscripten only supports WASI I/O on stdin/stdout/stderr, so
cannot be used for this example. See emscripten-core/emscripten#17167
47 changes: 39 additions & 8 deletions examples/wasi/cat.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,28 @@ import (
"context"
"embed"
_ "embed"
"fmt"
"io/fs"
"log"
"os"

"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/sys"
"github.com/tetratelabs/wazero/wasi_snapshot_preview1"
)

// catFS is an embedded filesystem limited to test.txt
//go:embed testdata/test.txt
var catFS embed.FS

// catWasm was compiled the TinyGo source testdata/cat.go
//go:embed testdata/cat.wasm
var catWasm []byte
// catWasmTinyGo was compiled from testdata/tinygo/cat.go
//go:embed testdata/tinygo/cat.wasm
var catWasmTinyGo []byte

// catWasmZigCc was compiled from the directory testdata/zig-cc:
// zig cc cat.c -o cat.wasm --target=wasm32-wasi -O3
//go:embed testdata/zig-cc/cat.wasm
var catWasmZigCc []byte

// main writes an input file to stdout, just like `cat`.
//
Expand All @@ -40,23 +47,47 @@ func main() {
log.Panicln(err)
}

// Combine the above into our baseline config, overriding defaults (which discards stdout and has no file system).
config := wazero.NewModuleConfig().WithStdout(os.Stdout).WithFS(rooted)
// Combine the above into our baseline config, overriding defaults.
config := wazero.NewModuleConfig().
// By default, I/O streams are discarded and there's no file system.
WithStdout(os.Stdout).WithStderr(os.Stderr).WithFS(rooted)

// Instantiate WASI, which implements system I/O such as console output.
if _, err = wasi_snapshot_preview1.Instantiate(ctx, r); err != nil {
log.Panicln(err)
}

// Choose the binary we want to test. Most compilers that implement WASI
// are portable enough to use binaries interchangeably.
var catWasm []byte
compiler := os.Getenv("WASM_COMPILER")
switch compiler {
case "":
fallthrough // default to TinyGo
case "tinygo":
catWasm = catWasmTinyGo
case "zig-cc":
catWasm = catWasmZigCc
default:
log.Panicln("unknown compiler", compiler)
}

// Compile the WebAssembly module using the default configuration.
code, err := r.CompileModule(ctx, catWasm, wazero.NewCompileConfig())
if err != nil {
log.Panicln(err)
}

// InstantiateModule runs the "_start" function which is what TinyGo compiles "main" to.
// * Set the program name (arg[0]) to "wasi" and add args to write "/test.txt" to stdout twice.
// InstantiateModule runs the "_start" function, WASI's "main".
// * Set the program name (arg[0]) to "wasi"; arg[1] should be "/test.txt".
if _, err = r.InstantiateModule(ctx, code, config.WithArgs("wasi", os.Args[1])); err != nil {
log.Panicln(err)

// Note: Most compilers do not exit the module after running "_start",
// unless there was an error. This allows you to call exported functions.
if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 {
fmt.Fprintf(os.Stderr, "exit_code: %d\n", exitErr.ExitCode())
} else if !ok {
log.Panicln(err)
}
}
}
Loading

0 comments on commit 16b6ccc

Please sign in to comment.