fix(raft-drive-async): endpoint-quiescence for bug-1197 vote-ep teardown (task 3.4a)

When the (still-default-OFF) peer-control dispatch flag is ON,
raft_cb_become_follower runs as a vtask pool worker and frees every peer
vote-ep while other pool workers may still hold those eps' data — a UAF
(bug-1197). This makes that path quiescence-safe so the flag can later be
flipped ON (deferred to the 4.x prod-flip gate; production stays OFF here).

Mechanism (no libxce-wide endpoint refcount; reuses the existing per-ep FIFO +
busy serialization):

- Deferred teardown-as-vtask: the loser loop, when dispatch is ON and the pool
  is wired, does per vote-ep endpoint_mark_tearing_down -> iomgr_unmonitor ->
  enqueue a VTASK_IO/VTASK_LIVENESS teardown vtask bound to that ep. Per-ep busy
  FIFO makes it run strictly after any in-flight/queued same-ep peer-control
  vtask; the body NULLs iod->ep before endpoint_fini+free(ep) to disarm
  run_task's post-body endpoint_unblock_tasks (which would touch the freed
  ep->lock). OFF / pool-unwired path is byte-for-byte the legacy endpoint_fini.

- RCU-like drain closes the dispatch/teardown TOCTOU window: new _Atomic int
  endpoint_t.dispatch_inflight (+ tearing_down:1 bit). The ON dispatch brackets
  its flag-check+enqueue with fetch_add/fetch_sub (seq_cst);
  endpoint_mark_tearing_down sets the flag under ep->lock, releases, then spins
  until dispatch_inflight drains to 0. So every dispatch that read the flag
  false finishes its enqueue before mark returns (FIFO-before the teardown
  vtask); any later dispatch reads the flag set and drops the packet. ep->lock
  is deliberately NOT held across the enqueue (it inverts with the taskq lock in
  vtask_eligible); the counter is the lock-free handshake. Drain is bounded: the
  held window is just a calloc + non-blocking enqueue on the single io_worker.

Still open before the production flag flip (4.x): the borrowed-ep race in
snapshot_lane_fn/configpush_lane_fn is distinct (slowlane thread vs io_worker
RDHUP teardown, not the vtask-pool FIFO) and needs its own per-standby-ep
discipline.

Tests: test_ha_vep_teardown.c — 5 ASan tests (ordering, late-event drop, run_task
unblock disarm, OFF-path unchanged, deterministic mark-drains-inflight-dispatch).
xcelib_test 282/0; controller_unit_test EXIT=0; ASan-clean. Adversarial
cpp-review: no CRITICAL/HIGH; handshake formally sound, flag-ON path race-free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S6fDCtXkkEgupu7rUah1y2
This commit is contained in:
Rongsong Shen 2026-06-26 17:37:26 +00:00
parent bd02064ebc
commit 6d510d8cf4
10 changed files with 1037 additions and 10 deletions

View file

@ -5,7 +5,9 @@
#include <openssl/err.h>
#include <openssl/ssl.h>
#include <poll.h>
#include <sched.h>
#include <signal.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@ -725,6 +727,8 @@ static int do_endpoint_init(endpoint_t* ep)
ep->iomgr = NULL;
ep->ssl = NULL;
ep->busy = 0;
ep->tearing_down = 0;
ep->dispatch_inflight = 0;
ep->ssl_handshake_done = 0;
ep->msg_handler = NULL;
ep->err_handler = NULL;
@ -1008,6 +1012,45 @@ int endpoint_is_task_blocked(endpoint_t* ep)
return (busy != 0);
}
/* bug-1197: set the tearing-down flag, then DRAIN in-flight dispatches.
*
* Once set, tearing_down is never cleared (the ep is about to be endpoint_fini'd
* + freed). The flag is set under ep->lock so any late-event dispatch decision
* (which reads it under ep->lock via endpoint_is_tearing_down) observes it.
*
* The drain closes the TOCTOU window: a dispatch increments dispatch_inflight
* (seq_cst) BEFORE reading the flag and decrements it AFTER its pool enqueue
* completes. We release ep->lock before spinning (the dispatch's flag-read
* takes ep->lock, so holding it here would deadlock the very dispatch we wait
* on), then spin until the counter is 0. When this returns, every dispatch
* that read the flag as false has finished its enqueue; since the teardown
* caller enqueues the teardown vtask only AFTER this returns, no same-ep
* peer-control vtask can be enqueued after the teardown vtask. The drained
* window is just a calloc + non-blocking enqueue on the single io_worker, so
* the spin is bounded and short. */
void endpoint_mark_tearing_down(endpoint_t* ep)
{
mylock_lock(&(ep->lock));
ep->tearing_down = 1;
mylock_unlock(&(ep->lock));
while (atomic_load_explicit(&ep->dispatch_inflight, memory_order_seq_cst)
> 0) {
sched_yield();
}
}
int endpoint_is_tearing_down(endpoint_t* ep)
{
int tearing;
mylock_lock(&(ep->lock));
tearing = ep->tearing_down;
mylock_unlock(&(ep->lock));
return (tearing != 0);
}
void endpoint_fini(endpoint_t* ep)
{
dataq_fini(&(ep->outq));