The primary echoes its own last_known_elected_at_ms on every outgoing heartbeat (threaded through xce_ha_heartbeat_req's existing scalar-arg plumbing, alongside term/committed_lsn/committed_config_tag); a standby processing a received heartbeat (ha_consume_heartbeat_committed, the sole production call site) stores it unconditionally into its own last_known_elected_at_ms, reconcile-free -- no other HA field moves. ha_observe_heartbeat_committed itself is untouched; the new store sits next to its call site, not inside it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RLSNJhNoULvVcUhXEFafQC
296 lines
12 KiB
C
296 lines
12 KiB
C
#include <errno.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <unity/unity.h>
|
|
#include "request.h"
|
|
#include "xce/types.h"
|
|
|
|
/* Tests for xce_hostctrl_req() validation paths.
|
|
* We test only early-return paths (before xce_request() is called)
|
|
* since a live endpoint is not available in unit tests. */
|
|
|
|
static void test_hostctrl_req_null_ep(void)
|
|
{
|
|
xce_hostctrl_t ctrl = { .action = HOST_OPEN, .hostname = "host1" };
|
|
int rc = xce_hostctrl_req(NULL, &ctrl);
|
|
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
|
|
}
|
|
|
|
/* ── HA heartbeat sender (xce_ha_heartbeat_req) ───────────────────────────
|
|
* The HA heartbeat is an OP_nop request carrying a HeartbeatMsg body. The
|
|
* sender builds that request and hands it to xce_request(); since unit tests
|
|
* have no live endpoint, these tests round-trip the same request shape the
|
|
* sender builds through xce_encode()/xce_decode() and assert the three
|
|
* committed-position fields survive the wire. A companion guard confirms the
|
|
* generic xce_nop_req still produces a body-less nop. */
|
|
|
|
/* Build the heartbeat request exactly as xce_ha_heartbeat_req does and encode
|
|
* it into a heap io_packet_t. xce_decode() takes ownership of the packet
|
|
* (free_packet + FREEUP), so the caller must NOT free the returned pkt — it is
|
|
* consumed by the subsequent xce_decode(). Mirrors the test_wal_wire pattern. */
|
|
static io_packet_t *encode_heartbeat(uint64_t term, uint64_t committed_lsn,
|
|
uint64_t committed_config_tag,
|
|
uint64_t elected_at_ms)
|
|
{
|
|
heartbeat_msg_t hb = {
|
|
.sender_priority = 0,
|
|
.term = term,
|
|
.committed_lsn = committed_lsn,
|
|
.committed_config_tag = committed_config_tag,
|
|
.elected_at_ms = elected_at_ms,
|
|
};
|
|
xce_request_t request = {
|
|
.opcode = OPCode_nop,
|
|
.body_which = Request_body_heartbeatmsg,
|
|
.body = { .heartbeatmsg = &hb },
|
|
};
|
|
|
|
io_packet_t *pkt = (io_packet_t *)calloc(1, sizeof(*pkt));
|
|
TEST_ASSERT_NOT_NULL(pkt);
|
|
/* xce_encode returns 1 on success (mirrors xce_request's encode path). */
|
|
TEST_ASSERT_EQUAL_INT(1, xce_encode(&request, pkt));
|
|
return pkt;
|
|
}
|
|
|
|
static void test_ha_heartbeat_req_null_ep(void)
|
|
{
|
|
/* Mirror the nop sender's NULL-endpoint guard. */
|
|
int rc = xce_ha_heartbeat_req(NULL, 1, 1, 1, 1);
|
|
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
|
|
}
|
|
|
|
static void test_ha_heartbeat_req_roundtrip_fields(void)
|
|
{
|
|
/* Arrange/Act: encode a heartbeat carrying a committed position and an
|
|
* election time (Req 12.2), decode. */
|
|
io_packet_t *pkt = encode_heartbeat(7, 9, 4, 12345);
|
|
|
|
xce_request_t req;
|
|
memset(&req, 0, sizeof(req));
|
|
TEST_ASSERT_EQUAL_INT(0, xce_decode(pkt, &req)); /* consumes/frees pkt */
|
|
|
|
/* Assert: OP_nop + heartbeatmsg arm with all four fields intact. */
|
|
TEST_ASSERT_EQUAL_INT(OPCode_nop, req.opcode);
|
|
TEST_ASSERT_EQUAL_INT(Request_body_heartbeatmsg, req.body_which);
|
|
TEST_ASSERT_NOT_NULL(req.body.heartbeatmsg);
|
|
TEST_ASSERT_EQUAL_UINT64(7, req.body.heartbeatmsg->term);
|
|
TEST_ASSERT_EQUAL_UINT64(9, req.body.heartbeatmsg->committed_lsn);
|
|
TEST_ASSERT_EQUAL_UINT64(4, req.body.heartbeatmsg->committed_config_tag);
|
|
TEST_ASSERT_EQUAL_UINT64(12345, req.body.heartbeatmsg->elected_at_ms);
|
|
|
|
xce_free_request(&req);
|
|
printf("test_ha_heartbeat_req_roundtrip_fields: PASS\n");
|
|
}
|
|
|
|
/* Guard: the generic nop sender still produces a body-less nop request. */
|
|
static void test_nop_req_remains_bodyless(void)
|
|
{
|
|
xce_request_t request
|
|
= { .opcode = OPCode_nop, .body_which = Request_body_nomsg };
|
|
|
|
io_packet_t *pkt = (io_packet_t *)calloc(1, sizeof(*pkt));
|
|
TEST_ASSERT_NOT_NULL(pkt);
|
|
TEST_ASSERT_EQUAL_INT(1, xce_encode(&request, pkt));
|
|
|
|
xce_request_t req;
|
|
memset(&req, 0, sizeof(req));
|
|
TEST_ASSERT_EQUAL_INT(0, xce_decode(pkt, &req)); /* consumes/frees pkt */
|
|
|
|
TEST_ASSERT_EQUAL_INT(OPCode_nop, req.opcode);
|
|
TEST_ASSERT_EQUAL_INT(Request_body_nomsg, req.body_which);
|
|
|
|
xce_free_request(&req);
|
|
printf("test_nop_req_remains_bodyless: PASS\n");
|
|
}
|
|
|
|
static void test_hostctrl_req_null_ctrl(void)
|
|
{
|
|
/* non-NULL dummy ep is enough; the NULL ctrl check fires first */
|
|
int rc = xce_hostctrl_req((endpoint_t*)0x1, NULL);
|
|
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
|
|
}
|
|
|
|
static void test_hostctrl_req_null_hostname(void)
|
|
{
|
|
xce_hostctrl_t ctrl = { .action = HOST_OPEN, .hostname = NULL };
|
|
int rc = xce_hostctrl_req((endpoint_t*)0x1, &ctrl);
|
|
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
|
|
}
|
|
|
|
static void test_hostctrl_req_empty_hostname(void)
|
|
{
|
|
xce_hostctrl_t ctrl = { .action = HOST_OPEN, .hostname = "" };
|
|
int rc = xce_hostctrl_req((endpoint_t*)0x1, &ctrl);
|
|
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
|
|
}
|
|
|
|
static void test_hostctrl_req_noop(void)
|
|
{
|
|
xce_hostctrl_t ctrl = { .action = HOST_DO_NONE, .hostname = "host1" };
|
|
int rc = xce_hostctrl_req((endpoint_t*)0x1, &ctrl);
|
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
|
}
|
|
|
|
static void test_hostctrl_req_reject_tag(void)
|
|
{
|
|
xce_hostctrl_t ctrl = { .action = HOST_TAG, .hostname = "host1" };
|
|
int rc = xce_hostctrl_req((endpoint_t*)0x1, &ctrl);
|
|
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
|
|
}
|
|
|
|
static void test_hostctrl_req_reject_untag(void)
|
|
{
|
|
xce_hostctrl_t ctrl = { .action = HOST_UNTAG, .hostname = "host1" };
|
|
int rc = xce_hostctrl_req((endpoint_t*)0x1, &ctrl);
|
|
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
|
|
}
|
|
|
|
static void test_hostctrl_req_reject_negative_action(void)
|
|
{
|
|
xce_hostctrl_t ctrl = { .action = -1, .hostname = "host1" };
|
|
int rc = xce_hostctrl_req((endpoint_t*)0x1, &ctrl);
|
|
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
|
|
}
|
|
|
|
/* init_reply_by_req must fully zero the reply so the generated
|
|
* encode_RequestReply never reads a stale `redirect_host` pointer (and the
|
|
* other common trailer fields) off an uninitialized stack reply. Regression
|
|
* guard for the heap-use-after-free in encode_RequestReply / strlen seen on a
|
|
* NOP/state-report reply whose redirect_host was stack garbage. */
|
|
static void test_init_reply_by_req_zeroes_reply(void)
|
|
{
|
|
/* Poison the whole reply with non-zero bytes — mirrors an uninitialized
|
|
* stack reply whose redirect_host happens to be a (dangling) non-NULL. */
|
|
xce_reply_t reply;
|
|
xce_request_t req = { .seq = 42, .opcode = OPCode_nop };
|
|
memset(&reply, 0xFF, sizeof(reply));
|
|
|
|
init_reply_by_req(&reply, &req);
|
|
|
|
/* seq/opcode carried from the request */
|
|
TEST_ASSERT_EQUAL_UINT64(42, reply.seq);
|
|
TEST_ASSERT_EQUAL_INT(OPCode_nop, reply.opcode);
|
|
|
|
/* Trailer/common fields the encoder always reads must be cleared so a
|
|
* handler that only sets data_which/data cannot leak garbage onto the wire
|
|
* (or dereference a dangling redirect_host). */
|
|
TEST_ASSERT_NULL(reply.redirect_host);
|
|
TEST_ASSERT_EQUAL_UINT(0, reply.redirect_port);
|
|
TEST_ASSERT_EQUAL_INT(0, reply.status);
|
|
TEST_ASSERT_EQUAL_INT(0, reply.data_which);
|
|
}
|
|
|
|
/* ── job-array-single-wal-record task 5.3b: extract_data() wire dispatch for
|
|
* XceData_data_jobArray ─────────────────────────────────────────────────────
|
|
* extract_data() is the shared inbound-push reassembly step every receiver
|
|
* (scheduler, API server) passes through inside vtask_entry() (xcectx.c)
|
|
* before its own handler ever runs. Its switch had no case for
|
|
* XceData_data_jobArray -- it fell into `default: rc = -EINVAL`, and
|
|
* vtask_entry()'s `if (callnext)` treats ANY nonzero return (including a
|
|
* negative errno) as "reassembly complete, proceed" -- so the real payload
|
|
* was silently freed and the receiver was handed an empty, zeroed
|
|
* dataset_t{rows=NULL, nrows=0}. This test drives the REAL wire encode/
|
|
* decode path (mirrors xcesched_push_jobarray()'s exact construction,
|
|
* sched.c, and apiserver_push_jobarray()'s identical shape, apiserver.c) so
|
|
* the payload extract_data() sees here is genuinely wire-decoded, not a
|
|
* hand-built struct. */
|
|
|
|
static void test_extract_data_job_array_passes_through_intact(void)
|
|
{
|
|
job_submit_data_t submit;
|
|
xce_job_submit_t job_array;
|
|
xce_data_t data;
|
|
xce_request_t request;
|
|
xce_request_t decoded;
|
|
io_packet_t *pkt;
|
|
dataset_t dataset;
|
|
int rc;
|
|
xce_job_submit_t *got;
|
|
|
|
/* Arrange: build the exact wire shape xcesched_push_jobarray()/
|
|
* apiserver_push_jobarray() send: OPCode_push / Request_body_pushmsg
|
|
* carrying one Data.jobArray descriptor (base jobid, array_size, one
|
|
* shared submit spec) -- never chunked, exactly one per commit. */
|
|
memset(&submit, 0, sizeof(submit));
|
|
submit.queue = "normal";
|
|
submit.user = "tester";
|
|
submit.mintasks = 1;
|
|
submit.maxtasks = 1;
|
|
submit.resreq = "type == local";
|
|
|
|
memset(&job_array, 0, sizeof(job_array));
|
|
job_array.jobid = 70000;
|
|
job_array.array_size = 12;
|
|
job_array.submit = &submit;
|
|
|
|
memset(&data, 0, sizeof(data));
|
|
data.table = XceData_data_jobArray;
|
|
data.data.job_array = &job_array;
|
|
|
|
memset(&request, 0, sizeof(request));
|
|
request.opcode = OPCode_push;
|
|
request.body_which = Request_body_pushmsg;
|
|
request.body.pushmsg = &data;
|
|
|
|
pkt = (io_packet_t *)calloc(1, sizeof(*pkt));
|
|
TEST_ASSERT_NOT_NULL(pkt);
|
|
TEST_ASSERT_EQUAL_INT(1, xce_encode(&request, pkt));
|
|
|
|
memset(&decoded, 0, sizeof(decoded));
|
|
TEST_ASSERT_EQUAL_INT(0, xce_decode(pkt, &decoded)); /* consumes/frees pkt */
|
|
TEST_ASSERT_EQUAL_INT(Request_body_pushmsg, decoded.body_which);
|
|
TEST_ASSERT_NOT_NULL(decoded.body.pushmsg);
|
|
TEST_ASSERT_EQUAL_INT(XceData_data_jobArray, decoded.body.pushmsg->table);
|
|
|
|
/* Act: this is exactly what vtask_entry() calls on every inbound push. */
|
|
memset(&dataset, 0, sizeof(dataset));
|
|
dataset.table = decoded.body.pushmsg->table;
|
|
rc = extract_data(&dataset, decoded.body.pushmsg);
|
|
|
|
/* Assert: nonzero/"reassembly complete" (matches vtask_entry's
|
|
* `if (callnext)` truthy contract -- a single, unchunked descriptor never
|
|
* waits for more) AND the genuine payload, not an empty dataset. Before
|
|
* the fix, rc was -EINVAL (still truthy, so vtask_entry proceeded
|
|
* anyway) but dataset.rows stayed NULL/nrows stayed 0 -- exactly the
|
|
* silent-drop bug this test pins down. */
|
|
TEST_ASSERT_EQUAL_INT(1, rc);
|
|
TEST_ASSERT_NOT_NULL(dataset.rows);
|
|
TEST_ASSERT_EQUAL_INT(1, dataset.nrows);
|
|
|
|
got = (xce_job_submit_t *)dataset.rows;
|
|
TEST_ASSERT_EQUAL_UINT64(70000, got->jobid);
|
|
TEST_ASSERT_EQUAL_UINT64(12, got->array_size);
|
|
TEST_ASSERT_NOT_NULL(got->submit);
|
|
TEST_ASSERT_EQUAL_STRING("normal", got->submit->queue);
|
|
TEST_ASSERT_EQUAL_STRING("tester", got->submit->user);
|
|
|
|
/* Cleanup: extract_data() transfers ownership of the decoded
|
|
* xce_job_submit_t out of decoded.body.pushmsg->data.job_array into
|
|
* dataset.rows (nulling the source pointer) -- free the now-hollow
|
|
* container and the transferred descriptor separately. */
|
|
xce_free_request(&decoded);
|
|
free_JobSubmit_ptr((xce_job_submit_t **)&dataset.rows);
|
|
printf("test_extract_data_job_array_passes_through_intact: PASS\n");
|
|
}
|
|
|
|
void test_request(void)
|
|
{
|
|
printf("\n>> host control request validation tests\n\n");
|
|
RUN_TEST(test_hostctrl_req_null_ep);
|
|
RUN_TEST(test_hostctrl_req_null_ctrl);
|
|
RUN_TEST(test_hostctrl_req_null_hostname);
|
|
RUN_TEST(test_hostctrl_req_empty_hostname);
|
|
RUN_TEST(test_hostctrl_req_noop);
|
|
RUN_TEST(test_hostctrl_req_reject_tag);
|
|
RUN_TEST(test_hostctrl_req_reject_untag);
|
|
RUN_TEST(test_hostctrl_req_reject_negative_action);
|
|
RUN_TEST(test_init_reply_by_req_zeroes_reply);
|
|
|
|
printf("\n>> HA heartbeat sender round-trip tests\n\n");
|
|
RUN_TEST(test_ha_heartbeat_req_null_ep);
|
|
RUN_TEST(test_ha_heartbeat_req_roundtrip_fields);
|
|
RUN_TEST(test_nop_req_remains_bodyless);
|
|
|
|
printf("\n>> extract_data() XceData_data_jobArray wire dispatch tests\n\n");
|
|
RUN_TEST(test_extract_data_job_array_passes_through_intact);
|
|
}
|