fix(controller): CtrlHelloRequest.key Data field crashed HA leader election (bug-1835)

CtrlHelloRequest.key was declared `Data` in controller.capnp. This
project's capnp-c backend silently no-ops encoding of Data-typed fields
without zero-initializing the caller's local struct, leaving an
uninitialized capn_ptr that write_CtrlHelloRequest unconditionally wrote
to the wire -- SIGSEGV in capn_setp/copy_ptr. Hit live: ctrl-1 won its
Raft election and crashed calling ha_send_ctrlhello_to_all_standbys().

Same bug class as bug-1504/bug-1505, both already fixed by switching the
field from Data to List(UInt8); bug-1505's own writeup had flagged
CtrlHelloRequest.key as a known-but-unfixed instance. Applied the
identical fix here: Data -> List(UInt8) + keyLen companion in the schema,
uint8_t*/key_len in typemap.h. The field is unused in production (always
NULL), so no consumer code changed. Added a round-trip regression test
covering both the NULL-key production shape and real key content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-09-21 03:51:48 +00:00
parent 1e43345b2e
commit cd9ecb4159
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
7 changed files with 197 additions and 21 deletions

View file

@ -273,6 +273,111 @@ static void test_wal_catchup_append_term_fields_round_trip(void)
printf("test_wal_catchup_append_term_fields_round_trip: PASS\n");
}
/* ------------------------------------------------------------------ */
/* CtrlHelloRequest.key round-trip through the generic codec, now that */
/* it's List(UInt8) instead of Data -- this is the exact field/path that */
/* SIGSEGV'd a live 3-node HA cluster (write_CtrlHelloRequest writing an */
/* uninitialized capn_ptr for a no-op'd Data field, hit the moment a */
/* newly-elected primary called ha_send_ctrlhello_to_all_standbys()). */
/* bug-1505's own writeup had already flagged CtrlHelloRequest.key as a */
/* known-but-unfixed instance of the same bug class as bug-1504/bug-1505 */
/* above; see controller.capnp's CtrlHelloRequest.key comment for the */
/* full root cause. Covers both the actual production shape (key==NULL, */
/* the only value any current caller ever sets -- must not crash) and a */
/* real non-NULL key, to prove the codec genuinely carries content now */
/* rather than silently dropping it. */
/* ------------------------------------------------------------------ */
static void test_ctrl_hello_request_key_round_trip(void)
{
/* Case 1: key == NULL (matches ha_send_ctrlhello_to_all_standbys and
* ha.c's hello builder -- the ONLY shape any production caller uses
* today, and exactly what used to SIGSEGV via uninitialized stack). */
ctrl_hello_req_t src;
memset(&src, 0, sizeof(src));
src.name = "ctrl-1";
src.key = NULL;
src.key_len = 0;
src.applied_lsn = 100ULL;
src.config_tag = 55ULL;
struct capn c;
capn_init_malloc(&c);
struct capn_segment *cs = capn_root(&c).seg;
CtrlHelloRequest_ptr p;
encode_CtrlHelloRequest_ptr(cs, &p, &src);
capn_setp(capn_root(&c), 0, p.p);
int64_t sz = capn_size(&c);
TEST_ASSERT_TRUE(sz > 0);
uint8_t *buf = (uint8_t *)malloc((size_t)sz);
TEST_ASSERT_NOT_NULL(buf);
capn_write_mem(&c, buf, (size_t)sz, 0);
capn_free(&c);
struct capn d;
TEST_ASSERT_EQUAL_INT(0, capn_init_mem(&d, buf, (size_t)sz, 0));
CtrlHelloRequest_ptr dp;
dp.p = capn_getp(capn_root(&d), 0, 1);
struct CtrlHelloRequest wire;
read_CtrlHelloRequest(&wire, dp);
ctrl_hello_req_t dst;
memset(&dst, 0, sizeof(dst));
decode_CtrlHelloRequest(&dst, &wire);
TEST_ASSERT_EQUAL_UINT64(100ULL, dst.applied_lsn);
TEST_ASSERT_EQUAL_UINT64(55ULL, dst.config_tag);
TEST_ASSERT_EQUAL_UINT32(0, dst.key_len);
TEST_ASSERT_NULL(dst.key);
free_CtrlHelloRequest(&dst);
capn_free(&d);
free(buf);
/* Case 2: non-NULL key -- proves the wire genuinely carries content
* now, which the old no-op'd Data codec never did. */
static const uint8_t key_bytes[] = {
0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04
};
memset(&src, 0, sizeof(src));
src.name = "ctrl-2";
src.key = (uint8_t *)key_bytes;
src.key_len = (uint32_t)sizeof(key_bytes);
src.applied_lsn = 200ULL;
src.config_tag = 99ULL;
capn_init_malloc(&c);
cs = capn_root(&c).seg;
encode_CtrlHelloRequest_ptr(cs, &p, &src);
capn_setp(capn_root(&c), 0, p.p);
sz = capn_size(&c);
TEST_ASSERT_TRUE(sz > 0);
buf = (uint8_t *)malloc((size_t)sz);
TEST_ASSERT_NOT_NULL(buf);
capn_write_mem(&c, buf, (size_t)sz, 0);
capn_free(&c);
TEST_ASSERT_EQUAL_INT(0, capn_init_mem(&d, buf, (size_t)sz, 0));
dp.p = capn_getp(capn_root(&d), 0, 1);
read_CtrlHelloRequest(&wire, dp);
memset(&dst, 0, sizeof(dst));
decode_CtrlHelloRequest(&dst, &wire);
TEST_ASSERT_EQUAL_UINT64(200ULL, dst.applied_lsn);
TEST_ASSERT_EQUAL_UINT64(99ULL, dst.config_tag);
TEST_ASSERT_EQUAL_UINT32((uint32_t)sizeof(key_bytes), dst.key_len);
TEST_ASSERT_NOT_NULL(dst.key);
TEST_ASSERT_EQUAL_MEMORY(key_bytes, dst.key, sizeof(key_bytes));
free_CtrlHelloRequest(&dst);
capn_free(&d);
free(buf);
printf("test_ctrl_hello_request_key_round_trip: PASS\n");
}
/* ------------------------------------------------------------------ */
/* Test 6: Data union's new jobArray variant (job-array-single-wal-record */
/* task 1.2, Req 6.1/6.2) round-trips the compact (base jobid, array size, */
@ -1736,6 +1841,7 @@ void test_e2e_capnp(void)
RUN_TEST(test_repl_envelope_capnp_fields);
RUN_TEST(test_request_vote_last_log_tuple_round_trip);
RUN_TEST(test_wal_catchup_append_term_fields_round_trip);
RUN_TEST(test_ctrl_hello_request_key_round_trip);
RUN_TEST(test_data_job_array_variant_round_trip);
RUN_TEST(test_replay_header_snapshot_entry_round_trip);
RUN_TEST(test_session_summary_round_trip);