Skip to content

Commit abfb3c8

Browse files
joeyparrishclaude
andauthored
fix: Time out external commands and network requests (#75)
Nothing in the installer had an upper bound on how long it would wait. Every external command went through execFile with no timeout, and every request went through node-fetch with no timeout. Each of those talks to something that can stop answering without ever failing, so a single sick component hung the entire installation forever. This was observed on a Windows lab node: the log ended after the Chrome line and never produced another, because the next installer's 'adb shell dumpsys' never returned. The service that runs the installer at startup sat wedged behind it until the process was killed by hand. Skipping one browser is far better than that, and main.js already catches per-installer errors and moves on. Cap commands at 60s, metadata requests at 60s, and archive downloads at 5 minutes. The limits are deliberately generous, since a false timeout means a driver silently doesn't get installed. Two details matter for the kill actually working: Run our own timer instead of execFile's 'timeout' option, because that option kills only the process we started. On Windows, tools installed through Chocolatey (adb included, via shaka-lab-browsers) run behind a generated shim, so that would kill the launcher and orphan the tool that is actually stuck, which is the stray process this is meant to prevent. taskkill /T covers the tree there, as it already does elsewhere. Destroy the output pipes before killing. We wait on the streams to close, and a leftover grandchild holding the write end open means that never happens; the timer would fire and the promise would still hang. child_process does the same in its own timeout handling. Timeouts are reported as timeouts rather than as generic command failures, and getMacAppVersion and getAndroidAppVersion now let them propagate instead of reporting the browser as absent, so a hang is visible in a log instead of looking like a missing browser. Co-authored-by: Claude Code (Claude Opus 5) <noreply@anthropic.com>
1 parent 5ad1e49 commit abfb3c8

1 file changed

Lines changed: 122 additions & 8 deletions

File tree

utils.js

Lines changed: 122 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,71 @@ const util = require('util');
1616
const yauzl = require('yauzl');
1717
const zlib = require('zlib');
1818

19-
const execFile = util.promisify(childProcess.execFile);
2019
const pipeline = util.promisify(stream.pipeline);
2120
const zipFromBuffer = util.promisify(yauzl.fromBuffer);
2221

2322
const WINDOWS_REGISTRY_APP_PATHS =
2423
'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\App\ Paths\\';
2524

25+
// Every command we run is a quick local probe: read a registry value, ask a
26+
// binary for its version, ask an attached device what it has installed. None
27+
// of them should take more than a second or two. But each one talks to
28+
// something that can stop answering without ever failing: an unresponsive
29+
// Android device over adb, an unhealthy OS service behind PowerShell, a
30+
// launch-on-demand app behind osascript. Without a limit, one sick component
31+
// hangs the whole installation forever, which is much worse than skipping a
32+
// browser. Be generous, since a false timeout means a driver doesn't get
33+
// installed, but do not wait indefinitely.
34+
const COMMAND_TIMEOUT_MS = 60 * 1000;
35+
36+
// Version metadata requests are small. This caps both the wait for a response
37+
// and the time spent reading the body, so a connection that opens and then
38+
// stalls cannot block us forever.
39+
const FETCH_TIMEOUT_MS = 60 * 1000;
40+
41+
// Driver archives are a few megabytes, and may be pulled over a slow link, so
42+
// they get a much larger budget than metadata requests.
43+
const DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000;
44+
45+
/**
46+
* Forcibly kill a running command, and anything it spawned.
47+
*
48+
* On Windows, tools installed through Chocolatey (adb among them) run behind a
49+
* generated shim, so the process we started is only a launcher and the real
50+
* tool is its child. Killing the launcher alone would leave the hung tool
51+
* running, which is the stray process this timeout exists to prevent.
52+
* taskkill /T covers the tree and /F forces it.
53+
*
54+
* Elsewhere, the wrappers we run into (the google-chrome shell script, for
55+
* example) exec the real binary in place, so there is no separate child.
56+
*
57+
* @param {!ChildProcess} child
58+
*/
59+
function killProcessTree(child) {
60+
// Tear down the pipes first. We are waiting on the command's output streams
61+
// to close, and if it left a child of its own holding the write end open,
62+
// that never happens and killing the process alone would not unblock us.
63+
// child_process does the same thing in its own timeout handling.
64+
if (child.stdout) {
65+
child.stdout.destroy();
66+
}
67+
if (child.stderr) {
68+
child.stderr.destroy();
69+
}
70+
71+
if (os.platform() == 'win32') {
72+
const root = process.env.SystemRoot || 'C:\\Windows';
73+
// Errors are ignored: by the time this runs, the process may already be
74+
// gone, and there is nothing useful to do about it either way.
75+
childProcess.execFile(
76+
`${root}\\System32\\taskkill.exe`,
77+
['/pid', child.pid.toString(), '/t', '/f'],
78+
() => {});
79+
} else {
80+
child.kill('SIGKILL');
81+
}
82+
}
83+
2684
/**
2785
* A static utility class for driver installers to use for common operations.
2886
*/
@@ -32,13 +90,54 @@ class InstallerUtils {
3290
* All output is interpretted as UTF-8.
3391
*
3492
* Throws if the command fails. If the command does not exist, the thrown
35-
* error has .code == 'ENOENT'.
93+
* error has .code == 'ENOENT'. If the command runs longer than the timeout,
94+
* it is killed and the thrown error has .killed == true.
3695
*
3796
* @param {!Array<string>} args
97+
* @param {number=} timeoutMs
3898
* @return {!Promise<!Object>} as returned by child_process.spawn
3999
*/
40-
static async runCommand(args) {
41-
return await execFile(args[0], args.slice(1), {encoding: 'utf8'});
100+
static runCommand(args, timeoutMs=COMMAND_TIMEOUT_MS) {
101+
// NOTE: We run our own timer rather than passing execFile's "timeout"
102+
// option, because that option kills only the process we started, which on
103+
// Windows can be a shim wrapping the tool that is actually stuck.
104+
return new Promise((resolve, reject) => {
105+
let timedOut = false;
106+
let timer = null;
107+
108+
const child = childProcess.execFile(
109+
args[0], args.slice(1), {encoding: 'utf8'},
110+
(error, stdout, stderr) => {
111+
clearTimeout(timer);
112+
113+
if (!error) {
114+
resolve({stdout, stderr});
115+
return;
116+
}
117+
118+
// Attach the output the same way util.promisify(execFile) would
119+
// have. Callers below read .stderr off the thrown error.
120+
error.stdout = stdout;
121+
error.stderr = stderr;
122+
123+
if (timedOut) {
124+
// Say what actually happened, so a hang is recognizable in a log
125+
// instead of looking like an ordinary command failure. Callers
126+
// use .killed to tell the two apart.
127+
error.killed = true;
128+
error.message =
129+
`Command timed out after ${timeoutMs / 1000}s: ` +
130+
args.join(' ');
131+
}
132+
133+
reject(error);
134+
});
135+
136+
timer = setTimeout(() => {
137+
timedOut = true;
138+
killProcessTree(child);
139+
}, timeoutMs);
140+
});
42141
}
43142

44143
/**
@@ -73,10 +172,11 @@ class InstallerUtils {
73172
* Fetch a URL, throwing if the HTTP status code is not 2XX.
74173
*
75174
* @param {string} url
175+
* @param {number=} timeoutMs
76176
* @return {!Promise<!Response>}
77177
*/
78-
static async fetchUrl(url) {
79-
const response = await fetch(url);
178+
static async fetchUrl(url, timeoutMs=FETCH_TIMEOUT_MS) {
179+
const response = await fetch(url, {timeout: timeoutMs});
80180
if (!response.ok) {
81181
throw new Error(
82182
`Failed to fetch ${url}: ${response.status} ${response.statusText}`,
@@ -200,6 +300,13 @@ class InstallerUtils {
200300
]);
201301
return result.stdout.trim();
202302
} catch (error) {
303+
if (error.killed) {
304+
// A timeout is not the same as "no such app". Let it propagate, so
305+
// that a hung osascript is reported instead of quietly skipping the
306+
// browser's driver. See also the Firefox-specific workaround in
307+
// firefox.js, added for a hang of exactly this kind.
308+
throw error;
309+
}
203310
return null;
204311
}
205312
}
@@ -221,6 +328,12 @@ class InstallerUtils {
221328
if (error.code == 'ENOENT') {
222329
// No adb, so no Android connection.
223330
return null;
331+
} else if (error.killed) {
332+
// adb stopped responding, which happens when a device is attached but
333+
// wedged. runCommand already put the timeout into the message, so
334+
// propagate it rather than flattening it into the generic failure
335+
// below.
336+
throw error;
224337
} else if (error.code != 0) {
225338
if (error.stderr.includes('no devices')) {
226339
// No devices attached.
@@ -312,7 +425,8 @@ class InstallerUtils {
312425
// The GitHub API has rate limits, but this is public. It will redirect to
313426
// a URL specific to the tag.
314427
const url = `https://github.com/${repo}/releases/latest`;
315-
const response = await fetch(url, {method: 'HEAD'});
428+
const response = await fetch(
429+
url, {method: 'HEAD', timeout: FETCH_TIMEOUT_MS});
316430
// The redirected URL will be something like:
317431
// "https://github.com/mozilla/geckodriver/releases/tag/v0.30.0"
318432
return response.url.split('/').pop();
@@ -389,7 +503,7 @@ class InstallerUtils {
389503
*/
390504
static async extractFromNetworkArchive(
391505
url, nameInArchive, outputPath, isZip) {
392-
const response = await InstallerUtils.fetchUrl(url);
506+
const response = await InstallerUtils.fetchUrl(url, DOWNLOAD_TIMEOUT_MS);
393507
const buffer = Buffer.from(await response.arrayBuffer());
394508

395509
// If the output file already exists, remove it before overwriting it.

0 commit comments

Comments
 (0)