Skip to content

Commit c757550

Browse files
panvaavivkeller
andcommitted
test: add opt-in process WPT runner
To hopefully get to the bottom of WPT crashes that have no traces. Signed-off-by: Filip Skokan <panva.ip@gmail.com> Co-authored-by: Aviv Keller <me@aviv.sh> Signed-off-by: Aviv Keller <me@aviv.sh> PR-URL: #64894 Fixes: #43583 Reviewed-By: Filip Skokan <panva.ip@gmail.com> Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Matthew Aitken <maitken033380023@gmail.com> Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
1 parent 5615b25 commit c757550

9 files changed

Lines changed: 532 additions & 157 deletions

File tree

test/common/wpt.js

Lines changed: 205 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const events = require('events');
99
const os = require('os');
1010
const { inspect } = require('util');
1111
const { Worker } = require('worker_threads');
12+
const { fork } = require('child_process');
1213

1314
const workerPath = path.join(__dirname, 'wpt/worker.js');
1415
const kRunWorkerGlobals = false;
@@ -451,7 +452,10 @@ class WPTTestSpec {
451452
return true;
452453
}
453454
const [filename, variant = ''] = arg.split('?');
454-
return filename === this.filename &&
455+
// Spec filenames are relative paths, so they use the platform separator,
456+
// while the argument is written with forward slashes.
457+
return filename.split(path.sep).join('/') ===
458+
this.filename.split(path.sep).join('/') &&
455459
(!variant || this.variant.substring(1) === variant);
456460
}
457461

@@ -632,23 +636,28 @@ const limit = (concurrency) => {
632636
let running = 0;
633637
const queue = [];
634638

635-
const execute = async (fn) => {
636-
if (running < concurrency) {
637-
running++;
638-
try {
639-
await fn();
640-
} finally {
641-
running--;
642-
if (queue.length > 0) {
643-
execute(queue.shift());
644-
}
639+
const execute = async ({ fn, resolve, reject }) => {
640+
running++;
641+
try {
642+
resolve(await fn());
643+
} catch (err) {
644+
reject(err);
645+
} finally {
646+
running--;
647+
if (queue.length > 0) {
648+
execute(queue.shift());
645649
}
646-
} else {
647-
queue.push(fn);
648650
}
649651
};
650652

651-
return execute;
653+
return (fn) => new Promise((resolve, reject) => {
654+
const task = { fn, resolve, reject };
655+
if (running < concurrency) {
656+
execute(task);
657+
} else {
658+
queue.push(task);
659+
}
660+
});
652661
};
653662

654663
function isUnexpectedPass(spec, name) {
@@ -683,8 +692,111 @@ function getHarnessErrorName(harnessStatus) {
683692
return harnessStatus.message || 'WPT test harness error';
684693
}
685694

695+
/**
696+
* @typedef {object} SpecHandlers
697+
* @property {(message: object) => void} message Handles a message from the spec.
698+
* @property {(failure: { name: string, message: string, stack: string })
699+
* => boolean} failure Reports a spec that died without completing. Returns
700+
* false when the spec had already finished and the failure was ignored.
701+
*/
702+
703+
/**
704+
* @typedef {object} SpecHandle
705+
* @property {() => void} kill Forces the spec to stop running.
706+
* @property {Promise<unknown>} finished Settles once the spec has stopped.
707+
*/
708+
709+
/**
710+
* Run a spec on a worker thread.
711+
* @param {string[]} execArgv
712+
* @param {object} workerData
713+
* @param {SpecHandlers} handlers
714+
* @returns {SpecHandle}
715+
*/
716+
function runSpecOnThread(execArgv, workerData, handlers) {
717+
const worker = new Worker(workerPath, { execArgv, workerData });
718+
worker.on('message', handlers.message);
719+
worker.on('error', (err) => handlers.failure({
720+
name: `${err}`,
721+
message: err.message,
722+
stack: inspect(err),
723+
}));
724+
return {
725+
kill: () => worker.terminate(),
726+
finished: events.once(worker, 'exit').catch(() => {}),
727+
};
728+
}
729+
730+
/**
731+
* Run a spec in a child process, so that a spec crashing the process only
732+
* takes down its own run and the runner can attribute the crash to it.
733+
* @param {string[]} execArgv
734+
* @param {object} workerData
735+
* @param {SpecHandlers} handlers
736+
* @returns {SpecHandle}
737+
*/
738+
function runSpecInProcess(execArgv, workerData, handlers) {
739+
const child = fork(workerPath, {
740+
execArgv,
741+
// Status files may skip subtests by regular expression, which JSON
742+
// serialization would not preserve.
743+
serialization: 'advanced',
744+
stdio: ['ignore', 'inherit', 'pipe', 'ipc'],
745+
});
746+
child.send(workerData);
747+
748+
let stderr = '';
749+
child.stderr.setEncoding('utf8');
750+
child.stderr.on('data', (chunk) => {
751+
stderr += chunk;
752+
});
753+
754+
child.on('message', (message) => {
755+
// The spec reports uncaught errors itself so that they are named the same
756+
// way as they would be on the worker thread backend.
757+
if (message.type === 'uncaught') {
758+
handlers.failure(message.error);
759+
return;
760+
}
761+
handlers.message(message);
762+
});
763+
child.on('error', (err) => handlers.failure({
764+
name: `${err}`,
765+
message: err.message,
766+
stack: inspect(err),
767+
}));
768+
// `close` rather than `exit` so that everything the process wrote to stderr
769+
// on its way out is part of the reported failure.
770+
child.on('close', (code, signal) => {
771+
const name = signal ?
772+
`Test process was killed by signal ${signal}` :
773+
`Test process exited with code ${code}`;
774+
if (!handlers.failure({ name, message: name, stack: stderr }) && stderr) {
775+
process.stderr.write(stderr);
776+
}
777+
});
778+
779+
return {
780+
kill: () => child.kill('SIGKILL'),
781+
finished: events.once(child, 'close').catch(() => {}),
782+
};
783+
}
784+
785+
const backends = {
786+
__proto__: null,
787+
thread: runSpecOnThread,
788+
process: runSpecInProcess,
789+
};
790+
686791
class WPTRunner {
687-
constructor(path, { concurrency = os.availableParallelism() - 1 || 1 } = {}) {
792+
constructor(path, {
793+
concurrency = os.availableParallelism() - 1 || 1,
794+
backend = 'thread',
795+
} = {}) {
796+
if (!Number.isInteger(concurrency) || concurrency < 1) {
797+
throw new TypeError('WPT concurrency must be a positive integer');
798+
}
799+
688800
// RISC-V has very limited virtual address space in the currently common
689801
// sv39 mode, in which we can only create a very limited number of wasm
690802
// memories(27 from a fresh node repl). Limit the concurrency to avoid
@@ -693,6 +805,15 @@ class WPTRunner {
693805
concurrency = Math.min(10, concurrency);
694806
}
695807

808+
// The override exists so that every suite can be run either way without
809+
// editing the drivers, which is how the two backends are kept compatible.
810+
backend = process.env.WPT_BACKEND || backend;
811+
this.runSpec = backends[backend];
812+
if (this.runSpec === undefined) {
813+
throw new Error(`Invalid WPT backend ${backend}, expected one of ` +
814+
`${Object.keys(backends).join(', ')}`);
815+
}
816+
696817
this.path = path;
697818
this.resource = new ResourceLoader(path);
698819
this.concurrency = concurrency;
@@ -708,7 +829,7 @@ class WPTRunner {
708829

709830
this.results = {};
710831
this.inProgress = new Set();
711-
this.workers = new Map();
832+
this.handles = new Map();
712833
this.unexpectedFailures = [];
713834
this.skippedSpecCount = 0;
714835

@@ -803,6 +924,7 @@ class WPTRunner {
803924
const queue = this.buildQueue();
804925

805926
const run = limit(this.concurrency);
927+
const jobs = [];
806928

807929
for (const spec of queue) {
808930
const content = spec.getContent();
@@ -828,66 +950,55 @@ class WPTRunner {
828950
this.scriptsModifier?.(obj);
829951
scriptsToRun.push(obj);
830952

831-
run(async () => {
832-
const worker = new Worker(workerPath, {
833-
execArgv: this.flags,
834-
workerData: {
835-
testRelativePath: relativePath,
836-
wptRunner: __filename,
837-
wptPath: this.path,
838-
initScript: this.fullInitScript(spec),
839-
harness: {
840-
code: fs.readFileSync(harnessPath, 'utf8'),
841-
filename: harnessPath,
842-
},
843-
scriptsToRun,
844-
needsGc: !!meta.script?.find((script) => script === '/common/gc.js'),
845-
skippedTests: spec.skippedTests,
846-
},
847-
});
953+
jobs.push(run(async () => {
848954
this.inProgress.add(spec);
849-
this.workers.set(spec, worker);
850-
851955
const reportResult = this.report?.getResult(spec);
852-
worker.on('message', (message) => {
853-
switch (message.type) {
854-
case 'result':
855-
return this.resultCallback(spec, message.result, reportResult);
856-
case 'skip':
857-
return this.skipTest(spec, { name: message.name }, reportResult);
858-
case 'completion':
859-
return this.completionCallback(spec, message.status, reportResult);
860-
default:
861-
throw new Error(`Unexpected message from worker: ${message.type}`);
862-
}
863-
});
864956

865-
worker.on('error', (err) => {
866-
if (!this.inProgress.has(spec)) {
867-
// The test is already finished. Ignore errors that occur after it.
868-
// This can happen normally, for example in timers tests.
869-
return;
870-
}
871-
// Generate a subtest failure for visibility.
872-
// No need to record this synthetic failure with wpt.fyi.
873-
this.fail(
874-
spec,
875-
{
876-
status: NODE_UNCAUGHT,
877-
name: `${err}`,
878-
message: err.message,
879-
stack: inspect(err),
880-
},
881-
kUncaught,
882-
);
883-
// Mark the whole test as failed in wpt.fyi report.
884-
reportResult?.finish('ERROR');
885-
this.inProgress.delete(spec);
886-
this.report?.write();
957+
const handle = this.runSpec(this.flags, {
958+
testRelativePath: relativePath,
959+
wptRunner: __filename,
960+
wptPath: this.path,
961+
initScript: this.fullInitScript(spec),
962+
harness: {
963+
code: fs.readFileSync(harnessPath, 'utf8'),
964+
filename: harnessPath,
965+
},
966+
scriptsToRun,
967+
needsGc: !!meta.script?.find((script) => script === '/common/gc.js'),
968+
skippedTests: spec.skippedTests,
969+
}, {
970+
message: (message) => {
971+
switch (message.type) {
972+
case 'result':
973+
return this.resultCallback(spec, message.result, reportResult);
974+
case 'skip':
975+
return this.skipTest(spec, { name: message.name }, reportResult);
976+
case 'completion':
977+
return this.completionCallback(spec, message.status, reportResult);
978+
default:
979+
throw new Error(`Unexpected message from spec runner: ${message.type}`);
980+
}
981+
},
982+
failure: (failure) => {
983+
if (!this.inProgress.has(spec)) {
984+
// The test is already finished. Ignore anything that happens
985+
// after it, including the runner terminating it itself.
986+
return false;
987+
}
988+
// Generate a subtest failure for visibility.
989+
// No need to record this synthetic failure with wpt.fyi.
990+
this.fail(spec, { status: NODE_UNCAUGHT, ...failure }, kUncaught);
991+
// Mark the whole test as failed in wpt.fyi report.
992+
reportResult?.finish('ERROR');
993+
this.inProgress.delete(spec);
994+
this.report?.write();
995+
return true;
996+
},
887997
});
998+
this.handles.set(spec, handle);
888999

889-
await events.once(worker, 'exit').catch(() => {});
890-
});
1000+
await handle.finished;
1001+
}));
8911002
}
8921003

8931004
process.on('exit', () => {
@@ -955,6 +1066,25 @@ class WPTRunner {
9551066
`Consider updating ${file} for these files:\n${unexpectedPasses.join('\n')}`);
9561067
}
9571068
});
1069+
1070+
// Promises do not keep the event loop alive. Keep a referenced handle
1071+
// until every queued spec has run, including the gap between terminating
1072+
// one spec runner and receiving its exit event.
1073+
const keepAlive = setInterval(() => {}, 2 ** 31 - 1);
1074+
try {
1075+
const outcomes = await Promise.allSettled(jobs);
1076+
const errors = outcomes
1077+
.filter((outcome) => outcome.status === 'rejected')
1078+
.map((outcome) => outcome.reason);
1079+
if (errors.length === 1) {
1080+
throw errors[0];
1081+
}
1082+
if (errors.length > 1) {
1083+
throw new AggregateError(errors, 'Multiple WPT spec runners failed');
1084+
}
1085+
} finally {
1086+
clearInterval(keepAlive);
1087+
}
9581088
}
9591089

9601090
// Map WPT test status to strings
@@ -1021,9 +1151,9 @@ class WPTRunner {
10211151
// Write report incrementally so results survive even if the process
10221152
// is killed before the exit handler runs.
10231153
this.report?.write();
1024-
// Always force termination of the worker. Some tests allocate resources
1025-
// that would otherwise keep it alive.
1026-
this.workers.get(spec).terminate();
1154+
// Always force termination of the spec runner. Some tests allocate
1155+
// resources that would otherwise keep it alive.
1156+
this.handles.get(spec).kill();
10271157
}
10281158

10291159
addTestResult(spec, item) {
@@ -1167,6 +1297,7 @@ class WPTRunner {
11671297
}
11681298

11691299
module.exports = {
1300+
backends,
11701301
getHarnessErrorName,
11711302
getUnexpectedPasses,
11721303
harness: harnessMock,

0 commit comments

Comments
 (0)