From 7bc151b112d01e2b14345b03dbce272262f14672 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 15 May 2026 17:15:47 -0700 Subject: [PATCH] emit-tsd: add TSD_SKIP_EXPORTS for toolchain-owned exports; warn on unhandled multi-value returns When a higher-level toolchain (e.g. wasm-bindgen) lowers compound types to wasm-level (ptr, len) tuples and installs its own JS wrapper via --js-library, emcc has no way to recover the user-facing JS type from the raw wasm signature. Today --emit-tsd asserts on those exports. Add TSD_SKIP_EXPORTS so the toolchain can list the exports it owns. Multi-value-return exports not on the list are skipped with a warning instead of crashing the build. --- .../tools_reference/settings_reference.rst | 19 +++++++++ src/settings.js | 14 +++++++ test/other/test_emit_tsd_multivalue.c | 18 ++++++++ test/test_other.py | 41 +++++++++++++++++++ tools/emscripten.py | 19 +++++++-- 5 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 test/other/test_emit_tsd_multivalue.c diff --git a/site/source/docs/tools_reference/settings_reference.rst b/site/source/docs/tools_reference/settings_reference.rst index 986c25c5d0bc4..4d082abb75f25 100644 --- a/site/source/docs/tools_reference/settings_reference.rst +++ b/site/source/docs/tools_reference/settings_reference.rst @@ -1553,6 +1553,25 @@ It only does ``Module['X'] = X;`` Default value: true +.. _tsd_skip_exports: + +TSD_SKIP_EXPORTS +================ + +List of wasm export names that ``--emit-tsd`` should omit from the +generated ``.d.ts``. Intended for cases where a downstream toolchain +(e.g. wasm-bindgen, via ``--js-library``) owns the user-facing JS type +for those exports and provides its own type declarations. emcc has no +way to recover that JS-level type from the raw wasm signature, so the +right thing is to omit the export and let the toolchain's own ``.d.ts`` +supply the type via downstream merging. + +Wasm exports with multi-value returns that are not on this list are +skipped with a warning rather than typed, since the wasm-level tuple +type is rarely what JS callers actually see. + +Default value: [] + .. _retain_compiler_settings: RETAIN_COMPILER_SETTINGS diff --git a/src/settings.js b/src/settings.js index 1141fcd9807f2..8993859930aaa 100644 --- a/src/settings.js +++ b/src/settings.js @@ -1068,6 +1068,20 @@ var EXPORT_ALL = false; // It only does ``Module['X'] = X;`` var EXPORT_KEEPALIVE = true; +// List of wasm export names that ``--emit-tsd`` should omit from the +// generated ``.d.ts``. Intended for cases where a downstream toolchain +// (e.g. wasm-bindgen, via ``--js-library``) owns the user-facing JS type +// for those exports and provides its own type declarations. emcc has no +// way to recover that JS-level type from the raw wasm signature, so the +// right thing is to omit the export and let the toolchain's own ``.d.ts`` +// supply the type via downstream merging. +// +// Wasm exports with multi-value returns that are not on this list are +// skipped with a warning rather than typed, since the wasm-level tuple +// type is rarely what JS callers actually see. +// [link] +var TSD_SKIP_EXPORTS = []; + // Remembers the values of these settings, and makes them accessible // through getCompilerSetting and emscripten_get_compiler_setting. // To see what is retained, look for compilerSettings in the generated code. diff --git a/test/other/test_emit_tsd_multivalue.c b/test/other/test_emit_tsd_multivalue.c new file mode 100644 index 0000000000000..6f5e168f4cf14 --- /dev/null +++ b/test/other/test_emit_tsd_multivalue.c @@ -0,0 +1,18 @@ +#include + +struct Pair { int a; int b; }; + +// Returning a small struct by value with the experimental multi-value ABI +// causes clang to emit a wasm function with two return values (i32, i32). +// This mirrors the shape that higher-level toolchains (e.g. wasm-bindgen) +// produce when lowering compound types to (ptr, len) pairs. +EMSCRIPTEN_KEEPALIVE struct Pair make_pair(int a, int b) { + struct Pair p = {a, b}; + return p; +} + +EMSCRIPTEN_KEEPALIVE int add(int a, int b) { + return a + b; +} + +int main() {} diff --git a/test/test_other.py b/test/test_other.py index f77aa3e902129..68bb58f7f0f73 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -3803,6 +3803,47 @@ def test_emit_tsd_wasm_only(self): expected = 'Wasm only output is not compatible with --emit-tsd' self.assert_fail([EMCC, test_file('other/test_emit_tsd.c'), '--emit-tsd', 'test_emit_tsd_wasm_only.d.ts', '-o', 'out.wasm'], expected) + def _emit_tsd_multivalue_cmd(self, out_basename, extra=None): + return [EMCC, test_file('other/test_emit_tsd_multivalue.c'), + '-mmultivalue', '-Xclang', '-target-abi', '-Xclang', 'experimental-mv', + '--emit-tsd', f'{out_basename}.d.ts', + '-sMODULARIZE', '-sEXPORT_ES6', '-sEXPORT_KEEPALIVE', + '-o', f'{out_basename}.mjs'] + (extra or []) + self.get_cflags() + + def test_emit_tsd_multivalue_skipped(self): + # Listing a multi-value-return export in TSD_SKIP_EXPORTS produces a + # valid .d.ts with that export omitted and no warning. + proc = self.run_process(self._emit_tsd_multivalue_cmd( + 'test_emit_tsd_mv_skip', extra=['-sTSD_SKIP_EXPORTS=make_pair']), stderr=PIPE) + actual = read_file('test_emit_tsd_mv_skip.d.ts') + self.assertNotContained('make_pair', actual) + self.assertContained('_add(_0: number, _1: number): number;', actual) + self.assertNotContained('TSD_SKIP_EXPORTS', proc.stderr) + + def test_emit_tsd_multivalue_unhandled(self): + # A multi-value-return export not on the skip list is dropped from + # the .d.ts with a warning rather than crashing the build. + cmd = self._emit_tsd_multivalue_cmd('test_emit_tsd_mv_warn') + ['-Wno-error=emcc'] + proc = self.run_process(cmd, stderr=PIPE) + actual = read_file('test_emit_tsd_mv_warn.d.ts') + self.assertNotContained('make_pair', actual) + self.assertContained('_add(_0: number, _1: number): number;', actual) + self.assertContained('make_pair', proc.stderr) + self.assertContained('TSD_SKIP_EXPORTS', proc.stderr) + + def test_emit_tsd_skip_exports_unknown(self): + # Skip-list entries that don't match any export are silently ignored + # (toolchains may pass conservative lists). + proc = self.run_process([EMCC, test_file('other/test_emit_tsd.c'), + '--emit-tsd', 'test_emit_tsd_unknown_skip.d.ts', + '-sMODULARIZE', '-sEXPORT_ES6', + '-sTSD_SKIP_EXPORTS=does_not_exist', + '-o', 'test_emit_tsd_unknown_skip.mjs'] + + self.get_cflags(), stderr=PIPE) + actual = read_file('test_emit_tsd_unknown_skip.d.ts') + self.assertContained('_fooInt', actual) + self.assertNotContained('does_not_exist', proc.stderr) + @requires_dev_dependency('typescript') def test_emit_tsd_heap(self): self.run_process([EMCC, test_file('other/test_emit_tsd.c'), diff --git a/tools/emscripten.py b/tools/emscripten.py index c801a7465a6e0..44d85fb5278a7 100644 --- a/tools/emscripten.py +++ b/tools/emscripten.py @@ -686,23 +686,36 @@ def create_tsd(metadata, embind_tsd): out += create_tsd_exported_runtime_methods(metadata) # Manually generate definitions for any Wasm function exports. out += 'interface WasmModule {\n' + tsd_skip_exports = set(settings.TSD_SKIP_EXPORTS) for name, functype in metadata.function_exports.items(): mangled = asmjs_mangle(name) should_export = settings.EXPORT_KEEPALIVE and mangled in settings.EXPORTED_FUNCTIONS if not should_export: continue + # The toolchain that produced this wasm may own the user-facing JS + # type for this export via a --js-library wrapper. Skip it and let + # downstream .d.ts merging supply the real type. + if name in tsd_skip_exports or mangled in tsd_skip_exports: + continue + if len(functype.returns) > 1: + # Multi-value returns are valid wasm (e.g. wasm-bindgen lowers Rust + # `String` to a (ptr, len) pair) but the raw tuple is rarely what JS + # callers see. If the toolchain knew about this export it would + # have listed it in TSD_SKIP_EXPORTS; warn rather than guess a type. + diagnostics.warning('emcc', 'skipping wasm export "%s" from --emit-tsd: ' + 'multi-value return; add to TSD_SKIP_EXPORTS to silence', + name) + continue arguments = [] for index, type in enumerate(functype.params): arguments.append(f"_{index}: {type_to_ts_type(type)}") - out += f' {mangled}({", ".join(arguments)}): ' - assert len(functype.returns) <= 1, 'One return type only supported' if functype.returns: ret_ts_type = type_to_ts_type(functype.returns[0]) else: ret_ts_type = 'void' if settings.ASYNCIFY == 2 and any(fnmatch.fnmatch(name, pat) for pat in settings.ASYNCIFY_EXPORTS): ret_ts_type = f'Promise<{ret_ts_type}>' - out += f'{ret_ts_type};\n' + out += f' {mangled}({", ".join(arguments)}): {ret_ts_type};\n' out += '}\n' out += f'\n{embind_tsd}' # Combine all the various exports.