Skip to content

Send become_coordinator on graceful coordinator handover - #178

Merged
Mearman merged 5 commits into
mainfrom
feat/coordinator-handover-sender
Sep 17, 2026
Merged

Mearman merged 5 commits into
mainfrom
feat/coordinator-handover-sender

Conversation

@Mearman

@Mearman Mearman commented Sep 17, 2026

Copy link
Copy Markdown
Member

Closes #170

PeerLifecycle.handleBecomeCoordinator (the receiving side of a become_coordinator wire message) was real, wired, and unit-tested, but nothing in src/ ever sent that message -- only the crash-race path (each surviving peer independently racing to rebind the coordinator port) ever produced a new coordinator, despite the README documenting graceful handoff to the longest-running peer as live behaviour.

  • PeerLifecycle.sendCoordinatorHandover() picks the remaining peer with the earliest recorded PeerInfo.startedAt as successor and sends it become_coordinator carrying every other remaining peer, matching the peerList shape handleBecomeCoordinator already expects. A no-op when this side isn't the coordinator or no other peers remain.
  • MeshStore.shutdown() now calls it after losing the hub gateway role but before the transport closes, since the handoff message rides the peer sessions shutdown() is about to tear down.
  • Wiring this up for real exposed a genuine race the receiving path had never been exercised against end-to-end before: the outgoing coordinator's listening socket may not have finished releasing the coordinator port by the time the successor's become_coordinator handler tries to rebind it, and that dispatch is fire-and-forget in mesh-store.ts, so a bind failure there was an unhandled rejection that crashed the process. Reproduced deterministically by the real mesh-e2e.integration.test.ts on every run once the sender actually started firing (confirmed even with an isolated random port, ruling out cross-process contention as the cause). Fixed by retrying becomeCoordinator's bind on EADDRINUSE with a short backoff (retryOnAddrInUse, extracted into its own bind-retry.ts to keep wire-mesh-transport.ts under the repo's max-lines cap) -- this also benefits the pre-existing crash-race becomeCoordinator call in init(), not just the new graceful path.
  • Also randomised mesh-e2e.integration.test.ts's own coordinator port instead of a fixed literal, since a shared dev machine can have more than one concurrent run of this same file.

Test plan

  • pnpm typecheck
  • pnpm lint
  • pnpm test (full suite, 902 tests)
  • mesh-e2e.integration.test.ts run 5x in isolation to confirm the bind race is actually fixed, not just less likely

Graceful coordinator shutdown never sent a become_coordinator message,
so only the crash-race path (each surviving peer independently racing
to rebind the coordinator port) ever produced a new coordinator,
despite the README documenting handoff to the longest-running peer as
live behaviour.

sendCoordinatorHandover picks the remaining peer with the earliest
recorded startedAt as successor and sends it become_coordinator
carrying every other remaining peer, matching the peerList shape
handleBecomeCoordinator already expects. A no-op when this side isn't
the coordinator or no other peers remain.

Not yet wired into MeshStore.shutdown().
MeshStore.shutdown() stopped the stale-agent checker, dropped the hub
gateway connection, and closed the transport, but never sent the
become_coordinator handoff the README documents -- only the
crash-race path (each surviving peer racing to rebind the coordinator
port) ever actually produced a new coordinator.

shutdown() now calls PeerLifecycle.sendCoordinatorHandover() after
losing the gateway role but before the transport closes, since the
handoff message rides the peer sessions shutdown() is about to tear
down. The call is unconditional: sendCoordinatorHandover() is already
a no-op when this side isn't the coordinator.
Array.prototype.filter() already returns a fresh array, so sorting it
in place needs no further defensive spread.
Sending become_coordinator during a graceful shutdown (agent-comms#170)
races the outgoing coordinator's own listening-socket close against the
successor's rebind of the identical port: onBecomeCoordinator's dispatch
in mesh-store.ts is fire-and-forget, so a bind failure there becomes an
unhandled promise rejection that crashes the process, and the real
mesh-e2e integration test reproduced this deterministically on every
run once the sender actually started firing.

becomeCoordinator now retries its bind a bounded number of times with a
short delay whenever wireTransport.listen() rejects with EADDRINUSE,
extracted as retryOnAddrInUse in its own bind-retry.ts (kept
wire-mesh-transport.ts under the repo's max-lines cap). Retrying a
plain server bind carries none of the risk the existing comment on
MeshStore.init() documents against retrying a TLS connectToCoordinator
(a Node TLS session-cache bug) -- this is ordinary bind-after-close
backoff, and benefits the pre-existing crash-race becomeCoordinator
call in init() equally, not just the new graceful path.
E2E_PORT was a fixed literal, so two concurrent vitest runs of this
same file on the same machine (a real condition on a shared
development host running several agent-comms worktrees at once) bind
the identical port and one of them fails with EADDRINUSE. Picking a
random port per process, in a band clear of every sibling integration
test's own hardcoded port, avoids that collision. This is independent
of, and layered on top of, becomeCoordinator's own bind-retry fix: that
fix covers the real protocol-level race this test exists to exercise,
this covers cross-process port contention on a shared machine.
@Mearman
Mearman force-pushed the feat/coordinator-handover-sender branch from c6d0ffb to bd03d54 Compare September 17, 2026 17:03
@Mearman
Mearman marked this pull request as ready for review September 17, 2026 17:05
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review Completed 2026-09-17T17:11:08.801699Z bd03d54 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Mearman
Mearman merged commit bed922e into main Sep 17, 2026
6 checks passed
@Mearman
Mearman deleted the feat/coordinator-handover-sender branch September 17, 2026 17:11
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 3.10.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Graceful coordinator handover never sends become_coordinator

1 participant