Skip to content

Commit 7cbe054

Browse files
vivianludrickclaude
andcommitted
LOC-7325: harden adjacent callback paths from second review round
- start() exhausted-retry branch: record the daemon pid from a connected payload so stop() can reach an orphan; prefer the payload's message only when it actually carries one, otherwise surface the exec error and keep raw crash output as error.extra. - Extract prepareBinaryRetry so start/startSync share one retry block; split extractErrorMessage out of getErrorMessage. - stop(): add missing return — a treeKill error fired the callback twice. - LocalBinary.download(): settle-once guard across response/stream/request handlers (an errored stream still emits 'close', double-firing the callback); exhausted retries and source-url failures now still deliver the callback instead of hanging the caller forever. - index.d.ts: declare error.extra, startSync, and error-typed callbacks. - Tests: observe throws via uncaughtExceptionMonitor instead of detaching mocha's handler (regressions now fail with the real error, not a bare timeout); route assertion failures to mocha's done; new cases for non-zero exit with crash text and connected-payload pid recording. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1db2f0b commit 7cbe054

4 files changed

Lines changed: 131 additions & 69 deletions

File tree

index.d.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,15 @@ declare module "browserstack-local" {
1919
[key: string]: string | boolean;
2020
}
2121

22+
interface LocalError extends Error {
23+
/** Raw binary output (truncated to 1KB) attached when the output could not be parsed. */
24+
extra?: string;
25+
}
26+
2227
class Local {
23-
start(options: Partial<Options>, callback: (error?: Error) => void): void;
28+
start(options: Partial<Options>, callback: (error?: LocalError) => void): void;
29+
startSync(options: Partial<Options>): LocalError | undefined;
2430
isRunning(): boolean;
25-
stop(callback: () => void): void;
31+
stop(callback: (error?: LocalError) => void): void;
2632
}
2733
}

lib/Local.js

Lines changed: 42 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -66,18 +66,7 @@ function Local(){
6666
const binaryDownloadErrorMessage = `Error while trying to execute binary: ${util.format(error)}`;
6767
console.error(binaryDownloadErrorMessage);
6868
if(that.retriesLeft > 0) {
69-
console.log('Retrying Binary Download. Retries Left', that.retriesLeft);
70-
that.retriesLeft -= 1;
71-
try {
72-
fs.unlinkSync(that.binaryPath);
73-
} catch(unlinkError) {
74-
// The binary may already be gone (a prior retry or a concurrent
75-
// instance); the retry only needs the path cleared for re-download.
76-
console.error('Could not delete binary: ', unlinkError.message);
77-
}
78-
delete(that.binaryPath);
79-
process.env.BINARY_DOWNLOAD_ERROR_MESSAGE = binaryDownloadErrorMessage;
80-
process.env.BINARY_DOWNLOAD_FALLBACK_ENABLED = true;
69+
that.prepareBinaryRetry(binaryDownloadErrorMessage);
8170
return that.startSync(options);
8271
} else {
8372
throw new LocalError(error.toString());
@@ -117,29 +106,26 @@ function Local(){
117106
const binaryDownloadErrorMessage = `Error while trying to execute binary: ${util.format(error)}`;
118107
console.error(binaryDownloadErrorMessage);
119108
if(that.retriesLeft > 0) {
120-
console.log('Retrying Binary Download. Retries Left', that.retriesLeft);
121-
that.retriesLeft -= 1;
122-
try {
123-
fs.unlinkSync(that.binaryPath);
124-
} catch(unlinkError) {
125-
// The binary may already be gone (a prior retry or a concurrent
126-
// instance); the retry only needs the path cleared for re-download.
127-
console.error('Could not delete binary: ', unlinkError.message);
128-
}
129-
delete(that.binaryPath);
130-
process.env.BINARY_DOWNLOAD_ERROR_MESSAGE = binaryDownloadErrorMessage;
131-
process.env.BINARY_DOWNLOAD_FALLBACK_ENABLED = true;
109+
that.prepareBinaryRetry(binaryDownloadErrorMessage);
132110
that.start(options, callback);
133111
return;
134112
}
135113
// The binary can exit non-zero while still printing a JSON
136-
// diagnostic on stdout; prefer its message over the generic
137-
// execution error.
114+
// diagnostic on stdout; keep whichever diagnostic is richer.
138115
var failure = that.parseBinaryOutput(stdout, stderr);
139-
if(failure.data && failure.data['state'] != 'connected') {
140-
safeCallback(new LocalError(that.getErrorMessage(failure.data)));
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));
141125
} else {
142-
safeCallback(new LocalError(error.toString()));
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));
143129
}
144130
return;
145131
}
@@ -170,13 +156,35 @@ function Local(){
170156
// the execFile callback that throw is an uncaughtException the caller cannot
171157
// catch; a non-string message crashes consumers doing error.message.match().
172158
// See LOC-7325.
173-
this.getErrorMessage = function(data){
159+
this.extractErrorMessage = function(data){
174160
var message = data && data['message'];
175161
if(message && typeof message === 'object')
176162
message = message['message'];
177163
if(typeof message === 'string' && message.length > 0)
178164
return message;
179-
return 'Failed to start BrowserStack Local';
165+
return null;
166+
};
167+
168+
this.getErrorMessage = function(data){
169+
return this.extractErrorMessage(data) || 'Failed to start BrowserStack Local';
170+
};
171+
172+
// Shared retry bookkeeping for start and startSync: drop the (possibly
173+
// corrupt) binary so the next attempt re-downloads it, and record the
174+
// failure for the fallback download source.
175+
this.prepareBinaryRetry = function(binaryDownloadErrorMessage){
176+
console.log('Retrying Binary Download. Retries Left', this.retriesLeft);
177+
this.retriesLeft -= 1;
178+
try {
179+
fs.unlinkSync(this.binaryPath);
180+
} catch(unlinkError) {
181+
// The binary may already be gone (a prior retry or a concurrent
182+
// instance); the retry only needs the path cleared for re-download.
183+
console.error('Could not delete binary: ', unlinkError.message);
184+
}
185+
delete(this.binaryPath);
186+
process.env.BINARY_DOWNLOAD_ERROR_MESSAGE = binaryDownloadErrorMessage;
187+
process.env.BINARY_DOWNLOAD_FALLBACK_ENABLED = true;
180188
};
181189

182190
// Raw binary output can be up to execFile's 1MB maxBuffer; truncate before
@@ -214,7 +222,9 @@ function Local(){
214222
this.stop = function (callback) {
215223
if(!this.pid) return callback();
216224
this.killAllProcesses(function(error){
217-
if(error) callback(new LocalError(error.toString()));
225+
// Without the return, a treeKill error fired the callback twice:
226+
// once with the error, then once with undefined.
227+
if(error) return callback(new LocalError(error.toString()));
218228
callback();
219229
});
220230
};

lib/LocalBinary.js

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,12 @@ function LocalBinary(){
146146
});
147147
} else {
148148
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.
152+
if(callback) {
153+
callback(binaryPath);
154+
}
149155
}
150156
};
151157

@@ -203,7 +209,11 @@ function LocalBinary(){
203209
this.download = function(conf, destParentDir, callback, retries){
204210
this.getDownloadPath(conf, retries, (err, downloadUrl) => {
205211
if(err) {
206-
return console.error('Unable to fetch the source url to download the binary with error: ', err);
212+
// Route through the retry path (which eventually surfaces a failure
213+
// to the caller) instead of returning without ever calling back.
214+
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));
207217
}
208218

209219
this.httpPath = downloadUrl;
@@ -216,6 +226,15 @@ function LocalBinary(){
216226
var binaryPath = path.join(destParentDir, destBinaryName);
217227
var fileStream = fs.createWriteStream(binaryPath);
218228

229+
// Exactly one of the handlers below may resolve this download attempt
230+
// (retry or callback); a retried attempt gets a fresh settled flag.
231+
var settled = false;
232+
var settle = function() {
233+
if(settled) return true;
234+
settled = true;
235+
return false;
236+
};
237+
219238
var options = url.parse(this.httpPath);
220239
if(conf.proxyHost && conf.proxyPort) {
221240
options.agent = new HttpsProxyAgent({
@@ -249,19 +268,25 @@ function LocalBinary(){
249268
}
250269

251270
response.on('error', function(err) {
271+
if(settle()) return;
252272
that.binaryDownloadError('Got Error in binary download response', util.format(err));
253273
that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath);
254274
});
255275
fileStream.on('error', function (err) {
276+
if(settle()) return;
256277
that.binaryDownloadError('Got Error while downloading binary file', util.format(err));
257278
that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath);
258279
});
259280
fileStream.on('close', function () {
281+
// An errored stream still emits 'close' (autoDestroy); without the
282+
// guard this fired the callback in addition to the retry above.
283+
if(settle()) return;
260284
fs.chmod(binaryPath, '0755', function() {
261285
callback(binaryPath);
262286
});
263287
});
264288
}).on('error', function(err) {
289+
if(settle()) return;
265290
that.binaryDownloadError('Got Error in binary downloading request', util.format(err));
266291
that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath);
267292
});

test/local_start_output_handling.js

Lines changed: 55 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,31 +13,43 @@ var expect = require('expect.js'),
1313
// `start()` with stub binaries that reproduce each output shape and assert the
1414
// callback fires exactly once with an error, and that nothing throws.
1515
//
16+
// Throws are observed via process.on('uncaughtExceptionMonitor'), which sees
17+
// every uncaught exception WITHOUT detaching mocha's own handler: a regression
18+
// fails the test with the real thrown error instead of a bare timeout, and no
19+
// unrelated error is ever swallowed.
20+
//
1621
// Stubs are shell scripts, so these are skipped on Windows.
1722
describe('Local.start output handling', function () {
18-
var stubDir, bsLocal, uncaught, mochaListeners;
23+
var stubDir, bsLocal, uncaught, monitorListener;
1924

2025
function stub(name, body) {
2126
var stubPath = path.join(stubDir, name);
2227
fs.writeFileSync(stubPath, '#!/bin/sh\n' + body + '\n', { mode: 0o755 });
2328
return stubPath;
2429
}
2530

26-
// Drives start() with the given stub and collects every callback invocation.
27-
// The callback, any second (fall-through) invocation and any throw all come
28-
// out of the same synchronous execFile exithandler, so settling two ticks
29-
// after the first callback observes all of them deterministically — no
30-
// fixed sleep. The guard timer only fires if the callback never does (the
31-
// exact regression this suite exists to catch), so that failure mode shows
32-
// up as an assertion on calls.length instead of a mocha timeout.
33-
function run(stubPath, done) {
31+
// Drives start() with the given stub, then hands every callback invocation
32+
// plus the uncaught array to `assert`. The callback, any second
33+
// (fall-through) invocation and any throw all come out of the same
34+
// synchronous execFile exithandler, so settling two ticks after the first
35+
// callback observes all of them deterministically — no fixed sleep. The
36+
// guard timer only fires if the callback never does, so that failure mode
37+
// shows up as an assertion on calls.length instead of a mocha timeout.
38+
// Assertion failures are routed to mocha's done, never left to throw
39+
// asynchronously.
40+
function run(stubPath, done, assert) {
3441
var calls = [], finished = false;
3542

3643
function finish() {
3744
if (finished) return;
3845
finished = true;
3946
clearTimeout(guard);
40-
done(calls, uncaught);
47+
try {
48+
assert(calls, uncaught);
49+
done();
50+
} catch (assertionError) {
51+
done(assertionError);
52+
}
4153
}
4254

4355
var guard = setTimeout(finish, 5000);
@@ -67,18 +79,13 @@ describe('Local.start output handling', function () {
6779
// executes the real binary from the network.
6880
bsLocal.retriesLeft = 0;
6981

70-
// Capture uncaughtExceptions for the duration of each test. Mocha's own
71-
// handler is snapshotted here and restored in afterEach, so restoration
72-
// survives a throwing test body.
7382
uncaught = [];
74-
mochaListeners = process.listeners('uncaughtException');
75-
process.removeAllListeners('uncaughtException');
76-
process.on('uncaughtException', function (err) { uncaught.push(err); });
83+
monitorListener = function (err) { uncaught.push(err); };
84+
process.on('uncaughtExceptionMonitor', monitorListener);
7785
});
7886

7987
afterEach(function () {
80-
process.removeAllListeners('uncaughtException');
81-
mochaListeners.forEach(function (listener) { process.on('uncaughtException', listener); });
88+
process.removeListener('uncaughtExceptionMonitor', monitorListener);
8289
});
8390

8491
if (os.platform().match(/win32/i)) {
@@ -88,73 +95,88 @@ describe('Local.start output handling', function () {
8895

8996
it('reports an error exactly once when the binary exits with no output', function (done) {
9097
this.timeout(10000);
91-
run(stub('empty-output.sh', 'exit 0'), function (calls, uncaught) {
98+
run(stub('empty-output.sh', 'exit 0'), done, function (calls, uncaught) {
9299
expect(uncaught).to.eql([]);
93100
expect(calls.length).to.equal(1);
94101
expect(calls[0]).to.be.an('object');
95102
expect(calls[0].message).to.equal('No output received');
96-
done();
97103
});
98104
});
99105

100106
it('reports an error exactly once when the binary emits non-JSON output', function (done) {
101107
this.timeout(10000);
102-
run(stub('garbage-output.sh', 'echo "segmentation fault"; exit 0'), function (calls, uncaught) {
108+
run(stub('garbage-output.sh', 'echo "segmentation fault"; exit 0'), done, function (calls, uncaught) {
103109
expect(uncaught).to.eql([]);
104110
expect(calls.length).to.equal(1);
105111
expect(calls[0].message).to.match(/^Invalid output received: /);
106112
expect(calls[0].extra).to.match(/segmentation fault/);
107-
done();
108113
});
109114
});
110115

111116
it('reports an error exactly once when the binary emits literal null', function (done) {
112117
this.timeout(10000);
113-
run(stub('null-output.sh', 'echo "null"; exit 0'), function (calls, uncaught) {
118+
run(stub('null-output.sh', 'echo "null"; exit 0'), done, function (calls, uncaught) {
114119
expect(uncaught).to.eql([]);
115120
expect(calls.length).to.equal(1);
116121
expect(calls[0].message).to.match(/^Invalid output received: /);
117-
done();
118122
});
119123
});
120124

121125
it('reports a fallback message when a non-connected payload has no message key', function (done) {
122126
this.timeout(10000);
123-
run(stub('no-message-key.sh', 'echo \'{"state":"disconnected"}\'; exit 0'), function (calls, uncaught) {
127+
run(stub('no-message-key.sh', 'echo \'{"state":"disconnected"}\'; exit 0'), done, function (calls, uncaught) {
124128
expect(uncaught).to.eql([]);
125129
expect(calls.length).to.equal(1);
126130
expect(calls[0].message).to.equal('Failed to start BrowserStack Local');
127-
done();
128131
});
129132
});
130133

131134
it('reports a fallback message when the payload message is not a string', function (done) {
132135
this.timeout(10000);
133-
run(stub('non-string-message.sh', 'echo \'{"state":"disconnected","message":42}\'; exit 0'), function (calls, uncaught) {
136+
run(stub('non-string-message.sh', 'echo \'{"state":"disconnected","message":42}\'; exit 0'), done, function (calls, uncaught) {
134137
expect(uncaught).to.eql([]);
135138
expect(calls.length).to.equal(1);
136139
expect(calls[0].message).to.equal('Failed to start BrowserStack Local');
137-
done();
138140
});
139141
});
140142

141143
it('surfaces the binary message when a non-connected payload carries one', function (done) {
142144
this.timeout(10000);
143-
run(stub('with-message.sh', 'echo \'{"state":"disconnected","message":{"message":"Invalid key"}}\'; exit 0'), function (calls, uncaught) {
145+
run(stub('with-message.sh', 'echo \'{"state":"disconnected","message":{"message":"Invalid key"}}\'; exit 0'), done, function (calls, uncaught) {
144146
expect(uncaught).to.eql([]);
145147
expect(calls.length).to.equal(1);
146148
expect(calls[0].message).to.equal('Invalid key');
147-
done();
148149
});
149150
});
150151

151152
it('surfaces the JSON diagnostic when the binary exits non-zero with a payload', function (done) {
152153
this.timeout(10000);
153-
run(stub('nonzero-with-payload.sh', 'echo \'{"state":"disconnected","message":{"message":"Invalid key"}}\'; exit 1'), function (calls, uncaught) {
154+
run(stub('nonzero-with-payload.sh', 'echo \'{"state":"disconnected","message":{"message":"Invalid key"}}\'; exit 1'), done, function (calls, uncaught) {
154155
expect(uncaught).to.eql([]);
155156
expect(calls.length).to.equal(1);
156157
expect(calls[0].message).to.equal('Invalid key');
157-
done();
158+
});
159+
});
160+
161+
it('keeps crash output as extra when the binary exits non-zero with non-JSON output', function (done) {
162+
this.timeout(10000);
163+
run(stub('nonzero-garbage.sh', 'echo "segmentation fault"; exit 139'), done, function (calls, uncaught) {
164+
expect(uncaught).to.eql([]);
165+
expect(calls.length).to.equal(1);
166+
expect(calls[0].message).to.match(/Command failed/);
167+
expect(calls[0].extra).to.match(/segmentation fault/);
168+
});
169+
});
170+
171+
it('records the daemon pid when a connected payload precedes a non-zero exit', function (done) {
172+
this.timeout(10000);
173+
run(stub('connected-then-fail.sh', 'echo \'{"state":"connected","pid":12345}\'; exit 1'), done, function (calls, uncaught) {
174+
expect(uncaught).to.eql([]);
175+
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.
179+
expect(bsLocal.pid).to.equal(12345);
158180
});
159181
});
160182

@@ -180,13 +202,12 @@ describe('Local.start output handling', function () {
180202
it('truncates oversized non-JSON output attached to the error', function (done) {
181203
this.timeout(10000);
182204
// ~64KB of garbage; extra should be capped at 1KB plus a truncation note.
183-
run(stub('huge-output.sh', 'head -c 65536 /dev/zero | tr "\\0" "x"; exit 0'), function (calls, uncaught) {
205+
run(stub('huge-output.sh', 'head -c 65536 /dev/zero | tr "\\0" "x"; exit 0'), done, function (calls, uncaught) {
184206
expect(uncaught).to.eql([]);
185207
expect(calls.length).to.equal(1);
186208
expect(calls[0].message).to.match(/^Invalid output received: /);
187209
expect(calls[0].extra.length).to.be.below(1100);
188210
expect(calls[0].extra).to.match(/\[truncated \d+ bytes\]$/);
189-
done();
190211
});
191212
});
192213
});

0 commit comments

Comments
 (0)