myxce/src/scheduler/testsuite/test_sim_event_loop.c
Rongsong Shen c89e4e4520
fix(scheduler): release replay-event payloads after the simulator pushes them (bug-1884, part 2)
sim_process_replay_event_message() now hands the decoded replay event to
the caller, which keeps it beside the translated core event and frees it
after xce_core_push_events() with driver.c's release_translated_payload()
rules: a pushed config is kept by the core, and a rusages report is kept
by the job it found. An untranslatable event is freed at once, and events
after the last session are freed at the end. The payloads were an
"accepted leak" per event; exit-time leak on the reproduction replay
75,495 -> 25,807 bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:55:34 +00:00

1081 lines
44 KiB
C

/*
* test_sim_event_loop.c - contract test for the simulation-mode event loop
* and virtual clock advancement (scheduler-core spec, task 11.2, Req 5 AC
* 14/18).
*
* Observable under test (task 11.2's own text):
* - replaying a fixture file with events at non-uniform timestamps
* processes them in file order with the virtual clock reporting each
* event's exact timestamp at the moment it's pushed.
*
* Two tests, at two different granularities:
*
* Test 1 (unit-level, white-box): calls sim_process_replay_event_message()
* -- the extracted per-message decode+clock-advance+translate step --
* directly, sequentially, once per hand-built ReplayEvent message, with
* deliberately non-uniform timestamps (including a same-timestamp pair,
* Req 5 AC 14's "same-timestamp events processed in file order" clause),
* asserting a REAL xce_core_now(&sim.core) read matches EXACTLY after
* each call -- this is the precise, sequential, file-order proof: a
* picker-hook spy (the only other observation point xce_core_push_events()
* offers) cannot see this because every event in one accumulated batch is
* pushed through ONE xce_core_push_events() call, after the clock has
* already been walked through all of them.
*
* Test 2 (integration-level): drives the full sched_sim_driver_run() --
* this task's other Observable, "treat each session-boundary message as
* the trigger for one complete scheduling round" -- against a fixture
* with TWO ReplaySession boundaries at increasing beginRound timestamps,
* asserting the run completes (rc == 0 -- this is the RED/GREEN proof:
* before task 11.2, sched_sim_driver_run() returned -ENOSYS here) and
* that the virtual clock ends at the SECOND session's beginRound -- which
* only happens if both session boundaries were reached, in order.
*
* Fixture strategy: same production-code-reuse technique test_sim_startup.c
* already establishes for the header+snapshot prefix (sched_record_start()/
* sched_record_stop() against a small real xce_scheduler_t fixture) PLUS
* hand-constructed ReplayEvent/ReplaySession messages appended afterward,
* using the SAME encode_ReplayEvent_ptr()/write_capnp_message() technique
* test_e2e_capnp.c's own test_replay_event_job_state_change_round_trip/
* test_replay_event_end_session_round_trip already prove works, and
* low-level new_ReplaySession()/ReplaySession_set_beginRound() field
* setters for ReplaySession, mirroring sim.c's own
* write_lone_snapshotentry_message() precedent (test_sim_startup.c) for
* minimal hand-built messages that leave irrelevant fields at their
* zero-value defaults.
*
* White-box #include of ../src/core.c, ../src/core_clock.c,
* ../src/core_events.c, ../src/core_session.c, ../src/decision.c (task
* 11.2's own core-driving calls: xce_core_push_events()/xce_core_go()/
* xce_core_end_session()/make_pend_decision()) PLUS ../src/plugin.c,
* ../src/plugins/builtin_fcfs.c (the real plugin_register_core()/
* builtin_fcfs_new_plugin(), task 11.2's own new core.h-scoped picker
* registration path -- NOT schedobj.c/plugin_register(), which need a
* genuine xce_scheduler_t* this test deliberately never builds one of,
* same Req 5 AC 17 boundary sim.c/sim_loop.c themselves respect) PLUS
* ../src/recording.c (fixture-building only, same as test_sim_startup.c)
* PLUS ../src/sim.c/../src/sim_loop.c (the code under test) PLUS
* ../src/sim_verify.c (task 11.4's sibling file -- sim_loop.c's
* sched_sim_driver_run() now calls sim_decision_eq()/sim_log_mismatch() as
* extern functions defined there; this white-box TU must include it too or
* those calls are undefined references at link time, even though neither
* test in this file exercises --sim-verify itself -- that is
* test_sim_verify.c's own job) PLUS ../src/recording_session.c/
* ../src/sim_record.c (task 11.5's sibling files -- same reasoning:
* sched_sim_driver_run() now unconditionally references
* sched_record_session_ctx_new()/_finalize()/sim_record_write_header_and_
* snapshot()/sim_record_write_event() as extern functions, undefined
* references at link time otherwise, even though this file never sets
* --sim-output either) PLUS ../src/repl.c (task 12.1's sibling file -- same
* reasoning again: sim_loop.c now `#include`s repl.h, whose task 12.1
* inline wrappers unconditionally reference xce_repl_start()/
* xce_repl_post_session_check() as extern functions, undefined references
* at link time otherwise, even though this file never sets --interactive)
* PLUS
* ../src/snapshot.c/snapshot_wire.c/snapshot_load.c (task 8.1, needed by
* both the fixture-building and sim.c's own snapshot load).
*/
#include <errno.h>
#include <limits.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <unity/unity.h>
#include "controller.capnp.h"
#include "kvmgr.h"
#include "scheduler.h"
#include "xstring.h"
/* Pull in the real functions under test into this TU (white-box #include,
* same convention as test_sim_startup.c/test_core_full_cycle.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/plugin.c"
#include "../src/plugins/builtin_fcfs.c"
#include "../src/recording.c"
#include "../src/recording_session.c"
#include "../src/repl.c"
#include "../src/repl_fmt.c"
#include "../src/repl_rollup.c"
#include "../src/repl_break.c"
#include "../src/repl_info.c"
#include "../src/repl_params.c"
#include "../src/sim.c"
#include "../src/sim_loop.c"
#include "../src/sim_picker.c" /* bug-1567: sched_sim_driver_run() now calls sim_bootstrap_picker() (sim_loop.c-cap sibling) as an extern -- undefined reference at link time otherwise */
#include "../src/sim_limit.c" /* bug-1825: sched_sim_driver_run() now also calls sim_bootstrap_limit_plugin() (sim_loop.c-cap sibling, sim_picker.c's own bug-1567 precedent) as an extern -- undefined reference at link time otherwise */
#include "../src/sim_record.c"
#include "../src/sim_verify.c"
#include "../src/snapshot.c"
#include "../src/snapshot_load.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; }
/* repl_info.c (scheduler-sim-diagnostics spec, task 2.1) now calls
* limit_get_configured_limits() (plugins/limit.c, not part of this TU) to
* resolve `info limit`'s name/all argument -- link-satisfying stub only,
* same rationale as kill_sched_job()/kill_sched_resval() above; this
* file's own fixtures never register the limit plugin, so NULL
* (limit_get_configured_limits()'s own documented "plugin absent" return)
* is also the behaviorally correct answer here, not just a link
* placeholder. repl_info_limit() itself is not yet wired into
* repl_cmd_info() (task 4), so no test in this file can reach it anyway. */
var limit_get_configured_limits(xce_sched_core_t* core)
{
(void)core;
return NULL;
}
/* scheduler-sim-diagnostics spec, task 3.1: repl_info.c now also calls
* limit_get_usage_instances() (plugins/limit.c, not part of this TU) to
* read a limit's live usage instances -- identical link-satisfying stub,
* same rationale as limit_get_configured_limits() immediately above; this
* file's own fixtures never register the limit plugin, so NULL
* (limit_get_usage_instances()'s own documented "plugin absent" return) is
* also the behaviorally correct answer here, not just a link placeholder. */
var limit_get_usage_instances(xce_sched_core_t* core)
{
(void)core;
return NULL;
}
/* scheduler-sim-diagnostics spec, task 6 (bug-1825): sim_loop.c's
* sched_sim_driver_run() now also calls sim_bootstrap_limit_plugin()
* (sim_limit.c, pulled into this TU via the #include above), which in
* turn calls limit_new_plugin() (plugins/limit.c, not part of this TU) --
* identical link-satisfying stub rationale as limit_get_configured_
* limits()/limit_get_usage_instances() immediately above. Returning NULL
* here is also the behaviorally correct answer, not just a link
* placeholder: sim_bootstrap_limit_plugin() already handles a NULL
* limit_new_plugin() return gracefully (logs an ERROR, registers nothing,
* returns) -- the exact same "out of memory" path sim_bootstrap_picker()
* (sim_picker.c) takes for a NULL builtin_fcfs_new_plugin() -- so this
* file's fixtures simply end up with no limit plugin registered, same
* observable state as before this task. */
xce_sched_plugin_t* limit_new_plugin(void)
{
return NULL;
}
/* limit_seed_config() (plugins/limit.c, not part of this TU) -- bug-1825
* remediation round 2's new honestly-typed config-seed entry point.
* sim_bootstrap_limit_plugin() (sim_limit.c) only calls it after
* plugin_register_core() has already succeeded against a real, non-NULL
* limit_new_plugin() plugin -- unreachable in this file since the
* limit_new_plugin() stub immediately above always returns NULL, but the
* symbol must still resolve at link time. Same link-satisfying-stub
* rationale as limit_new_plugin() immediately above. */
int limit_seed_config(xce_sched_plugin_t* plugin, xce_sched_core_t* core,
xce_conf4sched_t* config)
{
(void)plugin;
(void)core;
(void)config;
return 0;
}
/* 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;
}
/* core_reset_workers()'s (core_session.c) linker dependency -- see
* test_core_full_cycle.c's own file header comment for the same stub;
* never invoked (no worker is ever added here). */
void sched_worker_release_host_ctx(xce_sched_worker_t* w) { (void)w; }
/* plugin_prepare_allocation()'s (plugin.c, the "limit" plugin category)
* linker dependency -- never invoked (this file never drives real
* allocation, Req 5 AC 17 / this file's own header comment). */
int sched_fill_limit_requests(xce_sched_alloc_req_t* alloc_req,
xlist_t* limits, xlist_t* rusages, int nhosts,
int nslots)
{
(void)alloc_req;
(void)limits;
(void)rusages;
(void)nhosts;
(void)nslots;
return 0;
}
/* sched_sim_driver_run()'s (sim_loop.c, scheduler-sim-decision-parity task
* 2.1) new round-boundary call to the relocated sched_account_jobs()
* (core.h) -- a no-op stand-in here since this file does not link
* scheduler.c/resource.c, same rationale test_sim_equivalence.c's own copy
* documents. */
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;
}
/* Real reimplementation of schedobj.c's sched_resolve_job_picker -- same
* rationale/algorithm as test_sim_startup.c's/test_core_full_cycle.c's own
* copies (schedobj.c is not linked: it would need plugin_register(), which
* requires a genuine xce_scheduler_t* neither this file's fixtures nor
* sim_loop.c's own core-scoped registration path have, Req 5 AC 17). */
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;
}
/* scheduler-sim-live-allocation spec, task 4.1: sim_loop.c's real
* sched_sim_driver_run() (white-box #include'd into this TU above) now
* spawns sched_job_picker()/sched_allocator() (sched_election.c) directly
* instead of its own, now-removed sim_picker_thread_fn() -- an undefined
* reference at link time in this white-box TU otherwise, since
* sched_election.c's own sched_allocator() calls the REAL
* sched_make_allocation()/sched_commit_decision() (scheduler.c), neither
* part of this file's own hand-picked white-box #include set (this file's
* own header comment lists that set). This file's own test(s) do not
* exercise genuine resource-matching allocation, so this link-satisfying
* pair faithfully reproduces the exact PEND-only contract the REMOVED
* sim_picker_thread_fn() itself used to provide -- split across the SAME
* two-thread jbuf hand-off the real production pair now uses (one
* producer, one consumer, matching the project's jbuf capacity-1
* rendezvous invariant) instead of the old single fused thread -- so this
* file's own existing assertions are unaffected by this task's production
* change. Genuine resource-based dispatch through the real, unmodified
* sched_job_picker()/sched_allocator() pair is proved separately by
* test_sim_live_allocation.c (task 4.1's own dedicated fixture, which
* links the real scheduler_srcs instead of white-boxing individual files,
* precisely so it can reach the real allocation chain this stub
* deliberately does not). */
void* sched_job_picker(void* arg)
{
xce_sched_core_t* core = (xce_sched_core_t*)arg;
xce_sched_plugin_t* picker = NULL;
for (;;) {
int go = 0;
uint64_t jobid = 0;
cbuf_get(&(core->wbuf), &go);
if (sched_resolve_job_picker(core, &picker) < 0) {
jobid = 0;
cbuf_put(&(core->jbuf), &jobid);
continue;
}
picker->picker_iface->begin_session(picker);
for (;;) {
xce_sched_job_t* job = picker->picker_iface->next_job(picker);
if (job == NULL) {
break;
}
jobid = job->jobid;
cbuf_put(&(core->jbuf), &jobid);
}
jobid = 0;
cbuf_put(&(core->jbuf), &jobid);
picker->picker_iface->end_session(picker);
}
return NULL;
}
void* sched_allocator(void* arg)
{
xce_sched_core_t* core = (xce_sched_core_t*)arg;
xce_sched_plugin_t* picker = NULL;
for (;;) {
uint64_t jobid = 0;
xce_decision_t* decision = NULL;
xce_sched_job_t* job;
cbuf_get(&(core->jbuf), &jobid);
if (jobid == 0) {
decision = NULL;
cbuf_put(&(core->decisions), &decision);
continue;
}
job = sched_get_job(core, jobid);
if (job == NULL) {
continue;
}
(void)sched_resolve_job_picker(core, &picker);
if (picker != NULL) {
picker->picker_iface->on_pend(picker, job->jobid);
}
decision = make_pend_decision(make_pendreason(job->jobid));
cbuf_put(&(core->decisions), &decision);
}
return NULL;
}
/* scheduler-sim-live-allocation spec, task 4.1 (bug-1829 fix): sim_loop.c's
* real sched_sim_driver_run() (white-box #include'd into this TU above) now
* also calls sim_bootstrap_seqjob_allocator()/sim_bootstrap_parjob_
* allocator() (sim_alloc.c) -- undefined references at link time in this
* white-box TU otherwise, since neither symbol is part of this file's own
* hand-picked white-box #include set. This file's own test(s) exercise the
* link-satisfying sched_job_picker()/sched_allocator() stub pair above
* (which never reaches real primary allocation), so a true no-op is the
* correct, behavior-preserving stand-in here -- the real bootstrap logic is
* exercised separately by test_sim_live_allocation.c (task 4.1's own
* dedicated fixture, which links the real scheduler_srcs). */
void sim_bootstrap_seqjob_allocator(xce_sched_sim_driver_t* sim)
{
(void)sim;
}
void sim_bootstrap_parjob_allocator(xce_sched_sim_driver_t* sim)
{
(void)sim;
}
void setUp(void) { return; }
void tearDown(void) { return; }
/* ---------------------------------------------------------------------- */
/* Fixture helpers */
/* ---------------------------------------------------------------------- */
static void make_tmp_dir(char* out, size_t out_len, const char* suffix)
{
char tmpl[PATH_MAX];
snprintf(tmpl, sizeof(tmpl), "/tmp/sched_simloop%s_XXXXXX", suffix);
TEST_ASSERT_NOT_NULL(mkdtemp(tmpl));
TEST_ASSERT_TRUE(strlen(tmpl) < out_len);
strcpy(out, tmpl);
}
static int stub_picker_begin_session(void* picker)
{
(void)picker;
return 0;
}
static xce_sched_job_t* stub_picker_next_job(void* picker)
{
(void)picker;
return NULL;
}
static int stub_picker_end_session(void* picker)
{
(void)picker;
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_sim_event_loop_picker",
.picker_iface = &g_stub_picker_iface };
/* Builds a small, real xce_scheduler_t fixture purely to drive
* sched_record_start()/sched_record_stop() and produce a REAL, valid
* `.replay` header+snapshot prefix this test's OWN code under test then
* consumes (same pattern test_sim_startup.c's build_fixture_scheduler()
* establishes). Deliberately registers a picker OTHER than "builtin_fcfs"
* (name never matches) -- neither test in this file pushes a real
* candidate job, so which picker resolves at replay time never matters,
* and this keeps the fixture's own recorded schedPolicy from accidentally
* satisfying sim_register_default_picker()'s exact-name check. */
static void build_fixture_scheduler(xce_scheduler_t* sched, char* record_dir)
{
int rc;
memset(sched, 0, sizeof(*sched));
mylock_init(&(sched->record_lock));
rc = xce_sched_core_init(&(sched->core));
TEST_ASSERT_EQUAL_INT(0, rc);
lock(sched->core.plugins, RTTI_LOCK_WRITE);
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;
sched->record_dir = record_dir;
}
/* Produces a valid `.replay` header+snapshot prefix at `out_path` via the
* real sched_record_start() -- identical technique test_sim_startup.c's own
* write_valid_fixture() establishes for the header+snapshot bytes
* themselves -- but deliberately does NOT call sched_record_stop(): that
* function unconditionally appends its own ReplayEvent{endSession} marker
* (recording.c, Req 5 AC 20) immediately after the snapshot, which would
* terminate sched_sim_driver_run()'s read loop the instant it reached that
* marker -- before ever seeing anything this test's own callers append
* afterward. Returns the STILL-OPEN FILE* (sched_record_start()'s own
* `sched.record_file`, positioned right after the snapshot) so the caller
* can append its own hand-built events/sessions directly onto the same
* stream, ending with its own append_end_of_stream_marker() call, then
* fclose() it directly -- bypassing sched_record_stop()'s real
* event/session-recording API entirely, which this test never exercises. */
static FILE* write_header_and_snapshot(char* out_path, size_t out_path_len,
const char* suffix)
{
char record_dir[PATH_MAX];
xce_scheduler_t sched;
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), suffix);
build_fixture_scheduler(&sched, record_dir);
rc = sched_record_start(&sched, "fixture.replay");
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_TRUE(strlen(sched.record_file_path) < out_path_len);
strcpy(out_path, sched.record_file_path);
return sched.record_file;
}
/* Appends one ReplayEvent{jobStateChange} message to the already-open
* stream `f` -- same encode_ReplayEvent_ptr()/write_capnp_message()
* technique test_e2e_capnp.c's own
* test_replay_event_job_state_change_round_trip already proves works.
* jobStateChange is used throughout this file purely as a cheap,
* already-proven-safe payload shape (a plain value struct, no further
* nested pointers) -- core_events.c's own JOB_STATE_CHANGE handling
* WARN+skips an unknown jobid rather than fail (verified in core_events.c),
* so neither test here needs a real job to exist in the loaded snapshot.
*/
static void append_jobstatechange_event(FILE* f, uint64_t ts, uint64_t jobid)
{
job_state_change_event_t change;
replay_event_t src;
struct capn c;
struct capn_segment* cs;
ReplayEvent_ptr p;
memset(&change, 0, sizeof(change));
change.jobid = jobid;
change.old_state = 1u;
change.new_state = 2u;
change.exit_code = 0;
memset(&src, 0, sizeof(src));
src.ts = ts;
src.body_which = ReplayEvent_body_jobStateChange;
src.body.job_state_change = &change;
capn_init_malloc(&c);
cs = capn_root(&c).seg;
encode_ReplayEvent_ptr(cs, &p, &src);
capn_setp(capn_root(&c), 0, p.p);
TEST_ASSERT_EQUAL_INT(0, write_capnp_message(f, &c));
capn_free(&c);
}
/* Appends the .replay stream's whole-stream termination marker
* (ReplayEvent{endSession}, Req 5 AC 20) -- same technique
* test_e2e_capnp.c's own test_replay_event_end_session_round_trip already
* proves works. */
static void append_end_of_stream_marker(FILE* f)
{
replay_event_t src;
struct capn c;
struct capn_segment* cs;
ReplayEvent_ptr p;
memset(&src, 0, sizeof(src));
src.body_which = ReplayEvent_body_endSession;
capn_init_malloc(&c);
cs = capn_root(&c).seg;
encode_ReplayEvent_ptr(cs, &p, &src);
capn_setp(capn_root(&c), 0, p.p);
TEST_ASSERT_EQUAL_INT(0, write_capnp_message(f, &c));
capn_free(&c);
}
/* Appends a minimal ReplaySession message with only beginRound set --
* decisions/summary are left at new_ReplaySession()'s zero-value defaults
* (empty list / capn_null), mirroring sim.c's own
* write_lone_snapshotentry_message() precedent (test_sim_startup.c) for
* hand-built messages whose irrelevant fields nothing under test reads.
* sched_sim_driver_run() only ever reads beginRound off a ReplaySession
* (via the lightweight ReplaySession_get_beginRound() accessor, task 11.2's
* own scope: the decisions/summary fields are task 11.4/11.5 territory). */
static void append_replay_session(FILE* f, uint64_t begin_round)
{
struct capn c;
struct capn_segment* cs;
ReplaySession_ptr sp;
capn_init_malloc(&c);
cs = capn_root(&c).seg;
sp = new_ReplaySession(cs);
ReplaySession_set_beginRound(sp, begin_round);
capn_setp(capn_root(&c), 0, sp.p);
TEST_ASSERT_EQUAL_INT(0, write_capnp_message(f, &c));
capn_free(&c);
}
/* ---------------------------------------------------------------------- */
/* Test 1: sim_process_replay_event_message() advances the virtual clock */
/* to each event's own timestamp, in exact file order, including a */
/* same-timestamp pair (Req 5 AC 14). */
/* ---------------------------------------------------------------------- */
static void
test_process_replay_event_advances_virtual_clock_to_exact_event_ts_in_file_order(
void)
{
static const uint64_t ts_values[5]
= { 1000ULL, 1050ULL, 1050ULL, 500000ULL, 9999999999ULL };
char path[PATH_MAX];
xce_sched_sim_driver_t sim;
FILE* f;
int i;
int rc;
/* A minimal sim driver: only sim->core is exercised by
* sim_process_replay_event_message() (via sim_advance_virtual_clock()),
* so xce_sched_core_init() plus a frozen (speed=0) clock is a
* sufficient, self-contained fixture -- no replay file, snapshot, or
* full sched_sim_driver_start() needed for this unit-level test. */
memset(&sim, 0, sizeof(sim));
rc = xce_sched_core_init(&(sim.core));
TEST_ASSERT_EQUAL_INT(0, rc);
/* speed_num=0 (discrete/frozen -- AC 3's own documented mode table,
* core_clock.c) so xce_core_now() reports EXACTLY virtual_clock_base
* regardless of real wall-clock time elapsed between
* sim_advance_virtual_clock()'s clock_gettime() call and this test's
* own assertion immediately after -- deterministic, never flaky. */
atomic_store_explicit(&(sim.core.speed_num), 0, memory_order_release);
atomic_store_explicit(&(sim.core.speed_den), 1, memory_order_release);
make_tmp_dir(path, sizeof(path), "_clockjump");
strcat(path, "/events.replay");
f = fopen(path, "w");
TEST_ASSERT_NOT_NULL(f);
for (i = 0; i < 5; i++) {
append_jobstatechange_event(f, ts_values[i], 42ULL);
}
fclose(f);
f = fopen(path, "r");
TEST_ASSERT_NOT_NULL(f);
for (i = 0; i < 5; i++) {
struct capn c;
capn_ptr root;
xce_core_event_t ev;
replay_event_t decoded;
int record_rc = 0;
int translated;
TEST_ASSERT_EQUAL_INT(0, capn_init_fp(&c, f, 0));
TEST_ASSERT_TRUE(msg_root_has_shape(&c, &root, ReplayEvent_word_count,
ReplayEvent_pointer_count));
/* record_rc: task 11.5's new --sim-output pass-through out-param
* (sim_loop.c) -- always 0 here, sim.record_file is NULL for this
* whole fixture (no --sim-output involved in this task's own
* Observable). */
translated = sim_process_replay_event_message(&sim, &c, root, &ev,
&decoded, &record_rc);
TEST_ASSERT_EQUAL_INT(0, record_rc);
TEST_ASSERT_EQUAL_INT(1, translated);
TEST_ASSERT_EQUAL_INT(XCE_CORE_EVENT_JOB_STATE_CHANGE, ev.type);
TEST_ASSERT_EQUAL_UINT64(ts_values[i], ev.ts);
/* The Observable itself: a REAL clock read, immediately after this
* one event was processed, matches that event's exact timestamp --
* not a trivially-true check, and not observable any other way
* (see this file's own header comment for why a picker-hook spy
* cannot see per-event granularity). */
TEST_ASSERT_EQUAL_UINT64(ts_values[i], xce_core_now(&(sim.core)));
free_ReplayEvent(&decoded);
}
fclose(f);
printf("test_process_replay_event_advances_virtual_clock_to_exact_event_"
"ts_in_file_order: PASS\n");
}
/* ---------------------------------------------------------------------- */
/* Test 2: sched_sim_driver_run() treats each ReplaySession boundary as */
/* the trigger for one complete scheduling round, in file order, across */
/* multiple rounds -- this task's other Observable, and the RED->GREEN */
/* proof (sched_sim_driver_run() returned -ENOSYS here before task 11.2). */
/* ---------------------------------------------------------------------- */
static void
test_run_processes_multiple_sessions_in_order_and_no_longer_returns_enosys(void)
{
char path[PATH_MAX];
FILE* f;
sched_sim_args_t args;
/* static, not stack-local (2026-08-19 ASAN finding): sched_sim_driver_
* run() spawns sim_picker_thread_fn (sim_loop.c) on a real pthread that
* is, by this codebase's own documented design, "never joined or
* cancelled: no sim-driver teardown/fini function exists yet" (that
* function's own header comment) -- it loops forever on
* cbuf_get(&sim.core.wbuf, ...), reading `sim` for the lifetime of the
* process. A stack-local `sim` here would make that a genuine stack-
* use-after-return the instant this test function returns (a later
* test's own stack frame reusing the same address triggers ASAN's
* detector -- reproduced live when a new test was added between this
* one and its neighbor). `static` storage sidesteps it cheaply: the
* zombie thread keeps reading valid (if stale) memory instead of a
* freed stack frame. This does not fix the underlying thread-lifecycle
* gap (a real fix needs an actual sim-driver teardown function, out of
* scope here) -- it only makes this test suite safe to run under ASAN
* regardless of test ordering/stack layout. */
static xce_sched_sim_driver_t sim;
int rc;
f = write_header_and_snapshot(path, sizeof(path), "_run");
TEST_ASSERT_NOT_NULL(f);
/* Non-uniform timestamps throughout, two ReplaySession boundaries at
* strictly increasing beginRound values -- the final virtual-clock
* assertion below only holds if BOTH boundaries were reached, in
* order (a driver that stalled after the first, or processed them out
* of order, would leave the clock at the wrong value). */
append_jobstatechange_event(f, 2000ULL, 999ULL); /* unknown jobid --
* core_events.c
* WARN+skips it,
* verified safe */
append_jobstatechange_event(f, 2500ULL, 999ULL);
append_replay_session(f, 3000ULL); /* session boundary #1 */
append_jobstatechange_event(f, 4000ULL, 999ULL);
append_replay_session(f, 5000ULL); /* session boundary #2 */
append_end_of_stream_marker(f);
fclose(f);
memset(&args, 0, sizeof(args));
args.replay_path = path;
args.sim_start_ts = SCHED_SIM_START_TS_LATEST;
rc = sched_sim_driver_run(&sim, &args);
/* The RED->GREEN proof: before task 11.2, this returned -ENOSYS
* (test_sim_startup.c's own former test_run_starts_then_fails_enosys_
* pending_event_loop, removed now that this Observable supersedes it). */
TEST_ASSERT_EQUAL_INT(0, rc);
/* Both ReplaySession boundaries were reached, in file order: the clock
* ends at the SECOND (later) beginRound, not the first, and not the
* last ReplayEvent's own ts (2000/2500/4000 all precede 5000). */
TEST_ASSERT_EQUAL_UINT64(5000ULL, xce_core_now(&(sim.core)));
printf("test_run_processes_multiple_sessions_in_order_and_no_longer_"
"returns_enosys: PASS\n");
}
/* ---------------------------------------------------------------------- */
/* Test 2b (design review follow-up, Req 5 AC 3): sched_sim_driver_run() */
/* must deactivate tlog's virtual-clock logging on completion -- it has no */
/* fini()/teardown function, and sched_sim_driver_start() (called */
/* internally) activates logging via g_active_core_for_logging = &sim.core */
/* (core_clock.c's module-static, white-box #included above). Left active */
/* past this call, `sim` (this test's own local/stack struct) would be a */
/* dangling reference the moment this function returns -- and in a real */
/* process running MULTIPLE sim sessions back to back (or, in this test */
/* binary, any later test calling tlog after this one returns), every */
/* subsequent log line would read a stale/dangling core pointer. Seed a */
/* bogus non-NULL sentinel first (simulating "a prior run left this set") */
/* so the assertion genuinely proves deactivation happened, not just that */
/* nothing ever touched it. */
/* ---------------------------------------------------------------------- */
static void
test_run_deactivates_tlog_virtual_clock_logging_on_completion(void)
{
char path[PATH_MAX];
FILE* f;
sched_sim_args_t args;
/* static -- see test_run_processes_multiple_sessions_in_order_and_no_
* longer_returns_enosys()'s own comment on this same pattern above: the
* picker thread sched_sim_driver_run() spawns is never joined, so a
* stack-local `sim` here is a stack-use-after-return once this
* function returns (this is in fact the exact test whose addition
* first surfaced that ASAN finding live). */
static xce_sched_sim_driver_t sim;
int rc;
f = write_header_and_snapshot(path, sizeof(path), "_tlogdeactivate");
TEST_ASSERT_NOT_NULL(f);
append_end_of_stream_marker(f);
fclose(f);
memset(&args, 0, sizeof(args));
args.replay_path = path;
args.sim_start_ts = SCHED_SIM_START_TS_LATEST;
xce_core_set_active_for_logging((xce_sched_core_t*)0x1); /* bogus sentinel */
rc = sched_sim_driver_run(&sim, &args);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_NULL_MESSAGE(
g_active_core_for_logging,
"sched_sim_driver_run() must deactivate tlog virtual-clock logging "
"before returning -- otherwise later log calls dereference this "
"now-out-of-scope sim.core");
printf("test_run_deactivates_tlog_virtual_clock_logging_on_completion:"
" PASS\n");
}
/* ---------------------------------------------------------------------- */
/* Test 3 (bug-1567 regression): a job/queue already present in the */
/* INITIAL snapshot -- as opposed to arriving later via a ReplayEvent -- */
/* must still reach the real picker and get a live decision. */
/* ---------------------------------------------------------------------- */
/* Same fixture technique as write_header_and_snapshot() above, but (a)
* records schedPolicy="builtin_fcfs" (test_sim_verify.c's own
* build_fixture_scheduler() precedent -- this file's own copy deliberately
* uses a non-matching stub name instead, so it cannot be reused here
* unmodified) so sim_bootstrap_picker() (sim_picker.c) registers the REAL
* builtin_fcfs plugin, not a no-op stub, and (b) adds one real queue and
* one real PEND job directly to core->queues/core->jobs (core_build_queue()/
* core_build_job() + sched_add_queue()/sched_add_job(), core_events.c,
* already white-box #included above) BEFORE calling sched_record_start() --
* so xce_sched_core_save_snapshot() (snapshot.c) captures both into the
* INITIAL snapshot itself, not as a later ReplayEvent. */
static FILE* write_header_and_snapshot_with_pending_job(char* out_path,
size_t out_path_len,
const char* suffix,
const char* queue_name,
uint64_t jobid)
{
char record_dir[PATH_MAX];
xce_scheduler_t sched;
xce_queue_t wire_queue;
xce_sched_queue_t* q;
job_submit_data_t submit;
xce_job_t wire_job;
xce_sched_job_t* job;
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), suffix);
memset(&sched, 0, sizeof(sched));
mylock_init(&(sched.record_lock));
rc = xce_sched_core_init(&(sched.core));
TEST_ASSERT_EQUAL_INT(0, rc);
sched.core.job_pick_plugin = (char*)"builtin_fcfs";
sched.record_dir = record_dir;
memset(&wire_queue, 0, sizeof(wire_queue));
wire_queue.queue = (char*)queue_name;
wire_queue.status = QUEUE_STAT_OPEN;
wire_queue.priority = 1;
q = core_build_queue(&wire_queue);
TEST_ASSERT_NOT_NULL(q);
TEST_ASSERT_GREATER_OR_EQUAL(0, sched_add_queue(&(sched.core), q));
memset(&submit, 0, sizeof(submit));
submit.queue = (char*)queue_name;
submit.user = (char*)"alice";
submit.mintasks = 1;
submit.maxtasks = 1;
memset(&wire_job, 0, sizeof(wire_job));
wire_job.jobid = jobid;
wire_job.state = JOB_STAT_PEND;
wire_job.submit = &submit;
job = core_build_job(&(sched.core), &wire_job);
TEST_ASSERT_NOT_NULL(job);
TEST_ASSERT_GREATER_OR_EQUAL(0, sched_add_job(&(sched.core), job));
rc = sched_record_start(&sched, "fixture.replay");
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_TRUE(strlen(sched.record_file_path) < out_path_len);
strcpy(out_path, sched.record_file_path);
return sched.record_file;
}
/* Same dup()/dup2() STDOUT_FILENO capture technique test_sim_verify.c's own
* run_sim_and_capture_stdout() establishes (this file's white-box TU does
* not #include that file, so a plain copy is needed here rather than a
* cross-file static call). */
static char* run_sim_and_capture_stdout(const char* path, int* out_rc)
{
sched_sim_args_t args;
/* static -- same never-joined-picker-thread hazard documented on
* test_run_processes_multiple_sessions_in_order_and_no_longer_returns_
* enosys()'s own `sim` declaration above. */
static xce_sched_sim_driver_t sim;
char tmp[] = "/tmp/xce_simloop_run_XXXXXX";
int fd = mkstemp(tmp);
int saved_stdout;
off_t len;
char* buf;
TEST_ASSERT_TRUE_MESSAGE(fd >= 0, "mkstemp for stdout capture must "
"succeed");
fflush(stdout);
saved_stdout = dup(STDOUT_FILENO);
TEST_ASSERT_TRUE_MESSAGE(saved_stdout >= 0, "dup(STDOUT_FILENO) must "
"succeed");
TEST_ASSERT_TRUE_MESSAGE(dup2(fd, STDOUT_FILENO) >= 0,
"dup2 redirect of STDOUT_FILENO must succeed");
memset(&args, 0, sizeof(args));
args.replay_path = path;
args.sim_start_ts = SCHED_SIM_START_TS_LATEST;
*out_rc = sched_sim_driver_run(&sim, &args);
fflush(stdout);
dup2(saved_stdout, STDOUT_FILENO);
close(saved_stdout);
len = lseek(fd, 0, SEEK_CUR);
TEST_ASSERT_TRUE(len >= 0);
buf = (char*)calloc(1, (size_t)len + 1);
TEST_ASSERT_NOT_NULL(buf);
TEST_ASSERT_TRUE(lseek(fd, 0, SEEK_SET) == 0);
if (len > 0) {
ssize_t nread = read(fd, buf, (size_t)len);
TEST_ASSERT_TRUE(nread == len);
}
close(fd);
unlink(tmp);
return buf;
}
/*
* bug-1567: before this fix, sched_sim_driver_start() (sim.c) loaded the
* initial snapshot while no picker plugin was registered yet (sim.h's own
* "Known scope boundary"), so xce_sched_core_load_snapshot()'s
* picker-rebuild walk (on_queue_update/on_job_push) was skipped for the
* job/queue this fixture bakes into the INITIAL snapshot -- and nothing
* ever replayed that walk once sim_bootstrap_picker() registered the real
* picker moments later. The job was therefore invisible to next_job()
* forever: no PEND, no DISPATCH, nothing -- exactly the user report this
* bug tracks ("no job will be scheduled and all jobs in pend state").
* After the fix, sim_bootstrap_picker() (sim_picker.c) replays that walk
* itself, so this job -- despite never appearing in a single ReplayEvent
* -- still reaches the picker and gets a live PEND decision (this driver
* never DISPATCHes at all, a separate, intentionally out-of-scope gap --
* see sim_picker_thread_fn()'s own doc comment, sim_loop.c).
*/
static void
test_job_present_only_in_initial_snapshot_still_reaches_the_picker(void)
{
char path[PATH_MAX];
char pend_marker[80];
char jobid_buf[64];
FILE* f;
int rc;
char* captured;
f = write_header_and_snapshot_with_pending_job(path, sizeof(path),
"_initsnap", "normal",
4242ULL);
TEST_ASSERT_NOT_NULL(f);
/* One session boundary is enough to trigger one scheduling round --
* the job under test arrives via the INITIAL SNAPSHOT, not via any
* ReplayEvent appended here (deliberately: zero jobs/queues events in
* this stream is the whole point of this regression). */
append_replay_session(f, 1000ULL);
append_end_of_stream_marker(f);
fclose(f);
captured = run_sim_and_capture_stdout(path, &rc);
/* bug-1591: sim_print_decision() (sim_loop.c) now renders jobids via
* fmt_jobid(), not raw %llu -- this marker must match that same
* human-readable form. */
fmt_jobid(jobid_buf, sizeof(jobid_buf), 4242ULL);
snprintf(pend_marker, sizeof(pend_marker), "PEND jobid=%s", jobid_buf);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_NOT_NULL_MESSAGE(
strstr(captured, pend_marker),
"a job present only in the replay file's INITIAL snapshot must "
"still reach the real picker and receive a live PEND decision "
"(bug-1567)");
free(captured);
printf("test_job_present_only_in_initial_snapshot_still_reaches_the_"
"picker: PASS\n");
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(
test_process_replay_event_advances_virtual_clock_to_exact_event_ts_in_file_order);
RUN_TEST(
test_run_processes_multiple_sessions_in_order_and_no_longer_returns_enosys);
RUN_TEST(test_run_deactivates_tlog_virtual_clock_logging_on_completion);
RUN_TEST(
test_job_present_only_in_initial_snapshot_still_reaches_the_picker);
return UNITY_END();
}