Skip to content

Commit aa0550e

Browse files
authored
Fuzz JSPI (#7148)
* Add a new "sleep" fuzzer import, that does a sleep for some ms. * Add JSPI support in fuzz_shell.js. This is in the form of commented-out async/await keywords - commented out so that normal fuzzing is not impacted. When we want to fuzz JSPI, we uncomment them. We also apply the JSPI operations of marking imports and exports as suspending/promising. JSPI fuzzing is added to both fuzz_opt.py and ClusterFuzz's run.py.
1 parent 353b759 commit aa0550e

File tree

11 files changed

+279
-119
lines changed

11 files changed

+279
-119
lines changed

scripts/clusterfuzz/run.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,15 @@ def get_js_file_contents(i, output_dir):
200200

201201
print(f'Created {bytes} wasm bytes')
202202

203+
# Some of the time, fuzz JSPI (similar to fuzz_opt.py, see details there).
204+
if system_random.random() < 0.25:
205+
# Prepend the flag to enable JSPI.
206+
js = 'var JSPI = 1;\n\n' + js
207+
208+
# Un-comment the async and await keywords.
209+
js = js.replace('/* async */', 'async')
210+
js = js.replace('/* await */', 'await')
211+
203212
return js
204213

205214

scripts/fuzz_opt.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,11 @@ def randomize_fuzz_settings():
232232
if random.random() < 0.5:
233233
GEN_ARGS += ['--enclose-world']
234234

235-
print('randomized settings (NaNs, OOB, legalize):', NANS, OOB, LEGALIZE)
235+
# Test JSPI somewhat rarely, as it may be slower.
236+
global JSPI
237+
JSPI = random.random() < 0.25
238+
239+
print('randomized settings (NaNs, OOB, legalize, JSPI):', NANS, OOB, LEGALIZE, JSPI)
236240

237241

238242
def init_important_initial_contents():
@@ -758,11 +762,39 @@ def run_d8_js(js, args=[], liftoff=True):
758762
return run_vm(cmd)
759763

760764

761-
FUZZ_SHELL_JS = in_binaryen('scripts', 'fuzz_shell.js')
765+
# For JSPI, we must customize fuzz_shell.js. We do so the first time we need
766+
# it, and save the filename here.
767+
JSPI_JS_FILE = None
768+
769+
770+
def get_fuzz_shell_js():
771+
js = in_binaryen('scripts', 'fuzz_shell.js')
772+
773+
if not JSPI:
774+
# Just use the normal fuzz shell script.
775+
return js
776+
777+
global JSPI_JS_FILE
778+
if JSPI_JS_FILE:
779+
# Use the customized file we've already created.
780+
return JSPI_JS_FILE
781+
782+
JSPI_JS_FILE = os.path.abspath('jspi_fuzz_shell.js')
783+
with open(JSPI_JS_FILE, 'w') as f:
784+
# Enable JSPI.
785+
f.write('var JSPI = 1;\n\n')
786+
787+
# Un-comment the async and await keywords.
788+
with open(js) as g:
789+
code = g.read()
790+
code = code.replace('/* async */', 'async')
791+
code = code.replace('/* await */', 'await')
792+
f.write(code)
793+
return JSPI_JS_FILE
762794

763795

764796
def run_d8_wasm(wasm, liftoff=True, args=[]):
765-
return run_d8_js(FUZZ_SHELL_JS, [wasm] + args, liftoff=liftoff)
797+
return run_d8_js(get_fuzz_shell_js(), [wasm] + args, liftoff=liftoff)
766798

767799

768800
def all_disallowed(features):
@@ -850,7 +882,7 @@ class D8:
850882
name = 'd8'
851883

852884
def run(self, wasm, extra_d8_flags=[]):
853-
return run_vm([shared.V8, FUZZ_SHELL_JS] + shared.V8_OPTS + get_v8_extra_flags() + extra_d8_flags + ['--', wasm])
885+
return run_vm([shared.V8, get_fuzz_shell_js()] + shared.V8_OPTS + get_v8_extra_flags() + extra_d8_flags + ['--', wasm])
854886

855887
def can_run(self, wasm):
856888
# V8 does not support shared memories when running with
@@ -1160,7 +1192,7 @@ def fix_number(x):
11601192
compare_between_vms(before, interpreter, 'Wasm2JS (vs interpreter)')
11611193

11621194
def run(self, wasm):
1163-
with open(FUZZ_SHELL_JS) as f:
1195+
with open(get_fuzz_shell_js()) as f:
11641196
wrapper = f.read()
11651197
cmd = [in_bin('wasm2js'), wasm, '--emscripten']
11661198
# avoid optimizations if we have nans, as we don't handle them with
@@ -1193,6 +1225,10 @@ def can_run_on_wasm(self, wasm):
11931225
# specifically for growth here
11941226
if INITIAL_CONTENTS:
11951227
return False
1228+
# We run in node, which lacks JSPI support, and also we need wasm2js to
1229+
# implement wasm suspending using JS async/await.
1230+
if JSPI:
1231+
return False
11961232
return all_disallowed(['exception-handling', 'simd', 'threads', 'bulk-memory', 'nontrapping-float-to-int', 'tail-call', 'sign-ext', 'reference-types', 'multivalue', 'gc', 'multimemory', 'memory64'])
11971233

11981234

scripts/fuzz_shell.js

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
// This script can be customized by setting the following variables in code that
2+
// runs before this script.
3+
//
4+
// The binary to be run. (If not set, we get the filename from argv and read
5+
// from it.)
6+
var binary;
7+
// A second binary to be linked in and run as well. (Can also be read from
8+
// argv.)
9+
var secondBinary;
10+
// Whether we are fuzzing JSPI. In addition to this being set, the "async" and
11+
// "await" keywords must be taken out of the /* KEYWORD */ comments (which they
12+
// are normally in, so as not to affect normal fuzzing).
13+
var JSPI;
14+
115
// Shell integration: find argv and set up readBinary().
216
var argv;
317
var readBinary;
@@ -25,9 +39,6 @@ if (typeof process === 'object' && typeof require === 'function') {
2539
};
2640
}
2741

28-
// The binary to be run. This may be set already (by code that runs before this
29-
// script), and if not, we get the filename from argv.
30-
var binary;
3142
if (!binary) {
3243
binary = readBinary(argv[0]);
3344
}
@@ -43,7 +54,6 @@ if (argv.length > 0 && argv[argv.length - 1].startsWith('exports:')) {
4354

4455
// If a second parameter is given, it is a second binary that we will link in
4556
// with it.
46-
var secondBinary;
4757
if (argv[1]) {
4858
secondBinary = readBinary(argv[1]);
4959
}
@@ -163,9 +173,9 @@ function callFunc(func) {
163173
// Calls a given function in a try-catch, swallowing JS exceptions, and return 1
164174
// if we did in fact swallow an exception. Wasm traps are not swallowed (see
165175
// details below).
166-
function tryCall(func) {
176+
/* async */ function tryCall(func) {
167177
try {
168-
func();
178+
/* await */ func();
169179
return 0;
170180
} catch (e) {
171181
// We only want to catch exceptions, not wasm traps: traps should still
@@ -243,19 +253,39 @@ var imports = {
243253
},
244254

245255
// Export operations.
246-
'call-export': (index) => {
247-
callFunc(exportList[index].value);
256+
'call-export': /* async */ (index) => {
257+
/* await */ callFunc(exportList[index].value);
248258
},
249-
'call-export-catch': (index) => {
250-
return tryCall(() => callFunc(exportList[index].value));
259+
'call-export-catch': /* async */ (index) => {
260+
return tryCall(/* async */ () => /* await */ callFunc(exportList[index].value));
251261
},
252262

253263
// Funcref operations.
254-
'call-ref': (ref) => {
255-
callFunc(ref);
264+
'call-ref': /* async */ (ref) => {
265+
// This is a direct function reference, and just like an export, it must
266+
// be wrapped for JSPI.
267+
ref = wrapExportForJSPI(ref);
268+
/* await */ callFunc(ref);
269+
},
270+
'call-ref-catch': /* async */ (ref) => {
271+
ref = wrapExportForJSPI(ref);
272+
return tryCall(/* async */ () => /* await */ callFunc(ref));
256273
},
257-
'call-ref-catch': (ref) => {
258-
return tryCall(() => callFunc(ref));
274+
275+
// Sleep a given amount of ms (when JSPI) and return a given id after that.
276+
'sleep': (ms, id) => {
277+
if (!JSPI) {
278+
return id;
279+
}
280+
return new Promise((resolve, reject) => {
281+
setTimeout(() => {
282+
resolve(id);
283+
}, 0); // TODO: Use the ms in some reasonable, deterministic manner.
284+
// Rather than actually setTimeout on them we could manage
285+
// a queue of pending sleeps manually, and order them based
286+
// on the "ms" (which would not be literal ms, but just
287+
// how many time units to wait).
288+
});
259289
},
260290
},
261291
// Emscripten support.
@@ -274,6 +304,22 @@ if (typeof WebAssembly.Tag !== 'undefined') {
274304
};
275305
}
276306

307+
// If JSPI is available, wrap the imports and exports.
308+
if (JSPI) {
309+
for (var name of ['sleep', 'call-export', 'call-export-catch', 'call-ref',
310+
'call-ref-catch']) {
311+
imports['fuzzing-support'][name] =
312+
new WebAssembly.Suspending(imports['fuzzing-support'][name]);
313+
}
314+
}
315+
316+
function wrapExportForJSPI(value) {
317+
if (JSPI && typeof value === 'function') {
318+
value = WebAssembly.promising(value);
319+
}
320+
return value;
321+
}
322+
277323
// If a second binary will be linked in then set up the imports for
278324
// placeholders. Any import like (import "placeholder" "0" (func .. will be
279325
// provided by the secondary module, and must be called using an indirection.
@@ -312,13 +358,14 @@ function build(binary) {
312358
// keep the ability to call anything that was ever exported.)
313359
for (var key in instance.exports) {
314360
var value = instance.exports[key];
361+
value = wrapExportForJSPI(value);
315362
exports[key] = value;
316363
exportList.push({ name: key, value: value });
317364
}
318365
}
319366

320367
// Run the code by calling exports.
321-
function callExports() {
368+
/* async */ function callExports() {
322369
// Call the exports we were told, or if we were not given an explicit list,
323370
// call them all.
324371
var relevantExports = exportsToCall || exportList;
@@ -342,7 +389,7 @@ function callExports() {
342389

343390
try {
344391
console.log('[fuzz-exec] calling ' + name);
345-
var result = callFunc(value);
392+
var result = /* await */ callFunc(value);
346393
if (typeof result !== 'undefined') {
347394
console.log('[fuzz-exec] note result: ' + name + ' => ' + printed(result));
348395
}

src/tools/execution-results.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ struct LoggingExternalInterface : public ShellExternalInterface {
128128
} catch (const WasmException& e) {
129129
return {Literal(int32_t(1))};
130130
}
131+
} else if (import->base == "sleep") {
132+
// Do not actually sleep, just return the id.
133+
return {arguments[1]};
131134
} else {
132135
WASM_UNREACHABLE("unknown fuzzer import");
133136
}

src/tools/fuzzing.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ class TranslateToFuzzReader {
117117
Name callExportCatchImportName;
118118
Name callRefImportName;
119119
Name callRefCatchImportName;
120+
Name sleepImportName;
120121

121122
std::unordered_map<Type, std::vector<Name>> globalsByType;
122123
std::unordered_map<Type, std::vector<Name>> mutableGlobalsByType;
@@ -238,6 +239,7 @@ class TranslateToFuzzReader {
238239
void addImportCallingSupport();
239240
void addImportThrowingSupport();
240241
void addImportTableSupport();
242+
void addImportSleepSupport();
241243
void addHashMemorySupport();
242244

243245
// Special expression makers
@@ -249,6 +251,7 @@ class TranslateToFuzzReader {
249251
// Call either an export or a ref. We do this from a single function to better
250252
// control the frequency of each.
251253
Expression* makeImportCallCode(Type type);
254+
Expression* makeImportSleep(Type type);
252255
Expression* makeMemoryHashLogging();
253256

254257
// Function creation

src/tools/fuzzing/fuzzing.cpp

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ void TranslateToFuzzReader::build() {
317317
}
318318
addImportLoggingSupport();
319319
addImportCallingSupport();
320+
addImportSleepSupport();
320321
modifyInitialFunctions();
321322
// keep adding functions until we run out of input
322323
while (!random.finished()) {
@@ -909,6 +910,24 @@ void TranslateToFuzzReader::addImportTableSupport() {
909910
}
910911
}
911912

913+
void TranslateToFuzzReader::addImportSleepSupport() {
914+
if (!oneIn(4)) {
915+
// Fuzz this somewhat rarely, as it may be slow.
916+
return;
917+
}
918+
919+
// An import that sleeps for a given number of milliseconds, and also receives
920+
// an integer id. It returns that integer id (useful for tracking separate
921+
// sleeps).
922+
sleepImportName = Names::getValidFunctionName(wasm, "sleep");
923+
auto func = std::make_unique<Function>();
924+
func->name = sleepImportName;
925+
func->module = "fuzzing-support";
926+
func->base = "sleep";
927+
func->type = Signature({Type::i32, Type::i32}, Type::i32);
928+
wasm.addFunction(std::move(func));
929+
}
930+
912931
void TranslateToFuzzReader::addHashMemorySupport() {
913932
// Add memory hasher helper (for the hash, see hash.h). The function looks
914933
// like:
@@ -1090,6 +1109,13 @@ Expression* TranslateToFuzzReader::makeImportCallCode(Type type) {
10901109
return builder.makeCall(exportTarget, {index}, type);
10911110
}
10921111

1112+
Expression* TranslateToFuzzReader::makeImportSleep(Type type) {
1113+
// Sleep for some ms, and return a given id.
1114+
auto* ms = make(Type::i32);
1115+
auto id = make(Type::i32);
1116+
return builder.makeCall(sleepImportName, {ms, id}, Type::i32);
1117+
}
1118+
10931119
Expression* TranslateToFuzzReader::makeMemoryHashLogging() {
10941120
auto* hash = builder.makeCall(std::string("hashMemory"), {}, Type::i32);
10951121
return builder.makeCall(logImportNames[Type::i32], {hash}, Type::none);
@@ -1768,6 +1794,9 @@ Expression* TranslateToFuzzReader::_makeConcrete(Type type) {
17681794
if (callExportCatchImportName || callRefCatchImportName) {
17691795
options.add(FeatureSet::MVP, &Self::makeImportCallCode);
17701796
}
1797+
if (sleepImportName) {
1798+
options.add(FeatureSet::MVP, &Self::makeImportSleep);
1799+
}
17711800
options.add(FeatureSet::ReferenceTypes, &Self::makeRefIsNull);
17721801
options.add(FeatureSet::ReferenceTypes | FeatureSet::GC,
17731802
&Self::makeRefEq,

test/lit/exec/fuzzing-api.wast

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
(import "fuzzing-support" "call-ref" (func $call.ref (param funcref)))
2020
(import "fuzzing-support" "call-ref-catch" (func $call.ref.catch (param funcref) (result i32)))
2121

22+
(import "fuzzing-support" "sleep" (func $sleep (param i32 i32) (result i32)))
23+
2224
(table $table 10 20 funcref)
2325

2426
;; Note that the exported table appears first here, but in the binary and in
@@ -284,7 +286,6 @@
284286

285287
;; CHECK: [fuzz-exec] calling ref.calling.trap
286288
;; CHECK-NEXT: [trap unreachable]
287-
;; CHECK-NEXT: warning: no passes specified, not doing any work
288289
(func $ref.calling.trap (export "ref.calling.trap")
289290
;; We try to catch an exception here, but the target function traps, which is
290291
;; not something we can catch. We will trap here, and not log at all.
@@ -294,6 +295,18 @@
294295
)
295296
)
296297
)
298+
299+
;; CHECK: [fuzz-exec] calling do-sleep
300+
;; CHECK-NEXT: [fuzz-exec] note result: do-sleep => 42
301+
;; CHECK-NEXT: warning: no passes specified, not doing any work
302+
(func $do-sleep (export "do-sleep") (result i32)
303+
(call $sleep
304+
;; A ridiculous amount of ms, but in the interpreter it is ignored anyhow.
305+
(i32.const -1)
306+
;; An id, that is returned back to us.
307+
(i32.const 42)
308+
)
309+
)
297310
)
298311
;; CHECK: [fuzz-exec] calling logging
299312
;; CHECK-NEXT: [LoggingExternalInterface logging 42]
@@ -354,6 +367,10 @@
354367

355368
;; CHECK: [fuzz-exec] calling ref.calling.trap
356369
;; CHECK-NEXT: [trap unreachable]
370+
371+
;; CHECK: [fuzz-exec] calling do-sleep
372+
;; CHECK-NEXT: [fuzz-exec] note result: do-sleep => 42
373+
;; CHECK-NEXT: [fuzz-exec] comparing do-sleep
357374
;; CHECK-NEXT: [fuzz-exec] comparing export.calling
358375
;; CHECK-NEXT: [fuzz-exec] comparing export.calling.catching
359376
;; CHECK-NEXT: [fuzz-exec] comparing logging

0 commit comments

Comments
 (0)