diff --git a/.circleci/config.yml b/.circleci/config.yml index b1436f4666ab2..1243a7372f1b4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -76,12 +76,17 @@ commands: install-rust: steps: - run: - name: install rust + name: install rust and wasm-bindgen + # The wasm-bindgen CLI must exactly match the library version the + # test crates pin. 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 + WB=wasm-bindgen-0.2.127-x86_64-unknown-linux-musl + curl -sSfL https://github.com/wasm-bindgen/wasm-bindgen/releases/download/0.2.127/$WB.tar.gz | tar xz -C /tmp + mv /tmp/$WB/wasm-bindgen ${HOME}/.cargo/bin/ install-node-version: description: "install a specific version of node" parameters: diff --git a/ChangeLog.md b/ChangeLog.md index 74ede3362cf3d..e09084933ccd9 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -24,6 +24,11 @@ See docs/process.md for more on how version tagging works. diagnostic warning has been removed. (#27646) - `WASM=0` and `WASM=2` (wasm2js) were marked as deprecated. (See #27608) - mimalloc was updated to 3.5.1. (#27662) +- `-sWASM_BINDGEN` supports emcc usage as a post-link step, where + `EXPORTED_FUNCTIONS` is authoritative. wasm-bindgen processing is only + performed when the linker inputs carry the wasm-bindgen Emscripten marker + section, so `-sWASM_BINDGEN` can safely be passed to non-wasm-bindgen builds. + (#27208) 6.0.9 - 09/01/26 ---------------- diff --git a/site/source/docs/tools_reference/settings_reference.rst b/site/source/docs/tools_reference/settings_reference.rst index 185aa492a06a3..3decfb5b6f220 100644 --- a/site/source/docs/tools_reference/settings_reference.rst +++ b/site/source/docs/tools_reference/settings_reference.rst @@ -3352,7 +3352,17 @@ Default value: [] WASM_BINDGEN ============ -Run wasm-bindgen and integrate the rust-exported symbols into the rest of Emscripten's JS output. +Run wasm-bindgen and integrate the rust-exported symbols into the rest of +Emscripten's JS output. +Even with this setting enabled, wasm-bindgen processing is only performed +when the linker inputs carry the wasm-bindgen Emscripten marker section +(emitted by the wasm-bindgen crate). When the marker is absent the build is +unchanged, so -sWASM_BINDGEN can safely be passed unconditionally to +non-wasm-bindgen builds, and by toolchains that link via emcc. +If EXPORTED_FUNCTIONS is set it is taken as the complete export list and +must include every export wasm-bindgen reaches by name (rustc supplies this +when driving the link). Otherwise those exports are discovered from the +linker inputs. .. note:: This is an experimental setting diff --git a/src/postamble.js b/src/postamble.js index a474aa57280f3..54ab9bbe3997c 100644 --- a/src/postamble.js +++ b/src/postamble.js @@ -238,7 +238,14 @@ function checkUnflushedContent() { #endif // EXIT_RUNTIME #endif // ASSERTIONS +#if WASM_ESM_INTEGRATION && WASM_BINDGEN +// wasm-bindgen's glue reaches the wasm exports by name on an aggregate object. +// TODO: Remove once the minimum wasm-bindgen version uses the per-export +// receiving bindings instead (wasm-bindgen/wasm-bindgen#5270). +import * as wasmExports from './{{{ WASM_BINARY_FILE }}}'; +#else var wasmExports; +#endif #if SPLIT_MODULE var wasmRawExports; #endif diff --git a/src/settings.js b/src/settings.js index 3ce85156f714d..667a51862e4fe 100644 --- a/src/settings.js +++ b/src/settings.js @@ -2223,7 +2223,17 @@ var LEGACY_RUNTIME = false; // [link] var SIGNATURE_CONVERSIONS = []; -// Run wasm-bindgen and integrate the rust-exported symbols into the rest of Emscripten's JS output. +// Run wasm-bindgen and integrate the rust-exported symbols into the rest of +// Emscripten's JS output. +// Even with this setting enabled, wasm-bindgen processing is only performed +// when the linker inputs carry the wasm-bindgen Emscripten marker section +// (emitted by the wasm-bindgen crate). When the marker is absent the build is +// unchanged, so -sWASM_BINDGEN can safely be passed unconditionally to +// non-wasm-bindgen builds, and by toolchains that link via emcc. +// If EXPORTED_FUNCTIONS is set it is taken as the complete export list and +// must include every export wasm-bindgen reaches by name (rustc supplies this +// when driving the link). Otherwise those exports are discovered from the +// linker inputs. // [link] // [experimental] var WASM_BINDGEN = 0; diff --git a/test/rust/bindgen_greeter/Cargo.toml b/test/rust/bindgen_greeter/Cargo.toml new file mode 100644 index 0000000000000..966fd3b838570 --- /dev/null +++ b/test/rust/bindgen_greeter/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "bindgen_greeter" +edition = "2021" + +[[bin]] +name = "bindgen_greeter" +path = "src/main.rs" + +[dependencies] +# 0.2.127 is the first release with the emscripten __export/__force attribute +# support (wasm-bindgen/wasm-bindgen#5253); must match the wasm-bindgen-cli +# version exactly. +wasm-bindgen = "0.2.127" 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 a4a2fedaee15c..18bfcb63c1290 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -272,6 +272,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) @@ -15324,22 +15329,97 @@ 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.127']) self.run_process(['cargo', 'build']) lib = 'target/wasm32-unknown-emscripten/debug/libbindgen_integration.a' self.assertExists(lib) - create_file('empty.c', '') + # A hand-written EMSCRIPTEN_KEEPALIVE C export must remain surfaced + # alongside wasm-bindgen's self-registered API; the wasm-bindgen glue + # suppression must not drop it. + create_file('native.c', ''' + #include + EMSCRIPTEN_KEEPALIVE int em_double(int x) { return x * 2; } + ''') create_file('post.js', ''' - Module.onRuntimeInitialized = () => out(Module.rs_add(17, 25)); + Module.onRuntimeInitialized = () => { + out('rs_add=' + Module.rs_add(17, 25)); + out('em_double=' + Module._em_double(20)); + }; ''') - self.run_process(['cargo', 'install', 'wasm-bindgen-cli']) - self.do_runf('empty.c', '42', cflags=[lib, '-sWASM_BINDGEN', '-Wno-experimental', '--post-js=post.js', '-lexports.js']) + output = self.do_runf('native.c', cflags=[lib, '-sWASM_BINDGEN', '-Wno-experimental', '--post-js=post.js', '-lexports.js']) + self.assertContained('rs_add=42', output) + self.assertContained('em_double=40', output) + + # ESM integration and ES6 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_integration': (['-sWASM_ESM_INTEGRATION'], ''' + import init, * as mod from './bindgen_greeter.js'; + await init(); + '''), + 'es6': (['-sMODULARIZE', '-sEXPORT_ES6'], ''' + import Module from './bindgen_greeter.js'; + const mod = await Module(); + '''), + }) + def test_wasm_bindgen_rustc_driven(self, ldflags, prelude): + # cargo/rustc links via emcc; pass -sWASM_BINDGEN (plus the output-mode + # settings) through as link args so emcc runs wasm-bindgen as a post-link step. + copytree(test_file('rust/bindgen_greeter'), '.') + link_args = ['-sWASM_BINDGEN', '-Wno-experimental'] + ldflags + rustflags = ', '.join(f'"-Clink-arg={a}"' for a in link_args) + ensure_dir('.cargo') + create_file('.cargo/config.toml', f''' + [build] + target = "wasm32-unknown-emscripten" + rustflags = [{rustflags}] + + [target.wasm32-unknown-emscripten] + linker = "{EMCC}" + ''') + 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); + ''') + # Importing wasm modules is stable from node 25. + if not self.try_require_node_version(25): + self.node_args += ['--experimental-wasm-modules'] + self.node_args += ['--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_wasm_bindgen_no_marker(self): + # -sWASM_BINDGEN is a no-op for an ordinary build with no wasm-bindgen + # marker section: wasm-bindgen is never invoked (so it need not be installed) + # and the program builds and runs normally. + self.do_runf('hello_world.c', 'Hello, world!', cflags=['-sWASM_BINDGEN', '-Wno-experimental']) @requires_rust + @requires_wasm_bindgen @requires_dev_dependency('typescript') def test_wasm_bindgen_tsd_multi_return(self): copytree(test_file('rust/bindgen_integration'), '.') @@ -15350,11 +15430,10 @@ def test_wasm_bindgen_tsd_multi_return(self): Ok(42) } ''') - self.run_process(['cargo', 'add', 'wasm-bindgen']) + self.run_process(['cargo', 'add', 'wasm-bindgen@0.2.127']) self.run_process(['cargo', 'build']) lib = 'target/wasm32-unknown-emscripten/debug/libbindgen_integration.a' create_file('empty.c', '') - self.run_process(['cargo', 'install', 'wasm-bindgen-cli']) self.run_process([EMCC, 'empty.c', '--emit-tsd', 'test_multi.d.ts', '-sWASM_BINDGEN', '-Wno-experimental', '-o', 'test_multi.js'] + [lib] + self.get_cflags()) actual = read_file('test_multi.d.ts') self.assertContained("multi_value_return(): [number, number, number];", actual) diff --git a/tools/building.py b/tools/building.py index 684146752d89d..06967d97e6a55 100644 --- a/tools/building.py +++ b/tools/building.py @@ -39,6 +39,7 @@ LLVM_DWARFDUMP, LLVM_NM, LLVM_OBJCOPY, + LLVM_OBJDUMP, WASM_LD, asmjs_mangle, check_call, @@ -62,6 +63,8 @@ user_requested_exports: set[str] = set() # JS library symbols exported via the `__export` decorator. extra_js_exports: set[str] = set() +# Mangled wasm exports wasm-bindgen's glue reaches by name, kept off the public surface. +wasm_bindgen_internal_exports: 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`. @@ -307,16 +310,12 @@ def get_wasm_bindgen_exported_symbols(input_files): return symbols -def lld_flags(args, linker_inputs=None): +def lld_flags(args): # lld doesn't currently support --start-group/--end-group since the # semantics are more like the windows linker where there is no need for # grouping. args = [a for a in args if a not in {'--start-group', '--end-group'}] - if settings.WASM_BINDGEN: - exported_symbols = get_wasm_bindgen_exported_symbols(linker_inputs) - args.extend(f'--export={e}' for e in exported_symbols) - # Emscripten currently expects linkable output (SIDE_MODULE/MAIN_MODULE) to # include all archive contents. if settings.LINKABLE and (settings.FAKE_DYLIBS or not settings.SIDE_MODULE): @@ -345,7 +344,7 @@ def lld_flags(args, linker_inputs=None): return args -def link_lld(args, target, external_symbols=None, linker_inputs=None): +def link_lld(args, target, external_symbols=None): # runs lld to link things. if not os.path.exists(WASM_LD): exit_with_error('linker binary not found in LLVM directory: %s', WASM_LD) @@ -354,7 +353,7 @@ def link_lld(args, target, external_symbols=None, linker_inputs=None): # normal linker flags that are used when building and executable if '--relocatable' not in args and '-r' not in args: cmd += lld_flags_for_executable(external_symbols) - cmd += lld_flags(args, linker_inputs) + cmd += lld_flags(args) cmd = get_command_with_possible_response_file(cmd) if settings.LINK_AS_CXX: check_call(cmd) @@ -1319,6 +1318,13 @@ def run_wasm_opt(infile, outfile=None, args=[], **kwargs): # ruff: ignore[mutab return run_binaryen_command('wasm-opt', infile, outfile, args=args, **kwargs) +def has_wasm_bindgen_marker(input_files): + if not input_files: + return False + result = check_call([LLVM_OBJDUMP, '--section-headers', *input_files], stdout=PIPE) + return '__wasm_bindgen_emscripten_marker' in result.stdout + + def run_wasm_bindgen(infile): bindgen_out_dir = os.path.join(get_emscripten_temp_dir(), 'bindgen_out') @@ -1333,16 +1339,31 @@ 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) + + exports_after = {e.name for e in webassembly.get_exports(new_wasm_path)} + removed_exports = exports_before - exports_after + added_exports = exports_after - exports_before + + shutil.copyfile(new_wasm_path, infile) - shutil.copyfile(os.path.join(bindgen_out_dir, new_wasm_file), infile) + # Only emitted when the crate imports JS snippets. + 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, added_exports, extern_pre_js, snippets_dir intermediate_counter = 0 diff --git a/tools/emscripten.py b/tools/emscripten.py index a9f9f878e72de..0651dfaaee4b0 100644 --- a/tools/emscripten.py +++ b/tools/emscripten.py @@ -610,6 +610,7 @@ def finalize_wasm(infile, outfile, js_syms): expected_exports = set(settings.EXPORTED_FUNCTIONS) expected_exports.update(asmjs_mangle(s) for s in settings.REQUIRED_EXPORTS) expected_exports.update(asmjs_mangle(s) for s in settings.EXPORT_IF_DEFINED) + expected_exports.update(building.wasm_bindgen_internal_exports) # Assume that when JS symbol dependencies are exported it is because they # are needed by by a JS symbol and are not being explicitly exported due # to EMSCRIPTEN_KEEPALIVE (llvm.used). @@ -637,7 +638,7 @@ def finalize_wasm(infile, outfile, js_syms): metadata.all_exports.remove('main') else: metadata.all_exports.remove('__main_argc_argv') - else: + elif '_main' not in building.wasm_bindgen_internal_exports: unexpected_exports.append('_main') building.user_requested_exports.update(unexpected_exports) diff --git a/tools/link.py b/tools/link.py index 5e97948b4be4e..dfa16c4220179 100644 --- a/tools/link.py +++ b/tools/link.py @@ -1890,12 +1890,22 @@ def phase_link(linker_args, linker_inputs, wasm_target, js_syms): # TODO(sbc): Remove this double execution of wasm-ld if we ever find a way to # distinguish EMSCRIPTEN_KEEPALIVE exports from `--export-dynamic` exports. settings.LINKABLE = False - building.link_lld(linker_args, wasm_target, external_symbols=js_syms, - linker_inputs=linker_inputs) + building.link_lld(linker_args, wasm_target, external_symbols=js_syms) settings.LINKABLE = True rtn = extract_metadata.extract_metadata(wasm_target) - building.link_lld(linker_args, wasm_target, external_symbols=js_syms, linker_inputs=linker_inputs) + # WASM_BINDGEN is a no-op unless the inputs carry the wasm-bindgen marker section. + if settings.WASM_BINDGEN and not building.has_wasm_bindgen_marker(linker_inputs): + settings.WASM_BINDGEN = 0 + + # If EXPORTED_FUNCTIONS is provided for WASM_BINDGEN, it forms the authoritative + # list of exports of the Wasm module (per rustc linking semantics). + # Otherwise, discover the symbols directly from the linker inputs for e.g. static + # linking Rust. + if settings.WASM_BINDGEN and 'EXPORTED_FUNCTIONS' not in user_settings: + linker_args += [f'--export={e}' for e in building.get_wasm_bindgen_exported_symbols(linker_inputs)] + + building.link_lld(linker_args, wasm_target, external_symbols=js_syms) return rtn @@ -1918,8 +1928,24 @@ def phase_post_link(in_wasm, wasm_target, target, js_syms, base_metadata=None): settings.TARGET_JS_NAME = os.path.basename(js_target) if settings.WASM_BINDGEN: - bindgen_jslib = building.run_wasm_bindgen(in_wasm) + bindgen_jslib, removed_exports, added_exports, extern_pre_js, snippets_dir = building.run_wasm_bindgen(in_wasm) settings.JS_LIBRARIES.append(bindgen_jslib) + # The exports wasm-bindgen reaches by name (the supplied EXPORTED_FUNCTIONS + # plus anything its expansion added) are internal glue only on the Wasm module, + # while wasm-bindgen's JS library registers the final user-facing API itself. + # Keep EXPORTED_FUNCTIONS off every export layer, and drop the placeholder exports it consumed + # (__wbindgen_describe*, etc.) so they aren't reported as undefined. + removed = {shared.asmjs_mangle(e) for e in removed_exports} + building.wasm_bindgen_internal_exports = ( + set(settings.USER_EXPORTS) | {shared.asmjs_mangle(e) for e in added_exports}) + drop = removed | building.wasm_bindgen_internal_exports + settings.EXPORTED_FUNCTIONS = [e for e in settings.EXPORTED_FUNCTIONS if e not in drop] + settings.USER_EXPORTS = [e for e in settings.USER_EXPORTS if e not in removed] + building.user_requested_exports.clear() + 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) diff --git a/tools/shared.py b/tools/shared.py index d20ea3742b195..f56786022e9a9 100644 --- a/tools/shared.py +++ b/tools/shared.py @@ -636,6 +636,7 @@ def init(): LLVM_NM = llvm_tool_path('llvm-nm') LLVM_DWARFDUMP = llvm_tool_path('llvm-dwarfdump') LLVM_OBJCOPY = llvm_tool_path('llvm-objcopy') +LLVM_OBJDUMP = llvm_tool_path('llvm-objdump') WASM_LD = llvm_tool_path('wasm-ld') LLVM_PROFDATA = llvm_tool_path('llvm-profdata') LLVM_COV = llvm_tool_path('llvm-cov')