--- id: "bug-1430" status: "fixed" status_updated: "2026-09-24" status_note: "" component: "controller" severity: "crash" timestamp: "2026-07-10T07:12:00Z" last_seen: "2026-09-19T09:00:00Z" occurrences: 2 files: ["src/controller/ha.c"] tags: ["asan", "use-after-free", "prevote", "delaymgr", "chaotic-soak", "raft"] related: ["bug-1423", "bug-1424"] legacy: true --- # bug-1430: AddressSanitizer: heap-use-after-free on address ... WRITE of size 4 ... #0… ## Description AddressSanitizer: heap-use-after-free on address ... WRITE of size 4 ... #0 ha_prevote_cancel_round_timeout ../controller/ha.c:6712 #1 ha_start_prevote_async ../controller/ha.c:6889 #2 ha_failover_stagger_fn ../controller/ha.c:7259 -- crashed 2 of 3 nodes during a live chaotic HA soak (10-round overlapping-kill mode) ## Environment _Not recorded (migrated from buglog.json)._ ## Reproduction _Not recorded (migrated from buglog.json)._ ## Root cause ha_prevote_round_timeout_fn (the delaymgr task body for a PreVote round's timeout) only cleared the static s_prevote_round_ctx pointer inside its 'round still in progress' branch (ha->raft.pre_vote_in_progress true). When a round instead got decided via a reply arriving through raft_on_pre_vote_reply (which clears pre_vote_in_progress without touching s_prevote_round_ctx) and the round-timeout task fired afterward anyway (a benign, expected race), the function fell straight through to free(ctx) with s_prevote_round_ctx still pointing at that about-to-be-freed memory. The next PreVote round's ha_start_prevote_async defensively calls ha_prevote_cancel_round_timeout() before arming its own timer, reading the dangling pointer and writing to old->cancelled -- UAF. Same call stack (single delaymgr thread T8) on both crashing nodes, all-100%-reproducible under chaotic-mode's rapid overlapping restarts. ## Fix ha_prevote_round_timeout_fn now does a CAS (atomic_compare_exchange_strong_explicit matching s_prevote_round_ctx against this specific ctx, clearing to NULL) UNCONDITIONALLY as the first action, before either branch -- so the static pointer is never left dangling regardless of which path is taken. Brings it in line with the already-correct pattern used by the sibling function raft_election_timeout_fn (same file), which unconditionally clears its own stored ctx pointer before checking cancelled. ## Verification _Not recorded (migrated from buglog.json)._ ## References - Related: bug-1423, bug-1424