@@ -9,6 +9,7 @@ const events = require('events');
99const os = require ( 'os' ) ;
1010const { inspect } = require ( 'util' ) ;
1111const { Worker } = require ( 'worker_threads' ) ;
12+ const { fork } = require ( 'child_process' ) ;
1213
1314const workerPath = path . join ( __dirname , 'wpt/worker.js' ) ;
1415const 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
654663function 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+
686791class 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
11691299module . exports = {
1300+ backends,
11701301 getHarnessErrorName,
11711302 getUnexpectedPasses,
11721303 harness : harnessMock ,
0 commit comments