Skip to content

Commit b845ad3

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 test workers starting at once -- and the open fails with EBUSY/EPERM. POSIX allows opening and unlinking a file in use, so this only manifests on Windows. Several defects turned that transient condition into a hard crash before any session started. download.js and LocalBinary.js registered their write-stream 'error' handler inside the async https.get callback. createWriteStream fails at the open() syscall and emits on the next tick, well before the TLS round trip completes, so the error arrived with no listener and node's `throw er` killed the download child. The handlers now attach immediately after createWriteStream. Handling that error is not sufficient on its own: the request is still in flight, and without tearing it down the child stays alive downloading into a stream nobody reads, so the parent's spawnSync blocks for a whole download before it can retry. The throw was also doing the job of stopping the download; download.js now destroys the request explicitly. retryBinaryDownload did its work inside an async callback, so on the sync path it returned undefined to a caller that had already given up -- which surfaced as "Couldn't find binary file" while the retries carried on, orphaned, in the background. This happened even when the unlink succeeded, so it is not a consequence of the EPERM. The sync path is now synchronous end to end and returns its result. Retrying instantly against a live lock just burns the retry budget in milliseconds, so a busy binary is now probed (openSync 'r+') and waited on, bounded, rather than deleted. spawnSync reports spawn-level failures through obj.error, leaving stdout null; reading .length threw a TypeError that replaced the real cause in the reported message, after which the binary was deleted anyway. execFile raises the same class of failure synchronously rather than through its callback -- EBADARCH from a binary built for another architecture, for instance -- and inside the getBinaryPath callback an uncaught throw there killed the consumer's process outright, the same failure mode as the download path. Both are now handled, and the unlinkSync calls in the error paths no longer throw out of start(). Tests cover the retry return value, the retry ceiling, the busy probe and the unhandled-'error' regression. They 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. .eslintrc.json moves from es6 to es2017 for Atomics/SharedArrayBuffer, used for the bounded wait on the sync path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8096a53 commit b845ad3

5 files changed

Lines changed: 263 additions & 62 deletions

File tree

‎.eslintrc.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"no-constant-condition": 0
2121
},
2222
"env": {
23-
"es6": true,
23+
"es2017": true,
2424
"node": true
2525
},
2626
"extends": "eslint:recommended",

‎lib/Local.js‎

Lines changed: 58 additions & 37 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-level failure; reading .length below threw a
62+
TypeError that replaced the real cause in the reported message. */
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,10 @@ 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+
/* An unremovable binary is not a reason to abort the retry: on Windows
88+
this is EPERM on a locked file, and it threw straight out of
89+
startSync, killing the consumer's run. */
90+
try { fs.unlinkSync(that.binaryPath); } catch(err) { /* ignored */ }
8391
delete(that.binaryPath);
8492
that.binaryDownloadState.errorMessage = binaryDownloadErrorMessage;
8593
that.binaryDownloadState.fallbackEnabled = true;
@@ -107,46 +115,59 @@ function Local(){
107115
}
108116

109117
that.opcode = 'start';
110-
that.tunnel = childProcess.execFile(that.binaryPath, that.getBinaryArgs(), function(error, stdout, stderr){
111-
if(error) {
112-
const binaryDownloadErrorMessage = `Error while trying to execute binary: ${util.format(error)}`;
113-
console.error(binaryDownloadErrorMessage);
114-
if(that.retriesLeft > 0) {
115-
console.log('Retrying Binary Download. Retries Left', that.retriesLeft);
116-
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);
122-
return;
123-
} else {
124-
callback(new LocalError(error.toString()));
125-
return;
126-
}
127-
}
128118

129-
var data = {};
130-
var output = stdout || stderr;
131-
if(!output) {
132-
callback(new LocalError('No output received'));
133-
return;
134-
}
135-
try {
136-
data = JSON.parse(output);
137-
} catch(parseError) {
138-
callback(new LocalError('Invalid output received: ' + parseError.message, output));
119+
var onBinaryError = function(error) {
120+
const binaryDownloadErrorMessage = `Error while trying to execute binary: ${util.format(error)}`;
121+
console.error(binaryDownloadErrorMessage);
122+
if(that.retriesLeft > 0) {
123+
console.log('Retrying Binary Download. Retries Left', that.retriesLeft);
124+
that.retriesLeft -= 1;
125+
try { fs.unlinkSync(that.binaryPath); } catch(err) { /* ignored */ }
126+
delete(that.binaryPath);
127+
that.binaryDownloadState.errorMessage = binaryDownloadErrorMessage;
128+
that.binaryDownloadState.fallbackEnabled = true;
129+
that.start(options, callback);
139130
return;
140131
}
132+
callback(new LocalError(error.toString()));
133+
};
134+
135+
/* execFile raises spawn-level failures synchronously rather than through
136+
its callback -- EBADARCH from a binary built for another architecture,
137+
for instance. This runs inside the getBinaryPath callback, so an
138+
uncaught throw here kills the consumer's process outright: the same
139+
failure mode as the download path, and equally not something a caller
140+
can wrap. */
141+
try {
142+
that.tunnel = childProcess.execFile(that.binaryPath, that.getBinaryArgs(), function(error, stdout, stderr){
143+
if(error) {
144+
return onBinaryError(error);
145+
}
141146

142-
if(data['state'] != 'connected'){
143-
callback(new LocalError(that.getErrorMessage(data)));
144-
} else {
145-
that.pid = data['pid'];
146-
that.isProcessRunning = true;
147-
callback();
148-
}
149-
});
147+
var data = {};
148+
var output = stdout || stderr;
149+
if(!output) {
150+
callback(new LocalError('No output received'));
151+
return;
152+
}
153+
try {
154+
data = JSON.parse(output);
155+
} catch(parseError) {
156+
callback(new LocalError('Invalid output received: ' + parseError.message, output));
157+
return;
158+
}
159+
160+
if(data['state'] != 'connected'){
161+
callback(new LocalError(that.getErrorMessage(data)));
162+
} else {
163+
that.pid = data['pid'];
164+
that.isProcessRunning = true;
165+
callback();
166+
}
167+
});
168+
} catch(spawnError) {
169+
onBinaryError(spawnError);
170+
}
150171
}, options['bs-host']);
151172
};
152173

‎lib/LocalBinary.js‎

Lines changed: 80 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ function LocalBinary(){
7171
env.BROWSERSTACK_LOCAL_AUTH_TOKEN = this.key;
7272
}
7373
const obj = childProcess.spawnSync(cmd, opts, { env: env });
74+
/* On a spawn-level failure stdout is null, so reading .length here threw a
75+
TypeError that masked the real cause. */
76+
if(obj.error) {
77+
throw(util.format(obj.error));
78+
}
7479
if(obj.stdout.length > 0) {
7580
this.sourceURL = obj.stdout.toString().replace(/\n+$/, '');
7681
this.downloadState.sourceURL = this.sourceURL;
@@ -148,23 +153,63 @@ function LocalBinary(){
148153
this.downloadErrorMessage = errorMessagePrefix + ' : ' + errorMessage;
149154
};
150155

156+
/* A locked binary is a transient, ordinary condition on Windows (AV scan, a
157+
tunnel still releasing its handle, a concurrent run), not a corrupt file.
158+
Mirrors the probe the CLI binary already uses. */
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 download path has no event loop to come back
173+
to, so the wait has to happen inline. */
174+
this.waitWhileBinaryBusySync = function(binaryPath) {
175+
for(var i = 0; i < this.BUSY_MAX_WAITS; i++) {
176+
if(!fs.existsSync(binaryPath) || !this.isBinaryBusy(binaryPath)) return;
177+
console.log('Binary is in use, waiting before retrying.');
178+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, this.BUSY_WAIT_MS);
179+
}
180+
};
181+
151182
this.retryBinaryDownload = function(conf, destParentDir, callback, retries, binaryPath) {
152183
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. */
184+
if(retries <= 0) {
185+
console.error('Number of retries to download exceeded.');
186+
return;
187+
}
188+
console.log('Retrying Download. Retries left', retries);
189+
190+
/* The sync path must stay synchronous end to end: its return value is what
191+
downloadSync -> binaryPath() -> Local.getBinaryPath hands back. Doing the
192+
unlink in a callback returned undefined to the caller before the retry had
193+
done anything, which surfaced as "Couldn't find binary file" while the
194+
retries carried on, orphaned, in the background. */
195+
if(!callback) {
196+
that.waitWhileBinaryBusySync(binaryPath);
197+
/* A missing file is the expected case, and a still-locked one is better
198+
re-downloaded over than crashed on. */
199+
try { fs.unlinkSync(binaryPath); } catch(err) { /* ignored */ }
200+
return that.downloadSync(conf, destParentDir, retries - 1);
201+
}
202+
203+
var attemptAsync = function(waitsLeft) {
204+
if(waitsLeft > 0 && fs.existsSync(binaryPath) && that.isBinaryBusy(binaryPath)) {
205+
console.log('Binary is in use, waiting before retrying.');
206+
return setTimeout(function() { attemptAsync(waitsLeft - 1); }, that.BUSY_WAIT_MS);
207+
}
159208
fs.unlink(binaryPath, function() {
160-
if(!callback) {
161-
return that.downloadSync(conf, destParentDir, retries - 1);
162-
}
163209
that.download(conf, destParentDir, callback, retries - 1);
164210
});
165-
} else {
166-
console.error('Number of retries to download exceeded.');
167-
}
211+
};
212+
attemptAsync(that.BUSY_MAX_WAITS);
168213
};
169214

170215
this.downloadSync = function(conf, destParentDir, retries) {
@@ -198,6 +243,10 @@ function LocalBinary(){
198243
const userAgent = [packageName, version].join('/');
199244
const env = Object.assign({ 'USER_AGENT': userAgent }, process.env);
200245
const obj = childProcess.spawnSync(cmd, opts, { env: env });
246+
if(obj.error) {
247+
that.binaryDownloadError('Download failed with error', util.format(obj.error));
248+
return that.retryBinaryDownload(conf, destParentDir, null, retries, binaryPath);
249+
}
201250
let output;
202251
if(obj.stdout.length > 0) {
203252
if(fs.existsSync(binaryPath)){
@@ -234,6 +283,23 @@ function LocalBinary(){
234283
var binaryPath = path.join(destParentDir, destBinaryName);
235284
var fileStream = fs.createWriteStream(binaryPath);
236285

286+
/* Now that the stream handler is registered before https.get, a failed
287+
open and the in-flight request can both report an error for the same
288+
attempt. Collapse them: one attempt triggers at most one retry. */
289+
var retried = false;
290+
var retryOnce = function(prefix, err) {
291+
that.binaryDownloadError(prefix, util.format(err));
292+
if(retried) return;
293+
retried = true;
294+
that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath);
295+
};
296+
297+
/* Same reasoning as lib/download.js: the open() failure lands before the
298+
https.get callback can register a listener. */
299+
fileStream.on('error', function (err) {
300+
retryOnce('Got Error while downloading binary file', err);
301+
});
302+
237303
var options = url.parse(this.httpPath);
238304
if(conf.proxyHost && conf.proxyPort) {
239305
options.agent = new HttpsProxyAgent({
@@ -267,21 +333,15 @@ function LocalBinary(){
267333
}
268334

269335
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);
336+
retryOnce('Got Error in binary download response', err);
276337
});
277338
fileStream.on('close', function () {
278339
fs.chmod(binaryPath, '0755', function() {
279340
callback(binaryPath);
280341
});
281342
});
282343
}).on('error', function(err) {
283-
that.binaryDownloadError('Got Error in binary downloading request', util.format(err));
284-
that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath);
344+
retryOnce('Got Error in binary downloading request', err);
285345
});
286346
});
287347
};

‎lib/download.js‎

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

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

12+
/* Attached synchronously, before the async https.get below. createWriteStream
13+
fails at the open() syscall and emits 'error' on the next tick -- on Windows
14+
an EBUSY/EPERM when the .exe is held by AV, a shutting-down tunnel or a
15+
concurrent run. Registering the handler inside the https.get callback left a
16+
window with no listener, which Node turns into an unhandled 'error' and a
17+
hard `throw er` that killed this child. */
18+
var request;
19+
20+
fileStream.on('error', function (err) {
21+
console.error('Got Error while downloading binary file', err);
22+
process.exitCode = 1;
23+
/* The request is still in flight. Without tearing it down this child stays
24+
alive downloading into a stream nobody is reading, and the parent's
25+
spawnSync blocks for a whole download before it can retry — where the
26+
unhandled 'error' at least failed immediately. */
27+
if(request) request.destroy();
28+
});
29+
1230
var options = url.parse(httpPath);
1331
/* isUndefined, not plain truthiness: the parent passes literal `undefined`
1432
placeholders for the proxy slots when only a CA is configured, and those
@@ -37,7 +55,7 @@ options.headers = Object.assign({}, options.headers, {
3755
'user-agent': process.env.USER_AGENT,
3856
});
3957

40-
https.get(options, function (response) {
58+
request = https.get(options, function (response) {
4159
const contentEncoding = response.headers['content-encoding'];
4260
if (typeof contentEncoding === 'string' && contentEncoding.match(/gzip/i)) {
4361
if (process.env.BROWSERSTACK_LOCAL_DEBUG_GZIP) {
@@ -52,12 +70,11 @@ https.get(options, function (response) {
5270
response.on('error', function(err) {
5371
console.error('Got Error in binary download response', err);
5472
});
55-
fileStream.on('error', function (err) {
56-
console.error('Got Error while downloading binary file', err);
57-
});
5873
fileStream.on('close', function () {
5974
console.log('Done');
6075
});
6176
}).on('error', function(err) {
77+
/* Already reported and already exiting; this is our own destroy() landing. */
78+
if(process.exitCode === 1) return;
6279
console.error('Got Error in binary downloading request', err);
6380
});

0 commit comments

Comments
 (0)