Skip to content

Fix connect tunnel stall on non-auth handshake rejection - #1689

Open
grrowl wants to merge 1 commit into
get-bb:mainfrom
grrowl:fix-connect-tunnel-handshake-stall
Open

Fix connect tunnel stall on non-auth handshake rejection#1689
grrowl wants to merge 1 commit into
get-bb:mainfrom
grrowl:fix-connect-tunnel-handshake-stall

Conversation

@grrowl

@grrowl grrowl commented Aug 17, 2026

Copy link
Copy Markdown

The bug

In plugins/connect/src/tunnel.ts, the unexpected-response handler for the
connect tunnel handles a gate handshake rejection. For statuses other than
401/403 it only recorded lastError and logged a warning:

this.lastError = `tunnel rejected: HTTP ${statusCode}`;
this.options.log.warn(this.lastError);

The subtle part is why that leaves the socket wedged. Registering an
unexpected-response listener makes ws skip its own internal
abortHandshake — it assumes the app now owns teardown. But this handler never
tore the socket down, so neither error nor close ever fired. The
reconnect backoff (ReconnectBackoff + the setTimeout that re-dials) lives
entirely in the close handler, so it was never scheduled and the socket sat in
CONNECTING indefinitely.

User impact

A single non-auth rejection from the getbb.app gate — exactly the transient
502/503 Cloudflare serves mid-deploy — took a user's bb connect tunnel
permanently offline. It presented silently as "connect randomly stops working
and never comes back," recoverable only by reloading the plugin or re-pairing.

The fix

Call tunnel.terminate() in that branch. During CONNECTING, terminate()
routes to abortHandshake, which aborts the request and emits error then
close, so the existing backoff/reconnect path runs unchanged — no handler
restructuring needed.

The 401/403 branch was already fine: credentialRejected() calls teardown(),
which invokes this.tunnel?.terminate(), so that socket is torn down rather than
stranded in CONNECTING.

Reproduction / test

Added tunnel-handshake.test.ts, which stands up a real ws server that
rejects the upgrade with an HTTP 502, points a ConnectTunnel at it, and
asserts a reconnect is scheduled (nextRetryAt becomes non-null, state is
reconnecting). It runs against a real handshake because a mocked ws cannot
reproduce the abortHandshake-skip behavior.

  • Before the fix: the test fails — nextRetryAt stays null and the wait
    times out after 3s.
  • After the fix: it passes in ~60ms. All 79 package tests and typecheck pass.

HOST_DAEMON_PROTOCOL_VERSION is unaffected: this changes only the plugin's
client-side reaction to a gate handshake rejection, no server↔host-daemon wire
message, session payload, or RPC.

AGENT GENERATED: by Claude Opus 4.8

When the gate rejects the tunnel upgrade with a status other than 401/403
(e.g. a 502/503 Cloudflare returns mid-deploy), the `unexpected-response`
handler only recorded `lastError` and warned. Because that listener is
registered, `ws` skips its own `abortHandshake`, so neither `error` nor
`close` ever fires. The reconnect backoff is scheduled in the `close`
handler, so it was never set and the socket sat in CONNECTING forever.

To a user this presents as `bb connect` silently going offline and never
coming back until they reload the plugin or re-pair — a single transient
502 from the getbb.app gate is enough to trigger it.

Call `tunnel.terminate()` in that branch. During CONNECTING it routes to
`abortHandshake`, which aborts the request and emits `error` then `close`,
so the existing backoff/reconnect path runs unchanged. The 401/403 branch
already tears the socket down via `credentialRejected` -> `teardown()`.

Reproduced with a test that stands up a real `ws` server rejecting the
upgrade with a 502 and asserts a reconnect is scheduled; it fails before
this change (nextRetryAt stays null) and passes after.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

1 participant