Skip to content

Commit 7017b20

Browse files
committed
Run wasm-bindgen when its marker is present (cargo/rustc-driven flow)
When cargo/rustc drives the build with emcc as the linker, the linked wasm carries a __wasm_bindgen_emscripten_marker custom section and rustc supplies the exact -sEXPORTED_FUNCTIONS. Detect that marker in phase_post_link and run wasm-bindgen as a post-link step, the same way the -sWASM_BINDGEN staticlib flow does, without any export discovery. This defines two clearly-separated modes: - C++-driven (-sWASM_BINDGEN set): the user owns EXPORTED_FUNCTIONS; their exports are left untouched. - marker-driven (set not passed, marker detected): rustc's EXPORTED_FUNCTIONS is the raw wasm export set the generated glue reaches by name (the method shims, the __wbindgen_* runtime, the marker, main), not a user-facing API. The user-facing API is exactly what wasm-bindgen self-registers via its library (wasm-bindgen 0.2.126), so the rustc-supplied set is dropped from every user-export layer: the ESM wrapper (WASM_ESM_INTEGRATION, via user_requested_exports) and the factory Module attachment (MODULARIZE, via EXPORTED_FUNCTIONS / should_export), and the keepalive wasm exports are not surfaced. main still runs automatically on init, matching the emscripten idiom, even though _main is not exported. Both output modes then expose only the clean API (e.g. a `Greeter` class). - Strip the placeholder symbols wasm-bindgen consumes (__wbindgen_describe*, __externref_*, ...) from EXPORTED_FUNCTIONS so they aren't reported as undefined exports. - Wire imported JS: feed library_bindgen.extern-pre.js as extern-pre-js and copy the snippets/ dir next to the output so relative imports resolve. - Forward the JS library symbols that get a top-level export (MODULARIZE=instance) so the WASM_ESM_INTEGRATION wrapper re-exports them. - Under WASM_ESM_INTEGRATION && WASM_BINDGEN, provide wasmExports via a namespace import of the wasm so the glue's by-name access works. Add an end-to-end test (test/rust/bindgen_greeter) parameterized over the ESM and factory output modes, and install a pinned wasm-bindgen-cli alongside rust in CI so the flow is always exercised.
1 parent 8326028 commit 7017b20

10 files changed

Lines changed: 191 additions & 7 deletions

File tree

.circleci/config.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,18 @@ commands:
6868
install-rust:
6969
steps:
7070
- run:
71-
name: install rust
71+
name: install rust and wasm-bindgen
72+
# rust and wasm-bindgen are always installed together so there is no
73+
# CI environment with one but not the other. The wasm-bindgen-cli
74+
# version is pinned to match the library the test crate depends on;
75+
# wasm-bindgen requires the CLI and the library to be the exact same
76+
# version.
7277
command: |
7378
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
7479
export PATH=${HOME}/.cargo/bin:${PATH}
7580
rustup target add wasm32-unknown-emscripten
7681
echo "export PATH=\"\$HOME/.cargo/bin:\$PATH\"" >> $BASH_ENV
82+
cargo install wasm-bindgen-cli --version 0.2.126 --locked
7783
install-node-version:
7884
description: "install a specific version of node"
7985
parameters:

src/jsifier.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ const addedLibraryItems = {};
4747

4848
const extraLibraryFuncs = [];
4949

50+
// JS library symbols emitted with a top-level `export` (MODULARIZE=instance),
51+
// forwarded so the WASM_ESM_INTEGRATION wrapper can re-export them.
52+
const exportedLibrarySymbols = [];
53+
5054
// Experimental feature to check for invalid __deps entries.
5155
// See `EMCC_CHECK_DEPS` in in the environment to try it out.
5256
const CHECK_DEPS = process.env.EMCC_CHECK_DEPS;
@@ -803,6 +807,7 @@ function(${args}) {
803807
// In MODULARIZE=instance mode mark JS library symbols are exported at
804808
// the point of declaration.
805809
contentText = 'export ' + contentText;
810+
exportedLibrarySymbols.push(mangled);
806811
}
807812

808813
// Dynamic linking needs signatures to create proper wrappers.
@@ -934,6 +939,7 @@ var proxiedFunctionTable = [
934939
'//FORWARDED_DATA:' +
935940
JSON.stringify({
936941
librarySymbols,
942+
exportedLibrarySymbols,
937943
nativeAliases,
938944
warnings: warningOccured(),
939945
asyncFuncs,

src/postamble.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,14 @@ function checkUnflushedContent() {
242242
#endif // EXIT_RUNTIME
243243
#endif // ASSERTIONS
244244

245+
#if WASM_ESM_INTEGRATION && WASM_BINDGEN
246+
// wasm-bindgen's glue reaches the exports by name off a `wasmExports` object,
247+
// so provide the aggregate via a namespace import. Emscripten's own named
248+
// imports are unaffected and remain tree-shakable.
249+
import * as wasmExports from './{{{ WASM_BINARY_FILE }}}';
250+
#else
245251
var wasmExports;
252+
#endif
246253
#if SPLIT_MODULE
247254
var wasmRawExports;
248255
#endif
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[build]
2+
target = "wasm32-unknown-emscripten"
3+
rustflags = [
4+
"-Cllvm-args=-enable-emscripten-cxx-exceptions=0",
5+
"-Cpanic=abort",
6+
"-Crelocation-model=static",
7+
]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[package]
2+
name = "bindgen_greeter"
3+
edition = "2021"
4+
5+
[[bin]]
6+
name = "bindgen_greeter"
7+
path = "src/main.rs"
8+
9+
[dependencies]
10+
wasm-bindgen = "=0.2.126"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
use wasm_bindgen::prelude::*;
2+
3+
#[wasm_bindgen]
4+
pub struct Greeter {
5+
greeting: String,
6+
}
7+
8+
#[wasm_bindgen]
9+
impl Greeter {
10+
#[wasm_bindgen(constructor)]
11+
pub fn new(greeting: String) -> Greeter {
12+
Greeter { greeting }
13+
}
14+
15+
pub fn greet(&self, name: String) -> String {
16+
format!("{}, {}!", self.greeting, name)
17+
}
18+
}
19+
20+
fn main() {
21+
// Matches the emscripten idiom: main runs automatically on init.
22+
println!("main ran");
23+
}

test/test_other.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,11 @@ def requires_rust(func):
271271
return requires_tool('cargo', 'RUST')(func)
272272

273273

274+
def requires_wasm_bindgen(func):
275+
assert callable(func)
276+
return requires_tool('wasm-bindgen', 'WASM_BINDGEN')(func)
277+
278+
274279
def requires_pkg_config(func):
275280
assert callable(func)
276281

@@ -15014,9 +15019,12 @@ def test_rust_integration_basics(self):
1501415019
self.do_runf('main.cpp', 'Hello from rust!', cflags=[lib])
1501515020

1501615021
@requires_rust
15022+
@requires_wasm_bindgen
1501715023
def test_wasm_bindgen_integration(self):
1501815024
copytree(test_file('rust/bindgen_integration'), '.')
15019-
self.run_process(['cargo', 'add', 'wasm-bindgen'])
15025+
# Pin the library to the (managed) wasm-bindgen-cli version on PATH;
15026+
# wasm-bindgen requires the CLI and the library to match exactly.
15027+
self.run_process(['cargo', 'add', 'wasm-bindgen@=0.2.126'])
1502015028
self.run_process(['cargo', 'build'])
1502115029
lib = 'target/wasm32-unknown-emscripten/debug/libbindgen_integration.a'
1502215030
self.assertExists(lib)
@@ -15026,9 +15034,53 @@ def test_wasm_bindgen_integration(self):
1502615034
Module.onRuntimeInitialized = () => out(Module.rs_add(17, 25));
1502715035
''')
1502815036

15029-
self.run_process(['cargo', 'install', 'wasm-bindgen-cli'])
1503015037
self.do_runf('empty.c', '42', cflags=[lib, '-sWASM_BINDGEN', '--post-js=post.js', '-lexports.js'])
1503115038

15039+
# ESM-integration and factory (MODULARIZE) surface the clean wasm-bindgen API
15040+
# differently (named ESM exports vs `Module.<name>`). Both must expose exactly
15041+
# the `Greeter` class and none of the raw wasm exports rustc lists.
15042+
@requires_rust
15043+
@requires_wasm_bindgen
15044+
@parameterized({
15045+
'esm': (['-sWASM_ESM_INTEGRATION'], '''
15046+
import init, * as mod from './bindgen_greeter.js';
15047+
await init();
15048+
'''),
15049+
'factory': (['-sMODULARIZE', '-sEXPORT_ES6'], '''
15050+
import Module from './bindgen_greeter.js';
15051+
const mod = await Module();
15052+
'''),
15053+
})
15054+
def test_wasm_bindgen_rustc_driven(self, cflags, prelude):
15055+
# cargo/rustc links via emcc; the wasm carries wasm-bindgen's marker section,
15056+
# which emcc detects and runs wasm-bindgen against (no -sWASM_BINDGEN needed).
15057+
copytree(test_file('rust/bindgen_greeter'), '.')
15058+
# rustc invokes emcc as the linker; ensure it uses *this* emcc and pass the
15059+
# output-mode settings through.
15060+
with env_modify({'CARGO_TARGET_WASM32_UNKNOWN_EMSCRIPTEN_LINKER': EMCC,
15061+
'EMCC_CFLAGS': ' '.join(cflags)}):
15062+
self.run_process(['cargo', 'build'])
15063+
15064+
# cargo copies only the .js and .wasm; the ESM support module and snippets
15065+
# stay in deps/, so run from there.
15066+
out_dir = 'target/wasm32-unknown-emscripten/debug/deps'
15067+
create_file(os.path.join(out_dir, 'run.mjs'), prelude + '''
15068+
const greeting = new mod.Greeter('Hello').greet('world');
15069+
if (greeting !== 'Hello, world!') throw new Error('unexpected greeting: ' + greeting);
15070+
// None of the raw wasm exports leak into the user-facing API.
15071+
for (const name of ['_main', 'greeter_greet', '_greeter_greet',
15072+
'__wbindgen_malloc', '___wbindgen_malloc']) {
15073+
if (mod[name] !== undefined) throw new Error('leaked export: ' + name);
15074+
}
15075+
console.log(greeting);
15076+
''')
15077+
self.node_args += ['--experimental-wasm-modules', '--no-warnings']
15078+
output = self.run_js(os.path.join(out_dir, 'run.mjs'))
15079+
self.assertContained('Hello, world!', output)
15080+
# `main` runs automatically on init (matching the emscripten C++ idiom),
15081+
# even though `_main` is not surfaced as a user-facing export.
15082+
self.assertContained('main ran', output)
15083+
1503215084
def test_relative_em_cache(self):
1503315085
with env_modify({'EM_CACHE': 'foo'}):
1503415086
self.assert_fail([EMCC, '-c', test_file('hello_world.c')], 'emcc: error: environment variable EM_CACHE must be an absolute path: foo')

tools/building.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@
5757
_is_ar_cache: dict[str, bool] = {}
5858
# the exports the user requested
5959
user_requested_exports: set[str] = set()
60+
# JS library symbols emitted with a top-level `export` (MODULARIZE=instance),
61+
# used by the WASM_ESM_INTEGRATION wrapper to re-export them.
62+
exported_js_library_symbols: set[str] = set()
6063
# A list of feature flags to pass to each binaryen invocation (like `wasm-opt`,
6164
# etc.). This is received by the first call to binaryen (e.g. `wasm-emscripten-finalize`)
6265
# which reads it using `--detect-features`.
@@ -1285,6 +1288,14 @@ def run_wasm_opt(infile, outfile=None, args=[], **kwargs): # noqa
12851288
return run_binaryen_command('wasm-opt', infile, outfile, args=args, **kwargs)
12861289

12871290

1291+
def is_wasm_bindgen_module(wasm_file):
1292+
# wasm-bindgen marks modules built for the emscripten target with this custom
1293+
# section so emcc, when used as the linker (e.g. by cargo/rustc), knows to run
1294+
# wasm-bindgen as a post-link step.
1295+
with webassembly.Module(wasm_file) as module:
1296+
return module.get_custom_section('__wasm_bindgen_emscripten_marker') is not None
1297+
1298+
12881299
def run_wasm_bindgen(infile):
12891300
bindgen_out_dir = os.path.join(get_emscripten_temp_dir(), 'bindgen_out')
12901301

@@ -1299,16 +1310,33 @@ def run_wasm_bindgen(infile):
12991310
'--out-dir',
13001311
bindgen_out_dir,
13011312
]
1313+
exports_before = {e.name for e in webassembly.get_exports(infile)}
1314+
13021315
check_call(cmd)
13031316

13041317
# Don't try to predict the .wasm filename that wasm-bindgen outputs. Instead
13051318
# just grab the .wasm file itself.
13061319
all_output_files = os.listdir(bindgen_out_dir)
13071320
new_wasm_file = [x for x in all_output_files if x.endswith('.wasm')][0]
1321+
new_wasm_path = os.path.join(bindgen_out_dir, new_wasm_file)
1322+
1323+
# Report which placeholder exports wasm-bindgen consumed so the caller can
1324+
# drop them from EXPORTED_FUNCTIONS.
1325+
removed_exports = exports_before - {e.name for e in webassembly.get_exports(new_wasm_path)}
1326+
1327+
shutil.copyfile(new_wasm_path, infile)
13081328

1309-
shutil.copyfile(os.path.join(bindgen_out_dir, new_wasm_file), infile)
1329+
# wasm-bindgen emits imported JS snippets into `snippets/` and the `import`
1330+
# statements referencing them into `library_bindgen.extern-pre.js`, only when
1331+
# the crate actually imports JS.
1332+
extern_pre_js = os.path.join(bindgen_out_dir, 'library_bindgen.extern-pre.js')
1333+
if not os.path.exists(extern_pre_js):
1334+
extern_pre_js = None
1335+
snippets_dir = os.path.join(bindgen_out_dir, 'snippets')
1336+
if not os.path.isdir(snippets_dir):
1337+
snippets_dir = None
13101338

1311-
return os.path.join(bindgen_out_dir, 'library_bindgen.js')
1339+
return os.path.join(bindgen_out_dir, 'library_bindgen.js'), removed_exports, extern_pre_js, snippets_dir
13121340

13131341

13141342
intermediate_counter = 0

tools/emscripten.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@ def emscript(in_wasm, out_wasm, outfile_js, js_syms, finalize=True, base_metadat
440440
pre += "}\n"
441441

442442
report_missing_exports(forwarded_json['librarySymbols'])
443+
building.exported_js_library_symbols.update(forwarded_json['exportedLibrarySymbols'])
443444

444445
asm_const_pairs = ['%s: %s' % (key, value) for key, value in asm_consts]
445446
if asm_const_pairs or settings.MAIN_MODULE:
@@ -610,8 +611,14 @@ def finalize_wasm(infile, outfile, js_syms):
610611
unexpected_exports = [asmjs_mangle(e) for e in unexpected_exports]
611612
unexpected_exports = [e for e in unexpected_exports if e not in expected_exports]
612613

614+
# Marker-driven flow (rustc linked via emcc, no user -sWASM_BINDGEN): rustc's
615+
# EXPORTED_FUNCTIONS is the raw wasm export set, not a user-chosen API. Treat
616+
# it like a build with no exports specified - `main` still runs as the entry,
617+
# but no raw wasm exports are surfaced.
618+
marker_driven = settings.WASM_BINDGEN and 'WASM_BINDGEN' not in user_settings
619+
613620
if (not settings.STANDALONE_WASM and 'main' in metadata.all_exports) or '__main_argc_argv' in metadata.all_exports:
614-
if 'EXPORTED_FUNCTIONS' in user_settings and '_main' not in settings.USER_EXPORTS:
621+
if not marker_driven and 'EXPORTED_FUNCTIONS' in user_settings and '_main' not in settings.USER_EXPORTS:
615622
# If `_main` was unexpectedly exported we assume it was added to
616623
# EXPORT_IF_DEFINED by `phase_linker_setup` in order that we can detect
617624
# it and report this warning. After reporting the warning we explicitly
@@ -626,6 +633,11 @@ def finalize_wasm(infile, outfile, js_syms):
626633
else:
627634
unexpected_exports.append('_main')
628635

636+
# The user-facing API is exclusively wasm-bindgen's library symbols; the raw
637+
# wasm exports are internal (including `_main`, which still runs via the entry).
638+
if marker_driven:
639+
unexpected_exports = []
640+
629641
building.user_requested_exports.update(unexpected_exports)
630642
settings.EXPORTED_FUNCTIONS.extend(unexpected_exports)
631643

tools/link.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1903,9 +1903,39 @@ def phase_post_link(options, in_wasm, wasm_target, target, js_syms, base_metadat
19031903

19041904
settings.TARGET_JS_NAME = os.path.basename(js_target)
19051905

1906+
# Two wasm-bindgen modes:
1907+
# - C++-driven: user passes -sWASM_BINDGEN and owns EXPORTED_FUNCTIONS (the
1908+
# staticlib flow); their exports are left untouched.
1909+
# - marker-driven: user did *not* pass it, but cargo/rustc linked via emcc and
1910+
# the wasm carries the marker section; rustc's EXPORTED_FUNCTIONS is the raw
1911+
# wasm export set, not a user-facing API (see below).
1912+
marker_driven = 'WASM_BINDGEN' not in user_settings and building.is_wasm_bindgen_module(in_wasm)
1913+
if marker_driven:
1914+
settings.WASM_BINDGEN = 1
1915+
19061916
if settings.WASM_BINDGEN:
1907-
bindgen_jslib = building.run_wasm_bindgen(in_wasm)
1917+
bindgen_jslib, removed_exports, extern_pre_js, snippets_dir = building.run_wasm_bindgen(in_wasm)
19081918
settings.JS_LIBRARIES.append(bindgen_jslib)
1919+
# Drop the placeholder symbols wasm-bindgen consumed so they aren't reported
1920+
# as undefined exports.
1921+
removed = {shared.asmjs_mangle(e) for e in removed_exports}
1922+
settings.EXPORTED_FUNCTIONS = [e for e in settings.EXPORTED_FUNCTIONS if e not in removed]
1923+
settings.USER_EXPORTS = [e for e in settings.USER_EXPORTS if e not in removed]
1924+
building.user_requested_exports.difference_update(removed)
1925+
if marker_driven:
1926+
# rustc's exports are all wasm exports the glue reaches by name, not a
1927+
# user-facing API. Drop them from every user-export layer: the ESM wrapper
1928+
# (user_requested_exports) and the factory Module attachment
1929+
# (EXPORTED_FUNCTIONS, via should_export).
1930+
settings.EXPORTED_FUNCTIONS = [e for e in settings.EXPORTED_FUNCTIONS if e not in settings.USER_EXPORTS]
1931+
settings.USER_EXPORTS = []
1932+
building.user_requested_exports.clear()
1933+
# Imported JS: emit wasm-bindgen's `import` statements as extern-pre-js and
1934+
# place the snippet files alongside the output so relative imports resolve.
1935+
if extern_pre_js:
1936+
options.extern_pre_js.append(extern_pre_js)
1937+
if snippets_dir:
1938+
shutil.copytree(snippets_dir, os.path.join(os.path.dirname(js_target), 'snippets'), dirs_exist_ok=True)
19091939

19101940
metadata = phase_emscript(in_wasm, wasm_target, js_syms, base_metadata)
19111941

@@ -2140,6 +2170,9 @@ def node_detection_code():
21402170

21412171
def create_esm_wrapper(wrapper_file, support_target, wasm_target):
21422172
js_exports = building.user_requested_exports.union(settings.EXPORTED_RUNTIME_METHODS)
2173+
# JS library symbols the support module exports at declaration (e.g.
2174+
# wasm-bindgen's); the wrapper must forward these too.
2175+
js_exports |= building.exported_js_library_symbols
21432176
js_exports = ', '.join(sorted(js_exports))
21442177

21452178
wrapper = []

0 commit comments

Comments
 (0)