From c2176c6211d29fa02a9094f8d2e8820a06a4a76e Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 18 Sep 2026 19:34:18 +0530 Subject: [PATCH 1/2] Build/Test Tools: Stop builds hanging on Node.js 24 shutdown. Grunt ends a run with `process.exit()`. Node's exit handler joins the V8 platform worker pool, and when a background compile job is parked waiting for a garbage collection the main thread can no longer run, the two deadlock and the build never exits. Node.js 24 turns on both `--maglev` and `--concurrent-sparkplug`, where Node.js 22 had neither, which is what made this reachable. `NODE_OPTIONS` rejects V8 options, so a launcher passes them on the command line and every Grunt entry point routes through it. See https://github.com/nodejs/node/issues/64274. See #66116. --- package.json | 16 +++++------ tools/grunt.js | 60 ++++++++++++++++++++++++++++++++++++++++ tools/gutenberg/utils.js | 6 +++- 3 files changed, 73 insertions(+), 9 deletions(-) create mode 100644 tools/grunt.js diff --git a/package.json b/package.json index ca760568737a3..2a29c13a2ec8f 100644 --- a/package.json +++ b/package.json @@ -117,13 +117,13 @@ "zod": "3.25.1" }, "scripts": { - "build": "grunt build", - "build:dev": "grunt build --dev", - "build:gutenberg": "grunt build:gutenberg", - "dev": "grunt watch --dev", - "test": "grunt test", - "watch": "grunt watch", - "grunt": "grunt", + "build": "node ./tools/grunt.js build", + "build:dev": "node ./tools/grunt.js build --dev", + "build:gutenberg": "node ./tools/grunt.js build:gutenberg", + "dev": "node ./tools/grunt.js watch --dev", + "test": "node ./tools/grunt.js test", + "watch": "node ./tools/grunt.js watch", + "grunt": "node ./tools/grunt.js", "lint:jsdoc": "wp-scripts lint-js", "lint:jsdoc:fix": "wp-scripts lint-js --fix", "typecheck:js": "tsc --build", @@ -146,6 +146,6 @@ "typecheck:php:baselines": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan:baselines --", "gutenberg:copy": "node tools/gutenberg/copy.js", "gutenberg:verify": "node tools/gutenberg/utils.js", - "gutenberg:download": "node tools/gutenberg/download.js && grunt build:gutenberg" + "gutenberg:download": "node tools/gutenberg/download.js && node ./tools/grunt.js build:gutenberg" } } diff --git a/tools/grunt.js b/tools/grunt.js new file mode 100644 index 0000000000000..0b71213252a97 --- /dev/null +++ b/tools/grunt.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node + +/** + * Runs Grunt with V8's concurrent Maglev and Sparkplug compilers disabled. + * + * Grunt exits via `process.exit()`, which can deadlock against a parked + * background compile job. `NODE_OPTIONS` rejects V8 options. + * + * @see https://github.com/nodejs/node/issues/64274 + * @package WordPress + */ + +const { spawn } = require( 'child_process' ); +const os = require( 'os' ); +const path = require( 'path' ); + +const gruntBin = path.resolve( __dirname, '../node_modules/grunt/bin/grunt' ); +const forwardedSignals = [ 'SIGINT', 'SIGTERM', 'SIGHUP' ]; + +const child = spawn( + process.execPath, + [ + '--no-maglev', + '--no-concurrent-sparkplug', + gruntBin, + ...process.argv.slice( 2 ), + ], + { stdio: 'inherit' } +); + +/* + * Without this, a supervisor that signals only this process leaves Grunt + * running. Terminal interrupts already reach both through the process group. + */ +const forward = ( signal ) => child.kill( signal ); +forwardedSignals.forEach( ( signal ) => process.on( signal, forward ) ); + +// Listeners hold the event loop open, so drop them once Grunt is gone. +const release = () => + forwardedSignals.forEach( ( signal ) => process.off( signal, forward ) ); + +child.on( 'error', ( error ) => { + release(); + console.error( error.message ); + process.exitCode = 1; +} ); + +child.on( 'exit', ( code, signal ) => { + release(); + + if ( ! signal ) { + process.exitCode = code ?? 1; + return; + } + + console.error( `Grunt was terminated by ${ signal }.` ); + + const signalNumber = os.constants.signals[ signal ]; + process.exitCode = signalNumber ? 128 + signalNumber : 1; +} ); diff --git a/tools/gutenberg/utils.js b/tools/gutenberg/utils.js index b43e760735efe..f4ccfcce5d961 100644 --- a/tools/gutenberg/utils.js +++ b/tools/gutenberg/utils.js @@ -231,7 +231,11 @@ function downloadGutenberg() { process.exit( downloadResult.status ?? 1 ); } - const buildResult = spawnSync( 'grunt', [ 'build:gutenberg' ], { stdio: 'inherit', shell: true } ); + const buildResult = spawnSync( process.execPath, [ path.join( __dirname, '../grunt.js' ), 'build:gutenberg' ], { stdio: 'inherit' } ); + if ( buildResult.error ) { + console.error( buildResult.error.message ); + process.exit( 1 ); + } if ( buildResult.status !== 0 ) { process.exit( buildResult.status ?? 1 ); } From d4f125f790f452c0450ba852405cf6c01a78b9ab Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Mon, 21 Sep 2026 15:56:40 +0530 Subject: [PATCH 2/2] Build/Test Tools: Pass the V8 flags from the Grunt npm scripts. Drop the launcher and put `--no-maglev --no-concurrent-sparkplug` in the Grunt npm scripts directly, so Grunt keeps its usual process layout. The Gutenberg download goes through `npm run build:gutenberg`. See #66116. --- package.json | 16 +++++------ tools/grunt.js | 60 ---------------------------------------- tools/gutenberg/utils.js | 6 +--- 3 files changed, 9 insertions(+), 73 deletions(-) delete mode 100644 tools/grunt.js diff --git a/package.json b/package.json index 2a29c13a2ec8f..34ee90a5fe33f 100644 --- a/package.json +++ b/package.json @@ -117,13 +117,13 @@ "zod": "3.25.1" }, "scripts": { - "build": "node ./tools/grunt.js build", - "build:dev": "node ./tools/grunt.js build --dev", - "build:gutenberg": "node ./tools/grunt.js build:gutenberg", - "dev": "node ./tools/grunt.js watch --dev", - "test": "node ./tools/grunt.js test", - "watch": "node ./tools/grunt.js watch", - "grunt": "node ./tools/grunt.js", + "build": "node --no-maglev --no-concurrent-sparkplug node_modules/grunt/bin/grunt build", + "build:dev": "node --no-maglev --no-concurrent-sparkplug node_modules/grunt/bin/grunt build --dev", + "build:gutenberg": "node --no-maglev --no-concurrent-sparkplug node_modules/grunt/bin/grunt build:gutenberg", + "dev": "node --no-maglev --no-concurrent-sparkplug node_modules/grunt/bin/grunt watch --dev", + "test": "node --no-maglev --no-concurrent-sparkplug node_modules/grunt/bin/grunt test", + "watch": "node --no-maglev --no-concurrent-sparkplug node_modules/grunt/bin/grunt watch", + "grunt": "node --no-maglev --no-concurrent-sparkplug node_modules/grunt/bin/grunt", "lint:jsdoc": "wp-scripts lint-js", "lint:jsdoc:fix": "wp-scripts lint-js --fix", "typecheck:js": "tsc --build", @@ -146,6 +146,6 @@ "typecheck:php:baselines": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan:baselines --", "gutenberg:copy": "node tools/gutenberg/copy.js", "gutenberg:verify": "node tools/gutenberg/utils.js", - "gutenberg:download": "node tools/gutenberg/download.js && node ./tools/grunt.js build:gutenberg" + "gutenberg:download": "node tools/gutenberg/download.js && npm run build:gutenberg" } } diff --git a/tools/grunt.js b/tools/grunt.js deleted file mode 100644 index 0b71213252a97..0000000000000 --- a/tools/grunt.js +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env node - -/** - * Runs Grunt with V8's concurrent Maglev and Sparkplug compilers disabled. - * - * Grunt exits via `process.exit()`, which can deadlock against a parked - * background compile job. `NODE_OPTIONS` rejects V8 options. - * - * @see https://github.com/nodejs/node/issues/64274 - * @package WordPress - */ - -const { spawn } = require( 'child_process' ); -const os = require( 'os' ); -const path = require( 'path' ); - -const gruntBin = path.resolve( __dirname, '../node_modules/grunt/bin/grunt' ); -const forwardedSignals = [ 'SIGINT', 'SIGTERM', 'SIGHUP' ]; - -const child = spawn( - process.execPath, - [ - '--no-maglev', - '--no-concurrent-sparkplug', - gruntBin, - ...process.argv.slice( 2 ), - ], - { stdio: 'inherit' } -); - -/* - * Without this, a supervisor that signals only this process leaves Grunt - * running. Terminal interrupts already reach both through the process group. - */ -const forward = ( signal ) => child.kill( signal ); -forwardedSignals.forEach( ( signal ) => process.on( signal, forward ) ); - -// Listeners hold the event loop open, so drop them once Grunt is gone. -const release = () => - forwardedSignals.forEach( ( signal ) => process.off( signal, forward ) ); - -child.on( 'error', ( error ) => { - release(); - console.error( error.message ); - process.exitCode = 1; -} ); - -child.on( 'exit', ( code, signal ) => { - release(); - - if ( ! signal ) { - process.exitCode = code ?? 1; - return; - } - - console.error( `Grunt was terminated by ${ signal }.` ); - - const signalNumber = os.constants.signals[ signal ]; - process.exitCode = signalNumber ? 128 + signalNumber : 1; -} ); diff --git a/tools/gutenberg/utils.js b/tools/gutenberg/utils.js index f4ccfcce5d961..d0084744024c8 100644 --- a/tools/gutenberg/utils.js +++ b/tools/gutenberg/utils.js @@ -231,11 +231,7 @@ function downloadGutenberg() { process.exit( downloadResult.status ?? 1 ); } - const buildResult = spawnSync( process.execPath, [ path.join( __dirname, '../grunt.js' ), 'build:gutenberg' ], { stdio: 'inherit' } ); - if ( buildResult.error ) { - console.error( buildResult.error.message ); - process.exit( 1 ); - } + const buildResult = spawnSync( 'npm', [ 'run', 'build:gutenberg' ], { stdio: 'inherit', shell: true } ); if ( buildResult.status !== 0 ) { process.exit( buildResult.status ?? 1 ); }