← reports

#1494 · Desktop: an unreachable saved remote server target leaves the app on a dead-end startup error

Bug Medium Effort: not set desktop open on GitHub 2026-08-18 base 16ceb3a540f81c1189efaffb27a39b1d9443abf5 (main)

Verdict: REPRODUCED · root-cause confidence: high · linked open PRs: none (PR #1519 attempted a fix and was closed unmerged in the 13 Aug sweep)

TL;DR

Plain-language framing. The desktop app remembers which bb server it should show (the server target, saved in server-target.json in the app's user-data folder). "This Mac" (builtin) starts a local server; a custom or connect target just loads a remote URL into the Electron window like a browser would. Electron's BrowserWindow.loadURL returns a promise that rejects when the page cannot be fetched (host down, port closed, DNS failure, timeout).

The reporter points the app at a server over Tailscale; when that host is unreachable at launch, the window shows "Could not open bb" followed by a raw Electron stack trace, and nothing to click. I reproduced this exactly against the built desktop shell at 16ceb3a54 on Linux under Xvfb with a custom target nothing listens on: the same "Could not open bb / Error: ERR_CONNECTION_REFUSED (-102) loading … at rejectAndCleanup (node:electron/js2c/browser_init…)" screen with zero interactive controls (screenshot below). The mechanism is exactly what the issue says: applyServerTarget() awaits loadWindowUrl for the custom/connect branches without any try/catch, the rejection unwinds through runDesktopApp() into the top-level .catch which renders error.stack into the static, script-less error view.

Two things I found beyond the issue: (1) the recovery route the issue names, Window ▸ Server, is worse when the target is still unreachable: re-selecting the same custom item (or any remote item) goes through void setActiveServerTarget(), which has no top-level catch at all, so the rejection becomes an UnhandledPromiseRejectionWarning on stderr and the window turns completely blank (Chromium's failed-navigation page), with bbAppLoaded left true. (2) When the host silently drops packets (a sleeping Tailscale peer is the realistic case) the loading screen sits for Chromium's ~2 min connect timeout with the copy "Starting local services…" before the same dead end appears (ERR_CONNECTION_TIMED_OUT), which is issue #1524's territory. Nothing on origin/main after the base commit touches this path, so it is still open.

Claims vs findings

ClaimStatusEvidence
Startup with an unreachable custom target shows "Could not open bb" + raw Electron stack, no controlsVerifiedLive repro on the built shell (Xvfb, Electron 41.7.0): body text and screenshot 1494-startup-error-refused.png; CDP count of button,a,input,[role=button] on the page = 0.
Reporter saw ERR_FAILED (-2)Unverified (code differs, path identical)I cannot bring a Tailscale peer down here. Closed port gives ERR_CONNECTION_REFUSED (-102), blackholed IP gives ERR_CONNECTION_TIMED_OUT (-118), port 1 gives ERR_UNSAFE_PORT (-312). All are the same rejection of loadURL handled by the same code path; only the message differs.
applyServerTarget() is called at startup for a non-builtin target with no guardVerifiedmain.ts#L2280-L2289: await applyServerTarget(); inside runDesktopApp().
The custom branch does bbAppLoaded = true; await loadWindowUrl(...) and nothing catches the rejectionVerifiedmain.ts#L1226-L1234. loadUrlIntoWindow only swallows ERR_ABORTED and rethrows everything else (desktop-window-factory.ts#L197-L209).
Top-level handler renders error.stackVerifiedmain.ts#L2292-L2301; the visible text in the repro is exactly error.stack + " Logs are under …/logs/."
Error view is static HTML under default-src 'none', cannot carry a working buttonVerifiedlocal-view.ts#L54-L65 (no actions rendered) and #L81 (CSP). Unit repro test below asserts both.
Same unguarded load on the connect branch after successful authVerified by codemain.ts#L1220-L1221. Not exercised live (no bb Connect account/server here).
refreshApplicationMenu() never runs, "so the Server menu keeps showing the failed target as active"Half rightThe call at #L1235 is indeed skipped. But the menu's checked item comes from serverTargetStore.getTarget() (buildMenuServerItems), and the store still says custom, so the menu would show the failed target as active even if the refresh ran (and it did run once at #L2272 before the load). The skipped refresh is real but not the reason for the checkmark.
bbAppLoaded is true while the error screen showsRefuted for the startup path, verified for the menu pathAt startup the top-level catch calls loadStartupError, which sets bbAppLoaded = false (#L1463-L1465). When the same failure happens from the Server menu nothing resets it, so it stays true while a blank page shows (see "menu path" below).
Recovery is only via Window ▸ Server or hand-editing server-target.jsonVerified, and the menu route is worse than describedRe-selecting an unreachable target from the menu produced a blank white window and an UnhandledPromiseRejectionWarning (log + screenshot). Switching to "This Mac" would work (that branch is guarded), but the screen does not say so.

Environment

Minimal reproduction

Repro scripts live in 1494/repro/. They take the worktree root as an argument; nothing else is hardcoded. Precondition: pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build in the worktree, and xvfb-run on PATH (on macOS just run electron directly, without xvfb-run).

A. Startup with an unreachable custom target (the issue)

  1. Launch the shell with a saved custom target pointing at a closed port. The script writes server-target.json (what it looks like) into a fresh user-data dir and starts Electron with a CDP port so we can read the page:
    $ /tmp/bb-reports/issues/1494/repro/run-desktop-unreachable.sh \
        /home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-22 \
        /tmp/bb-1494-userdata http://127.0.0.1:47771 9333 \
        /tmp/bb-reports/issues/1494/electron-run2-refused.log &
  2. After ~5 s, read what the window shows and screenshot it:
    $ node /tmp/bb-reports/issues/1494/repro/cdp-screenshot.mjs 9333 assets/1494-startup-error-refused.png
    URL: data:text/html;charset=utf-8,%3C!doctype%20html%3E%0A%3Chtml...
    BODY TEXT:
    Could not open bb
    
    Error: ERR_CONNECTION_REFUSED (-102) loading 'http://127.0.0.1:47771/' at rejectAndCleanup (node:electron/js2c/browser_init:2:89743) at WebContents.finishListener (node:electron/js2c/browser_init:2:89905) at WebContents.emit (node:events:509:28) Logs are under /tmp/bb-1494-userdata/bb-data/logs/.
    interactive controls on page: 0
    wrote assets/1494-startup-error-refused.png
  3. Electron's stderr (electron-run2-refused.log) shows the rejection reaching the top-level handler, which writes it to stderr before rendering it:
    (node:2469768) electron: Failed to load URL: http://127.0.0.1:47771/ with error: ERR_CONNECTION_REFUSED
    Error: ERR_CONNECTION_REFUSED (-102) loading 'http://127.0.0.1:47771/'
        at rejectAndCleanup (node:electron/js2c/browser_init:2:89743)
        at WebContents.finishListener (node:electron/js2c/browser_init:2:89905)
        at WebContents.emit (node:events:509:28)

Expected (issue): a screen that names the unreachable server and offers Retry and Use This Mac, like the builtin failure does. Actual: the screen above.

Desktop window showing Could not open bb and an Electron stack trace
The moment the bug shows: "Could not open bb" with Electron internals as the body and no controls. Same shape as the issue's screen (only the error code differs: refused vs the reporter's ERR_FAILED).
Loading screen
Before: the loading view that precedes it. Note the copy says "Starting local services" although no local service is started for a remote target. With a blackholed host (run C) this screen stayed up ~2m17s.

B. The "recovery" route from the issue: Window ▸ Server → same target again

The only escape the issue found is the Server menu. Selecting a remote item there runs the same code (setActiveServerTargetapplyServerTarget) but from a fire-and-forget void call with no catch. To click the native menu deterministically I launched with --inspect=9334 and evaluated Menu.getApplicationMenu()…item.click() in the main process (main-click-server-menu.mjs); it is the same handler a mouse click runs.

$ BB_1494_INSPECT=9334 /tmp/bb-reports/issues/1494/repro/run-desktop-unreachable.sh <worktree> /tmp/bb-1494-userdata http://127.0.0.1:47771 9333 electron-run3-menu-reselect.log &
$ node /tmp/bb-reports/issues/1494/repro/main-click-server-menu.mjs 9334 "127.0.0.1:47771"
{ "type": "string", "value": "clicked Window > Server > 127.0.0.1:47771" }
$ node /tmp/bb-reports/issues/1494/repro/cdp-screenshot.mjs 9333 assets/1494-menu-reselect-blank.png
URL: http://127.0.0.1:47771/...
BODY TEXT:

interactive controls on page: 0

Electron stderr (electron-run3-menu-reselect.log):

(node:2496378) electron: Failed to load URL: http://127.0.0.1:47771/ with error: ERR_CONNECTION_REFUSED
(node:2496378) UnhandledPromiseRejectionWarning: Error: ERR_CONNECTION_REFUSED (-102) loading 'http://127.0.0.1:47771/'
    at rejectAndCleanup (node:electron/js2c/browser_init:2:89743)
    …
(node:2496378) UnhandledPromiseRejectionWarning: Unhandled promise rejection. …
Blank white window
After the menu click: the window is entirely blank (the page URL is now the dead http://127.0.0.1:47771/), the error message is gone, and bbAppLoaded is left true. Nothing on screen tells the user what happened.

C. Blackholed host (closest to a sleeping Tailscale peer)

$ /tmp/bb-reports/issues/1494/repro/run-desktop-unreachable.sh <worktree> /tmp/bb-1494-userdata http://10.255.255.1:38886 9333 electron-run4-blackhole.log &
# 07:44:10 launch … loading view (screenshot above) … 07:46:27:
(node:2516483) electron: Failed to load URL: http://10.255.255.1:38886/ with error: ERR_CONNECTION_TIMED_OUT
Error: ERR_CONNECTION_TIMED_OUT (-118) loading 'http://10.255.255.1:38886/'
    at rejectAndCleanup (node:electron/js2c/browser_init:2:89743) …
$ node cdp-screenshot.mjs 9333 assets/1494-blackhole-timeout.png
BODY TEXT:
Could not open bb
Error: ERR_CONNECTION_TIMED_OUT (-118) loading 'http://10.255.255.1:38886/' at rejectAndCleanup … Logs are under /tmp/bb-1494-userdata/bb-data/logs/.
interactive controls on page: 0

Same dead end after 2m17s of an unexplained spinner (screenshot, log).

Unit-level repro (what the error view can and cannot show)

main.ts runs on import, so applyServerTarget itself is not unit-testable without a refactor. The testable half is the view: repro-1494-startup-error-view.test.ts (also at apps/desktop/test/ in the worktree) feeds createLocalViewUrl exactly what the top-level catch feeds it and asserts the current behaviour (passes) and the expected behaviour (fails on main). Run from apps/desktop: pnpm exec vitest run test/repro-1494-startup-error-view.test.ts (output):

❯ test/repro-1494-startup-error-view.test.ts (4 tests | 2 failed)
     × EXPECTED (fails on main): offers a Retry / Use This Mac control
       AssertionError: expected '<!doctype html>…' to match /<button|<a /u
     × EXPECTED (fails on main): names the server instead of the stack
       AssertionError: expected … not to contain 'rejectAndCleanup'
 Test Files  1 failed (1)   Tests  2 failed | 2 passed (4)
// Repro for get-bb/bb#1494 (unit half).
//
// The desktop startup error screen is a static `data:` URL rendered by
// `createLocalViewUrl`. This test shows what the live repro shows visually:
// the view that `runDesktopApp().catch(...)` (apps/desktop/src/main.ts) falls
// back to when a saved remote target is unreachable carries the raw Electron
// stack as its body, offers no control the user could click, and locks itself
// down with `default-src 'none'` so it cannot ever run a Retry / Use This Mac
// handler. Together with the unguarded `loadWindowUrl` in `applyServerTarget`
// this is the "dead end" from the issue.
//
// The assertions marked EXPECTED (desired behaviour) FAIL on main; the ones
// marked CURRENT document what the code does today and pass.
import { describe, expect, it } from "vitest";
import { createLocalViewUrl } from "../src/local-view.js";

// Exactly what runDesktopApp's top-level catch does with the loadURL rejection
// (main.ts: `details: message` where message = error.stack).
const electronRejection = new Error(
  "ERR_CONNECTION_REFUSED (-102) loading 'http://127.0.0.1:47771/'",
);
electronRejection.stack =
  "Error: ERR_CONNECTION_REFUSED (-102) loading 'http://127.0.0.1:47771/'\n" +
  "    at rejectAndCleanup (node:electron/js2c/browser_init:2:89743)\n" +
  "    at WebContents.finishListener (node:electron/js2c/browser_init:2:89905)\n" +
  "    at WebContents.emit (node:events:509:28)";

function decodeDataUrl(url: string): string {
  const prefix = "data:text/html;charset=utf-8,";
  expect(url.startsWith(prefix)).toBe(true);
  return decodeURIComponent(url.slice(prefix.length));
}

describe("#1494 startup error view for an unreachable saved server target", () => {
  const html = decodeDataUrl(
    createLocalViewUrl({
      viewModel: {
        details: `${electronRejection.stack} Logs are under /tmp/x/logs/.`,
        kind: "error",
        logText: "",
        title: "Could not open bb",
      },
    }),
  );

  it("CURRENT: renders Electron internals as the user-facing message", () => {
    expect(html).toContain("node:electron/js2c/browser_init");
    expect(html).toContain("rejectAndCleanup");
  });

  it("CURRENT: forbids scripts, so no button could ever work here", () => {
    expect(html).toContain(
      `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'">`,
    );
    expect(html).not.toMatch(/<script/u);
  });

  it("EXPECTED (fails on main): offers a Retry / Use This Mac control", () => {
    expect(html).toMatch(/<button|<a /u);
    expect(html).toMatch(/Retry|This Mac|Window .* Server/u);
  });

  it("EXPECTED (fails on main): names the server instead of the stack", () => {
    expect(html).toContain("127.0.0.1:47771");
    expect(html).not.toContain("rejectAndCleanup");
  });
});

Root cause

1. The remote branches of applyServerTarget() treat a page load as infallible. The builtin branch checks its outcome and renders a named error (main.ts#L1166-L1180); the connect branch checks authentication (#L1197-L1215) but then, like the custom branch, does this (#L1220-L1235):

    bbAppLoaded = true;
    await loadWindowUrl({ url: target.server.url });   // connect
    …
  } else {
    // A custom server is a plain web load with no bb Connect involved.
    bbAppLoaded = true;
    await loadWindowUrl({ url: target.url });          // custom
    if (!isCurrent()) {
      return;
    }
    startRemoteSystemConfigSync(target.url);
  }
  refreshApplicationMenu();

loadWindowUrldesktopWindowFactory.loadUrlloadUrlIntoWindow, which awaits BrowserWindow.loadURL and rethrows anything that is not ERR_ABORTED (desktop-window-factory.ts#L197-L209). Electron rejects that promise for every network-level failure (ERR_CONNECTION_REFUSED, ERR_CONNECTION_TIMED_OUT, ERR_NAME_NOT_RESOLVED, ERR_FAILED, …). Nothing between there and the caller handles it, and startRemoteSystemConfigSync and refreshApplicationMenu are skipped.

2. Where the rejection lands decides what the user sees.

3. The error view is terminal by construction. renderErrorView emits title, details and optional log text only (local-view.ts#L54-L65) inside a document whose CSP is default-src 'none'; style-src 'unsafe-inline' (#L81) and which is loaded as a data: URL, so it has neither script nor preload; a "Retry" button would need an IPC path that does not exist. The only in-app way out is the native menu, which the copy never mentions.

Why the symptom follows. Unreachable target → loadURL rejects → nobody in applyServerTarget catches → at startup the crash handler prints the stack into a button-less page (issue's screen); from the menu nothing prints anything (blank page). Both leave the saved target as-is, so the next launch repeats it, hence "reproduces on launch, every time".

Deeper issue. There is no notion of "target reachable?" for remote targets at all: probeBbServer/waitForCompatibleServer (apps/desktop/src/server-probe.ts) are used only for the local runtime. Also loadUrlIntoWindow has no deadline, so a half-open target keeps the loading screen up until Chromium's own connect timeout (~2 min here); that is #1524, closed as not planned but still present.

Proposed fix (first principles)

  1. Make the remote load a handled outcome. In applyServerTarget() wrap the two remote loadWindowUrl calls in try/catch (or a small loadRemoteServerPage() helper). On rejection: log the Electron detail once, then await loadStartupError({ title: "Could not reach <host>", details: "The bb server at <origin> did not answer. Check that the machine is awake and reachable, then use Window ▸ Server to retry or switch to This Mac.", logs: "" }), stop the Connect session renewal on the connect branch, and call refreshApplicationMenu(). Set bbAppLoaded = true only after the load resolves and isCurrent() still holds. Print the URL without credentials/query (a custom URL may carry a token). This alone removes the stack trace, fixes the blank-window menu path, and keeps the startup crash handler for real crashes.
  2. Give the error view real actions. The data: view cannot host handlers under its CSP. Either (a) render the local views through a bb-local:// (or file:) scheme with the existing app preload and a narrow, sender-checked IPC ("retry", "use-builtin") that the main process only honours while the current window URL is that local view (a remote page must not be able to trigger it), or (b) at minimum put "Window ▸ Server ▸ This Mac" in the copy. #1519 implemented (a) with a per-render one-use token and sender check; SlopCop's review there is a good checklist (do not trust remote content, redact URLs).
  3. Optionally probe first / add a deadline. A short fetch(origin + "/api/v1/system/health"…) style probe with a timeout before loadWindowUrl would turn the 2-minute spinner into a fast, named error and would also cover the half-open case from #1524. Keep it in the desktop (host-local UX policy); it does not cross the server/daemon boundary and no protocol version bump is needed.
  4. Fix the loading copy ("Starting local services…") for remote targets, and add tests: a unit test for the helper (rejection → error view called with the redacted label; ERR_ABORTED/generation-superseded → no error view) and the view test above turned positive.

Risks: item 2(a) touches how every local view is served; a token/sender check must be in place before any renderer can ask the main process to switch targets. Item 1 must not catch ERR_ABORTED-style superseded loads as failures (the generation check already handles overlap; keep it after the await).

PR review

No open PR is linked. #1519 ("Recover from an unreachable saved server target", branch sweep/issue-1494-desktop-unreachable-server, +768/−16, diff saved at 1494/pr1519.diff) implemented items 1 and 2(a) above (new remote-server-load.ts, startup-error-ipc.ts, actions in local-view.ts, preload wiring, tests) and was closed unmerged on 2026-08-13 as part of the sweep cleanup, not for a technical reason; SlopCop's remaining finding at close was URL redaction (addressed by describeServerUrl). It is a reasonable base to reopen or cherry-pick; I did not check it out or run its tests since it is closed.

Related issues

Appendix

Commands run

gh issue view 1494 --comments
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build > /tmp/bb-reports/issues/1494/build.log 2>&1
git fetch origin main; git log 16ceb3a54..origin/main --oneline -- apps/desktop/    # (empty: nothing newer touches the desktop)
# run 1 (port 1 → ERR_UNSAFE_PORT, same screen): electron-run1.log
# run 2 (closed port → ERR_CONNECTION_REFUSED):
/tmp/bb-reports/issues/1494/repro/run-desktop-unreachable.sh <worktree> /tmp/bb-1494-userdata http://127.0.0.1:47771 9333 /tmp/bb-reports/issues/1494/electron-run2-refused.log
node /tmp/bb-reports/issues/1494/repro/cdp-screenshot.mjs 9333 /tmp/bb-reports/issues/assets/1494-startup-error-refused.png
# run 3 (menu re-select via main-process inspector):
BB_1494_INSPECT=9334 /tmp/bb-reports/issues/1494/repro/run-desktop-unreachable.sh <worktree> /tmp/bb-1494-userdata http://127.0.0.1:47771 9333 /tmp/bb-reports/issues/1494/electron-run3-menu-reselect.log
node /tmp/bb-reports/issues/1494/repro/main-click-server-menu.mjs 9334 "127.0.0.1:47771"
node /tmp/bb-reports/issues/1494/repro/cdp-screenshot.mjs 9333 /tmp/bb-reports/issues/assets/1494-menu-reselect-blank.png
# run 4 (blackholed IP → ERR_CONNECTION_TIMED_OUT after 2m17s):
/tmp/bb-reports/issues/1494/repro/run-desktop-unreachable.sh <worktree> /tmp/bb-1494-userdata http://10.255.255.1:38886 9333 /tmp/bb-reports/issues/1494/electron-run4-blackhole.log
node /tmp/bb-reports/issues/1494/repro/cdp-screenshot.mjs 9333 /tmp/bb-reports/issues/assets/1494-loading-before.png     # at +12 s
node /tmp/bb-reports/issues/1494/repro/cdp-screenshot.mjs 9333 /tmp/bb-reports/issues/assets/1494-blackhole-timeout.png  # after failure
# unit repro (from apps/desktop):
pnpm exec vitest run test/repro-1494-startup-error-view.test.ts > /tmp/bb-reports/issues/1494/repro-test-main.log
gh pr view 1519 …; gh pr diff 1519 > /tmp/bb-reports/issues/1494/pr1519.diff
pkill -f user-data-dir=/tmp/bb-1494-userdata; pnpm dev:stop

Launcher script (run-desktop-unreachable.sh)

#!/usr/bin/env bash
# Repro for get-bb/bb#1494: launch the built desktop shell (apps/desktop/dist)
# under Xvfb with a saved `custom` server target that nothing listens on.
# Usage: run-desktop-unreachable.sh <worktree-root> <userdata-dir> <url> <cdp-port> <log>
set -u
ROOT="$1"; UD="$2"; URL="$3"; PORT="$4"; LOG="$5"
mkdir -p "$UD"
printf '{"connectServer":null,"customServerUrl":"%s","target":"custom"}\n' "$URL" > "$UD/server-target.json"
cd "$ROOT/apps/desktop"
export BB_DESKTOP_VERSION_CHECK=0 BB_DESKTOP_AUTO_UPDATE=0 BB_DATA_DIR="$UD/bb-data"
exec xvfb-run -a -s "-screen 0 1400x900x24" ./node_modules/.bin/electron \
  --no-sandbox --user-data-dir="$UD" --remote-debugging-port="$PORT" ${BB_1494_INSPECT:+--inspect=$BB_1494_INSPECT} . > "$LOG" 2>&1

Main-process menu click helper (main-click-server-menu.mjs)

// Drive the Electron MAIN process over the Node inspector protocol
// (electron --inspect=<port>) and click an item in the Window ▸ Server menu,
// exactly what a user does with the mouse. Usage:
//   node main-click-server-menu.mjs <inspect-port> <server-item-id>
// <server-item-id> is the menu LABEL, e.g. "This Mac" or "127.0.0.1:47771".
const [port, itemId] = process.argv.slice(2);
const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
const ws = new WebSocket(targets[0].webSocketDebuggerUrl);
await new Promise((r) => (ws.onopen = r));
let id = 0;
const pending = new Map();
ws.onmessage = (ev) => {
  const msg = JSON.parse(ev.data);
  if (msg.id && pending.has(msg.id)) {
    pending.get(msg.id)(msg);
    pending.delete(msg.id);
  }
};
const send = (method, params = {}) =>
  new Promise((resolve) => {
    const i = ++id;
    pending.set(i, resolve);
    ws.send(JSON.stringify({ id: i, method, params }));
  });
const expression = `(() => {
  const { Menu } = process.mainModule.require("electron");
  const menu = Menu.getApplicationMenu();
  const walk = (items, path) => {
    for (const item of items) {
      const here = [...path, item.label || item.role || item.type];
      if (item.label === ${JSON.stringify(itemId)}) {
        item.click();
        return "clicked " + here.join(" > ");
      }
      if (item.submenu) {
        const r = walk(item.submenu.items, here);
        if (r) return r;
      }
    }
    return null;
  };
  return walk(menu.items, []) || "item not found";
})()`;
const res = await send("Runtime.evaluate", { expression, returnByValue: true });
process.stdout.write(JSON.stringify(res.result?.result ?? res, null, 2) + "\n");
ws.close();

Raw logs

Things checked and ruled out