Repository navigation
SINGLE_FILE_BINARY_ENCODE: report a non-UTF-8 read clearly under ASSERTIONS - #28066
piandtheradians-glitch wants to merge 4 commits into
Conversation
…RTIONS With `-sSINGLE_FILE` and the default `SINGLE_FILE_BINARY_ENCODE=1`, a `.js` that is read in an encoding other than UTF-8 (for example from a page with no `<meta charset>` loaded from `file://`) mangles the embedded Wasm binary, and fails with an error about the Wasm rather than the encoding, such as `CompileError: WebAssembly.instantiate(): unknown type form: 85`. As discussed in emscripten-core#28048, under `ASSERTIONS` the expected length of the embedded binary is now passed to `binaryDecode()`, which checks it once before decoding. Read as UTF-8, each byte of the binary is one character of the string, so a different length means the file was read in another encoding. It then aborts with: > The embedded Wasm binary is 16265 characters long, but should be 14895: this file was not read as UTF-8 (SINGLE_FILE_BINARY_ENCODE). Serve it with charset=utf-8, add <meta charset="utf-8"> to the page, or build with -sSINGLE_FILE_BINARY_ENCODE=0 Builds without `ASSERTIONS` are unchanged (byte-identical output checked for `-O0 -sASSERTIONS=0`, `-O2` and `-sMINIMAL_RUNTIME -O2`). Tests: - `other.test_single_file_not_utf8`: re-encodes the output as Latin-1 (what a Windows-1252 read amounts to) and expects the message instead of a `CompileError`. On `main` it fails with the `CompileError`. - `browser.test_single_file_not_utf8`: loads the build from a `windows-1252` page. On `main` it fails with the `CompileError`; with this change it reports the message (checked in headless Chromium). - `other.test_single_file_instantiate_failure`: updated for the new `binaryDecode` signature under `ASSERTIONS`. Also run locally, with the emsdk version in `test/emsdk_version.txt`: `other.test_single_file*` (all pass except the two `d8` shell tests, as no `d8` here), the browser `test_single_file*` tests, and the code size tests touching `SINGLE_FILE` (`test_codesize_hello_single_file`, `test_minimal_runtime_code_size*`, `test_unoptimized_code_size`, `test_binary_encode_is_smaller_than_base64_encode`). Checked by hand with `-O2`, `--closure=1`, `-sMINIMAL_RUNTIME` and `-sMODULARIZE -sEXPORT_ES6`. Fixes emscripten-core#28048
There was a problem hiding this comment.
Code Review
This pull request introduces validation for the UTF-8 encoding of embedded WebAssembly binaries in SINGLE_FILE builds when ASSERTIONS are enabled. It passes the expected binary size to binaryDecode and asserts that the decoded string length matches, adding tests to verify this behavior. Feedback suggests using strict equality (===) instead of loose equality (==) in the assertion to follow JavaScript best practices.
| // differ, the file was not read as UTF-8 (for example a page without | ||
| // <meta charset="utf-8"> loading it from file://), and the bytes are wrong. | ||
| // Say so here, rather than let it fail as a confusing wasm CompileError. | ||
| assert(bin.length === expectedLength, `The embedded Wasm binary is ${bin.length} characters long, but should be ${expectedLength}: this file was not read as UTF-8 (SINGLE_FILE_BINARY_ENCODE). Serve it with charset=utf-8, add <meta charset="utf-8"> to the page, or build with -sSINGLE_FILE_BINARY_ENCODE=0`); |
There was a problem hiding this comment.
Should we just say something like: "See documentation on SINGLE_FILE_BINARY_ENCODE for ways to address this issue"
There was a problem hiding this comment.
| // Each byte of the binary is one character of the string, so if the lengths | ||
| // differ, the file was not read as UTF-8 (for example a page without | ||
| // <meta charset="utf-8"> loading it from file://), and the bytes are wrong. | ||
| // Say so here, rather than let it fail as a confusing wasm CompileError. |
There was a problem hiding this comment.
This comment seems unnecessary.
There was a problem hiding this comment.
Removed in 691cf6e.
| function findWasmBinary() { | ||
| #if SINGLE_FILE && SINGLE_FILE_BINARY_ENCODE && !WASM2JS | ||
| #if ASSERTIONS | ||
| return binaryDecode("<<< WASM_BINARY_DATA >>>", "<<< WASM_BINARY_SIZE >>>"); |
There was a problem hiding this comment.
Presumably there should not be quotes around the second argument here?
There was a problem hiding this comment.
If we were so simplify this patch so that this second argument is always passed, would that argument be removed by closure compiler / terser? i.e. could we get away with just the if ASSERTIONS in the function body, and not here?
There was a problem hiding this comment.
Yes, it does get removed. I measured it with hello world and -sSINGLE_FILE, comparing the current branch (#if ASSERTIONS at the call sites) with the size always passed to a one-parameter binaryDecode():
| Build | #if at call sites |
Size always passed |
|---|---|---|
-O0 |
57855 bytes | 57836 bytes (, 4943 kept) |
-O2 |
7753 | 7753, argument removed |
-Oz --closure=1 |
3768 | 3768, argument removed |
-sMINIMAL_RUNTIME -Oz --closure=1 |
2005 | 2005, argument removed |
One detail: because the optimizer removes the placeholder before link.py substitutes it, do_replace for WASM_BINARY_SIZE would have to tolerate it being absent.
The catch is the #if ASSERTIONS in the function body. test_binary_encode and test_binary_encode_strict (in both other and browser) load src/binaryDecode.js raw, without the preprocessor, so the #if is a syntax error there. That was the CI failure on b2ca763. So for now the branch keeps binaryDecode.js as it is on main and does the check in checkBinaryLength() in runtime_common.js.
If you prefer your simpler shape, I'd suggest:
// preamble.js (and the same in postamble_minimal.js)
-#if ASSERTIONS
- return binaryDecode(checkBinaryLength("<<< WASM_BINARY_DATA >>>", "<<< WASM_BINARY_SIZE >>>"));
-#else
- return binaryDecode("<<< WASM_BINARY_DATA >>>");
-#endif
+ return binaryDecode("<<< WASM_BINARY_DATA >>>", "<<< WASM_BINARY_SIZE >>>"); // binaryDecode.js (checkBinaryLength() removed from runtime_common.js)
-function binaryDecode(bin) {
+function binaryDecode(bin, expectedLength) {
+#if ASSERTIONS
+ assert(bin.length === expectedLength, `... See the documentation on SINGLE_FILE_BINARY_ENCODE ...`);
+#endif # link.py: always substitute, and tolerate the placeholder already being optimized away
- if settings.ASSERTIONS:
- js = do_replace(js, '"<<< WASM_BINARY_SIZE >>>"', str(os.path.getsize(wasm_target)))
+ js = js.replace('"<<< WASM_BINARY_SIZE >>>"', str(os.path.getsize(wasm_target)))The two binary_encode tests (other and browser) would also run binaryDecode.js through tools/preprocessor.mjs before loading it.
Generally speaking I prefer the simpler shape, but I'd like to go with your preference here. I'm happy to make that change in this PR, or to leave the branch as it is. Either way, I can also follow up separately with anything else you'd like around this.
| if settings.SINGLE_FILE_BINARY_ENCODE: | ||
| js = do_replace(js, '"<<< WASM_BINARY_DATA >>>"', binary_encode(wasm_target)) | ||
| if settings.ASSERTIONS: | ||
| js = do_replace(js, '"<<< WASM_BINARY_SIZE >>>"', str(os.path.getsize(wasm_target))) |
There was a problem hiding this comment.
Do we need the inner quotes here?
There was a problem hiding this comment.
Whichever shape we go with (the current one, or the simpler one discussed in the other thread), my agent reports that they're required, in both places (this also answers the question on preamble.js). link.py fills the placeholders into the final JS after acorn, terser and closure have run, so until then the placeholder has to be valid JS, like the "<<< WASM_BINARY_DATA >>>" next to it. Without the quotes, -O2 fails in acorn-optimizer.mjs with SyntaxError: Unexpected token. Only -O0 builds, because nothing parses it first. Including the quotes in the do_replace pattern means they go away along with the placeholder, which leaves a bare number.
Awaiting your response.
`test_binary_encode` and `test_binary_encode_strict` (other and browser) load
`src/binaryDecode.js` as plain JS, without the preprocessor, so the `#if
ASSERTIONS` added to it was a syntax error there ("Private field '#if' must be
declared in an enclosing class").
`binaryDecode()` is back as it was on `main`; the length check is now a
separate `checkBinaryLength()` in `runtime_common.js` (also `@noinline`, for
the same reason as `binaryDecode()`), called on the string before decoding
under `ASSERTIONS`.
Run locally: `other.test_binary_encode`, `other.test_binary_encode_strict` and
`other.test_single_file*` (45 tests) pass; `test_binary_encode` fails on the
previous commit and passes with this one.
Point at the SINGLE_FILE_BINARY_ENCODE documentation instead of listing the fixes in the message, and remove the explanatory comment, as suggested in review.
With
-sSINGLE_FILEand the defaultSINGLE_FILE_BINARY_ENCODE=1, a.jsthat is read in an encoding other than UTF-8 (for example from a page with no<meta charset>loaded fromfile://) mangles the embedded Wasm binary, and fails with an error about the Wasm rather than the encoding, such asCompileError: WebAssembly.instantiate(): unknown type form: 85.As discussed in #28048, under
ASSERTIONSthe embedded binary's length is now checked once before decoding, by a separatecheckBinaryLength()inruntime_common.js. Read as UTF-8, each byte of the binary is one character of the string, so a different length means the file was read in another encoding. It then aborts with:Builds without
ASSERTIONSare unchanged (byte-identical output checked for-O0 -sASSERTIONS=0,-O2and-sMINIMAL_RUNTIME -O2).Tests:
other.test_single_file_not_utf8: re-encodes the output as Latin-1 (what a Windows-1252 read amounts to) and expects the message instead of aCompileError. Onmainit fails with theCompileError.browser.test_single_file_not_utf8: loads the build from awindows-1252page. Onmainit fails with theCompileError; with this change it reports the message (checked in headless Chromium).other.test_binary_encodeandbrowser.test_binary_encode(and their_strictvariants), which loadsrc/binaryDecode.jswithout the preprocessor: unchanged, and passing.Also run locally on the current head, with the emsdk version in
test/emsdk_version.txt: all 43other.test_single_file*tests (including the twod8shell tests, with V8 15.8 from jsvu), all 6browser.test_single_file*tests, and thecodesize.test_codesize_hello*,codesize.test_minimal_runtime_code_size*,codesize.test_unoptimized_code_sizeandcodesize.test_no_closure_code_sizetests (expectations unchanged). Checked by hand with-O2,--closure=1,-sMINIMAL_RUNTIMEand-sMODULARIZE -sEXPORT_ES6.Fixes #28048
Disclosure: researched and written by Claude (Claude Code) at my direction, and reviewed by me. The tests were run as described above.