myxce/src/scheduler/testsuite/test_driver_session_ordering.c
Rongsong Shen c9acd502c0
fix(scheduler): clock/resval/jobid-format fixes plus REPL live-operator gaps (task 12.5)
Bugs fixed, discovered across a day of live-operator testing against a
real deployed cluster:

- bug-1584: clock anchors never initialized to real wall-clock values,
  timestamps showed system uptime instead of Unix epoch.
- bug-1585: heap-use-after-free in the normal-mode driver's periodic
  resval update path -- core_events.c's RESVALS_FULL/_DELTA handlers do a
  shallow zarray_put() of the wire-decoded xce_val_t*, but driver.c's
  release_translated_payload() unconditionally freed the same decoded
  array right after, so the next periodic report for the same
  worker+resource triggered a UAF read via kill_sched_resval(). New
  regression test: test_resval_ownership.c.
- bug-1587/1588/1591: raw-ns/raw-uint64 jobid/timestamp prints across the
  REPL/SIM family converted to the same human-readable formatting already
  used elsewhere (fmt_jobid(), timestamp formatting).
- bug-1590: repl_info.c's XCE_VAL_NUMERIC checks corrected to
  Value_u_fvalue, fixing `info worker`/`info resource` falling back to
  0.00 for real numeric resvals.

task 12.5 (Req 5.22 amendment) -- five REPL gaps surfaced by a live
operator session, each with its own bug id:
- bug-1594: `continue`/`step` reaching EOF with no breakpoint hit now
  re-enters the REPL via xce_repl_on_replay_complete() instead of exiting
  the process; step/continue issued after replay is already complete
  re-prompt instead of silently exiting.
- bug-1595: `step`/`continue` now echo each input event (not just
  decisions) via repl_print_event_summary(), in real event/decision
  order.
- bug-1593: `info resource <name>|all` now reports resource definition
  metadata restricted to XCE_RESF_GLOBAL/XCE_RESF_SHARED resources, read
  from core->sharedInst, instead of aggregating host-based resources
  across every worker.
- bug-1596: repl_split_tokens() strips one matching pair of surrounding
  quote characters from each token.

See requirements.md's Req 5 AC 22 "Amendment (2026-08-19)" for the full
rationale on each of the five task 12.5 gaps.

Full scheduler suite green (41/42; the one remaining failure,
test_recordstart_symlink_escape_replies_error, is the pre-existing
unrelated bug-1560).
2026-08-19 09:48:46 +00:00

804 lines
31 KiB
C

/*
* test_driver_session_ordering.c - the safety-property proof referenced by
* name in core.h's xce_core_begin_session()/xce_core_signal_go() doc
* comment, driver.h's xce_sched_driver_run_normal_mode_round() doc comment,
* and driver.c's own header comment (scheduler-core spec, task 5.2, Req 1
* AC 7 / Req 5 AC 9).
*
* The property under test: a normal-mode scheduling round MUST call
* plugin_begin_session()/sched_account_jobs() strictly AFTER
* xce_core_begin_session()'s resval reset and strictly BEFORE
* xce_core_signal_go() wakes the picker thread -- otherwise the picker
* could observe a session whose resource-in-use accounting was reset to
* zero but not yet re-summed from currently running jobs, letting the
* picker/allocator over-allocate resources already consumed by running
* jobs. plugin_end_session() must likewise actually run, once per
* completed round.
*
* Function under test: the REAL xce_sched_driver_run_normal_mode_round()
* (driver.c) -- NOT a hand-copied reimplementation of its call sequence.
* That function started life as a `static` helper inside main.c's loop
* body, unreachable from any test binary; it was relocated (same file
* content, task 5.2's own scope) into driver.c and exported via driver.h
* specifically so this safety property could be proven against the real
* production code path instead of a copy that could silently drift from
* it over time -- see driver.h's declaration comment for the full
* rationale. This file white-box #includes ../src/driver.c (same
* convention as test_driver_translate.c/test_driver_decision_cb.c) to pull
* that real function into this TU.
*
* Design: real picker thread + real stand-in allocator thread, NOT a
* sequential drain in the test's own thread -- same reasoning
* test_core_full_cycle.c's own header comment gives for its identical
* design choice, and the same reason applies with EXTRA force here:
* xce_sched_driver_run_normal_mode_round() is now a single BLOCKING call
* (it does not return control to its caller between xce_core_signal_go()
* and the decision-drain loop the way test_core_full_cycle.c's own test
* function -- which plays driver AND allocator itself -- can), so the
* calling (main/test) thread cannot ALSO act as the stand-in allocator the
* way test_core_full_cycle.c's test function does. This test therefore
* spawns two collaborator threads:
*
* - picker_thread_fn(): reproduces sched_job_picker()'s real per-session
* body verbatim (identical to test_core_full_cycle.c's
* test_picker_thread_fn), single-shot for this test's one session.
* This fixture pushes no jobs, so the picker's own next_job() returns
* NULL immediately -- but the wbuf rendezvous (cbuf_get(&core->wbuf))
* this thread blocks on before doing ANYTHING else is exactly the
* cross-thread synchronization point that makes the ordering
* assertions below sound: whatever the driver did in its own thread
* BEFORE xce_core_signal_go()'s cbuf_put(&core->wbuf) is guaranteed
* visible-before whatever this thread does AFTER its cbuf_get(&wbuf)
* returns (the SAME reasoning test_core_full_cycle.c's own header
* comment documents for its analogous claim).
*
* - stand_in_allocator_thread_fn(): drains the picker's jbuf
* end-of-session sentinel (jobid == 0 -- the only jbuf item this
* fixture ever produces, since no jobs are pushed) and pushes the
* matching NULL end-of-session marker onto core->decisions, exactly
* as sched_allocator() (scheduler.c) does for a session that elects no
* jobs. This is what unblocks xce_sched_driver_run_normal_mode_round()'s
* own decision-drain loop so the round can complete and reach
* plugin_end_session()/xce_core_end_session().
*
* plugin_begin_session()/plugin_end_session() are the REAL production
* functions (white-box #include of ../src/plugin.c), driven through a
* genuinely test-registered xce_sched_plugin_t (g_observer_plugin below)
* whose begin_session_fn/end_session_fn set an atomic counter + log an
* event -- per this task's own suggested technique, this is a stronger
* proof than spying on plugin_begin_session() itself: it proves BOTH that
* xce_sched_driver_run_normal_mode_round() calls the real
* plugin_begin_session()/plugin_end_session() symbols AND that their real
* iterate-registered-plugins logic fires a genuinely registered plugin's
* hooks when driven this way. plugin.c is not otherwise exercised by any
* existing test file in this directory (codegraph's own blast-radius
* report flags plugin_begin_session/plugin_end_session/sched_account_jobs
* as having "no covering tests found" before this file), so this is
* incidental first coverage for plugin_begin_session()/plugin_end_session()
* as a byproduct of this task's own target.
*
* sched_account_jobs() (scheduler.c) is instead a SPY test double (this
* file never links scheduler.c) -- same rationale
* test_driver_decision_cb.c/test_driver_translate.c already established
* for sched_publish_dispatch()/sched_publish_pend_reason(): it is
* xce_scheduler_t*-coupled and pulls in resource.c's sched_charge_account()
* machinery, whose OWN resource-accounting correctness is out of THIS
* task's scope (this test's target is the DRIVER's ordering discipline --
* does it call sched_account_jobs() at all, and in the right window --
* not whether sched_account_jobs() itself charges resources correctly,
* which is scheduler-allocation spec territory with its own coverage).
*
* White-box #include of ../src/core.c, ../src/core_clock.c,
* ../src/core_events.c, ../src/core_session.c, ../src/decision.c,
* ../src/driver.c (same file set as test_core_full_cycle.c, needed for the
* real xce_sched_core_init()/xce_core_begin_session()/xce_core_signal_go()/
* xce_core_end_session()/xce_core_set_decision_cb() under test, transitively
* required by driver.c) PLUS ../src/plugin.c (new to this file, for the
* real plugin_begin_session()/plugin_end_session() -- see above).
*
* Stubs below: the usual kill_sched_* / sched_worker_release_host_ctx /
* sched_resolve_job_picker set (same rationale as test_core_full_cycle.c's
* own identical stubs -- this fixture never tears down, never adds a
* worker, and needs its own real reimplementation of schedobj.c's
* sched_resolve_job_picker since schedobj.c itself is not linked) PLUS
* sched_publish_dispatch()/sched_publish_pend_reason() link-satisfying
* no-op stubs (driver.c's xce_sched_driver_on_decision references them;
* never actually invoked here since this fixture produces no DISPATCH/PEND
* decisions) PLUS sched_fill_limit_requests() (resource.c) -- plugin.c's
* plugin_prepare_allocation() references it; never actually invoked here
* either (this test never calls plugin_prepare_allocation()), a pure
* link-satisfying no-op like the others. Unlike every other file in this
* directory, plugin_unregister() is NOT stubbed here -- it is the REAL
* plugin.c symbol (needed as core.c's plugins-kvmgr datakiller), never
* actually invoked at runtime since this fixture, like every other file's,
* is never torn down.
*
* CONCERN surfaced during this test's construction (documented here, not
* silently worked around): plugin_end_session() (plugin.c, pre-existing,
* untouched by this task's diff) has a latent bug -- its per-plugin guard
* checks `plugin->begin_session_fn != NULL` but then calls
* `plugin->end_session_fn(plugin)`. A plugin registering ONLY
* end_session_fn (no begin_session_fn) would silently never have its
* end_session_fn invoked; a plugin registering ONLY begin_session_fn (no
* end_session_fn) would crash via a NULL function-pointer call the first
* time a round ends. g_observer_plugin below deliberately sets BOTH
* fields, so this test's own result is unaffected by the bug -- but the
* bug is real, pre-existing, and out of this task's scope to fix (it is
* not part of task 5.2's diff and not something
* xce_sched_driver_run_normal_mode_round() can compensate for from the
* outside); flagged here for the reviewer rather than silently working around
* it.
*/
#include <errno.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
#include "controller.capnp.h"
#include "kvmgr.h"
#include "preason.h"
#include "scheduler.h"
#include "xstring.h"
/* Pull in the real xce_sched_core_init()/xce_core_begin_session()/
* xce_core_signal_go()/xce_core_end_session()/xce_core_set_decision_cb()/
* xce_sched_driver_run_normal_mode_round()/plugin_begin_session()/
* plugin_end_session() under test into this TU (white-box #include, same
* convention as test_core_full_cycle.c/test_driver_translate.c/
* test_driver_decision_cb.c). */
#include "../src/core.c"
#include "../src/core_clock.c"
#include "../src/core_events.c"
#include "../src/core_session.c"
#include "../src/decision.c"
#include "../src/driver.c"
#include "../src/plugin.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; }
/* dup_val - real reimplementation of snapshot_wire.c's function of the same
* name (verbatim logic), needed because core_events.c's RESVALS_FULL/
* RESVALS_DELTA handlers now call it (bug-1585 fix) and this file's
* white-box #include of ../src/core_events.c does not pull in
* snapshot_wire.c (same "reimplement the one small function instead of the
* whole file" rationale this file's own sched_resolve_job_picker copy
* already documents). This file's own kill_sched_resval is a no-op stub, so
* the deep copy's contents are never inspected here -- only link-time
* presence matters for this file's own tests. */
xce_val_t* dup_val(const xce_val_t* src)
{
xce_val_t* v;
if (src == NULL) {
return NULL;
}
v = (xce_val_t*)calloc(1, sizeof(*v));
if (v == NULL) {
return NULL;
}
v->which = src->which;
if (src->which == XCE_VAL_STRING) {
v->u.svalue
= (src->u.svalue != NULL) ? makeString(src->u.svalue) : NULL;
} else {
v->u = src->u;
}
return v;
}
/* core_reset_workers()'s (core_session.c) linker dependency -- see file
* header comment; never invoked (no worker is ever added here). */
void sched_worker_release_host_ctx(xce_sched_worker_t* w) { (void)w; }
/* Link-satisfying stub for scheduler.c's sched_publish_dispatch()/
* sched_publish_pend_reason() -- see file header comment. Never actually
* invoked at runtime: this fixture pushes no jobs, so the driver's
* decision-drain loop never observes a non-NULL decision, so
* xce_sched_driver_on_decision() (driver.c) is never called here. */
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 stub for resource.c's sched_fill_limit_requests() --
* plugin.c's plugin_prepare_allocation() references it; never actually
* invoked here (this test never calls plugin_prepare_allocation()). See
* file header comment. */
int sched_fill_limit_requests(xce_scheduler_t* sched, xvec_idval_t* vec,
xlist_t* limits)
{
(void)sched;
(void)vec;
(void)limits;
return 0;
}
/* Link-satisfying stubs for recording.c/recording_events.c/
* recording_session.c's real functions -- as of scheduler-core spec task
* 9.2, driver.c's (real, #included above)
* xce_sched_driver_translate_pending_events()/
* xce_sched_driver_run_normal_mode_round() reference these
* unconditionally (the `sched.recording`/`record_ctx != NULL` gates that
* decide whether they do real work are runtime checks INSIDE these
* functions, not something that removes the call site itself -- the
* linker needs the symbol regardless). This file's fixture never sets
* `sched.recording` (memset(&sched, 0, ...) below leaves it false), so at
* runtime xce_sched_driver_translate_pending_events() calls this stub with
* a non-NULL `sched` (record_ctx == &sched, always non-NULL here) but
* xce_sched_driver_run_normal_mode_round() never calls
* sched_record_session_begin() with `sched.recording == true` -- either
* way, these stubs never need to do real work for this file's ordering
* proof, which is not about recording. Never links recording.c's real
* implementations (they need xce_sched_core_save_snapshot(), snapshot.c,
* an even bigger dependency surface this file's own fixture doesn't
* otherwise need). */
int sched_record_write_events(xce_scheduler_t* sched, xce_core_event_t* events,
int n)
{
(void)sched;
(void)events;
(void)n;
return 0;
}
/* sched_record_start/sched_record_stop (scheduler-core spec, task 9.4) --
* driver.c's handle_deferred_admin_record() (new this task) calls both;
* this file never enqueues an XCE_SCHED_OP_ADMIN_RECORD request, so
* neither is actually invoked at runtime, same rationale as
* sched_record_write_events above. */
int sched_record_start(xce_scheduler_t* sched, const char* record_file)
{
(void)sched;
(void)record_file;
return 0;
}
int sched_record_stop(xce_scheduler_t* sched)
{
(void)sched;
return 0;
}
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 -- see
* file header comment (same rationale/algorithm as test_core_full_cycle.c's
* own copy). */
int sched_resolve_job_picker(xce_sched_core_t* core, xce_sched_plugin_t** out)
{
xce_sched_plugin_t* plugin = NULL;
int rc = 0;
if ((core == NULL) || (out == NULL)) {
return -EINVAL;
}
*out = NULL;
if (core->job_pick_plugin == NULL) {
return -EINVAL;
}
lock(core->plugins, RTTI_LOCK_READ);
rc = key_find(core->plugins, core->job_pick_plugin, (void**)&plugin);
unlock(core->plugins);
if (rc <= 0) {
return (rc < 0) ? rc : -ENOENT;
}
if ((plugin == NULL) || (plugin->picker_iface == NULL)
|| (plugin->picker_iface->next_job == NULL)
|| (plugin->picker_iface->begin_session == NULL)
|| (plugin->picker_iface->end_session == NULL)
|| (plugin->picker_iface->on_dispatch == NULL)
|| (plugin->picker_iface->on_pend == NULL)) {
return -EINVAL;
}
*out = plugin;
return 0;
}
/* sched_account_jobs() itself is defined further below (SPY test double
* section) -- its prototype already comes from scheduler.h, included
* above. */
void setUp(void) { return; }
void tearDown(void) { return; }
/* ---------------------------------------------------------------------- */
/* Event log: records hook/thread firing order across all three threads */
/* involved (main/driver thread, picker thread, stand-in allocator thread) */
/* via a single atomically-claimed index per entry -- same pattern/ */
/* thread-safety argument as test_core_full_cycle.c's own g_events. */
/* ---------------------------------------------------------------------- */
typedef enum {
EVT_OBSERVER_BEGIN_SESSION,
EVT_ACCOUNT_JOBS,
EVT_PICKER_BEGIN_SESSION,
EVT_PICKER_NEXT_JOB_NULL,
EVT_PICKER_END_SESSION,
EVT_ALLOCATOR_DRAINED_JBUF_SENTINEL,
EVT_ALLOCATOR_PUSHED_END_DECISION,
EVT_OBSERVER_END_SESSION,
} ordering_event_tag_t;
#define MAX_EVENTS 32
typedef struct {
ordering_event_tag_t tags[MAX_EVENTS];
_Atomic int n;
} event_log_t;
static event_log_t g_events;
static void log_event(ordering_event_tag_t tag)
{
int idx = atomic_fetch_add(&(g_events.n), 1);
if (idx < MAX_EVENTS) {
g_events.tags[idx] = tag;
}
}
static int find_log_index(ordering_event_tag_t tag)
{
int n = atomic_load(&(g_events.n));
int cap = (n > MAX_EVENTS) ? MAX_EVENTS : n;
int i;
for (i = 0; i < cap; i++) {
if (g_events.tags[i] == tag) {
return i;
}
}
return -1;
}
/* ---------------------------------------------------------------------- */
/* sched_account_jobs() SPY -- see file header comment. */
/* ---------------------------------------------------------------------- */
static _Atomic int g_account_jobs_calls;
int sched_account_jobs(xce_scheduler_t* sched)
{
(void)sched;
atomic_fetch_add(&g_account_jobs_calls, 1);
log_event(EVT_ACCOUNT_JOBS);
return 0;
}
/* ---------------------------------------------------------------------- */
/* Observer plugin: OLD begin_session_fn/end_session_fn hook category */
/* (plugin.h) -- genuinely registered into core->plugins and driven by */
/* the REAL plugin_begin_session()/plugin_end_session() (plugin.c). See */
/* file header comment for why both fields are set (sidesteps the */
/* documented plugin_end_session() guard-field bug). */
/* ---------------------------------------------------------------------- */
static _Atomic int g_observer_begin_calls;
static _Atomic int g_observer_end_calls;
static int observer_begin_session_fn(void* arg)
{
(void)arg;
atomic_fetch_add(&g_observer_begin_calls, 1);
log_event(EVT_OBSERVER_BEGIN_SESSION);
return 0;
}
static int observer_end_session_fn(void* arg)
{
(void)arg;
atomic_fetch_add(&g_observer_end_calls, 1);
log_event(EVT_OBSERVER_END_SESSION);
return 0;
}
static xce_sched_plugin_t g_observer_plugin
= { .name = "test_ordering_observer",
.begin_session_fn = observer_begin_session_fn,
.end_session_fn = observer_end_session_fn };
/* ---------------------------------------------------------------------- */
/* Stub picker plugin: NEW picker_iface category (plugin.h) -- resolved by */
/* sched_resolve_job_picker() and driven by a real picker pthread below. */
/* This fixture never pushes any jobs, so next_job() always returns NULL */
/* immediately -- the picker's SOLE purpose here is to prove the wbuf */
/* rendezvous timing (see file header comment), not to exercise job flow. */
/* ---------------------------------------------------------------------- */
static int stub_picker_begin_session(void* picker)
{
(void)picker;
log_event(EVT_PICKER_BEGIN_SESSION);
return 0;
}
static xce_sched_job_t* stub_picker_next_job(void* picker)
{
(void)picker;
log_event(EVT_PICKER_NEXT_JOB_NULL);
return NULL;
}
static int stub_picker_end_session(void* picker)
{
(void)picker;
log_event(EVT_PICKER_END_SESSION);
return 0;
}
static int stub_picker_jobid_noop(void* picker, uint64_t jobid)
{
(void)picker;
(void)jobid;
return 0;
}
static int stub_picker_queue_update_noop(void* picker, xce_sched_queue_t* q)
{
(void)picker;
(void)q;
return 0;
}
static int stub_picker_job_noop(void* picker, xce_sched_job_t* job)
{
(void)picker;
(void)job;
return 0;
}
static int stub_picker_job_state_change_noop(void* picker, xce_sched_job_t* job,
uint32_t state)
{
(void)picker;
(void)job;
(void)state;
return 0;
}
static xce_job_picker_iface_t g_stub_picker_iface
= { .init = NULL,
.fini = NULL,
.begin_session = stub_picker_begin_session,
.next_job = stub_picker_next_job,
.end_session = stub_picker_end_session,
.on_dispatch = stub_picker_jobid_noop,
.on_pend = stub_picker_jobid_noop,
.on_queue_update = stub_picker_queue_update_noop,
.on_job_push = stub_picker_job_noop,
.on_job_state_change = stub_picker_job_state_change_noop,
.on_job_update = stub_picker_job_noop,
.on_config = NULL,
.on_job_rusage = NULL };
static xce_sched_plugin_t g_stub_picker_plugin
= { .name = "test_stub_picker", .picker_iface = &g_stub_picker_iface };
/* ---------------------------------------------------------------------- */
/* Picker thread: reproduces sched_job_picker()'s real per-session body */
/* verbatim (identical to test_core_full_cycle.c's own test_picker_thread_fn),
*/
/* single-shot -- see file header comment. */
/* ---------------------------------------------------------------------- */
static void* picker_thread_fn(void* arg)
{
xce_sched_core_t* core = (xce_sched_core_t*)arg;
xce_sched_plugin_t* job_picker = NULL;
int rc;
int go = 0;
uint64_t jobid;
rc = sched_resolve_job_picker(core, &job_picker);
if (rc < 0) {
return NULL;
}
cbuf_get(&(core->wbuf), &go);
job_picker->picker_iface->begin_session(job_picker);
for (;;) {
xce_sched_job_t* job = job_picker->picker_iface->next_job(job_picker);
if (job == NULL) {
break;
}
jobid = job->jobid;
cbuf_put(&(core->jbuf), &jobid);
}
jobid = 0;
cbuf_put(&(core->jbuf), &jobid);
job_picker->picker_iface->end_session(job_picker);
return NULL;
}
/* ---------------------------------------------------------------------- */
/* Stand-in allocator thread -- see file header comment for why this must */
/* be a real, separate thread here (unlike test_core_full_cycle.c, whose */
/* test function itself can play this role sequentially). */
/* ---------------------------------------------------------------------- */
static void* stand_in_allocator_thread_fn(void* arg)
{
xce_sched_core_t* core = (xce_sched_core_t*)arg;
uint64_t jobid;
xce_decision_t* decision = NULL;
cbuf_get(&(core->jbuf), &jobid);
(void)jobid; /* always 0 here -- this fixture pushes no jobs */
log_event(EVT_ALLOCATOR_DRAINED_JBUF_SENTINEL);
cbuf_put(&(core->decisions), &decision);
log_event(EVT_ALLOCATOR_PUSHED_END_DECISION);
return NULL;
}
/* ---------------------------------------------------------------------- */
/* Driver-side decision callback spy -- must never actually fire in this */
/* test (this fixture produces no DISPATCH/PEND decisions), asserted below.*/
/* ---------------------------------------------------------------------- */
typedef struct {
int calls;
} test_driver_ctx_t;
static int test_on_decision(void* driver_ctx, xce_decision_t* decision)
{
test_driver_ctx_t* ctx = (test_driver_ctx_t*)driver_ctx;
(void)decision;
ctx->calls++;
return 0;
}
/* ---------------------------------------------------------------------- */
/* The contract test. */
/* ---------------------------------------------------------------------- */
/*
* test_round_runs_plugin_begin_and_account_jobs_before_picker_signaled -
* drives the REAL xce_sched_driver_run_normal_mode_round() through one
* complete round against a real picker thread + a real stand-in allocator
* thread, and proves (see file header comment for the full design):
*
* 1. plugin_begin_session() (observer) and sched_account_jobs() (spy)
* both actually fire, exactly once each.
* 2. Both complete strictly BEFORE the picker thread's begin_session
* hook runs -- i.e. strictly before xce_core_signal_go()'s
* cbuf_put(&core->wbuf) is observed by the picker thread's
* cbuf_get(&wbuf). This is the central safety property (Req 1 AC 7 /
* Req 5 AC 9): "called in the right window", not just "eventually
* called".
* 3. plugin_end_session() (observer) fires exactly once, after the
* round's decision-drain loop genuinely receives the end-of-session
* marker (proven via the stand-in allocator's own cbuf_put ->
* cbuf_get cross-thread edge).
* 4. xce_core_end_session() genuinely clears session_active.
* 5. No decision ever reaches the registered on_decision callback (this
* fixture pushes no jobs -- a sanity check that the ordering proof
* above isn't accidentally relying on unrelated activity).
*/
static void
test_round_runs_plugin_begin_and_account_jobs_before_picker_signaled(void)
{
xce_scheduler_t sched;
pthread_t picker_tid;
pthread_t allocator_tid;
test_driver_ctx_t driver_ctx;
int rc;
int idx_observer_begin;
int idx_account_jobs;
int idx_picker_begin;
int idx_picker_next_null;
int idx_picker_end;
int idx_allocator_sentinel;
int idx_allocator_pushed_end;
int idx_observer_end;
memset(&g_events, 0, sizeof(g_events));
memset(&sched, 0, sizeof(sched));
memset(&driver_ctx, 0, sizeof(driver_ctx));
atomic_store(&g_account_jobs_calls, 0);
atomic_store(&g_observer_begin_calls, 0);
atomic_store(&g_observer_end_calls, 0);
rc = xce_sched_core_init(&(sched.core));
TEST_ASSERT_EQUAL_INT(0, rc);
/* Register both plugins directly into core->plugins (bypassing
* plugin_register() -- same convention test_core_full_cycle.c's
* fixture_init() uses: this test does not need plugin_register()'s
* own init_fn/legacy-wrapper logic, only the registered-plugin
* iteration plugin_begin_session()/plugin_end_session()/
* sched_resolve_job_picker() perform). Must run AFTER
* xce_sched_core_init() (which memsets the whole core, including
* job_pick_plugin, back to zero). */
lock(sched.core.plugins, RTTI_LOCK_WRITE);
rc = key_add(sched.core.plugins, (void*)g_observer_plugin.name,
(void*)&g_observer_plugin);
TEST_ASSERT_GREATER_OR_EQUAL(0, rc);
rc = key_add(sched.core.plugins, (void*)g_stub_picker_plugin.name,
(void*)&g_stub_picker_plugin);
TEST_ASSERT_GREATER_OR_EQUAL(0, rc);
unlock(sched.core.plugins);
sched.core.job_pick_plugin = g_stub_picker_plugin.name;
rc = xce_core_set_decision_cb(&(sched.core), test_on_decision, &driver_ctx);
TEST_ASSERT_EQUAL_INT(0, rc);
rc = pthread_create(&picker_tid, NULL, picker_thread_fn, &(sched.core));
TEST_ASSERT_EQUAL_INT(0, rc);
rc = pthread_create(&allocator_tid, NULL, stand_in_allocator_thread_fn,
&(sched.core));
TEST_ASSERT_EQUAL_INT(0, rc);
/* The function under test: the REAL production
* xce_sched_driver_run_normal_mode_round(), driven exactly the way
* main.c's while(1) loop drives it. Blocks until the round completes
* (the stand-in allocator thread above pushes the end-of-session NULL
* decision, unblocking this function's own decision-drain loop). */
xce_sched_driver_run_normal_mode_round(&sched);
rc = pthread_join(picker_tid, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
rc = pthread_join(allocator_tid, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
/* --- plugin_begin_session()/sched_account_jobs()/plugin_end_session()
* genuinely ran, exactly once each --- */
TEST_ASSERT_EQUAL_INT(1, atomic_load(&g_observer_begin_calls));
TEST_ASSERT_EQUAL_INT(1, atomic_load(&g_account_jobs_calls));
TEST_ASSERT_EQUAL_INT(1, atomic_load(&g_observer_end_calls));
/* --- No decision ever reached the driver callback --- */
TEST_ASSERT_EQUAL_INT(0, driver_ctx.calls);
/* --- xce_core_end_session() genuinely cleared session_active --- */
TEST_ASSERT_EQUAL_INT(0, atomic_load_explicit(&(sched.core.session_active),
memory_order_acquire));
/* --- Ordering, from the event log --- */
idx_observer_begin = find_log_index(EVT_OBSERVER_BEGIN_SESSION);
idx_account_jobs = find_log_index(EVT_ACCOUNT_JOBS);
idx_picker_begin = find_log_index(EVT_PICKER_BEGIN_SESSION);
idx_picker_next_null = find_log_index(EVT_PICKER_NEXT_JOB_NULL);
idx_picker_end = find_log_index(EVT_PICKER_END_SESSION);
idx_allocator_sentinel
= find_log_index(EVT_ALLOCATOR_DRAINED_JBUF_SENTINEL);
idx_allocator_pushed_end
= find_log_index(EVT_ALLOCATOR_PUSHED_END_DECISION);
idx_observer_end = find_log_index(EVT_OBSERVER_END_SESSION);
TEST_ASSERT_TRUE(idx_observer_begin >= 0);
TEST_ASSERT_TRUE(idx_account_jobs >= 0);
TEST_ASSERT_TRUE(idx_picker_begin >= 0);
TEST_ASSERT_TRUE(idx_picker_next_null >= 0);
TEST_ASSERT_TRUE(idx_picker_end >= 0);
TEST_ASSERT_TRUE(idx_allocator_sentinel >= 0);
TEST_ASSERT_TRUE(idx_allocator_pushed_end >= 0);
TEST_ASSERT_TRUE(idx_observer_end >= 0);
/* THE central safety-property assertion (Req 1 AC 7 / Req 5 AC 9): both
* plugin_begin_session() and sched_account_jobs() complete strictly
* BEFORE the picker thread's begin_session runs -- proven via the wbuf
* cross-thread handoff (see file header comment). Not "eventually
* called" -- "called in the right window". */
TEST_ASSERT_TRUE(idx_observer_begin < idx_picker_begin);
TEST_ASSERT_TRUE(idx_account_jobs < idx_picker_begin);
/* Documents the actual call order xce_sched_driver_run_normal_mode_round()
* uses today (plugin_begin_session() then sched_account_jobs()) -- per
* driver.c's own header comment this relative order is NOT itself
* safety-critical (neither has a data dependency on the other), but it
* is what genuinely happens, so it is asserted here too. */
TEST_ASSERT_TRUE(idx_observer_begin < idx_account_jobs);
/* plugin_end_session() runs strictly AFTER the round's decision-drain
* loop genuinely observed the end-of-session marker -- proven via the
* allocator thread's cbuf_put(&core->decisions)/main thread's
* cbuf_get(&core->decisions) cross-thread edge, the same style of proof
* as the wbuf handoff above. */
TEST_ASSERT_TRUE(idx_allocator_pushed_end < idx_observer_end);
}
/*
* test_null_driver_ctx_is_safe_noop - xce_sched_driver_run_normal_mode_round()
* guards `driver_ctx == NULL` before touching anything (driver.c's own
* `if (sched == NULL) { return; }`) -- must not crash, must not call any
* of the observer/spy hooks above.
*/
static void test_null_driver_ctx_is_safe_noop(void)
{
memset(&g_events, 0, sizeof(g_events));
atomic_store(&g_account_jobs_calls, 0);
atomic_store(&g_observer_begin_calls, 0);
atomic_store(&g_observer_end_calls, 0);
xce_sched_driver_run_normal_mode_round(NULL);
TEST_ASSERT_EQUAL_INT(0, atomic_load(&g_observer_begin_calls));
TEST_ASSERT_EQUAL_INT(0, atomic_load(&g_account_jobs_calls));
TEST_ASSERT_EQUAL_INT(0, atomic_load(&g_observer_end_calls));
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(
test_round_runs_plugin_begin_and_account_jobs_before_picker_signaled);
RUN_TEST(test_null_driver_ctx_is_safe_noop);
return UNITY_END();
}