Browse Source

LibWasm+Meta: Implement instantiation/execution primitives in test-wasm

This also optionally generates a test suite from the WebAssembly
testsuite, which can be enabled via passing `INCLUDE_WASM_SPEC_TESTS`
to cmake, which will generate test-wasm-compatible tests and the
required fixtures.
The generated directories are excluded from git since there's no point
in committing them.
Ali Mohammad Pur 4 năm trước cách đây
mục cha
commit
24b2a6c93a

+ 1 - 0
.gitignore

@@ -27,3 +27,4 @@ sync-local.sh
 .vim/
 
 Userland/Libraries/LibWasm/Tests/Fixtures/SpecTests
+Userland/Libraries/LibWasm/Tests/Spec

+ 2 - 4
CMakeLists.txt

@@ -267,11 +267,9 @@ if(INCLUDE_WASM_SPEC_TESTS)
         file(GLOB WASM_TESTS "${CMAKE_BINARY_DIR}/testsuite-master/*.wast")
         foreach(PATH ${WASM_TESTS})
             get_filename_component(NAME ${PATH} NAME_WLE)
-            message(STATUS "Compiling WebAssembly test ${NAME}...")
+            message(STATUS "Generating test cases for WebAssembly test ${NAME}...")
             execute_process(
-                COMMAND wasm-as -n ${PATH} -o "${WASM_SPEC_TEST_PATH}/${NAME}.wasm"
-                OUTPUT_QUIET
-                ERROR_QUIET)
+                COMMAND bash ${CMAKE_SOURCE_DIR}/Meta/generate-libwasm-spec-test.sh "${PATH}" "${CMAKE_SOURCE_DIR}/Userland/Libraries/LibWasm/Tests/Spec" "${NAME}" "${WASM_SPEC_TEST_PATH}")
         endforeach()
         file(REMOVE testsuite-master)
     endif()

+ 232 - 0
Meta/generate-libwasm-spec-test.py

@@ -0,0 +1,232 @@
+#!/usr/bin/env python3
+
+from sys import argv, stderr
+from os import path
+from string import whitespace
+import re
+import math
+from tempfile import NamedTemporaryFile
+from subprocess import call
+import json
+
+atom_end = set('()"' + whitespace)
+
+
+def parse(sexp):
+    sexp = re.sub(r'(?m)\(;.*;\)', '', re.sub(r'(;;.*)', '', sexp))
+    stack, i, length = [[]], 0, len(sexp)
+    while i < length:
+        c = sexp[i]
+        kind = type(stack[-1])
+        if kind == list:
+            if c == '(':
+                stack.append([])
+            elif c == ')':
+                stack[-2].append(stack.pop())
+            elif c == '"':
+                stack.append('')
+            elif c in whitespace:
+                pass
+            else:
+                stack.append((c,))
+        elif kind == str:
+            if c == '"':
+                stack[-2].append(stack.pop())
+            elif c == '\\':
+                i += 1
+                stack[-1] += sexp[i]
+            else:
+                stack[-1] += c
+        elif kind == tuple:
+            if c in atom_end:
+                atom = stack.pop()
+                stack[-1].append(atom)
+                continue
+            else:
+                stack[-1] = ((stack[-1][0] + c),)
+        i += 1
+    return stack.pop()
+
+
+def parse_typed_value(ast):
+    types = {
+        'i32.const': 'i32',
+        'i64.const': 'i64',
+        'f32.const': 'float',
+        'f64.const': 'double',
+    }
+    if len(ast) == 2 and ast[0][0] in types:
+        return {"type": types[ast[0][0]], "value": ast[1][0]}
+
+    return {"type": "error"}
+
+
+def generate_module_source_for_compilation(entries):
+    s = '('
+    for entry in entries:
+        if type(entry) == tuple and len(entry) == 1 and type(entry[0]) == str:
+            s += entry[0] + ' '
+        elif type(entry) == str:
+            s += json.dumps(entry) + ' '
+        elif type(entry) == list:
+            s += generate_module_source_for_compilation(entry)
+        else:
+            raise Exception("wat? I dunno how to pretty print " + str(type(entry)))
+    while s.endswith(' '):
+        s = s[:len(s) - 1]
+    return s + ')'
+
+
+def generate(ast):
+    if type(ast) != list:
+        return []
+    tests = []
+    for entry in ast:
+        if len(entry) > 0 and entry[0] == ('module',):
+            tests.append({
+                "module": generate_module_source_for_compilation(entry),
+                "tests": []
+            })
+        elif len(entry) in [2, 3] and entry[0][0].startswith('assert_'):
+            if entry[1][0] == ('invoke',):
+                tests[-1]["tests"].append({
+                    "kind": entry[0][0][len('assert_'):],
+                    "function": {
+                        "name": entry[1][1],
+                        "args": list(parse_typed_value(x) for x in entry[1][2:])
+                    },
+                    "result": parse_typed_value(entry[2]) if len(entry) == 3 else None
+                })
+            else:
+                print("Ignoring unknown assertion argument", entry[1][0], file=stderr)
+        elif len(entry) >= 2 and entry[0][0] == 'invoke':
+            # toplevel invoke :shrug:
+            tests[-1]["tests"].append({
+                "kind": "ignore",
+                "function": {
+                    "name": entry[1][1],
+                    "args": list(parse_typed_value(x) for x in entry[1][2:])
+                },
+                "result": parse_typed_value(entry[2]) if len(entry) == 3 else None
+            })
+        else:
+            print("Ignoring unknown entry", entry, file=stderr)
+    return tests
+
+
+def genarg(spec):
+    if spec['type'] == 'error':
+        return '0'
+
+    def gen():
+        x = spec['value']
+        if x == 'nan':
+            return 'NaN'
+        if x == '-nan':
+            return '-NaN'
+
+        try:
+            x = float.fromhex(x)
+            if math.isnan(x):
+                # FIXME: This is going to mess up the different kinds of nan
+                return '-NaN' if math.copysign(1.0, x) < 0 else 'NaN'
+            if math.isinf(x):
+                return 'Infinity' if x > 0 else '-Infinity'
+            return str(x)
+        except ValueError:
+            try:
+                x = int(x, 0)
+                return str(x)
+            except ValueError:
+                return x
+
+    x = gen()
+    if x.startswith('nan'):
+        return 'NaN'
+    if x.startswith('-nan'):
+        return '-NaN'
+    return x
+
+
+all_names_in_main = {}
+
+
+def genresult(ident, entry):
+    if entry['kind'] == 'return':
+        return_check = f'expect({ident}_result).toBe({genarg(entry["result"])})' if entry["result"] is not None else ''
+        return (
+            f'let {ident}_result ='
+            f' module.invoke({ident}, {", ".join(genarg(x) for x in entry["function"]["args"])});\n        '
+            f'{return_check};\n    '
+        )
+
+    if entry['kind'] == 'trap':
+        return (
+            f'expect(() => module.invoke({ident}, {", ".join(genarg(x) for x in entry["function"]["args"])}))'
+            '.toThrow(TypeError, "Execution trapped");\n    '
+        )
+
+    if entry['kind'] == 'ignore':
+        return f'module.invoke({ident}, {", ".join(genarg(x) for x in entry["function"]["args"])});\n    '
+
+    return f'throw Exception("(Test Generator) Unknown test kind {entry["kind"]}");\n    '
+
+
+def gentest(entry, main_name):
+    name = entry["function"]["name"]
+    if type(name) != str:
+        print("Unsupported test case (call to", name, ")", file=stderr)
+        return '\n    '
+    ident = '_' + re.sub("[^a-zA-Z_0-9]", "_", name)
+    count = all_names_in_main.get(name, 0)
+    all_names_in_main[name] = count + 1
+    test_name = f'execution of {main_name}: {name} (instance {count})'
+    source = (
+        f'test({json.dumps(test_name)}, () => {{\n'
+        f'let {ident} = module.getExport({json.dumps(name)});\n        '
+        f'expect({ident}).not.toBeUndefined();\n        '
+        f'{genresult(ident, entry)}'
+        '});\n\n    '
+    )
+    return source
+
+
+def gen_parse_module(name):
+    return (
+        f'let content;\n    '
+        f'try {{\n        '
+        f'content = readBinaryWasmFile("Fixtures/SpecTests/{name}.wasm");\n    '
+        f'}} catch {{ read_okay = false; }}\n    '
+        f'const module = parseWebAssemblyModule(content)\n    '
+    )
+
+
+def main():
+    with open(argv[1]) as f:
+        sexp = f.read()
+    name = argv[2]
+    module_output_path = argv[3]
+    ast = parse(sexp)
+    for index, description in enumerate(generate(ast)):
+        testname = f'{name}_{index}'
+        outpath = path.join(module_output_path, f'{testname}.wasm')
+        with NamedTemporaryFile("w+") as temp:
+            temp.write(description["module"])
+            temp.flush()
+            rc = call(["wasm-as", "-n", temp.name, "-o", outpath])
+            if rc != 0:
+                print("Failed to compile", name, "module index", index, "skipping that test", file=stderr)
+                continue
+
+        sep = ""
+        print(f'''{{
+let readOkay = true;
+{gen_parse_module(testname)}
+if (readOkay) {{
+{sep.join(gentest(x, testname) for x in description["tests"])}
+}}}}
+''')
+
+
+if __name__ == "__main__":
+    main()

+ 16 - 0
Meta/generate-libwasm-spec-test.sh

@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+
+if [ $# -ne 4 ]; then
+  echo "Usage: $0 <input spec file> <output path> <name> <module output path>"
+  exit 1
+fi
+
+INPUT_FILE="$1"
+OUTPUT_PATH="$2"
+NAME="$3"
+MODULE_OUTPUT_PATH="$4"
+
+mkdir -p "$OUTPUT_PATH"
+mkdir -p "$MODULE_OUTPUT_PATH"
+
+python3 "$(dirname "$0")/generate-libwasm-spec-test.py" "$INPUT_FILE" "$NAME" "$MODULE_OUTPUT_PATH" | prettier --stdin-filepath "test-$NAME.js" > "$OUTPUT_PATH/$NAME.js"

+ 145 - 3
Tests/LibWasm/test-wasm.cpp

@@ -27,6 +27,36 @@ TESTJS_GLOBAL_FUNCTION(read_binary_wasm_file, readBinaryWasmFile)
     return array;
 }
 
+class WebAssemblyModule final : public JS::Object {
+    JS_OBJECT(WebAssemblyModule, JS::Object);
+
+public:
+    // FIXME: This should only contain an instantiated module, not the entire abstract machine!
+    explicit WebAssemblyModule(JS::Object& prototype)
+        : JS::Object(prototype)
+    {
+    }
+
+    static WebAssemblyModule* create(JS::GlobalObject& global_object, Wasm::Module module)
+    {
+        auto instance = global_object.heap().allocate<WebAssemblyModule>(global_object, *global_object.object_prototype());
+        instance->m_module = move(module);
+        if (auto result = instance->m_machine.instantiate(*instance->m_module, {}); result.is_error())
+            global_object.vm().throw_exception<JS::TypeError>(global_object, result.release_error().error);
+        return instance;
+    }
+    void initialize(JS::GlobalObject&) override;
+
+    ~WebAssemblyModule() override = default;
+
+private:
+    JS_DECLARE_NATIVE_FUNCTION(get_export);
+    JS_DECLARE_NATIVE_FUNCTION(wasm_invoke);
+
+    Wasm::AbstractMachine m_machine;
+    Optional<Wasm::Module> m_module;
+};
+
 TESTJS_GLOBAL_FUNCTION(parse_webassembly_module, parseWebAssemblyModule)
 {
     auto object = vm.argument(0).to_object(global_object);
@@ -43,9 +73,12 @@ TESTJS_GLOBAL_FUNCTION(parse_webassembly_module, parseWebAssemblyModule)
         vm.throw_exception<JS::SyntaxError>(global_object, Wasm::parse_error_to_string(result.error()));
         return {};
     }
-    if (stream.handle_any_error())
-        return JS::js_undefined();
-    return JS::js_null();
+
+    if (stream.handle_any_error()) {
+        vm.throw_exception<JS::SyntaxError>(global_object, "Bianry stream contained errors");
+        return {};
+    }
+    return WebAssemblyModule::create(global_object, result.release_value());
 }
 
 TESTJS_GLOBAL_FUNCTION(compare_typed_arrays, compareTypedArrays)
@@ -68,3 +101,112 @@ TESTJS_GLOBAL_FUNCTION(compare_typed_arrays, compareTypedArrays)
     auto& rhs_array = static_cast<JS::TypedArrayBase&>(*rhs);
     return JS::Value(lhs_array.viewed_array_buffer()->buffer() == rhs_array.viewed_array_buffer()->buffer());
 }
+
+void WebAssemblyModule::initialize(JS::GlobalObject& global_object)
+{
+    Base::initialize(global_object);
+    define_native_function("getExport", get_export);
+    define_native_function("invoke", wasm_invoke);
+}
+
+JS_DEFINE_NATIVE_FUNCTION(WebAssemblyModule::get_export)
+{
+    auto name = vm.argument(0).to_string(global_object);
+    if (vm.exception())
+        return {};
+    auto this_value = vm.this_value(global_object);
+    auto object = this_value.to_object(global_object);
+    if (vm.exception())
+        return {};
+    if (!object || !is<WebAssemblyModule>(object)) {
+        vm.throw_exception<JS::TypeError>(global_object, "Not a WebAssemblyModule");
+        return {};
+    }
+    auto instance = static_cast<WebAssemblyModule*>(object);
+    for (auto& entry : instance->m_machine.module_instance().exports()) {
+        if (entry.name() == name) {
+            auto& value = entry.value();
+            if (auto ptr = value.get_pointer<Wasm::FunctionAddress>())
+                return JS::Value(static_cast<unsigned long>(ptr->value()));
+            vm.throw_exception<JS::TypeError>(global_object, String::formatted("'{}' does not refer to a function", name));
+            return {};
+        }
+    }
+    vm.throw_exception<JS::TypeError>(global_object, String::formatted("'{}' could not be found", name));
+    return {};
+}
+
+JS_DEFINE_NATIVE_FUNCTION(WebAssemblyModule::wasm_invoke)
+{
+    auto address = static_cast<unsigned long>(vm.argument(0).to_double(global_object));
+    if (vm.exception())
+        return {};
+    auto this_value = vm.this_value(global_object);
+    auto object = this_value.to_object(global_object);
+    if (vm.exception())
+        return {};
+    if (!object || !is<WebAssemblyModule>(object)) {
+        vm.throw_exception<JS::TypeError>(global_object, "Not a WebAssemblyModule");
+        return {};
+    }
+    auto instance = static_cast<WebAssemblyModule*>(object);
+    Wasm::FunctionAddress function_address { address };
+    auto function_instance = instance->m_machine.store().get(function_address);
+    if (!function_instance) {
+        vm.throw_exception<JS::TypeError>(global_object, "Invalid function address");
+        return {};
+    }
+
+    const Wasm::FunctionType* type { nullptr };
+    function_instance->visit([&](auto& value) { type = &value.type(); });
+    if (!type) {
+        vm.throw_exception<JS::TypeError>(global_object, "Invalid function found at given address");
+        return {};
+    }
+
+    Vector<Wasm::Value> arguments;
+    if (type->parameters().size() + 1 > vm.argument_count()) {
+        vm.throw_exception<JS::TypeError>(global_object, String::formatted("Expected {} arguments for call, but found {}", type->parameters().size() + 1, vm.argument_count()));
+        return {};
+    }
+    size_t index = 1;
+    for (auto& param : type->parameters()) {
+        auto value = vm.argument(index++).to_double(global_object);
+        switch (param.kind()) {
+        case Wasm::ValueType::Kind::I32:
+            arguments.append(Wasm::Value(static_cast<i32>(value)));
+            break;
+        case Wasm::ValueType::Kind::I64:
+            arguments.append(Wasm::Value(static_cast<i64>(value)));
+            break;
+        case Wasm::ValueType::Kind::F32:
+            arguments.append(Wasm::Value(static_cast<float>(value)));
+            break;
+        case Wasm::ValueType::Kind::F64:
+            arguments.append(Wasm::Value(static_cast<double>(value)));
+            break;
+        case Wasm::ValueType::Kind::FunctionReference:
+            arguments.append(Wasm::Value(Wasm::FunctionAddress { static_cast<u64>(value) }));
+            break;
+        case Wasm::ValueType::Kind::ExternReference:
+            arguments.append(Wasm::Value(Wasm::ExternAddress { static_cast<u64>(value) }));
+            break;
+        }
+    }
+
+    auto result = instance->m_machine.invoke(function_address, arguments);
+    if (result.is_trap()) {
+        vm.throw_exception<JS::TypeError>(global_object, "Execution trapped");
+        return {};
+    }
+
+    if (result.values().is_empty())
+        return JS::js_null();
+
+    JS::Value return_value;
+    result.values().first().value().visit(
+        [&](const auto& value) { return_value = JS::Value(static_cast<double>(value)); },
+        [&](const Wasm::FunctionAddress& index) { return_value = JS::Value(static_cast<double>(index.value())); },
+        [&](const Wasm::ExternAddress& index) { return_value = JS::Value(static_cast<double>(index.value())); });
+    return return_value;
+}