Repository navigation
feat(server): peer re-elects to host when its host dies - #100
Conversation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A peer whose host process died was stranded as a peer forever: role is elected once in doConnect() and ensureConnected() short-circuits on the live handle, while peer.ts only used ws close to reject the first-ready gate — it never told FetchproxyServer the host vanished. Every later verb call reused the dead handle and failed with 'peer WS closed before ready', even with the port now free. Fix (lazy teardown + re-election): - peer.ts: expose onClose(), fired from the existing ws 'close' handler. - ws-server.ts: on the peer's onClose, tear down the stranded handle and reset role so the next verb call re-elects via electRole() — becoming the new host on a freed port, or a peer of whoever grabbed it. A 'closing' flag (latched in close(), re-armed in doConnect()) distinguishes an intentional shutdown from host death. - host.ts: close() now also closes the electRole HTTP server. wss.close() leaves an externally-provided server bound, leaking the listener and blocking a same-process re-election. Tests: peer onClose unit test; integration tests for re-host on host loss and for the closing guard. Full server suite green (360). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Claude finished @chrischall's task in 6m 47s —— View job PR Review
Verdict: pass — no 🔴 Important findings. Two 🟡 nits (inline). The fix is well-reasoned and correctly implemented. Here's the analysis: Correctness of the The two-guard approach at
The leak fix ( Test coverage Both new scenarios are covered: stranded-peer → re-elects to host, and intentional
No 🟡 Nits (inline): |
| this.peerHandle.onClose(() => { | ||
| if (this.closing || this.peerHandle === null) return; | ||
| this.stopKeepalive(); | ||
| this.rejectAllPending(); |
There was a problem hiding this comment.
🟡 Nit — misleading default error message
rejectAllPending() is called without a custom reason here, so callers receive "extension disconnected" — but the actual cause is that the upstream host process died, not an extension event. This makes it harder to distinguish a peer-loss failure from a genuine extension disconnect in logs/tool errors.
| this.rejectAllPending(); | |
| this.rejectAllPending('fetchproxy host process died — peer re-electing'); |
|
|
||
| **Date:** 2026-06-03 | ||
| **Package:** `@fetchproxy/server` | ||
| **Status:** approved (design), pending implementation |
There was a problem hiding this comment.
🟡 Nit — stale status
This spec is landing in the same PR as the implementation, so the status should reflect that.
| **Status:** approved (design), pending implementation | |
| **Status:** implemented (PR #100) |
🤖 I have created a release *beep* *boop* --- ## [0.13.0](v0.12.0...v0.13.0) (2026-06-03) ### Features * **server:** peer re-elects to host when its host dies ([#100](#100)) ([4bd94fe](4bd94fe)) --- 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>
Bumps `@fetchproxy/server` 0.11.1 → 0.13.0. ## What this delivers - **v0.13.0 — host failover.** A connected peer re-elects itself to host when the current host tab dies ([fetchproxy#100](chrischall/fetchproxy#100)). The bridge no longer goes dark when the hosting tab is closed or crashes. - **v0.12.0 — per-identity pairing + connection dot.** Per-identity pairing, non-blocking scope growth, and a connection-status indicator in the extension ([fetchproxy#97](chrischall/fetchproxy#97)). ## Why `enhancement` and not `dependencies` `@fetchproxy/server` is first-party (chrischall/fetchproxy) — per the repo's release-notes convention, bumps to packages we own that ship real product improvements get `enhancement`/`feat:` so they drive a release and land under Features, not hidden under Dependencies. ## Verification - `npm install` → resolves `@fetchproxy/server@0.13.0` - `npm run build` → typecheck + bundle clean - `npm test` → 142/142 pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ng) (#53) Bumps `@fetchproxy/server` (and `@fetchproxy/bootstrap` where pinned) to `^0.13.0`. 0.13.0 brings the bridge **host-failover + per-identity pairing** work (peer re-host on host-loss, [fetchproxy#100](chrischall/fetchproxy#100)): when the server that won the bridge-host election steps down, a peer promotes to host and re-pairs `capture_request_header`, so the shared-port browser bridge keeps working regardless of which fleet server wins the election. Lockfile synced via `npm install` (pulls `@fetchproxy/protocol@0.13.0` transitively). `npm run build` + `npm test` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ng) (#108) Bumps `@fetchproxy/server` (and `@fetchproxy/bootstrap` where pinned) to `^0.13.0`. 0.13.0 brings the bridge **host-failover + per-identity pairing** work (peer re-host on host-loss, [fetchproxy#100](chrischall/fetchproxy#100)): when the server that won the bridge-host election steps down, a peer promotes to host and re-pairs `capture_request_header`, so the shared-port browser bridge keeps working regardless of which fleet server wins the election. Lockfile synced via `npm install` (pulls `@fetchproxy/protocol@0.13.0` transitively). `npm run build` + `npm test` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ng) (#55) Bumps `@fetchproxy/server` (and `@fetchproxy/bootstrap` where pinned) to `^0.13.0`. 0.13.0 brings the bridge **host-failover + per-identity pairing** work (peer re-host on host-loss, [fetchproxy#100](chrischall/fetchproxy#100)): when the server that won the bridge-host election steps down, a peer promotes to host and re-pairs `capture_request_header`, so the shared-port browser bridge keeps working regardless of which fleet server wins the election. Lockfile synced via `npm install` (pulls `@fetchproxy/protocol@0.13.0` transitively). `npm run build` + `npm test` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ng) (#22) Bumps `@fetchproxy/server` (and `@fetchproxy/bootstrap` where pinned) to `^0.13.0`. 0.13.0 brings the bridge **host-failover + per-identity pairing** work (peer re-host on host-loss, [fetchproxy#100](chrischall/fetchproxy#100)): when the server that won the bridge-host election steps down, a peer promotes to host and re-pairs `capture_request_header`, so the shared-port browser bridge keeps working regardless of which fleet server wins the election. Lockfile synced via `npm install` (pulls `@fetchproxy/protocol@0.13.0` transitively). `npm run build` + `npm test` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ng) (#55) Bumps `@fetchproxy/server` (and `@fetchproxy/bootstrap` where pinned) to `^0.13.0`. 0.13.0 brings the bridge **host-failover + per-identity pairing** work (peer re-host on host-loss, [fetchproxy#100](chrischall/fetchproxy#100)): when the server that won the bridge-host election steps down, a peer promotes to host and re-pairs `capture_request_header`, so the shared-port browser bridge keeps working regardless of which fleet server wins the election. Lockfile synced via `npm install` (pulls `@fetchproxy/protocol@0.13.0` transitively). `npm run build` + `npm test` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ng) (#60) Bumps `@fetchproxy/server` (and `@fetchproxy/bootstrap` where pinned) to `^0.13.0`. 0.13.0 brings the bridge **host-failover + per-identity pairing** work (peer re-host on host-loss, [fetchproxy#100](chrischall/fetchproxy#100)): when the server that won the bridge-host election steps down, a peer promotes to host and re-pairs `capture_request_header`, so the shared-port browser bridge keeps working regardless of which fleet server wins the election. Lockfile synced via `npm install` (pulls `@fetchproxy/protocol@0.13.0` transitively). `npm run build` + `npm test` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ng) (#130) Bumps `@fetchproxy/server` (and `@fetchproxy/bootstrap` where pinned) to `^0.13.0`. 0.13.0 brings the bridge **host-failover + per-identity pairing** work (peer re-host on host-loss, [fetchproxy#100](chrischall/fetchproxy#100)): when the server that won the bridge-host election steps down, a peer promotes to host and re-pairs `capture_request_header`, so the shared-port browser bridge keeps working regardless of which fleet server wins the election. Lockfile synced via `npm install` (pulls `@fetchproxy/protocol@0.13.0` transitively). `npm run build` + `npm test` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ng) (#105) Bumps `@fetchproxy/server` (and `@fetchproxy/bootstrap` where pinned) to `^0.13.0`. 0.13.0 brings the bridge **host-failover + per-identity pairing** work (peer re-host on host-loss, [fetchproxy#100](chrischall/fetchproxy#100)): when the server that won the bridge-host election steps down, a peer promotes to host and re-pairs `capture_request_header`, so the shared-port browser bridge keeps working regardless of which fleet server wins the election. Lockfile synced via `npm install` (pulls `@fetchproxy/protocol@0.13.0` transitively). `npm run build` + `npm test` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ng) (#57) Bumps `@fetchproxy/server` (and `@fetchproxy/bootstrap` where pinned) to `^0.13.0`. 0.13.0 brings the bridge **host-failover + per-identity pairing** work (peer re-host on host-loss, [fetchproxy#100](chrischall/fetchproxy#100)): when the server that won the bridge-host election steps down, a peer promotes to host and re-pairs `capture_request_header`, so the shared-port browser bridge keeps working regardless of which fleet server wins the election. Lockfile synced via `npm install` (pulls `@fetchproxy/protocol@0.13.0` transitively). `npm run build` + `npm test` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ng) (#52) Bumps `@fetchproxy/server` (and `@fetchproxy/bootstrap` where pinned) to `^0.13.0`. 0.13.0 brings the bridge **host-failover + per-identity pairing** work (peer re-host on host-loss, [fetchproxy#100](chrischall/fetchproxy#100)): when the server that won the bridge-host election steps down, a peer promotes to host and re-pairs `capture_request_header`, so the shared-port browser bridge keeps working regardless of which fleet server wins the election. Lockfile synced via `npm install` (pulls `@fetchproxy/protocol@0.13.0` transitively). `npm run build` + `npm test` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ng) (#57) Bumps `@fetchproxy/server` (and `@fetchproxy/bootstrap` where pinned) to `^0.13.0`. 0.13.0 brings the bridge **host-failover + per-identity pairing** work (peer re-host on host-loss, [fetchproxy#100](chrischall/fetchproxy#100)): when the server that won the bridge-host election steps down, a peer promotes to host and re-pairs `capture_request_header`, so the shared-port browser bridge keeps working regardless of which fleet server wins the election. Lockfile synced via `npm install` (pulls `@fetchproxy/protocol@0.13.0` transitively). `npm run build` + `npm test` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Problem
The concentrator role (
hostvspeer) is elected once, inFetchproxyServer.doConnect()viaelectRole()(a127.0.0.1:37149bind race). After that,ensureConnected()short-circuits forever on the live handle. A peer dials the host's WebSocket — butpeer.tsonly usedws 'close'to reject the first-ready gate; it never toldFetchproxyServerthe host vanished. So when the host process died, the peer was stranded as a peer permanently: every subsequent verb call reused the dead handle and failed withpeer WS closed before ready, even though the port was now free and it could trivially become the new host.Observed in the field: a Claude Desktop app held the bridge host (fetch-only); a second, byte-delivery-capable MCP came up as a peer. Killing the desktop host freed the port, but the peer stayed stuck with no way to recover short of a full MCP process restart.
Fix — lazy teardown + re-election
peer.ts: exposeonClose(), fired from the existingws.once('close')handler (mirrors theonRenegotiate/onPendingPaircallback pattern). Intent-agnostic — fires on any close.ws-server.ts: on the peer'sonClose, tear down the stranded handle and resetroleso the next verb call re-elects viaelectRole()— becoming the new host on a freed port, or a peer of whoever grabbed it. Aclosingflag (latched inclose(), re-armed indoConnect()) distinguishes an intentional shutdown from host death; it's latched across the async WS-close window rather than reset inclose()'s tail to avoid a race.host.ts:close()now also closes theelectRoleHTTP server.wss.close()does not close an externally-provided server, so the port stayed bound until process exit — a leaked listener, and the blocker for a same-process re-election.Lazy (re-elect on next call) was chosen over eager/background self-heal and capped-retry variants: it matches the existing lazy-connect philosophy and needs the least code. Eager self-heal and dial-retry-on-host-mid-restart are noted as future work in the design spec.
Tests (TDD)
peer.test.ts:onClosefires when the host WS closes.integration/reconnect.test.ts: a stranded peer re-elects to host on the next call after its host dies; an intentionalpeer.close()does not re-elect (closing guard).Full server package suite green (360 tests); whole repo green (762, excluding stale local worktree copies).
npm run buildclean across all workspaces.Design spec:
docs/superpowers/specs/2026-06-03-peer-rehost-on-host-loss-design.md.🤖 Generated with Claude Code