forked from lei.chen/myxce
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:
parent
bd02064ebc
commit
6d510d8cf4
10 changed files with 1037 additions and 10 deletions
|
|
@ -108,6 +108,9 @@ extern int config_schedule_activate_retry(cluster_t *c);
|
|||
static void on_vote_connected(endpoint_t *ep);
|
||||
static void on_pre_vote_connected(endpoint_t *ep);
|
||||
static int vote_ep_err_handler(void *ep_raw);
|
||||
/* bug-1197: defer a vote-ep teardown onto the per-ep FIFO of the vtask pool so
|
||||
* it runs strictly after the last live use of the ep (flag-ON loser path). */
|
||||
static void vote_ep_teardown_async(ha_state_t *ha, endpoint_t *ep);
|
||||
/* ha-single-leader-role task 2.2: shared promotion seam — every road to
|
||||
* PRIMARY registers the persist hooks and arms the own-term gate. */
|
||||
static void ha_register_persist_hooks(cluster_t *c);
|
||||
|
|
@ -1285,11 +1288,24 @@ static void raft_cb_become_follower(void *ctx, uint64_t term, int leader_idx)
|
|||
&ha->elect_ctx, memory_order_acquire);
|
||||
if (ectx) {
|
||||
for (int i = 0; i < ectx->n_peers; i++) {
|
||||
if (ectx->votes[i].ep != NULL) {
|
||||
INFO("HA: closing vote ep[%d] (loser path)", i);
|
||||
endpoint_fini(ectx->votes[i].ep);
|
||||
ectx->votes[i].ep = NULL;
|
||||
endpoint_t *ep = ectx->votes[i].ep;
|
||||
if (ep == NULL)
|
||||
continue;
|
||||
INFO("HA: closing vote ep[%d] (loser path)", i);
|
||||
if (ha_peerctrl_dispatch_enabled() && ha->pool != NULL) {
|
||||
/* bug-1197: this callback runs on a pool worker; another
|
||||
* worker may be mid-vtask (or have a queued vtask) on this
|
||||
* same ep reading ep->data. Defer teardown onto the ep's
|
||||
* per-ep FIFO so endpoint_fini+free runs strictly after the
|
||||
* last live use, and gate late dispatch (tearing_down). */
|
||||
vote_ep_teardown_async(ha, ep);
|
||||
} else {
|
||||
/* Flag OFF / pool unwired: UNCHANGED legacy behavior —
|
||||
* endpoint_fini only, no free(ep) (the struct is leaked per
|
||||
* lost election, exactly as before this commit). */
|
||||
endpoint_fini(ep);
|
||||
}
|
||||
ectx->votes[i].ep = NULL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5365,6 +5381,127 @@ static vtask_class_t peer_ctrl_classify(ha_state_t *ha, io_packet_t *pkt)
|
|||
return VTASK_LIVENESS;
|
||||
}
|
||||
|
||||
/* ── bug-1197: deferred vote-ep teardown ──────────────────────────────────────
|
||||
*
|
||||
* When g_drive_async_peerctrl_dispatch_enabled is ON, raft_cb_become_follower
|
||||
* runs on a pool worker and must endpoint_fini() every loser vote-ep. But
|
||||
* another pool worker can be mid-vtask (or have a queued vtask) on one of those
|
||||
* same eps reading ep->data (a vote_ep_ctx_t) — freeing ep->data underneath it
|
||||
* is a UAF. Instead of freeing inline we enqueue a teardown vtask BOUND to the
|
||||
* ep (VTASK_IO): the pool's per-ep busy serialization guarantees it runs after
|
||||
* any in-flight or earlier-queued same-ep vtask completes. We also mark the ep
|
||||
* tearing_down + iomgr_unmonitor it BEFORE enqueue so the io_worker drops any
|
||||
* late same-batch event instead of dispatching a new vtask onto a doomed ep. */
|
||||
|
||||
#ifdef XCE_TESTING
|
||||
/* test-only ordering probe: fired (with the real ep) at the top of the teardown
|
||||
* vtask body, before endpoint_fini, so a test can assert teardown ran strictly
|
||||
* after the last live same-ep use. NULL in production. */
|
||||
void (*ha_vep_teardown_probe)(endpoint_t *) = NULL;
|
||||
/* test-only drain probe: fired in peer_ctrl_msg_handler AFTER the tearing_down
|
||||
* check passes but BEFORE the pool enqueue (inside the dispatch_inflight
|
||||
* bracket), so a test can park a dispatch in the drained window and prove
|
||||
* endpoint_mark_tearing_down blocks until the enqueue completes. NULL in
|
||||
* production. */
|
||||
void (*ha_peerctrl_predispatch_probe)(endpoint_t *) = NULL;
|
||||
#endif
|
||||
|
||||
/* Runs on a pool worker, bound VTASK_IO to ep, strictly AFTER any in-flight or
|
||||
* queued same-ep peer-control vtask (per-ep busy FIFO). On the loser path
|
||||
* ectx->votes[i].ep is the sole reference to ep, so this owns the free. */
|
||||
static int vote_ep_teardown_vtask_entry(vtask_t *task)
|
||||
{
|
||||
io_task_data_t *iod = &task->data->u.iodata;
|
||||
endpoint_t *ep = iod->ep;
|
||||
|
||||
/* Disarm run_task's post-body endpoint_unblock_tasks: it would touch
|
||||
* ep->lock AFTER we free ep below. NULLing the task's ep makes run_task
|
||||
* skip the unblock (run_task: `if (ep != NULL) endpoint_unblock_tasks`). */
|
||||
iod->ep = NULL;
|
||||
|
||||
if (ep != NULL) {
|
||||
#ifdef XCE_TESTING
|
||||
if (ha_vep_teardown_probe)
|
||||
ha_vep_teardown_probe(ep);
|
||||
#endif
|
||||
endpoint_fini(ep);
|
||||
free(ep);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Build + enqueue the teardown vtask. Caller has ALREADY
|
||||
* endpoint_mark_tearing_down(ep) + iomgr_unmonitor(ep). Controllers never
|
||||
* self-terminate: on any alloc/enqueue failure we fail loud (WARN) and tear the
|
||||
* ep down inline — with the pool unwired or the task rejected there is no other
|
||||
* worker that could be racing this ep, so an inline endpoint_fini+free is safe. */
|
||||
static void vote_ep_teardown_enqueue(ha_state_t *ha, endpoint_t *ep)
|
||||
{
|
||||
vtaskmgr_t *pool = (vtaskmgr_t *)ha->pool;
|
||||
vtask_data_t *data = calloc(1, sizeof(*data));
|
||||
vtask_t *task = calloc(1, sizeof(*task));
|
||||
|
||||
if (pool == NULL || data == NULL || task == NULL) {
|
||||
WARN("bug-1197: vote-ep teardown enqueue unavailable (pool=%p data=%p "
|
||||
"task=%p) — tearing down inline (stay alive)",
|
||||
(void *)pool, (void *)data, (void *)task);
|
||||
free(data);
|
||||
free(task);
|
||||
endpoint_fini(ep);
|
||||
free(ep);
|
||||
return;
|
||||
}
|
||||
|
||||
data->vtype = VTASK_IO;
|
||||
data->u.iodata.ep = ep; /* drives per-ep serialization in bind_vtask */
|
||||
data->u.iodata.pkt = NULL;
|
||||
vtask_init(task, (vtask_func_t)vote_ep_teardown_vtask_entry, data);
|
||||
|
||||
/* VTASK_LIVENESS is never admission-gated, so the teardown can always be
|
||||
* enqueued even when the pool is saturated by blocking tasks. */
|
||||
if (vtask_enq_classified(pool, task, VTASK_LIVENESS) < 0) {
|
||||
WARN("bug-1197: vote-ep teardown enqueue failed — tearing down inline "
|
||||
"(stay alive)");
|
||||
free(data);
|
||||
free(task);
|
||||
endpoint_fini(ep);
|
||||
free(ep);
|
||||
}
|
||||
}
|
||||
|
||||
/* The flag-ON loser-path teardown for one vote-ep: mark tearing_down, stop epoll
|
||||
* wakeups, then defer the free onto the ep's per-ep FIFO. ORDERING (bug-1197):
|
||||
* - endpoint_mark_tearing_down sets the flag under ep->lock; the io_worker's
|
||||
* late-event gate (peer_ctrl_msg_handler, ON branch) reads it under ep->lock
|
||||
* via endpoint_is_tearing_down, so any dispatch DECISION taken after the
|
||||
* mark observes it and drops — nothing new is enqueued for this ep.
|
||||
* - iomgr_unmonitor stops FUTURE epoll events (an event already in the current
|
||||
* batch is handled by the tearing_down gate above).
|
||||
* - the teardown vtask is itself VTASK_IO bound to ep, so the pool's per-ep
|
||||
* busy serialization runs it strictly after any in-flight / earlier-queued
|
||||
* same-ep vtask completes.
|
||||
* NOTE: we cannot hold ep->lock ACROSS the dispatch enqueue (vtask_enq_classified
|
||||
* → dataq_enq takes the taskq lock, while the pool dispatcher takes ep->lock
|
||||
* UNDER the taskq lock in vtask_eligible → endpoint_is_task_blocked — an AB/BA
|
||||
* inversion). The check is therefore done under ep->lock and the enqueue
|
||||
* outside it; see the io_worker gate for the residual-window note. */
|
||||
static void vote_ep_teardown_async(ha_state_t *ha, endpoint_t *ep)
|
||||
{
|
||||
endpoint_mark_tearing_down(ep);
|
||||
if (ep->iomgr != NULL)
|
||||
iomgr_unmonitor(ep->iomgr, ep);
|
||||
vote_ep_teardown_enqueue(ha, ep);
|
||||
}
|
||||
|
||||
#ifdef XCE_TESTING
|
||||
/* ha_vote_ep_teardown_for_test: drive the flag-ON per-ep async teardown branch
|
||||
* for a single ep without staging a full election/become_follower. */
|
||||
void ha_vote_ep_teardown_for_test(ha_state_t *ha, endpoint_t *ep)
|
||||
{
|
||||
vote_ep_teardown_async(ha, ep);
|
||||
}
|
||||
#endif
|
||||
|
||||
/*
|
||||
* peer_ctrl_dispatch_to_pool (raft-drive-async task 3.1): hand the decoded
|
||||
* peer-control packet to the vtask pool and return promptly. Per-endpoint
|
||||
|
|
@ -5466,7 +5603,50 @@ static int peer_ctrl_msg_handler(void *ep_raw)
|
|||
if (!ha_peerctrl_dispatch_enabled())
|
||||
return peer_ctrl_msg_handler_inline(ep, pkt);
|
||||
|
||||
return peer_ctrl_dispatch_to_pool(ep, pkt);
|
||||
/* bug-1197 late-event gate (ON path only): if this ep is being torn down,
|
||||
* a same-batch event may still be delivered by the io_worker after the
|
||||
* teardown thread called iomgr_unmonitor. Dropping it here keeps the doomed
|
||||
* ep off the vtask pool.
|
||||
*
|
||||
* The window between the tearing_down check and the pool enqueue is closed
|
||||
* by ep->dispatch_inflight: we increment it (seq_cst) BEFORE reading the
|
||||
* flag and decrement it AFTER the enqueue completes. endpoint_mark_tearing_
|
||||
* down sets the flag (under ep->lock) and then DRAINS this counter to 0
|
||||
* before returning. So once the teardown caller's mark returns, every
|
||||
* dispatch that read the flag as false has already finished its enqueue;
|
||||
* because the teardown vtask is enqueued only AFTER mark returns, that
|
||||
* peer-control vtask is strictly FIFO-before the teardown vtask (and gates
|
||||
* on the same ep busy bit). Any dispatch starting after the flag is set
|
||||
* reads it true here and drops. No same-ep vtask is ever enqueued after the
|
||||
* teardown vtask — the window is fully closed. We must NOT hold ep->lock
|
||||
* across the enqueue (it would invert with the taskq lock, see
|
||||
* vote_ep_teardown_async); the counter is the lock-free handshake instead. */
|
||||
atomic_fetch_add_explicit(&ep->dispatch_inflight, 1, memory_order_seq_cst);
|
||||
|
||||
if (endpoint_is_tearing_down(ep)) {
|
||||
if (pkt) { free_packet(pkt); free(pkt); }
|
||||
atomic_fetch_sub_explicit(&ep->dispatch_inflight, 1,
|
||||
memory_order_seq_cst);
|
||||
return 0;
|
||||
}
|
||||
|
||||
#ifdef XCE_TESTING
|
||||
/* test-only seam: fires AFTER the flag-check passed but BEFORE the enqueue,
|
||||
* so a test can park a dispatch in exactly the drained window and prove
|
||||
* endpoint_mark_tearing_down blocks until the enqueue completes. */
|
||||
if (ha_peerctrl_predispatch_probe)
|
||||
ha_peerctrl_predispatch_probe(ep);
|
||||
#endif
|
||||
|
||||
/* NOTE (drain liveness): this call runs while dispatch_inflight is held, so
|
||||
* a concurrent endpoint_mark_tearing_down on this ep spins until it returns.
|
||||
* peer_ctrl_dispatch_to_pool only ever enqueues (non-blocking) or, on OOM,
|
||||
* runs peer_ctrl_msg_handler_inline — which must also stay non-blocking
|
||||
* (the vtask-handler no-block invariant). Keep it bounded or the drain is. */
|
||||
int rc = peer_ctrl_dispatch_to_pool(ep, pkt);
|
||||
|
||||
atomic_fetch_sub_explicit(&ep->dispatch_inflight, 1, memory_order_seq_cst);
|
||||
return rc;
|
||||
}
|
||||
|
||||
#ifdef XCE_TESTING
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue