Skip to content

LOC-7325: stop uncatchable TypeError on empty binary output in Local.start - #182

Open
vivianludrick wants to merge 6 commits into
masterfrom
fix/LOC-7325-local-start-callback-fallthrough
Open

LOC-7325: stop uncatchable TypeError on empty binary output in Local.start#182
vivianludrick wants to merge 6 commits into
masterfrom
fix/LOC-7325-local-start-callback-fallthrough

Conversation

@vivianludrick

@vivianludrick vivianludrick commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Fixes an uncatchable TypeError thrown out of Local.start() when the BrowserStackLocal binary exits with no output.

JIRA Story: https://browserstack.atlassian.net/browse/LOC-7325

The bug

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 the next statement, which dereferences data['message']['message'] on data = {}:

TypeError: Cannot read properties of undefined (reading 'message')
    at .../browserstack-local/lib/Local.js:127:50

Two things make this worse than a normal error path:

  • The callback fires twice — once legitimately with No output received, then again from the throwing statement.
  • The caller cannot catch it. The throw happens inside a callback invoked by node's internal exithandler, so no try/catch around local.start(...) intercepts it. It surfaces as an uncaughtException, which means the blast radius is set by the host process's exception policy, not by this package. In the case that surfaced it, a host with a fatal uncaughtException handler lost its entire reporting plane because an optional tunnel failed to start.

The trigger is not exotic — any environment where the binary exits without emitting JSON reaches it: wrong or blocked binary path, killed process, permission failure, or a shimmed binary in CI.

The fix

Three paths reached the same unguarded deref; all three are now closed:

Path Before After
Empty stdout and stderr callback, then fall through and throw callback once, then return
Terminal branch of the error handler callback, then fall through and throw callback once, then return
Non-connected payload with no message key throws falls back to Failed to start BrowserStack Local

Also guarded JSON.parse: non-JSON output (a plain-text crash message, for instance) threw a SyntaxError from the same uncatchable position. It is now reported through the callback as Invalid output received: <reason>, with the raw output attached as the error's extra field.

startSync shared the unguarded deref and now uses the same helper. Its empty-output branch already returned, so it was never exposed to the fall-through.

Every changed path now invokes the callback exactly once and lets the caller handle the failure normally.

Tests

Added test/local_start_output_handling.js — drives start() with stub binaries for each output shape and asserts the callback fires exactly once and that nothing escapes as an uncaughtException. No credentials or network needed.

Verified the tests actually catch the defect by toggling the fix:

  • On master: 3 of the 4 fail, with the ticket's exact TypeError: Cannot read properties of undefined (reading 'message').
  • With this change: all 4 pass.

Full suite, excluding the LocalBinary > Download block that needs real credentials:

  • master: 28 passing, 3 failing, 2 pending
  • this branch: 32 passing, 3 failing, 2 pending

Same 3 failures before and after (should return is running properly ×2, should stop local) — all pre-existing and credential-gated, none related to this change. npm run pretest (eslint over lib/* index.js) is clean.

Note: the fix avoids optional chaining because the repo's eslint config sets env: es6 (ES2015).

Scope

Code fix only — no version bump or publish here. 1.5.13 is the latest published version and carries the defect, so this needs a release to reach consumers.


Behavior note (changelog)

startSync now returns a LocalError('Invalid output received: ...') when the binary prints unparseable output, instead of throwing after deleting and re-downloading the binary. This matches its existing return-an-error contract for empty-output and non-connected payloads (the known external consumer, browserstack-node-sdk, checks the return value). Raw binary output attached to errors is exposed as error.extra, truncated to 1KB.

Round 3 additions:

  • A connected payload with a pid now counts as success even if the foreground process exits non-zero (previously contradictory: error reported while isRunning() was true; startSync already behaved this way).
  • A downloaded binary that runs but prints unparseable output is evicted without retrying, so the next start re-downloads a fresh copy; binaries passed via binarypath are never evicted.
  • Terminal download failure (source URL unreachable / retries exhausted) now fails start() immediately with the recorded download error instead of hanging or cascading into ~100 retry attempts.
  • Deterministic binary failures that exit non-zero with a JSON diagnostic fail fast with that message instead of burning the 9-cycle re-download budget first.

…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.
@vivianludrick
vivianludrick marked this pull request as ready for review August 31, 2026 13:37
@vivianludrick
vivianludrick requested a review from a team as a code owner August 31, 2026 13:37
@vivianludrick

Copy link
Copy Markdown
Collaborator Author

Claude Code Review

Verdict: the fix works for the output shapes it targets, but it doesn't yet guarantee the PR's stated invariant ("callback fires exactly once and nothing escapes"). Three confirmed gaps in lib/Local.js should be fixed before merge; the new test suite also has hazards worth addressing.

10 findings survived adversarial verification (7 confirmed by live execution or code inspection, 3 plausible), ranked by severity:

Confirmed — code

  1. lib/Local.js:132null output still crashes uncatchably. JSON.parse('null') succeeds, so the new try/catch never fires, and data['state'] then throws Cannot read properties of null inside the execFile callback — the exact uncatchable TypeError this PR set out to fix, and the callback never fires. Fix: if(!data || data['state'] != 'connected') or route null to the error callback.

  2. lib/Local.js:54startSync only got half the fix. Its JSON.parse is still unguarded, so non-JSON output falls into the outer catch, which treats it as a binary-execution failure: it deletes the binary (including a user-supplied binarypath) and re-downloads it up to 9 times, then throws a raw SyntaxError instead of the new "Invalid output received" error. Suggest extracting one shared parse helper used by both start and startSync.

  3. lib/Local.js:107 — bare fs.unlinkSync in the retry branch. If the binary path is already gone (prior retry, concurrent instance, AV quarantine), unlinkSync throws out of the execFile callback — same uncatchable-throw class, and the caller's callback never fires (promise wrappers hang forever). A once-guarded callback + whole-body try/catch would close this class structurally instead of per-branch.

Confirmed — tests (test/local_start_output_handling.js)

  1. Line 35 — retry path is left armed. retriesLeft stays 9, so any execFile failure on the stub (e.g. noexec tmpdir in CI) deletes the stub and downloads + runs the real BrowserStackLocal binary from the network with key dummy-key, up to 9 times. One line fixes it: bsLocal.retriesLeft = 0 in beforeEach.

  2. Line 41 — fixed setTimeout(1000) settle. Races the execFile callback on loaded CI boxes (flaky expected 0 to equal 1) and adds 4+ seconds of dead sleep; a late throw after the window hits mocha instead of the suite's uncaught-capture. Settle on the first callback, or hook the tunnel's close event.

  3. Line 30 — removeAllListeners('uncaughtException'). Strips mocha's global handler with restoration living only inside the 1s timer — not exception-safe, and it swallows unrelated async errors process-wide during each window. process.on('uncaughtExceptionMonitor') observes without detaching.

  4. Line 48 — tmpdir leak. mkdtempSync(bs-local-7325-*) has no after() cleanup, so every run leaks a directory into the system tmpdir. One fs.rmSync(stubDir, { recursive: true }) in after() fixes it.

Plausible (depends on binary payload shapes / caller patterns)

  1. lib/Local.js:151getErrorMessage can return a non-string ({"message":42}error.message === 42; nested objects → [object Object]), which crashes consumers doing error.message.match(...). Add a typeof message === 'string' check.

  2. lib/Local.js:115 — the added return discards the binary's JSON diagnostic when it exits non-zero with a valid failure payload on stdout; callers now get only the generic "Error while trying to execute binary" after 9 delete/re-download cycles. Consider parsing stdout in the exhausted branch and preferring the payload's message.

  3. lib/Local.js:128 — the parse-failure path attaches the full raw output (up to execFile's 1MB maxBuffer) as error.extra; serializers/log shippers will dump it wholesale. Truncate at attach time (~1KB is plenty).


One candidate finding was dropped as refuted (getErrorMessage as instance method / minimal typings — that matches this repo's existing conventions). Lint (npm run pretest) passes clean. The test-toggling methodology in the new suite is a good idea — the items above are about making it deterministic and safe in CI.

🤖 Generated with Claude Code

vivianludrick and others added 2 commits August 31, 2026 19:30
Addresses all 10 review findings on PR #182:

- Shared parseBinaryOutput helper used by both start and startSync:
  guards JSON.parse in the sync path too (no more binary delete +
  9 re-downloads on non-JSON output) and rejects payloads that parse
  to null or a non-object ('null' is valid JSON, so the parse guard
  alone missed it).
- Once-guarded safeCallback + whole-body try/catch inside the execFile
  callback so no branch can throw uncatchably or fire the callback
  twice; consumer-callback throws still propagate.
- fs.unlinkSync in both retry branches wrapped: a missing binary no
  longer aborts the retry.
- getErrorMessage only returns strings; non-string payload messages
  fall back to the generic message.
- Exhausted-retry branch parses stdout and prefers the binary's JSON
  diagnostic over the generic execution error.
- Raw output attached as error.extra truncated to 1KB.
- Tests: retriesLeft=0 (no accidental real-binary download in CI),
  deterministic settle instead of fixed 1s sleep, exception-safe
  uncaughtException snapshot/restore in beforeEach/afterEach, tmpdir
  cleanup in after(), plus new cases for null output, non-string
  message, non-zero-exit diagnostics, truncation, and startSync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 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>
Comment thread lib/LocalBinary.js Fixed
vivianludrick and others added 3 commits September 1, 2026 03:07
…ntics

- 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>
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.
The three Download tests could never pass. `binary.key` was never set,
though binaryPath() sets it before every real download() call, so the
source-url fetch was rejected with "Invalid auth token". `retries` was
omitted too, making the retry check `undefined > 0` — every failure went
straight to the terminal branch with no retry.

On master this block hangs rather than fails, because the terminal path
there never invokes the callback; that defect was fixed earlier in this
branch, which turned the hang into a fast assertion failure and made the
real cause visible.

With the key supplied the block passes for the first time, covering the
real HTTP download end to end — direct and through a proxy, async and
sync — which is what exercises the new non-200 response check against a
live source.

Verified with real credentials: full suite 59 passing, 1 failing. The one
failure (`should stop local`) fails identically on origin/master.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants