-
Notifications
You must be signed in to change notification settings - Fork 55
LOC-7420: tolerate a busy binary instead of crashing the consumer #185
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,3 +1,5 @@ | ||||||||||||||||||||||
| /* global Atomics, SharedArrayBuffer -- ES2017, used for the blocking wait in | ||||||||||||||||||||||
| waitWhileBinaryBusySync; declared here rather than widening the lint env. */ | ||||||||||||||||||||||
| var https = require('https'), | ||||||||||||||||||||||
| fs = require('fs'), | ||||||||||||||||||||||
| path = require('path'), | ||||||||||||||||||||||
|
|
@@ -71,6 +73,10 @@ function LocalBinary(){ | |||||||||||||||||||||
| env.BROWSERSTACK_LOCAL_AUTH_TOKEN = this.key; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| const obj = childProcess.spawnSync(cmd, opts, { env: env }); | ||||||||||||||||||||||
| /* stdout is null on a spawn failure; reading .length masked the real cause. */ | ||||||||||||||||||||||
| if(obj.error) { | ||||||||||||||||||||||
| throw(util.format(obj.error)); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if(obj.stdout.length > 0) { | ||||||||||||||||||||||
| this.sourceURL = obj.stdout.toString().replace(/\n+$/, ''); | ||||||||||||||||||||||
| this.downloadState.sourceURL = this.sourceURL; | ||||||||||||||||||||||
|
|
@@ -148,23 +154,60 @@ function LocalBinary(){ | |||||||||||||||||||||
| this.downloadErrorMessage = errorMessagePrefix + ' : ' + errorMessage; | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /* A locked binary is transient on Windows (AV scan, a tunnel still releasing | ||||||||||||||||||||||
| its handle), not a corrupt one. Mirrors the CLI binary's existing probe. */ | ||||||||||||||||||||||
| this.BUSY_ERROR_CODES = ['EBUSY', 'EPERM', 'ETXTBSY', 'EACCES']; | ||||||||||||||||||||||
| this.BUSY_MAX_WAITS = 3; | ||||||||||||||||||||||
| this.BUSY_WAIT_MS = 1000; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| this.isBinaryBusy = function(binaryPath) { | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| fs.closeSync(fs.openSync(binaryPath, 'r+')); | ||||||||||||||||||||||
| return false; | ||||||||||||||||||||||
| } catch(err) { | ||||||||||||||||||||||
| return this.BUSY_ERROR_CODES.indexOf(err.code) !== -1; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /* Blocking by design: the sync path has no event loop to come back to. */ | ||||||||||||||||||||||
| this.waitWhileBinaryBusySync = function(binaryPath) { | ||||||||||||||||||||||
| for(var i = 0; i < this.BUSY_MAX_WAITS; i++) { | ||||||||||||||||||||||
| if(!fs.existsSync(binaryPath) || !this.isBinaryBusy(binaryPath)) return; | ||||||||||||||||||||||
| console.log('Binary is in use, waiting before retrying.'); | ||||||||||||||||||||||
| Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, this.BUSY_WAIT_MS); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| this.retryBinaryDownload = function(conf, destParentDir, callback, retries, binaryPath) { | ||||||||||||||||||||||
| var that = this; | ||||||||||||||||||||||
| if(retries > 0) { | ||||||||||||||||||||||
| console.log('Retrying Download. Retries left', retries); | ||||||||||||||||||||||
| /* Single unlink instead of stat-then-unlinkSync: the gap between the two | ||||||||||||||||||||||
| let a concurrent writer swap the file, and a failing unlinkSync threw | ||||||||||||||||||||||
| out of the stat callback where it could not be caught. A missing file | ||||||||||||||||||||||
| is the expected case here, so any error is ignored. */ | ||||||||||||||||||||||
| if(retries <= 0) { | ||||||||||||||||||||||
| console.error('Number of retries to download exceeded.'); | ||||||||||||||||||||||
| /* The async contract has to be completed or Local.start() waits forever. | ||||||||||||||||||||||
| An empty path is the signal; the caller reports it. */ | ||||||||||||||||||||||
| if(callback) callback(); | ||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This completes the contract when retries are exhausted, but Evidence: stub repro with Suggestion: if(err) {
console.error('Unable to fetch the source url to download the binary with error: ', err);
return callback();
}The new |
||||||||||||||||||||||
| return; | ||||||||||||||||||||||
|
Comment on lines
+183
to
+188
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: sed -n '145,220p' lib/LocalBinary.js
sed -n '260,350p' lib/LocalBinary.js
rg -n "getBinaryPath|retryBinaryDownload|start[(: ]" lib/Local.js lib/LocalBinary.js testRepository: browserstack/browserstack-local-nodejs Length of output: 11819 Complete the asynchronous operation when retries are exhausted. When Add a terminal error result to the callback contract. Update 🤖 Prompt for AI Agents |
||||||||||||||||||||||
| } | ||||||||||||||||||||||
| console.log('Retrying Download. Retries left', retries); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /* Must stay synchronous: this return value is what downloadSync -> | ||||||||||||||||||||||
| binaryPath() -> Local.getBinaryPath hands back. Retrying inside a callback | ||||||||||||||||||||||
| returned undefined before the retry had done anything. */ | ||||||||||||||||||||||
| if(!callback) { | ||||||||||||||||||||||
| that.waitWhileBinaryBusySync(binaryPath); | ||||||||||||||||||||||
| try { fs.unlinkSync(binaryPath); } catch(err) { /* missing or locked */ } | ||||||||||||||||||||||
| return that.downloadSync(conf, destParentDir, retries - 1); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| var attemptAsync = function(waitsLeft) { | ||||||||||||||||||||||
| if(waitsLeft > 0 && fs.existsSync(binaryPath) && that.isBinaryBusy(binaryPath)) { | ||||||||||||||||||||||
| console.log('Binary is in use, waiting before retrying.'); | ||||||||||||||||||||||
| return setTimeout(function() { attemptAsync(waitsLeft - 1); }, that.BUSY_WAIT_MS); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| fs.unlink(binaryPath, function() { | ||||||||||||||||||||||
| if(!callback) { | ||||||||||||||||||||||
| return that.downloadSync(conf, destParentDir, retries - 1); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| that.download(conf, destParentDir, callback, retries - 1); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| } else { | ||||||||||||||||||||||
| console.error('Number of retries to download exceeded.'); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
| attemptAsync(that.BUSY_MAX_WAITS); | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| this.downloadSync = function(conf, destParentDir, retries) { | ||||||||||||||||||||||
|
|
@@ -198,6 +241,14 @@ function LocalBinary(){ | |||||||||||||||||||||
| const userAgent = [packageName, version].join('/'); | ||||||||||||||||||||||
| const env = Object.assign({ 'USER_AGENT': userAgent }, process.env); | ||||||||||||||||||||||
| const obj = childProcess.spawnSync(cmd, opts, { env: env }); | ||||||||||||||||||||||
| if(obj.status !== 0) { | ||||||||||||||||||||||
| that.binaryDownloadError('Download failed with status', String(obj.status)); | ||||||||||||||||||||||
| return that.retryBinaryDownload(conf, destParentDir, null, retries, binaryPath); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if(obj.error) { | ||||||||||||||||||||||
| that.binaryDownloadError('Download failed with error', util.format(obj.error)); | ||||||||||||||||||||||
| return that.retryBinaryDownload(conf, destParentDir, null, retries, binaryPath); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| let output; | ||||||||||||||||||||||
| if(obj.stdout.length > 0) { | ||||||||||||||||||||||
| if(fs.existsSync(binaryPath)){ | ||||||||||||||||||||||
|
|
@@ -221,7 +272,8 @@ function LocalBinary(){ | |||||||||||||||||||||
| this.download = function(conf, destParentDir, callback, retries){ | ||||||||||||||||||||||
| this.getDownloadPath(conf, retries, (err, downloadUrl) => { | ||||||||||||||||||||||
| if(err) { | ||||||||||||||||||||||
| return console.error('Unable to fetch the source url to download the binary with error: ', err); | ||||||||||||||||||||||
| console.error('Unable to fetch the source url to download the binary with error: ', err); | ||||||||||||||||||||||
| return callback(); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| this.httpPath = downloadUrl; | ||||||||||||||||||||||
|
|
@@ -234,6 +286,21 @@ function LocalBinary(){ | |||||||||||||||||||||
| var binaryPath = path.join(destParentDir, destBinaryName); | ||||||||||||||||||||||
| var fileStream = fs.createWriteStream(binaryPath); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /* A failed open and the in-flight request can both report on the same | ||||||||||||||||||||||
| attempt; one attempt must trigger at most one retry. */ | ||||||||||||||||||||||
| var retried = false; | ||||||||||||||||||||||
| var retryOnce = function(prefix, err) { | ||||||||||||||||||||||
| that.binaryDownloadError(prefix, util.format(err)); | ||||||||||||||||||||||
| if(retried) return; | ||||||||||||||||||||||
| retried = true; | ||||||||||||||||||||||
| that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath); | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /* Same as lib/download.js: the open() failure lands first. */ | ||||||||||||||||||||||
| fileStream.on('error', function (err) { | ||||||||||||||||||||||
| retryOnce('Got Error while downloading binary file', err); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| var options = url.parse(this.httpPath); | ||||||||||||||||||||||
| if(conf.proxyHost && conf.proxyPort) { | ||||||||||||||||||||||
| options.agent = new HttpsProxyAgent({ | ||||||||||||||||||||||
|
|
@@ -267,21 +334,18 @@ function LocalBinary(){ | |||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| response.on('error', function(err) { | ||||||||||||||||||||||
| that.binaryDownloadError('Got Error in binary download response', util.format(err)); | ||||||||||||||||||||||
| that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| fileStream.on('error', function (err) { | ||||||||||||||||||||||
| that.binaryDownloadError('Got Error while downloading binary file', util.format(err)); | ||||||||||||||||||||||
| that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath); | ||||||||||||||||||||||
| retryOnce('Got Error in binary download response', err); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| fileStream.on('close', function () { | ||||||||||||||||||||||
| /* node emits 'close' after 'error' too, so without this a failed | ||||||||||||||||||||||
| attempt reports success alongside the retry it just started. */ | ||||||||||||||||||||||
| if(retried) return; | ||||||||||||||||||||||
| fs.chmod(binaryPath, '0755', function() { | ||||||||||||||||||||||
| callback(binaryPath); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
Comment on lines
339
to
345
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '260,345p' lib/LocalBinary.js
cat package.json | sed -n '1,120p'
rg -n "fileStream|retryOnce|downloadBinary" test lib/LocalBinary.jsRepository: browserstack/browserstack-local-nodejs Length of output: 5040 🏁 Script executed: rg -n -A35 -B15 "retryBinaryDownload|this\\.retryBinaryDownload|retryOnce|fileStream\\.on\\('close'|fileStream\\.on\\('finish'" lib/LocalBinary.js lib/download.js test 2>/dev/nullRepository: browserstack/browserstack-local-nodejs Length of output: 19810 🌐 Web query:
💡 Result: <source_evidence> Citations:
🏁 Script executed: sed -n '215,345p' lib/LocalBinary.js; printf '\\n--- related stream code ---\\n'; rg -n -A20 -B10 "retryBinaryDownload|createWriteStream|\\.on\\('close'|\\.on\\('finish'" lib test 2>/dev/nullRepository: browserstack/browserstack-local-nodejs Length of output: 21465 Guard completion after a failed download attempt. When Proposed fix fileStream.on('close', function () {
+ if(retried) return;
fs.chmod(binaryPath, '0755', function() {
callback(binaryPath);
});
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| }).on('error', function(err) { | ||||||||||||||||||||||
| that.binaryDownloadError('Got Error in binary downloading request', util.format(err)); | ||||||||||||||||||||||
| that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath); | ||||||||||||||||||||||
| retryOnce('Got Error in binary downloading request', err); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,18 @@ const binaryPath = process.argv[2], httpPath = process.argv[3], proxyHost = proc | |
|
|
||
| var fileStream = fs.createWriteStream(binaryPath); | ||
|
|
||
| /* Must be attached before the async https.get: createWriteStream emits 'error' | ||
| on the next tick, and with no listener node turns that into a hard throw. */ | ||
| var request; | ||
|
|
||
| fileStream.on('error', function (err) { | ||
| console.error('Got Error while downloading binary file', err); | ||
| process.exitCode = 1; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nothing reads this exit code yet: Suggestion: have |
||
| /* Otherwise the child keeps downloading into a dead stream and the parent's | ||
| spawnSync blocks for a whole download before it can retry. */ | ||
| if(request) request.destroy(); | ||
| }); | ||
|
|
||
| var options = url.parse(httpPath); | ||
| /* isUndefined, not plain truthiness: the parent passes literal `undefined` | ||
| placeholders for the proxy slots when only a CA is configured, and those | ||
|
|
@@ -37,7 +49,7 @@ options.headers = Object.assign({}, options.headers, { | |
| 'user-agent': process.env.USER_AGENT, | ||
| }); | ||
|
|
||
| https.get(options, function (response) { | ||
| request = https.get(options, function (response) { | ||
| const contentEncoding = response.headers['content-encoding']; | ||
| if (typeof contentEncoding === 'string' && contentEncoding.match(/gzip/i)) { | ||
| if (process.env.BROWSERSTACK_LOCAL_DEBUG_GZIP) { | ||
|
|
@@ -52,12 +64,11 @@ https.get(options, function (response) { | |
| response.on('error', function(err) { | ||
| console.error('Got Error in binary download response', err); | ||
| }); | ||
| fileStream.on('error', function (err) { | ||
| console.error('Got Error while downloading binary file', err); | ||
| }); | ||
| fileStream.on('close', function () { | ||
| if(process.exitCode === 1) return; // errored; not a completed download | ||
| console.log('Done'); | ||
| }); | ||
| }).on('error', function(err) { | ||
| if(process.exitCode === 1) return; // our own destroy() landing | ||
| console.error('Got Error in binary downloading request', err); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When this unlink fails (the locked-file case this PR is about),
delete(that.binaryPath)+startSync→binaryPath()passes thecheckPath(X_OK)atLocalBinary.js:355— on WindowsX_OKbehaves likeF_OK— so the same corrupt file is re-spawned for all 9 retries with no wait.Evidence: reproduced with a corrupt binary whose unlink fails: it was spawned 10× in 73 ms and never replaced. The new busy wait lives only in
retryBinaryDownload, which this path never reaches.Suggestion: call
this.binary.waitWhileBinaryBusySync(that.binaryPath)before the unlink here (and thesetTimeoutvariant instart()at :128), and don't reuse the file if the unlink still fails. Otherwise defect 4's recovery only works when the unlink succeeds.