feat(ha-entity-snapshots): restore-before-advance load_snapshot implementation (task 5.2)

Replaces the naive task-5.1 stopgap with the real restore contract:
detects legacy vs entity-bearing checkpoints by remaining file size;
restores every job then every queue status via restore_job/
restore_queue_state, in file order, with bounds-checked length-prefixed
reads; only after every entity restores successfully does
current_lsn/checkpoint_lsn commit to the checkpoint's position. Every
failure path (bounds check, decode, restore error, unrecognized
format_version) returns before that single commit point -- position is
provably untouched on failure, never partially advanced.

Also fixes the pre-existing restore_job decode leak (job.c): job_init
deep-copies submit (never nulled) and MOVE_PTRs allocs/pidinfo/
pend_reasons (nulled on the one path that runs) -- free_Job(&j) is safe
unconditionally on every reachable return path (EEXIST early-return,
create_job success, create_job failure), verified against the exact
generated free_*_ptr null-check guards.

Two pre-existing test_config_repl.c wire-protocol tests needed their
synthetic garbage snapshot bodies updated to a well-formed empty-entity
tail, since load_snapshot now genuinely parses past the legacy header
(previously ignored); their actual assertions (chunk-reassembly
correctness) are unchanged.

This is the exact function/contract that caused task 2.2's rejection
(position advancing before restore validated). Independent review
traced the memory-safety fix through the generated capnp code field-by-
field, confirmed the test_config_repl.c change doesn't weaken any
assertion, and confirmed restore_queue_state's ENOENT-aborts-the-whole-
checkpoint behavior is faithful to design.md's unconditional contract
(flagged as a watch-item for task 7.1's soak, not a defect).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDYFbrgyDHbvNYcxApKhrX
This commit is contained in:
Rongsong Shen 2026-07-01 08:30:10 +00:00
parent a8e300d96a
commit 9fdb6778d4
4 changed files with 297 additions and 56 deletions

View file

@ -2954,16 +2954,38 @@ static void test_snapshot_recv_two_chunks_assembles_and_renames(void)
/* Build a fake 128-byte snapshot body (two 64-byte chunks).
* payload_bytes contains the raw file bytes for each chunk.
* We skip writing an actual snapshot source file — the receiver
* writes snap.part and renames it; we use the resulting file to verify. */
* writes snap.part and renames it; we use the resulting file to verify.
*
* The body must be a WELL-FORMED entity-bearing checkpoint, not opaque
* filler (ha-entity-snapshots task 5.2): load_snapshot() now actually
* parses everything past the legacy header — a 0xAA/0xBB filler tail
* used to be silently ignored (load_snapshot only ever read the 57-byte
* header) but is now correctly rejected as an unrecognized
* format_version. Layout (design.md "Checkpoint File Layout"): the
* 57-byte legacy header (cfgref_present=0 at byte 16, so bytes 17-56 are
* unread and left zero) + format_version=1 + job_count=0 + queue_count=0
* (69 bytes total), padded with zero filler out to 128 bytes to keep the
* two-64-byte-chunk shape this test exercises — the padding is never
* read (load_snapshot_entities stops right after the queue_count=0
* loop), so its content is immaterial. */
uint64_t snap_lsn = 100;
size_t total_size = 128;
uint8_t body[128];
memset(body, 0, sizeof(body));
memcpy(body, "XCESNAP\0", 8);
memcpy(body + 8, &snap_lsn, 8);
/* body[16] (cfgref_present) left 0 by the memset above. */
uint32_t fake_fmt = 1u; /* must match persist.c's SNAPSHOT_FORMAT_VERSION */
uint32_t fake_job_count = 0u;
uint32_t fake_queue_count = 0u;
memcpy(body + 57, &fake_fmt, sizeof(fake_fmt));
memcpy(body + 61, &fake_job_count, sizeof(fake_job_count));
memcpy(body + 65, &fake_queue_count, sizeof(fake_queue_count));
uint8_t chunk0[64];
uint8_t chunk1[64];
memset(chunk0, 0xAA, 64);
memset(chunk1, 0xBB, 64);
/* Embed XCESNAP header in chunk0 so load_snapshot can parse it */
memcpy(chunk0, "XCESNAP\0", 8);
memcpy(chunk0 + 8, &snap_lsn, 8);
memcpy(chunk0, body, 64);
memcpy(chunk1, body + 64, 64);
persist_t *p = calloc(1, sizeof(persist_t));
TEST_ASSERT_NOT_NULL(p);
@ -3140,9 +3162,21 @@ static void test_snapshot_recv_128kb_two_chunks(void)
TEST_ASSERT_NOT_NULL(chunk0);
TEST_ASSERT_NOT_NULL(chunk1);
/* Embed valid XCESNAP header in chunk0 so persist_load_snapshot succeeds */
/* Embed valid XCESNAP header in chunk0 so persist_load_snapshot succeeds.
* chunk0/chunk1 are otherwise zero-filled, but a well-formed entity-
* bearing checkpoint (ha-entity-snapshots task 5.2) needs format_version
* = SNAPSHOT_FORMAT_VERSION right after the 57-byte legacy header, or
* load_snapshot() correctly rejects an all-zero format_version as
* unrecognized rather than silently ignoring the body as before. The
* job_count=0/queue_count=0 that follow are already satisfied by the
* zero-fill; the rest of the 128KB body is never read (load_snapshot
* stops right after the queue_count=0 loop) so it stays harmless
* filler. */
memcpy(chunk0, "XCESNAP\0", 8);
memcpy(chunk0 + 8, &snap_lsn, 8);
/* chunk0[16] (cfgref_present) already 0 from calloc. */
const uint32_t fake_fmt = 1u; /* must match persist.c's SNAPSHOT_FORMAT_VERSION */
memcpy(chunk0 + 57, &fake_fmt, sizeof(fake_fmt));
/* ── Chunk 0: not last ── */
snapshot_transfer_t c0;