diff --git a/.circleci/config.yml b/.circleci/config.yml index d752eb2ec62b3..6070f4ef91651 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -68,12 +68,18 @@ commands: install-rust: steps: - run: - name: install rust + name: install rust and wasm-bindgen + # rust and wasm-bindgen are always installed together so there is no + # CI environment with one but not the other. The wasm-bindgen-cli + # version is pinned to match the library the test crate depends on; + # wasm-bindgen requires the CLI and the library to be the exact same + # version. command: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y export PATH=${HOME}/.cargo/bin:${PATH} rustup target add wasm32-unknown-emscripten echo "export PATH=\"\$HOME/.cargo/bin:\$PATH\"" >> $BASH_ENV + cargo install wasm-bindgen-cli --version 0.2.126 --locked install-node-version: description: "install a specific version of node" parameters: diff --git a/src/jsifier.mjs b/src/jsifier.mjs index 84dc251827b75..e607fcd324199 100644 --- a/src/jsifier.mjs +++ b/src/jsifier.mjs @@ -934,6 +934,10 @@ var proxiedFunctionTable = [ '//FORWARDED_DATA:' + JSON.stringify({ librarySymbols, + // The final EXPORTED_FUNCTIONS set, including additions made by JS + // libraries (e.g. wasm-bindgen self-registering its exports), so the + // caller can re-derive which library symbols were exported. + exportedFunctions: Array.from(EXPORTED_FUNCTIONS), nativeAliases, warnings: warningOccured(), asyncFuncs, diff --git a/src/postamble.js b/src/postamble.js index 1fa46c9b4e83c..7e820f2ba4311 100644 --- a/src/postamble.js +++ b/src/postamble.js @@ -242,7 +242,14 @@ function checkUnflushedContent() { #endif // EXIT_RUNTIME #endif // ASSERTIONS +#if WASM_ESM_INTEGRATION +// Provide the aggregate exports object for code that reaches the wasm exports by +// name (e.g. wasm-bindgen's glue) via a namespace import. Emscripten's own named +// imports are unaffected and remain tree-shakable. +import * as wasmExports from './{{{ WASM_BINARY_FILE }}}'; +#else var wasmExports; +#endif #if SPLIT_MODULE var wasmRawExports; #endif diff --git a/test/rust/bindgen_greeter/.cargo/config.toml b/test/rust/bindgen_greeter/.cargo/config.toml new file mode 100644 index 0000000000000..c9be0d51ea09a --- /dev/null +++ b/test/rust/bindgen_greeter/.cargo/config.toml @@ -0,0 +1,7 @@ +[build] +target = "wasm32-unknown-emscripten" +rustflags = [ + "-Cllvm-args=-enable-emscripten-cxx-exceptions=0", + "-Cpanic=abort", + "-Crelocation-model=static", +] diff --git a/test/rust/bindgen_greeter/Cargo.toml b/test/rust/bindgen_greeter/Cargo.toml new file mode 100644 index 0000000000000..ae11b6329c60a --- /dev/null +++ b/test/rust/bindgen_greeter/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "bindgen_greeter" +edition = "2021" + +[[bin]] +name = "bindgen_greeter" +path = "src/main.rs" + +[dependencies] +wasm-bindgen = "=0.2.126" diff --git a/test/rust/bindgen_greeter/src/main.rs b/test/rust/bindgen_greeter/src/main.rs new file mode 100644 index 0000000000000..e4afb889b0c19 --- /dev/null +++ b/test/rust/bindgen_greeter/src/main.rs @@ -0,0 +1,23 @@ +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub struct Greeter { + greeting: String, +} + +#[wasm_bindgen] +impl Greeter { + #[wasm_bindgen(constructor)] + pub fn new(greeting: String) -> Greeter { + Greeter { greeting } + } + + pub fn greet(&self, name: String) -> String { + format!("{}, {}!", self.greeting, name) + } +} + +fn main() { + // Matches the emscripten idiom: main runs automatically on init. + println!("main ran"); +} diff --git a/test/test_other.py b/test/test_other.py index fa798c2c5f000..d8219e650523f 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -271,6 +271,11 @@ def requires_rust(func): return requires_tool('cargo', 'RUST')(func) +def requires_wasm_bindgen(func): + assert callable(func) + return requires_tool('wasm-bindgen', 'WASM_BINDGEN')(func) + + def requires_pkg_config(func): assert callable(func) @@ -15014,9 +15019,12 @@ def test_rust_integration_basics(self): self.do_runf('main.cpp', 'Hello from rust!', cflags=[lib]) @requires_rust + @requires_wasm_bindgen def test_wasm_bindgen_integration(self): copytree(test_file('rust/bindgen_integration'), '.') - self.run_process(['cargo', 'add', 'wasm-bindgen']) + # Pin the library to the (managed) wasm-bindgen-cli version on PATH; + # wasm-bindgen requires the CLI and the library to match exactly. + self.run_process(['cargo', 'add', 'wasm-bindgen@=0.2.126']) self.run_process(['cargo', 'build']) lib = 'target/wasm32-unknown-emscripten/debug/libbindgen_integration.a' self.assertExists(lib) @@ -15026,9 +15034,53 @@ def test_wasm_bindgen_integration(self): Module.onRuntimeInitialized = () => out(Module.rs_add(17, 25)); ''') - self.run_process(['cargo', 'install', 'wasm-bindgen-cli']) self.do_runf('empty.c', '42', cflags=[lib, '-sWASM_BINDGEN', '--post-js=post.js', '-lexports.js']) + # ESM-integration and factory (MODULARIZE) surface the clean wasm-bindgen API + # differently (named ESM exports vs `Module.`). Both must expose exactly + # the `Greeter` class and none of the raw wasm exports rustc lists. + @requires_rust + @requires_wasm_bindgen + @parameterized({ + 'esm': (['-sWASM_ESM_INTEGRATION'], ''' + import init, * as mod from './bindgen_greeter.js'; + await init(); + '''), + 'factory': (['-sMODULARIZE', '-sEXPORT_ES6'], ''' + import Module from './bindgen_greeter.js'; + const mod = await Module(); + '''), + }) + def test_wasm_bindgen_rustc_driven(self, cflags, prelude): + # cargo/rustc links via emcc; the wasm carries wasm-bindgen's marker section, + # which emcc detects and runs wasm-bindgen against (no -sWASM_BINDGEN needed). + copytree(test_file('rust/bindgen_greeter'), '.') + # rustc invokes emcc as the linker; ensure it uses *this* emcc and pass the + # output-mode settings through. + with env_modify({'CARGO_TARGET_WASM32_UNKNOWN_EMSCRIPTEN_LINKER': EMCC, + 'EMCC_CFLAGS': ' '.join(cflags)}): + self.run_process(['cargo', 'build']) + + # cargo copies only the .js and .wasm; the ESM support module and snippets + # stay in deps/, so run from there. + out_dir = 'target/wasm32-unknown-emscripten/debug/deps' + create_file(os.path.join(out_dir, 'run.mjs'), prelude + ''' + const greeting = new mod.Greeter('Hello').greet('world'); + if (greeting !== 'Hello, world!') throw new Error('unexpected greeting: ' + greeting); + // None of the raw wasm exports leak into the user-facing API. + for (const name of ['_main', 'greeter_greet', '_greeter_greet', + '__wbindgen_malloc', '___wbindgen_malloc']) { + if (mod[name] !== undefined) throw new Error('leaked export: ' + name); + } + console.log(greeting); + ''') + self.node_args += ['--experimental-wasm-modules', '--no-warnings'] + output = self.run_js(os.path.join(out_dir, 'run.mjs')) + self.assertContained('Hello, world!', output) + # `main` runs automatically on init (matching the emscripten C++ idiom), + # even though `_main` is not surfaced as a user-facing export. + self.assertContained('main ran', output) + def test_relative_em_cache(self): with env_modify({'EM_CACHE': 'foo'}): self.assert_fail([EMCC, '-c', test_file('hello_world.c')], 'emcc: error: environment variable EM_CACHE must be an absolute path: foo') diff --git a/tools/building.py b/tools/building.py index ea95f2e7d66ba..9bab56f563888 100644 --- a/tools/building.py +++ b/tools/building.py @@ -57,6 +57,10 @@ _is_ar_cache: dict[str, bool] = {} # the exports the user requested user_requested_exports: set[str] = set() +# JS library symbols that were exported (MODULARIZE=instance), derived from the +# JS compiler's librarySymbols and EXPORTED_FUNCTIONS; the WASM_ESM_INTEGRATION +# wrapper re-exports them. +exported_js_library_symbols: set[str] = set() # A list of feature flags to pass to each binaryen invocation (like `wasm-opt`, # etc.). This is received by the first call to binaryen (e.g. `wasm-emscripten-finalize`) # which reads it using `--detect-features`. @@ -1285,6 +1289,14 @@ def run_wasm_opt(infile, outfile=None, args=[], **kwargs): # noqa return run_binaryen_command('wasm-opt', infile, outfile, args=args, **kwargs) +def is_wasm_bindgen_module(wasm_file): + # wasm-bindgen marks modules built for the emscripten target with this custom + # section so emcc, when used as the linker (e.g. by cargo/rustc), knows to run + # wasm-bindgen as a post-link step. + with webassembly.Module(wasm_file) as module: + return module.get_custom_section('__wasm_bindgen_emscripten_marker') is not None + + def run_wasm_bindgen(infile): bindgen_out_dir = os.path.join(get_emscripten_temp_dir(), 'bindgen_out') @@ -1299,16 +1311,33 @@ def run_wasm_bindgen(infile): '--out-dir', bindgen_out_dir, ] + exports_before = {e.name for e in webassembly.get_exports(infile)} + check_call(cmd) # Don't try to predict the .wasm filename that wasm-bindgen outputs. Instead # just grab the .wasm file itself. all_output_files = os.listdir(bindgen_out_dir) new_wasm_file = [x for x in all_output_files if x.endswith('.wasm')][0] + new_wasm_path = os.path.join(bindgen_out_dir, new_wasm_file) + + # Report which placeholder exports wasm-bindgen consumed so the caller can + # drop them from EXPORTED_FUNCTIONS. + removed_exports = exports_before - {e.name for e in webassembly.get_exports(new_wasm_path)} + + shutil.copyfile(new_wasm_path, infile) - shutil.copyfile(os.path.join(bindgen_out_dir, new_wasm_file), infile) + # wasm-bindgen emits imported JS snippets into `snippets/` and the `import` + # statements referencing them into `library_bindgen.extern-pre.js`, only when + # the crate actually imports JS. + extern_pre_js = os.path.join(bindgen_out_dir, 'library_bindgen.extern-pre.js') + if not os.path.exists(extern_pre_js): + extern_pre_js = None + snippets_dir = os.path.join(bindgen_out_dir, 'snippets') + if not os.path.isdir(snippets_dir): + snippets_dir = None - return os.path.join(bindgen_out_dir, 'library_bindgen.js') + return os.path.join(bindgen_out_dir, 'library_bindgen.js'), removed_exports, extern_pre_js, snippets_dir intermediate_counter = 0 diff --git a/tools/emscripten.py b/tools/emscripten.py index 2e0c23b8ebb33..b217e58e96d79 100644 --- a/tools/emscripten.py +++ b/tools/emscripten.py @@ -440,6 +440,13 @@ def emscript(in_wasm, out_wasm, outfile_js, js_syms, finalize=True, base_metadat pre += "}\n" report_missing_exports(forwarded_json['librarySymbols']) + # A JS library symbol is exported (MODULARIZE=instance) when it is in + # EXPORTED_FUNCTIONS; derive that set rather than tracking it separately. The + # forwarded EXPORTED_FUNCTIONS includes additions made by JS libraries (e.g. + # wasm-bindgen self-registering its exports). + exported_functions = set(forwarded_json['exportedFunctions']) + building.exported_js_library_symbols.update( + s for s in forwarded_json['librarySymbols'] if s in exported_functions) asm_const_pairs = ['%s: %s' % (key, value) for key, value in asm_consts] if asm_const_pairs or settings.MAIN_MODULE: @@ -610,8 +617,14 @@ def finalize_wasm(infile, outfile, js_syms): unexpected_exports = [asmjs_mangle(e) for e in unexpected_exports] unexpected_exports = [e for e in unexpected_exports if e not in expected_exports] + # Marker-driven flow (rustc linked via emcc, no user -sWASM_BINDGEN): rustc's + # EXPORTED_FUNCTIONS is the raw wasm export set, not a user-chosen API. Treat + # it like a build with no exports specified - `main` still runs as the entry, + # but no raw wasm exports are surfaced. + marker_driven = settings.WASM_BINDGEN and 'WASM_BINDGEN' not in user_settings + if (not settings.STANDALONE_WASM and 'main' in metadata.all_exports) or '__main_argc_argv' in metadata.all_exports: - if 'EXPORTED_FUNCTIONS' in user_settings and '_main' not in settings.USER_EXPORTS: + if not marker_driven and 'EXPORTED_FUNCTIONS' in user_settings and '_main' not in settings.USER_EXPORTS: # If `_main` was unexpectedly exported we assume it was added to # EXPORT_IF_DEFINED by `phase_linker_setup` in order that we can detect # it and report this warning. After reporting the warning we explicitly @@ -626,6 +639,11 @@ def finalize_wasm(infile, outfile, js_syms): else: unexpected_exports.append('_main') + # The user-facing API is exclusively wasm-bindgen's library symbols; the raw + # wasm exports are internal (including `_main`, which still runs via the entry). + if marker_driven: + unexpected_exports = [] + building.user_requested_exports.update(unexpected_exports) settings.EXPORTED_FUNCTIONS.extend(unexpected_exports) diff --git a/tools/link.py b/tools/link.py index 0b633c110864b..fa6d883bbee76 100644 --- a/tools/link.py +++ b/tools/link.py @@ -1903,9 +1903,39 @@ def phase_post_link(options, in_wasm, wasm_target, target, js_syms, base_metadat settings.TARGET_JS_NAME = os.path.basename(js_target) + # Two wasm-bindgen modes: + # - C++-driven: user passes -sWASM_BINDGEN and owns EXPORTED_FUNCTIONS (the + # staticlib flow); their exports are left untouched. + # - marker-driven: user did *not* pass it, but cargo/rustc linked via emcc and + # the wasm carries the marker section; rustc's EXPORTED_FUNCTIONS is the raw + # wasm export set, not a user-facing API (see below). + marker_driven = 'WASM_BINDGEN' not in user_settings and building.is_wasm_bindgen_module(in_wasm) + if marker_driven: + settings.WASM_BINDGEN = 1 + if settings.WASM_BINDGEN: - bindgen_jslib = building.run_wasm_bindgen(in_wasm) + bindgen_jslib, removed_exports, extern_pre_js, snippets_dir = building.run_wasm_bindgen(in_wasm) settings.JS_LIBRARIES.append(bindgen_jslib) + # Drop the placeholder symbols wasm-bindgen consumed so they aren't reported + # as undefined exports. + removed = {shared.asmjs_mangle(e) for e in removed_exports} + settings.EXPORTED_FUNCTIONS = [e for e in settings.EXPORTED_FUNCTIONS if e not in removed] + settings.USER_EXPORTS = [e for e in settings.USER_EXPORTS if e not in removed] + building.user_requested_exports.difference_update(removed) + if marker_driven: + # rustc's exports are all wasm exports the glue reaches by name, not a + # user-facing API. Drop them from every user-export layer: the ESM wrapper + # (user_requested_exports) and the factory Module attachment + # (EXPORTED_FUNCTIONS, via should_export). + settings.EXPORTED_FUNCTIONS = [e for e in settings.EXPORTED_FUNCTIONS if e not in settings.USER_EXPORTS] + settings.USER_EXPORTS = [] + building.user_requested_exports.clear() + # Imported JS: emit wasm-bindgen's `import` statements as extern-pre-js and + # place the snippet files alongside the output so relative imports resolve. + if extern_pre_js: + options.extern_pre_js.append(extern_pre_js) + if snippets_dir: + shutil.copytree(snippets_dir, os.path.join(os.path.dirname(js_target), 'snippets'), dirs_exist_ok=True) metadata = phase_emscript(in_wasm, wasm_target, js_syms, base_metadata) @@ -2140,6 +2170,9 @@ def node_detection_code(): def create_esm_wrapper(wrapper_file, support_target, wasm_target): js_exports = building.user_requested_exports.union(settings.EXPORTED_RUNTIME_METHODS) + # JS library symbols the support module exports at declaration (e.g. + # wasm-bindgen's); the wrapper must forward these too. + js_exports |= building.exported_js_library_symbols js_exports = ', '.join(sorted(js_exports)) wrapper = []