Every Go-identifier reference in // and /* */ comments now uses
godoc's [Name] linking syntax so pkg.go.dev and `go doc` render
them as clickable cross-references. No behaviour change.
Pattern applied across the tree:
In-package [Foo], [Foo.Bar]
Cross-package [pkg.Foo], [pkg.Foo.Bar]
Stdlib [netip.Prefix], [errors.Is], [context.Context]
Tailscale [tailcfg.MapResponse], [tailcfg.Node.CapMap],
[tailcfg.NodeAttrSuggestExitNode]
Skip rules:
- File:line refs left as plain text
- HuJSON wire keys inside backtick raw strings untouched
- ACL/policy syntax tokens (tag:foo, autogroup:self, ...) not Go
symbols, left as plain text
- JSON/OIDC wire keys, gorm tags, RFC IPv6 placeholders, markdown
link tags, decorative dividers — all left as-is
643 lines
21 KiB
Go
643 lines
21 KiB
Go
package servertest_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/netip"
|
|
"slices"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/juanfont/headscale/hscontrol/servertest"
|
|
"github.com/juanfont/headscale/hscontrol/state"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/juanfont/headscale/hscontrol/util"
|
|
"github.com/stretchr/testify/require"
|
|
"pgregory.net/rapid"
|
|
"tailscale.com/tailcfg"
|
|
)
|
|
|
|
// haPropClient bundles a TestClient with the node ID and route it
|
|
// advertises. The property test mutates connection state through
|
|
// these handles and inspects the resulting NodeStore snapshot.
|
|
type haPropClient struct {
|
|
tc *servertest.TestClient
|
|
id types.NodeID
|
|
name string
|
|
route netip.Prefix
|
|
// connected mirrors the prober's view (mapBatcher.IsConnected).
|
|
// Tracked alongside the runtime read so anti-flap invariants can
|
|
// reason about the candidate set the prober actually evaluates.
|
|
connected bool
|
|
// freshSinceReconnect is true between a successful Reconnect and
|
|
// the prober's second observation of the new SessionEpoch — the
|
|
// window in which the session-stability guard must defer instead
|
|
// of installing an Unhealthy bit. Reset by the first ProberTick
|
|
// that runs against the new session; cleared on Disconnect.
|
|
freshSinceReconnect bool
|
|
}
|
|
|
|
// checkTB embeds *testing.T to satisfy testing.TB (which requires an
|
|
// unexported method only the testing package can provide) while
|
|
// collecting Cleanup functions in a local LIFO stack. runCleanups()
|
|
// runs them when the rapid check body exits so each check's resources
|
|
// are released immediately, not at the outer test's teardown — which
|
|
// would otherwise accumulate hundreds of servers and tens of thousands
|
|
// of goroutines across a 300-check run.
|
|
//
|
|
// Fatal/Fatalf still call through to *testing.T and fail the outer
|
|
// test. Inside a rapid check use rt.Fatalf instead so rapid can shrink
|
|
// the failing op sequence.
|
|
type checkTB struct {
|
|
*testing.T
|
|
|
|
mu sync.Mutex
|
|
cleanups []func()
|
|
}
|
|
|
|
func (c *checkTB) Cleanup(fn func()) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
c.cleanups = append(c.cleanups, fn)
|
|
}
|
|
|
|
func (c *checkTB) runCleanups() {
|
|
c.mu.Lock()
|
|
cs := c.cleanups
|
|
c.cleanups = nil
|
|
c.mu.Unlock()
|
|
|
|
for _, v := range slices.Backward(cs) {
|
|
v()
|
|
}
|
|
}
|
|
|
|
// haReadvertise pushes hostinfo + approved routes again. Called after
|
|
// every Reconnect so the new poll session re-publishes the prefix. The
|
|
// controlclient.Direct carries the SetHostinfo state across
|
|
// re-registration, but pushing again removes a race where the test
|
|
// inspects PrimaryRoutes before the initial map of the new session
|
|
// landed.
|
|
func haReadvertise(
|
|
tb testing.TB,
|
|
srv *servertest.TestServer,
|
|
c *haPropClient,
|
|
) {
|
|
tb.Helper()
|
|
|
|
c.tc.Direct().SetHostinfo(&tailcfg.Hostinfo{
|
|
BackendLogID: "servertest-" + c.name,
|
|
Hostname: c.name,
|
|
RoutableIPs: []netip.Prefix{c.route},
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
_ = c.tc.Direct().SendUpdate(ctx)
|
|
|
|
_, ch, err := srv.State().SetApprovedRoutes(c.id, []netip.Prefix{c.route})
|
|
require.NoError(tb, err)
|
|
srv.App.Change(ch)
|
|
}
|
|
|
|
// readPrimaries builds the prefix→owner map by inverting
|
|
// State.GetNodePrimaryRoutes for every client in the fleet. The State
|
|
// API does not expose the raw snapshot map directly; iterating the
|
|
// known clients reconstructs it without poking at NodeStore internals.
|
|
// Any inconsistency between routes and isPrimaryRoute surfaces as a
|
|
// duplicate ownership claim and trips invariant 4.
|
|
func readPrimaries(
|
|
rt *rapid.T,
|
|
srv *servertest.TestServer,
|
|
clients []*haPropClient,
|
|
) map[netip.Prefix]types.NodeID {
|
|
rt.Helper()
|
|
|
|
out := make(map[netip.Prefix]types.NodeID)
|
|
|
|
for _, c := range clients {
|
|
for _, p := range srv.State().GetNodePrimaryRoutes(c.id) {
|
|
if prev, ok := out[p]; ok {
|
|
rt.Fatalf(
|
|
"prefix %s has two owners: %d and %d "+
|
|
"(GetNodePrimaryRoutes claimed both)",
|
|
p, prev, c.id,
|
|
)
|
|
}
|
|
|
|
out[p] = c.id
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// checkHAInvariants walks every prefix → primary mapping in the live
|
|
// snapshot and asserts the six properties documented in the test
|
|
// header. prevPrimaries is the snapshot taken before the just-applied
|
|
// op so anti-flap can compare moves.
|
|
//
|
|
//nolint:gocyclo // invariant checker over several independent properties
|
|
func checkHAInvariants(
|
|
rt *rapid.T,
|
|
srv *servertest.TestServer,
|
|
clients []*haPropClient,
|
|
prevPrimaries map[netip.Prefix]types.NodeID,
|
|
skipAntiFlap bool,
|
|
) {
|
|
rt.Helper()
|
|
|
|
st := srv.State()
|
|
primaries := readPrimaries(rt, srv, clients)
|
|
|
|
// Collect every prefix advertised by the fleet so the all-down
|
|
// honesty check can iterate them, even prefixes with zero current
|
|
// advertisers.
|
|
prefixSet := make(map[netip.Prefix]struct{})
|
|
for _, c := range clients {
|
|
prefixSet[c.route] = struct{}{}
|
|
}
|
|
|
|
// Build the candidate set per prefix from the NodeStore: a node is a
|
|
// candidate when it is IsOnline=true and AllApprovedRoutes contains
|
|
// the prefix. This is exactly the input to electPrimaryRoutes; the
|
|
// prober's mapBatcher.IsConnected view is a separate gate the
|
|
// prober consults before dispatching a probe, not part of the
|
|
// election input.
|
|
advertisersByPrefix := make(map[netip.Prefix][]types.NodeID)
|
|
|
|
for _, c := range clients {
|
|
nv, ok := st.GetNodeByID(c.id)
|
|
if !ok || !nv.Valid() {
|
|
continue
|
|
}
|
|
|
|
online, known := nv.IsOnline().GetOk()
|
|
if !known || !online {
|
|
continue
|
|
}
|
|
|
|
for _, p := range nv.AllApprovedRoutes() {
|
|
if p == c.route {
|
|
advertisersByPrefix[p] = append(advertisersByPrefix[p], c.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Invariant 4: isPrimaryRoute must be consistent with routes.
|
|
for prefix, owner := range primaries {
|
|
got := srv.State().GetNodePrimaryRoutes(owner)
|
|
if !slices.Contains(got, prefix) {
|
|
rt.Fatalf(
|
|
"prefix %s primary=%d but GetNodePrimaryRoutes(%d)=%v missing it",
|
|
prefix, owner, owner, got,
|
|
)
|
|
}
|
|
}
|
|
|
|
for prefix, owner := range primaries {
|
|
nv, ok := st.GetNodeByID(owner)
|
|
if !ok || !nv.Valid() {
|
|
rt.Fatalf(
|
|
"prefix %s primary %d missing from NodeStore",
|
|
prefix, owner,
|
|
)
|
|
}
|
|
|
|
// Invariant 1: primary IsOnline in the NodeStore view.
|
|
// state.Disconnect's 10s grace can leave a recently-disconnected
|
|
// node with IsOnline=true; the structural rule still requires
|
|
// the bit before the snapshot may keep it as primary.
|
|
online, known := nv.IsOnline().GetOk()
|
|
if !known || !online {
|
|
rt.Fatalf(
|
|
"prefix %s primary %d is not online (IsOnline=%v known=%v)",
|
|
prefix, owner, online, known,
|
|
)
|
|
}
|
|
|
|
// Invariant 2: primary advertises the prefix and is among the
|
|
// snapshot's candidate set.
|
|
approved := nv.AllApprovedRoutes()
|
|
if !slices.Contains(approved, prefix) {
|
|
rt.Fatalf(
|
|
"prefix %s primary %d does not advertise it (approved=%v)",
|
|
prefix, owner, approved,
|
|
)
|
|
}
|
|
|
|
if !slices.Contains(advertisersByPrefix[prefix], owner) {
|
|
rt.Fatalf(
|
|
"prefix %s primary %d not in advertiser set %v",
|
|
prefix, owner, advertisersByPrefix[prefix],
|
|
)
|
|
}
|
|
|
|
// Invariant 5 (anti-flap): if the previous snapshot already had
|
|
// a primary for this prefix AND that primary is still a healthy,
|
|
// online, advertising candidate, the new snapshot must keep it.
|
|
// Skipped on settle reads where we re-check without an op in
|
|
// between — the prober may run in a deferred batch and the
|
|
// election may legitimately move once on its own timeline.
|
|
if !skipAntiFlap {
|
|
if prev, hadPrev := prevPrimaries[prefix]; hadPrev && prev != owner {
|
|
prevNV, exists := st.GetNodeByID(prev)
|
|
if exists && prevNV.Valid() {
|
|
prevOnline, prevKnown := prevNV.IsOnline().GetOk()
|
|
prevApproved := prevNV.AllApprovedRoutes()
|
|
|
|
stillCandidate := prevKnown && prevOnline &&
|
|
slices.Contains(prevApproved, prefix)
|
|
stillHealthy := st.IsNodeHealthy(prev)
|
|
|
|
if stillCandidate && stillHealthy {
|
|
rt.Fatalf(
|
|
"prefix %s flapped primary %d -> %d "+
|
|
"while previous primary was still a healthy candidate",
|
|
prefix, prev, owner,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Invariant 6 (all-down honesty): if every candidate of a prefix is
|
|
// either offline OR unhealthy, the snapshot must either drop the
|
|
// primary OR keep one that is still a candidate from the same set
|
|
// (the all-unhealthy preserve-prev rule). A primary pointed at a
|
|
// non-candidate after every candidate has gone dark would point
|
|
// peers at a node the prober has already declared unreachable.
|
|
for prefix := range prefixSet {
|
|
owner, hasPrimary := primaries[prefix]
|
|
if !hasPrimary {
|
|
continue
|
|
}
|
|
|
|
candidates := advertisersByPrefix[prefix]
|
|
|
|
// Empty candidates with a primary present trips invariant 2
|
|
// above; reaching this branch with len(candidates) == 0 means
|
|
// the snapshot is internally inconsistent.
|
|
if len(candidates) == 0 {
|
|
rt.Fatalf(
|
|
"prefix %s has primary %d but no advertiser in the snapshot",
|
|
prefix, owner,
|
|
)
|
|
}
|
|
|
|
anyHealthy := slices.ContainsFunc(candidates, st.IsNodeHealthy)
|
|
|
|
// Healthy preference: a primary should not be unhealthy when a
|
|
// healthy candidate exists. Asserting this through the real
|
|
// prober → State → NodeStore seam catches any divergence the
|
|
// unit-level model would miss.
|
|
if anyHealthy && !st.IsNodeHealthy(owner) {
|
|
rt.Fatalf(
|
|
"prefix %s primary %d unhealthy but %v has a healthy candidate",
|
|
prefix, owner, candidates,
|
|
)
|
|
}
|
|
|
|
// All-unhealthy guards: with every candidate unhealthy, the
|
|
// election has exactly two correct choices:
|
|
// - preserve prev primary when it is still a candidate
|
|
// - leave the prefix unmapped (no candidate fallback)
|
|
//
|
|
// Any other outcome points peers at a node already declared
|
|
// unreachable.
|
|
if !anyHealthy {
|
|
prevOwner, hadPrev := prevPrimaries[prefix]
|
|
prevStillCandidate := hadPrev &&
|
|
slices.Contains(candidates, prevOwner)
|
|
|
|
if prevStillCandidate && owner != prevOwner {
|
|
rt.Fatalf(
|
|
"prefix %s all-unhealthy election picked %d, "+
|
|
"but prev primary %d is still a candidate — "+
|
|
"preserve-prev rule violated",
|
|
prefix, owner, prevOwner,
|
|
)
|
|
}
|
|
|
|
if !prevStillCandidate && (!hadPrev || owner != prevOwner) {
|
|
rt.Fatalf(
|
|
"prefix %s all-unhealthy fallback elected %d "+
|
|
"but prev primary %v is gone; election "+
|
|
"must leave the prefix unmapped",
|
|
prefix, owner, prevOwner,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// snapshotPrimaries returns a defensive copy of the live primary
|
|
// route map so the caller can compare to a later snapshot without
|
|
// aliasing. Inverts GetNodePrimaryRoutes since State does not expose
|
|
// the raw snapshot map.
|
|
func snapshotPrimaries(
|
|
rt *rapid.T,
|
|
srv *servertest.TestServer,
|
|
clients []*haPropClient,
|
|
) map[netip.Prefix]types.NodeID {
|
|
return readPrimaries(rt, srv, clients)
|
|
}
|
|
|
|
// TestHAProberProperty drives a real Headscale TestServer with a small
|
|
// fleet of HA-route-advertising clients through a randomised sequence
|
|
// of connect / disconnect / reconnect / prober-tick operations and
|
|
// asserts that the live PrimaryRoutes() snapshot honours every HA
|
|
// invariant after each step.
|
|
//
|
|
// Coverage versus the unit-level NodeStore property test:
|
|
//
|
|
// - This test exercises the FULL seam: real prober → real PingNode
|
|
// dispatch → real responseCh handling → State.BatchSetNodeHealth
|
|
// → NodeStore election → mapper batcher updates. The unit test
|
|
// bypasses the prober entirely.
|
|
//
|
|
// - Disconnect/Reconnect goes through the real poll-session
|
|
// lifecycle: mapBatcher.RemoveNode flips IsConnected, the 10s
|
|
// grace period defers state.Disconnect, and state.Connect bumps
|
|
// SessionEpoch on rejoin. The prober defers fresh-session probes
|
|
// so a reconnect mid-cycle does not install a stale Unhealthy.
|
|
//
|
|
// - The batched BatchSetNodeHealth is observable through the
|
|
// all-unhealthy preserve-prev invariant: per-call writes would
|
|
// publish an intermediate "one unhealthy, one healthy" snapshot,
|
|
// leaving primary on a non-prev node after both flips land.
|
|
//
|
|
// - The all-unhealthy fallback that leaves prefixes unmapped is
|
|
// covered by the honesty invariant: with every candidate
|
|
// unhealthy AND prev gone from the candidate set, the prefix
|
|
// must stay unmapped instead of pinned to any candidate.
|
|
//
|
|
// Caveat: TestClient is a real controlclient.Direct that responds to
|
|
// PingRequest over Noise, so the timeout path of the prober rarely
|
|
// fires. The session-stability guard is asserted
|
|
// (freshSinceReconnect tracking + healthy assertion after the first
|
|
// probe of a new session) but the tight race —
|
|
// probe-against-old-session-while-reconnecting — needs a dedicated
|
|
// reconnect test to trigger deterministically.
|
|
//
|
|
// Ops drawn by rapid:
|
|
//
|
|
// - ClientDisconnect(i) — cancel poll session, flips IsConnected
|
|
// - ClientReconnect(i) — re-register and start new poll session
|
|
// - ProberTick() — invoke prober.ProbeOnce synchronously
|
|
// - WaitForSnapshot() — re-check invariants without a new op
|
|
//
|
|
// Initial connect happens once during setup so the fleet always has
|
|
// the same baseline. Drawing ClientConnect in the op loop would
|
|
// require setting up auth keys and waiting for peer convergence
|
|
// inside the rapid body, which would dominate the per-check budget
|
|
// for little extra coverage.
|
|
func TestHAProberProperty(t *testing.T) {
|
|
// Per-check wall-cost is ~15-25s (real Noise handshake on each
|
|
// reconnect), so the default 100-check rapid budget blows past
|
|
// the 10-minute go test timeout used by the Tests workflow.
|
|
// Skip on CI by default so the everyday sweep stays fast; runs
|
|
// locally so seed shrinking works without an opt-in flag.
|
|
if util.IsCI() {
|
|
t.Skip("skipping HA prober property test in CI; runs locally")
|
|
}
|
|
|
|
if testing.Short() {
|
|
t.Skip("skipping property test in short mode")
|
|
}
|
|
|
|
rapid.Check(t, func(rt *rapid.T) {
|
|
// Fresh server per rapid check: rapid shrinks by replaying ops
|
|
// from a clean state, so we cannot reuse a server across runs.
|
|
// Cleanups registered against checkTB run at the end of THIS
|
|
// check so server resources are released before the next one
|
|
// starts.
|
|
tb := &checkTB{T: t}
|
|
defer tb.runCleanups()
|
|
|
|
srv := servertest.NewServer(tb)
|
|
user := srv.CreateUser(tb, "ha-prop")
|
|
|
|
// Three HA candidates is the smallest set that exercises every
|
|
// election shape (primary, secondary, tertiary) while keeping
|
|
// per-check setup under ~10 seconds. Higher numbers blow out
|
|
// the wall-clock budget without adding new failure modes — the
|
|
// dual-disconnect and reconnect-during-probe regressions all
|
|
// reproduce at N=3.
|
|
const numClients = 3
|
|
|
|
route := netip.MustParsePrefix("10.200.0.0/24")
|
|
|
|
clients := make([]*haPropClient, 0, numClients)
|
|
|
|
for i := range numClients {
|
|
name := fmt.Sprintf("ha-prop-r%d", i+1)
|
|
tc := servertest.NewClient(tb, srv, name, servertest.WithUser(user))
|
|
|
|
// Wait for at least the initial netmap so the registration
|
|
// committed and findNodeID is guaranteed to see the node.
|
|
tc.WaitForUpdate(tb, 10*time.Second)
|
|
|
|
id := findNodeID(tb, srv, name)
|
|
|
|
c := &haPropClient{
|
|
tc: tc,
|
|
id: id,
|
|
name: name,
|
|
route: route,
|
|
connected: true,
|
|
}
|
|
clients = append(clients, c)
|
|
|
|
haReadvertise(tb, srv, c)
|
|
}
|
|
|
|
// Prober uses a short timeout so each ProberTick op drains
|
|
// the cycle in well under a second. TestClient is backed by a
|
|
// real controlclient.Direct that DOES respond to PingRequest
|
|
// over Noise (see TestPingNode), so most probes record a
|
|
// successful response. The probe-timeout path still fires for
|
|
// nodes whose poll session is mid-bounce — exactly the seam
|
|
// the session-stability guard was built for — but timing it
|
|
// reliably from outside the prober is hard; the property test
|
|
// focuses on the steady-state election invariants instead.
|
|
prober := state.NewHAHealthProber(
|
|
srv.State(),
|
|
types.HARouteConfig{
|
|
ProbeInterval: 30 * time.Second,
|
|
ProbeTimeout: 50 * time.Millisecond,
|
|
},
|
|
srv.URL,
|
|
srv.App.MapBatcher().IsConnected,
|
|
)
|
|
|
|
// Sanity: every client should now be an advertiser of route,
|
|
// and the snapshot must have an HA primary already.
|
|
require.Eventually(tb, func() bool {
|
|
for _, c := range clients {
|
|
if slices.Contains(
|
|
srv.State().GetNodePrimaryRoutes(c.id),
|
|
route,
|
|
) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}, 10*time.Second, 50*time.Millisecond,
|
|
"setup: route should have a primary before drawing ops")
|
|
|
|
idxGen := rapid.IntRange(0, numClients-1)
|
|
opGen := rapid.IntRange(0, 3)
|
|
opCount := rapid.IntRange(15, 35).Draw(rt, "opCount")
|
|
|
|
for step := range opCount {
|
|
op := opGen.Draw(rt, fmt.Sprintf("op_%d", step))
|
|
|
|
prev := snapshotPrimaries(rt, srv, clients)
|
|
|
|
// Anti-flap is meaningful only across operations that do
|
|
// not themselves mutate the candidate set or per-node
|
|
// health. Connect/Disconnect both mutate IsOnline (via
|
|
// state.Connect, or via the asynchronous grace-period
|
|
// state.Disconnect), and the resulting election move is
|
|
// expected, not a flap. ProbeOnce, by contrast, must keep
|
|
// the primary on the previous owner whenever that owner
|
|
// is still a candidate — this is what the reconnect-
|
|
// during-probe and dual-disconnect guards preserve.
|
|
isProberOp := op == 2
|
|
|
|
switch op {
|
|
case 0: // ClientDisconnect
|
|
idx := idxGen.Draw(rt, fmt.Sprintf("disc_idx_%d", step))
|
|
c := clients[idx]
|
|
|
|
// Refuse to disconnect the last connected node. The
|
|
// prober's HANodes gate requires ≥2 online candidates
|
|
// per prefix; with only one node left, no probe runs
|
|
// and the prober-driven invariants (anti-flap,
|
|
// reconnect-defer) become unfalsifiable. Keeping at
|
|
// least two candidates is enough to exercise every
|
|
// failure shape we care about.
|
|
connectedCount := 0
|
|
|
|
for _, cc := range clients {
|
|
if cc.connected {
|
|
connectedCount++
|
|
}
|
|
}
|
|
|
|
if c.connected && connectedCount > 2 {
|
|
c.tc.Disconnect(tb)
|
|
c.connected = false
|
|
c.freshSinceReconnect = false
|
|
}
|
|
|
|
case 1: // ClientReconnect
|
|
idx := idxGen.Draw(rt, fmt.Sprintf("rec_idx_%d", step))
|
|
c := clients[idx]
|
|
|
|
if !c.connected {
|
|
c.tc.Reconnect(tb)
|
|
|
|
// Wait for the new poll session to register with
|
|
// the batcher so the prober's IsConnected gate
|
|
// reflects the reconnect on the next ProberTick.
|
|
// WaitForUpdate adds ~1s per call; polling
|
|
// IsConnected directly converges in <100ms.
|
|
require.Eventually(
|
|
tb,
|
|
func() bool {
|
|
return srv.App.MapBatcher().
|
|
IsConnected(c.id)
|
|
},
|
|
10*time.Second,
|
|
5*time.Millisecond,
|
|
"reconnect: batcher should see %s online",
|
|
c.name,
|
|
)
|
|
|
|
c.connected = true
|
|
c.freshSinceReconnect = true
|
|
|
|
// Re-push hostinfo so the new session's initial
|
|
// map sets RoutableIPs as expected. SetApprovedRoutes
|
|
// is idempotent on the same set.
|
|
haReadvertise(tb, srv, c)
|
|
}
|
|
|
|
case 2: // ProberTick — drive one synchronous probe cycle.
|
|
// Capture freshness BEFORE running the probe; the
|
|
// prober itself flips a node from "fresh" to "stable"
|
|
// when it sees the same SessionEpoch twice, and the
|
|
// "fresh sessions must defer" rule is expressed
|
|
// against the pre-probe state.
|
|
freshThisCycle := make(map[types.NodeID]bool, len(clients))
|
|
for _, c := range clients {
|
|
freshThisCycle[c.id] = c.freshSinceReconnect
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(
|
|
context.Background(),
|
|
5*time.Second,
|
|
)
|
|
|
|
// Use App.Change as the dispatcher — the prober batches
|
|
// its results through BatchSetNodeHealth and emits a
|
|
// single PolicyChange. The real wiring is what we want
|
|
// to test.
|
|
prober.ProbeOnce(ctx, srv.App.Change)
|
|
cancel()
|
|
|
|
// Session-stability rule: the first probe against a
|
|
// freshly-reconnected node must defer instead of
|
|
// installing an Unhealthy bit. In this servertest the
|
|
// TestClient is backed by a real controlclient.Direct
|
|
// that DOES respond to PingRequests over Noise, so
|
|
// the tight timing window — probe dispatched against
|
|
// an old session whose ping never returns — rarely
|
|
// opens. The rule still runs as a sanity gate: if it
|
|
// ever does fire, the freshly-reconnected node will
|
|
// surface as unhealthy after a single probe, and the
|
|
// test catches it.
|
|
for _, c := range clients {
|
|
if !freshThisCycle[c.id] || !c.connected {
|
|
continue
|
|
}
|
|
|
|
if !srv.State().IsNodeHealthy(c.id) {
|
|
rt.Fatalf(
|
|
"node %d (%s) was marked unhealthy by "+
|
|
"the first probe after reconnect; "+
|
|
"session-stability guard should have "+
|
|
"deferred",
|
|
c.id, c.name,
|
|
)
|
|
}
|
|
|
|
c.freshSinceReconnect = false
|
|
}
|
|
|
|
case 3: // WaitForSnapshot — re-check invariants without
|
|
// applying a new op. NodeStore writes are synchronous
|
|
// inside UpdateNode/UpdateNodes, so there is nothing
|
|
// to wait on; the no-op step still verifies that two
|
|
// consecutive reads (one snapshot, the same snapshot)
|
|
// stay consistent. This is invariant 3: stable
|
|
// conditions = stable primary. We skip the anti-flap
|
|
// comparison so a primary that legitimately changed
|
|
// in the prior op is not re-flagged here.
|
|
checkHAInvariants(rt, srv, clients, prev, true)
|
|
|
|
continue
|
|
}
|
|
|
|
checkHAInvariants(rt, srv, clients, prev, !isProberOp)
|
|
}
|
|
})
|
|
}
|