Skip to content

Commit 54b4208

Browse files
Merge pull request #182 from browserstack/fix/LOC-7325-local-start-callback-fallthrough
LOC-7325: stop uncatchable TypeError on empty binary output in Local.start
2 parents 536bb6f + c2bb790 commit 54b4208

2 files changed

Lines changed: 128 additions & 7 deletions

File tree

lib/Local.js

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ function Local(){
5757
else
5858
return new LocalError('No output received');
5959
if(data['state'] != 'connected'){
60-
return new LocalError(data['message']['message']);
60+
return new LocalError(that.getErrorMessage(data));
6161
} else {
6262
that.pid = data['pid'];
6363
that.isProcessRunning = true;
@@ -112,19 +112,25 @@ function Local(){
112112
return;
113113
} else {
114114
callback(new LocalError(error.toString()));
115+
return;
115116
}
116117
}
117118

118119
var data = {};
119-
if(stdout)
120-
data = JSON.parse(stdout);
121-
else if(stderr)
122-
data = JSON.parse(stderr);
123-
else
120+
var output = stdout || stderr;
121+
if(!output) {
124122
callback(new LocalError('No output received'));
123+
return;
124+
}
125+
try {
126+
data = JSON.parse(output);
127+
} catch(parseError) {
128+
callback(new LocalError('Invalid output received: ' + parseError.message, output));
129+
return;
130+
}
125131

126132
if(data['state'] != 'connected'){
127-
callback(new LocalError(data['message']['message']));
133+
callback(new LocalError(that.getErrorMessage(data)));
128134
} else {
129135
that.pid = data['pid'];
130136
that.isProcessRunning = true;
@@ -134,6 +140,17 @@ function Local(){
134140
}, options['bs-host']);
135141
};
136142

143+
// The binary reports failures as {"state": "...", "message": {"message": "..."}},
144+
// but not every non-connected payload carries a message key. Dereferencing it
145+
// blindly throws, and inside the execFile callback that throw is an
146+
// uncaughtException the caller cannot catch. See LOC-7325.
147+
this.getErrorMessage = function(data){
148+
var message = data && data['message'];
149+
if(message && typeof message === 'object')
150+
message = message['message'];
151+
return message || 'Failed to start BrowserStack Local';
152+
};
153+
137154
this.isRunning = function(){
138155
return this.pid && running(this.pid) && this.isProcessRunning;
139156
};
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
var expect = require('expect.js'),
2+
fs = require('fs'),
3+
os = require('os'),
4+
path = require('path'),
5+
browserstack = require('../index');
6+
7+
// Regression tests for LOC-7325.
8+
//
9+
// `Local.start` handles the binary's output inside an `execFile` callback. A
10+
// throw there is raised by node's internal exithandler, so no try/catch around
11+
// `start()` can intercept it — it surfaces as an uncaughtException and the
12+
// blast radius is set by the host process's exception policy. These tests drive
13+
// `start()` with stub binaries that reproduce each output shape and assert the
14+
// callback fires exactly once with an error, and that nothing throws.
15+
//
16+
// Stubs are shell scripts, so these are skipped on Windows.
17+
describe('Local.start output handling', function () {
18+
var stubDir, bsLocal;
19+
20+
function stub(name, body) {
21+
var stubPath = path.join(stubDir, name);
22+
fs.writeFileSync(stubPath, '#!/bin/sh\n' + body + '\n', { mode: 0o755 });
23+
return stubPath;
24+
}
25+
26+
// Drives start() with the given stub and collects every callback invocation
27+
// plus any uncaughtException raised out of the execFile callback.
28+
function run(stubPath, done) {
29+
var calls = [], uncaught = [];
30+
var existing = process.listeners('uncaughtException');
31+
process.removeAllListeners('uncaughtException');
32+
process.on('uncaughtException', function (err) { uncaught.push(err); });
33+
34+
bsLocal.binaryPath = stubPath;
35+
bsLocal.start({ key: 'dummy-key', localIdentifier: 'loc-7325' }, function (error) {
36+
calls.push(error);
37+
});
38+
39+
// Settle past the execFile callback before asserting, so a second
40+
// (throwing) invocation would have happened by now if it were going to.
41+
setTimeout(function () {
42+
process.removeAllListeners('uncaughtException');
43+
existing.forEach(function (listener) { process.on('uncaughtException', listener); });
44+
done(calls, uncaught);
45+
}, 1000);
46+
}
47+
48+
before(function () {
49+
stubDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bs-local-7325-'));
50+
});
51+
52+
beforeEach(function () {
53+
bsLocal = new browserstack.Local();
54+
// Keep the stubs from clobbering ./local.log in the repo root.
55+
bsLocal.logfile = path.join(stubDir, 'local.log');
56+
});
57+
58+
if (os.platform().match(/win32/i)) {
59+
it.skip('skipped on Windows (stub binaries are shell scripts)');
60+
return;
61+
}
62+
63+
it('reports an error exactly once when the binary exits with no output', function (done) {
64+
this.timeout(10000);
65+
run(stub('empty-output.sh', 'exit 0'), function (calls, uncaught) {
66+
expect(uncaught).to.eql([]);
67+
expect(calls.length).to.equal(1);
68+
expect(calls[0]).to.be.an('object');
69+
expect(calls[0].message).to.equal('No output received');
70+
done();
71+
});
72+
});
73+
74+
it('reports an error exactly once when the binary emits non-JSON output', function (done) {
75+
this.timeout(10000);
76+
run(stub('garbage-output.sh', 'echo "segmentation fault"; exit 0'), function (calls, uncaught) {
77+
expect(uncaught).to.eql([]);
78+
expect(calls.length).to.equal(1);
79+
expect(calls[0].message).to.match(/^Invalid output received: /);
80+
expect(calls[0].extra).to.match(/segmentation fault/);
81+
done();
82+
});
83+
});
84+
85+
it('reports a fallback message when a non-connected payload has no message key', function (done) {
86+
this.timeout(10000);
87+
run(stub('no-message-key.sh', 'echo \'{"state":"disconnected"}\'; exit 0'), function (calls, uncaught) {
88+
expect(uncaught).to.eql([]);
89+
expect(calls.length).to.equal(1);
90+
expect(calls[0].message).to.equal('Failed to start BrowserStack Local');
91+
done();
92+
});
93+
});
94+
95+
it('surfaces the binary message when a non-connected payload carries one', function (done) {
96+
this.timeout(10000);
97+
run(stub('with-message.sh', 'echo \'{"state":"disconnected","message":{"message":"Invalid key"}}\'; exit 0'), function (calls, uncaught) {
98+
expect(uncaught).to.eql([]);
99+
expect(calls.length).to.equal(1);
100+
expect(calls[0].message).to.equal('Invalid key');
101+
done();
102+
});
103+
});
104+
});

0 commit comments

Comments
 (0)