fix(controller): stop SIGSEGV in WalCatchupEntry capnp encode (bug-1505)

controller_unit_test crashed deterministically in write_WalCatchupEntry
(test_wal_catchup_append_term_fields_round_trip) -- the same bug class
as bug-1504 (SnapshotData.internalState): this codebase's capnpc-c
backend silently no-ops Data-field encoding in generated encode_XXX()
composite helpers without zero-initializing the caller's local struct
first, leaving an uninitialized capn_ptr written onto the wire.

Confirmed zero production exposure before fixing: xce_wal_batch_encode_
raw()/wal_entry_to_wire() (request.c) never call the generic
encode_WalCatchupEntry() at all -- they memset() their own struct and
call write_WalCatchupEntry() directly with every field hand-set, so
this was 100% test-only exposure (grepped every caller of the generic
encode/decode pair; only the crashing test uses it).

Fix mirrors bug-1504: WalCatchupEntry.payload (@2) and batchPayloads
(@10) switched from Data to List(UInt8) in controller.capnp (new
batchPayloadsLen @11 companion; payload reuses the existing
wirePayloadLen @9), typemap.h's wal_catchup_entry_t field types
updated, and request.c's extract_data_bytes() widened to take a raw
capn_ptr (it already only used .p internally) so its call sites work
regardless of which Data-shaped wrapper the generated accessor
returns now. batchPayloads' single-top-level-blob shape confirmed
this does not reintroduce bug-847's per-element cross-segment
far-pointer loss -- that issue is inherent to per-element data
regardless of Data vs List(UInt8) spelling.

Strengthened test_wal_catchup_append_term_fields_round_trip with real
payload/batchPayloads content assertions -- previously never
exercised, since the test's src fields were left NULL.

Verified: controller suite 3/3 (was 2/3, crashing), scheduler suite
unaffected at 37/37 (unrelated schema area).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPPXeKSUXUqcqvzNXY1rzJ
This commit is contained in:
Rongsong Shen 2026-08-07 11:09:27 +00:00
parent 829fbe41dc
commit 1fd467d072
Signed by: rshen
SSH key fingerprint: SHA256:8Vf2pZ3h6rJa7EhGK+WaAQ9Mta/apZvteW6g9U/WKKk
7 changed files with 121 additions and 22 deletions

View file

@ -954,7 +954,29 @@ struct SnapshotTransfer $C.mapname("snapshot_transfer_t") {
struct WalCatchupEntry $C.mapname("wal_catchup_entry_t") {
lsn @0 :UInt64 $C.mapname("lsn");
entryType @1 :UInt8 $C.mapname("entry_type");
payload @2 :Data $C.mapname("payload");
# payload is List(UInt8), NOT Data (bug-1505 fix, same class as bug-1504 on
# SnapshotData.internalState -- see that struct's own comment for the full
# root cause: this codebase's capnpc-c backend silently no-ops every Data
# field's encoding in generated encode_XXX() helpers without zero-
# initializing the caller's local struct first, leaving a genuinely
# uninitialized capn_ptr written onto the wire -- a real, reproducible
# SIGSEGV in write_WalCatchupEntry, hit by
# test_wal_catchup_append_term_fields_round_trip even though that test
# never touches payload content at all (src.payload stays NULL -- the
# crash is from the OUTPUT struct's uninitialized field, independent of
# the input). List(UInt8) gets a real generated encode/decode from this
# same backend (proven precedent: FetchFileReply.content/bytesRead), so
# no hand-written accessor is needed. Reuses wirePayloadLen (@9, below)
# as its maplistcount companion -- already exactly "this sub-entry's
# payload byte count" for the bug-847 slicing use below, same value.
# NOTE: production (xce_wal_batch_encode_raw/wal_entry_to_wire, request.c)
# never goes through this generic encode_WalCatchupEntry() path at all --
# it memset()s its own struct and calls write_WalCatchupEntry() directly
# with every field hand-set (payload deliberately left at its post-memset
# zero value, per bug-847's own comment below) -- so this fix has zero
# production behavior impact, it only stops the generic codec (used by
# this one test) from crashing.
payload @2 :List(UInt8) $C.mapname("payload") $C.maplistcount("wire_payload_len");
entries @3 :List(WalCatchupEntry) $C.mapname("entries") $C.maplistcount("n_entries");
# Raft AppendEntries term fields (ha-term-safety task 1.1, Req 1.3/1.4).
# This envelope is the replication-carrying message (WAL catch-up); the four
@ -996,8 +1018,25 @@ struct WalCatchupEntry $C.mapname("wal_catchup_entry_t") {
# n_entries (0/1/N); sub-entries no longer carry their own @2 payload on
# this path (kept for any other/legacy sender; decode still handles it
# defensively as a fallback when batchPayloads is absent).
#
# NOTE (bug-1505 fix, same as `payload` above): this note's own "cross-
# segment loss" concern is about scattered PER-ELEMENT Data (or
# List(UInt8) -- identical wire representation either way in this
# codegen; the Data vs List(UInt8) schema keyword only changes whether
# THIS backend's generic encode_XXX() composite helper populates the
# field automatically, not the underlying wire type). batchPayloads
# itself is the single TOP-LEVEL blob this fix already routes around that
# exact problem with -- so switching ITS OWN keyword to List(UInt8) does
# not reintroduce bug-847's issue; it only stops the generic codec's
# SnapshotData.internalState-class no-op/uninitialized-capn_ptr crash
# (bug-1504) for this field too. batchPayloadsLen (@11, below) is its
# maplistcount companion, distinct from raw_batch_payloads_len
# (typemap.h) -- that field is populated by request.c's OWN hand-rolled
# xce_decode() extraction (a completely separate, already-correct code
# path this fix does not touch), not by this generic decode.
wirePayloadLen @9 :UInt32 $C.mapname("wire_payload_len");
batchPayloads @10 :Data $C.mapname("batch_payloads");
batchPayloads @10 :List(UInt8) $C.mapname("batch_payloads") $C.maplistcount("batch_payloads_wire_len");
batchPayloadsLen @11 :UInt32 $C.mapname("batch_payloads_wire_len");
}
struct CtrlHelloRequest $C.mapname("ctrl_hello_req_t") {