myxce/src/scheduler/testsuite/test_admin_request.c
Rongsong Shen 144a01982e
refactor(scheduler-sim-decision-trace): split trace sources under the 800-line cap
Task 2.4: outcome capture moves to sched_trace_outcome.c and the
position/host/summary/rollback half of the model to sched_trace_scan.c;
shared helpers go behind sched_trace_internal.h (sti_ prefix). Code is
moved unchanged; every target that compiles the trace sources gets all
four, and the core isolation scan covers the new files.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 10:16:46 +00:00

1258 lines
50 KiB
C

/*
* test_admin_request.c - contract test for the scheduler-side
* OPCode_schedAdmin request handler (scheduler-core spec, task 9.4, Req 5
* AC 10). This is the scheduler-side counterpart to task 10.1's
* controller-side forwarding dispatch (out of THIS spec's boundary): the
* controller relays a SchedAdminRequest verbatim to this endpoint, and this
* file proves the scheduler decodes it, dispatches by action to
* sched_record_start()/sched_record_stop()/sched_record_status() (tasks
* 9.2/9.3/9.4), and sends back a SchedAdminReply whose fields match
* requirements.md's "Reply field semantics by action" table.
*
* Observable under test:
* - recordStatus is answered SYNCHRONOUSLY, on the calling (handler)
* thread, directly from handler.c's sched_handle_admin_request() --
* never enqueued -- both when not recording (all-zero/empty reply) and
* while genuinely recording (live event count/file/started-at).
* - recordStart/recordStop are DEFERRED: enqueued onto the same
* pend_updates dataq every other controller-sourced update flows
* through (XCE_SCHED_OP_ADMIN_RECORD), NOT executed synchronously --
* `sched->recording` is provably unchanged immediately after
* scheduler_req_handler() returns -- and only actually performed (real
* sched_record_start()/sched_record_stop() call, real reply send) by a
* subsequent xce_sched_driver_translate_pending_events() drain
* (driver.c's handle_deferred_admin_record()).
* - recordStart while already recording / recordStop while not recording
* reply {ok=false, error="already recording"/"not recording"} with the
* table's documented (unchanged) field values.
* - An invalid recordFile (path separator) surfaces all the way through
* the deferred path as {ok=false, error="invalid record file name"},
* never just an internal log line, and never actually opens a file.
* - The same rejection holds for a ".."-traversal recordFile and for a
* pre-planted symlink inside record_dir that escapes outside it --
* both submitted through this same real admin request path (task
* 9.1's sched_validate_record_filename() contract), not just proven at
* the internal validation-function level
* (test_recording_path_validation.c) or the single path-separator case
* above (task 9.5's genuine-gap audit).
* - A genuine write failure (the underlying fd closed out from under the
* open sched->record_file, task 9.3's own real-I/O technique) that
* occurs while recording -- itself started through this same real
* admin request path -- cleanly disables recording via
* sched_record_abort_on_error(); a subsequent recordStatus through
* this SAME real handler entry point correctly reports
* recording=false; and a further admin request afterward still
* succeeds cleanly (task 9.5's disk-full Observable, proven through
* the request-handler path specifically, not just the internal
* driver-loop path task 9.3 already proved).
* - An unrecognized SchedAdminAction returns -EINVAL, logs, and neither
* enqueues anything nor sends any reply -- no crash.
*
* White-box #include of ../src/core.c, ../src/core_clock.c,
* ../src/core_events.c, ../src/driver.c (same set test_driver_translate.c
* uses to exercise xce_sched_driver_translate_pending_events() for real)
* PLUS ../src/recording.c/recording_events.c (task 9.1/9.2/9.3's REAL
* sched_record_start()/sched_record_stop()/sched_record_status()/
* sched_record_write_events() -- this file's whole point is proving the
* deferred path performs REAL file I/O, so these must be genuine, not
* stubbed) PLUS ../src/snapshot.c/snapshot_wire.c (task 8.1's real
* xce_sched_core_save_snapshot(), needed by sched_record_start()) PLUS
* ../src/handler.c (task 9.4's sched_handle_admin_request()/
* scheduler_req_handler(), the code under test).
*
* recording_session.c is NOT linked: its sched_record_session_begin()/
* sched_record_session_add_decision()/sched_record_session_end() trio is
* only reachable from xce_sched_driver_run_normal_mode_round(), which this
* file never calls (it drives xce_sched_driver_translate_pending_events()
* directly, same as test_driver_translate.c) -- stubbed below purely to
* satisfy the linker, same rationale as that file's identical stubs for the
* same reason. Likewise core_session.c/plugin.c are not linked (their
* exported functions are stubbed below); no real scheduling session is ever
* run by any test in this file.
*
* xcecli_reply_schedadmin() is a SPY test double (client.c's real
* implementation needs a live capnp connection this file never sets up) --
* same pattern test_driver_decision_cb.c already establishes for
* sched_publish_dispatch()/sched_publish_pend_reason(). It deep-copies
* every field of the passed sched_admin_reply_t (including string fields)
* into fixed-size static storage rather than retaining the passed pointers:
* both handle_deferred_admin_record() (recordStop's `file`) and
* sched_record_status() (`file`, a BORROWED alias of
* sched->record_file_path) document that their reply pointers are only
* valid for the duration of the synchronous call -- recordStop's in
* particular aliases a stack-local buffer that is gone the instant
* handle_deferred_admin_record() returns, so retaining the raw pointer
* would be a stack-use-after-return read the very next line.
*/
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <unity/unity.h>
#include "controller.capnp.h"
#include "dataq.h"
#include "kvmgr.h"
#include "mylock.h"
#include "scheduler.h"
#include "xstring.h"
/* Pull in the real functions under test into this TU (white-box #include,
* same convention as test_driver_translate.c/test_record_lifecycle.c). */
#include "../src/core.c"
#include "../src/core_clock.c"
#include "../src/core_events.c"
#include "../src/driver.c"
/* Hooked sources compile SCHED_TRACE bodies (scheduler-sim-decision-trace). */
#include "../src/sched_trace.c"
#include "../src/sched_trace_scan.c"
#include "../src/sched_trace_capture.c"
#include "../src/sched_trace_outcome.c"
#include "../src/recording.c"
#include "../src/recording_events.c"
#include "../src/snapshot.c"
#include "../src/snapshot_wire.c"
void kill_sched_job(void* arg) { (void)arg; }
void kill_sched_queue(void* arg) { (void)arg; }
void kill_sched_worker(void* arg) { (void)arg; }
void kill_sched_user(void* arg) { (void)arg; }
void kill_sched_project(void* arg) { (void)arg; }
void kill_sched_resval(xce_val_t* val) { (void)val; }
/* resource-accounting.md's reconciliation (resource.c, not linked here --
* this file only #includes core_events.c). core_events.c's
* XCE_CORE_EVENT_JOB_RUSAGE handler calls this whenever job->accounts !=
* NULL; link-satisfying stub only, same rationale as every other stub in
* this block (see test_job_update.c's identical stub). */
int sched_reconcile_job_resource_usage(xce_sched_core_t* c,
xce_job_account_t* a,
xce_job_rusages_t* r)
{
(void)c; (void)a; (void)r; return 0;
}
void plugin_unregister(void* arg) { (void)arg; }
/* Link-satisfying stubs for scheduler.c's sched_publish_dispatch()/
* sched_publish_pend_reason() -- driver.c's (real, #included above)
* xce_sched_driver_on_decision() references both; never actually invoked by
* any test in this file (no session is ever run, so no decision is ever
* drained). Same rationale as test_driver_translate.c's identical stubs. */
int sched_publish_dispatch(xce_scheduler_t* sched, xce_job_dispatch_t* d)
{
(void)sched;
(void)d;
return 0;
}
int sched_publish_pend_reason(xce_scheduler_t* sched, xce_preason_set_t* r)
{
(void)sched;
(void)r;
return 0;
}
/* Link-satisfying stubs: xce_sched_driver_run_normal_mode_round() (driver.c,
* real, #included above, but never CALLED by any test here -- this file
* only drives xce_sched_driver_translate_pending_events() directly, same as
* test_driver_translate.c) references xce_core_begin_session()/
* xce_core_signal_go()/xce_core_end_session() (core_session.c) and
* plugin_begin_session()/plugin_end_session() (plugin.c) /
* sched_account_jobs() (scheduler.c). Never actually invoked at runtime. */
int xce_core_begin_session(xce_sched_core_t* core)
{
(void)core;
return 0;
}
int xce_core_signal_go(xce_sched_core_t* core)
{
(void)core;
return 0;
}
int xce_core_end_session(xce_sched_core_t* core)
{
(void)core;
return 0;
}
int plugin_begin_session(xce_sched_core_t* core)
{
(void)core;
return 0;
}
int plugin_end_session(xce_sched_core_t* core)
{
(void)core;
return 0;
}
int sched_account_jobs(xce_sched_core_t* core)
{
(void)core;
return 0;
}
/* bug-1836 (scheduler-driver-equivalence fix, Recorded-Decision
* Reconciliation capacity gate): link-satisfying stubs, mirroring this
* file's existing sched_account_jobs() no-op precedent immediately above --
* sim_loop.c's sim_reconcile_unaccounted_jobs() now also calls
* sched_check_host_satisfy()/sched_check_shared_satisfy() (resource.c) to
* re-validate a recorded decision against live capacity, and
* sched_charge_account() (resource.c) to keep that check's own view of
* capacity up to date across multiple candidates in the same round. This
* file never exercises real resource contention (same rationale as the
* existing sched_account_jobs()/sched_ctx_get_uncomit_host() no-ops), so
* the satisfy checks trivially always succeed and the charge call is a
* no-op. */
int sched_check_host_satisfy(xce_sched_core_t* core, xce_sched_worker_t* w,
xvec_t* uncomitted, xvec_idval_t* wanted,
xvec_idval_t* unmeet)
{
(void)core;
(void)w;
(void)uncomitted;
(void)wanted;
(void)unmeet;
return 1;
}
int sched_check_shared_satisfy(xce_sched_core_t* core, xvec_t* uncomitted,
xvec_idval_t* wanted, xvec_idval_t* unmeet)
{
(void)core;
(void)uncomitted;
(void)wanted;
(void)unmeet;
return 1;
}
int sched_charge_account(xce_sched_core_t* core, xce_job_account_t* acct)
{
(void)core;
(void)acct;
return 0;
}
int sched_uncharge_account(xce_sched_core_t* core, xce_job_account_t* acct)
{
(void)core;
(void)acct;
return 0;
}
/* Link-satisfying stubs for recording_session.c's real functions -- see
* this file's header comment for why recording_session.c itself is not
* linked. Never actually invoked (no session is ever run). */
sched_record_session_ctx_t* sched_record_session_begin(xce_scheduler_t* sched,
uint64_t begin_round_ts)
{
(void)sched;
(void)begin_round_ts;
return NULL;
}
void sched_record_session_add_decision(sched_record_session_ctx_t* ctx,
uint64_t ts, xce_decision_t* decision)
{
(void)ctx;
(void)ts;
(void)decision;
}
int sched_record_session_end(xce_scheduler_t* sched,
sched_record_session_ctx_t* ctx, uint64_t end_ts)
{
(void)sched;
(void)ctx;
(void)end_ts;
return 0;
}
/* Real reimplementation of schedobj.c's sched_resolve_job_picker -- same
* rationale as test_driver_translate.c's own copy (schedobj.c is not
* linked). Never actually exercised (no test here pushes core events, so
* xce_core_push_events() -- the only real caller -- is never reached: every
* enqueued request in this file is XCE_SCHED_OP_ADMIN_RECORD, intercepted
* by driver.c BEFORE the translated batch/push path). Present only to
* satisfy the linker for core_events.c. */
int sched_resolve_job_picker(xce_sched_core_t* core, xce_sched_plugin_t** out)
{
(void)core;
(void)out;
return -EINVAL;
}
/* Link-satisfying stub for scheduler.c's sched_init_config() -- handler.c's
* (real, #included below) OPCode_push/configpushmsg case references it;
* this file never exercises that path (out of scope -- this file's whole
* surface is OPCode_schedAdmin). Never actually invoked. */
int sched_init_config(xce_scheduler_t* s, xce_conf4sched_t* c)
{
(void)s;
(void)c;
return 0;
}
/* bug-1846: handler.c's config-push path now stashes via sched_stash_config()
* (scheduler.c, not linked here) instead of calling sched_init_config() --
* link-satisfying stub, same rationale as the one above. */
int sched_stash_config(xce_scheduler_t* s, xce_conf4sched_t* c)
{
(void)s;
(void)c;
return 0;
}
/* Pull in the real sched_handle_admin_request()/scheduler_req_handler()
* under test into this TU. */
#include "../src/handler.c"
/* ---------------------------------------------------------------------- */
/* Spy test double for client.c's xcecli_reply_schedadmin() -- see file */
/* header comment for why every field is deep-copied rather than retaining */
/* the passed sched_admin_reply_t's reply->file/reply->error pointers. */
/* ---------------------------------------------------------------------- */
static int g_reply_calls;
static uint64_t g_last_seq;
static xcecli_ctx_t g_last_ctx;
static int g_last_ok;
static int g_last_error_null;
static char g_last_error[256];
static int g_last_recording;
static int g_last_file_null;
static char g_last_file[PATH_MAX];
static uint64_t g_last_event_count;
static uint64_t g_last_started_at;
static void reset_reply_spy(void)
{
g_reply_calls = 0;
g_last_seq = 0;
g_last_ctx = NULL;
g_last_ok = 0;
g_last_error_null = 1;
g_last_error[0] = '\0';
g_last_recording = 0;
g_last_file_null = 1;
g_last_file[0] = '\0';
g_last_event_count = 0;
g_last_started_at = 0;
}
int xcecli_reply_schedadmin(xcecli_ctx_t c, uint64_t seq,
sched_admin_reply_t* areply)
{
g_reply_calls++;
g_last_ctx = c;
g_last_seq = seq;
if (areply == NULL) {
return 0;
}
g_last_ok = areply->ok;
g_last_recording = areply->recording;
g_last_event_count = areply->eventCount;
g_last_started_at = areply->startedAt;
g_last_error_null = (areply->error == NULL);
g_last_error[0] = '\0';
if (areply->error != NULL) {
strncpy(g_last_error, areply->error, sizeof(g_last_error) - 1);
}
g_last_file_null = (areply->file == NULL);
g_last_file[0] = '\0';
if (areply->file != NULL) {
strncpy(g_last_file, areply->file, sizeof(g_last_file) - 1);
}
return 0;
}
void setUp(void) { reset_reply_spy(); }
void tearDown(void) { return; }
/* ---------------------------------------------------------------------- */
/* Fixture helpers -- same pattern as test_record_lifecycle.c's own. */
/* ---------------------------------------------------------------------- */
static void make_tmp_dir(char* out, size_t out_len, const char* suffix)
{
char tmpl[PATH_MAX];
snprintf(tmpl, sizeof(tmpl), "/tmp/sched_adminreq%s_XXXXXX", suffix);
TEST_ASSERT_NOT_NULL(mkdtemp(tmpl));
TEST_ASSERT_TRUE(strlen(tmpl) < out_len);
strcpy(out, tmpl);
}
/* Same helper as test_recording_path_validation.c's own touch_file() --
* creates an empty regular file, used below to build a genuine symlink
* target outside record_dir for the symlink-escape-through-the-admin-path
* test. */
static void touch_file(const char* path)
{
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
TEST_ASSERT_TRUE(fd >= 0);
close(fd);
}
/* `record_dir` must outlive `sched` (caller-owned storage), same convention
* as test_record_lifecycle.c's fixture_init(). No picker plugin is
* registered -- unlike test_record_lifecycle.c's fixture, no test in this
* file ever runs a scheduling session, so core->job_pick_plugin staying
* NULL is fine (sched_resolve_job_picker() above is never actually
* reached). */
static void fixture_init(xce_scheduler_t* sched, char* record_dir)
{
int rc;
memset(sched, 0, sizeof(*sched));
/* This fixture hand-builds xce_scheduler_t rather than calling the full
* sched_init() (which starts real threads/network I/O this white-box
* suite never wants) -- record_lock (scheduler.h field comments, task
* 9.4 fix round) is normally initialized by sched_init(), so it must be
* initialized here too, the same way core.lock is initialized by the
* xce_sched_core_init() call just below. Every test in this file
* exercises sched_record_status()/sched_record_start()/
* sched_record_stop()/sched_record_write_events() for real, all of
* which now take this lock. */
mylock_init(&(sched->record_lock));
rc = xce_sched_core_init(&(sched->core));
TEST_ASSERT_EQUAL_INT(0, rc);
sched->core.pend_updates[0] = (dataq_t*)calloc(1, sizeof(dataq_t));
dataq_init(sched->core.pend_updates[0], NULL);
sched->core.pend_updates[1] = (dataq_t*)calloc(1, sizeof(dataq_t));
dataq_init(sched->core.pend_updates[1], NULL);
sched->record_dir = record_dir;
/* Dummy non-NULL xcecli_ctx_t (an opaque void*) -- xcecli_reply_schedadmin
* is spied above, so nothing ever dereferences this; a real production
* ctx would come from xcecli_ctx_init(). */
sched->apictx = (xcecli_ctx_t)sched;
}
/* Builds a request/reply pair on the stack and drives it through the REAL
* production entry point, scheduler_req_handler() (handler.c) -- exactly
* the function main.c registers as the scheduler's request handler and the
* function that dispatches OPCode_schedAdmin to sched_handle_admin_request()
* (handler.c's own new `case` this task added). */
static int send_admin_request(xce_scheduler_t* sched, int action,
const char* record_file, uint64_t seq)
{
sched_admin_req_t areq;
xce_request_t request;
memset(&areq, 0, sizeof(areq));
areq.action = action;
areq.recordFile = (char*)record_file;
memset(&request, 0, sizeof(request));
request.opcode = OPCode_schedAdmin;
request.seq = seq;
request.body_which = Request_body_schedadminmsg;
request.body.schedadminmsg = &areq;
return scheduler_req_handler(&request, sched);
}
/* ---------------------------------------------------------------------- */
/* recordStatus -- synchronous, answered directly from the handler thread. */
/* ---------------------------------------------------------------------- */
static void test_recordstatus_not_recording_returns_empty_state(void)
{
char record_dir[PATH_MAX];
xce_scheduler_t sched;
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_status_idle");
fixture_init(&sched, record_dir);
rc = send_admin_request(&sched, SchedAdminAction_recordStatus, NULL, 111);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(1, g_reply_calls);
TEST_ASSERT_EQUAL_UINT64(111, g_last_seq);
TEST_ASSERT_EQUAL_PTR(sched.apictx, g_last_ctx);
TEST_ASSERT_TRUE(g_last_ok);
TEST_ASSERT_FALSE(g_last_recording);
TEST_ASSERT_TRUE(g_last_file_null);
TEST_ASSERT_EQUAL_UINT64(0, g_last_event_count);
TEST_ASSERT_EQUAL_UINT64(0, g_last_started_at);
/* recordStatus never touches the deferred queue. */
TEST_ASSERT_NULL(dataq_deq(sched.core.pend_updates[0], NULL));
}
static void test_recordstatus_while_recording_reflects_live_state(void)
{
char record_dir[PATH_MAX];
xce_scheduler_t sched;
xce_core_event_t ev;
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_status_live");
fixture_init(&sched, record_dir);
rc = sched_record_start(&sched, "live-status.replay");
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_TRUE(sched.recording);
/* Real event writes via recording_events.c's genuine
* sched_record_write_events() -- record_event_count genuinely advances,
* not a fabricated field. */
memset(&ev, 0, sizeof(ev));
ev.type = XCE_CORE_EVENT_JOB_STATE_CHANGE;
ev.u.job_state_change.jobid = 7ULL;
TEST_ASSERT_EQUAL_INT(0, sched_record_write_events(&sched, &ev, 1));
ev.u.job_state_change.jobid = 8ULL;
TEST_ASSERT_EQUAL_INT(0, sched_record_write_events(&sched, &ev, 1));
TEST_ASSERT_EQUAL_UINT64(2ULL, sched.record_event_count);
rc = send_admin_request(&sched, SchedAdminAction_recordStatus, NULL, 222);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(1, g_reply_calls);
TEST_ASSERT_TRUE(g_last_ok);
TEST_ASSERT_TRUE(g_last_recording);
TEST_ASSERT_FALSE(g_last_file_null);
TEST_ASSERT_TRUE(strstr(g_last_file, "live-status.replay") != NULL);
TEST_ASSERT_EQUAL_UINT64(2ULL, g_last_event_count);
TEST_ASSERT_TRUE(g_last_started_at > 0);
TEST_ASSERT_EQUAL_UINT64(sched.record_start_ts, g_last_started_at);
}
/* ---------------------------------------------------------------------- */
/* recordStatus concurrency -- record_lock (scheduler.h field comments, */
/* task 9.4 fix round) genuinely protects the recording-state fields */
/* against the vtask-thread sched_record_status() reader / driver-thread */
/* writer race the code review flagged. Two DETERMINISTIC proofs rather */
/* than a single flaky, purely timing-dependent race reproduction -- */
/* task 9.2's TOCTOU test already established this codebase's precedent */
/* of preferring a deterministic proof over a genuinely racy test: */
/* */
/* 1. record_lock is a REAL mutex a second, genuine pthread actually */
/* blocks on -- not just "the code calls mylock_lock()" verified by */
/* inspection. Proven by holding the lock on the main thread */
/* (simulating "driver thread is mid-mutation"), launching a real */
/* thread that calls sched_record_status(), and asserting it has NOT */
/* completed after a generous wait -- then asserting it DOES */
/* complete once the lock is released. The only timing dependency is */
/* in the safe direction: a slow CI runner can only make the "still */
/* blocked" assertion harder to observe, never produce a false */
/* positive (record_lock is genuinely held for the whole wait). */
/* 2. reply->file is a genuinely independent allocation, not an alias of */
/* the live sched->record_file_path -- proven by taking a status */
/* snapshot, then calling the real sched_record_stop() (which frees */
/* sched->record_file_path for real) and confirming the snapshot's */
/* reply->file is untouched. This reproduces the exact */
/* use-after-free the finding identified, deterministically, with no */
/* reliance on OS thread-scheduling timing at all. */
/* ---------------------------------------------------------------------- */
typedef struct {
xce_scheduler_t* sched;
sched_admin_reply_t reply;
int rc;
atomic_int done;
} status_thread_arg_t;
static void* status_thread_fn(void* arg)
{
status_thread_arg_t* a = (status_thread_arg_t*)arg;
a->rc = sched_record_status(a->sched, &(a->reply));
atomic_store(&(a->done), 1);
return NULL;
}
static void test_recordstatus_genuinely_blocks_on_record_lock(void)
{
char record_dir[PATH_MAX];
xce_scheduler_t sched;
status_thread_arg_t arg;
pthread_t th;
make_tmp_dir(record_dir, sizeof(record_dir), "_status_lockblock");
fixture_init(&sched, record_dir);
TEST_ASSERT_EQUAL_INT(0, sched_record_start(&sched, "lockblock.replay"));
memset(&arg, 0, sizeof(arg));
arg.sched = &sched;
atomic_store(&(arg.done), 0);
/* Simulate "driver thread is mid-mutation": hold record_lock on THIS
* (main test) thread, exactly the window sched_record_start()/
* sched_record_stop()/sched_record_abort_on_error() hold it for in
* production. */
mylock_lock(&(sched.record_lock));
TEST_ASSERT_EQUAL_INT(0, pthread_create(&th, NULL, status_thread_fn, &arg));
/* Generous wait -- if record_lock were NOT actually taken by
* sched_record_status() (i.e. the fix regressed to a no-op), the
* spawned thread would complete almost instantly and `done` would
* already be 1 here. */
usleep(150000); /* 150ms */
TEST_ASSERT_EQUAL_INT(0, atomic_load(&(arg.done)));
mylock_unlock(&(sched.record_lock));
TEST_ASSERT_EQUAL_INT(0, pthread_join(th, NULL));
TEST_ASSERT_EQUAL_INT(1, atomic_load(&(arg.done)));
TEST_ASSERT_EQUAL_INT(0, arg.rc);
TEST_ASSERT_TRUE(arg.reply.recording);
TEST_ASSERT_NOT_NULL(arg.reply.file);
TEST_ASSERT_TRUE(strstr(arg.reply.file, "lockblock.replay") != NULL);
free(arg.reply.file);
TEST_ASSERT_EQUAL_INT(0, sched_record_stop(&sched));
}
static void test_recordstatus_file_survives_concurrent_record_stop_free(void)
{
char record_dir[PATH_MAX];
xce_scheduler_t sched;
sched_admin_reply_t snapshot;
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_status_uaf");
fixture_init(&sched, record_dir);
rc = sched_record_start(&sched, "uaftest.replay");
TEST_ASSERT_EQUAL_INT(0, rc);
rc = sched_record_status(&sched, &snapshot);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_NOT_NULL(snapshot.file);
/* Genuinely a DIFFERENT allocation from the live pointer -- not an
* alias (the exact bug this fix closes: the pre-fix version set
* reply->file = sched->record_file_path directly). */
TEST_ASSERT_TRUE(sched.record_file_path != snapshot.file);
TEST_ASSERT_EQUAL_STRING(sched.record_file_path, snapshot.file);
/* sched_record_stop() frees sched->record_file_path for real -- if
* snapshot.file aliased it, this would be a genuine use-after-free
* (ASan would abort) the moment the next assertion reads it. */
rc = sched_record_stop(&sched);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_NULL(sched.record_file_path);
TEST_ASSERT_TRUE(strstr(snapshot.file, "uaftest.replay") != NULL);
free(snapshot.file);
}
/* ---------------------------------------------------------------------- */
/* recordStart -- deferred, not synchronous. */
/* ---------------------------------------------------------------------- */
static void test_recordstart_is_deferred_then_performed_by_driver_round(void)
{
char record_dir[PATH_MAX];
xce_scheduler_t sched;
char expect_path[PATH_MAX];
int rc;
struct stat st;
make_tmp_dir(record_dir, sizeof(record_dir), "_start_deferred");
fixture_init(&sched, record_dir);
rc = send_admin_request(&sched, SchedAdminAction_recordStart,
"admin-start.replay", 333);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Deferred, not synchronous: no start yet, no reply yet. */
TEST_ASSERT_FALSE(sched.recording);
TEST_ASSERT_NULL(sched.record_file_path);
TEST_ASSERT_EQUAL_INT(0, g_reply_calls);
/* A subsequent driver round actually performs the deferred start. */
rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched);
TEST_ASSERT_EQUAL_INT(0, rc); /* n==0: the admin op never enters the
* translated event batch. */
TEST_ASSERT_TRUE(sched.recording);
TEST_ASSERT_NOT_NULL(sched.record_file_path);
TEST_ASSERT_TRUE(strstr(sched.record_file_path, "admin-start.replay")
!= NULL);
/* The file is genuinely created on disk. */
snprintf(expect_path, sizeof(expect_path), "%s/admin-start.replay",
record_dir);
TEST_ASSERT_EQUAL_INT(0, stat(expect_path, &st));
TEST_ASSERT_TRUE(S_ISREG(st.st_mode));
/* And the deferred reply was genuinely sent, matching the "recordStart
* (ok)" column of requirements.md's field-semantics table. */
TEST_ASSERT_EQUAL_INT(1, g_reply_calls);
TEST_ASSERT_EQUAL_UINT64(333, g_last_seq);
TEST_ASSERT_TRUE(g_last_ok);
TEST_ASSERT_TRUE(g_last_error_null);
TEST_ASSERT_TRUE(g_last_recording);
TEST_ASSERT_FALSE(g_last_file_null);
TEST_ASSERT_TRUE(strstr(g_last_file, "admin-start.replay") != NULL);
TEST_ASSERT_EQUAL_UINT64(0, g_last_event_count);
TEST_ASSERT_TRUE(g_last_started_at > 0);
}
static void test_recordstart_empty_file_auto_generates_name(void)
{
char record_dir[PATH_MAX];
xce_scheduler_t sched;
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_start_auto");
fixture_init(&sched, record_dir);
/* NULL recordFile -- "empty = auto-generate" (requirements.md's
* SchedAdminRequest.recordFile field comment). */
rc = send_admin_request(&sched, SchedAdminAction_recordStart, NULL, 444);
TEST_ASSERT_EQUAL_INT(0, rc);
rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_TRUE(sched.recording);
TEST_ASSERT_NOT_NULL(sched.record_file_path);
TEST_ASSERT_TRUE(strstr(sched.record_file_path, record_dir) != NULL);
TEST_ASSERT_TRUE(strstr(sched.record_file_path, ".replay") != NULL);
TEST_ASSERT_EQUAL_INT(1, g_reply_calls);
TEST_ASSERT_TRUE(g_last_ok);
TEST_ASSERT_FALSE(g_last_file_null);
}
static void test_recordstart_while_already_recording_replies_error(void)
{
char record_dir[PATH_MAX];
xce_scheduler_t sched;
char path_before[PATH_MAX];
uint64_t started_before;
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_start_already");
fixture_init(&sched, record_dir);
rc = sched_record_start(&sched, "first.replay");
TEST_ASSERT_EQUAL_INT(0, rc);
strcpy(path_before, sched.record_file_path);
started_before = sched.record_start_ts;
rc = send_admin_request(&sched, SchedAdminAction_recordStart,
"second.replay", 555);
TEST_ASSERT_EQUAL_INT(0, rc);
rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Original recording untouched by the rejected second start. */
TEST_ASSERT_TRUE(sched.recording);
TEST_ASSERT_EQUAL_STRING(path_before, sched.record_file_path);
TEST_ASSERT_EQUAL_UINT64(0ULL, sched.record_event_count);
TEST_ASSERT_EQUAL_INT(1, g_reply_calls);
TEST_ASSERT_EQUAL_UINT64(555, g_last_seq);
TEST_ASSERT_FALSE(g_last_ok);
TEST_ASSERT_FALSE(g_last_error_null);
TEST_ASSERT_EQUAL_STRING("already recording", g_last_error);
/* "(unchanged)" -- requirements.md's recordStart(err) column. */
TEST_ASSERT_TRUE(g_last_recording);
TEST_ASSERT_FALSE(g_last_file_null);
TEST_ASSERT_EQUAL_STRING(path_before, g_last_file);
TEST_ASSERT_EQUAL_UINT64(0, g_last_event_count);
TEST_ASSERT_EQUAL_UINT64(started_before, g_last_started_at);
TEST_ASSERT_EQUAL_INT(0, sched_record_stop(&sched));
}
static void test_recordstart_invalid_filename_replies_error(void)
{
char record_dir[PATH_MAX];
xce_scheduler_t sched;
char escape_path[PATH_MAX];
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_start_invalid");
fixture_init(&sched, record_dir);
/* Contains a path separator -- rejected before any filesystem access
* (recording.c's sched_validate_record_filename(), task 9.1). */
rc = send_admin_request(&sched, SchedAdminAction_recordStart,
"sub/dir.replay", 666);
TEST_ASSERT_EQUAL_INT(0, rc);
rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched);
TEST_ASSERT_EQUAL_INT(0, rc);
/* No file opened, no directory created, recording never started. */
TEST_ASSERT_FALSE(sched.recording);
TEST_ASSERT_NULL(sched.record_file_path);
TEST_ASSERT_NULL(sched.record_file);
snprintf(escape_path, sizeof(escape_path), "%s/sub", record_dir);
TEST_ASSERT_FALSE(
(access(escape_path, F_OK) == 0)); /* "sub" was never created */
TEST_ASSERT_EQUAL_INT(1, g_reply_calls);
TEST_ASSERT_FALSE(g_last_ok);
TEST_ASSERT_FALSE(g_last_error_null);
TEST_ASSERT_EQUAL_STRING("invalid record file name", g_last_error);
TEST_ASSERT_FALSE(g_last_recording);
TEST_ASSERT_TRUE(g_last_file_null);
TEST_ASSERT_EQUAL_UINT64(0, g_last_event_count);
TEST_ASSERT_EQUAL_UINT64(0, g_last_started_at);
}
/* ---------------------------------------------------------------------- */
/* More recordStart path-validation rejection cases through the SAME real */
/* admin request path (task 9.5's genuine-gap audit): the test above only */
/* covers the path-separator case. sched_validate_record_filename() */
/* (recording.c, task 9.1) also rejects a ".."-traversal filename and a */
/* pre-planted symlink-escape -- test_recording_path_validation.c already */
/* proves both at the internal function level; these two prove the SAME */
/* rejections surface correctly once the deferred recordStart is actually */
/* attempted by xce_sched_driver_translate_pending_events(), through the */
/* real scheduler_req_handler() entry point, exactly like the */
/* path-separator test above. */
/* ---------------------------------------------------------------------- */
static void test_recordstart_dotdot_filename_replies_error(void)
{
char record_dir[PATH_MAX];
xce_scheduler_t sched;
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_start_dotdot");
fixture_init(&sched, record_dir);
/* Contains ".." -- rejected before any filesystem access, same
* short-circuiting string check as the path-separator case above but a
* genuinely DIFFERENT branch inside sched_validate_record_filename()
* (strstr(filename, "..") rather than strchr(filename, '/')). */
rc = send_admin_request(&sched, SchedAdminAction_recordStart,
"../evil.replay", 1111);
TEST_ASSERT_EQUAL_INT(0, rc);
rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_FALSE(sched.recording);
TEST_ASSERT_NULL(sched.record_file_path);
TEST_ASSERT_NULL(sched.record_file);
TEST_ASSERT_EQUAL_INT(1, g_reply_calls);
TEST_ASSERT_FALSE(g_last_ok);
TEST_ASSERT_FALSE(g_last_error_null);
TEST_ASSERT_EQUAL_STRING("invalid record file name", g_last_error);
TEST_ASSERT_FALSE(g_last_recording);
TEST_ASSERT_TRUE(g_last_file_null);
TEST_ASSERT_EQUAL_UINT64(0, g_last_event_count);
TEST_ASSERT_EQUAL_UINT64(0, g_last_started_at);
/* The driver loop keeps working after the rejection: a subsequent
* recordStatus request through the SAME real handler entry point
* (synchronous, not deferred) succeeds cleanly, proving the rejected
* recordStart left no dangling state that would wedge later requests. */
rc = send_admin_request(&sched, SchedAdminAction_recordStatus, NULL, 1112);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(2, g_reply_calls);
TEST_ASSERT_TRUE(g_last_ok);
TEST_ASSERT_FALSE(g_last_recording);
}
static void test_recordstart_symlink_escape_replies_error(void)
{
char record_dir[PATH_MAX];
char outside_dir[PATH_MAX];
char outside_target[PATH_MAX];
char link_path[PATH_MAX];
xce_scheduler_t sched;
struct stat st;
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_start_symlink");
make_tmp_dir(outside_dir, sizeof(outside_dir), "_start_symlink_dst");
fixture_init(&sched, record_dir);
/* Same symlink-escape shape as test_recording_path_validation.c's
* test_symlink_inside_record_dir_escaping_outside_is_rejected: a
* pre-existing symlink INSIDE record_dir, named exactly like an
* innocent-looking filename, whose target lives OUTSIDE record_dir.
* The filename component alone ("looks_safe") violates neither the
* '/' nor the ".." rule -- only the canonical-path containment check
* on the pre-existing symlink's resolved target catches this. Proven
* here through the real admin request path, not just at the internal
* sched_validate_record_filename() level.
*
* bug-1560: a canonical-path containment failure is deliberately
* -EACCES, not -EINVAL (sched_validate_record_filename(),
* recording.c's path_is_contained() branch) -- -EINVAL is reserved
* for a malformed filename rejected before any filesystem access
* (Step 1: '/' or ".." in the bare filename, see
* test_recordstart_dotdot_filename_replies_error above). The sibling
* unit test at the sched_validate_record_filename() level already
* asserts -EACCES for this exact scenario
* (test_symlink_inside_record_dir_escaping_outside_is_rejected,
* test_recording_path_validation.c) -- this test's own expected
* error string below was simply wrong, asserting the -EINVAL string
* against what is, and always was, an -EACCES rejection. Not an
* environment-dependent permission-check ordering issue as
* originally suspected: this fails identically as uid 0 or uid
* 1000, since escape-containment and filesystem-permission errors
* were never actually conflated in production code -- only in this
* test's assertion. */
snprintf(outside_target, sizeof(outside_target), "%s/secret", outside_dir);
touch_file(outside_target);
snprintf(link_path, sizeof(link_path), "%s/looks_safe", record_dir);
TEST_ASSERT_EQUAL_INT(0, symlink(outside_target, link_path));
rc = send_admin_request(&sched, SchedAdminAction_recordStart, "looks_safe",
2222);
TEST_ASSERT_EQUAL_INT(0, rc);
rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_FALSE(sched.recording);
TEST_ASSERT_NULL(sched.record_file_path);
TEST_ASSERT_NULL(sched.record_file);
TEST_ASSERT_EQUAL_INT(1, g_reply_calls);
TEST_ASSERT_FALSE(g_last_ok);
TEST_ASSERT_FALSE(g_last_error_null);
TEST_ASSERT_EQUAL_STRING("permission denied accessing record file",
g_last_error);
TEST_ASSERT_FALSE(g_last_recording);
TEST_ASSERT_TRUE(g_last_file_null);
TEST_ASSERT_EQUAL_UINT64(0, g_last_event_count);
TEST_ASSERT_EQUAL_UINT64(0, g_last_started_at);
/* The escaped-to outside file was never opened, let alone truncated --
* proves the rejection happened before any fopen()/ftruncate() on the
* resolved-outside path. */
TEST_ASSERT_EQUAL_INT(0, stat(outside_target, &st));
TEST_ASSERT_EQUAL_INT(0, (int)st.st_size);
/* Driver loop keeps working after the rejection, same proof as the
* ".."-traversal case above. */
rc = send_admin_request(&sched, SchedAdminAction_recordStatus, NULL, 2223);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(2, g_reply_calls);
TEST_ASSERT_TRUE(g_last_ok);
TEST_ASSERT_FALSE(g_last_recording);
}
/* ---------------------------------------------------------------------- */
/* recordStop -- deferred, not synchronous. */
/* ---------------------------------------------------------------------- */
static void test_recordstop_is_deferred_then_performed_by_driver_round(void)
{
char record_dir[PATH_MAX];
xce_scheduler_t sched;
char path_before[PATH_MAX];
uint64_t started_before;
xce_core_event_t ev;
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_stop_deferred");
fixture_init(&sched, record_dir);
rc = sched_record_start(&sched, "stopme.replay");
TEST_ASSERT_EQUAL_INT(0, rc);
strcpy(path_before, sched.record_file_path);
started_before = sched.record_start_ts;
memset(&ev, 0, sizeof(ev));
ev.type = XCE_CORE_EVENT_JOB_STATE_CHANGE;
ev.u.job_state_change.jobid = 9ULL;
TEST_ASSERT_EQUAL_INT(0, sched_record_write_events(&sched, &ev, 1));
TEST_ASSERT_EQUAL_UINT64(1ULL, sched.record_event_count);
rc = send_admin_request(&sched, SchedAdminAction_recordStop, NULL, 777);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Deferred, not synchronous: still recording, no reply yet. */
TEST_ASSERT_TRUE(sched.recording);
TEST_ASSERT_EQUAL_INT(0, g_reply_calls);
rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_FALSE(sched.recording);
TEST_ASSERT_NULL(sched.record_file_path);
TEST_ASSERT_NULL(sched.record_file);
TEST_ASSERT_EQUAL_INT(1, g_reply_calls);
TEST_ASSERT_EQUAL_UINT64(777, g_last_seq);
TEST_ASSERT_TRUE(g_last_ok);
TEST_ASSERT_TRUE(g_last_error_null);
TEST_ASSERT_FALSE(g_last_recording);
/* "last path (informational)". */
TEST_ASSERT_FALSE(g_last_file_null);
TEST_ASSERT_EQUAL_STRING(path_before, g_last_file);
/* "final count". */
TEST_ASSERT_EQUAL_UINT64(1ULL, g_last_event_count);
/* "original start (historical)". */
TEST_ASSERT_EQUAL_UINT64(started_before, g_last_started_at);
}
static void test_recordstop_while_not_recording_replies_error(void)
{
char record_dir[PATH_MAX];
xce_scheduler_t sched;
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_stop_notrecording");
fixture_init(&sched, record_dir);
rc = send_admin_request(&sched, SchedAdminAction_recordStop, NULL, 888);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, g_reply_calls); /* deferred */
rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_FALSE(sched.recording);
TEST_ASSERT_EQUAL_INT(1, g_reply_calls);
TEST_ASSERT_EQUAL_UINT64(888, g_last_seq);
TEST_ASSERT_FALSE(g_last_ok);
TEST_ASSERT_FALSE(g_last_error_null);
TEST_ASSERT_EQUAL_STRING("not recording", g_last_error);
TEST_ASSERT_FALSE(g_last_recording);
TEST_ASSERT_TRUE(g_last_file_null);
TEST_ASSERT_EQUAL_UINT64(0, g_last_event_count);
TEST_ASSERT_EQUAL_UINT64(0, g_last_started_at);
}
/* ---------------------------------------------------------------------- */
/* Disk-full simulation through the admin/request-handler path (task 9.5). */
/* Mirrors test_record_lifecycle.c's */
/* test_write_failure_disables_recording_and_driver_keeps_running() -- */
/* same real fflush()+setvbuf(_IONBF)+close(fd) write-failure technique, */
/* task 9.3's own Observable -- but with BOTH the initiating recordStart */
/* AND the verifying recordStatus driven through scheduler_req_handler(), */
/* the real production request-handler entry point, rather than task */
/* 9.3's direct internal sched_record_start()/sched_record_status() calls. */
/* ---------------------------------------------------------------------- */
static void
test_write_failure_through_admin_path_disables_recording_cleanly(void)
{
char record_dir[PATH_MAX];
xce_scheduler_t sched;
xce_core_event_t ev;
int rc;
int fd;
int calls_before;
uint64_t original_started_at;
make_tmp_dir(record_dir, sizeof(record_dir), "_diskfull");
fixture_init(&sched, record_dir);
/* 1. recordStart, genuinely through the admin request path: deferred,
* then performed by a real driver round -- same pattern as
* test_recordstart_is_deferred_then_performed_by_driver_round above. */
rc = send_admin_request(&sched, SchedAdminAction_recordStart,
"diskfull.replay", 3001);
TEST_ASSERT_EQUAL_INT(0, rc);
rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_TRUE(sched.recording);
TEST_ASSERT_NOT_NULL(sched.record_file);
TEST_ASSERT_EQUAL_INT(1, g_reply_calls);
TEST_ASSERT_TRUE(g_last_ok);
/* sched_record_abort_on_error() (recording.c) deliberately does NOT
* reset record_start_ts -- only sched_record_start() ever (re)stamps
* it, on the NEXT successful start (recording.c's own field-write
* list). So a post-abort recordStatus must still report THIS original
* started-at, not 0 -- captured here for that later assertion. */
original_started_at = g_last_started_at;
/* 2. Force a genuine write failure on the now-open sched->record_file --
* the same real technique test_record_lifecycle.c uses: flush what
* recordStart already buffered, force the stream unbuffered, then close
* the underlying fd out from under the FILE* so the very next fwrite()
* hits a genuine EBADF, not a simulated return code. */
TEST_ASSERT_EQUAL_INT(0, fflush(sched.record_file));
TEST_ASSERT_EQUAL_INT(0, setvbuf(sched.record_file, NULL, _IONBF, 0));
fd = fileno(sched.record_file);
TEST_ASSERT_TRUE(fd >= 0);
TEST_ASSERT_EQUAL_INT(0, close(fd));
/* 3. Drive the failing write via one real event batch -- the same
* sched_record_write_events() call
* xce_sched_driver_translate_pending_events() itself invokes for a
* translated batch (this file's own
* test_recordstatus_while_recording_reflects_live_state above already
* establishes this direct call as this suite's real event-batch
* trigger). */
memset(&ev, 0, sizeof(ev));
ev.type = XCE_CORE_EVENT_JOB_STATE_CHANGE;
ev.u.job_state_change.jobid = 42ULL;
TEST_ASSERT_TRUE(sched_record_write_events(&sched, &ev, 1) < 0);
/* (a) sched_record_abort_on_error()'s cleanup genuinely fired. */
TEST_ASSERT_FALSE(sched.recording);
TEST_ASSERT_NULL(sched.record_file);
TEST_ASSERT_NULL(sched.record_file_path);
/* (b) A subsequent recordStatus request through the SAME real handler
* entry point correctly reports the abort. */
calls_before = g_reply_calls;
rc = send_admin_request(&sched, SchedAdminAction_recordStatus, NULL, 3002);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(calls_before + 1, g_reply_calls);
TEST_ASSERT_EQUAL_UINT64(3002, g_last_seq);
TEST_ASSERT_TRUE(g_last_ok);
TEST_ASSERT_FALSE(g_last_recording);
TEST_ASSERT_TRUE(g_last_file_null);
TEST_ASSERT_EQUAL_UINT64(0, g_last_event_count);
TEST_ASSERT_EQUAL_UINT64(original_started_at, g_last_started_at);
/* (c) The scheduler keeps processing normally afterward: a further
* admin request -- a brand new recordStart -- succeeds cleanly through
* the SAME real handler + driver-round path, genuinely creating a new
* file on disk. */
calls_before = g_reply_calls;
rc = send_admin_request(&sched, SchedAdminAction_recordStart,
"recovery.replay", 3003);
TEST_ASSERT_EQUAL_INT(0, rc);
rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(calls_before + 1, g_reply_calls);
TEST_ASSERT_TRUE(g_last_ok);
TEST_ASSERT_TRUE(sched.recording);
TEST_ASSERT_NOT_NULL(sched.record_file_path);
TEST_ASSERT_TRUE(strstr(sched.record_file_path, "recovery.replay") != NULL);
}
/* ---------------------------------------------------------------------- */
/* Unrecognized action / NULL argument guards. */
/* ---------------------------------------------------------------------- */
static void test_unrecognized_action_returns_einval_no_crash_no_reply(void)
{
char record_dir[PATH_MAX];
xce_scheduler_t sched;
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_bad_action");
fixture_init(&sched, record_dir);
rc = send_admin_request(&sched, 999 /* not a valid SchedAdminAction */,
NULL, 1010);
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
TEST_ASSERT_EQUAL_INT(0, g_reply_calls); /* no synchronous reply */
TEST_ASSERT_NULL(
dataq_deq(sched.core.pend_updates[0], NULL)); /* nothing enqueued */
/* A subsequent driver round is a genuine no-op -- nothing to drain. */
rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, g_reply_calls);
}
static void test_null_sched_returns_einval(void)
{
sched_admin_req_t areq;
xce_request_t request;
memset(&areq, 0, sizeof(areq));
areq.action = SchedAdminAction_recordStatus;
memset(&request, 0, sizeof(request));
request.opcode = OPCode_schedAdmin;
request.body_which = Request_body_schedadminmsg;
request.body.schedadminmsg = &areq;
TEST_ASSERT_EQUAL_INT(-EINVAL, sched_handle_admin_request(NULL, &request));
TEST_ASSERT_EQUAL_INT(0, g_reply_calls);
}
static void test_null_request_returns_einval(void)
{
char record_dir[PATH_MAX];
xce_scheduler_t sched;
make_tmp_dir(record_dir, sizeof(record_dir), "_null_req");
fixture_init(&sched, record_dir);
TEST_ASSERT_EQUAL_INT(-EINVAL, sched_handle_admin_request(&sched, NULL));
TEST_ASSERT_EQUAL_INT(0, g_reply_calls);
}
static void test_null_schedadminmsg_body_returns_einval(void)
{
char record_dir[PATH_MAX];
xce_scheduler_t sched;
xce_request_t request;
make_tmp_dir(record_dir, sizeof(record_dir), "_null_body");
fixture_init(&sched, record_dir);
memset(&request, 0, sizeof(request));
request.opcode = OPCode_schedAdmin;
request.body_which = Request_body_schedadminmsg;
request.body.schedadminmsg = NULL;
TEST_ASSERT_EQUAL_INT(-EINVAL,
sched_handle_admin_request(&sched, &request));
TEST_ASSERT_EQUAL_INT(0, g_reply_calls);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_recordstatus_not_recording_returns_empty_state);
RUN_TEST(test_recordstatus_while_recording_reflects_live_state);
RUN_TEST(test_recordstatus_genuinely_blocks_on_record_lock);
RUN_TEST(test_recordstatus_file_survives_concurrent_record_stop_free);
RUN_TEST(test_recordstart_is_deferred_then_performed_by_driver_round);
RUN_TEST(test_recordstart_empty_file_auto_generates_name);
RUN_TEST(test_recordstart_while_already_recording_replies_error);
RUN_TEST(test_recordstart_invalid_filename_replies_error);
RUN_TEST(test_recordstart_dotdot_filename_replies_error);
RUN_TEST(test_recordstart_symlink_escape_replies_error);
RUN_TEST(test_recordstop_is_deferred_then_performed_by_driver_round);
RUN_TEST(test_recordstop_while_not_recording_replies_error);
RUN_TEST(test_write_failure_through_admin_path_disables_recording_cleanly);
RUN_TEST(test_unrecognized_action_returns_einval_no_crash_no_reply);
RUN_TEST(test_null_sched_returns_einval);
RUN_TEST(test_null_request_returns_einval);
RUN_TEST(test_null_schedadminmsg_body_returns_einval);
return UNITY_END();
}