Skip to content

Commit a484a46

Browse files
Merge pull request #185 from browserstack/loc-7420-busy-binary-download
LOC-7420: tolerate a busy binary instead of crashing the consumer
2 parents 8096a53 + 2ee720a commit a484a46

4 files changed

Lines changed: 307 additions & 31 deletions

File tree

‎lib/Local.js‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ function Local(){
5858
}
5959
try{
6060
const obj = childProcess.spawnSync(that.binaryPath, that.getBinaryArgs());
61+
/* stdout is null on a spawn failure; reading .length masked the real cause
62+
and the binary was deleted on a TypeError rather than the actual error. */
63+
if(obj.error) {
64+
throw obj.error;
65+
}
6166
this.tunnel = {pid: obj.pid};
6267
var data = {};
6368
if(obj.stdout.length > 0)
@@ -79,7 +84,12 @@ function Local(){
7984
if(that.retriesLeft > 0) {
8085
console.log('Retrying Binary Download. Retries Left', that.retriesLeft);
8186
that.retriesLeft -= 1;
82-
fs.unlinkSync(that.binaryPath);
87+
if(that.binary) that.binary.waitWhileBinaryBusySync(that.binaryPath);
88+
try { fs.unlinkSync(that.binaryPath); } catch(err) { /* ignored */ }
89+
/* Still there: binaryPath() would hand back the same unusable file. */
90+
if(fs.existsSync(that.binaryPath)) {
91+
return new LocalError(binaryDownloadErrorMessage);
92+
}
8393
delete(that.binaryPath);
8494
that.binaryDownloadState.errorMessage = binaryDownloadErrorMessage;
8595
that.binaryDownloadState.fallbackEnabled = true;
@@ -99,6 +109,11 @@ function Local(){
99109
return callback();
100110

101111
this.getBinaryPath(function(binaryPath){
112+
/* Matches startSync's check below: the download can exhaust its retries
113+
and hand back nothing, and execFile(undefined) throws uncatchably. */
114+
if(!binaryPath) {
115+
return callback(new LocalError('Couldn\'t find binary file'));
116+
}
102117
that.binaryPath = binaryPath;
103118
try {
104119
fs.writeFileSync(that.logfile, '');
@@ -114,11 +129,21 @@ function Local(){
114129
if(that.retriesLeft > 0) {
115130
console.log('Retrying Binary Download. Retries Left', that.retriesLeft);
116131
that.retriesLeft -= 1;
117-
fs.unlinkSync(that.binaryPath);
118-
delete(that.binaryPath);
119-
that.binaryDownloadState.errorMessage = binaryDownloadErrorMessage;
120-
that.binaryDownloadState.fallbackEnabled = true;
121-
that.start(options, callback);
132+
var replace = function(waitsLeft) {
133+
if(waitsLeft > 0 && fs.existsSync(that.binaryPath) &&
134+
that.binary && that.binary.isBinaryBusy(that.binaryPath)) {
135+
return setTimeout(function() { replace(waitsLeft - 1); }, 1000);
136+
}
137+
try { fs.unlinkSync(that.binaryPath); } catch(err) { /* ignored */ }
138+
if(fs.existsSync(that.binaryPath)) {
139+
return callback(new LocalError(binaryDownloadErrorMessage));
140+
}
141+
delete(that.binaryPath);
142+
that.binaryDownloadState.errorMessage = binaryDownloadErrorMessage;
143+
that.binaryDownloadState.fallbackEnabled = true;
144+
that.start(options, callback);
145+
};
146+
replace(3);
122147
return;
123148
} else {
124149
callback(new LocalError(error.toString()));

‎lib/LocalBinary.js‎

Lines changed: 85 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* global Atomics, SharedArrayBuffer -- ES2017, used for the blocking wait in
2+
waitWhileBinaryBusySync; declared here rather than widening the lint env. */
13
var https = require('https'),
24
fs = require('fs'),
35
path = require('path'),
@@ -71,6 +73,10 @@ function LocalBinary(){
7173
env.BROWSERSTACK_LOCAL_AUTH_TOKEN = this.key;
7274
}
7375
const obj = childProcess.spawnSync(cmd, opts, { env: env });
76+
/* stdout is null on a spawn failure; reading .length masked the real cause. */
77+
if(obj.error) {
78+
throw(util.format(obj.error));
79+
}
7480
if(obj.stdout.length > 0) {
7581
this.sourceURL = obj.stdout.toString().replace(/\n+$/, '');
7682
this.downloadState.sourceURL = this.sourceURL;
@@ -148,23 +154,60 @@ function LocalBinary(){
148154
this.downloadErrorMessage = errorMessagePrefix + ' : ' + errorMessage;
149155
};
150156

157+
/* A locked binary is transient on Windows (AV scan, a tunnel still releasing
158+
its handle), not a corrupt one. Mirrors the CLI binary's existing probe. */
159+
this.BUSY_ERROR_CODES = ['EBUSY', 'EPERM', 'ETXTBSY', 'EACCES'];
160+
this.BUSY_MAX_WAITS = 3;
161+
this.BUSY_WAIT_MS = 1000;
162+
163+
this.isBinaryBusy = function(binaryPath) {
164+
try {
165+
fs.closeSync(fs.openSync(binaryPath, 'r+'));
166+
return false;
167+
} catch(err) {
168+
return this.BUSY_ERROR_CODES.indexOf(err.code) !== -1;
169+
}
170+
};
171+
172+
/* Blocking by design: the sync path has no event loop to come back to. */
173+
this.waitWhileBinaryBusySync = function(binaryPath) {
174+
for(var i = 0; i < this.BUSY_MAX_WAITS; i++) {
175+
if(!fs.existsSync(binaryPath) || !this.isBinaryBusy(binaryPath)) return;
176+
console.log('Binary is in use, waiting before retrying.');
177+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, this.BUSY_WAIT_MS);
178+
}
179+
};
180+
151181
this.retryBinaryDownload = function(conf, destParentDir, callback, retries, binaryPath) {
152182
var that = this;
153-
if(retries > 0) {
154-
console.log('Retrying Download. Retries left', retries);
155-
/* Single unlink instead of stat-then-unlinkSync: the gap between the two
156-
let a concurrent writer swap the file, and a failing unlinkSync threw
157-
out of the stat callback where it could not be caught. A missing file
158-
is the expected case here, so any error is ignored. */
183+
if(retries <= 0) {
184+
console.error('Number of retries to download exceeded.');
185+
/* The async contract has to be completed or Local.start() waits forever.
186+
An empty path is the signal; the caller reports it. */
187+
if(callback) callback();
188+
return;
189+
}
190+
console.log('Retrying Download. Retries left', retries);
191+
192+
/* Must stay synchronous: this return value is what downloadSync ->
193+
binaryPath() -> Local.getBinaryPath hands back. Retrying inside a callback
194+
returned undefined before the retry had done anything. */
195+
if(!callback) {
196+
that.waitWhileBinaryBusySync(binaryPath);
197+
try { fs.unlinkSync(binaryPath); } catch(err) { /* missing or locked */ }
198+
return that.downloadSync(conf, destParentDir, retries - 1);
199+
}
200+
201+
var attemptAsync = function(waitsLeft) {
202+
if(waitsLeft > 0 && fs.existsSync(binaryPath) && that.isBinaryBusy(binaryPath)) {
203+
console.log('Binary is in use, waiting before retrying.');
204+
return setTimeout(function() { attemptAsync(waitsLeft - 1); }, that.BUSY_WAIT_MS);
205+
}
159206
fs.unlink(binaryPath, function() {
160-
if(!callback) {
161-
return that.downloadSync(conf, destParentDir, retries - 1);
162-
}
163207
that.download(conf, destParentDir, callback, retries - 1);
164208
});
165-
} else {
166-
console.error('Number of retries to download exceeded.');
167-
}
209+
};
210+
attemptAsync(that.BUSY_MAX_WAITS);
168211
};
169212

170213
this.downloadSync = function(conf, destParentDir, retries) {
@@ -198,6 +241,14 @@ function LocalBinary(){
198241
const userAgent = [packageName, version].join('/');
199242
const env = Object.assign({ 'USER_AGENT': userAgent }, process.env);
200243
const obj = childProcess.spawnSync(cmd, opts, { env: env });
244+
if(obj.status !== 0) {
245+
that.binaryDownloadError('Download failed with status', String(obj.status));
246+
return that.retryBinaryDownload(conf, destParentDir, null, retries, binaryPath);
247+
}
248+
if(obj.error) {
249+
that.binaryDownloadError('Download failed with error', util.format(obj.error));
250+
return that.retryBinaryDownload(conf, destParentDir, null, retries, binaryPath);
251+
}
201252
let output;
202253
if(obj.stdout.length > 0) {
203254
if(fs.existsSync(binaryPath)){
@@ -221,7 +272,8 @@ function LocalBinary(){
221272
this.download = function(conf, destParentDir, callback, retries){
222273
this.getDownloadPath(conf, retries, (err, downloadUrl) => {
223274
if(err) {
224-
return console.error('Unable to fetch the source url to download the binary with error: ', err);
275+
console.error('Unable to fetch the source url to download the binary with error: ', err);
276+
return callback();
225277
}
226278

227279
this.httpPath = downloadUrl;
@@ -234,6 +286,21 @@ function LocalBinary(){
234286
var binaryPath = path.join(destParentDir, destBinaryName);
235287
var fileStream = fs.createWriteStream(binaryPath);
236288

289+
/* A failed open and the in-flight request can both report on the same
290+
attempt; one attempt must trigger at most one retry. */
291+
var retried = false;
292+
var retryOnce = function(prefix, err) {
293+
that.binaryDownloadError(prefix, util.format(err));
294+
if(retried) return;
295+
retried = true;
296+
that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath);
297+
};
298+
299+
/* Same as lib/download.js: the open() failure lands first. */
300+
fileStream.on('error', function (err) {
301+
retryOnce('Got Error while downloading binary file', err);
302+
});
303+
237304
var options = url.parse(this.httpPath);
238305
if(conf.proxyHost && conf.proxyPort) {
239306
options.agent = new HttpsProxyAgent({
@@ -267,21 +334,18 @@ function LocalBinary(){
267334
}
268335

269336
response.on('error', function(err) {
270-
that.binaryDownloadError('Got Error in binary download response', util.format(err));
271-
that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath);
272-
});
273-
fileStream.on('error', function (err) {
274-
that.binaryDownloadError('Got Error while downloading binary file', util.format(err));
275-
that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath);
337+
retryOnce('Got Error in binary download response', err);
276338
});
277339
fileStream.on('close', function () {
340+
/* node emits 'close' after 'error' too, so without this a failed
341+
attempt reports success alongside the retry it just started. */
342+
if(retried) return;
278343
fs.chmod(binaryPath, '0755', function() {
279344
callback(binaryPath);
280345
});
281346
});
282347
}).on('error', function(err) {
283-
that.binaryDownloadError('Got Error in binary downloading request', util.format(err));
284-
that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath);
348+
retryOnce('Got Error in binary downloading request', err);
285349
});
286350
});
287351
};

‎lib/download.js‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ const binaryPath = process.argv[2], httpPath = process.argv[3], proxyHost = proc
99

1010
var fileStream = fs.createWriteStream(binaryPath);
1111

12+
/* Must be attached before the async https.get: createWriteStream emits 'error'
13+
on the next tick, and with no listener node turns that into a hard throw. */
14+
var request;
15+
16+
fileStream.on('error', function (err) {
17+
console.error('Got Error while downloading binary file', err);
18+
process.exitCode = 1;
19+
/* Otherwise the child keeps downloading into a dead stream and the parent's
20+
spawnSync blocks for a whole download before it can retry. */
21+
if(request) request.destroy();
22+
});
23+
1224
var options = url.parse(httpPath);
1325
/* isUndefined, not plain truthiness: the parent passes literal `undefined`
1426
placeholders for the proxy slots when only a CA is configured, and those
@@ -37,7 +49,7 @@ options.headers = Object.assign({}, options.headers, {
3749
'user-agent': process.env.USER_AGENT,
3850
});
3951

40-
https.get(options, function (response) {
52+
request = https.get(options, function (response) {
4153
const contentEncoding = response.headers['content-encoding'];
4254
if (typeof contentEncoding === 'string' && contentEncoding.match(/gzip/i)) {
4355
if (process.env.BROWSERSTACK_LOCAL_DEBUG_GZIP) {
@@ -52,12 +64,11 @@ https.get(options, function (response) {
5264
response.on('error', function(err) {
5365
console.error('Got Error in binary download response', err);
5466
});
55-
fileStream.on('error', function (err) {
56-
console.error('Got Error while downloading binary file', err);
57-
});
5867
fileStream.on('close', function () {
68+
if(process.exitCode === 1) return; // errored; not a completed download
5969
console.log('Done');
6070
});
6171
}).on('error', function(err) {
72+
if(process.exitCode === 1) return; // our own destroy() landing
6273
console.error('Got Error in binary downloading request', err);
6374
});

0 commit comments

Comments
 (0)