Skip to content

Link wasm-wasm directly in the fuzzer#8794

Open
kripken wants to merge 1 commit into
WebAssembly:mainfrom
kripken:direct2
Open

Link wasm-wasm directly in the fuzzer#8794
kripken wants to merge 1 commit into
WebAssembly:mainfrom
kripken:direct2

Conversation

@kripken
Copy link
Copy Markdown
Member

@kripken kripken commented Jun 1, 2026

This avoids passing JSPI-wrapped exports from one wasm module as the imports
to another. Without this, the stack traces and erroring behavior become very
confusing. I noticed this during #8736 (fuzzing start) but I don't think it's limited to
the start function.

With that said, I admit I don't totally understand why the stack traces etc. get
very odd before this PR... async stuff is complex. But this seems like the right
thing anyhow?

@kripken kripken requested a review from brendandahl June 1, 2026 21:47
@kripken kripken requested a review from a team as a code owner June 1, 2026 21:47
@kripken kripken requested review from tlively and removed request for a team June 1, 2026 21:47
@kripken
Copy link
Copy Markdown
Member Author

kripken commented Jun 1, 2026

Emscripten also has some complexity around the original exports and the instrumented ones, which I also am not totally sure I follow,

https://github.com/emscripten-core/emscripten/blob/1465d28c96510a9b2a69854bdc5e062f5f9e0790/src/lib/libasync.js#L57

@brendandahl
Copy link
Copy Markdown
Collaborator

I can't seem to find the conversation, but IIRC one of the other v8 folks said that the wrapped version should behave the same as the original. Maybe a bug in v8? I can poke around a bit more on it tomorrow.

@kripken
Copy link
Copy Markdown
Member Author

kripken commented Jun 1, 2026

Thanks, then let me reduce my testcase here, and I'll get that to you.

@kripken
Copy link
Copy Markdown
Member Author

kripken commented Jun 1, 2026

Ok, here is the testcase:

https://gist.github.com/kripken/1759e0f618e0a755e5a69f28f6b78142

$ diff -U2 a.js b.js
--- a.js	2026-06-01 16:41:32.078358653 -0700
+++ b.js	2026-06-01 16:41:33.662363189 -0700
@@ -203,5 +203,4 @@
 // whose keys are strings and whose values are the corresponding exports).
 var exports = {};
-var rawExports = {};
 
 // Also track exports in a list, to allow access by index. Each entry here will
@@ -520,5 +519,5 @@
     assert(secondBinary);
     // Provide the primary module's exports to the secondary.
-    imports['primary'] = rawExports;
+    imports['primary'] = exports;
   }
 
@@ -554,5 +553,4 @@
     var key = e.name;
     var value = instance.exports[key];
-    rawExports[key] = value;
     value = wrapExportForJSPI(value);
     exports[key] = value;

The only diff between the JS files is to use the raw exports as the imports of the other module. And the behavior is very different:

$ v8 --experimental-wasm-custom-descriptors --fuzzing --experimental-wasm-acquire-release a.js -- a.0.wasm a.1.wasm
V8 is running with experimental features enabled. Stability and security will suffer.
exception thrown: failed to instantiate module: RuntimeError: unreachable
[fuzz-exec] export global$_1
[LoggingExternalInterface logging function]
[fuzz-exec] export 0_invoker
[fuzz-exec] export 2_invoker
[fuzz-exec] export 3_invoker
[fuzz-exec] export 4_invoker
[fuzz-exec] export 6_invoker
[fuzz-exec] export 7_invoker
exception thrown: RuntimeError: unreachable
[fuzz-exec] export 8_invoker
[fuzz-exec] export 10_invoker
[fuzz-exec] export 11_invoker
[fuzz-exec] export func
exception thrown: RuntimeError: unreachable
[fuzz-exec] export func_invoker
[fuzz-exec] export func_33_invoker
[fuzz-exec] export new
exception thrown: RuntimeError: unreachable
[fuzz-exec] export get
exception thrown: RuntimeError: unreachable
[fuzz-exec] export set_get
exception thrown: RuntimeError: unreachable
[fuzz-exec] export global$_2
[LoggingExternalInterface logging function]
$ echo $?
0

versus

$ v8 --experimental-wasm-custom-descriptors --fuzzing --experimental-wasm-acquire-release b.js -- a.0.wasm a.1.wasm
V8 is running with experimental features enabled. Stability and security will suffer.
[fuzz-exec] export global$_1
[LoggingExternalInterface logging function]
[fuzz-exec] export 0_invoker
[fuzz-exec] export 2_invoker
[fuzz-exec] export 3_invoker
[fuzz-exec] export 4_invoker
[fuzz-exec] export 6_invoker
[fuzz-exec] export 7_invoker
exception thrown: RuntimeError: unreachable
[fuzz-exec] export 8_invoker
[fuzz-exec] export 10_invoker
[fuzz-exec] export 11_invoker
[fuzz-exec] export func
exception thrown: RuntimeError: unreachable
[fuzz-exec] export func_invoker
[fuzz-exec] export func_33_invoker
[fuzz-exec] export new
exception thrown: RuntimeError: unreachable
[fuzz-exec] export get
exception thrown: RuntimeError: unreachable
[fuzz-exec] export set_get
exception thrown: RuntimeError: unreachable
[fuzz-exec] export global$_2
[LoggingExternalInterface logging function]
wasm-function[6]:0x134: RuntimeError: unreachable
RuntimeError: unreachable
    at wasm://wasm/b12b3406:wasm-function[6]:0x134
    at build (b.js:526:16)
    at b.js:719:3

1 pending unhandled Promise rejection(s) detected.
$ echo $?
1

The start function in the second module is important somehow - I only see this when fuzzing start functions.

@kripken
Copy link
Copy Markdown
Member Author

kripken commented Jun 2, 2026

I updated the gist with a browser version in c.mjs and a.html.

By commenting/uncommenting the lines here, the different behavior can be seen:

    // Provide the primary module's exports to the secondary.
    //imports['primary'] = exports;
    imports['primary'] = rawExports;

What happens here is that we instantiate a second wasm file, with imports from the first. That second wasm file's start function is actually an imported function from the first. If it is wrapped for JSPI, behavior is very different.

If not wrapped, this is the result:

[fuzz-exec] export global$_1
c.mjs:172 [LoggingExternalInterface logging function]
c.mjs:653 [fuzz-exec] export 0_invoker
c.mjs:653 [fuzz-exec] export 2_invoker

[..all the other exports are logged..]

c.mjs:653 [fuzz-exec] export 3_invoker18c9d392:0x132 Uncaught (in promise) RuntimeError: unreachable
    at 18c9d392:0x132
    at build (c.mjs:514:16)
    at c.mjs:708:3

If wrapped, this:

exception thrown: failed to instantiate module: RuntimeError: unreachable
    at 18c9d392:0x132
    at build (c.mjs:514:16)
    at c.mjs:708:3
18c9d392:0x132 Uncaught RuntimeError: unreachable
    at 18c9d392:0x132
    at build (c.mjs:514:16)
    at c.mjs:708:3

It looks like when it is not wrapped, the error during the start function (i.e. during instantiation of the second module) happens asynchronously - later, after the script continues on to running the exports.

And when it is wrapped, we synchronously experience the start function's error, halting the script right there.

If this is not a browser bug, then the latter behavior is what we want in our fuzzer I think?

@kripken
Copy link
Copy Markdown
Member Author

kripken commented Jun 2, 2026

Btw my motivation for testing this in a browser was to compare browsers. But in Firefox (with the JSPI flag set) I get this:

Uncaught TypeError: Unsupported JSPI function argument type
    wrapExportForJSPI http://localhost:8000/c.mjs:441
    build http://localhost:8000/c.mjs:544
    <anonymous> http://localhost:8000/c.mjs:704
[c.mjs:441:25](http://localhost:8000/c.mjs)

Perhaps the JSPI impl there is incomplete?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants