Skip to content

feat(server): peer re-elects to host when its host dies - #100

Merged
chrischall merged 2 commits into
mainfrom
feat/peer-rehost-on-host-loss
Jun 3, 2026
Merged

chrischall merged 2 commits into
mainfrom
feat/peer-rehost-on-host-loss

Conversation

@chrischall

Copy link
Copy Markdown
Owner

Problem

The concentrator role (host vs peer) is elected once, in FetchproxyServer.doConnect() via electRole() (a 127.0.0.1:37149 bind race). After that, ensureConnected() short-circuits forever on the live handle. A peer dials the host's WebSocket — but peer.ts only used ws 'close' to reject the first-ready gate; it never told FetchproxyServer the 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 with peer 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: expose onClose(), fired from the existing ws.once('close') handler (mirrors the onRenegotiate/onPendingPair callback pattern). Intent-agnostic — fires on any close.
  • 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; it's latched across the async WS-close window rather than reset in close()'s tail to avoid a race.
  • host.ts: close() now also closes the electRole HTTP 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: onClose fires 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 intentional peer.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 build clean across all workspaces.

Design spec: docs/superpowers/specs/2026-06-03-peer-rehost-on-host-loss-design.md.

🤖 Generated with Claude Code

chrischall and others added 2 commits June 3, 2026 17:20
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>
@chrischall chrischall added bug Release notes: Bug Fixes enhancement Release notes: Features labels Jun 3, 2026
@github-actions github-actions Bot added the auto-review Auto-review pipeline is handling this PR label Jun 3, 2026
@claude

claude Bot commented Jun 3, 2026 •

Copy link
Copy Markdown

Claude finished @chrischall's task in 6m 47s —— View job


PR Review

  • Read CLAUDE.md conventions
  • Read PR diff and changed files
  • Review for correctness, edge cases, conventions
  • Check test coverage
  • Post findings

Verdict: pass — no 🔴 Important findings. Two 🟡 nits (inline).

The fix is well-reasoned and correctly implemented. Here's the analysis:

Correctness of the closing flag + peerHandle === null guard

The two-guard approach at ws-server.ts:1103 is sound. close() sets peerHandle = null synchronously (no await between the peerHandle.close() call and the null assignment), so any delayed close event from the torn-down socket always sees a null peerHandle. The closing flag provides a fast path for the same synchronous window, and doConnect() re-arms it only after it holds the new handle — so the guard is always accurate.

host.ts HTTP server close

The leak fix (opts.httpServer.close() inside the wss.close() callback, host.ts:341) is exactly right. wss.close() stops accepting new upgrades but does not close the underlying http.Server, leaving the port bound — a same-process re-election would fail with EADDRINUSE without this. The callback nesting preserves the shutdown order (WS clients drained first, port released second).

Test coverage

Both new scenarios are covered: stranded-peer → re-elects to host, and intentional close() → does not re-elect. The peer.test.ts unit test directly verifies the onClose callback fires on WS close. The integration tests use the real electRole() bind race on isolated ports (41090/41091), consistent with the existing integration suite's pattern.

console.* discipline

No console.log/console.debug/console.info introduced — the new code paths are silent, as required by CLAUDE.md.

🟡 Nits (inline):

  1. ws-server.ts:1105 — rejectAllPending() without a custom reason emits "extension disconnected" when the actual cause is host process death; misleads diagnostics. View →
  2. docs/superpowers/specs/…:5 — spec status still says "pending implementation"; should be updated to reflect this PR. View →

this.peerHandle.onClose(() => {
if (this.closing || this.peerHandle === null) return;
this.stopKeepalive();
this.rejectAllPending();

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 — 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.

Suggested change
this.rejectAllPending();
this.rejectAllPending('fetchproxy host process died — peer re-electing');


**Date:** 2026-06-03
**Package:** `@fetchproxy/server`
**Status:** approved (design), pending implementation

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 — stale status

This spec is landing in the same PR as the implementation, so the status should reflect that.

Suggested change
**Status:** approved (design), pending implementation
**Status:** implemented (PR #100)

@chrischall chrischall added the ready-to-merge Arms auto-merge — added by the pipeline on a pass/warn verdict, never by hand label Jun 3, 2026
@chrischall
chrischall merged commit 4bd94fe into main Jun 3, 2026
21 checks passed
@chrischall
chrischall deleted the feat/peer-rehost-on-host-loss branch June 3, 2026 21:41
chrischall added a commit that referenced this pull request Jun 3, 2026
🤖 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>
chrischall added a commit to chrischall/opentable-mcp that referenced this pull request Jun 4, 2026
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>
chrischall added a commit to chrischall/creditkarma-mcp that referenced this pull request Jun 4, 2026
…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>
chrischall added a commit to chrischall/compass-mcp that referenced this pull request Jun 4, 2026
…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>
chrischall added a commit to chrischall/infinitecampus-mcp that referenced this pull request Jun 4, 2026
…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>
chrischall added a commit to chrischall/evite-mcp that referenced this pull request Jun 4, 2026
…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>
chrischall added a commit to chrischall/canvas-parent-mcp that referenced this pull request Jun 4, 2026
…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>
chrischall added a commit to chrischall/honeybook-mcp that referenced this pull request Jun 4, 2026
…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>
chrischall added a commit to chrischall/zillow-mcp that referenced this pull request Jun 4, 2026
…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>
chrischall added a commit to chrischall/redfin-mcp that referenced this pull request Jun 4, 2026
…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>
chrischall added a commit to chrischall/zola-mcp that referenced this pull request Jun 4, 2026
…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>
chrischall added a commit to chrischall/resy-mcp that referenced this pull request Jun 4, 2026
…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>
chrischall added a commit to chrischall/zola-mcp that referenced this pull request Sep 25, 2026
…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>
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 bug Release notes: Bug Fixes 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