LOC-7325: stop uncatchable TypeError on empty binary output in Local.start - #182
LOC-7325: stop uncatchable TypeError on empty binary output in Local.start#182vivianludrick wants to merge 4 commits into
Conversation
…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.
Claude Code ReviewVerdict: 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 10 findings survived adversarial verification (7 confirmed by live execution or code inspection, 3 plausible), ranked by severity: Confirmed — code
Confirmed — tests (
|
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>
…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>
Fixes an uncatchable
TypeErrorthrown out ofLocal.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 anexecFilecallback. The empty-output branch called back withNo output receivedbut did notreturn, so control fell through to the next statement, which dereferencesdata['message']['message']ondata = {}:Two things make this worse than a normal error path:
No output received, then again from the throwing statement.exithandler, so notry/catcharoundlocal.start(...)intercepts it. It surfaces as anuncaughtException, 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 fataluncaughtExceptionhandler 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:
returnerrorhandlerreturnmessagekeyFailed to start BrowserStack LocalAlso guarded
JSON.parse: non-JSON output (a plain-text crash message, for instance) threw aSyntaxErrorfrom the same uncatchable position. It is now reported through the callback asInvalid output received: <reason>, with the raw output attached as the error'sextrafield.startSyncshared 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— drivesstart()with stub binaries for each output shape and asserts the callback fires exactly once and that nothing escapes as anuncaughtException. No credentials or network needed.Verified the tests actually catch the defect by toggling the fix:
master: 3 of the 4 fail, with the ticket's exactTypeError: Cannot read properties of undefined (reading 'message').Full suite, excluding the
LocalBinary > Downloadblock that needs real credentials:master: 28 passing, 3 failing, 2 pendingSame 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 overlib/* 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.13is the latest published version and carries the defect, so this needs a release to reach consumers.Behavior note (changelog)
startSyncnow returns aLocalError('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 aserror.extra, truncated to 1KB.Round 3 additions:
isRunning()was true;startSyncalready behaved this way).startre-downloads a fresh copy; binaries passed viabinarypathare never evicted.start()immediately with the recorded download error instead of hanging or cascading into ~100 retry attempts.