21 KiB
Design Document: ha-elect-ctx-lifecycle
Overview
Purpose: Give the HA election carrier (ha_elect_ctx_t) a real, safe lifetime instead of the current deliberate per-round leak, without touching election/PreVote/failover semantics.
Users: Controller maintainers (leak is invisible to operators today — ASAN's detect_leaks=0 default hides it — but it accumulates one carrier per failover/election/PreVote round for the life of the process).
Impact: Adds a reference count to ha_elect_ctx_t and one new generic completion hook to endpoint_t (on_fini, mirroring the existing on_connected fire-once hook). No change to when elections start, how votes are tallied, or how endpoints are torn down — this design only observes the moment a vote endpoint's teardown actually completes and releases the carrier reference that endpoint held.
Goals
- Carrier is freed exactly once, exactly when no vote endpoint can still reference it (Req 1).
- Freeing can never race a live endpoint's read of the carrier (Req 2).
- One mechanism, applied identically to the real-election path (
raft_cb_send_vote_request/ha_start_election_async) and the PreVote path (raft_cb_send_pre_vote_request/ha_start_prevote_async) (Req 3). - Zero observable change to vote tallying, freshness gates, or failover timing (Req 4).
Non-Goals
- Changing
endpoint_fini,vote_ep_teardown_async, or any teardown ordering/timing — this design adds an observation point, not a new teardown path. - Changing the PreVote protocol,
ha_elect_ctx_t's field contents, or vote-tally logic. - A general-purpose object-lifetime framework. The mechanism here is specific to this one carrier; it is not offered as reusable infrastructure for other structs.
Boundary Commitments
This Spec Owns
- The reference count field on
ha_elect_ctx_tand its increment/decrement discipline. - The two capture sites that must increment (
raft_cb_send_vote_request,raft_cb_send_pre_vote_request). - The one release path that must decrement (vote-endpoint fini completion) and the one release path that must decrement on round supersession (
ha_start_election_async,ha_start_prevote_asyncoverwritingha->elect_ctx). - The new
endpoint_t.on_finihook's existence and its firing point insideendpoint_fini(one field, one conditional call — not a new subsystem).
Out of Boundary
- The PreVote protocol itself (
ha-prevote, shipped). - Any change to election, vote-granting, or failover semantics.
endpoint_fini's existing teardown mechanics (FREEUP(ep->data), socket/SSL/packet cleanup) — untouched, just preceded by one optional hook call.- The async endpoint-teardown ordering itself (
vote_ep_teardown_async,endpoint_mark_tearing_down, the bug-1197 deferred-vtask path) — this design fires after that ordering completes, whatever path got there. - The bug-1298 endpoint
refcntmechanism — a different lifetime (theendpoint_titself) from the one this spec adds (theha_elect_ctx_tcarrier a vote endpoint'sep->datapoints into). The two refcounts are independent and must not be conflated.
Allowed Dependencies
endpoint_t(src/include/netio.h,src/lib/netio.c) — may add one field + one call site, may not change existing field semantics.ha_elect_ctx_t,vote_ep_ctx_t(src/controller/ha.h,src/controller/ha.c) — internal toha.c/ha.h, this spec's primary work area.- Existing atomics discipline on
ha->elect_ctx(atomic_store_explicit/atomic_load_explicit,memory_order_release/memory_order_acquire) — extended (one site moves toatomic_exchange_explicitto retrieve the superseded pointer), not replaced.
Revalidation Triggers
- Any new site that assigns
ep->data = <vote_ep_ctx_t*>and wires up a vote endpoint outside the two existing capture sites (would need the same increment + hook wiring). - Any change to
endpoint_fini's call sequence that could reorderFREEUP(ep->data)relative to the newon_finihook. - Any future feature that gives
endpoint_t.on_finia second consumer (must confirm the two consumers cannot conflict on the same endpoint). - Any change to the single-node / no-local-config fast-exit paths in
ha_start_election_asyncthat currentlyfree(ctx)directly without ever creating a vote endpoint (they must keep doing so — see Architecture).
Architecture
Existing Architecture Analysis
Two sibling capture functions allocate a fresh ha_elect_ctx_t each round and publish it via atomic_store_explicit(&ha->elect_ctx, ..., memory_order_release):
ha_start_election_async(real election) has two fast-exit paths that already free the ctx correctly: no-local-config bail-out and the single-node case (n_peers == 1,raft_start_electionresolves synchronously viaon_become_leader). Neither path ever creates a vote endpoint, so the ctx is never shared — a directfree(ctx)is correct today and stays correct; this design does not touch them.- The multi-node path is where the leak lives:
raft_start_electionfireson_send_vote_requestper peer, each call routing throughraft_cb_send_vote_request, whichcallocs avote_ep_ctx_t(vctx), setsvctx->ctx = ectx, attaches it asep->data, and hands the endpoint to iomgr.ha_start_prevote_async→raft_cb_send_pre_vote_requestis the structurally identical PreVote sibling. vctxis freed exactly once, generically, insideendpoint_fini(src/lib/netio.c:1171,FREEUP(ep->data)) — reached viaendpoint_unref(bug-1298 refcount hitting 0) on every teardown path: connect failure,iomgr_monitorfailure, the flag-OFF loser-path loop (ha.c:~2142), the flag-ON deferred-vtask path (vote_ep_teardown_async→vote_ep_teardown_vtask_entry), and RDHUP/err-handler paths. This is deliberately the single placevctxmemory is reclaimed; the design must not duplicate that free, only observe it. (Line numbers as of current source;g_drive_async_peerctrl_dispatch_enablednow defaults ON per raft-drive-async, so the flag-ON deferred-vtask path is the live default today — both paths still funnel throughendpoint_finiexactly once, so the mechanism is unaffected.)- The carrier itself is currently never freed for the multi-node case: the next round's
atomic_store_explicit(&ha->elect_ctx, mc, ...)(both the election and PreVote sites) silently discards the previous pointer, with a comment explicitly documenting the leak as deliberate (UAF avoidance).
Mechanism
Reference-count the carrier. One bias reference represents "this is (or was, pending drain) the published ha->elect_ctx value"; one reference per live vote_ep_ctx_t that captured it.
ha_elect_ctx_t.refcount (new, _Atomic int, starts at 1)
+1 at capture: raft_cb_send_vote_request / raft_cb_send_pre_vote_request,
at the point ep->data = vctx is committed (i.e. only for vctx instances
that actually reach a live endpoint — the pre-endpoint_get() alloc-fail
bailouts never increment, so they need no matching decrement)
-1 at vote-endpoint completion: new endpoint_t.on_fini(ep) hook, fired by
endpoint_fini() immediately before FREEUP(ep->data), for every teardown
path (connect-fail, iomgr_monitor-fail, loser-path unref, deferred
vtask teardown, RDHUP/err-handler) — all of them funnel through
endpoint_fini exactly once (bug-1298 invariant), so this is the single
correct observation point regardless of which path got there
-1 at supersession: ha_start_election_async / ha_start_prevote_async, at
the atomic_store_explicit(&ha->elect_ctx, new_ctx, ...) site — changed
to atomic_exchange_explicit to retrieve the outgoing pointer, then
release it (decrement; free at 0) instead of discarding it
free when refcount reaches 0 (pthread_mutex_destroy + sem_destroy + free),
regardless of which of the two release kinds above is the last one to fire
Why a bias reference is required (not pure per-endpoint counting): between atomic_store_explicit(&ha->elect_ctx, ctx, ...) and the first raft_cb_send_vote_request callback actually running, the carrier has zero vote-endpoint references yet but is already live and readable (e.g. ha.c:1638-1639, 2008-2009 inside raft_cb_become_leader read ha->elect_ctx without assuming any endpoint exists — e.g. single-vote-outstanding or mid-fan-out windows). A pure "count of attached endpoints" would allow a spurious free at refcount 0 before the round is superseded. The bias reference removes that window: the carrier is only reclaimed once it is both superseded and drained of endpoint references, in whichever order those two events occur.
Why a generic endpoint_t.on_fini hook (not per-call-site decrements at every endpoint_unref/teardown call): a vctx is only actually freed inside endpoint_fini, but endpoint_unref (which may or may not trigger endpoint_fini synchronously, depending on whether a pool vtask still holds a ref) is called from roughly a dozen sites in ha.c across connect-failure, iomgr-failure, the loser-path loop, and the bug-1197 deferred-teardown-vtask path. Decrementing at each call site would require knowing, at every one of those dozen sites, whether this call is the one that actually drops the count to zero — which duplicates endpoint_fini's own refcount logic and is exactly the kind of coupling Req 2.3/2.4 (uniform coverage of every free path, "including error and abort paths") is trying to make robust against drift. A single hook fired at the one true free point is correct by construction for every current and future teardown path, mirroring the existing on_connected fire-once hook already on endpoint_t.
Architecture Pattern & Boundary Map
sequenceDiagram
participant ES as ElectionStart (election or PreVote)
participant CB as SendVoteRequest callback
participant EP as VoteEndpoint
participant FI as endpoint_fini
ES->>ES: allocate carrier, refcount = 1
ES->>ES: exchange published carrier for new one, get old carrier
ES->>ES: release old carrier (decrement, free if zero)
ES->>CB: start election, fan out per peer
CB->>CB: record carrier on vctx, increment refcount
CB->>EP: attach vctx as endpoint data, set on_fini hook
Note over EP: connect, vote RPC, reply, or error -<br/>any teardown path funnels here
EP->>FI: endpoint_fini runs once refcount reaches zero
FI->>FI: call on_fini hook if set (decrement carrier refcount, free if zero)
FI->>FI: free endpoint data (existing behavior, unchanged)
Selected pattern: reference counting with a bias reference, observed via a generic completion hook — same shape as the existing bug-1298 endpoint_t.refcnt mechanism, applied one level up (the carrier a vote endpoint points into, not the endpoint itself).
Domain boundaries: carrier lifetime lives entirely in ha.c/ha.h (internal type, per research.md); the only cross-boundary touch is the one generic hook field added to endpoint_t.
Existing patterns preserved: endpoint_fini remains the single vctx free site; atomic_store_explicit(..., memory_order_release) / atomic_load_explicit(..., memory_order_acquire) discipline on ha->elect_ctx is preserved (the supersession site becomes atomic_exchange_explicit, same memory order semantics, just also yielding the old value instead of discarding it).
New components rationale: on_fini hook — needed because no existing hook fires at endpoint-teardown completion; refcount field — needed because the leak is a multi-referrer lifetime problem, not a single-owner one.
Steering compliance: no thread-role change — the hook fires on whatever thread already calls endpoint_fini today (iomgr thread or pool worker, per bug-1197's deferred-teardown-vtask path); the decrement/free is O(1) non-blocking (atomic decrement + conditional free()), so it introduces no new blocking work on any thread class.
File Structure Plan
Modified Files
src/include/netio.h— addvoid (*on_fini)(struct endpoint_t *ep);field toendpoint_t, adjacent to the existingon_connectedfield, with a doc comment cross-referencing it as the completion-side counterpart.src/lib/netio.c— inendpoint_fini, addif (ep->on_fini) { ep->on_fini(ep); }immediately before the existingFREEUP(ep->data)line (as-built: the hook is invoked unconditionally when set, not gated onep->data;vote_ep_on_finiitself null-checksep->data). No other change to the function.src/controller/ha.h— add_Atomic int refcount;toha_elect_ctx_t(next to the existingdone_postedguard field, same struct region).src/controller/ha.c:- New static helper
ha_elect_ctx_release(ha_elect_ctx_t *ctx)— decrement; on reaching 0,pthread_mutex_destroy+sem_destroy+free. Single choke point for both release kinds (endpoint completion, supersession). - New static hook function
vote_ep_on_fini(endpoint_t *ep)— readsvctx = ep->data, callsha_elect_ctx_release(vctx->ctx). Wired asep->on_finiat both capture sites, same placeendpoint_set_err_handleris already called. raft_cb_send_vote_request: initializeectx->refcountis NOT done here (ctx already exists, allocated earlier) — this site only increments (atomic_fetch_add_explicit(&ectx->refcount, 1, memory_order_relaxed)) at the pointep->data = vctxis committed, and setsep->on_fini.raft_cb_send_pre_vote_request: identical increment + hook wiring at itsep->data = vctxsite.ha_start_election_async: at carrier allocation, initializectx->refcount = 1. At the multi-node publish site, changeatomic_store_explicit(&ha->elect_ctx, ctx, memory_order_release)toatomic_exchange_explicit(&ha->elect_ctx, ctx, memory_order_acq_rel)capturing the return asold_ctx; ifold_ctxnon-NULL, callha_elect_ctx_release(old_ctx). The two existing fast-exitfree(ctx)paths (no-local-config, single-node) are unchanged — they never share the ctx, so a direct free at refcount 1→destroy is still correct (or, for consistency and to avoid a second free idiom, route them throughha_elect_ctx_release(ctx)too — same result, one less code path to reason about).ha_start_prevote_async: identicalrefcount = 1init at allocation; identicalatomic_exchange_explicit+ha_elect_ctx_release(old_ctx)at its publish site (the former "Do NOT free a prior ctx" comment site, nowha.c:~8042), comment updated to describe the new mechanism instead of documenting the leak as deliberate.
- New static helper
No New Files
This is a localized extension to two existing files (ha.c/ha.h) plus a minimal, generic addition to the shared endpoint abstraction (netio.h/netio.c). No new translation units.
System Flows
See the sequence diagram in Architecture above — it is the complete flow for both the real-election and PreVote paths (structurally identical, differing only in which capture function runs).
State transitions for ha_elect_ctx_t.refcount (per instance, monotonically decreasing after peak):
stateDiagram-v2
[*] --> Live: calloc, refcount=1 (bias)
Live --> Live: vote-ep capture (+1) — 0..n_peers-1 times
Live --> Live: vote-ep endpoint_fini completes (-1) — any order, any thread
Live --> Superseded: next round's atomic_exchange (-1, bias released)
Live --> Freed: refcount reaches 0 while still current (all endpoints drained before next round starts)
Superseded --> Freed: last outstanding vote-ep drains (-1 reaches 0)
Freed --> [*]
Requirements Traceability
| Requirement | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1 | Reclaim once all referencing vote-eps freed | ha_elect_ctx_release, refcount field |
— | refcount state diagram |
| 1.2 | No bounded wait/timeout — driven by actual release | vote_ep_on_fini hook fired from endpoint_fini |
endpoint_t.on_fini |
sequence diagram |
| 1.3 | Zero ASAN leak over many rounds under leak detection | full mechanism (all sites below) | — | validation: --detect_leaks=1 soak, new test |
| 2.1 | Never free while a vctx holds a reference | refcount discipline (+1 capture / -1 completion) | — | sequence diagram |
| 2.2 | Carrier still valid at endpoint_fini read time |
hook fires from inside endpoint_fini, before FREEUP(ep->data) — vctx itself (thus vctx->ctx) still valid at hook-call time |
endpoint_t.on_fini firing order |
sequence diagram |
| 2.3 | Same mechanism at both capture sites | raft_cb_send_vote_request, raft_cb_send_pre_vote_request both increment + wire hook |
— | Modified Files |
| 2.4 | Same mechanism on every vctx free path, incl. error/abort | single hook fires from the one true vctx free site (endpoint_fini) regardless of which teardown path reached it |
endpoint_t.on_fini |
Architecture §Mechanism rationale |
| 3.1 | Same lifetime mechanism for election and PreVote | identical refcount/ha_elect_ctx_release/vote_ep_on_fini reused by both paths |
— | Modified Files |
| 3.2 | Superseded PreVote round's carrier reclaimable like election's | ha_start_prevote_async's publish site uses the same atomic_exchange_explicit + release pattern |
— | state diagram |
| 4.1 | No change to vote-tally/quorum/freshness behavior | mechanism touches only allocation/free bookkeeping, never votes[] contents or tally logic |
— | — |
| 4.2 | No change to election/PreVote/failover timing or outcome | hook/refcount work is O(1) non-blocking, off the liveness-critical send/reply path | — | Architecture §Steering compliance |
| 4.3 | Full HA soak shows no new loss/divergence/stall | validation plan (Testing Strategy) | — | — |
Testing Strategy
- New unit test — leak closure: run N consecutive multi-node election (and separately, PreVote) rounds in the existing controller test harness under ASAN with leak detection enabled (distinct from the suite's current
detect_leaks=0default, per research.md) and assert zeroha_elect_ctx_tbytes leaked. This is Req 1.3's direct acceptance test. - New unit test — no premature free: construct a round with an in-flight vote endpoint (captured, not yet torn down) and drive a supersession (start a new round); assert the carrier is still valid (not freed) while the outstanding endpoint's teardown has not yet completed, and becomes freed only after its
on_finifires. Directly tests Req 2.1/2.2. - New unit test — both paths covered: parametrize (or duplicate) the above two tests for
ha_start_prevote_async/raft_cb_send_pre_vote_request, confirming Req 3.1/3.2 — no path left on the old leak-forever behavior. - Regression — existing election/PreVote/failover suites: run unchanged; must stay green with zero behavioral diff (Req 4.1/4.2). Any assertion change here would itself indicate a semantic regression.
- Live — full HA soak:
/xce-test-hasequential and chaotic regression post-merge (Req 4.3), watching specifically for job loss, divergence, or a liveness stall newly attributable to this change (bug-339/bug-340 vote-freshness behavior must be bit-for-bit unaffected, since the fix touches allocation/free timing, not vote-freshness computation).
Risks
| Risk | Mitigation |
|---|---|
A future second consumer of endpoint_t.on_fini on the same endpoint could silently override this one |
Not applicable today (vote endpoints have exactly one hook consumer); flagged as a Revalidation Trigger above so a future feature adding a second hook consumer on a vote endpoint is forced to notice the conflict. |
Off-by-one in the increment/decrement pairing (e.g. incrementing at vctx->ctx = ectx instead of at ep->data = vctx) could reintroduce either a leak or a premature free |
Increment is placed at the exact point the reference becomes "live" (attached to an endpoint that will reach endpoint_fini); the two pre-endpoint_get() alloc-fail bailouts never increment, matching that they never attach ep->data. Verified by the "no premature free" unit test above. |
Relaxed memory order on the increment (memory_order_relaxed) racing the release of the bias reference |
The increment happens on the same thread that is about to hand the endpoint to iomgr for connect — happens-before is already established by the existing ep->data = vctx write and subsequent endpoint_connect/iomgr_monitor calls, which themselves use acquire/release on the endpoint's own refcnt. The carrier's refcount only needs atomicity (concurrent increments from a fan-out of N peers, concurrent decrements from N endpoint_fini calls on different threads), not ordering relative to carrier contents — the carrier's field contents are fully populated before the round's atomic_store/atomic_exchange publish (memory_order_release), which is the actual publish barrier readers rely on. |