|
| 1 | +# Firefox `q2 preview` peer-connection timeout: root cause |
| 2 | + |
| 3 | +**Date:** 2026-06-11 |
| 4 | +**Strand:** bd-jit6pdwq (q2 preview: Firefox flaky 'Document automerge:<id> is unavailable' on cold start) |
| 5 | +**Symptom:** sporadic, Firefox-only `Peer connection failed, continuing in |
| 6 | +offline mode: Error: Timeout waiting for peer connection`, followed by a |
| 7 | +permanent `Document … is unavailable` boot error in the preview SPA. |
| 8 | + |
| 9 | +## TL;DR |
| 10 | + |
| 11 | +Firefox serializes WebSocket *opening handshakes* per host, browser-wide: |
| 12 | +only one WebSocket to `127.0.0.1` may be in the CONNECTING state at a |
| 13 | +time, across **all tabs**. If any tab holds a WebSocket handshake that |
| 14 | +hangs (TCP accepted, HTTP 101 never sent — e.g. a wedged/suspended |
| 15 | +`q2 preview` server, or any unresponsive localhost endpoint), every other |
| 16 | +localhost WebSocket handshake in the browser queues behind it for up to |
| 17 | +Firefox's `network.websocket.timeout.open` (20 s default) per attempt. |
| 18 | +The preview SPA budgets only 5 s for the samod `peer` event, so it falls |
| 19 | +into "offline mode", `repo.find()` then marks the (ephemeral, never |
| 20 | +cached) index doc unavailable, and the boot fails **permanently** — even |
| 21 | +though the WebSocket typically connects fine a few seconds later. |
| 22 | + |
| 23 | +Chromium does not serialize handshakes this way and is immune. |
| 24 | + |
| 25 | +## Evidence (all scripts in `claude-notes/tmp/`) |
| 26 | + |
| 27 | +1. **Server exonerated** (`peer-handshake-probe.mjs`): 10/10 Node |
| 28 | + connections to the live failing server (port 59776) reached `peer` in |
| 29 | + 0.5–8 ms. The Rust hub answers WS upgrades instantly. |
| 30 | + |
| 31 | +2. **Clean Firefox exonerated** (`firefox-repro.mjs`): 15/15 cold loads |
| 32 | + of the preview SPA in a fresh Playwright Firefox connected in ~260 ms. |
| 33 | + The bug does not reproduce in an idle browser → environmental trigger. |
| 34 | + |
| 35 | +3. **Reproduction** (`firefox-serialization-test.mjs` + |
| 36 | + `blackhole-server.mjs`): one tab holding a WebSocket to a localhost |
| 37 | + port that accepts TCP but never answers the upgrade ("blackhole", |
| 38 | + simulating a wedged/suspended server). A second tab then loads the |
| 39 | + preview SPA: |
| 40 | + |
| 41 | + - **Firefox**: `Peer connection failed, continuing in offline mode` |
| 42 | + at t=5.29 s — the exact production failure. Retries at +5 s and |
| 43 | + +10 s also starved. |
| 44 | + - **Chromium** (control): `Peer connected - online mode` at t=59 ms |
| 45 | + under identical conditions. |
| 46 | + - **Firefox + stale tab on a *closed* port** (control): peer at |
| 47 | + 430 ms. Fast TCP-reset failures do *not* starve the queue; a |
| 48 | + genuinely hung handshake is required. |
| 49 | + |
| 50 | + The per-host serialization lives in Firefox's `nsWSAdmissionManager` |
| 51 | + (`netwerk/protocol/websocket/WebSocketChannel.cpp`). |
| 52 | + |
| 53 | +## Source-level confirmation that the key excludes the port |
| 54 | + |
| 55 | +Verified against mozilla-central master (2026-06-11), |
| 56 | +`netwerk/protocol/websocket/WebSocketChannel.cpp`: |
| 57 | + |
| 58 | +- `nsWSAdmissionManager::ConditionallyConnect` defers a handshake when |
| 59 | + `IndexOf(ws->mAddress, ws->mOriginSuffix) >= 0` — the queue lookup |
| 60 | + compares **resolved IP address + origin attributes only**; `nsOpenConn` |
| 61 | + carries no port or path. Code comment: "If there is already another WS |
| 62 | + channel connecting to this IP address, defer BeginOpen and mark as |
| 63 | + waiting in queue." |
| 64 | +- `mAddress` is the **DNS-resolved IP** (`GetNextAddrAsString`), so |
| 65 | + `localhost` and `127.0.0.1` share one key. |
| 66 | +- The **port-aware** structure is the separate `FailDelayManager` |
| 67 | + (reconnect backoff), keyed by `(address, path, port)` — which is why |
| 68 | + the closed-port control didn't interfere cross-port but the hung |
| 69 | + handshake did. |
| 70 | +- This implements RFC 6455 §4.1 literally: "If multiple connections to |
| 71 | + the same IP address are attempted simultaneously, the client MUST |
| 72 | + serialize them so that there is no more than one connection at a |
| 73 | + time." The spec's serialization clause is per-IP, portless. Chromium |
| 74 | + interprets it loosely (our control: 59 ms with a pending handshake). |
| 75 | +- Caveat: `mOriginSuffix` carries container-tab / private-browsing |
| 76 | + identity, so handshakes in a Firefox container or private window do |
| 77 | + **not** share the slot with normal tabs. (Diagnostic implication: the |
| 78 | + bug "disappearing" in a private window is consistent with this root |
| 79 | + cause, not evidence against it.) |
| 80 | + |
| 81 | +## Why this user, why sporadic |
| 82 | + |
| 83 | +The workflow accumulates localhost WebSocket clients: every `q2 preview` |
| 84 | +session opens a tab whose automerge-repo `WebSocketClientAdapter` retries |
| 85 | +`ws://127.0.0.1:<port>/ws` every 5 s, **forever**, after the server goes |
| 86 | +away. Q1 (`quarto preview`) live-reload tabs do the same on their ports. |
| 87 | +Any one of these endpoints entering an accept-but-don't-respond state |
| 88 | +(server wedged, process suspended, kernel listen-backlog of a dying |
| 89 | +process, single-threaded server busy) turns its tab into a perpetual |
| 90 | +slot-holder: each retry occupies the browser-wide handshake slot for up |
| 91 | +to 20 s. While that state persists, *every* new preview tab fails its |
| 92 | +5 s peer wait; close the offending tab (or the endpoint recovers) and |
| 93 | +previews work again. Hence "sporadic", and "reproduces every time" on a |
| 94 | +bad day (bd-jit6pdwq). |
| 95 | + |
| 96 | +## Aggravating design factors found along the way |
| 97 | + |
| 98 | +These make a transient handshake delay into a hard, unrecoverable failure: |
| 99 | + |
| 100 | +1. **5 s peer budget == 5 s adapter retry interval.** |
| 101 | + `PreviewApp.tsx` passes `peerTimeoutMs: 5000`; |
| 102 | + `WebSocketClientAdapter`'s `retryInterval` default is also 5000 ms. |
| 103 | + Any first-attempt failure loses the race by construction — recovery |
| 104 | + at t≥5 s can never beat the deadline at t=5 s. |
| 105 | + |
| 106 | +2. **No recovery after the timeout.** After `waitForPeer` rejects, |
| 107 | + `connect()` proceeds to `findDoc()`; `networkSubsystem.whenReady()` |
| 108 | + force-resolves 1 s after adapter creation even when unconnected, so |
| 109 | + `handle.request()` runs against zero peers → handle resolves |
| 110 | + UNAVAILABLE → `connect()` throws → SPA sets `boot: 'error'` |
| 111 | + permanently. The adapter often connects seconds later (we observed |
| 112 | + this), but nothing retries the boot. |
| 113 | + |
| 114 | +3. **IndexedDB gates the WebSocket `join`.** automerge-repo's |
| 115 | + `NetworkSubsystem` defers `adapter.connect()` (which creates the |
| 116 | + WebSocket and sends `join`) until `storageSubsystem.id()` — an |
| 117 | + IndexedDB open+read — resolves (verified empirically: a 6 s storage |
| 118 | + delay produces the identical timeout; `slow-storage-probe.mjs`). |
| 119 | + The preview SPA gets IndexedDB unconditionally from |
| 120 | + `buildStorageAdapter()`, yet its docs are ephemeral — the cache can |
| 121 | + never hit, and each preview port pollutes the Firefox profile with a |
| 122 | + new origin's database. Any IDB slowness (Firefox is notorious) |
| 123 | + silently eats the peer budget. Not the trigger observed here, but a |
| 124 | + second independent path to the same failure. |
| 125 | + |
| 126 | +4. **Stale preview tabs retry forever** (no backoff cap, no give-up), |
| 127 | + which is what keeps a hung endpoint's slot occupied perpetually and |
| 128 | + adds localhost WS churn generally. |
| 129 | + |
| 130 | +## Fix directions (not implemented in this session) |
| 131 | + |
| 132 | +- **Make boot resilient instead of deadline-bound** (primary): for the |
| 133 | + preview SPA, offline mode is meaningless — don't hard-fail on the 5 s |
| 134 | + peer timeout. Wait for the `peer` event (with status UI), or retry |
| 135 | + `findDoc` when a peer connects after a failed boot. automerge-repo |
| 136 | + handles can recover from UNAVAILABLE via `progress`/re-request. |
| 137 | +- **De-align the deadlines**: if a finite budget is kept, make it ≫ the |
| 138 | + adapter retry interval (e.g. 15–30 s), or pass a smaller |
| 139 | + `retryInterval` to the adapter. |
| 140 | +- **Memory storage for the preview SPA**: thread a storage option |
| 141 | + through `createSyncClient()` so preview uses `MemoryStorageAdapter`; |
| 142 | + removes the IDB gate on `join` and stops per-port profile pollution. |
| 143 | +- **Cap stale-tab retries**: exponential backoff and/or a "server gone — |
| 144 | + reload when ready" terminal state in the SPA, so dead preview tabs |
| 145 | + stop hammering localhost. (Both kindness to Firefox's handshake queue |
| 146 | + and battery.) |
| 147 | +- **User-side mitigation meanwhile**: close old preview tabs; if a |
| 148 | + preview suddenly shows the offline error, suspect some localhost tab |
| 149 | + whose server is hung (check DevTools Network → WS for a pending |
| 150 | + handshake) rather than the new preview itself. |
| 151 | + |
| 152 | +## Loose end (resolved) |
| 153 | + |
| 154 | +The user's 59776 preview server went down mid-investigation; the user |
| 155 | +confirmed they closed it themselves. No spontaneous server death |
| 156 | +observed; a sibling instance survived identical probing. |
0 commit comments