-
Notifications
You must be signed in to change notification settings - Fork 758
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
Conversation
@@ -271,7 +275,7 @@ var imports = { | |||
'throw': (which) => { | |||
if (!which) { | |||
// Throw a JS exception. | |||
throw 0; | |||
throw new Error('js exception'); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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".
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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
:
// 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. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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).
if (!rethrow) { | ||
/* await */ callFunc(exportList[index].value); | ||
} else { | ||
tryCall(/* async */ () => /* await */ callFunc(exportList[index].value), | ||
rethrow); | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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...)
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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:
binaryen/scripts/fuzz_shell.js
Lines 10 to 13 in c83115a
// 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.
src/tools/fuzzing/fuzzing.cpp
Outdated
@@ -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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's "normal mode"?
There was a problem hiding this comment.
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.
src/tools/fuzzing/fuzzing.cpp
Outdated
if (!catching) { | ||
// The first bit matters here, so we can send anything. | ||
args.push_back(make(Type::i32)); | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
src/tools/fuzzing/fuzzing.cpp
Outdated
if (!catching) { | ||
// The first bit matters here, so we can send anything. | ||
args.push_back(make(Type::i32)); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(also updated this comment)
if (!rethrow) { | ||
/* await */ callFunc(exportList[index].value); | ||
} else { | ||
tryCall(/* async */ () => /* await */ callFunc(exportList[index].value), | ||
rethrow); | ||
} |
There was a problem hiding this comment.
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?
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)); | ||
} |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
Add a parameter to
call-export, call-ref
, where the first bit says "catch andrethrow 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).