From 69b1a394e3ffa97b854fda30b025286b9a855523 Mon Sep 17 00:00:00 2001 From: pranay-v29 Date: Tue, 29 Sep 2026 18:46:22 +0530 Subject: [PATCH 1/2] Reuse a running binary instead of replacing it Parallel runs on one machine share ~/.browserstack/BrowserStackLocal.exe. While one run is executing it, Windows keeps the file locked for that run's whole lifetime, so a second run that reaches the download path hits EBUSY on the open. 1.5.15 no longer crashes on that, but it waits three seconds and then tries to delete and re-download the file -- which cannot succeed while the first run holds it, so the second run exhausts its retries and never gets a tunnel (SDK-7713). A locked binary that still answers --version is a working binary. Before waiting on or replacing it, probe it and reuse it if it runs; probe again after the wait, since an AV scan or a finishing writer can release it. A file that does not run is still replaced as before. The version check mirrors browserstack-local-python's __verify_binary. Co-Authored-By: Claude Opus 5.5 --- lib/LocalBinary.js | 36 +++++++++++++++++++++----- test/local_binary_busy_download.js | 41 ++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/lib/LocalBinary.js b/lib/LocalBinary.js index 632d88b..acc68fb 100644 --- a/lib/LocalBinary.js +++ b/lib/LocalBinary.js @@ -14,6 +14,7 @@ var https = require('https'), fetchDownloadSourceUrlAsync = require('./fetchDownloadSourceUrlAsync'); const packageName = 'browserstack-local-nodejs'; +const VERSION_REGEX = /BrowserStack Local version \d+\.\d+/; function LocalBinary(){ this.hostOS = process.platform; @@ -178,6 +179,24 @@ function LocalBinary(){ } }; + /* A binary that answers --version is usable as-is, even while another run holds it. */ + this.isUsableBinarySync = function(binaryPath) { + if(!fs.existsSync(binaryPath)) return false; + var obj = childProcess.spawnSync(binaryPath, ['--version'], { timeout: 10000 }); + return !obj.error && VERSION_REGEX.test(String(obj.stdout)); + }; + + this.isUsableBinary = function(binaryPath, done) { + if(!fs.existsSync(binaryPath)) return done(false); + try { + childProcess.execFile(binaryPath, ['--version'], { timeout: 10000 }, function(err, stdout) { + done(!err && VERSION_REGEX.test(String(stdout))); + }); + } catch(err) { + done(false); + } + }; + this.retryBinaryDownload = function(conf, destParentDir, callback, retries, binaryPath) { var that = this; if(retries <= 0) { @@ -193,18 +212,23 @@ function LocalBinary(){ binaryPath() -> Local.getBinaryPath hands back. Retrying inside a callback returned undefined before the retry had done anything. */ if(!callback) { + if(that.isUsableBinarySync(binaryPath)) return binaryPath; that.waitWhileBinaryBusySync(binaryPath); + if(that.isUsableBinarySync(binaryPath)) return 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() { - that.download(conf, destParentDir, callback, retries - 1); + that.isUsableBinary(binaryPath, function(usable) { + if(usable) return callback(binaryPath); + 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() { + that.download(conf, destParentDir, callback, retries - 1); + }); }); }; attemptAsync(that.BUSY_MAX_WAITS); diff --git a/test/local_binary_busy_download.js b/test/local_binary_busy_download.js index d7a70fb..8fd772a 100644 --- a/test/local_binary_busy_download.js +++ b/test/local_binary_busy_download.js @@ -130,6 +130,47 @@ describe('LocalBinary busy-binary download handling', function () { }); }); + // SDK-7713: parallel runs share ~/.browserstack/BrowserStackLocal.exe, and + // while one run executes it the file is locked. A binary that still runs + // must be reused, not replaced. Stubs are shell scripts, so skipped on Windows. + (process.platform === 'win32' ? describe.skip : describe)('usable binary reuse', function () { + var dir; + beforeEach(function () { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bs-local-')); }); + + function stub(output) { + var p = path.join(dir, 'BrowserStackLocal'); + fs.writeFileSync(p, '#!/bin/sh\necho "' + output + '"\n', { mode: 0o755 }); + return p; + } + + it('reuses a usable binary instead of re-downloading (sync)', function () { + var binary = new LocalBinary(), downloaded = false, p = stub('BrowserStack Local version 8.9.36'); + binary.downloadSync = function () { downloaded = true; }; + + expect(binary.retryBinaryDownload({}, dir, null, 9, p)).to.equal(p); + expect(downloaded).to.be(false); + expect(fs.existsSync(p)).to.be(true); + }); + + it('still replaces a binary that does not run (sync)', function () { + var binary = new LocalBinary(), downloaded = false, p = stub('garbage'); + binary.downloadSync = function () { downloaded = true; }; + + binary.retryBinaryDownload({}, dir, null, 9, p); + expect(downloaded).to.be(true); + }); + + it('reuses a usable binary instead of re-downloading (async)', function (done) { + var binary = new LocalBinary(), p = stub('BrowserStack Local version 8.9.36'); + binary.download = function () { done(new Error('should not re-download')); }; + + binary.retryBinaryDownload({}, dir, function (binaryPath) { + expect(binaryPath).to.equal(p); + done(); + }, 9, p); + }); + }); + describe('isBinaryBusy', function () { it('reports a readable file as free', function () { var binary = new LocalBinary(), From cfe59cefdc0a9cebb5acfd0564e213755b4f8421 Mon Sep 17 00:00:00 2001 From: pranay-v29 Date: Wed, 30 Sep 2026 18:19:16 +0530 Subject: [PATCH 2/2] Look for BrowserStackLocal.exe on Windows before downloading binaryPath() picks the file to look for with this.windows, but that flag was only set inside getBinaryFilename(), which runs on the download path. On a fresh LocalBinary it was still undefined, so on Windows binaryPath() checked for "BrowserStackLocal" without the extension, never found it, and re-downloaded the binary on every start -- writing over BrowserStackLocal.exe each time. That is what the SDK-7713 logs show: a second run started fourteen seconds after the first run's tunnel was up, went straight to the download, and hit EBUSY because the first run was executing the file. It also means every Windows start paid for a full download. Set the flag in the constructor, as Local.js already does. This replaces the --version probe from the previous commit, which only reused the binary after the collision; with the right filename there is no download to collide. Co-Authored-By: Claude Opus 5.5 --- lib/LocalBinary.js | 38 ++++--------------- test/local_binary_busy_download.js | 61 +++++++++++++++--------------- 2 files changed, 39 insertions(+), 60 deletions(-) diff --git a/lib/LocalBinary.js b/lib/LocalBinary.js index acc68fb..eb94cfc 100644 --- a/lib/LocalBinary.js +++ b/lib/LocalBinary.js @@ -14,10 +14,11 @@ var https = require('https'), fetchDownloadSourceUrlAsync = require('./fetchDownloadSourceUrlAsync'); const packageName = 'browserstack-local-nodejs'; -const VERSION_REGEX = /BrowserStack Local version \d+\.\d+/; function LocalBinary(){ this.hostOS = process.platform; + /* Set here, not only in getBinaryFilename(): binaryPath() reads it before any download. */ + this.windows = /mswin|msys|mingw|cygwin|bccwin|wince|emc|win32/i.test(this.hostOS); this.is64bits = process.arch == 'x64'; this.isArm64 = process.arch == 'arm64'; this.baseRetries = 9; @@ -179,24 +180,6 @@ function LocalBinary(){ } }; - /* A binary that answers --version is usable as-is, even while another run holds it. */ - this.isUsableBinarySync = function(binaryPath) { - if(!fs.existsSync(binaryPath)) return false; - var obj = childProcess.spawnSync(binaryPath, ['--version'], { timeout: 10000 }); - return !obj.error && VERSION_REGEX.test(String(obj.stdout)); - }; - - this.isUsableBinary = function(binaryPath, done) { - if(!fs.existsSync(binaryPath)) return done(false); - try { - childProcess.execFile(binaryPath, ['--version'], { timeout: 10000 }, function(err, stdout) { - done(!err && VERSION_REGEX.test(String(stdout))); - }); - } catch(err) { - done(false); - } - }; - this.retryBinaryDownload = function(conf, destParentDir, callback, retries, binaryPath) { var that = this; if(retries <= 0) { @@ -212,23 +195,18 @@ function LocalBinary(){ binaryPath() -> Local.getBinaryPath hands back. Retrying inside a callback returned undefined before the retry had done anything. */ if(!callback) { - if(that.isUsableBinarySync(binaryPath)) return binaryPath; that.waitWhileBinaryBusySync(binaryPath); - if(that.isUsableBinarySync(binaryPath)) return binaryPath; try { fs.unlinkSync(binaryPath); } catch(err) { /* missing or locked */ } return that.downloadSync(conf, destParentDir, retries - 1); } var attemptAsync = function(waitsLeft) { - that.isUsableBinary(binaryPath, function(usable) { - if(usable) return callback(binaryPath); - 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() { - that.download(conf, destParentDir, callback, retries - 1); - }); + 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() { + that.download(conf, destParentDir, callback, retries - 1); }); }; attemptAsync(that.BUSY_MAX_WAITS); diff --git a/test/local_binary_busy_download.js b/test/local_binary_busy_download.js index 8fd772a..81063b9 100644 --- a/test/local_binary_busy_download.js +++ b/test/local_binary_busy_download.js @@ -130,44 +130,45 @@ describe('LocalBinary busy-binary download handling', function () { }); }); - // SDK-7713: parallel runs share ~/.browserstack/BrowserStackLocal.exe, and - // while one run executes it the file is locked. A binary that still runs - // must be reused, not replaced. Stubs are shell scripts, so skipped on Windows. - (process.platform === 'win32' ? describe.skip : describe)('usable binary reuse', function () { - var dir; - beforeEach(function () { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bs-local-')); }); - - function stub(output) { - var p = path.join(dir, 'BrowserStackLocal'); - fs.writeFileSync(p, '#!/bin/sh\necho "' + output + '"\n', { mode: 0o755 }); - return p; + // SDK-7713: binaryPath() read this.windows before anything had set it, so on + // Windows it looked for "BrowserStackLocal" without ".exe", never found it, and + // re-downloaded on every start -- colliding with any other run executing it. + describe('existing binary on Windows', function () { + var dir, exe; + + function windowsBinary() { + var platform = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + var binary = new LocalBinary(); + } finally { + Object.defineProperty(process, 'platform', platform); + } + binary.orderedPaths = [dir]; + binary.download = binary.downloadSync = function () { throw new Error('should not re-download'); }; + return binary; } - it('reuses a usable binary instead of re-downloading (sync)', function () { - var binary = new LocalBinary(), downloaded = false, p = stub('BrowserStack Local version 8.9.36'); - binary.downloadSync = function () { downloaded = true; }; - - expect(binary.retryBinaryDownload({}, dir, null, 9, p)).to.equal(p); - expect(downloaded).to.be(false); - expect(fs.existsSync(p)).to.be(true); + beforeEach(function () { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bs-local-')); + exe = path.join(dir, 'BrowserStackLocal.exe'); + fs.writeFileSync(exe, 'binary', { mode: 0o755 }); }); - it('still replaces a binary that does not run (sync)', function () { - var binary = new LocalBinary(), downloaded = false, p = stub('garbage'); - binary.downloadSync = function () { downloaded = true; }; - - binary.retryBinaryDownload({}, dir, null, 9, p); - expect(downloaded).to.be(true); + afterEach(function () { + fs.unlinkSync(exe); + fs.rmdirSync(dir); }); - it('reuses a usable binary instead of re-downloading (async)', function (done) { - var binary = new LocalBinary(), p = stub('BrowserStack Local version 8.9.36'); - binary.download = function () { done(new Error('should not re-download')); }; + it('reuses BrowserStackLocal.exe instead of re-downloading (sync)', function () { + expect(windowsBinary().binaryPath({}, null, 'key', 9)).to.equal(exe); + }); - binary.retryBinaryDownload({}, dir, function (binaryPath) { - expect(binaryPath).to.equal(p); + it('reuses BrowserStackLocal.exe instead of re-downloading (async)', function (done) { + windowsBinary().binaryPath({}, null, 'key', 9, function (binaryPath) { + expect(binaryPath).to.equal(exe); done(); - }, 9, p); + }); }); });