11//@ts -check
2- const { execSync, exec } = require ( 'child_process' ) ;
2+ const { execSync, spawn } = require ( 'child_process' ) ;
33const { existsSync, readFileSync } = require ( 'fs' ) ;
44
5+ const IDLE_TIMEOUT_MS = 3 * 60 * 1000 ;
6+ const ABSOLUTE_TIMEOUT_MS = 5 * 60 * 1000 ;
7+ const KILL_GRACE_MS = 5_000 ;
8+ const DEBUG_NAMESPACES = 'pw:install' ;
9+
510main ( ) . catch ( ( error ) => {
611 console . error ( 'Unexpected error:' , error ) ;
712 process . exit ( 1 ) ;
@@ -25,7 +30,9 @@ async function main() {
2530 ( json . devDependencies || { } ) . hasOwnProperty ( 'cypress' ) ;
2631
2732 if ( hasPlaywright ) {
28- console . log ( 'Installing browsers required by Playwright' ) ;
33+ console . log (
34+ `Installing browsers required by Playwright (idle-timeout=${ IDLE_TIMEOUT_MS / 1000 } s, absolute-timeout=${ ABSOLUTE_TIMEOUT_MS / 1000 } s)` ,
35+ ) ;
2936 try {
3037 const output = await runCmdAsync (
3138 `${ getPackageManagerCommand ( ) } playwright install` ,
@@ -53,17 +60,20 @@ async function main() {
5360 console . error (
5461 'Failed to install Playwright browsers after installing system dependencies.' ,
5562 ) ;
63+ printFailureContext ( reattempt ) ;
5664 process . exit ( reattempt . code ) ;
5765 }
5866 console . log ( 'Successfully installed Playwright browsers.' ) ;
5967 } else {
6068 console . error ( 'Unable to handle failure automatically.' ) ;
69+ printFailureContext ( output ) ;
6170 process . exit ( output . code ) ;
6271 }
6372 } else if ( output . code !== 0 ) {
6473 console . error (
6574 'There was an issue installing Playwright browsers. See above logs.' ,
6675 ) ;
76+ printFailureContext ( output ) ;
6777 process . exit ( output . code ) ;
6878 }
6979 } catch ( e ) {
@@ -84,34 +94,162 @@ async function main() {
8494
8595/**
8696 * @param {string } cmd
87- * @returns {Promise<{ stdout: string; stderr: string; code: number | null; }> }
97+ * @returns {Promise<{ stdout: string; stderr: string; code: number; killedByTimeout: boolean; killReason: string | null; }> }
8898 */
8999async function runCmdAsync ( cmd ) {
90100 return new Promise ( ( res , reject ) => {
91101 let stdout = '' ;
92102 let stderr = '' ;
93- const proc = exec ( cmd ) ;
103+ let killedByTimeout = false ;
104+ /** @type {string | null } */
105+ let killReason = null ;
106+ /** @type {NodeJS.Timeout | null } */
107+ let graceTimer = null ;
108+ /** @type {NodeJS.Timeout | null } */
109+ let idleTimer = null ;
110+
111+ const childEnv = { ...process . env } ;
112+ childEnv . DEBUG = childEnv . DEBUG
113+ ? `${ childEnv . DEBUG } ,${ DEBUG_NAMESPACES } `
114+ : DEBUG_NAMESPACES ;
115+
116+ // Use spawn (not exec) so that `detached: true` is actually honoured:
117+ // exec ignores it. `detached: true` puts the shell — and everything it
118+ // forks (pnpm, node, the playwright extract worker) — in its own process
119+ // group so `process.kill(-pid, signal)` takes the whole tree down at
120+ // once. A plain proc.kill() only signals `/bin/sh`, which doesn't
121+ // propagate to the descendants.
122+ const proc = spawn ( 'sh' , [ '-c' , cmd ] , { env : childEnv , detached : true } ) ;
123+ const rootPid = proc . pid ;
124+
125+ function killGroup ( signal ) {
126+ if ( ! rootPid ) return ;
127+ try {
128+ process . kill ( - rootPid , signal ) ;
129+ } catch ( e ) {
130+ // process group already gone
131+ }
132+ }
133+
134+ function escalateKill ( ) {
135+ killedByTimeout = true ;
136+ killGroup ( 'SIGTERM' ) ;
137+ process . stderr . write (
138+ `\nInstall Browsers: sent SIGTERM to process group ${ rootPid } . SIGKILL in ${ KILL_GRACE_MS / 1000 } s if still running.\n` ,
139+ ) ;
140+ graceTimer = setTimeout ( ( ) => {
141+ if ( proc . exitCode === null && proc . signalCode === null ) {
142+ process . stderr . write (
143+ `Install Browsers: grace expired, sending SIGKILL to process group ${ rootPid } .\n` ,
144+ ) ;
145+ killGroup ( 'SIGKILL' ) ;
146+ }
147+ } , KILL_GRACE_MS ) ;
148+ graceTimer . unref ( ) ;
149+ }
150+
151+ const absoluteTimer = setTimeout ( ( ) => {
152+ const seconds = Math . round ( ABSOLUTE_TIMEOUT_MS / 1000 ) ;
153+ killReason = `absolute timeout (${ seconds } s elapsed)` ;
154+ process . stderr . write (
155+ `\nInstall Browsers: \`${ cmd } \` reached absolute timeout of ${ seconds } s.\n` ,
156+ ) ;
157+ escalateKill ( ) ;
158+ } , ABSOLUTE_TIMEOUT_MS ) ;
159+ absoluteTimer . unref ( ) ;
160+
161+ function resetIdleTimer ( ) {
162+ if ( idleTimer ) clearTimeout ( idleTimer ) ;
163+ idleTimer = setTimeout ( ( ) => {
164+ const seconds = Math . round ( IDLE_TIMEOUT_MS / 1000 ) ;
165+ killReason = `idle timeout (no output for ${ seconds } s)` ;
166+ process . stderr . write (
167+ `\nInstall Browsers: \`${ cmd } \` produced no output for ${ seconds } s.\n` ,
168+ ) ;
169+ escalateKill ( ) ;
170+ } , IDLE_TIMEOUT_MS ) ;
171+ idleTimer . unref ( ) ;
172+ }
173+ resetIdleTimer ( ) ;
94174
95175 proc ?. stdout ?. on ( 'data' , ( data ) => {
96176 stdout += data . toString ( ) ;
97177 process . stdout . write ( data ) ;
178+ resetIdleTimer ( ) ;
98179 } ) ;
99180
100181 proc ?. stderr ?. on ( 'data' , ( data ) => {
101182 stderr += data . toString ( ) ;
102183 process . stderr . write ( data ) ;
184+ resetIdleTimer ( ) ;
103185 } ) ;
104186
105187 proc . on ( 'error' , ( error ) => {
188+ clearTimeout ( absoluteTimer ) ;
189+ if ( idleTimer ) clearTimeout ( idleTimer ) ;
190+ if ( graceTimer ) clearTimeout ( graceTimer ) ;
106191 reject ( error ) ;
107192 } ) ;
108193
109- proc . on ( 'close' , ( code ) => {
110- res ( { stdout, stderr, code } ) ;
194+ proc . on ( 'close' , ( code , signal ) => {
195+ clearTimeout ( absoluteTimer ) ;
196+ if ( idleTimer ) clearTimeout ( idleTimer ) ;
197+ if ( graceTimer ) clearTimeout ( graceTimer ) ;
198+ const resolvedCode =
199+ code !== null
200+ ? code
201+ : signal === 'SIGKILL'
202+ ? 137
203+ : signal === 'SIGTERM'
204+ ? 143
205+ : 1 ;
206+ res ( {
207+ stdout,
208+ stderr,
209+ code : resolvedCode ,
210+ killedByTimeout,
211+ killReason,
212+ } ) ;
111213 } ) ;
112214 } ) ;
113215}
114216
217+ /**
218+ * @param {{ killedByTimeout: boolean; killReason: string | null; } } output
219+ */
220+ function printFailureContext ( output ) {
221+ if ( output . killedByTimeout ) {
222+ console . error ( `\nInstall Browsers: terminated by ${ output . killReason } .` ) ;
223+ }
224+ console . error ( `Active node version: ${ process . version } ` ) ;
225+ const playwrightVersion = resolvePlaywrightVersion ( ) ;
226+ if ( playwrightVersion ) {
227+ console . error ( `Resolved playwright version: ${ playwrightVersion } ` ) ;
228+ }
229+ }
230+
231+ /**
232+ * @returns {string | null }
233+ */
234+ function resolvePlaywrightVersion ( ) {
235+ const candidates = [
236+ 'node_modules/@playwright/test/package.json' ,
237+ 'node_modules/playwright/package.json' ,
238+ 'node_modules/playwright-core/package.json' ,
239+ ] ;
240+ for ( const candidate of candidates ) {
241+ try {
242+ if ( existsSync ( candidate ) ) {
243+ const v = JSON . parse ( readFileSync ( candidate , 'utf8' ) ) ?. version ;
244+ if ( typeof v === 'string' && v ) return v ;
245+ }
246+ } catch ( e ) {
247+ // try the next one
248+ }
249+ }
250+ return null ;
251+ }
252+
115253function getPackageManagerCommand ( ) {
116254 if ( existsSync ( 'package-lock.json' ) ) {
117255 return 'npx' ;
0 commit comments