Skip to content

fix(server): prevent dual-active - #226

Merged
ninjamarcus merged 3 commits into
devfrom
fix-active-active
May 14, 2026
Merged

fix(server): prevent dual-active#226
ninjamarcus merged 3 commits into
devfrom
fix-active-active

Conversation

@matthewcooper

Copy link
Copy Markdown
Collaborator

Summary

Root cause: TOCTOU race between CreateCluster and InitiateJoin gRPC handlers running as concurrent goroutines on node 2.

Your setup process sent both pulsectl cluster create and pulsectl cluster join to node 2 within the same second. Both handlers check ClusterCheck() (len(config.Nodes) > 0) at the start — but both saw 0 nodes because neither had finished
writing yet. Both passed the guard and proceeded. The join correctly set node 2 to Maintenance, but CreateCluster ran after and unconditionally forced Status = Active at server.go:3326, overriding it.

The log evidence was clear:

  • Node 2 line 52: generated its own cluster token — impossible if joining correctly
  • Node 2 line 60: "Member already exists" — the join had already added this node; CreateCluster ignored the warning and set Active anyway
  • Node 2 line 62: "First node activated successfully" — this code path should never run on a joining node

Fix: Added clusterInitMu sync.Mutex to Server. Both CreateCluster (line 3201) and InitiateJoin (line 4549) acquire it before their ClusterCheck() call. Whichever runs first completes normally; the second acquires the mutex and now sees
nodes > 0, returns "cluster is already configured" / "leave first", and exits without touching state.

matthewcooper and others added 3 commits May 13, 2026 22:39
…n tests

- Acquire clusterInitMu at the top of HandleNodeJoin so the tokenless
  first-node branch is serialized with CreateCluster and InitiateJoin,
  closing the same TOCTOU class via the peer-driven Join RPC path
- Log WARN on the CreateCluster and InitiateJoin rejection paths so a
  racing setup script is visible in logs instead of being silently
  absorbed as an idempotent retry
- Bound InitiateJoin's outbound Join RPC with a 30s context timeout
  derived from the request context so a hung target cannot hold
  clusterInitMu indefinitely and block concurrent CreateCluster or
  HandleNodeJoin callers
- Add tests/unit/cluster_init_race_test.go with two tests: concurrent
  CreateCluster + InitiateJoin against a configured cluster (asserts
  documented rejection messages) and a timing-based regression test
  that verifies HandleNodeJoin acquires clusterInitMu by observing it
  blocks while CreateCluster holds the lock
- Run go mod tidy to add missing go.sum entries for transitive deps
  (go.opentelemetry.io/otel, golang.org/x/sys, golang.org/x/net) that
  CI started failing on after a Makefile-driven 'go get -u' resolved
  upstream tools to newer versions
- Drop google.golang.org/grpc/cmd/protoc-gen-go-grpc from go.mod
  require block; it is a build-time tool fetched separately by the
  Makefile, not a runtime dependency
@ninjamarcus
ninjamarcus merged commit 84f0148 into dev May 14, 2026
1 check passed
@matthewcooper
matthewcooper deleted the fix-active-active branch May 14, 2026 08:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants