Skip to content

Commit 909dc67

Browse files
committed
LOC-7325: stop uncatchable TypeError on empty binary output in Local.start
`start()` handles the binary's output inside an `execFile` callback. The empty-output branch called back with 'No output received' but did not return, so control fell through to `data['message']['message']` on `data = {}`. That threw a TypeError, and because the throw happens inside a callback invoked by node's internal exithandler, no try/catch around `local.start(...)` could intercept it — it surfaced as an uncaughtException in the host process. Three paths reached the same unguarded deref: - empty stdout and stderr (the reported one) — now returns after the callback, so it fires exactly once - the terminal branch of the `error` handler, which also fell through - any non-connected payload with no `message` key Also guards `JSON.parse`: non-JSON output threw a SyntaxError from the same uncatchable position, and is now reported through the callback with the raw output attached as `extra`. `startSync` shared the unguarded deref and now uses the same helper. Its empty-output branch already returned, so it was not exposed to the fall-through. Adds regression tests driving start() with stub binaries for each output shape, asserting the callback fires exactly once and nothing escapes as an uncaughtException. They need no credentials or network. Three of the four fail on master with the TypeError from the ticket.
1 parent 536bb6f commit 909dc67

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)