Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions src/jsifier.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions src/postamble.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}}';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are other places in codebase that also use wasmExports.. maybe we should remove the && WASM_BINDGEN so that they start working under WASM_ESM_INTEGRATION?

Actually I wonder how those codepaths work today? For example registerTLSInit(wasmExports['_emscripten_tls_init']);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — dropped the && WASM_BINDGEN so the namespace import applies to all WASM_ESM_INTEGRATION builds. It's safe because every wasmExports = ... assignment lives under #if !WASM_ESM_INTEGRATION (inside createWasm) or the non-instance branch, and WASM_ESM_INTEGRATION requires MODULARIZE=instance — so nothing assigns to the now read-only binding. Verified a plain C++ ESM build still builds and runs.

Re your TLS question: under ESM integration registerTLSInit already uses the named import (registerTLSInit(__emscripten_tls_init)) rather than wasmExports['_emscripten_tls_init'], which is why that path worked. This change makes the generic by-name wasmExports[...] accesses work under ESM integration too.

#else
var wasmExports;
#endif
#if SPLIT_MODULE
var wasmRawExports;
#endif
Expand Down
7 changes: 7 additions & 0 deletions test/rust/bindgen_greeter/.cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[build]
target = "wasm32-unknown-emscripten"
rustflags = [
"-Cllvm-args=-enable-emscripten-cxx-exceptions=0",
"-Cpanic=abort",
"-Crelocation-model=static",
]
10 changes: 10 additions & 0 deletions test/rust/bindgen_greeter/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[package]
name = "bindgen_greeter"
edition = "2021"

[[bin]]
name = "bindgen_greeter"
path = "src/main.rs"

[dependencies]
wasm-bindgen = "=0.2.126"
23 changes: 23 additions & 0 deletions test/rust/bindgen_greeter/src/main.rs
Original file line number Diff line number Diff line change
@@ -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");
}
56 changes: 54 additions & 2 deletions test/test_other.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand All @@ -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.<name>`). 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')
Expand Down
33 changes: 31 additions & 2 deletions tools/building.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we just have the rust compiler pass a flag rather than having magic behaviour like this? Maybe -sWASM_BINDGEN=auto (or some better name)?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm open to a flag. For rustc users it would be possible to pass as a -Clink-arg=-s... then. -sWASM_BINDGEN currently means a C/C++ build with Rust being linked in. We'd need to think more about if we could fully unify with that path, since right now it's a different type of bindgen path. Maybe it can be unified I'm not sure.

-sWASM_BINDGEN_RUST or something that makes it clear Rust is in charge of the top-level entry point bindings might make sense though.

Not sure what the best term is here either!



def run_wasm_bindgen(infile):
bindgen_out_dir = os.path.join(get_emscripten_temp_dir(), 'bindgen_out')

Expand All @@ -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
Expand Down
20 changes: 19 additions & 1 deletion tools/emscripten.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down
35 changes: 34 additions & 1 deletion tools/link.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 = []
Expand Down
Loading