Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[EH] Fuzz catch+rethrow on the JS side #7287

Merged
merged 79 commits into from
Feb 14, 2025
Merged

Conversation

kripken
Copy link
Member

@kripken kripken commented Feb 11, 2025

Add a parameter to call-export, call-ref, where the first bit says "catch and
rethrow any exception". This has no observable effect in binaryen, but causes us
to use different code paths in VMs (for example, a wasm exception may end up
caught in JS, then thrown in JS; passing wasm exnrefs to JS for throwing is not
possible otherwise, so this is the closest we can get).

@kripken kripken requested a review from aheejin February 11, 2025 00:48
@@ -271,7 +275,7 @@ var imports = {
'throw': (which) => {
if (!which) {
// Throw a JS exception.
throw 0;
throw new Error('js exception');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this effectively reverting #7286?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially, yes. That PR fixed us to throw similar things from JS and wasm, an i31 of 0 from wasm, and a JS number of 0. But it turns out that wasn't enough as the externref inside the exnref might end up logged out, and we need that logging to be identical. Throwing an object from both wasm and JS leads to both of them logging "object".

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But the results in this line says not object but js exception.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, the difference can't show up there, because the fuzzer treats exceptions as interchangeable. That is, it converts all text beginning with "exception thrown: " into the same thing, so they all compare the same. (because different VMs have different error messages)

The content of the exception can be read without throwing it, though, if we catch it and return the contents to the outside. Then this difference is noticeable. Here is the test, showing object:

https://github.com/WebAssembly/binaryen/pull/7287/files#diff-a32958975d55fbae4aa24cc650a4d85d1cecaa8f4a8fdb5955372eb77fb7efcaR392

Comment on lines +135 to +137
// The second argument determines if we should catch and rethrow
// exceptions. There is no observable difference in those two modes in
// the binaryen interpreter, so we don't need to do anything.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what this means. Does that mean we only rethrow an exception (in case it occurs) in JS, so we don't have any difference in Binaryen? If so, why do we rethrow only in JS?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We rethrow in JS because we hope to find VM bugs. They might create a new JS exception around the wasm one, or something like that, but I don't know the full details - this is a suggestion the VM people gave me.

We could catch and rethrow in Binaryen too I guess, but that would literally be just try-catch and rethrow (we have no stack traces or other info in exceptions).

Comment on lines +300 to +305
if (!rethrow) {
/* await */ callFunc(exportList[index].value);
} else {
tryCall(/* async */ () => /* await */ callFunc(exportList[index].value),
rethrow);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How are these two modes different? What happens we call callFunc and it throws? Doesn't it also get rethrown, because we don't catch anything there?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first arm of the if calls the function normally. If it throws, the exception keeps propagating out normally.

The second arm does the call inside a JS try-catch. If the call throws, we catch and rethrow it from JS.

VMs may implement those paths very different, I am told. This is just aiming to fuzz more things.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. Then why do we pass a function directly to callFunc in the first case and callFunc(()->func)) and why do we need async and await here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the second case we want tryCall to do the call inside a try-catch. So we give it code to run, as a () => { .. } function, rather than do the call first and give tryCall the result.

(Another option would be to write the try-catch here, that is, inline tryCall into this function. The only reason tryCall is separate is to avoid code duplication, see all the stuff in the catch body there.)

And, because of how async-await works in JavaScript, every function on the call stack must be declared async and called with await. So that part is just a side effect of our defining a function here. (I am actually not totally sure why all functions need async, but otherwise d8 ends up error "Pending promise rejection" - something to do with exception catching from Promises...)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I'm kind of confused why somewhere we print the contents and somewhere we don't, and when it matters and when it doesn't at this point 😵‍💫 But anyway it may not matter much.

Also async and await are all comments... Do you mean d8 errors out if you don't put those in comments?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general we don't print exceptions, because the error text (and stack trace) may vary between VMs. Everything else, including the externref inside an exception - which would be a JS value - we do print, if we extracted it from the exception. I might be forgetting something though.

The comments are documented earlier in the file:

// Whether we are fuzzing JSPI. In addition to this being set, the "async" and
// "await" keywords must be taken out of the /* KEYWORD */ comments (which they
// are normally in, so as not to affect normal fuzzing).
var JSPI;

We need async/await in JSPI mode, but don't want them to change anything in normal mode. So we have them in comments, and un-comment them in JSPI mode.

test/lit/exec/fuzzing-api.wast Outdated Show resolved Hide resolved
@@ -851,12 +851,16 @@ void TranslateToFuzzReader::addImportCallingSupport() {

if (choice & 1) {
// Given an export index, call it from JS.
// A second parameter has flags. The first bit determines whether we catch
// and rethrow all exceptions. (This ends up giving us the same signature
// and behavior as the normal mode, so we just add the flags here rather
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's "normal mode"?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"normal" is when the bit is not set. I'll clarify the comment.

Comment on lines 1146 to 1149
if (!catching) {
// The first bit matters here, so we can send anything.
args.push_back(make(Type::i32));
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this mean? Shouldn't this be either 0 or 1 depending on whether to rthrow?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is future-proof, as random values in the other bits might be used later, and have no downside now. I'll clarify in a comment.

Comment on lines 1182 to 1185
if (!catching) {
// The first bit matters here, so we can send anything.
args.push_back(make(Type::i32));
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(also updated this comment)

Comment on lines +300 to +305
if (!rethrow) {
/* await */ callFunc(exportList[index].value);
} else {
tryCall(/* async */ () => /* await */ callFunc(exportList[index].value),
rethrow);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I'm kind of confused why somewhere we print the contents and somewhere we don't, and when it matters and when it doesn't at this point 😵‍💫 But anyway it may not matter much.

Also async and await are all comments... Do you mean d8 errors out if you don't put those in comments?

Comment on lines +1146 to +1150
if (!catching) {
// Only the first bit matters here, so we can send anything (this is
// future-proof for later bits, and has no downside now).
args.push_back(make(Type::i32));
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still don't understand sorry... why can we send anything if the first bit matters? What if make(Type::i32)) returns 1? (The same for below)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the first bit in make(Type::i32) is 1 (including when the entire value is literally 1, or also when it is 3, etc.) then we will do the "rethrow" behavior, and if that first bit is 0 then we'll do the normal non-rethrow path. The other bits are currently ignored. But we get full coverage by just sending an arbitrary i32 here, since it gives both values for the first bit.

Maybe I'm not understanding your question. What are you worried about happening, or being missed?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see... I was confused and I thought 0 means catching and 1 means rethrowing somehow. But we have separate catching-and-returning functions and in these non-catching variants 0 means not doing anything and 1 means catching and rethrowing. Sorry for the confusion.

@kripken kripken merged commit 60d5626 into WebAssembly:main Feb 14, 2025
14 checks passed
@kripken kripken deleted the fuzz.catch-ref branch February 14, 2025 01:15
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