Skip to content

Commit b9fe94e

Browse files
vivianludrickclaude
andcommitted
LOC-7325: third review round — download-flow hardening + payload semantics
- start() now parses binary output BEFORE the retry gate: a structured payload means the binary executed, so deterministic failures fail fast with the payload's message instead of 9 delete/re-download cycles; an unusable payload message keeps the raw payload as error.extra. - A connected payload with pid is treated as success even when the foreground process exits non-zero (matches startSync), instead of reporting an error while isRunning() is true. - Invalid (unparseable) output from a downloaded binary evicts it — without retrying — so the next start self-heals with a fresh download; user-supplied binarypath binaries are never evicted. - LocalBinary.download(): gunzip stream gets an 'error' handler routed into the settled retry path (corrupt gzip was an uncatchable crash or a silent hang); non-2xx responses no longer write the error body to the binary and report success; source-url failures retry without deleting a pre-existing binary (also removes the this.windows-unset wrong-path trap); retryBinaryDownload's unlinkSync is guarded; retry exhaustion delivers callback(null) and Local.start maps a falsy path to a terminal LocalError carrying the recorded download error, instead of ENOENT-driven retry cascades (~100 attempts worst case). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7cbe054 commit b9fe94e

3 files changed

Lines changed: 139 additions & 40 deletions

File tree

lib/Local.js

Lines changed: 68 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ function Local(){
1818
this.windows = os.platform().match(/mswin|msys|mingw|cygwin|bccwin|wince|emc|win32/i);
1919
this.pid = undefined;
2020
this.isProcessRunning = false;
21+
this.userProvidedBinaryPath = false;
2122
this.retriesLeft = 9;
2223
this.key = process.env.BROWSERSTACK_ACCESS_KEY;
2324
this.logfile = this.sanitizePath(path.join(process.cwd(), 'local.log'));
@@ -52,8 +53,14 @@ function Local(){
5253
throw obj.error;
5354
this.tunnel = {pid: obj.pid};
5455
var result = that.parseBinaryOutput(obj.stdout, obj.stderr);
55-
if(result.error)
56+
if(result.error) {
57+
if(result.invalidOutput) {
58+
// A cached binary that runs but prints garbage may be corrupt on
59+
// disk; evict it so the next start re-downloads a fresh copy.
60+
that.evictDownloadedBinary();
61+
}
5662
return result.error;
63+
}
5764
var data = result.data;
5865
if(data['state'] != 'connected'){
5966
return new LocalError(that.getErrorMessage(data));
@@ -83,6 +90,12 @@ function Local(){
8390
return callback();
8491

8592
this.getBinaryPath(function(binaryPath){
93+
if(!binaryPath){
94+
// Terminal download failure signalled by LocalBinary (falsy path);
95+
// retrying here would only re-run the whole download cascade.
96+
var downloadErrorMessage = (that.binary && that.binary.downloadErrorMessage) || 'Unable to download BrowserStack Local binary';
97+
return callback(new LocalError(downloadErrorMessage));
98+
}
8699
that.binaryPath = binaryPath;
87100
try {
88101
fs.writeFileSync(that.logfile, '');
@@ -102,36 +115,53 @@ function Local(){
102115
callback(err);
103116
};
104117
try {
118+
var result = that.parseBinaryOutput(stdout, stderr);
105119
if(error) {
106120
const binaryDownloadErrorMessage = `Error while trying to execute binary: ${util.format(error)}`;
107121
console.error(binaryDownloadErrorMessage);
122+
if(result.data) {
123+
// The binary executed and reported a structured result, so the
124+
// failure is not a corrupt download — retrying (delete +
125+
// re-download) cannot help; fail fast with the richer diagnostic
126+
// instead of burning the retry budget first.
127+
if(result.data['state'] == 'connected' && result.data['pid']) {
128+
// The daemon came up even though the foreground process exited
129+
// non-zero; treat it as success so isRunning()/stop() agree
130+
// with reality (startSync likewise ignores the exit status
131+
// when the payload says connected).
132+
that.pid = result.data['pid'];
133+
that.isProcessRunning = true;
134+
safeCallback();
135+
return;
136+
}
137+
var payloadMessage = that.extractErrorMessage(result.data);
138+
if(payloadMessage) {
139+
safeCallback(new LocalError(payloadMessage));
140+
return;
141+
}
142+
// Payload parsed but carries no usable message: surface the
143+
// exec error and keep the raw payload as extra.
144+
var rawPayload = (stdout && stdout.length > 0) ? stdout : stderr;
145+
safeCallback(new LocalError(error.toString(), that.truncateForExtra(rawPayload)));
146+
return;
147+
}
108148
if(that.retriesLeft > 0) {
109149
that.prepareBinaryRetry(binaryDownloadErrorMessage);
110150
that.start(options, callback);
111151
return;
112152
}
113-
// The binary can exit non-zero while still printing a JSON
114-
// diagnostic on stdout; keep whichever diagnostic is richer.
115-
var failure = that.parseBinaryOutput(stdout, stderr);
116-
if(failure.data && failure.data['state'] == 'connected' && failure.data['pid']) {
117-
// The daemon came up even though the foreground process
118-
// errored; record the pid so a later stop() can still reach it.
119-
that.pid = failure.data['pid'];
120-
that.isProcessRunning = true;
121-
}
122-
var payloadMessage = (failure.data && failure.data['state'] != 'connected') ? that.extractErrorMessage(failure.data) : null;
123-
if(payloadMessage) {
124-
safeCallback(new LocalError(payloadMessage));
125-
} else {
126-
// Keep any raw (non-JSON) output as extra — it usually holds
127-
// the crash text.
128-
safeCallback(new LocalError(error.toString(), failure.error && failure.error.extra));
129-
}
153+
// Keep any raw (non-JSON) output as extra — it usually holds
154+
// the crash text.
155+
safeCallback(new LocalError(error.toString(), result.error && result.error.extra));
130156
return;
131157
}
132158

133-
var result = that.parseBinaryOutput(stdout, stderr);
134159
if(result.error) {
160+
if(result.invalidOutput) {
161+
// A cached binary that runs but prints garbage may be corrupt
162+
// on disk; evict it so the next start re-downloads a fresh copy.
163+
that.evictDownloadedBinary();
164+
}
135165
safeCallback(result.error);
136166
return;
137167
}
@@ -208,13 +238,27 @@ function Local(){
208238
try {
209239
data = JSON.parse(output);
210240
} catch(parseError) {
211-
return { error: new LocalError('Invalid output received: ' + parseError.message, this.truncateForExtra(output)) };
241+
return { error: new LocalError('Invalid output received: ' + parseError.message, this.truncateForExtra(output)), invalidOutput: true };
212242
}
213243
if(!data || typeof data !== 'object')
214-
return { error: new LocalError('Invalid output received: expected a JSON object', this.truncateForExtra(output)) };
244+
return { error: new LocalError('Invalid output received: expected a JSON object', this.truncateForExtra(output)), invalidOutput: true };
215245
return { data: data };
216246
};
217247

248+
// A binary that executes but prints unparseable output may be corrupt on
249+
// disk; evicting it lets the next start() download a fresh copy instead of
250+
// failing identically forever. Binaries supplied by the user via the
251+
// `binarypath` option are never evicted.
252+
this.evictDownloadedBinary = function(){
253+
if(this.userProvidedBinaryPath || !this.binaryPath) return;
254+
try {
255+
fs.unlinkSync(this.binaryPath);
256+
} catch(unlinkError) {
257+
console.error('Could not delete binary: ', unlinkError.message);
258+
}
259+
delete(this.binaryPath);
260+
};
261+
218262
this.isRunning = function(){
219263
return this.pid && running(this.pid) && this.isProcessRunning;
220264
};
@@ -324,8 +368,10 @@ function Local(){
324368
break;
325369

326370
case 'binarypath':
327-
if(value)
371+
if(value){
328372
this.binaryPath = value;
373+
this.userProvidedBinaryPath = true;
374+
}
329375
break;
330376

331377
default:

lib/LocalBinary.js

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,13 @@ function LocalBinary(){
137137
console.log('Retrying Download. Retries left', retries);
138138
fs.stat(binaryPath, function(err) {
139139
if(err == null) {
140-
fs.unlinkSync(binaryPath);
140+
try {
141+
fs.unlinkSync(binaryPath);
142+
} catch(unlinkError) {
143+
// A held handle (AV scan) or permissions can make the delete
144+
// fail; the retry will overwrite the file anyway.
145+
console.error('Could not delete binary: ', unlinkError.message);
146+
}
141147
}
142148
if(!callback) {
143149
return that.downloadSync(conf, destParentDir, retries - 1);
@@ -146,11 +152,10 @@ function LocalBinary(){
146152
});
147153
} else {
148154
console.error('Number of retries to download exceeded.');
149-
// Still hand the (missing or truncated) path back instead of never
150-
// calling the callback: the caller's execFile then fails with a real
151-
// error the user sees, rather than hanging forever.
155+
// Deliver terminal failure instead of never calling back; Local.start
156+
// treats a falsy path as a non-retryable download failure.
152157
if(callback) {
153-
callback(binaryPath);
158+
callback(null);
154159
}
155160
}
156161
};
@@ -209,11 +214,18 @@ function LocalBinary(){
209214
this.download = function(conf, destParentDir, callback, retries){
210215
this.getDownloadPath(conf, retries, (err, downloadUrl) => {
211216
if(err) {
212-
// Route through the retry path (which eventually surfaces a failure
213-
// to the caller) instead of returning without ever calling back.
217+
// Nothing has touched the disk on this path, so retry the source-url
218+
// fetch directly — going through retryBinaryDownload here would
219+
// delete a pre-existing binary that this attempt never wrote to.
214220
this.binaryDownloadError('Unable to fetch the source url to download the binary with error', util.format(err));
215-
var destName = (this.windows) ? 'BrowserStackLocal.exe' : 'BrowserStackLocal';
216-
return this.retryBinaryDownload(conf, destParentDir, callback, retries, path.join(destParentDir, destName));
221+
if(retries > 0) {
222+
console.log('Retrying Download. Retries left', retries);
223+
return this.download(conf, destParentDir, callback, retries - 1);
224+
}
225+
console.error('Number of retries to download exceeded.');
226+
// Deliver terminal failure instead of never calling back; Local.start
227+
// treats a falsy path as a non-retryable download failure.
228+
return callback(null);
217229
}
218230

219231
this.httpPath = downloadUrl;
@@ -256,13 +268,33 @@ function LocalBinary(){
256268
});
257269

258270
https.get(options, function (response) {
271+
if (response.statusCode >= 400) {
272+
// Without this check, an error body (404/403 HTML) was written to
273+
// binaryPath and reported as a successful download.
274+
if(settle()) return;
275+
response.resume();
276+
fileStream.destroy();
277+
that.binaryDownloadError('Got bad response while downloading binary, status code', String(response.statusCode));
278+
return that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath);
279+
}
280+
259281
const contentEncoding = response.headers['content-encoding'];
260282
if (typeof contentEncoding === 'string' && contentEncoding.match(/gzip/i)) {
261283
if (process.env.BROWSERSTACK_LOCAL_DEBUG_GZIP) {
262284
console.info('Using gzip in ' + options.headers['user-agent']);
263285
}
264286

265-
response.pipe(zlib.createGunzip()).pipe(fileStream);
287+
var gunzip = zlib.createGunzip();
288+
gunzip.on('error', function (err) {
289+
// pipe() does not forward stream errors: without this listener a
290+
// corrupt gzip body was an uncaughtException (or a silent hang,
291+
// since fileStream then never emits 'error'/'close').
292+
if(settle()) return;
293+
fileStream.destroy();
294+
that.binaryDownloadError('Got Error while unzipping binary', util.format(err));
295+
that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath);
296+
});
297+
response.pipe(gunzip).pipe(fileStream);
266298
} else {
267299
response.pipe(fileStream);
268300
}

test/local_start_output_handling.js

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -168,29 +168,50 @@ describe('Local.start output handling', function () {
168168
});
169169
});
170170

171-
it('records the daemon pid when a connected payload precedes a non-zero exit', function (done) {
171+
it('treats a connected payload as success even when the process exits non-zero', function (done) {
172172
this.timeout(10000);
173173
run(stub('connected-then-fail.sh', 'echo \'{"state":"connected","pid":12345}\'; exit 1'), done, function (calls, uncaught) {
174174
expect(uncaught).to.eql([]);
175175
expect(calls.length).to.equal(1);
176-
expect(calls[0]).to.be.an('object');
177-
// The daemon is up even though the foreground process errored;
178-
// stop() must still be able to reach it.
176+
// The daemon is up despite the foreground exit status; reporting an
177+
// error here left isRunning() true while start() claimed failure.
178+
expect(calls[0]).to.equal(undefined);
179179
expect(bsLocal.pid).to.equal(12345);
180+
expect(bsLocal.isProcessRunning).to.equal(true);
181+
});
182+
});
183+
184+
it('keeps the unparsed payload as extra when a non-zero exit has an unusable message', function (done) {
185+
this.timeout(10000);
186+
run(stub('nonzero-bad-message.sh', 'echo \'{"state":"disconnected","message":42}\'; exit 1'), done, function (calls, uncaught) {
187+
expect(uncaught).to.eql([]);
188+
expect(calls.length).to.equal(1);
189+
expect(calls[0].message).to.match(/Command failed/);
190+
expect(calls[0].extra).to.match(/"message":42/);
180191
});
181192
});
182193

183-
it('startSync returns an error without deleting the binary on non-JSON output', function () {
194+
it('startSync keeps a user-supplied binary on non-JSON output', function () {
184195
var stubPath = stub('sync-garbage.sh', 'echo "segmentation fault"; exit 0');
185-
bsLocal.binaryPath = stubPath;
186-
var err = bsLocal.startSync({ key: 'dummy-key', localIdentifier: 'loc-7325' });
196+
var err = bsLocal.startSync({ key: 'dummy-key', localIdentifier: 'loc-7325', binarypath: stubPath });
187197
expect(err).to.be.an('object');
188198
expect(err.message).to.match(/^Invalid output received: /);
189199
// The old code misclassified parse failures as binary-execution failures
190-
// and deleted the binary before re-downloading it.
200+
// and deleted the binary (even a user-supplied one) before re-downloading.
191201
expect(fs.existsSync(stubPath)).to.equal(true);
192202
});
193203

204+
it('startSync evicts a downloaded binary that prints non-JSON output, without retrying', function () {
205+
var stubPath = stub('sync-garbage-evict.sh', 'echo "segmentation fault"; exit 0');
206+
bsLocal.binaryPath = stubPath; // simulates a previously downloaded binary
207+
var err = bsLocal.startSync({ key: 'dummy-key', localIdentifier: 'loc-7325' });
208+
expect(err).to.be.an('object');
209+
expect(err.message).to.match(/^Invalid output received: /);
210+
// Corrupt-but-runnable downloads self-heal on the NEXT start via a fresh
211+
// download, instead of the old delete-and-retry-9-times loop.
212+
expect(fs.existsSync(stubPath)).to.equal(false);
213+
});
214+
194215
it('startSync returns an error when the binary emits literal null', function () {
195216
var stubPath = stub('sync-null.sh', 'echo "null"; exit 0');
196217
bsLocal.binaryPath = stubPath;

0 commit comments

Comments
 (0)