Skip to content

Commit efb2b21

Browse files
authored
fix(install-browsers): add idle + absolute timeouts and surface playw… (#126)
* fix(install-browsers): add idle + absolute timeouts and surface playwright install failures - 3min idle timeout, 5min absolute timeout, SIGTERM then SIGKILL after a 5s grace - Inject DEBUG=pw:install into the child env so the extraction step actually logs progress - On failure, print the active node version, resolved playwright version, and the last 10 lines of stdout/stderr so the cause of a hang is visible Supersedes #121. * fix(install-browsers): kill the whole process group on timeout Previous attempt used child_process.exec + proc.kill('SIGTERM'), which only signals /bin/sh — the pnpm/node/playwright descendants keep running and the 'close' event never fires, so timers are never cleared and printFailureContext never prints. Switch to spawn('sh', ['-c', cmd], { detached: true }) so the shell is the leader of its own process group, then signal the whole group with process.kill(-pid, signal). * fix(install-browsers): drop redundant last-stdout/stderr dump on failure The captured chunks are already in the live log right above the failure context, so re-printing them just looks like a second run and makes the output harder to scan. Keep only the bits that aren't visible elsewhere: the kill reason, the active node version, and the resolved playwright version.
1 parent b00210f commit efb2b21

1 file changed

Lines changed: 144 additions & 6 deletions

File tree

  • workflow-steps/install-browsers

workflow-steps/install-browsers/main.js

Lines changed: 144 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
//@ts-check
2-
const { execSync, exec } = require('child_process');
2+
const { execSync, spawn } = require('child_process');
33
const { 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+
510
main().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
*/
8999
async 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+
115253
function getPackageManagerCommand() {
116254
if (existsSync('package-lock.json')) {
117255
return 'npx';

0 commit comments

Comments
 (0)