Skip to content

Commit 5c1f1b8

Browse files
cscheidclaude
andcommitted
docs(claude-notes): root-cause Firefox q2-preview peer-connection timeout (bd-jit6pdwq)
Research note + repro scripts for the sporadic Firefox-only 'Timeout waiting for peer connection' failure in q2 preview. Root cause: Firefox serializes WebSocket opening handshakes per resolved IP address browser-wide (nsWSAdmissionManager, per RFC 6455 S4.1), so any tab holding a hung handshake to 127.0.0.1 — any port — starves all other localhost WS handshakes for up to 20s per attempt. The preview SPA's 5s peer budget then expires and the boot fails permanently. Reproduced with Playwright Firefox + a blackhole TCP server; Chromium control unaffected; closed-port control unaffected. Scripts in claude-notes/tmp/ are the evidence chain referenced by the note and by the bd-jit6pdwq strand comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8987534 commit 5c1f1b8

7 files changed

Lines changed: 376 additions & 0 deletions
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// TCP server that accepts connections and never responds — simulates a
2+
// wedged/suspended q2 preview server holding WS handshakes in CONNECTING.
3+
import net from 'node:net';
4+
const port = Number(process.argv[2] ?? 59001);
5+
const server = net.createServer((socket) => {
6+
// accept, read, never write, never close
7+
socket.on('data', () => {});
8+
socket.on('error', () => {});
9+
});
10+
server.listen(port, '127.0.0.1', () => console.log(`blackhole listening on ${port}`));

claude-notes/tmp/firefox-repro.mjs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Reproduce q2-preview peer-connection timeout in Firefox via Playwright.
2+
// Usage: node firefox-repro.mjs [url] [iterations]
3+
import { firefox } from 'playwright';
4+
5+
const url = process.argv[2] ?? 'http://127.0.0.1:59899/';
6+
const iterations = Number(process.argv[3] ?? 15);
7+
8+
const browser = await firefox.launch();
9+
let failures = 0;
10+
11+
for (let i = 0; i < iterations; i++) {
12+
const context = await browser.newContext(); // fresh storage each time
13+
const page = await context.newPage();
14+
const t0 = Date.now();
15+
const events = [];
16+
page.on('console', (msg) => {
17+
const text = msg.text();
18+
if (/peer|offline|Timeout|error|Error|unavailable/i.test(text)) {
19+
events.push(`[${Date.now() - t0}ms] console.${msg.type()}: ${text}`);
20+
}
21+
});
22+
page.on('websocket', (ws) => {
23+
events.push(`[${Date.now() - t0}ms] websocket opened: ${ws.url()}`);
24+
ws.on('close', () => events.push(`[${Date.now() - t0}ms] websocket closed`));
25+
});
26+
page.on('pageerror', (err) => events.push(`[${Date.now() - t0}ms] pageerror: ${err.message}`));
27+
28+
try {
29+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
30+
// give the SPA time to boot: wasm init + connect (5s peer budget) + render
31+
await page.waitForTimeout(9000);
32+
} catch (e) {
33+
events.push(`goto failed: ${e.message}`);
34+
}
35+
36+
const failed = events.some((e) => /Timeout waiting for peer/.test(e));
37+
if (failed) failures++;
38+
console.log(`--- run ${i}${failed ? ' *** PEER TIMEOUT ***' : ''}`);
39+
for (const e of events) console.log(' ', e);
40+
await context.close();
41+
}
42+
43+
console.log(`\n${failures}/${iterations} runs hit the peer timeout`);
44+
await browser.close();
45+
process.exit(0);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Does a hung WebSocket handshake to 127.0.0.1:<portA> delay a fresh
2+
// WebSocket handshake to 127.0.0.1:<portB> in the same browser?
3+
// Usage: node firefox-serialization-test.mjs <browser:firefox|chromium> <previewUrl> <blackholePort>
4+
import { firefox, chromium } from 'playwright';
5+
6+
const browserName = process.argv[2] ?? 'firefox';
7+
const previewUrl = process.argv[3] ?? 'http://127.0.0.1:59899/';
8+
const blackholePort = Number(process.argv[4] ?? 59001);
9+
10+
const browser = await (browserName === 'firefox' ? firefox : chromium).launch();
11+
const context = await browser.newContext();
12+
13+
// Tab A: stale preview tab analog — WS to the blackhole port, retrying.
14+
const tabA = await context.newPage();
15+
await tabA.goto(previewUrl); // same-origin page so we can open ws from it
16+
await tabA.evaluate((port) => {
17+
const tryConnect = () => {
18+
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
19+
ws.onclose = () => setTimeout(tryConnect, 500);
20+
ws.onerror = () => {};
21+
};
22+
tryConnect();
23+
}, blackholePort);
24+
console.log('tab A: hung-handshake websocket started');
25+
26+
// Give tab A's handshake a moment to enter CONNECTING.
27+
await new Promise((r) => setTimeout(r, 1500));
28+
29+
// Tab B: fresh preview load. Measure time to peer.
30+
const tabB = await context.newPage();
31+
const t0 = Date.now();
32+
const events = [];
33+
tabB.on('console', (msg) => {
34+
const text = msg.text();
35+
if (/peer|offline|Timeout/i.test(text)) {
36+
events.push(`[${Date.now() - t0}ms] ${msg.type()}: ${text}`);
37+
}
38+
});
39+
tabB.on('websocket', (ws) => events.push(`[${Date.now() - t0}ms] websocket opened: ${ws.url()}`));
40+
await tabB.goto(previewUrl, { waitUntil: 'domcontentloaded' });
41+
await tabB.waitForTimeout(12000);
42+
43+
console.log(`\n=== ${browserName} tab B events ===`);
44+
for (const e of events) console.log(' ', e);
45+
await browser.close();
46+
process.exit(0);
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Probe: time the samod/automerge-repo peer handshake against a running
2+
// q2 preview server. Usage: node peer-handshake-probe.mjs [url] [iterations]
3+
import { Repo } from '@automerge/automerge-repo';
4+
import { WebSocketClientAdapter } from '@automerge/automerge-repo-network-websocket';
5+
6+
const url = process.argv[2] ?? 'ws://127.0.0.1:59776/ws';
7+
const iterations = Number(process.argv[3] ?? 10);
8+
9+
async function once(i) {
10+
const t0 = performance.now();
11+
const adapter = new WebSocketClientAdapter(url);
12+
const repo = new Repo({ network: [adapter] });
13+
const result = await new Promise((resolve) => {
14+
const timeout = setTimeout(() => resolve({ ok: false, ms: performance.now() - t0 }), 10000);
15+
repo.networkSubsystem.on('peer', (p) => {
16+
clearTimeout(timeout);
17+
resolve({ ok: true, ms: performance.now() - t0, peerId: p.peerId });
18+
});
19+
});
20+
adapter.disconnect();
21+
console.log(`run ${i}: ${result.ok ? 'peer in ' + result.ms.toFixed(1) + ' ms (' + result.peerId + ')' : 'TIMEOUT after ' + result.ms.toFixed(0) + ' ms'}`);
22+
return result;
23+
}
24+
25+
let failures = 0;
26+
for (let i = 0; i < iterations; i++) {
27+
const r = await once(i);
28+
if (!r.ok) failures++;
29+
}
30+
console.log(`\n${failures}/${iterations} timed out`);
31+
process.exit(0);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Mechanism proof: does a slow storage adapter delay the websocket
2+
// join/peer handshake in automerge-repo? Mirrors quarto-sync-client's
3+
// connect(): new Repo({network, storage}) then waitForPeer(5000).
4+
import { Repo } from '@automerge/automerge-repo';
5+
import { WebSocketClientAdapter } from '@automerge/automerge-repo-network-websocket';
6+
7+
const url = process.argv[2] ?? 'ws://127.0.0.1:59776/ws';
8+
const storageDelayMs = Number(process.argv[3] ?? 6000);
9+
10+
class SlowMemoryStorage {
11+
data = new Map();
12+
delay() { return new Promise(r => setTimeout(r, storageDelayMs)); }
13+
async load(key) { await this.delay(); return this.data.get(key.join('.')); }
14+
async save(key, value) { await this.delay(); this.data.set(key.join('.'), value); }
15+
async remove(key) { this.data.delete(key.join('.')); }
16+
async loadRange(prefix) {
17+
await this.delay();
18+
const p = prefix.join('.');
19+
return [...this.data.entries()]
20+
.filter(([k]) => k.startsWith(p))
21+
.map(([k, data]) => ({ key: k.split('.'), data }));
22+
}
23+
async removeRange() {}
24+
}
25+
26+
function waitForPeer(repo, timeoutMs) {
27+
return new Promise((resolve, reject) => {
28+
const t = setTimeout(() => reject(new Error('Timeout waiting for peer connection')), timeoutMs);
29+
repo.networkSubsystem.on('peer', () => { clearTimeout(t); resolve(); });
30+
});
31+
}
32+
33+
const t0 = performance.now();
34+
const adapter = new WebSocketClientAdapter(url);
35+
const repo = new Repo({ network: [adapter], storage: new SlowMemoryStorage() });
36+
try {
37+
await waitForPeer(repo, 5000);
38+
console.log(`peer in ${(performance.now() - t0).toFixed(1)} ms — storage delay ${storageDelayMs} ms did NOT block handshake`);
39+
} catch (e) {
40+
console.log(`TIMEOUT at ${(performance.now() - t0).toFixed(1)} ms with storage delay ${storageDelayMs} ms — storage gates the handshake`);
41+
}
42+
adapter.disconnect();
43+
process.exit(0);
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Instrumented variant: trace storage id() resolution and adapter.connect timing.
2+
import { Repo } from '@automerge/automerge-repo';
3+
import { WebSocketClientAdapter } from '@automerge/automerge-repo-network-websocket';
4+
5+
const url = process.argv[2] ?? 'ws://127.0.0.1:59776/ws';
6+
const storageDelayMs = Number(process.argv[3] ?? 0);
7+
const t0 = performance.now();
8+
const ts = () => (performance.now() - t0).toFixed(1) + ' ms';
9+
10+
class SlowMemoryStorage {
11+
data = new Map();
12+
delay() { return new Promise(r => setTimeout(r, storageDelayMs)); }
13+
async load(key) {
14+
await this.delay();
15+
console.log(`[${ts()}] storage.load(${key.join('.')})`);
16+
return this.data.get(key.join('.'));
17+
}
18+
async save(key, value) {
19+
await this.delay();
20+
console.log(`[${ts()}] storage.save(${key.join('.')})`);
21+
this.data.set(key.join('.'), value);
22+
}
23+
async remove() {}
24+
async loadRange(prefix) {
25+
await this.delay();
26+
console.log(`[${ts()}] storage.loadRange(${prefix.join('.')})`);
27+
return [];
28+
}
29+
async removeRange() {}
30+
}
31+
32+
const adapter = new WebSocketClientAdapter(url);
33+
const origConnect = adapter.connect.bind(adapter);
34+
adapter.connect = (...args) => {
35+
console.log(`[${ts()}] adapter.connect() called`);
36+
return origConnect(...args);
37+
};
38+
39+
const repo = new Repo({ network: [adapter], storage: new SlowMemoryStorage() });
40+
const result = await new Promise((resolve) => {
41+
const t = setTimeout(() => resolve('timeout'), 5000);
42+
repo.networkSubsystem.on('peer', (p) => { clearTimeout(t); resolve('peer:' + p.peerId); });
43+
});
44+
console.log(`[${ts()}] result: ${result}`);
45+
process.exit(0);

0 commit comments

Comments
 (0)