diff --git a/ChangeLog.md b/ChangeLog.md index 99d84f7ffb075..910c622690b2c 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -40,6 +40,10 @@ See docs/process.md for more on how version tagging works. in the next release. (#27261) - The default value for `GROWABLE_ARRAYBUFFERS` was reverted to `0` since we found issues with Web API compatibility. (#27260) +- Fixed `-sWASM_ESM_INTEGRATION` builds at `-O2` and above, which previously + failed in the JS optimizer's metadce (dead code elimination) pass. metadce now + understands the native ES import/export wasm boundary that this mode emits. + (#27217) 6.0.2 - 07/01/26 ---------------- diff --git a/src/jsifier.mjs b/src/jsifier.mjs index 84dc251827b75..85ccf42a60ad7 100644 --- a/src/jsifier.mjs +++ b/src/jsifier.mjs @@ -40,6 +40,8 @@ import { warningOccured, localFile, timer, + toValidIdentifier, + quoteExportName, } from './utility.mjs'; import {LibraryManager, librarySymbols, nativeAliases} from './modules.mjs'; @@ -604,6 +606,11 @@ function(${args}) { let isStub = false; const mangled = mangleCSymbolName(symbol); + // The JS binding identifier for this symbol. This is the same as + // `mangled` for the common case, but differs when `mangled` is not a + // valid JS identifier (e.g. a reserved word), in which case we bind it to + // a legal name and export it under its original name via an alias. + const binding = toValidIdentifier(mangled); if (!LibraryManager.library.hasOwnProperty(symbol)) { const isWeakImport = WEAK_IMPORTS.has(symbol); @@ -761,20 +768,20 @@ function(${args}) { // modifyJSFunction which could have changed or removed the name. if (contentText.match(/^\s*([^}]*)\s*=>/s)) { // Handle arrow functions - contentText = `var ${mangled} = ` + contentText + ';'; + contentText = `var ${binding} = ` + contentText + ';'; } else if (contentText.startsWith('class ')) { // Handle class declarations (which also have typeof == 'function'.) - contentText = contentText.replace(/^class(?:\s+(?!extends\b)[^{\s]+)?/, `class ${mangled}`); + contentText = contentText.replace(/^class(?:\s+(?!extends\b)[^{\s]+)?/, `class ${binding}`); } else { // Handle regular (non-arrow) functions - contentText = contentText.replace(/function(?:\s+([^(]+))?\s*\(/, `function ${mangled}(`); + contentText = contentText.replace(/function(?:\s+([^(]+))?\s*\(/, `function ${binding}(`); } } else if (typeof snippet == 'string' && snippet.startsWith(';')) { // In JS libraries // foo: ';[code here verbatim]' // emits // 'var foo;[code here verbatim];' - contentText = 'var ' + mangled + snippet; + contentText = 'var ' + binding + snippet; if (snippet[snippet.length - 1] != ';' && snippet[snippet.length - 1] != '}') { contentText += ';'; } @@ -786,7 +793,7 @@ function(${args}) { if (isNativeAlias) { contentText = ''; } else { - contentText = `var ${mangled};`; + contentText = `var ${binding};`; } } else { // In JS libraries @@ -796,13 +803,18 @@ function(${args}) { if (typeof snippet == 'string' && snippet[0] == '=') { snippet = snippet.slice(1); } - contentText = `var ${mangled} = ${snippet};`; + contentText = `var ${binding} = ${snippet};`; } if (contentText && MODULARIZE == 'instance' && (EXPORT_ALL || EXPORTED_FUNCTIONS.has(mangled)) && !isStub) { // In MODULARIZE=instance mode mark JS library symbols are exported at - // the point of declaration. - contentText = 'export ' + contentText; + // the point of declaration. When the binding had to be renamed to a + // legal identifier, export it under its original name via an alias. + if (binding == mangled) { + contentText = 'export ' + contentText; + } else { + contentText += `\nexport { ${binding} as ${quoteExportName(mangled)} };`; + } } // Dynamic linking needs signatures to create proper wrappers. @@ -810,14 +822,14 @@ function(${args}) { if (!WASM_BIGINT) { sig = sig[0].replace('j', 'i') + sig.slice(1).replace(/j/g, 'ii'); } - contentText += `\n${mangled}.sig = '${sig}';`; + contentText += `\n${binding}.sig = '${sig}';`; } if (ASYNCIFY && isAsyncFunction) { assert(isFunction); - contentText += `\n${mangled}.isAsync = true;`; + contentText += `\n${binding}.isAsync = true;`; } if (isStub) { - contentText += `\n${mangled}.stub = true;`; + contentText += `\n${binding}.stub = true;`; if (ASYNCIFY && MAIN_MODULE) { contentText += `\nasyncifyStubs['${symbol}'] = undefined;`; } diff --git a/src/modules.mjs b/src/modules.mjs index bc6aa5295f5fa..bd4d01a37536f 100644 --- a/src/modules.mjs +++ b/src/modules.mjs @@ -23,6 +23,8 @@ import { mergeInto, localFile, timer, + toValidIdentifier, + quoteExportName, } from './utility.mjs'; import {preprocess, processMacros} from './parseTools.mjs'; @@ -463,12 +465,13 @@ function addMissingLibraryStubs(unusedLibSymbols) { } function exportSymbol(name) { + const binding = toValidIdentifier(name); // In MODULARIZE=instance mode symbols are exported by being included in // an export { foo, bar } list so we build up the simple list of names if (MODULARIZE === 'instance') { - return name; + return binding == name ? name : `${binding} as ${quoteExportName(name)}`; } - return `Module['${name}'] = ${name};`; + return `Module['${name}'] = ${binding};`; } // export parts of the JS runtime that the user asked for diff --git a/src/utility.mjs b/src/utility.mjs index 7d185e0c89fa5..f531a544217a7 100644 --- a/src/utility.mjs +++ b/src/utility.mjs @@ -17,6 +17,59 @@ export function safeQuote(x) { return x.replace(/"/g, '\\"').replace(/'/g, "\\'"); } +// JS reserved words that are otherwise valid identifiers but cannot be used as +// binding names (e.g. `var default = ...` is a syntax error). +const JS_RESERVED_WORDS = new Set([ + 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', + 'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', + 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof', 'new', + 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', + 'typeof', 'var', 'void', 'while', 'with', 'yield', 'let', 'static', + 'implements', 'interface', 'package', 'private', 'protected', 'public', + 'await', +]); + +// Whether `name` can be used verbatim as a JS binding identifier (e.g. after +// `var` or in an `export { x }` clause). +export function isValidIdentifier(name) { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !JS_RESERVED_WORDS.has(name); +} + +// Format `name` for the exported-name position of an ESM export/import clause +// (the part after `as`). Reserved words are legal there verbatim, but names +// with illegal characters must use the string-literal form. +export function quoteExportName(name) { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name); +} + +// Maps a desired name to the JS identifier we use as its internal binding. +// Names that are already valid identifiers map to themselves. Others (reserved +// words, or names with illegal characters) are rewritten to a legal identifier, +// with a numeric suffix appended to disambiguate any collisions. The mapping is +// memoized so that a given name always resolves to the same binding. +const legalizedIdentifiers = new Map(); +const usedIdentifiers = new Set(); + +export function toValidIdentifier(name) { + let ident = legalizedIdentifiers.get(name); + if (ident !== undefined) return ident; + if (isValidIdentifier(name)) { + ident = name; + } else { + let base = name.replace(/[^A-Za-z0-9_$]/g, '_'); + if (!/^[A-Za-z_$]/.test(base) || JS_RESERVED_WORDS.has(base)) { + base = '$' + base; + } + ident = base; + for (let i = 0; usedIdentifiers.has(ident); i++) { + ident = base + '_' + i; + } + } + legalizedIdentifiers.set(name, ident); + usedIdentifiers.add(ident); + return ident; +} + export function dump(item) { let funcData; try { diff --git a/test/codesize/test_codesize_hello_esm_integration.json b/test/codesize/test_codesize_hello_esm_integration.json new file mode 100644 index 0000000000000..227dfb0bb9776 --- /dev/null +++ b/test/codesize/test_codesize_hello_esm_integration.json @@ -0,0 +1,29 @@ +{ + "a.out.mjs": 671, + "a.out.mjs.gz": 401, + "a.out.support.mjs": 6397, + "a.out.support.mjs.gz": 2473, + "a.out.nodebug.wasm": 1688, + "a.out.nodebug.wasm.gz": 973, + "total": 8756, + "total_gz": 3847, + "sent": [ + "a (fd_write)" + ], + "imports": [ + "a (fd_write)" + ], + "exports": [ + "b (memory)", + "c (__wasm_call_ctors)", + "d (main)" + ], + "funcs": [ + "$__emscripten_stdout_close", + "$__emscripten_stdout_seek", + "$__stdio_write", + "$__towrite", + "$__wasm_call_ctors", + "$main" + ] +} diff --git a/test/codesize/test_codesize_minimal_esm_integration.json b/test/codesize/test_codesize_minimal_esm_integration.json new file mode 100644 index 0000000000000..f6d6b9716712d --- /dev/null +++ b/test/codesize/test_codesize_minimal_esm_integration.json @@ -0,0 +1,22 @@ +{ + "a.out.mjs": 683, + "a.out.mjs.gz": 410, + "a.out.support.mjs": 3343, + "a.out.support.mjs.gz": 1350, + "a.out.nodebug.wasm": 75, + "a.out.nodebug.wasm.gz": 87, + "total": 4101, + "total_gz": 1847, + "sent": [], + "imports": [], + "exports": [ + "a (memory)", + "b (__wasm_call_ctors)", + "c (add)", + "d (global_val)" + ], + "funcs": [ + "$__wasm_call_ctors", + "$add" + ] +} diff --git a/test/js_optimizer/applyDCEGraphRemovals-esm-output.js b/test/js_optimizer/applyDCEGraphRemovals-esm-output.js new file mode 100644 index 0000000000000..4530f155da741 --- /dev/null +++ b/test/js_optimizer/applyDCEGraphRemovals-esm-output.js @@ -0,0 +1,19 @@ +function fd_write_impl() {} + +function fd_close_impl() {} + +function unused_import_impl() {} + +import { memory, main as _main, used_export as _used_export, memory as wasmMemory } from "./a.out.wasm"; + +export { fd_write_impl as fd_write, fd_close_impl as fd_close }; + +export { _main }; + +var HEAP32; + +export { HEAP32 }; + +var $default = {}; + +export { $default as default }; diff --git a/test/js_optimizer/applyDCEGraphRemovals-esm.js b/test/js_optimizer/applyDCEGraphRemovals-esm.js new file mode 100644 index 0000000000000..02dbdba64e9b2 --- /dev/null +++ b/test/js_optimizer/applyDCEGraphRemovals-esm.js @@ -0,0 +1,39 @@ +// WASM_ESM_INTEGRATION: unused wasm imports are dropped from the `export {..}` +// that sends JS functions to the wasm, and unused wasm exports (including +// internal ones like the indirect function table) from the `import {..}` that +// receives them, keeping the two ES module interfaces in sync. + +function fd_write_impl() { +} +function fd_close_impl() { +} +function unused_import_impl() { +} + +import { + memory, + __indirect_function_table, + main as _main, + used_export as _used_export, + unused_export as _unused_export, + memory as wasmMemory, +} from './a.out.wasm'; + +export { + fd_write_impl as fd_write, + fd_close_impl as fd_close, + unused_import_impl as unused_import, +}; + +export { _main }; + +// MODULARIZE=instance runtime exports (bare form) must be left untouched. +var HEAP32; +export { HEAP32 }; + +// A reserved-word runtime export (aliased `$default as default`) must never be +// treated as a wasm import and dropped, even though it is aliased. +var $default = {}; +export { $default as default }; + +// EXTRA_INFO: { "unusedImports": ["unused_import"], "unusedExports": ["unused_export", "__indirect_function_table"] } diff --git a/test/js_optimizer/applyImportAndExportNameChanges-esm-output.js b/test/js_optimizer/applyImportAndExportNameChanges-esm-output.js new file mode 100644 index 0000000000000..4bd68e61e23a8 --- /dev/null +++ b/test/js_optimizer/applyImportAndExportNameChanges-esm-output.js @@ -0,0 +1,13 @@ +function fd_write_impl() {} + +function fd_close_impl() {} + +import { memory, b as _main, c as _malloc, memory as wasmMemory } from "./a.out.wasm"; + +export { fd_write_impl as d, fd_close_impl as e }; + +export { _main }; + +var HEAP32; + +export { HEAP32 }; diff --git a/test/js_optimizer/applyImportAndExportNameChanges-esm.js b/test/js_optimizer/applyImportAndExportNameChanges-esm.js new file mode 100644 index 0000000000000..78fbc90bc73ea --- /dev/null +++ b/test/js_optimizer/applyImportAndExportNameChanges-esm.js @@ -0,0 +1,30 @@ +// WASM_ESM_INTEGRATION: minified wasm import/export names are applied to the +// native ES import/export specifiers. Only the wasm-facing name of each +// specifier is renamed; the JS-local binding name is left intact (including the +// unaliased `memory`, whose local side must survive). + +function fd_write_impl() { +} +function fd_close_impl() { +} + +import { + memory, + main as _main, + malloc as _malloc, + memory as wasmMemory, +} from './a.out.wasm'; + +export { + fd_write_impl as fd_write, + fd_close_impl as fd_close, +}; + +// Re-export of a wasm export: the local name (_main) is never in the mapping. +export { _main }; + +// MODULARIZE=instance runtime export (bare form): never in the mapping. +var HEAP32; +export { HEAP32 }; + +// EXTRA_INFO: { "mapping": { "main": "b", "malloc": "c", "fd_write": "d", "fd_close": "e" } } diff --git a/test/js_optimizer/emitDCEGraph-esm-output.js b/test/js_optimizer/emitDCEGraph-esm-output.js new file mode 100644 index 0000000000000..b32c1783b307d --- /dev/null +++ b/test/js_optimizer/emitDCEGraph-esm-output.js @@ -0,0 +1,78 @@ +[ + { + "name": "emcc$defun$fd_close_impl", + "reaches": [ + "emcc$defun$helper" + ] + }, + { + "name": "emcc$defun$fd_write_impl", + "reaches": [] + }, + { + "name": "emcc$defun$helper", + "reaches": [] + }, + { + "name": "emcc$defun$unused_import_impl", + "reaches": [] + }, + { + "name": "emcc$export$__indirect_function_table", + "export": "__indirect_function_table", + "reaches": [] + }, + { + "name": "emcc$export$_main", + "export": "main", + "reaches": [], + "root": true + }, + { + "name": "emcc$export$_unused_export", + "export": "unused_export", + "reaches": [] + }, + { + "name": "emcc$export$_used_export", + "export": "used_export", + "reaches": [], + "root": true + }, + { + "name": "emcc$export$memory", + "export": "memory", + "reaches": [], + "root": true + }, + { + "name": "emcc$import$fd_close_impl", + "import": [ + "env", + "fd_close" + ], + "reaches": [ + "emcc$defun$fd_close_impl" + ] + }, + { + "name": "emcc$import$fd_write_impl", + "import": [ + "env", + "fd_write" + ], + "reaches": [ + "emcc$defun$fd_write_impl" + ] + }, + { + "name": "emcc$import$unused_import_impl", + "import": [ + "env", + "unused_import" + ], + "reaches": [ + "emcc$defun$unused_import_impl" + ] + } +] diff --git a/test/js_optimizer/emitDCEGraph-esm.js b/test/js_optimizer/emitDCEGraph-esm.js new file mode 100644 index 0000000000000..853f97e0f3110 --- /dev/null +++ b/test/js_optimizer/emitDCEGraph-esm.js @@ -0,0 +1,56 @@ +// WASM_ESM_INTEGRATION: the wasm<->JS boundary is expressed with native ES +// import/export syntax rather than the `wasmImports` object and +// `wasmExports['x']` member uses, and emitDCEGraph must build the same graph +// from it. + +// JS functions implementing wasm imports. +function fd_write_impl() { +} +function unused_import_impl() { +} + +// A JS function only reachable from a wasm import edge. +function helper() { +} +function fd_close_impl() { + helper(); +} + +// wasm exports received as ES imports. `memory` is imported twice (plain and +// aliased) and must map to a single export node. +import { + memory, + __indirect_function_table, + main as _main, + used_export as _used_export, + unused_export as _unused_export, + memory as wasmMemory, +} from './a.out.wasm'; + +// JS functions exported to the wasm module (the wasm imports). +export { + fd_write_impl as fd_write, + fd_close_impl as fd_close, + unused_import_impl as unused_import, +}; + +// Re-export of a wasm export to the JS entry point: a top-level use that should +// root the underlying `main` export, not be treated as a wasm import. +export { _main }; + +// MODULARIZE=instance runtime exports. These are plain JS bindings, not wasm +// import edges, and must be left untouched (bare `export {..}` form). +var HEAP32; +var baz = () => {}; +export { HEAP32, baz }; + +// A reserved-word runtime export (from the reserved-export-names support): the +// local is legalized to `$default` and aliased back to `default`. Though +// aliased like a wasm import edge, a wasm import name can never be `default`, +// so this must be treated as a plain runtime export, not a wasm import. +var $default = {}; +export { $default as default }; + +// Top-level uses: root the memory export (via the alias) and one wasm export. +wasmMemory.buffer; +_used_export(); diff --git a/test/js_optimizer/emitDCEGraph-sourcePhaseImports-output.js b/test/js_optimizer/emitDCEGraph-sourcePhaseImports-output.js new file mode 100644 index 0000000000000..dffdb995366ea --- /dev/null +++ b/test/js_optimizer/emitDCEGraph-sourcePhaseImports-output.js @@ -0,0 +1,38 @@ +[ + { + "name": "emcc$defun$fd_write_impl", + "reaches": [] + }, + { + "name": "emcc$export$_main", + "export": "main", + "reaches": [], + "root": true + }, + { + "name": "emcc$export$_unused_export", + "export": "unused_export", + "reaches": [] + }, + { + "name": "emcc$export$_used_export", + "export": "used_export", + "reaches": [], + "root": true + }, + { + "name": "emcc$export$memory", + "export": "memory", + "reaches": [] + }, + { + "name": "emcc$import$fd_write_impl", + "import": [ + "env", + "fd_write" + ], + "reaches": [ + "emcc$defun$fd_write_impl" + ] + } +] diff --git a/test/js_optimizer/emitDCEGraph-sourcePhaseImports.js b/test/js_optimizer/emitDCEGraph-sourcePhaseImports.js new file mode 100644 index 0000000000000..b563d380f3c32 --- /dev/null +++ b/test/js_optimizer/emitDCEGraph-sourcePhaseImports.js @@ -0,0 +1,29 @@ +// SOURCE_PHASE_IMPORTS combined with WASM_ESM_INTEGRATION: the glue emits a +// source-phase import for the wasm module alongside the value-phase import that +// binds the wasm exports. emitDCEGraph must skip the source-phase import +// (its specifier has no `imported`) and only treat the value-phase `.wasm` +// import as wasm export bindings. + +// Source-phase import of the wasm module itself. This is NOT a wasm export +// binding and must be ignored by the DCE graph builder. +import source wasmModule from './a.out.wasm'; + +function fd_write_impl() { +} + +// wasm exports received as ES imports (value-phase). +import { + memory, + main as _main, + used_export as _used_export, + unused_export as _unused_export, +} from './a.out.wasm'; + +export { + fd_write_impl as fd_write, +}; + +export { _main }; + +WebAssembly.instantiate(wasmModule); +_used_export(); diff --git a/test/test_codesize.py b/test/test_codesize.py index de0cea10d6ae9..15ef6e106c881 100644 --- a/test/test_codesize.py +++ b/test/test_codesize.py @@ -226,39 +226,68 @@ def run_codesize_test(self, filename, cflags, check_funcs=True, check_full_js=Fa filename = test_file('codesize', filename) expected_basename = test_file('codesize', self.id().split('.')[-1]) - # Run once without closure and parse output to find wasmImports - build_cmd = [compiler_for(filename), filename, '--output-eol=linux', '--emit-minification-map=minify.map'] + cflags + self.get_cflags() + # Under WASM_ESM_INTEGRATION the wasm<->JS boundary uses native ES + # import/export syntax and the JS glue lives in a separate support module. + esm = any(a.startswith('-sWASM_ESM_INTEGRATION') for a in cflags) + outfile = 'a.out.mjs' if esm else 'a.out.js' + js_file = 'a.out.support.mjs' if esm else outfile + + # Run once without closure and parse output to find the JS->wasm imports. + build_cmd = [compiler_for(filename), filename, '-o', outfile, '--output-eol=linux', '--emit-minification-map=minify.map'] + cflags + self.get_cflags() self.run_process(build_cmd + ['-g2']) # find the imports we send from JS # TODO(sbc): Find a way to do that that doesn't depend on internal details of # the generated code. - js = read_file('a.out.js') + js = read_file(js_file) if check_full_js: # Ignore absolute filenames in the generated code (they are likely /tmp files) js = re.sub(r'^// include: .*[/\\].*$', '// include: ', js, flags=re.MULTILINE) js = re.sub(r'^// end include: .*[/\\].*$', '// end include: ', js, flags=re.MULTILINE) self.assertFileContents(expected_basename + '.expected.js', js) - start = js.find('wasmImports = ') - self.assertNotEqual(start, -1) - end = js.find('}', start) - self.assertNotEqual(end, -1) - start = js.find('{', start) - self.assertNotEqual(start, -1) - relevant = js[start + 2:end - 1] - relevant = relevant.replace(' ', '').replace('"', '').replace("'", '') - relevant = relevant.replace('/**@export*/', '') - relevant = relevant.split(',') - sent = [x.split(':')[0].strip() for x in relevant] - sent = [x for x in sent if x] + if esm: + # The functions provided to wasm are emitted as `export { js as wasm }` + # specifiers under the "Export JS functions to the wasm module" comment. + # An empty set emits no export statement at all. + marker = js.find('Export JS functions to the wasm module') + self.assertNotEqual(marker, -1) + start = js.find('export', marker) + reexport = js.find('Re-export imported wasm functions', marker) + sent = [] + if start != -1 and (reexport == -1 or start < reexport): + end = js.find('}', start) + self.assertNotEqual(end, -1) + start = js.find('{', start) + for specifier in js[start + 1:end].split(','): + specifier = specifier.strip() + if specifier: + # keep the wasm-facing (minified) name after `as` + sent.append(specifier.rsplit(' as ', 1)[-1].strip()) + else: + start = js.find('wasmImports = ') + self.assertNotEqual(start, -1) + end = js.find('}', start) + self.assertNotEqual(end, -1) + start = js.find('{', start) + self.assertNotEqual(start, -1) + relevant = js[start + 2:end - 1] + relevant = relevant.replace(' ', '').replace('"', '').replace("'", '') + relevant = relevant.replace('/**@export*/', '') + relevant = relevant.split(',') + sent = [x.split(':')[0].strip() for x in relevant] + sent = [x for x in sent if x] # Deminify the sent list, if minification occurred if os.path.exists('minify.map'): sent = deminify_syms(sent, 'minify.map') os.remove('minify.map') sent.sort() - self.run_process(build_cmd + ['--profiling-funcs', '--closure=1']) + # closure is not yet compatible with the WASM_ESM_INTEGRATION module glue. + closure_args = [] if esm else ['--closure=1'] + self.run_process(build_cmd + ['--profiling-funcs'] + closure_args) - outputs = ['a.out.js'] + outputs = [outfile] + if esm: + outputs.append(js_file) info = {'sent': sent} if '-sSINGLE_FILE' not in cflags: @@ -270,7 +299,9 @@ def run_codesize_test(self, filename, cflags, check_funcs=True, check_full_js=Fa # Deminify the imports/export lists, if minification occurred if os.path.exists('minify.map'): exports = deminify_syms(exports, 'minify.map') - imports = [i.split('.', 1)[1] for i in imports] + # Under ESM integration the import module is the support module path + # (which itself contains dots), so take the field after the last dot. + imports = [i.rsplit('.', 1)[1] for i in imports] imports = deminify_syms(imports, 'minify.map') imports.sort() @@ -311,6 +342,9 @@ def strip_numeric_suffixes(funcname): # WasmFS should not be fully linked into a minimal program. 'wasmfs': (['-Oz', '-sWASMFS'],), 'esm': (['-Oz', '-sEXPORT_ES6'],), + # a program with no JS->wasm imports emits no `export {}` statement in the + # support module, exercising that metadce edge of the ES module boundary. + 'esm_integration': (['-Oz', '-sWASM_ESM_INTEGRATION', '-Wno-experimental'],), }) def test_codesize_minimal(self, args, check_full_js=False): self.set_setting('STRICT') @@ -366,6 +400,9 @@ def test_codesize_cxx(self, args): # WasmFS should not be fully linked into a hello world program. 'wasmfs': (['-O3', '-sWASMFS'],), 'single_file': (['-O3', '-sSINGLE_FILE'],), + # metadce must understand the native ES import/export module boundary and + # prune unused imports/exports from it. See #27217. + 'esm_integration': (['-O3', '-sWASM_ESM_INTEGRATION', '-Wno-experimental'],), }) def test_codesize_hello(self, args, kwargs={}): # noqa self.run_codesize_test('hello_world.c', args, **kwargs) diff --git a/test/test_other.py b/test/test_other.py index 4ac039a23e7a2..f994ecc068286 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -3058,10 +3058,14 @@ def test_extern_prepost(self): 'emitDCEGraph-sig': (['emitDCEGraph', '--no-print'],), 'emitDCEGraph-prefixing': (['emitDCEGraph', '--no-print'],), 'emitDCEGraph-scopes': (['emitDCEGraph', '--no-print'],), + 'emitDCEGraph-esm': (['emitDCEGraph', '--no-print', '--export-es6'],), + 'emitDCEGraph-sourcePhaseImports': (['emitDCEGraph', '--no-print', '--export-es6'],), 'minimal-runtime-applyDCEGraphRemovals': (['applyDCEGraphRemovals'],), 'applyDCEGraphRemovals': (['applyDCEGraphRemovals'],), + 'applyDCEGraphRemovals-esm': (['applyDCEGraphRemovals', '--export-es6'],), 'applyImportAndExportNameChanges': (['applyImportAndExportNameChanges'],), 'applyImportAndExportNameChanges2': (['applyImportAndExportNameChanges'],), + 'applyImportAndExportNameChanges-esm': (['applyImportAndExportNameChanges', '--export-es6'],), 'minimal-runtime-emitDCEGraph': (['emitDCEGraph', '--no-print'],), 'minimal-runtime-2-emitDCEGraph': (['emitDCEGraph', '--no-print'],), 'standalone-emitDCEGraph': (['emitDCEGraph', '--no-print'],), @@ -6258,6 +6262,34 @@ def test_modularize_instance_auto_init(self): self.assertContained('main1\nmain2\nfoo\nbar\nbaz\n', self.run_js('runner.mjs')) + def test_modularize_instance_reserved_export(self): + # Export names that are JS reserved words (and so not valid binding + # identifiers) are bound to a legal name internally and exported under their + # original name via an alias. With AUTO_INIT the `default` slot is free. + create_file('library.js', '''\ + addToLibrary({ + $default: () => { console.log('default-export'); return 42; }, + $delete: () => console.log('delete-export'), + });''') + self.run_process([EMCC, test_file('modularize_instance.c'), + '-sMODULARIZE=instance', '-sAUTO_INIT', + '-Wno-experimental', + '-sEXPORTED_RUNTIME_METHODS=default', + '-sEXPORTED_FUNCTIONS=_bar,_main,delete', + '--js-library', 'library.js', + '-o', 'modularize_instance.mjs'] + self.get_cflags()) + + create_file('runner.mjs', ''' + import { strict as assert } from 'assert'; + import theDefault, { delete as del, _foo as foo } from "./modularize_instance.mjs"; + assert(typeof theDefault === 'function'); + assert(theDefault() === 42); // reserved-word export via EXPORTED_RUNTIME_METHODS + del(); // reserved-word export via EXPORTED_FUNCTIONS + foo(); + ''') + + self.assertContained('main1\nmain2\ndefault-export\ndelete-export\nfoo\n', self.run_js('runner.mjs')) + @also_with_pthreads @requires_node_25 def test_esm_integration_auto_init(self): diff --git a/tools/acorn-optimizer.mjs b/tools/acorn-optimizer.mjs index 0f00c63106a0e..a43cfa4d15c4b 100755 --- a/tools/acorn-optimizer.mjs +++ b/tools/acorn-optimizer.mjs @@ -438,6 +438,29 @@ function getWasmImportsValue(node) { } } +// Under WASM_ESM_INTEGRATION the wasm exports are received as a native ES +// import from the wasm module itself: +// import { malloc as _malloc, memory } from './a.out.wasm'; +function isImportFromWasm(node) { + return ( + node.type === 'ImportDeclaration' && + // Skip source/defer phase imports (e.g. `import source foo from './a.wasm'` + // under SOURCE_PHASE_IMPORTS), whose default specifier binds the module + // itself rather than wasm exports. Value-phase imports leave `phase` unset. + !node.phase && + isLiteralString(node.source) && + node.source.value.endsWith('.wasm') + ); +} + +// A sourceless `export { a as b, .. };` statement (not `export ` and not +// a re-export `export {..} from '..'`). +function isExportSpecifierList(node) { + return ( + node.type === 'ExportNamedDeclaration' && !node.declaration && !node.source + ); +} + function isExportUse(node) { // Match usages of symbols on the `wasmExports` object. e.g: // wasmExports['X'] @@ -494,6 +517,29 @@ function applyImportAndExportNameChanges(ast) { if (mapping[name]) { setLiteralValue(prop, mapping[name]); } + } else if (isImportFromWasm(node)) { + // WASM_ESM_INTEGRATION: rename the wasm-facing name of each received + // export, e.g. `import { malloc as _malloc }` -> `import { a as _malloc }`. + // Replace the imported slot (rather than mutate it in place) since for an + // unaliased specifier acorn shares one node for both sides. + node.specifiers.forEach((spec) => { + const newName = mapping[spec.imported.name]; + if (newName) { + spec.imported = makeIdentifier(newName); + } + }); + } else if (isExportSpecifierList(node)) { + // WASM_ESM_INTEGRATION: rename the wasm-facing name of each JS function + // sent to wasm, e.g. `export { _fd_write as fd_write }` -> + // `export { _fd_write as a }`. Re-exports of wasm exports (`export { + // _main }`) carry a JS-local name that is never in the mapping, so they + // are left untouched. + node.specifiers.forEach((spec) => { + const newName = mapping[spec.exported.name]; + if (newName) { + spec.exported = makeIdentifier(newName); + } + }); } }); } @@ -607,6 +653,10 @@ function emitDCEGraph(ast) { const exportNameToGraphName = {}; // identical to wasmExports['..'] nameToGraphName let foundWasmImportsAssign = false; let foundMinimalRuntimeExports = false; + // Under WASM_ESM_INTEGRATION, JS names bound to wasm exports via an ES import + // from the wasm module. Lets us tell a re-export of a wasm export apart from a + // JS function that is itself exported to wasm (both are `export {..}`). + const wasmExportLocals = new Set(); function saveAsmExport(name, asmName) { // the asmName is what the wasm provides directly; the outside JS @@ -649,6 +699,54 @@ function emitDCEGraph(ast) { }); foundWasmImportsAssign = true; emptyOut(node); // ignore this in the second pass; this does not root + } else if (isImportFromWasm(node)) { + // WASM_ESM_INTEGRATION: wasm exports received as + // import { malloc as _malloc, memory } from './a.out.wasm'; + // Each binding is a wasm export, exactly like `var _x = wasmExports['x']`. + node.specifiers.forEach((spec) => { + const jsName = spec.local.name; // JS-side name + const asmName = spec.imported.name; // wasm-provided name + if (exportNameToGraphName.hasOwnProperty(asmName)) { + // Another local already binds this wasm export (e.g. both `memory` + // and `memory as wasmMemory`): point this local at the same node so + // a use of either roots the one underlying export. + nameToGraphName[jsName] = exportNameToGraphName[asmName]; + } else { + saveAsmExport(jsName, asmName); + } + wasmExportLocals.add(jsName); + }); + // This ES form stands in for the `wasmImports`/`wasmExports` idioms the + // non-ESM build emits, so it satisfies the sanity check below. + foundWasmImportsAssign = true; + // Drop from the second pass: the local bindings must not be seen as + // top-level uses (that would root every export and defeat DCE). + emptyOut(node); + } else if (isExportSpecifierList(node)) { + // Sourceless `export {..}` statements come in three forms: + // (a) JS functions sent to wasm: export { _fd_write as fd_write }; + // (b) re-exports of wasm exports: export { _main }; + // (c) runtime/library exports: export { HEAP32, baz }; + // (d) reserved-name runtime export: export { $default as default }; + // Only (a) are wasm import edges (recorded and removed here). (b) and + // (c) are ordinary top-level uses left in place to root in the second + // pass. (a) is the only form written with an alias (`x as y`), which + // acorn represents with distinct local/exported nodes; bare specifiers + // reuse a single node for both sides. (d) is also aliased but is a JS + // runtime export, not a wasm import: a wasm import name is a C symbol + // and can never be a JS reserved word like `default`. + const importEdges = node.specifiers.filter( + (spec) => + spec.local !== spec.exported && + spec.exported.name !== 'default' && + !wasmExportLocals.has(spec.local.name), + ); + if (importEdges.length) { + // (a) `export { jsName as nativeName }` - jsName implements the import. + importEdges.forEach((spec) => imports.push([spec.local.name, spec.exported.name])); + foundWasmImportsAssign = true; + emptyOut(node); // does not root; second pass ignores it + } } else if (node.type === 'AssignmentExpression') { const target = node.left; // Ignore assignment to the wasmExports object (as happens in @@ -894,6 +992,28 @@ function applyDCEGraphRemovals(ast) { } return true; }); + } else if (isImportFromWasm(node)) { + // WASM_ESM_INTEGRATION: drop unused wasm exports from + // import { malloc as _malloc, .. } from './a.out.wasm'; + node.specifiers = node.specifiers.filter((spec) => { + if (unusedExports.has(spec.imported.name)) { + foundUnusedExports.add(spec.imported.name); + return false; + } + return true; + }); + } else if (isExportSpecifierList(node)) { + // WASM_ESM_INTEGRATION: drop unused wasm imports from + // export { _fd_write as fd_write, .. }; + // Never drop `export { $default as default }`: it is a JS runtime export, + // not a wasm import (a wasm import name can never be `default`). + node.specifiers = node.specifiers.filter((spec) => { + if (spec.exported.name !== 'default' && unusedImports.has(spec.exported.name)) { + foundUnusedImports.add(spec.exported.name); + return false; + } + return true; + }); } else if (node.type === 'ExpressionStatement') { let expr = node.expression; // Inside the assignWasmExports function we have diff --git a/tools/building.py b/tools/building.py index 328af4bfcf1c8..d78a565c52a07 100644 --- a/tools/building.py +++ b/tools/building.py @@ -886,7 +886,11 @@ def metadce(js_file, wasm_file, debug_info, last): # Internal/system exports (e.g. memory, __asyncify_data, dynCall_*, etc.) # do not have standard JS receiving assignments (_name = wasmExports['name']), # so including them in unused_exports would fail applyDCEGraphRemovals's assertion. - if not shared.is_internal_symbol(native_name): + # However, under WASM_ESM_INTEGRATION the JS receives every wasm export + # as an ES import, so any export binaryen drops (including internal ones) + # must also be dropped from the JS import to keep the two module + # interfaces in sync. + if not shared.is_internal_symbol(native_name) or settings.WASM_ESM_INTEGRATION: unused_exports.append(native_name) if not unused_exports and not unused_imports: # nothing found to be unused, so we have nothing to remove diff --git a/tools/emscripten.py b/tools/emscripten.py index 07be8938baeb3..49fbee4b30fe6 100644 --- a/tools/emscripten.py +++ b/tools/emscripten.py @@ -891,11 +891,11 @@ def create_sending(metadata, library_symbols): sorted_items = sorted(send_items_map.items()) if settings.WASM_ESM_INTEGRATION: - elems = [] - for k, v in sorted_items: - elems.append(f'{v} as {k}') - elems = ',\n '.join(elems) exports = '// Export JS functions to the wasm module with demangled names.\n' + if not sorted_items: + # With no JS->wasm imports emit no `export {}` at all rather than an empty one. + return exports + elems = ',\n '.join(f'{v} as {k}' for k, v in sorted_items) exports += f"export {{\n {elems}\n}};" return exports @@ -928,7 +928,9 @@ def create_reexports(metadata): wasm_exports.append(exp) elif demangled == 'main' and '__main_argc_argv' in settings.WASM_EXPORTS: wasm_exports.append('_main') - exports += f"export {{ {', '.join(wasm_exports)} }};" + if wasm_exports: + # With nothing to re-export emit no `export {}` at all rather than an empty one. + exports += f"export {{ {', '.join(wasm_exports)} }};" return exports diff --git a/tools/link.py b/tools/link.py index db52cd9ced9ba..43059443a6c94 100644 --- a/tools/link.py +++ b/tools/link.py @@ -1637,7 +1637,10 @@ def limit_incoming_module_api(): not settings.MAIN_MODULE and \ settings.MINIFY_WASM_EXPORT_NAMES: settings.MINIFY_WASM_IMPORTS_AND_EXPORTS = 1 - settings.MINIFY_WASM_IMPORTED_MODULES = 1 + # Under WASM_ESM_INTEGRATION every wasm import is rewritten to come from the + # single support module (see create_esm_wrapper), so minifying the import + # module names buys nothing and would break that rewrite. + settings.MINIFY_WASM_IMPORTED_MODULES = not settings.WASM_ESM_INTEGRATION if settings.WASM_BIGINT: settings.LEGALIZE_JS_FFI = 0