โ† back to journal

The Score Rose One Line Too Late

2026-10-08 ยท Kit Wickham

In The Accusers Vouched for Each Other I simulated SWIM failure detection as HashiCorp's memberlist implements it, and found a way for healthy nodes to be declared dead. A node that's cut off from the network but keeps its timers running suspects its peers in silence. When several such nodes reconnect together, they count each other's suspicions as independent confirmations, the shrunken timeout is already overdue, and memberlist issues the death verdict on the spot, before the suspect can hear the accusation and refute it. Two small changes appeared to fix it. I ended that post saying all of it was a simulation and needed checking against the real library. This is that check.

Short version: the real library has the leak, at about the same outage lengths. One of my two fixes didn't work as written, for a reason the simulation couldn't show. A corrected version, together with the other fix, took false deaths to zero in every case I ran.

The setup

I ran 128 copies of memberlist v0.7.0 in one Go process, joined into one cluster, over an in-memory network I wrote for the test. Memberlist lets you plug in your own transport, so the library code is untouched: its probes, gossip, suspicion timers, TCP fallback ping and periodic full-state sync all run as shipped. The transport can cut nodes off. While a node is cut off, every UDP packet to or from it is held and delivered when the cut-off ends, and every TCP connection to or from it hangs until its timeout and fails. The node's own timers keep running. That's the "blackout" from the first post: think of a VM whose network is stalled, or a rack whose uplink drops.

Settings are memberlist's LAN defaults, except the suspicion multiplier, which I set to 5 (the default is 4) to match the Lifeguard paper and my simulation. Each run waits for the cluster to converge, cuts off 1, 8 or 32 nodes at once for a set time, then watches for 90 seconds after they reconnect. To fit more runs in, every interval and timeout runs 4ร— faster than real time, and outage lengths are reported in uncompressed seconds. I checked that compression doesn't matter where I could: with 10 nodes crashed, the median time for the first node to declare a crash was 11.9 to 12.5 seconds per run at real speed and 12.1 to 12.9 seconds at 4ร—. My simulation said 12.5. A run with an outage of practically zero length produced no false deaths.

The count I report is the same as before: healthy nodes declared dead by healthy nodes. Nodes that were actually cut off also get declared dead when the outage is long enough, which is correct, and I don't count it.

The leak is real

With memberlist as shipped, false deaths per outage (mean of 3 runs):

Outage1 node cut off8 together32 together
16 s004
24 s0575
32 s02067
40 s033385
50 s0931,088
65 s781,0542,651
90 s4583,0205,383

A single false death usually shows up as dozens in this count: when one healthy node is wrongly declared dead, most of the other healthy nodes hear about it and mark it dead too, until it refutes.

One node alone is safe until the outage passes Lifeguard's maximum suspicion timeout of 63 seconds, as designed, and as the simulation found (zero at 50 seconds, failing at 65). Nodes cut off together start failing much earlier: at 24 seconds for 8 of them and 16 seconds for 32. The simulation put those at 33 and 14 seconds. It ran outages back to back with 4 seconds between them, and these are single outages, so I'd compare where the failures start rather than the counts.

The mechanism is the one from the simulation. I logged every time a cut-off node's suspicion timer declared a healthy node dead. For outages of 50 seconds or less, with 8 or 32 nodes cut off, there were 374 such verdicts. Every one of them came after at least one confirmation from another node, and 373 fired within 2 seconds of the outage ending. That's the timer in memberlist's Confirm discovering it's already overdue and firing on the spot.

For comparison, turning Lifeguard off (fixed suspicion timeout, no probe slowdown) produces 568 false deaths per outage from a single node cut off for 16 seconds. Lifeguard is a big improvement; it just doesn't hold for correlated outages.

A fix that didn't work as written

The two changes from the first post were:

I patched both into a copy of the library, behind switches. Grace worked as expected: with 8 or 32 nodes cut off it removed nearly all false deaths up to 50-second outages (at most 3 per outage) and did nothing beyond 63 seconds, where suspicions run out during the outage itself. The health-scaled timeout barely helped. Across 54 outages from 24 to 90 seconds it cut false deaths from 43,233 to 19,016, and a single node cut off for 65 seconds still caused a false death, which the simulation said couldn't happen.

The reason is the order of two lines. When a probe fails, memberlist's probeNode marks the target as suspect, which creates the suspicion and its timer, and only afterwards, in a deferred call on the way out of the function, applies the health penalty for that failed probe. So the first suspicion a cut-off node raises is created while its score is still 0 and gets the ordinary 63-second timeout. The penalty lands a moment later. My simulation updated the score first, so it never saw this.

The fix is to apply the health scaling when the timer fires rather than when the suspicion is created. If the node is unhealthy at that moment and the scaled timeout hasn't passed, re-arm the timer for the difference:

s.timeoutFn = func() {
    if sc := s.scale.Load(); sc != nil {
        total := remainingSuspicionTime(s.n.Load(), s.k, 0, s.min, s.max)
        if extra := (*sc)(total) - time.Since(s.start); extra > 0 {
            s.timer.Reset(max(extra, PatchGrace))
            return
        }
    }
    fn(int(s.n.Load()))
}

Because both the timer and the instant-fire path in Confirm go through this function, both are covered.

Results

Three panels: healthy nodes marked dead by healthy nodes per outage, symmetric log scale from 0 to 10,000, against outage length from 24 to 130 seconds, for 1, 8 and 32 of 128 nodes cut off. memberlist as shipped: zero until 65 seconds with one node, then 78 rising to about 1,000 at 130 seconds; with 8 nodes it starts at 24 seconds and reaches about 5,000; with 32 nodes it is already 75 at 24 seconds. Adding a 2-second grace holds at or near zero up to 50 seconds for 8 and 32 nodes, then matches the shipped version. Health scaling set at creation tracks the shipped version closely. Grace plus health checked at firing is zero everywhere.

Mean of 3 runs per point. Dotted line: Lifeguard's 63-second maximum suspicion timeout. The green line sits at zero throughout. Health scaling set at creation was not run at 130 seconds.

VersionFalse deaths, 54 outages of 24 to 90 sAt 130 s, 8 / 32 nodes
memberlist as shipped43,2335,240 / 6,573
+ 2 s grace31,6335,204 / 6,612
+ health scaling at creation19,016not run
+ both of those13,527not run
+ health checked at firing68131 / 586
+ grace + health checked at firing00 / 0

54 outages: 1, 8 or 32 nodes cut off, 6 lengths, 3 runs each. Last column: mean per outage.

Checking health at firing on its own leaves a small leak with many nodes and long outages. I didn't trace those leftovers; the grace removed all of them. With both, no healthy node was declared dead in any of the 63 runs, out to 130-second outages.

The cost is in detection of real crashes, since the health score also rises when packets are lost. With 10 crashed nodes per run and 3 runs (30 crashes per cell), the median time for the first node to declare each crash:

UDP lossAs shipped: median / slowestWith both changes: median / slowest
0%12.4 / 15.4 s12.5 / 17.0 s
5%12.2 / 16.4 s12.4 / 20.5 s
20%12.2 / 14.7 s12.4 / 15.4 s

A fraction of a second on the median, and up to 4 seconds on the slowest of 30. That's small but not zero, and 30 crashes per cell can't pin down the tail. Every crashed node was eventually seen as dead by all 118 surviving nodes in every run.

One result didn't carry over from the simulation, in the reassuring direction. The simulation had Lifeguard falsely killing 0.16 nodes per node-hour under 40% packet loss. The real library, over 10 minutes at 30% and 40% UDP loss (3 runs each, about 64 node-hours per setting), had none, with or without the changes. The difference is memberlist's TCP fallback ping, which I left out of the simulation and which my test network never drops. Real TCP on a link losing 40% of its packets would be slow but would mostly get through, so I believe the direction of this, but not the exact zero.

What I'd take from it

Caveats: one process on one machine, with an in-memory network and time compressed 4ร—, checked against real speed only on crash detection. A cut-off here holds packets and delivers them late; a network that drops them instead would deliver fewer of the stale suspicions, but the cut-off node would still gossip them after reconnecting. Three runs per point is enough to see where the failures start but not for tight error bars. With the switches off, the patched library passes memberlist's own suspicion and awareness tests.