Skip to content

feat(server): lazy bridge connect — listen() loads identity only - #51

Merged
chrischall merged 3 commits into
mainfrom
fix/lazy-bridge-connect
May 26, 2026
Merged

chrischall merged 3 commits into
mainfrom
fix/lazy-bridge-connect

Conversation

@chrischall

Copy link
Copy Markdown
Owner

Summary

Defers the fetchproxy bridge connection (role election + WS bind/dial) from FetchproxyServer.listen() to the first verb call. listen() is now just a cheap identity load + mcpId generation.

Why

Today: every configured-but-unused MCP under Claude Desktop claims bridge resources at MCP-client startup. When several MCPs start in parallel they all race to bind port 37149 — only one wins host, the rest dial as peers. While the race is in flight, the browser extension's reconnect cycle produces a stream of ERR_CONNECTION_REFUSED errors in DevTools because it may reach the port before any MCP has bound it. And MCPs that never have a tool called still hold a peer WebSocket open to the host for the whole session.

After this PR: nothing touches the network until something actually needs it. Boot is quiet, idle MCPs cost zero socket descriptors, and the first-call latency in exchange is a one-time elect + handshake (sub-second on a warm extension).

API

  • listen() — unchanged signature, behavior reduced to identity load. MCP authors don't need to change anything to get the new behavior.
  • connect() — new public method. Idempotent wrapper around the internal ensureConnected. Useful for callers that want to surface the role / connection outcome at boot (most production MCPs won't need this; test harnesses do).
  • Each verb method (fetch, readCookies, readLocalStorage, readSessionStorage, captureRequestHeader, readIndexedDb) now awaits ensureConnected() at the top. The existing throwIfPendingPair / pair-code error path runs unchanged once the handle is up.
  • Concurrent first-callers share a connectingPromise mutex so two parallel verb calls don't race the port bind (mutex test included).

Tests

Two new in ws-server.test.ts:

  • listen() does not bind the port — connection is deferred — proves the role-null gap after listen() and that another instance can take the host slot in that window.
  • concurrent first verb calls share one connection election — fires two connect() calls in parallel and asserts the mutex prevents a double election.

Existing tests adapted: integration tests added await X.connect() after each await X.listen() so their mock extensions can dial a bound port. This verifies the new explicit eager path is functionally equivalent to the pre-0.5.3 implicit one.

Total: 486 tests passing (was 484, +2 new).

Test plan

  • npm test — 486/486 pass
  • npm run typecheck — clean
  • npm run build --workspaces --if-present — clean
  • Manual: launch Claude Desktop with several MCPs configured but call none — confirm none of the MCPs are connected to port 37149 (lsof -iTCP:37149). Then call one tool and confirm exactly that MCP is now bound/connected.

🤖 Generated with Claude Code

…b calls bind on demand

Before: `FetchproxyServer.listen()` did role election + startHost/startPeer
synchronously at boot. Every configured-but-unused MCP under Claude
Desktop claimed bridge resources at MCP-client startup, and multiple MCPs
starting in parallel raced to bind port 37149 — producing a flurry of
`ERR_CONNECTION_REFUSED` reconnect errors in the extension while the
losers gave up the port and dialed as peers. MCPs that never had a tool
called still held a peer WS open to the host for the whole session.

After: `listen()` is a cheap identity load + mcpId generation. The first
`fetch` / `readCookies` / `readLocalStorage` / `readSessionStorage` /
`captureRequestHeader` / `readIndexedDb` call triggers `ensureConnected`,
which runs the role election and brings up the host/peer handle exactly
once. Concurrent first-callers share an in-flight `connectingPromise`
mutex so only one election runs (no port-bind race within a single MCP
instance).

For callers that DO want eager-bind semantics — test harnesses that dial
a mock extension immediately, MCPs that want to log the role at boot —
there's a new public `connect()` method that's an idempotent wrapper
around `ensureConnected`. Production MCPs that just answer tool calls
should NOT call it; the lazy path is the supported one.

Implementation notes:
- `connect()` returns void on success, throws if `listen()` was never
  called. Calling it twice is a no-op (existing handle reused).
- `ensureConnected()` clears `connectingPromise` in `finally` so a
  transient connect failure can be retried by the next verb call rather
  than poisoning the cache.
- All four verb methods (`fetch`, `readCookies`, `readLocalStorage`,
  `readSessionStorage`, `captureRequestHeader`, `readIndexedDb`) now
  call `await this.ensureConnected()` at the top, replacing the old
  `if (!this.hostHandle && !this.peerHandle) throw ...` listen-check.
- The pre-existing `throwIfPendingPair` / `currentPendingPairCode` paths
  work unchanged once the handle is present.

Tests:
- `0.5.3+: listen() does not bind the port — connection is deferred` —
  asserts the role-null gap, then proves the port is free for another
  instance to take the host slot, then `connect()`s the first instance
  and watches it become a peer.
- `0.5.3+: concurrent first verb calls share one connection election` —
  fires two `connect()` calls in parallel; the mutex must prevent a
  double election.
- All existing tests pass: integration tests gained an explicit
  `await X.connect()` after each `await X.listen()` so the mock
  extensions in their harness can dial a bound port. Verifies the new
  eager path is functionally equivalent to the pre-0.5.3 implicit
  eager path.

Total: 486 tests passing (was 484, +2 new for lazy behavior).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@chrischall chrischall added the enhancement Release notes: Features label May 26, 2026
@github-actions github-actions Bot added the auto-review Auto-review pipeline is handling this PR label May 26, 2026
@claude

claude Bot commented May 26, 2026 •

Copy link
Copy Markdown

Claude finished @chrischall's task in 4m 10s —— View job


PR Review: feat(server): lazy bridge connect

  • Read CLAUDE.md conventions
  • Read PR diff and changed files
  • Review for correctness, edge cases, security
  • Check test coverage of new code paths
  • Post inline comments for findings
  • Post summary verdict

Summary

The lazy-connect design is sound and the implementation is clean. The connectingPromise mutex correctly serialises concurrent first-callers — the concurrent test in ws-server.test.ts exercises this well. Existing integration tests are adapted correctly (all add await X.connect() after await X.listen() so their mock extensions can dial a bound port). No correctness bugs or security issues found.

Two 🟡 nits, no 🔴 important findings.

Findings

🟡 Stale docstrings (flagged inline on line 314) — the class-level JSDoc still says "On listen(), the server races the configured port" and the role field doc says "Set after listen() succeeds." Both describe the pre-0.5.3 eager behavior and should be updated to reflect that the port race now happens lazily.

🟡 close() + in-flight doConnect() race (flagged inline on line 453) — if a verb call triggers doConnect() concurrently with close(), close() exits before the handle is established and never closes it. doConnect() then writes this.hostHandle after close() has returned, leaking the socket. Fixing it requires close() to await and clear the in-flight connectingPromise. Narrow race, not blocking.

Verdict: warn — no blocking issues, two nits.

// Always clear so a transient connect failure can be retried by
// the next verb call. Successful path: the handle is now set, so
// the next `ensureConnected` short-circuits on the first branch.
this.connectingPromise = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit — close() called while doConnect() is in flight can leak a handle

The finally correctly clears connectingPromise so a failed connection is retryable, but close() doesn't set connectingPromise = null before it exits. If a verb call triggers doConnect() and close() races it, close() will see hostHandle == null (the handle hasn't been set yet), so hostHandle.close() never runs. doConnect() then finishes and writes this.hostHandle = <live handle> after close() has already returned — the handle leaks.

One simple fix: have close() also clear connectingPromise (and optionally await it first so any in-flight election gets shut down cleanly):

async close(): Promise<void> {
  this.rejectAllPending();
  // Wait for any in-flight election before tearing down handles.
  if (this.connectingPromise) {
    await this.connectingPromise.catch(() => {});
  }
  if (this.hostHandle) await this.hostHandle.close();
  if (this.peerHandle) this.peerHandle.close();
  this.hostHandle = null;
  this.peerHandle = null;
  this.role = null;
  this.connectingPromise = null;
}

This is a narrow race (requires concurrent close() + verb call) so not blocking, but worth a follow-up.

// callers, cleared once the connection is up. Single source of truth
// for "we're connecting right now" so two parallel first-calls don't
// race the port bind.
private connectingPromise: Promise<void> | null = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit — two stale docstrings elsewhere in the class

Not in the diff but worth flagging here since this PR introduced the semantic change that makes them wrong:

  1. Class-level JSDoc (line ~260): "On listen(), the server races the configured port…" — this is the old eager behavior. Should say the port race happens lazily on the first verb call (or explicit connect()).
  2. role field JSDoc (line ~274): "Set after listen() succeeds. Null while not listening." — role is now null after listen() and only set after connect() / first verb call.

Fix this →

…ings

🟡 1. close() called mid-doConnect could leak the handle

The pre-existing close() ran `if (this.hostHandle) await this.hostHandle.close()` then nulled the handle. If a verb call's `doConnect()` was in flight when close() ran, close() observed `hostHandle == null` (the assignment hadn't happened yet), skipped the teardown, and exited. doConnect() then wrote `this.hostHandle = <live handle>` after close() had already returned — the WSS state and any listener registrations survived close().

Fix: close() now awaits `connectingPromise` (with `.catch(() => undefined)` so a failed connection doesn't propagate) before doing teardown. If doConnect succeeded, its handle assignment is visible by the time we hit the host/peer .close() lines. close() also clears `connectingPromise` at the end so a subsequent listen() + connect() after close() doesn't observe a stale promise reference.

New test `close() awaits an in-flight connect so no handle leaks` exercises the race and asserts post-close that hostHandle / peerHandle / connectingPromise are all null. Touches private fields because there's no public way to observe the leak otherwise — but the invariant is the right one to assert and a public surface would be overkill.

🟡 2. Stale docstrings

Class-level JSDoc and the `role` field doc both still described the pre-0.5.3 eager behavior:
- Class: "On `listen()`, the server races the configured port…" → now: lazy on first verb call (or explicit connect()).
- role field: "Set after `listen()` succeeds. Null while not listening." → now: null until first verb call / connect(); reset on close().

All 487 tests pass (was 486, +1 new for the race fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented May 26, 2026 •

Copy link
Copy Markdown

Claude finished @chrischall's task in 2m 34s —— View job


Claude finished re-reviewing PR #51 nit fixes —


Re-review: address PR #51 nits

  • Read CLAUDE.md conventions
  • Read updated ws-server.ts
  • Verify close()/doConnect() race fix
  • Verify stale docstrings fix
  • Post verdict

Summary

The close() race fix is correct: it awaits the in-flight connectingPromise.catch(...) before tearing down handles, so a doConnect() that completes mid-close() will have its handle picked up and properly closed. connectingPromise = null is also cleared at the end of close() (the ensureConnected finally will already have cleared it, but the redundant clear is harmless). The role field docstring is correctly updated.

One new 🟡 nit: the docstring fix accidentally inserted a bare /** on line 257, immediately before the /** that starts the class-level JSDoc on line 258. JavaScript block comments don't nest, so the outer /** (line 257) swallows the entire description — FetchproxyServer ends up with no JSDoc and IDE hover docs are silently lost. Flagged inline.

Findings

🟡 Nit — stray /** on line 257 strips FetchproxyServer of its class JSDoc (flagged inline). Remove the extra /** so the description is re-attached to the class.

Verdict: warn — close() race and role docstring are fixed correctly; one minor regression in the docstring edit itself.

Comment thread packages/server/src/ws-server.ts Outdated
error: string;
}

/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit — stray /** strips the class of its JSDoc

This line was added by the docstring fix but shouldn't be here. JavaScript block comments don't nest: the /** on line 257 opens a block comment, and the */ on line 274 closes it — which means the content from lines 258–274 (the entire updated class description) is inside that outer comment, not attached to FetchproxyServer. The class ends up with no JSDoc, so IDE hover docs (VS Code, etc.) won't show the description.

Suggested change
/**
/**
* The MCP-facing handle for the fetchproxy bridge.

Simply remove the extra /** on line 257 (i.e., collapse the two back into one /** on line 258).

PR #51's re-review caught that the docstring fix in f6f8582 accidentally
left the original opener `/**` on the line above the new docstring's
opener — two block-comment starts back-to-back. JavaScript block
comments don't nest, so the outer one swallowed the class description
and FetchproxyServer ended up with no JSDoc at all (IDE hover docs gone).

One-line fix: drop the orphan opener.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented May 26, 2026 •

Copy link
Copy Markdown

Claude finished @chrischall's task in 2m 29s —— View job


Re-review: lazy bridge connect (post-nit-fix sync)

  • Read CLAUDE.md conventions
  • Read updated ws-server.ts — all three previous nits resolved
  • Read test files — new close() race test added, coverage complete
  • Post verdict

Summary

All three findings from the previous review cycles are fully resolved in this sync:

🟡 Stray /** (bd4947e) — Removed. The class-level JSDoc is now a single /** block on line 257; FetchproxyServer has its description back in IDEs. ✓

🟡 Stale docstrings (f6f8582) — Both updated correctly. The class-level JSDoc now reads "listen() loads identity and reserves nothing. The first verb call (or an explicit connect()) races the configured port…" and the role field says "null until the first verb call (or an explicit connect()) — listen() no longer triggers the role election as of 0.5.3+." ✓

🟡 close() + in-flight doConnect() race (f6f8582) — Fixed correctly. close() now awaits this.connectingPromise.catch(() => undefined) before checking handles, so any in-flight doConnect() completes (writing this.hostHandle) before the teardown reads it. A new unit test (close() awaits an in-flight connect so no handle leaks, port 41059) covers this path directly by asserting hostHandle, peerHandle, and connectingPromise are all null post-close regardless of race order. ✓

No new issues found. Verdict: pass.

@chrischall chrischall added the ready-to-merge Arms auto-merge — added by the pipeline on a pass/warn verdict, never by hand label May 26, 2026
@chrischall
chrischall merged commit 8309c2b into main May 26, 2026
7 checks passed
@chrischall
chrischall deleted the fix/lazy-bridge-connect branch May 26, 2026 13:58
chrischall added a commit that referenced this pull request May 26, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.6.0](v0.5.1...v0.6.0)
(2026-05-26)


### Features

* **server:** lazy bridge connect — listen() loads identity only
([#51](#51))
([8309c2b](8309c2b))


### Bug Fixes

* 3 MCPs can work concurrently (peer session renegotiation + pendingPair
dict) ([#49](#49))
([4272e98](4272e98))
* **ci:** prevent labeled event from cancelling auto-review
([#47](#47))
([40bc4db](40bc4db))
* **extension:** handlers iterate ALL matching tabs instead of just the
first ([#50](#50))
([8bd437b](8bd437b))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto-review Auto-review pipeline is handling this PR enhancement Release notes: Features ready-to-merge Arms auto-merge — added by the pipeline on a pass/warn verdict, never by hand

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant