fix(config): serialize load_primary_config against concurrent invocations (bug-1476)

myconf->lock (mylock_init/fini wiring) had landed but load_primary_config()
itself was left in its RED-phase reproduction state (unguarded, decoding
directly into the shared myconf->cluster_config field), so the fix's own
regression test failed on resume: thread B raced through while thread A was
parked mid-call.

Wraps the whole free-decode-publish sequence in myconf->lock (acquired first,
released on every return path) and decodes into a local cluster_config_t*
first, publishing into myconf->cluster_config only after full decode and
validation succeed -- closing the UAF/double-free window a live 3-node ASan
soak caught (two concurrent handle_repl_config_push deliveries for the same
standby racing two load_primary_config calls).

Verified 4/4 clean full-suite runs (1036 tests, 0 failures) including the
specific race test.
This commit is contained in:
Rongsong Shen 2026-07-28 08:13:54 +00:00
parent ce8df99037
commit b08e392eeb
8 changed files with 704 additions and 31 deletions

View file

@ -611,10 +611,38 @@ static int load_cluster_config_from_file(char *file, cluster_config_t **config)
/* ── load_primary_config ────────────────────────── */
#ifdef XCE_TESTING
/* load_primary_config_race_hook: test-only synchronization seam (same
* pattern as push_thread_send_hook / xce_wal_stream_request_hook). When
* non-NULL, load_primary_config() calls this immediately after capturing the
* OLD myconf->cluster_config pointer this invocation is about to retire
* (free), right before it actually retires it -- letting a unit test
* deterministically force a SECOND, real, concurrent load_primary_config
* call through this exact window instead of depending on scheduler timing.
* NULL in production -- no behaviour change. See
* test_bug1476_concurrent_load_primary_config_no_uaf_or_double_free
* (test_config_repl.c) for the RED-phase reproduction this was built for:
* with the lock below removed, this hook deterministically reproduces the
* live ASan heap-use-after-free bug-1476 caught (two concurrent
* load_primary_config calls racing the free of the SAME myconf->
* cluster_config pointer). With the lock, a second invocation can never
* even reach this hook while the first is parked here -- it blocks
* acquiring myconf->lock before getting this far, which is the
* serialization proof the fix requires. */
void (*load_primary_config_race_hook)(void) = NULL;
#endif
int load_primary_config(cluster_t *c)
{
myconf_t *myconf = c->myconf;
/* bug-1476: serialize the whole free-decode-publish sequence against a
* concurrent load_primary_config() call for the SAME myconf_t. Held
* across every return path below, including the success path — a
* second caller must block here before it can even reach the race hook
* fired near the retire step, not merely around that step alone. */
mylock_lock(&myconf->lock);
/* Build conf dir path: <workdir>/<name> */
char conf_dir[4096];
snprintf(conf_dir, sizeof(conf_dir), "%s/%s",
@ -627,14 +655,17 @@ int load_primary_config(cluster_t *c)
INFO("No config at '%s/conf/current' (rc=%d) — "
"will fetch from primary after election",
conf_dir, rc);
mylock_unlock(&myconf->lock);
return -ENOENT;
}
/* Heap-allocate conffile so it survives past this stack frame */
free(myconf->conffile);
myconf->conffile = NULL;
if (asprintf(&myconf->conffile, "%s/conf/%s", conf_dir, fname) < 0)
if (asprintf(&myconf->conffile, "%s/conf/%s", conf_dir, fname) < 0) {
mylock_unlock(&myconf->lock);
return -ENOMEM;
}
/* Read raw bytes and check size */
uint8_t *bytes = NULL;
@ -644,6 +675,7 @@ int load_primary_config(cluster_t *c)
ERROR("Primary config '%s' unreadable: %d", myconf->conffile, rc);
free(myconf->conffile);
myconf->conffile = NULL;
mylock_unlock(&myconf->lock);
return rc;
}
@ -653,53 +685,61 @@ int load_primary_config(cluster_t *c)
memcpy(&tag, myconf->config_sha256, 8);
myconf->config_version_tag = be64toh(tag);
/* Free any prior decoded config before overwriting the pointer */
if (myconf->cluster_config) {
free_ClusterConfig(myconf->cluster_config);
free(myconf->cluster_config);
myconf->cluster_config = NULL;
}
/* Decode Cap'n Proto directly into myconf->cluster_config */
rc = load_cluster_config_from_file(myconf->conffile,
&myconf->cluster_config);
/* Decode Cap'n Proto into a LOCAL temporary first (bug-1476 fix part 2):
* decode_ClusterConfig_ptr calloc's and publishes its out-parameter
* pointer BEFORE decode_ClusterConfig populates the nested fields, so
* decoding directly into the shared myconf->cluster_config field would
* expose any other reader of that field to a live-but-incomplete
* object. Only a fully decoded and validated config is ever published
* below. */
cluster_config_t *new_config = NULL;
rc = load_cluster_config_from_file(myconf->conffile, &new_config);
if (rc != 0) {
ERROR("Primary config '%s' failed Cap'n Proto decode: %d",
myconf->conffile, rc);
free(bytes);
free(myconf->conffile);
myconf->conffile = NULL;
mylock_unlock(&myconf->lock);
return rc;
}
if (myconf->cluster_config == NULL) {
if (new_config == NULL) {
ERROR("Primary config '%s' decoded to NULL", myconf->conffile);
free(bytes);
free(myconf->conffile);
myconf->conffile = NULL;
mylock_unlock(&myconf->lock);
return -EINVAL;
}
free(bytes);
fill_default_cluster_config(myconf->cluster_config);
fill_default_cluster_config(new_config);
/* Check for reserved lost_and_found name */
if (config_contains_reserved_name(myconf->cluster_config)) {
if (config_contains_reserved_name(new_config)) {
ERROR("Config '%s' contains reserved name 'lost_and_found'",
myconf->conffile);
free_ClusterConfig(myconf->cluster_config);
free(myconf->cluster_config);
myconf->cluster_config = NULL;
free(myconf->conffile);
myconf->conffile = NULL;
free_ClusterConfig(new_config);
free(new_config);
mylock_unlock(&myconf->lock);
return -EINVAL;
}
/* Default generation to 1 if not set */
if (myconf->cluster_config->config_generation == 0)
myconf->cluster_config->config_generation = 1;
myconf->config_generation = myconf->cluster_config->config_generation;
if (new_config->config_generation == 0)
new_config->config_generation = 1;
myconf->config_generation = new_config->config_generation;
/* Publish: retire the prior config only now that the new one is fully
* decoded and validated — myconf->cluster_config never points at a
* freed or partially-built object. */
if (myconf->cluster_config) {
#ifdef XCE_TESTING
if (load_primary_config_race_hook)
load_primary_config_race_hook();
#endif
free_ClusterConfig(myconf->cluster_config);
free(myconf->cluster_config);
}
myconf->cluster_config = new_config;
/* Check staleness */
myconf->conf_stale = conf_check_stale(conf_dir, myconf->conf_stale_timeout);
@ -709,6 +749,7 @@ int load_primary_config(cluster_t *c)
INFO("Primary config loaded: tag=%016" PRIx64 " gen=%" PRIu64,
myconf->config_version_tag, myconf->config_generation);
mylock_unlock(&myconf->lock);
return 0;
}