Skip to content

Commit 69bd942

Browse files
committed
LOC-7325: fourth review round — close all six remaining findings
Never delete or retry a user-supplied binary (HIGH). The previous round added `userProvidedBinaryPath` and honoured it in evictDownloadedBinary, but the sibling path prepareBinaryRetry still unlinked unconditionally, so a `binarypath` binary that crashed was deleted out from under the user. addArgs re-applies the option on every retry, so the retry then re-exec'd the deleted file, burned all 9 attempts and replaced the real diagnostic with ENOENT. prepareBinaryRetry now delegates to evictDownloadedBinary so both deletion paths share one rule, and shouldRetryBinaryDownload stops the retry for user-supplied paths. Require the reported pid to be alive before calling a non-zero exit a success. A payload claiming 'connected' from a daemon that had already died was reported as a successful start, leaving the caller to run its whole suite against a dead tunnel. `is-running` was already a dependency. Route synchronous binary-path failures to the callback. getAvailableDirs throws when no candidate directory is writable (locked-down CI), and that throw escaped start() with the callback never fired — the same "caller never hears back" class this ticket set out to close. start() now has a single delivery guard covering both the sync throw and the execFile handler; startSync returns the error like every other failure it reports. Reject any non-200 download response. The >= 400 guard still let a 3xx through, and since https.get does not follow redirects the redirect body was written to the binary path, chmodded 0755 and returned as the binary. Destroy the write stream on response- and request-level download errors. pipe() unpipes on source error but never ends the destination, so each retry leaked a write fd and opened a second writer on the same path. Make the sync retry path synchronous. retryBinaryDownload deleted the stale binary inside an fs.stat callback, so it returned undefined and downloadSync discarded the retry's result: startSync reported "Couldn't find binary file" on the first failure while the retry chain ran on in the background holding the event loop open. Tests: adds test/local_binary_download.js (stubs https.get, so no network or TLS fixture) and extends the start() suite. 9 of the new assertions fail on the previous commit. Suite excluding the credential-gated LocalBinary Download block: 54 passing, same 3 pre-existing failures. The existing connected-payload test asserted success for pid 12345, which is not a live process; it now reports this process's own pid, with a new test covering the dead-pid case.
1 parent b9fe94e commit 69bd942

4 files changed

Lines changed: 411 additions & 54 deletions

File tree

lib/Local.js

Lines changed: 61 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,15 @@ function Local(){
3636
if(typeof options['onlyCommand'] !== 'undefined')
3737
return;
3838

39-
const binaryPath = this.getBinaryPath(null, options['bs-host']);
39+
var binaryPath;
40+
try {
41+
binaryPath = this.getBinaryPath(null, options['bs-host']);
42+
} catch(err) {
43+
// getAvailableDirs() throws when none of the candidate directories is
44+
// writable (locked-down CI containers). Report it the way startSync
45+
// reports every other failure instead of throwing at the caller.
46+
return new LocalError(err.toString());
47+
}
4048
that.binaryPath = binaryPath;
4149
try {
4250
fs.writeFileSync(that.logfile, '');
@@ -72,7 +80,7 @@ function Local(){
7280
}catch(error){
7381
const binaryDownloadErrorMessage = `Error while trying to execute binary: ${util.format(error)}`;
7482
console.error(binaryDownloadErrorMessage);
75-
if(that.retriesLeft > 0) {
83+
if(that.shouldRetryBinaryDownload()) {
7684
that.prepareBinaryRetry(binaryDownloadErrorMessage);
7785
return that.startSync(options);
7886
} else {
@@ -89,12 +97,23 @@ function Local(){
8997
if(typeof options['onlyCommand'] !== 'undefined')
9098
return callback();
9199

92-
this.getBinaryPath(function(binaryPath){
100+
// Every path below must deliver exactly one callback. The execFile handler
101+
// runs inside node's exithandler (a throw there is an uncaughtException the
102+
// caller cannot catch), and getBinaryPath can throw synchronously before
103+
// any of it runs. See LOC-7325.
104+
var settled = false;
105+
var deliver = function(err){
106+
if(settled) return;
107+
settled = true;
108+
callback(err);
109+
};
110+
111+
var onBinaryPath = function(binaryPath){
93112
if(!binaryPath){
94113
// Terminal download failure signalled by LocalBinary (falsy path);
95114
// retrying here would only re-run the whole download cascade.
96115
var downloadErrorMessage = (that.binary && that.binary.downloadErrorMessage) || 'Unable to download BrowserStack Local binary';
97-
return callback(new LocalError(downloadErrorMessage));
116+
return deliver(new LocalError(downloadErrorMessage));
98117
}
99118
that.binaryPath = binaryPath;
100119
try {
@@ -105,15 +124,6 @@ function Local(){
105124

106125
that.opcode = 'start';
107126
that.tunnel = childProcess.execFile(that.binaryPath, that.getBinaryArgs(), function(error, stdout, stderr){
108-
// Everything below runs inside node's exithandler: a throw here is an
109-
// uncaughtException the caller cannot catch, and a fall-through can
110-
// invoke the callback twice. Guard both structurally. See LOC-7325.
111-
var callbackCalled = false;
112-
var safeCallback = function(err){
113-
if(callbackCalled) return;
114-
callbackCalled = true;
115-
callback(err);
116-
};
117127
try {
118128
var result = that.parseBinaryOutput(stdout, stderr);
119129
if(error) {
@@ -124,35 +134,37 @@ function Local(){
124134
// failure is not a corrupt download — retrying (delete +
125135
// re-download) cannot help; fail fast with the richer diagnostic
126136
// 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).
137+
if(result.data['state'] == 'connected' && result.data['pid'] && running(result.data['pid'])) {
138+
// The daemon is genuinely up even though the foreground process
139+
// exited non-zero; treat it as success so isRunning()/stop()
140+
// agree with reality. The liveness check matters: a payload
141+
// claiming 'connected' from a daemon that then died would
142+
// otherwise be reported as a successful start, and the caller
143+
// would run its whole suite against a dead tunnel.
132144
that.pid = result.data['pid'];
133145
that.isProcessRunning = true;
134-
safeCallback();
146+
deliver();
135147
return;
136148
}
137149
var payloadMessage = that.extractErrorMessage(result.data);
138150
if(payloadMessage) {
139-
safeCallback(new LocalError(payloadMessage));
151+
deliver(new LocalError(payloadMessage));
140152
return;
141153
}
142154
// Payload parsed but carries no usable message: surface the
143155
// exec error and keep the raw payload as extra.
144156
var rawPayload = (stdout && stdout.length > 0) ? stdout : stderr;
145-
safeCallback(new LocalError(error.toString(), that.truncateForExtra(rawPayload)));
157+
deliver(new LocalError(error.toString(), that.truncateForExtra(rawPayload)));
146158
return;
147159
}
148-
if(that.retriesLeft > 0) {
160+
if(that.shouldRetryBinaryDownload()) {
149161
that.prepareBinaryRetry(binaryDownloadErrorMessage);
150162
that.start(options, callback);
151163
return;
152164
}
153165
// Keep any raw (non-JSON) output as extra — it usually holds
154166
// the crash text.
155-
safeCallback(new LocalError(error.toString(), result.error && result.error.extra));
167+
deliver(new LocalError(error.toString(), result.error && result.error.extra));
156168
return;
157169
}
158170

@@ -162,22 +174,30 @@ function Local(){
162174
// on disk; evict it so the next start re-downloads a fresh copy.
163175
that.evictDownloadedBinary();
164176
}
165-
safeCallback(result.error);
177+
deliver(result.error);
166178
return;
167179
}
168180
if(result.data['state'] != 'connected'){
169-
safeCallback(new LocalError(that.getErrorMessage(result.data)));
181+
deliver(new LocalError(that.getErrorMessage(result.data)));
170182
} else {
171183
that.pid = result.data['pid'];
172184
that.isProcessRunning = true;
173-
safeCallback();
185+
deliver();
174186
}
175187
} catch(err) {
176-
if(callbackCalled) throw err; // the caller's own callback threw — theirs to handle
177-
safeCallback(new LocalError(err.toString()));
188+
if(settled) throw err; // the caller's own callback threw — theirs to handle
189+
deliver(new LocalError(err.toString()));
178190
}
179191
});
180-
}, options['bs-host']);
192+
};
193+
194+
try {
195+
this.getBinaryPath(onBinaryPath, options['bs-host']);
196+
} catch(err) {
197+
// Same synchronous getAvailableDirs() throw as startSync. Without this
198+
// it escapes start() and the callback never fires at all.
199+
deliver(new LocalError(err.toString()));
200+
}
181201
};
182202

183203
// The binary reports failures as {"state": "...", "message": {"message": "..."}},
@@ -205,18 +225,22 @@ function Local(){
205225
this.prepareBinaryRetry = function(binaryDownloadErrorMessage){
206226
console.log('Retrying Binary Download. Retries Left', this.retriesLeft);
207227
this.retriesLeft -= 1;
208-
try {
209-
fs.unlinkSync(this.binaryPath);
210-
} catch(unlinkError) {
211-
// The binary may already be gone (a prior retry or a concurrent
212-
// instance); the retry only needs the path cleared for re-download.
213-
console.error('Could not delete binary: ', unlinkError.message);
214-
}
215-
delete(this.binaryPath);
228+
// Shares evictDownloadedBinary so both deletion paths honour the same
229+
// "never delete a user-supplied binary" rule.
230+
this.evictDownloadedBinary();
216231
process.env.BINARY_DOWNLOAD_ERROR_MESSAGE = binaryDownloadErrorMessage;
217232
process.env.BINARY_DOWNLOAD_FALLBACK_ENABLED = true;
218233
};
219234

235+
// Re-downloading only makes sense for a binary we downloaded ourselves.
236+
// addArgs re-applies the `binarypath` option on every retry, so retrying a
237+
// user-supplied binary would re-exec the file we just deleted, burn the
238+
// whole retry budget, and replace the real diagnostic with ENOENT.
239+
// See LOC-7325.
240+
this.shouldRetryBinaryDownload = function(){
241+
return this.retriesLeft > 0 && !this.userProvidedBinaryPath;
242+
};
243+
220244
// Raw binary output can be up to execFile's 1MB maxBuffer; truncate before
221245
// attaching it to an error so serializers don't dump the whole buffer.
222246
this.truncateForExtra = function(output){

lib/LocalBinary.js

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -131,23 +131,33 @@ function LocalBinary(){
131131
this.downloadErrorMessage = errorMessagePrefix + ' : ' + errorMessage;
132132
};
133133

134+
this.removeBinaryIfPresentSync = function(binaryPath) {
135+
try {
136+
if(fs.existsSync(binaryPath))
137+
fs.unlinkSync(binaryPath);
138+
} catch(unlinkError) {
139+
// A held handle (AV scan) or permissions can make the delete fail;
140+
// the retry will overwrite the file anyway.
141+
console.error('Could not delete binary: ', unlinkError.message);
142+
}
143+
};
144+
134145
this.retryBinaryDownload = function(conf, destParentDir, callback, retries, binaryPath) {
135146
var that = this;
136147
if(retries > 0) {
137148
console.log('Retrying Download. Retries left', retries);
149+
if(!callback) {
150+
// downloadSync consumes this return value. Deleting via an async
151+
// fs.stat callback made retryBinaryDownload return undefined, so
152+
// startSync reported "Couldn't find binary file" on the very first
153+
// failure while the retry chain kept running in the background and
154+
// held the event loop open. See LOC-7325.
155+
that.removeBinaryIfPresentSync(binaryPath);
156+
return that.downloadSync(conf, destParentDir, retries - 1);
157+
}
138158
fs.stat(binaryPath, function(err) {
139-
if(err == null) {
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-
}
147-
}
148-
if(!callback) {
149-
return that.downloadSync(conf, destParentDir, retries - 1);
150-
}
159+
if(err == null)
160+
that.removeBinaryIfPresentSync(binaryPath);
151161
that.download(conf, destParentDir, callback, retries - 1);
152162
});
153163
} else {
@@ -268,9 +278,13 @@ function LocalBinary(){
268278
});
269279

270280
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.
281+
if (response.statusCode !== 200) {
282+
// Anything but 200 is not the binary. Without this check an error
283+
// body (404/403 HTML) was written to binaryPath and reported as a
284+
// successful download; >= 400 alone still let a 3xx through, since
285+
// https.get does not follow redirects and would have written the
286+
// redirect body itself, chmodded it 0755 and returned it as the
287+
// binary. See LOC-7325.
274288
if(settle()) return;
275289
response.resume();
276290
fileStream.destroy();
@@ -301,6 +315,10 @@ function LocalBinary(){
301315

302316
response.on('error', function(err) {
303317
if(settle()) return;
318+
// pipe() unpipes the destination on a source error but never ends
319+
// it: without this the write fd leaks and the retry below opens a
320+
// second writer on the same path.
321+
fileStream.destroy();
304322
that.binaryDownloadError('Got Error in binary download response', util.format(err));
305323
that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath);
306324
});
@@ -319,6 +337,7 @@ function LocalBinary(){
319337
});
320338
}).on('error', function(err) {
321339
if(settle()) return;
340+
fileStream.destroy();
322341
that.binaryDownloadError('Got Error in binary downloading request', util.format(err));
323342
that.retryBinaryDownload(conf, destParentDir, callback, retries, binaryPath);
324343
});

0 commit comments

Comments
 (0)