Skip to content

Commit eecad0c

Browse files
pranay-v29claude
andcommitted
LOC-7420: tolerate a busy binary instead of crashing the consumer
On Windows, BrowserStackLocal.exe in ~/.browserstack is routinely unopenable for a moment -- an AV scan of a freshly written executable, a tunnel still releasing its handle, two workers starting at once. POSIX allows opening and unlinking a file in use, so this only shows on Windows. Several defects turned that transient condition into a crash before any session started. 1. download.js and LocalBinary.js registered the write-stream 'error' handler inside the async https.get callback. createWriteStream emits on the next tick, long before that runs, so the error had no listener and node's `throw er` killed the download child. Handlers now attach immediately after createWriteStream. Handling the error is not enough on its own: the request is still in flight, and without destroying it the child keeps downloading into a dead stream while the parent's spawnSync blocks for a full download before it can retry. The throw was also stopping the download. 2. retryBinaryDownload did its work in an async callback, so the sync path returned undefined to a caller that had already given up -- surfacing as "Couldn't find binary file" while the retries ran on, orphaned, in the background. This happened even when the unlink succeeded, so it is not a consequence of the EPERM. 3. Retrying instantly against a live lock just burns the retry budget, so a busy binary is now probed and waited on, bounded, rather than deleted. Follows the CLI binary's existing busy-code handling. 4. A binary that downloaded but cannot run -- a truncated file left by an interrupted download, which binaryPath() reuses because it only checks the file exists -- reported a TypeError from reading obj.stdout.length on a null stdout, masking the real cause, and then hit an unguarded unlinkSync that threw out of startSync on a locked file. Both are handled, so the sync path now deletes the unusable binary and re-downloads instead of failing. This is what the customer was working around by clearing ~/.browserstack by hand. Tests force the open to fail rather than reproducing a lock, since the defect is any createWriteStream failure rather than EBUSY specifically, so they need no Windows runner, network or credentials. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8096a53 commit eecad0c

4 files changed

Lines changed: 198 additions & 26 deletions

File tree

‎lib/Local.js‎

Lines changed: 8 additions & 2 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,8 @@ 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+
/* EPERM on a locked file threw straight out of startSync. */
88+
try { fs.unlinkSync(that.binaryPath); } catch(err) { /* ignored */ }
8389
delete(that.binaryPath);
8490
that.binaryDownloadState.errorMessage = binaryDownloadErrorMessage;
8591
that.binaryDownloadState.fallbackEnabled = true;
@@ -114,7 +120,7 @@ function Local(){
114120
if(that.retriesLeft > 0) {
115121
console.log('Retrying Binary Download. Retries Left', that.retriesLeft);
116122
that.retriesLeft -= 1;
117-
fs.unlinkSync(that.binaryPath);
123+
try { fs.unlinkSync(that.binaryPath); } catch(err) { /* ignored */ }
118124
delete(that.binaryPath);
119125
that.binaryDownloadState.errorMessage = binaryDownloadErrorMessage;
120126
that.binaryDownloadState.fallbackEnabled = true;

‎lib/LocalBinary.js‎

Lines changed: 73 additions & 20 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,57 @@ 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+
return;
186+
}
187+
console.log('Retrying Download. Retries left', retries);
188+
189+
/* Must stay synchronous: this return value is what downloadSync ->
190+
binaryPath() -> Local.getBinaryPath hands back. Retrying inside a callback
191+
returned undefined before the retry had done anything. */
192+
if(!callback) {
193+
that.waitWhileBinaryBusySync(binaryPath);
194+
try { fs.unlinkSync(binaryPath); } catch(err) { /* missing or locked */ }
195+
return that.downloadSync(conf, destParentDir, retries - 1);
196+
}
197+
198+
var attemptAsync = function(waitsLeft) {
199+
if(waitsLeft > 0 && fs.existsSync(binaryPath) && that.isBinaryBusy(binaryPath)) {
200+
console.log('Binary is in use, waiting before retrying.');
201+
return setTimeout(function() { attemptAsync(waitsLeft - 1); }, that.BUSY_WAIT_MS);
202+
}
159203
fs.unlink(binaryPath, function() {
160-
if(!callback) {
161-
return that.downloadSync(conf, destParentDir, retries - 1);
162-
}
163204
that.download(conf, destParentDir, callback, retries - 1);
164205
});
165-
} else {
166-
console.error('Number of retries to download exceeded.');
167-
}
206+
};
207+
attemptAsync(that.BUSY_MAX_WAITS);
168208
};
169209

170210
this.downloadSync = function(conf, destParentDir, retries) {
@@ -198,6 +238,10 @@ function LocalBinary(){
198238
const userAgent = [packageName, version].join('/');
199239
const env = Object.assign({ 'USER_AGENT': userAgent }, process.env);
200240
const obj = childProcess.spawnSync(cmd, opts, { env: env });
241+
if(obj.error) {
242+
that.binaryDownloadError('Download failed with error', util.format(obj.error));
243+
return that.retryBinaryDownload(conf, destParentDir, null, retries, binaryPath);
244+
}
201245
let output;
202246
if(obj.stdout.length > 0) {
203247
if(fs.existsSync(binaryPath)){
@@ -234,6 +278,21 @@ function LocalBinary(){
234278
var binaryPath = path.join(destParentDir, destBinaryName);
235279
var fileStream = fs.createWriteStream(binaryPath);
236280

281+
/* A failed open and the in-flight request can both report on the same
282+
attempt; one attempt must trigger at most one retry. */
283+
var retried = false;
284+
var retryOnce = function(prefix, err) {
285+
that.binaryDownloadError(prefix, util.format(err));
286+
if(retried) return;
287+
retried = true;
288+
that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath);
289+
};
290+
291+
/* Same as lib/download.js: the open() failure lands first. */
292+
fileStream.on('error', function (err) {
293+
retryOnce('Got Error while downloading binary file', err);
294+
});
295+
237296
var options = url.parse(this.httpPath);
238297
if(conf.proxyHost && conf.proxyPort) {
239298
options.agent = new HttpsProxyAgent({
@@ -267,21 +326,15 @@ function LocalBinary(){
267326
}
268327

269328
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);
329+
retryOnce('Got Error in binary download response', err);
276330
});
277331
fileStream.on('close', function () {
278332
fs.chmod(binaryPath, '0755', function() {
279333
callback(binaryPath);
280334
});
281335
});
282336
}).on('error', function(err) {
283-
that.binaryDownloadError('Got Error in binary downloading request', util.format(err));
284-
that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath);
337+
retryOnce('Got Error in binary downloading request', err);
285338
});
286339
});
287340
};

‎lib/download.js‎

Lines changed: 14 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,10 @@ 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 () {
5968
console.log('Done');
6069
});
6170
}).on('error', function(err) {
71+
if(process.exitCode === 1) return; // our own destroy() landing
6272
console.error('Got Error in binary downloading request', err);
6373
});

‎test/local_binary_busy_download.js‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
var expect = require('expect.js'),
2+
childProcess = require('child_process'),
3+
fs = require('fs'),
4+
os = require('os'),
5+
path = require('path'),
6+
LocalBinary = require('../lib/LocalBinary');
7+
8+
// Regression tests for LOC-7420.
9+
//
10+
// On Windows `BrowserStackLocal.exe` in ~/.browserstack is routinely
11+
// unopenable for a moment — an AV scan of a freshly written executable, a
12+
// tunnel still releasing its handle, two workers starting at once — and the
13+
// open fails with EBUSY/EPERM. Two defects turned that transient condition
14+
// into a hard failure:
15+
//
16+
// 1. `download.js` attached its write-stream 'error' handler inside the
17+
// async https.get callback, so the open failure arrived with no listener
18+
// and node killed the download child with an unhandled 'error'.
19+
// 2. `retryBinaryDownload` did its work inside an async callback, so on the
20+
// sync path it returned undefined to a caller that had already given up —
21+
// surfacing as "Couldn't find binary file" while the retries carried on,
22+
// orphaned, in the background.
23+
//
24+
// Neither needs Windows to reproduce: (1) is any createWriteStream failure,
25+
// and (2) is platform-independent.
26+
describe('LocalBinary busy-binary download handling', function () {
27+
28+
describe('retryBinaryDownload', function () {
29+
it('returns the retry result to the caller on the sync path', function () {
30+
var binary = new LocalBinary(),
31+
expected = path.join(os.tmpdir(), 'BrowserStackLocal-fake'),
32+
calls = 0;
33+
34+
// First attempt fails and retries; the retry succeeds. Before the fix
35+
// the returned value was lost in the async callback.
36+
binary.downloadSync = function (conf, dest, retries) {
37+
calls += 1;
38+
if (calls === 1) {
39+
return binary.retryBinaryDownload(conf, dest, null, retries, path.join(os.tmpdir(), 'bs-local-absent'));
40+
}
41+
return expected;
42+
};
43+
44+
expect(binary.downloadSync({}, os.tmpdir(), 9)).to.equal(expected);
45+
expect(calls).to.equal(2);
46+
});
47+
48+
it('stops at the retry ceiling instead of recursing', function () {
49+
var binary = new LocalBinary(), calls = 0;
50+
binary.downloadSync = function (conf, dest, retries) {
51+
calls += 1;
52+
return binary.retryBinaryDownload(conf, dest, null, retries, path.join(os.tmpdir(), 'bs-local-absent'));
53+
};
54+
55+
// One initial attempt plus `retries` further ones, then a clean stop.
56+
expect(binary.downloadSync({}, os.tmpdir(), 3)).to.be(undefined);
57+
expect(calls).to.equal(4);
58+
});
59+
});
60+
61+
describe('isBinaryBusy', function () {
62+
it('reports a readable file as free', function () {
63+
var binary = new LocalBinary(),
64+
probe = path.join(os.tmpdir(), 'bs-local-probe-' + process.pid);
65+
fs.writeFileSync(probe, 'x');
66+
try {
67+
expect(binary.isBinaryBusy(probe)).to.be(false);
68+
} finally {
69+
fs.unlinkSync(probe);
70+
}
71+
});
72+
73+
it('does not report a missing file as busy', function () {
74+
var binary = new LocalBinary();
75+
expect(binary.isBinaryBusy(path.join(os.tmpdir(), 'bs-local-absent-' + process.pid))).to.be(false);
76+
});
77+
});
78+
79+
describe('download.js', function () {
80+
// The open failure is forced with a directory at the target path. The
81+
// errno differs from Windows' EBUSY (-4082); the code path is the same.
82+
it('reports an unwritable target without crashing the child', function () {
83+
var dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bs-local-')),
84+
target = path.join(dir, 'BrowserStackLocal');
85+
fs.mkdirSync(target);
86+
87+
var obj = childProcess.spawnSync(process.execPath, [
88+
path.join(__dirname, '..', 'lib', 'download.js'),
89+
target,
90+
'https://local-downloads.browserstack.com/binaries/release/latest_unzip/BrowserStackLocal'
91+
], { env: Object.assign({ USER_AGENT: 'browserstack-local-test' }, process.env) });
92+
93+
var stderr = obj.stderr.toString();
94+
expect(stderr).to.contain('Got Error while downloading binary file');
95+
// The signature of the old defect: node's unhandled-'error' bail-out.
96+
expect(stderr).to.not.contain('Unhandled \'error\' event');
97+
expect(obj.status).to.equal(1);
98+
99+
fs.rmdirSync(target);
100+
fs.rmdirSync(dir);
101+
});
102+
});
103+
});

0 commit comments

Comments
 (0)