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).
This commit is contained in:
Rongsong Shen 2026-08-19 09:48:46 +00:00
parent 515f4ca098
commit c9acd502c0
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
32 changed files with 2547 additions and 295 deletions

View file

@ -325,6 +325,21 @@ sched_driver_translate_test = executable(
dependencies: [scheduler_test_deps],
install: false)
# Regression test for bug-1585's true root cause: a live heap-use-after-free
# in the RESVALS_FULL/RESVALS_DELTA event-application path (core_events.c),
# triggered by every second periodic resval report for the same
# worker+resource on ANY real deployment. Same white-box #include set as
# sched_driver_translate_test (its sibling), but with a REAL (non-stubbed)
# kill_sched_resval -- see test_resval_ownership.c's own header comment for
# why that distinction is exactly what makes this test able to catch the bug
# sched_driver_translate_test's own no-op stub cannot.
sched_resval_ownership_test = executable(
'sched_resval_ownership_test',
['test_resval_ownership.c'],
include_directories:[incdir],
dependencies: [scheduler_test_deps],
install: false)
# Contract test for the normal-mode driver's decision-publish callback
# (scheduler-core spec, task 5.2, Req 5 AC 6 / AC 9's publishing half):
# xce_sched_driver_on_decision() forwards DISPATCH/PEND decisions to
@ -944,6 +959,9 @@ test('scheduler core full cycle test',
test('scheduler driver translate test',
sched_driver_translate_test, suite: 'scheduler',
env: ['ASAN_OPTIONS=detect_leaks=0'])
test('scheduler resval ownership test',
sched_resval_ownership_test, suite: 'scheduler',
env: ['ASAN_OPTIONS=detect_leaks=0'])
test('scheduler driver decision cb test',
sched_driver_decision_cb_test, suite: 'scheduler',
env: ['ASAN_OPTIONS=detect_leaks=0'])

View file

@ -246,6 +246,53 @@ static void test_xce_sched_core_init_seeds_normal_mode_defaults(void)
1, atomic_load_explicit(&(core.speed_den), memory_order_acquire));
}
/* ---------------------------------------------------------------------- */
/* xce_core_set_wallclock_anchor() -- normal-mode epoch seeding (bug-1584) */
/* ---------------------------------------------------------------------- */
static uint64_t test_epoch_ns(void)
{
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
return ((uint64_t)ts.tv_sec * TEST_NSEC_PER_SEC) + (uint64_t)ts.tv_nsec;
}
static void test_xce_core_set_wallclock_anchor_seeds_real_epoch_time(void)
{
/* bug-1584: xce_sched_core_init() alone leaves xce_core_now() returning
* raw CLOCK_MONOTONIC-since-boot nanoseconds (see
* test_xce_sched_core_init_seeds_normal_mode_defaults above) -- NOT
* epoch time, which every real consumer of a normal-mode timestamp
* needs (recording.c's record_start_ts, SchedAdminReply.startedAt,
* documented in controller.capnp as "nanoseconds since epoch").
* xce_core_set_wallclock_anchor() is the fix: bracket its effect on
* xce_core_now() between two back-to-back CLOCK_REALTIME reads taken
* immediately before/after the call -- deterministic (real time only
* moves forward) and immune to sleep-based flakiness. Before this fix,
* `result` would be a small monotonic-since-boot value, far below
* `before` (a real epoch value ~1.7e18 ns as of 2026) -- this bracket
* fails loudly on that regression rather than passing by coincidence. */
xce_sched_core_t core;
uint64_t before, result, after;
fixture_init(&core);
before = test_epoch_ns();
xce_core_set_wallclock_anchor(&core);
result = xce_core_now(&core);
after = test_epoch_ns();
TEST_ASSERT_TRUE(result >= before);
TEST_ASSERT_TRUE(result <= after);
}
static void test_xce_core_set_wallclock_anchor_null_core_is_a_safe_noop(void)
{
/* Mirrors xce_core_now(NULL)'s defensive contract -- no crash. */
xce_core_set_wallclock_anchor(NULL);
}
/* ---------------------------------------------------------------------- */
/* tlog format integration (Req 5 AC 3) */
/* ---------------------------------------------------------------------- */
@ -451,6 +498,8 @@ int main(void)
RUN_TEST(test_xce_core_now_fast_simulation_scales_up_by_speed);
RUN_TEST(test_xce_core_now_slow_simulation_scales_down_by_speed);
RUN_TEST(test_xce_sched_core_init_seeds_normal_mode_defaults);
RUN_TEST(test_xce_core_set_wallclock_anchor_seeds_real_epoch_time);
RUN_TEST(test_xce_core_set_wallclock_anchor_null_core_is_a_safe_noop);
RUN_TEST(test_tlog_format_shows_frozen_virtual_time_not_infotime);
RUN_TEST(test_tlog_format_frozen_time_is_stable_across_calls);
RUN_TEST(test_tlog_format_falls_back_to_infotime_when_no_active_core);

View file

@ -67,6 +67,39 @@ 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;
}
void plugin_unregister(void* arg) { (void)arg; }
/* Real reimplementation of schedobj.c's sched_resolve_job_picker -- see

View file

@ -134,6 +134,39 @@ 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;
}
void plugin_unregister(void* arg) { (void)arg; }
/* core_reset_workers()'s (core_session.c) linker dependency -- see file

View file

@ -67,6 +67,39 @@ 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;
}
void plugin_unregister(void* arg) { (void)arg; }
/* Link-satisfying stubs: as of scheduler-core spec task 5.2,

View file

@ -165,6 +165,39 @@ 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; }

View file

@ -65,6 +65,39 @@ 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;
}
void plugin_unregister(void* arg) { (void)arg; }
/* Link-satisfying stubs for scheduler.c's sched_publish_dispatch()/
@ -673,19 +706,29 @@ static void test_drain_translates_and_pushes_all_ten_op_types(void)
r3->u.resourceupdate.resources = resources;
enqueue(&core, r3);
/* 4. UPDATE_RESVALS_FULL -> RESVALS_FULL. NOTE: core_events.c's
* RESVALS_FULL case does `zarray_put(&core->sharedInst, res->idx,
* rv->val)` -- a SHALLOW store of the SAME `val` pointer (verified by
* reading zarray_put_ex()'s source, src/lib/zarray.c) -- and this
* function's cleanup then frees `full_resval` (and, via
* free_ResourceVal->free_Value_ptr, `full_val` too), exactly mirroring
* scheduler.c's existing sched_pend_update_killer()'s identical
* RESVALS_FULL cleanup. This is a PRE-EXISTING production ownership
* characteristic (the same hazard already exists in sched_update()'s
* own live path today, untouched by this task) -- so after the drain
* below, core->sharedInst's stored pointer for "cpu" is only safe to
* check for non-NULL (the slot was populated), never to dereference. */
full_val = (xce_val_t*)calloc(1, sizeof(*full_val));
/* 4. UPDATE_RESVALS_FULL -> RESVALS_FULL. NOTE (bug-1585, FIXED): this
* comment used to document a PRE-EXISTING production ownership hazard --
* core_events.c's RESVALS_FULL case used to do `zarray_put(&core->
* sharedInst, res->idx, rv->val)`, a SHALLOW store of the SAME `val`
* pointer this function's cleanup then frees via
* free_ResourceVal->free_Value_ptr, exactly mirroring scheduler.c's
* sched_pend_update_killer()'s identical (also now-fixed) RESVALS_FULL
* cleanup -- meaning core->sharedInst's stored pointer for "cpu" used to
* be a dangling pointer the moment this drain returned, unsafe to
* dereference (only a non-NULL check was safe). core_events.c's
* RESVALS_FULL/RESVALS_DELTA handlers now `dup_val()` before
* `zarray_put()`, so the core owns an independent copy and this
* function's unconditional free of `full_val`/`delta_val` below is
* exactly what should happen -- core->sharedInst's stored value is now
* genuinely safe to read back and assert on (see
* test_resval_ownership.c for the dedicated regression test proving
* this end-to-end under a REAL, non-stubbed kill_sched_resval). `which`
* must be set explicitly here (XCE_VAL_NUMERIC) -- dup_val() now
* actually inspects it, and a bare calloc() leaves it 0 (==
* XCE_VAL_STRING), which would make dup_val() misread `u.ivalue32`'s
* bit pattern as a `char*` and crash in makeString(). */
full_val = (xce_val_t*)calloc(1, sizeof(*full_val));
full_val->which = XCE_VAL_NUMERIC;
full_val->u.ivalue32 = 16;
full_resval = (xce_resval_t*)calloc(1, sizeof(*full_resval));
full_resval->name = strdup("cpu");
@ -698,9 +741,10 @@ static void test_drain_translates_and_pushes_all_ten_op_types(void)
enqueue(&core, r4);
/* 5. UPDATE_RESVALS -> RESVALS_DELTA: attributes to worker "hostA".
* Same shallow-store-then-free hazard as 4 above (per-worker resvals
* zarray instead of core->sharedInst) -- same "non-NULL only" caveat. */
delta_val = (xce_val_t*)calloc(1, sizeof(*delta_val));
* Same fix as 4 above (per-worker resvals zarray instead of
* core->sharedInst) -- `which` set explicitly for the same reason. */
delta_val = (xce_val_t*)calloc(1, sizeof(*delta_val));
delta_val->which = XCE_VAL_NUMERIC;
delta_val->u.ivalue32 = 4;
delta_resval = (xce_resval_t*)calloc(1, sizeof(*delta_resval));
delta_resval->name = strdup("cpu");

View file

@ -52,6 +52,7 @@
*/
#include <errno.h>
#include <limits.h>
#include <sys/wait.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdint.h>
@ -638,6 +639,8 @@ static void test_info_job_unknown_reports_not_found(void)
static void test_info_queue_reports_status_and_queued_jobs(void)
{
xce_sched_sim_driver_t sim;
char jobid_marker[80];
char jobid_buf[64];
char* captured;
memset(&sim, 0, sizeof(sim));
@ -645,10 +648,16 @@ static void test_info_queue_reports_status_and_queued_jobs(void)
captured = capture_dispatch(&sim, "info queue normal");
/* bug-1591: repl_info_queue_one() (repl_info.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(jobid_marker, sizeof(jobid_marker), "jobid=%s", jobid_buf);
TEST_ASSERT_NOT_NULL(strstr(captured, "queue normal"));
TEST_ASSERT_NOT_NULL(strstr(captured, "priority=5"));
TEST_ASSERT_NOT_NULL(strstr(captured, "status=open"));
TEST_ASSERT_NOT_NULL_MESSAGE(strstr(captured, "jobid=4242"),
TEST_ASSERT_NOT_NULL_MESSAGE(strstr(captured, jobid_marker),
"the fixture's own PEND job must appear "
"in the queue's queued-job listing");
@ -736,6 +745,316 @@ static void test_info_worker_reports_resource_values(void)
printf("test_info_worker_reports_resource_values: PASS\n");
}
/*
* bug-1590: repl_info_worker_one() forced EVERY resource value through the
* numeric "total=%.2f available=%.2f" format, silently coercing any
* string- or boolean-valued resource (e.g. `type`/`model`, or any
* XCE_VAL_BOOLEAN resource) to 0.00 -- indistinguishable from a genuinely
* zero numeric value. Regression: a string and a boolean resource must
* each render their real value via a "value=..." line, never a fabricated
* total/available pair.
*/
static void test_info_worker_reports_string_and_boolean_resource_values(void)
{
xce_sched_sim_driver_t sim;
xce_core_event_t events[2];
xce_resource_t wire_resource_str;
xce_resource_t wire_resource_bool;
xce_worker_t wire_worker;
xce_worker_t* worker_ptrs[1];
xce_val_t val_str;
xce_val_t val_bool;
xce_name_val_t nv_str;
xce_name_val_t nv_bool;
xce_name_val_t* nv_ptrs[2];
char* captured;
int rc;
memset(&sim, 0, sizeof(sim));
rc = xce_sched_core_init(&(sim.core));
TEST_ASSERT_EQUAL_INT(0, rc);
memset(&wire_resource_str, 0, sizeof(wire_resource_str));
wire_resource_str.name = "type";
wire_resource_str.valtype = XCE_VAL_STRING;
TEST_ASSERT_NOT_NULL(makeResource(sim.core.resmgr, &wire_resource_str));
memset(&wire_resource_bool, 0, sizeof(wire_resource_bool));
wire_resource_bool.name = "dockerized";
wire_resource_bool.valtype = XCE_VAL_BOOLEAN;
TEST_ASSERT_NOT_NULL(makeResource(sim.core.resmgr, &wire_resource_bool));
memset(&val_str, 0, sizeof(val_str));
val_str.which = Value_u_svalue;
val_str.u.svalue = "linux86";
memset(&nv_str, 0, sizeof(nv_str));
nv_str.name = "type";
nv_str.val = &val_str;
memset(&val_bool, 0, sizeof(val_bool));
val_bool.which = Value_u_bvalue;
val_bool.u.bvalue = 1;
memset(&nv_bool, 0, sizeof(nv_bool));
nv_bool.name = "dockerized";
nv_bool.val = &val_bool;
nv_ptrs[0] = &nv_str;
nv_ptrs[1] = &nv_bool;
memset(&wire_worker, 0, sizeof(wire_worker));
wire_worker.name = "hostA";
wire_worker.status = 0;
wire_worker.nresvals_to_sched = 2;
wire_worker.resvals_to_sched = nv_ptrs;
worker_ptrs[0] = &wire_worker;
memset(events, 0, sizeof(events));
events[0].type = XCE_CORE_EVENT_WORKERS_PUSH;
events[0].u.workers_push.nworkers = 1;
events[0].u.workers_push.workers = worker_ptrs;
rc = xce_core_push_events(&(sim.core), events, 1);
TEST_ASSERT_EQUAL_INT(0, rc);
captured = capture_dispatch(&sim, "info worker hostA");
TEST_ASSERT_NOT_NULL_MESSAGE(
strstr(captured, "resource type: value=linux86"),
"a string-valued resource must display its real string value, "
"not a fabricated total/available pair (bug-1590)");
TEST_ASSERT_NULL_MESSAGE(strstr(captured, "resource type: total="),
"a string-valued resource must never be "
"rendered with a total=/available= pair");
TEST_ASSERT_NOT_NULL_MESSAGE(
strstr(captured, "resource dockerized: value=true"),
"a boolean-valued resource must display true/false, not a "
"fabricated total/available pair (bug-1590)");
TEST_ASSERT_NULL_MESSAGE(strstr(captured, "resource dockerized: total="),
"a boolean-valued resource must never be "
"rendered with a total=/available= pair");
free(captured);
printf("test_info_worker_reports_string_and_boolean_resource_values: "
"PASS\n");
}
/*
* bug-1590: repl_info_resource_one() (the "info resource <name>"
* aggregate-across-workers command) had the same defect one layer up --
* total/available aggregation silently excluded any non-numeric resource,
* while `valtype=string`/`valtype=boolean` was already labeled correctly
* two fields over. Regression: the resource's actual value must be
* reported, not silently dropped from the summary.
*/
static void test_info_resource_reports_string_value(void)
{
xce_sched_sim_driver_t sim;
xce_core_event_t events[1];
xce_resource_t wire_resource_str;
xce_val_t val_str;
xce_resval_t rv_str;
xce_resval_t* rv_ptrs[1];
char* captured;
int rc;
memset(&sim, 0, sizeof(sim));
rc = xce_sched_core_init(&(sim.core));
TEST_ASSERT_EQUAL_INT(0, rc);
/* bug-1593 (task 12.5): 'info resource' now reads a GLOBAL/SHARED
* resource's value from core->sharedInst -- pushed here via
* RESVALS_FULL (mirrors core_events.c's own RESVALS_FULL handler),
* not via a per-worker WORKERS_PUSH resval anymore. */
memset(&wire_resource_str, 0, sizeof(wire_resource_str));
wire_resource_str.name = "license_pool";
wire_resource_str.valtype = XCE_VAL_STRING;
wire_resource_str.flags = XCE_RESF_GLOBAL;
TEST_ASSERT_NOT_NULL(makeResource(sim.core.resmgr, &wire_resource_str));
memset(&val_str, 0, sizeof(val_str));
val_str.which = Value_u_svalue;
val_str.u.svalue = "linux86";
memset(&rv_str, 0, sizeof(rv_str));
rv_str.name = "license_pool";
rv_str.val = &val_str;
rv_ptrs[0] = &rv_str;
memset(events, 0, sizeof(events));
events[0].type = XCE_CORE_EVENT_RESVALS_FULL;
events[0].u.resvals_full.n = 1;
events[0].u.resvals_full.resvals = rv_ptrs;
rc = xce_core_push_events(&(sim.core), events, 1);
TEST_ASSERT_EQUAL_INT(0, rc);
captured = capture_dispatch(&sim, "info resource license_pool");
TEST_ASSERT_NOT_NULL_MESSAGE(
strstr(captured, "resource license_pool: desc=(none) "
"valtype=string flags=GLOBAL interval=0"),
"a GLOBAL resource's definition metadata must be printed "
"(bug-1593)");
TEST_ASSERT_NOT_NULL_MESSAGE(
strstr(captured, "value=linux86"),
"a GLOBAL/SHARED string resource's real value must be reported, "
"read from core->sharedInst (bug-1590/bug-1593)");
TEST_ASSERT_NULL_MESSAGE(strstr(captured, "total="),
"a string resource must never be rendered "
"with a total=/available= pair");
TEST_ASSERT_NULL_MESSAGE(strstr(captured, "host-based resource"),
"a GLOBAL resource must never be reported as "
"host-based");
free(captured);
printf("test_info_resource_reports_string_value: PASS\n");
}
/*
* bug-1593 (task 12.5): `info resource all` must list only resources
* flagged XCE_RESF_GLOBAL/XCE_RESF_SHARED -- a host-based resource (no
* such flag, e.g. cpus/mem) is `info worker`'s job, never this command's.
*/
static void test_info_resource_all_lists_only_global_shared_resources(void)
{
xce_sched_sim_driver_t sim;
xce_core_event_t events[1];
xce_resource_t wire_resource_shared;
xce_resource_t wire_resource_hostbased;
xce_val_t val;
xce_resval_t rv;
xce_resval_t* rv_ptrs[1];
char* captured;
int rc;
memset(&sim, 0, sizeof(sim));
rc = xce_sched_core_init(&(sim.core));
TEST_ASSERT_EQUAL_INT(0, rc);
memset(&wire_resource_shared, 0, sizeof(wire_resource_shared));
wire_resource_shared.name = "licenses";
wire_resource_shared.valtype = XCE_VAL_NUMERIC;
wire_resource_shared.flags = XCE_RESF_SHARED;
TEST_ASSERT_NOT_NULL(
makeResource(sim.core.resmgr, &wire_resource_shared));
memset(&wire_resource_hostbased, 0, sizeof(wire_resource_hostbased));
wire_resource_hostbased.name = "mem";
wire_resource_hostbased.valtype = XCE_VAL_NUMERIC;
/* flags left 0 -- host-based, no GLOBAL/SHARED bit. */
TEST_ASSERT_NOT_NULL(
makeResource(sim.core.resmgr, &wire_resource_hostbased));
memset(&val, 0, sizeof(val));
val.which = Value_u_fvalue;
val.u.fvalue = 8.0;
memset(&rv, 0, sizeof(rv));
rv.name = "licenses";
rv.val = &val;
rv_ptrs[0] = &rv;
memset(events, 0, sizeof(events));
events[0].type = XCE_CORE_EVENT_RESVALS_FULL;
events[0].u.resvals_full.n = 1;
events[0].u.resvals_full.resvals = rv_ptrs;
rc = xce_core_push_events(&(sim.core), events, 1);
TEST_ASSERT_EQUAL_INT(0, rc);
captured = capture_dispatch(&sim, "info resource all");
TEST_ASSERT_NOT_NULL_MESSAGE(
strstr(captured, "resource licenses:"),
"a SHARED resource must appear in 'info resource all' (bug-1593)");
TEST_ASSERT_NULL_MESSAGE(
strstr(captured, "resource mem:"),
"a host-based resource must NOT appear in 'info resource all' -- "
"that is info worker's job, not info resource's (bug-1593)");
free(captured);
printf("test_info_resource_all_lists_only_global_shared_resources: "
"PASS\n");
}
/*
* bug-1593 (task 12.5): a host-based resource's own NAMED lookup
* (`info resource <name>`, as opposed to `all`) still resolves it, so an
* operator can check its metadata/flags -- but reports no fabricated
* cluster-wide value, directing to `info worker` instead.
*/
static void test_info_resource_named_lookup_reports_host_based_note(void)
{
xce_sched_sim_driver_t sim;
xce_resource_t wire_resource;
char* captured;
int rc;
memset(&sim, 0, sizeof(sim));
rc = xce_sched_core_init(&(sim.core));
TEST_ASSERT_EQUAL_INT(0, rc);
memset(&wire_resource, 0, sizeof(wire_resource));
wire_resource.name = "cpus";
wire_resource.valtype = XCE_VAL_NUMERIC;
TEST_ASSERT_NOT_NULL(makeResource(sim.core.resmgr, &wire_resource));
captured = capture_dispatch(&sim, "info resource cpus");
TEST_ASSERT_NOT_NULL_MESSAGE(
strstr(captured, "resource cpus: desc=(none) valtype=numeric "
"flags=(none) interval=0"),
"a host-based resource's metadata must still be reported by a "
"named lookup (bug-1593)");
TEST_ASSERT_NOT_NULL_MESSAGE(
strstr(captured, "host-based resource -- see 'info worker <name>' "
"for per-host values"),
"a host-based resource must direct the operator to 'info worker', "
"never a fabricated cluster-wide value (bug-1593)");
free(captured);
printf("test_info_resource_named_lookup_reports_host_based_note: "
"PASS\n");
}
/*
* bug-1596 (task 12.5): repl_split_tokens() must strip one matching pair
* of surrounding quote characters -- an operator quoting an argument out
* of habit (e.g. pasted straight from another command's own output) must
* not get a silent wrong-answer failure.
*/
static void test_info_job_accepts_quoted_jobid_argument(void)
{
xce_sched_sim_driver_t sim;
char* captured_dquoted;
char* captured_squoted;
memset(&sim, 0, sizeof(sim));
build_fixture_core(&(sim.core), 777);
captured_dquoted = capture_dispatch(&sim, "info job \"777\"");
TEST_ASSERT_NOT_NULL_MESSAGE(
strstr(captured_dquoted, "job 777"),
"a double-quoted jobid argument must resolve the same job as the "
"unquoted form (bug-1596)");
TEST_ASSERT_NULL_MESSAGE(
strstr(captured_dquoted, "no such job"),
"must not silently mis-parse the quote characters into jobid 0");
free(captured_dquoted);
captured_squoted = capture_dispatch(&sim, "info job '777'");
TEST_ASSERT_NOT_NULL_MESSAGE(
strstr(captured_squoted, "job 777"),
"a single-quoted jobid argument must resolve the same job too");
TEST_ASSERT_NULL_MESSAGE(strstr(captured_squoted, "no such job"), "");
free(captured_squoted);
printf("test_info_job_accepts_quoted_jobid_argument: PASS\n");
}
static void test_info_summary_reports_counts(void)
{
xce_sched_sim_driver_t sim;
@ -989,20 +1308,102 @@ static void write_text_file(const char* path, const char* content)
fclose(f);
}
static xce_sched_sim_driver_t g_d_sim;
static sched_sim_args_t g_d_args;
static int g_d_rc;
static void run_driver_for_group_d_capture(void)
static char* read_whole_file(const char* path)
{
g_d_rc = sched_sim_driver_run(&g_d_sim, &g_d_args);
FILE* f = fopen(path, "rb");
long len;
char* buf;
TEST_ASSERT_NOT_NULL(f);
TEST_ASSERT_EQUAL_INT(0, fseek(f, 0, SEEK_END));
len = ftell(f);
TEST_ASSERT_TRUE(len >= 0);
rewind(f);
buf = (char*)malloc((size_t)len + 1);
TEST_ASSERT_NOT_NULL(buf);
if (len > 0) {
TEST_ASSERT_EQUAL_INT((int)len, (int)fread(buf, 1, (size_t)len, f));
}
buf[len] = '\0';
fclose(f);
return buf;
}
/*
* task 12.5 (bug-1594): a completed --interactive run now re-enters the
* REPL at replay completion instead of returning, so any Group D script
* that runs to the fixture's true EOF needs an explicit trailing `quit`
* and must run in a FORKED child -- otherwise the implicit-EOF-quit (or
* the scripted `quit` itself) calls exit(0) inside this shared unity test
* binary. Same fork+capture-file pattern test_repl_execution_control.c's
* own Test 3 and test_repl_snapshot_params.c already establish.
*/
static void run_group_d_child(const char* replay_path,
const char* script_path,
const char* capture_path)
{
xce_sched_sim_driver_t sim;
sched_sim_args_t args;
if (freopen(script_path, "r", stdin) == NULL) {
_exit(120);
}
if (freopen(capture_path, "w", stdout) == NULL) {
_exit(121);
}
memset(&sim, 0, sizeof(sim));
memset(&args, 0, sizeof(args));
args.replay_path = replay_path;
args.sim_start_ts = SCHED_SIM_START_TS_LATEST;
args.sim_speed = 0;
args.interactive = true;
(void)sched_sim_driver_run(&sim, &args);
fflush(stdout);
_exit(111); /* unreachable -- the script's trailing `quit` must
* already have exited */
}
/* Forks run_group_d_child(), waits for it, and returns the captured
* stdout as a malloc'd buffer (caller frees). Asserts a clean exit(0). */
static char* run_group_d_and_capture(const char* replay_path,
const char* script_path,
const char* capture_path)
{
pid_t pid = fork();
int status;
TEST_ASSERT_TRUE_MESSAGE(pid >= 0, "fork() must succeed");
if (pid == 0) {
run_group_d_child(replay_path, script_path, capture_path);
_exit(112); /* unreachable */
}
TEST_ASSERT_EQUAL_INT_MESSAGE(pid, waitpid(pid, &status, 0),
"waitpid must reap the REPL child");
TEST_ASSERT_TRUE_MESSAGE(WIFEXITED(status),
"the REPL child must exit normally via "
"quit's exit(0), not crash/signal");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, WEXITSTATUS(status),
"quit's exit(0) is the only correct "
"exit path");
return read_whole_file(capture_path);
}
static void test_event_breakpoint_pauses_repl_at_matching_event(void)
{
char replay_path[PATH_MAX];
char script_dir[PATH_MAX];
char aux_dir[PATH_MAX];
char script_path[PATH_MAX];
char capture_path[PATH_MAX];
char pend_marker[80];
char jobid_buf[64];
FILE* f;
char* captured;
char* bp_pos;
@ -1020,26 +1421,25 @@ static void test_event_breakpoint_pauses_repl_at_matching_event(void)
append_end_of_stream_marker(f);
fclose(f);
make_tmp_dir(script_dir, sizeof(script_dir), "_evbpscript");
snprintf(script_path, sizeof(script_path), "%s/script.txt", script_dir);
make_tmp_dir(aux_dir, sizeof(aux_dir), "_evbpaux");
snprintf(script_path, sizeof(script_path), "%s/script.txt", aux_dir);
snprintf(capture_path, sizeof(capture_path), "%s/capture.out", aux_dir);
/* task 12.5 (bug-1594): trailing `quit` -- the second `continue` now
* runs this one-session fixture all the way to replay completion,
* which re-enters the REPL instead of returning. */
write_text_file(script_path,
"break JOB_STATE_CHANGE jobid=222\ncontinue\ncontinue\n");
"break JOB_STATE_CHANGE jobid=222\ncontinue\ncontinue\nquit\n");
TEST_ASSERT_NOT_NULL(freopen(script_path, "r", stdin));
captured = run_group_d_and_capture(replay_path, script_path, capture_path);
memset(&g_d_sim, 0, sizeof(g_d_sim));
memset(&g_d_args, 0, sizeof(g_d_args));
g_d_args.replay_path = replay_path;
g_d_args.sim_start_ts = SCHED_SIM_START_TS_LATEST;
g_d_args.sim_speed = 0;
g_d_args.interactive = true;
captured = capture_stdout_around(run_driver_for_group_d_capture);
TEST_ASSERT_EQUAL_INT(0, g_d_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);
bp_pos = strstr(captured, "hit breakpoint 1 @ event #4: JOB_STATE_CHANGE");
pend_pos = strstr(captured, "PEND jobid=4242");
pend_pos = strstr(captured, pend_marker);
TEST_ASSERT_NOT_NULL_MESSAGE(
bp_pos, "the breakpoint must fire exactly at event #4 (jobid=222), "
@ -1064,8 +1464,12 @@ static void test_event_breakpoint_pauses_repl_at_matching_event(void)
static void test_decision_breakpoint_pauses_after_decision_output(void)
{
char replay_path[PATH_MAX];
char script_dir[PATH_MAX];
char aux_dir[PATH_MAX];
char script_path[PATH_MAX];
char capture_path[PATH_MAX];
char pend_marker[80];
char bp_marker[112];
char jobid_buf[64];
FILE* f;
char* captured;
char* pend_pos;
@ -1081,25 +1485,26 @@ static void test_decision_breakpoint_pauses_after_decision_output(void)
append_end_of_stream_marker(f);
fclose(f);
make_tmp_dir(script_dir, sizeof(script_dir), "_decbpscript");
snprintf(script_path, sizeof(script_path), "%s/script.txt", script_dir);
write_text_file(script_path, "break DECISION type=PEND\ncontinue\ncontinue\n");
make_tmp_dir(aux_dir, sizeof(aux_dir), "_decbpaux");
snprintf(script_path, sizeof(script_path), "%s/script.txt", aux_dir);
snprintf(capture_path, sizeof(capture_path), "%s/capture.out", aux_dir);
/* task 12.5 (bug-1594): trailing `quit` -- see the event-breakpoint
* test above for why. */
write_text_file(script_path,
"break DECISION type=PEND\ncontinue\ncontinue\nquit\n");
TEST_ASSERT_NOT_NULL(freopen(script_path, "r", stdin));
captured = run_group_d_and_capture(replay_path, script_path, capture_path);
memset(&g_d_sim, 0, sizeof(g_d_sim));
memset(&g_d_args, 0, sizeof(g_d_args));
g_d_args.replay_path = replay_path;
g_d_args.sim_start_ts = SCHED_SIM_START_TS_LATEST;
g_d_args.sim_speed = 0;
g_d_args.interactive = true;
/* bug-1591: sim_print_decision() (sim_loop.c) and repl_break.c's
* breakpoint-hit message now render jobids via fmt_jobid(), not raw
* %llu -- these markers must match that same human-readable form. */
fmt_jobid(jobid_buf, sizeof(jobid_buf), 555ULL);
snprintf(pend_marker, sizeof(pend_marker), "PEND jobid=%s", jobid_buf);
snprintf(bp_marker, sizeof(bp_marker),
"hit breakpoint 1 @ decision: PEND jobid=%s", jobid_buf);
captured = capture_stdout_around(run_driver_for_group_d_capture);
TEST_ASSERT_EQUAL_INT(0, g_d_rc);
pend_pos = strstr(captured, "PEND jobid=555");
bp_pos = strstr(captured, "hit breakpoint 1 @ decision: PEND jobid=555");
pend_pos = strstr(captured, pend_marker);
bp_pos = strstr(captured, bp_marker);
TEST_ASSERT_NOT_NULL_MESSAGE(pend_pos, "the fixture's own PEND decision "
"must have run");
@ -1115,6 +1520,84 @@ static void test_decision_breakpoint_pauses_after_decision_output(void)
printf("test_decision_breakpoint_pauses_after_decision_output: PASS\n");
}
/*
* bug-1595 (task 12.5): `step`/`continue` must echo each input event, in
* the order it is decoded, BEFORE the session's own resulting decision --
* v1 only ever printed decisions, leaving every event silent unless a
* breakpoint happened to match it.
*/
static void test_step_echoes_events_before_session_decisions(void)
{
char replay_path[PATH_MAX];
char aux_dir[PATH_MAX];
char script_path[PATH_MAX];
char capture_path[PATH_MAX];
char pend_marker[80];
char jobid_buf[64];
FILE* f;
char* captured;
char* jobpush_pos;
char* statechange_pos;
char* pend_pos;
f = write_header_and_snapshot(replay_path, sizeof(replay_path),
"_evorder");
TEST_ASSERT_NOT_NULL(f);
append_queue_update_event(f, 1000ULL, "normal");
append_jobs_push_event(f, 1500ULL, 6161ULL, "normal");
/* Unrelated jobid (7171, not 6161) -- deliberately does NOT match the
* job just pushed above. append_jobstatechange_event() is used here
* purely as a second event type to verify echo ordering against
* (see its own header comment: core_events.c WARN+skips an unknown
* jobid rather than fail). Reusing 6161 here would actually transition
* the real pushed job PEND->RUN before the session runs, so the picker
* would never offer it as a PEND candidate and the session's own
* decision this test asserts on below would never be produced. */
append_jobstatechange_event(f, 1600ULL, 7171ULL);
append_replay_session(f, 2000ULL);
append_end_of_stream_marker(f);
fclose(f);
make_tmp_dir(aux_dir, sizeof(aux_dir), "_evorderaux");
snprintf(script_path, sizeof(script_path), "%s/script.txt", aux_dir);
snprintf(capture_path, sizeof(capture_path), "%s/capture.out", aux_dir);
/* "step 1" pauses right after session 1 completes -- never reaches
* true replay EOF, so a plain trailing `quit` is enough here (same
* precedent test_step_1_processes_exactly_one_session_then_quit_exits,
* test_repl_execution_control.c, already establishes). */
write_text_file(script_path, "step 1\nquit\n");
captured = run_group_d_and_capture(replay_path, script_path, capture_path);
fmt_jobid(jobid_buf, sizeof(jobid_buf), 6161ULL);
snprintf(pend_marker, sizeof(pend_marker), "PEND jobid=%s", jobid_buf);
jobpush_pos = strstr(captured, "[sim] event: JOB_PUSH");
statechange_pos = strstr(captured, "[sim] event: JOB_STATE_CHANGE");
pend_pos = strstr(captured, pend_marker);
TEST_ASSERT_NOT_NULL_MESSAGE(jobpush_pos,
"a JOBS_PUSH event must be echoed "
"(bug-1595)");
TEST_ASSERT_NOT_NULL_MESSAGE(statechange_pos,
"a JOB_STATE_CHANGE event must be echoed "
"(bug-1595)");
TEST_ASSERT_NOT_NULL_MESSAGE(pend_pos,
"the session's own decision must still "
"be printed");
TEST_ASSERT_TRUE_MESSAGE(jobpush_pos < statechange_pos,
"events must be echoed in the order they are "
"decoded");
TEST_ASSERT_TRUE_MESSAGE(statechange_pos < pend_pos,
"events must be echoed BEFORE the session's "
"resulting decision (bug-1595)");
free(captured);
printf("test_step_echoes_events_before_session_decisions: PASS\n");
}
int main(void)
{
UNITY_BEGIN();
@ -1135,11 +1618,17 @@ int main(void)
RUN_TEST(test_info_job_unknown_reports_not_found);
RUN_TEST(test_info_queue_reports_status_and_queued_jobs);
RUN_TEST(test_info_worker_reports_resource_values);
RUN_TEST(test_info_worker_reports_string_and_boolean_resource_values);
RUN_TEST(test_info_resource_reports_string_value);
RUN_TEST(test_info_resource_all_lists_only_global_shared_resources);
RUN_TEST(test_info_resource_named_lookup_reports_host_based_note);
RUN_TEST(test_info_job_accepts_quoted_jobid_argument);
RUN_TEST(test_info_summary_reports_counts);
RUN_TEST(test_info_session_reports_position_after_synthetic_events);
RUN_TEST(test_event_breakpoint_pauses_repl_at_matching_event);
RUN_TEST(test_decision_breakpoint_pauses_after_decision_output);
RUN_TEST(test_step_echoes_events_before_session_decisions);
return UNITY_END();
}

View file

@ -107,6 +107,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
#include <unity/unity.h>
@ -571,12 +572,14 @@ test_repl_step_continue_to_completion_matches_batch_decision_sequence(void)
make_tmp_dir(script_dir, sizeof(script_dir), "_script");
snprintf(script_path, sizeof(script_path), "%s/script.txt", script_dir);
write_text_file(script_path, "step 1\ncontinue\n");
/* task 12.5 (bug-1594): trailing `quit` -- `continue` now re-enters
* the REPL at replay completion instead of returning, so this side
* must run in a FORKED child (matching test_repl_execution_control.c's
* own Test 3 pattern); otherwise quit's exit(0) would tear down this
* whole test binary before the batch-vs-REPL comparison below ever
* runs. */
write_text_file(script_path, "step 1\ncontinue\nquit\n");
TEST_ASSERT_NOT_NULL_MESSAGE(freopen(script_path, "r", stdin),
"freopen of scripted stdin must succeed");
memset(&sim_repl, 0, sizeof(sim_repl));
memset(&args_repl, 0, sizeof(args_repl));
args_repl.replay_path = fixture_path;
args_repl.sim_start_ts = SCHED_SIM_START_TS_LATEST;
@ -584,8 +587,30 @@ test_repl_step_continue_to_completion_matches_batch_decision_sequence(void)
args_repl.output_path = repl_out_path;
args_repl.interactive = true;
rc = sched_sim_driver_run(&sim_repl, &args_repl);
TEST_ASSERT_EQUAL_INT(0, rc);
{
pid_t pid = fork();
int status;
TEST_ASSERT_TRUE_MESSAGE(pid >= 0, "fork() must succeed");
if (pid == 0) {
if (freopen(script_path, "r", stdin) == NULL) {
_exit(120);
}
memset(&sim_repl, 0, sizeof(sim_repl));
(void)sched_sim_driver_run(&sim_repl, &args_repl);
_exit(111); /* unreachable -- quit must already have exited */
}
TEST_ASSERT_EQUAL_INT_MESSAGE(pid, waitpid(pid, &status, 0),
"waitpid must reap the REPL child");
TEST_ASSERT_TRUE_MESSAGE(WIFEXITED(status),
"the REPL child must exit normally via "
"quit's exit(0), not crash/signal");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, WEXITSTATUS(status),
"quit's exit(0) is the only correct "
"exit path");
}
/* ---- Decode every ReplaySession from both output files and compare
* the decision sequence, session by session, decision by decision. ---- */

View file

@ -455,25 +455,61 @@ static void test_should_pause_never_fires_in_continue_mode(void)
/* -- Observable (a), proved by output ORDER, not merely presence. */
/* ---------------------------------------------------------------------- */
static xce_sched_sim_driver_t g_t2_sim;
static sched_sim_args_t g_t2_args;
static int g_t2_rc;
static void run_driver_for_t2_capture(void)
/*
* task 12.5 (bug-1594): a completed --interactive run now re-enters the
* REPL (xce_repl_on_replay_complete(), repl.c) instead of returning, so a
* "continue"-to-EOF script that has no further scripted line will hit
* stdin EOF inside that re-entered repl_command_loop() -- which
* repl_command_loop() has always treated as an implicit `quit` (exit(0)).
* Before task 12.5 this test ran the driver IN-PROCESS (capture_stdout_
* around()) because sched_sim_driver_run() genuinely returned once EOF was
* reached; now it must fork, exactly like Test 3 below, since exit(0)
* would otherwise tear down this whole unity binary mid-suite.
*/
static void child_prepause_then_quit(const char* replay_path,
const char* script_path,
const char* capture_path)
{
g_t2_rc = sched_sim_driver_run(&g_t2_sim, &g_t2_args);
xce_sched_sim_driver_t sim;
sched_sim_args_t args;
if (freopen(script_path, "r", stdin) == NULL) {
_exit(120);
}
if (freopen(capture_path, "w", stdout) == NULL) {
_exit(121);
}
memset(&sim, 0, sizeof(sim));
memset(&args, 0, sizeof(args));
args.replay_path = replay_path;
args.sim_start_ts = SCHED_SIM_START_TS_LATEST;
args.sim_speed = 0;
args.interactive = true;
(void)sched_sim_driver_run(&sim, &args);
/* Unreachable in a correct implementation -- the script's trailing
* `quit` (or, absent that, stdin EOF) must already have called
* exit(0) from inside the post-replay-completion pause. */
fflush(stdout);
_exit(111);
}
static void test_pre_replay_pause_blocks_before_any_event_is_processed(void)
{
char replay_path[PATH_MAX];
char script_dir[PATH_MAX];
char aux_dir[PATH_MAX];
char script_path[PATH_MAX];
char capture_path[PATH_MAX];
char pend_marker[80];
char jobid_buf[64];
FILE* f;
pid_t pid;
int status;
char* captured;
char* ready_pos;
char* pend_pos;
FILE* saved_stdin_check;
f = write_header_and_snapshot(replay_path, sizeof(replay_path),
"_prepause");
@ -485,30 +521,43 @@ static void test_pre_replay_pause_blocks_before_any_event_is_processed(void)
append_end_of_stream_marker(f);
fclose(f);
make_tmp_dir(script_dir, sizeof(script_dir), "_prepausescript");
snprintf(script_path, sizeof(script_path), "%s/script.txt", script_dir);
/* A single "continue" -- 12.1's own scope means continue never
* re-pauses, so the run reaches EOF cleanly without reading stdin
* again. */
write_text_file(script_path, "continue\n");
make_tmp_dir(aux_dir, sizeof(aux_dir), "_prepauseaux");
snprintf(script_path, sizeof(script_path), "%s/script.txt", aux_dir);
snprintf(capture_path, sizeof(capture_path), "%s/capture.out", aux_dir);
/* "continue" runs to EOF (12.1's own scope: continue never re-pauses
* mid-stream absent a breakpoint); task 12.5 then re-enters the REPL
* at replay completion, so an explicit trailing `quit` is required to
* end the child deterministically. */
write_text_file(script_path, "continue\nquit\n");
saved_stdin_check = freopen(script_path, "r", stdin);
TEST_ASSERT_NOT_NULL_MESSAGE(saved_stdin_check,
"freopen of scripted stdin must succeed");
pid = fork();
TEST_ASSERT_TRUE_MESSAGE(pid >= 0, "fork() must succeed");
memset(&g_t2_sim, 0, sizeof(g_t2_sim));
memset(&g_t2_args, 0, sizeof(g_t2_args));
g_t2_args.replay_path = replay_path;
g_t2_args.sim_start_ts = SCHED_SIM_START_TS_LATEST;
g_t2_args.sim_speed = 0;
g_t2_args.interactive = true;
if (pid == 0) {
child_prepause_then_quit(replay_path, script_path, capture_path);
_exit(112); /* unreachable -- child_prepause_then_quit never
* returns */
}
captured = capture_stdout_around(run_driver_for_t2_capture);
TEST_ASSERT_EQUAL_INT_MESSAGE(pid, waitpid(pid, &status, 0),
"waitpid must reap the REPL child");
TEST_ASSERT_TRUE_MESSAGE(WIFEXITED(status),
"the REPL child must exit normally via quit's "
"exit(0), not crash/signal");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, WEXITSTATUS(status),
"quit's exit(0) is the only correct exit "
"path");
TEST_ASSERT_EQUAL_INT(0, g_t2_rc);
captured = read_whole_file(capture_path);
/* 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);
ready_pos = strstr(captured, "[sim] ready. type 'help' for commands.");
pend_pos = strstr(captured, "PEND jobid=4242");
pend_pos = strstr(captured, pend_marker);
TEST_ASSERT_NOT_NULL_MESSAGE(ready_pos,
"pre-replay pause banner must be printed");
@ -526,6 +575,204 @@ static void test_pre_replay_pause_blocks_before_any_event_is_processed(void)
"PASS\n");
}
/*
* task 12.5 (bug-1594) regression: `continue` reaching EOF with no
* breakpoints must NOT exit the process -- it must re-enter the REPL so
* the operator can inspect final state, and only `quit` (or stdin EOF,
* pre-existing documented behavior) may end it.
*/
static void child_continue_then_inspect(const char* replay_path,
const char* script_path,
const char* capture_path)
{
xce_sched_sim_driver_t sim;
sched_sim_args_t args;
if (freopen(script_path, "r", stdin) == NULL) {
_exit(120);
}
if (freopen(capture_path, "w", stdout) == NULL) {
_exit(121);
}
memset(&sim, 0, sizeof(sim));
memset(&args, 0, sizeof(args));
args.replay_path = replay_path;
args.sim_start_ts = SCHED_SIM_START_TS_LATEST;
args.sim_speed = 0;
args.interactive = true;
(void)sched_sim_driver_run(&sim, &args);
fflush(stdout);
_exit(111); /* unreachable -- the script's `quit` must already have
* exited */
}
static void test_continue_with_no_breakpoints_stays_alive_after_replay_complete(
void)
{
char replay_path[PATH_MAX];
char aux_dir[PATH_MAX];
char script_path[PATH_MAX];
char capture_path[PATH_MAX];
FILE* f;
pid_t pid;
int status;
char* captured;
f = write_header_and_snapshot(replay_path, sizeof(replay_path),
"_contnoexit");
TEST_ASSERT_NOT_NULL(f);
append_queue_update_event(f, 1000ULL, "normal");
append_jobs_push_event(f, 1500ULL, 9090ULL, "normal");
append_replay_session(f, 2000ULL);
append_end_of_stream_marker(f);
fclose(f);
make_tmp_dir(aux_dir, sizeof(aux_dir), "_contnoexitaux");
snprintf(script_path, sizeof(script_path), "%s/script.txt", aux_dir);
snprintf(capture_path, sizeof(capture_path), "%s/capture.out", aux_dir);
/* No breakpoints set. After `continue` runs to EOF, the REPL must
* still be alive to process `info summary` before `quit`. */
write_text_file(script_path, "continue\ninfo summary\nquit\n");
pid = fork();
TEST_ASSERT_TRUE_MESSAGE(pid >= 0, "fork() must succeed");
if (pid == 0) {
child_continue_then_inspect(replay_path, script_path, capture_path);
_exit(112);
}
TEST_ASSERT_EQUAL_INT_MESSAGE(pid, waitpid(pid, &status, 0),
"waitpid must reap the REPL child");
TEST_ASSERT_TRUE_MESSAGE(
WIFEXITED(status),
"the child must exit normally via quit's exit(0) -- a hang or "
"crash means the REPL did not survive replay completion");
TEST_ASSERT_EQUAL_INT(0, WEXITSTATUS(status));
captured = read_whole_file(capture_path);
TEST_ASSERT_NOT_NULL_MESSAGE(
strstr(captured, "[sim] replay complete."),
"replay completion must still print its banner");
TEST_ASSERT_NOT_NULL_MESSAGE(
strstr(captured, "1 jobs (1 pending, 0 running)"),
"info summary issued AFTER replay completion must still execute "
"against final state -- proves the REPL did not exit on EOF "
"(bug-1594)");
free(captured);
printf("test_continue_with_no_breakpoints_stays_alive_after_replay_"
"complete: PASS\n");
}
/*
* task 12.5 (bug-1594) regression: `step`/`continue` issued AFTER replay
* is already complete must re-prompt with an informational message, not
* be treated as "hand control back to a driver loop" (there is none left).
*/
static void child_continue_twice_after_complete(const char* replay_path,
const char* script_path,
const char* capture_path)
{
xce_sched_sim_driver_t sim;
sched_sim_args_t args;
if (freopen(script_path, "r", stdin) == NULL) {
_exit(120);
}
if (freopen(capture_path, "w", stdout) == NULL) {
_exit(121);
}
memset(&sim, 0, sizeof(sim));
memset(&args, 0, sizeof(args));
args.replay_path = replay_path;
args.sim_start_ts = SCHED_SIM_START_TS_LATEST;
args.sim_speed = 0;
args.interactive = true;
(void)sched_sim_driver_run(&sim, &args);
fflush(stdout);
_exit(111);
}
static void test_continue_after_replay_complete_reprompts_instead_of_exiting(
void)
{
char replay_path[PATH_MAX];
char aux_dir[PATH_MAX];
char script_path[PATH_MAX];
char capture_path[PATH_MAX];
FILE* f;
pid_t pid;
int status;
char* captured;
f = write_header_and_snapshot(replay_path, sizeof(replay_path),
"_contafter");
TEST_ASSERT_NOT_NULL(f);
append_queue_update_event(f, 1000ULL, "normal");
append_jobs_push_event(f, 1500ULL, 8080ULL, "normal");
append_replay_session(f, 2000ULL);
append_end_of_stream_marker(f);
fclose(f);
make_tmp_dir(aux_dir, sizeof(aux_dir), "_contafteraux");
snprintf(script_path, sizeof(script_path), "%s/script.txt", aux_dir);
snprintf(capture_path, sizeof(capture_path), "%s/capture.out", aux_dir);
/* Second `continue`/`step` are issued AFTER replay is already
* complete -- both must re-prompt, not attempt to resume anything. */
write_text_file(script_path, "continue\ncontinue\nstep\nquit\n");
pid = fork();
TEST_ASSERT_TRUE_MESSAGE(pid >= 0, "fork() must succeed");
if (pid == 0) {
child_continue_twice_after_complete(replay_path, script_path,
capture_path);
_exit(112);
}
TEST_ASSERT_EQUAL_INT_MESSAGE(pid, waitpid(pid, &status, 0),
"waitpid must reap the REPL child");
TEST_ASSERT_TRUE_MESSAGE(WIFEXITED(status),
"the child must exit normally via quit's "
"exit(0)");
TEST_ASSERT_EQUAL_INT(0, WEXITSTATUS(status));
captured = read_whole_file(capture_path);
{
const char* needle = "[sim] replay already complete -- no more "
"events to process. Use 'quit' to exit.";
char* first = strstr(captured, needle);
char* second = (first != NULL)
? strstr(first + 1, needle)
: NULL;
TEST_ASSERT_NOT_NULL_MESSAGE(
first, "a `continue` issued after replay completion must "
"print the re-prompt message, not silently resume/exit "
"(bug-1594)");
TEST_ASSERT_NOT_NULL_MESSAGE(
second, "a `step` issued after replay completion must ALSO "
"print the re-prompt message");
}
free(captured);
printf("test_continue_after_replay_complete_reprompts_instead_of_"
"exiting: PASS\n");
}
/* ---------------------------------------------------------------------- */
/* Test 3: "step 1" against a three-session fixture processes EXACTLY one */
/* session before re-pausing -- proved end to end via a forked child that */
@ -572,6 +819,10 @@ static void test_step_1_processes_exactly_one_session_then_quit_exits(void)
char aux_dir[PATH_MAX];
char script_path[PATH_MAX];
char capture_path[PATH_MAX];
char pend_marker_111[80];
char pend_marker_222[80];
char pend_marker_333[80];
char jobid_buf[64];
FILE* f;
pid_t pid;
int status;
@ -620,12 +871,25 @@ static void test_step_1_processes_exactly_one_session_then_quit_exits(void)
captured = read_whole_file(capture_path);
TEST_ASSERT_NOT_NULL_MESSAGE(strstr(captured, "PEND jobid=111"),
/* bug-1591: sim_print_decision() (sim_loop.c) now renders jobids via
* fmt_jobid(), not raw %llu -- these markers must match that same
* human-readable form. */
fmt_jobid(jobid_buf, sizeof(jobid_buf), 111ULL);
snprintf(pend_marker_111, sizeof(pend_marker_111), "PEND jobid=%s",
jobid_buf);
fmt_jobid(jobid_buf, sizeof(jobid_buf), 222ULL);
snprintf(pend_marker_222, sizeof(pend_marker_222), "PEND jobid=%s",
jobid_buf);
fmt_jobid(jobid_buf, sizeof(jobid_buf), 333ULL);
snprintf(pend_marker_333, sizeof(pend_marker_333), "PEND jobid=%s",
jobid_buf);
TEST_ASSERT_NOT_NULL_MESSAGE(strstr(captured, pend_marker_111),
"session 1's decision must have run");
TEST_ASSERT_NULL_MESSAGE(strstr(captured, "PEND jobid=222"),
TEST_ASSERT_NULL_MESSAGE(strstr(captured, pend_marker_222),
"session 2 must NEVER have run -- step 1 "
"must pause strictly after session 1");
TEST_ASSERT_NULL_MESSAGE(strstr(captured, "PEND jobid=333"),
TEST_ASSERT_NULL_MESSAGE(strstr(captured, pend_marker_333),
"session 3 must NEVER have run either");
free(captured);
@ -642,6 +906,8 @@ int main(void)
RUN_TEST(test_should_pause_never_fires_in_continue_mode);
RUN_TEST(test_pre_replay_pause_blocks_before_any_event_is_processed);
RUN_TEST(test_step_1_processes_exactly_one_session_then_quit_exits);
RUN_TEST(test_continue_with_no_breakpoints_stays_alive_after_replay_complete);
RUN_TEST(test_continue_after_replay_complete_reprompts_instead_of_exiting);
return UNITY_END();
}

View file

@ -0,0 +1,549 @@
/*
* test_resval_ownership.c - regression test for bug-1585's true root cause:
* a heap-use-after-free crash in the normal-mode driver's periodic resource-
* value (resval) update path, live-reproduced via gdb/ASAN against a real
* controller+scheduler+xcecd_virtual deployment (see .wolf/buglog.json
* bug-1585 for the full incident/backtrace).
*
* Root cause: core_events.c's XCE_CORE_EVENT_RESVALS_FULL and
* XCE_CORE_EVENT_RESVALS_DELTA handlers do `zarray_put(&(zarray), idx,
* rv->val)` -- a SHALLOW store of the wire-decoded `xce_val_t*` pointer
* (verified by reading zarray_put_ex(), src/lib/zarray.c: `array->data[idx]
* = data[i]`, no copy). zarray now considers itself the sole owner of that
* pointer, and will hand it to the zarray's killer (kill_sched_resval,
* schedobj.c) the NEXT time this same index is replaced. But driver.c's
* release_translated_payload() (XCE_SCHED_OP_UPDATE_RESVALS/_RESVALS_FULL
* cases) UNCONDITIONALLY frees the entire decoded ResValList/ResourceVal
* array immediately after xce_core_push_events() returns -- including the
* SAME `rv->val` the zarray just adopted by reference. The result: the very
* next periodic resval report for the same worker+resource (which happens
* every resource_sync interval on any live deployment, i.e. inevitably)
* triggers zarray_put's eviction of the now-dangling first value, which
* calls kill_sched_resval() -> free_Value() -> a heap-use-after-free READ
* on an already-fully-freed xce_val_t. This reliably crashes the live
* scheduler process within one resval-report cycle of any real deployment,
* which is why a `.replay` recording captured from a live session so often
* turns out empty/truncated (bug-1585): the scheduler dies before or during
* the interesting part of the session.
*
* This exact hazard was already NOTICED and explicitly documented as a
* "PRE-EXISTING production ownership characteristic" in
* test_driver_translate.c's test_drain_translates_and_pushes_all_ten_op_types
* comment (task 5.1) -- but never fixed, because that file's own
* kill_sched_resval is a pure no-op stub ("fixtures ... never torn down, so
* these killers are never actually invoked" -- true for the OTHER kvmgr
* datakillers, which fire only on core teardown, but NOT true for a
* zarray's killer, which fires on every REPLACEMENT of an occupied slot,
* not just teardown). That blind spot is exactly why no existing test
* caught this: this file uses the REAL kill_sched_resval (mirroring
* schedobj.c's implementation exactly) so the use-after-free is genuinely
* exercised, under ASAN, the same way the live crash was.
*
* Fix (core_events.c): both call sites now `dup_val(rv->val)` before
* zarray_put -- the same deep-copy-before-adopt convention this codebase
* ALREADY establishes elsewhere for the identical hazard (snapshot_load.c:
* `zarray_put(&(core->sharedInst), r->idx, dup_val(rv->val))` and
* `zarray_put(&(w->resvals), r->idx, dup_val(nv->val))`). After the fix,
* the zarray owns an independent copy; driver.c's unconditional free of the
* original decoded payload no longer touches memory the core still holds.
*
* White-box #include of ../src/core.c, ../src/core_clock.c,
* ../src/core_events.c, and ../src/driver.c -- same convention as
* test_driver_translate.c (this file's sibling), including its full stub
* set for driver.c/core_events.c's link-time externs this file's own tests
* never exercise (session/plugin/recording/publish -- see each stub's own
* comment, copied verbatim). The two stubs that matter for THIS file's
* Observable, unlike test_driver_translate.c, are NOT no-ops:
*
* - kill_sched_resval: the REAL implementation (schedobj.c:260 verbatim
* -- free_Value(val), no separate free(val); schedobj.c is not pulled
* in directly for the same "avoid its large stub surface" reason
* test_core_events.c/test_driver_translate.c already document for
* sched_resolve_job_picker).
* - dup_val: the REAL implementation (snapshot_wire.c:37 verbatim, same
* "reimplement the one small function instead of pulling in the whole
* file" rationale) -- core_events.c's fix (once applied) calls this by
* `extern` declaration, same idiom snapshot_load.c/snapshot.c already
* use for the identical symbol.
*/
#include <errno.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
#include "controller.capnp.h"
#include "kvmgr.h"
#include "scheduler.h"
#include "xstring.h"
/* Pull in the real xce_sched_core_init()/xce_core_push_events()/
* xce_sched_driver_translate_pending_events() under test into this TU --
* same white-box #include set as test_driver_translate.c. */
#include "../src/core.c"
#include "../src/core_clock.c"
#include "../src/core_events.c"
#include "../src/driver.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 plugin_unregister(void* arg) { (void)arg; }
/* REAL implementation -- schedobj.c:260 verbatim (see file header comment:
* this is the whole point of this test file). Deliberately reimplemented
* rather than pulling in schedobj.c itself (large stub surface, same
* rationale test_core_events.c/test_driver_translate.c already document for
* sched_resolve_job_picker). */
void kill_sched_resval(xce_val_t* val)
{
if (val == NULL) {
return;
}
free_Value(val);
}
/* REAL implementation -- snapshot_wire.c:37 verbatim. This is the fix under
* test: core_events.c's RESVALS_FULL/RESVALS_DELTA handlers must dup_val()
* before zarray_put() so the zarray owns an independent copy, not the same
* pointer driver.c's release_translated_payload() is about to free. */
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;
}
/* Link-satisfying stubs for scheduler.c's sched_publish_dispatch()/
* sched_publish_pend_reason() -- copied verbatim from test_driver_translate.c
* (see that file's header comment); never invoked by this file's tests. */
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 -- copied verbatim from test_driver_translate.c.
* xce_sched_driver_translate_pending_events() calls this behind a NULL
* `record_ctx` check; every call site below passes NULL, so never actually
* invoked at runtime. */
int sched_record_write_events(xce_scheduler_t* sched, xce_core_event_t* events,
int n)
{
(void)sched;
(void)events;
(void)n;
return 0;
}
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;
}
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(void* arg)
{
(void)arg;
return 0;
}
int plugin_end_session(void* arg)
{
(void)arg;
return 0;
}
int sched_account_jobs(xce_scheduler_t* sched)
{
(void)sched;
return 0;
}
/* Real reimplementation of schedobj.c's sched_resolve_job_picker -- copied
* verbatim from test_driver_translate.c's own copy (same rationale: no
* picker plugin is registered in this file's fixture, so this always
* returns -EINVAL and xce_core_push_events() proceeds with picker=NULL --
* every case in core_events.c already NULL-guards its picker hook call). */
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;
}
void setUp(void) { return; }
void tearDown(void) { return; }
/* ---------------------------------------------------------------------- */
/* Fixture -- identical to test_driver_translate.c's own. */
/* ---------------------------------------------------------------------- */
static void fixture_init(xce_sched_core_t* core)
{
int rc;
rc = xce_sched_core_init(core);
TEST_ASSERT_EQUAL_INT(0, rc);
core->pend_updates[0] = (dataq_t*)calloc(1, sizeof(dataq_t));
dataq_init(core->pend_updates[0], NULL);
core->pend_updates[1] = (dataq_t*)calloc(1, sizeof(dataq_t));
dataq_init(core->pend_updates[1], NULL);
}
static void enqueue(xce_sched_core_t* core, xce_sched_update_request_t* req)
{
dataq_enq(core->pend_updates[0], &(req->link));
}
static xce_sched_update_request_t* make_req(xce_sched_op_t op)
{
xce_sched_update_request_t* req
= (xce_sched_update_request_t*)calloc(1, sizeof(*req));
req->op = op;
return req;
}
/* Builds one heap-allocated worker->name-tagged resval delta, exactly the
* shape decode_ResourceVal_list() (controller.capnp.c) produces on the real
* wire-decode path -- see test_driver_translate.c's own
* make_wire_job()-adjacent comment for why heap allocation (not a stack
* local) matters here: this is what release_translated_payload()'s real
* free_ResValList_ptr() call expects to free. */
static xce_resval_list_t* make_resval_delta(const char* host,
const char* resname,
double fvalue)
{
xce_val_t* val = (xce_val_t*)calloc(1, sizeof(*val));
xce_resval_t* rv = (xce_resval_t*)calloc(1, sizeof(*rv));
xce_resval_t** vals = (xce_resval_t**)calloc(1, sizeof(*vals));
xce_resval_list_t* list = (xce_resval_list_t*)calloc(1, sizeof(*list));
val->which = XCE_VAL_NUMERIC;
val->u.fvalue = fvalue;
rv->name = strdup(resname);
rv->host = strdup(host);
rv->val = val;
vals[0] = rv;
list->count = 1;
list->vals = vals;
return list;
}
/* ---------------------------------------------------------------------- */
/* RESVALS_DELTA: two successive drains targeting the SAME worker+resource */
/* slot. RED (pre-fix): the second drain's zarray_put() eviction of the */
/* first drain's already-freed value crashes under ASAN */
/* (heap-use-after-free in free_Value, matching bug-1585's live backtrace).*/
/* GREEN (post-fix): both drains succeed, and the worker's live resval */
/* reads back the SECOND drain's value (8.0), proving no data corruption. */
/* ---------------------------------------------------------------------- */
static void test_resvals_delta_second_drain_does_not_use_after_free(void)
{
xce_sched_core_t core;
xce_worker_t* wire_worker;
xce_worker_t** worker_ptrs;
xce_resource_t* wire_resource;
xce_resource_t** resource_ptrs;
xce_resource_t* registered_res = NULL;
xce_sched_worker_t* w;
xce_val_t* got;
int rc;
fixture_init(&core);
/* Heap-allocated, heap-allocated-string wire payloads -- AND
* heap-allocated wrapper ARRAYS (not a stack array of pointers) --
* driver.c's REAL release_translated_payload() (this file links the
* genuine driver.c, not a stub) calls the real capnp-generated
* free_Worker_ptr()/free_Resource_ptr() on each element AND
* free(req->u.workerupdate.workers) on the array itself, exactly like a
* genuinely wire-decoded payload -- see test_driver_translate.c's
* make_wire_job() comment for why a stack local or string literal here
* would crash immediately (it does: this is precisely the pitfall this
* comment warns about, for both the element and the array). */
wire_worker = (xce_worker_t*)calloc(1, sizeof(*wire_worker));
wire_worker->name = strdup("hostA");
worker_ptrs = (xce_worker_t**)calloc(1, sizeof(*worker_ptrs));
worker_ptrs[0] = wire_worker;
wire_resource = (xce_resource_t*)calloc(1, sizeof(*wire_resource));
wire_resource->name = strdup("mem");
resource_ptrs = (xce_resource_t**)calloc(1, sizeof(*resource_ptrs));
resource_ptrs[0] = wire_resource;
{
xce_sched_update_request_t* rw = make_req(XCE_SCHED_OP_UPDATE_WORKER);
rw->u.workerupdate.nworkers = 1;
rw->u.workerupdate.workers = worker_ptrs;
enqueue(&core, rw);
xce_sched_update_request_t* rr
= make_req(XCE_SCHED_OP_UPDATE_RESOURCES);
rr->u.resourceupdate.nresources = 1;
rr->u.resourceupdate.resources = resource_ptrs;
enqueue(&core, rr);
/* xce_sched_driver_translate_pending_events() returns the number of
* events translated on success (test_driver_translate.c's own
* established convention -- e.g. its 10-op-type drain test asserts
* `TEST_ASSERT_EQUAL_INT(10, rc)`), not a bare 0/-errno status. Two
* requests (worker + resource) were enqueued above. */
rc = xce_sched_driver_translate_pending_events(&core, NULL);
TEST_ASSERT_EQUAL_INT(2, rc);
}
/* makeResource() (resobj.c) deep-copies into a FRESH xce_resource_t
* instance and assigns ITS OWN idx from the resmgr's key_add() return
* value -- wire_resource.idx (the source struct pushed above) is never
* mutated in place, so the authoritative idx must be read back from the
* resmgr, not assumed. */
rc = key_find(core.resmgr, "mem", (void**)&registered_res);
TEST_ASSERT_TRUE(rc > 0);
TEST_ASSERT_NOT_NULL(registered_res);
/* Drain #1: first periodic resval report -- adopts value 4.0. */
{
xce_sched_update_request_t* r1 = make_req(XCE_SCHED_OP_UPDATE_RESVALS);
r1->u.resvalupdate.resvals = make_resval_delta("hostA", "mem", 4.0);
enqueue(&core, r1);
rc = xce_sched_driver_translate_pending_events(&core, NULL);
TEST_ASSERT_EQUAL_INT(1, rc);
}
/* Drain #2: second periodic resval report for the SAME worker+resource
* -- this is exactly what a live deployment's resource_sync thread does
* every interval. Pre-fix, zarray_put()'s eviction of drain #1's value
* (already freed by drain #1's own release_translated_payload()) is a
* heap-use-after-free -- ASAN aborts the process right here. */
{
xce_sched_update_request_t* r2 = make_req(XCE_SCHED_OP_UPDATE_RESVALS);
r2->u.resvalupdate.resvals = make_resval_delta("hostA", "mem", 8.0);
enqueue(&core, r2);
rc = xce_sched_driver_translate_pending_events(&core, NULL);
TEST_ASSERT_EQUAL_INT(1, rc);
}
w = sched_get_worker(&core, "hostA");
TEST_ASSERT_NOT_NULL(w);
got = (xce_val_t*)zarray_get(&(w->resvals), registered_res->idx);
TEST_ASSERT_NOT_NULL(got);
TEST_ASSERT_EQUAL_INT(XCE_VAL_NUMERIC, got->which);
TEST_ASSERT_EQUAL_FLOAT(8.0f, (float)got->u.fvalue);
}
/* ---------------------------------------------------------------------- */
/* RESVALS_FULL: same hazard, core->sharedInst instead of a per-worker */
/* zarray (core_events.c's XCE_CORE_EVENT_RESVALS_FULL case, line ~750). */
/* ---------------------------------------------------------------------- */
static void test_resvals_full_second_drain_does_not_use_after_free(void)
{
xce_sched_core_t core;
xce_resource_t* wire_resource;
xce_resource_t** resource_ptrs;
xce_resource_t* registered_res = NULL;
xce_val_t* got;
int rc;
fixture_init(&core);
wire_resource = (xce_resource_t*)calloc(1, sizeof(*wire_resource));
wire_resource->name = strdup("slots");
resource_ptrs = (xce_resource_t**)calloc(1, sizeof(*resource_ptrs));
resource_ptrs[0] = wire_resource;
{
xce_sched_update_request_t* rr
= make_req(XCE_SCHED_OP_UPDATE_RESOURCES);
rr->u.resourceupdate.nresources = 1;
rr->u.resourceupdate.resources = resource_ptrs;
enqueue(&core, rr);
rc = xce_sched_driver_translate_pending_events(&core, NULL);
TEST_ASSERT_EQUAL_INT(1, rc);
}
/* See test_resvals_delta_second_drain_does_not_use_after_free()'s
* identical comment: makeResource() assigns idx on its own fresh
* instance, never on wire_resource itself. */
rc = key_find(core.resmgr, "slots", (void**)&registered_res);
TEST_ASSERT_TRUE(rc > 0);
TEST_ASSERT_NOT_NULL(registered_res);
/* Drain #1: first full resval snapshot -- adopts value 16.0. */
{
xce_val_t* val1 = (xce_val_t*)calloc(1, sizeof(*val1));
xce_resval_t* rv1 = (xce_resval_t*)calloc(1, sizeof(*rv1));
xce_resval_t** vals1 = (xce_resval_t**)calloc(1, sizeof(*vals1));
val1->which = XCE_VAL_NUMERIC;
val1->u.fvalue = 16.0;
rv1->name = strdup("slots");
rv1->val = val1;
vals1[0] = rv1;
xce_sched_update_request_t* r1
= make_req(XCE_SCHED_OP_UPDATE_RESVALS_FULL);
r1->u.resvalfullupdate.nvals = 1;
r1->u.resvalfullupdate.vals = vals1;
enqueue(&core, r1);
rc = xce_sched_driver_translate_pending_events(&core, NULL);
TEST_ASSERT_EQUAL_INT(1, rc);
}
/* Drain #2: second full resval snapshot for the SAME resource -- same
* live hazard as the RESVALS_DELTA test above. */
{
xce_val_t* val2 = (xce_val_t*)calloc(1, sizeof(*val2));
xce_resval_t* rv2 = (xce_resval_t*)calloc(1, sizeof(*rv2));
xce_resval_t** vals2 = (xce_resval_t**)calloc(1, sizeof(*vals2));
val2->which = XCE_VAL_NUMERIC;
val2->u.fvalue = 32.0;
rv2->name = strdup("slots");
rv2->val = val2;
vals2[0] = rv2;
xce_sched_update_request_t* r2
= make_req(XCE_SCHED_OP_UPDATE_RESVALS_FULL);
r2->u.resvalfullupdate.nvals = 1;
r2->u.resvalfullupdate.vals = vals2;
enqueue(&core, r2);
rc = xce_sched_driver_translate_pending_events(&core, NULL);
TEST_ASSERT_EQUAL_INT(1, rc);
}
got = (xce_val_t*)zarray_get(&(core.sharedInst), registered_res->idx);
TEST_ASSERT_NOT_NULL(got);
TEST_ASSERT_EQUAL_INT(XCE_VAL_NUMERIC, got->which);
TEST_ASSERT_EQUAL_FLOAT(32.0f, (float)got->u.fvalue);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_resvals_delta_second_drain_does_not_use_after_free);
RUN_TEST(test_resvals_full_second_drain_does_not_use_after_free);
return UNITY_END();
}

View file

@ -380,17 +380,23 @@ static char* run_sim_and_capture_stdout(const char* base_path,
static void test_baseline_run_uses_base_file_events(void)
{
char base_jobid_marker[32];
char alt_jobid_marker[32];
char base_jobid_marker[80];
char alt_jobid_marker[80];
char jobid_buf[64];
char path[PATH_MAX];
FILE* f;
int rc;
char* captured;
snprintf(base_jobid_marker, sizeof(base_jobid_marker), "jobid=%llu",
(unsigned long long)BASE_JOBID);
snprintf(alt_jobid_marker, sizeof(alt_jobid_marker), "jobid=%llu",
(unsigned long long)ALT_JOBID);
/* bug-1591: sim_print_decision() (sim_loop.c) now renders jobids via
* fmt_jobid(), not raw %llu -- these markers must match that same
* human-readable form, computed the same way, not hardcoded. */
fmt_jobid(jobid_buf, sizeof(jobid_buf), BASE_JOBID);
snprintf(base_jobid_marker, sizeof(base_jobid_marker), "jobid=%s",
jobid_buf);
fmt_jobid(jobid_buf, sizeof(jobid_buf), ALT_JOBID);
snprintf(alt_jobid_marker, sizeof(alt_jobid_marker), "jobid=%s",
jobid_buf);
f = write_header_and_snapshot(path, sizeof(path), "_baseline");
TEST_ASSERT_NOT_NULL(f);
@ -424,8 +430,9 @@ static void test_baseline_run_uses_base_file_events(void)
static void test_alt_events_run_uses_alt_file_events_not_base(void)
{
char base_jobid_marker[32];
char alt_jobid_marker[32];
char base_jobid_marker[80];
char alt_jobid_marker[80];
char jobid_buf[64];
char base_path[PATH_MAX];
char alt_path[PATH_MAX];
FILE* base_f;
@ -433,10 +440,14 @@ static void test_alt_events_run_uses_alt_file_events_not_base(void)
int rc;
char* captured;
snprintf(base_jobid_marker, sizeof(base_jobid_marker), "jobid=%llu",
(unsigned long long)BASE_JOBID);
snprintf(alt_jobid_marker, sizeof(alt_jobid_marker), "jobid=%llu",
(unsigned long long)ALT_JOBID);
/* bug-1591: see test_baseline_run_uses_base_file_events()'s identical
* fmt_jobid() comment. */
fmt_jobid(jobid_buf, sizeof(jobid_buf), BASE_JOBID);
snprintf(base_jobid_marker, sizeof(base_jobid_marker), "jobid=%s",
jobid_buf);
fmt_jobid(jobid_buf, sizeof(jobid_buf), ALT_JOBID);
snprintf(alt_jobid_marker, sizeof(alt_jobid_marker), "jobid=%s",
jobid_buf);
/* Base file: header + empty snapshot, then its OWN (never-to-be-read,
* once --sim-alt-events is set) events for BASE_JOBID -- present to

View file

@ -695,6 +695,8 @@ 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;
@ -714,9 +716,15 @@ test_job_present_only_in_initial_snapshot_still_reaches_the_picker(void)
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 jobid=4242"),
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)");

View file

@ -482,8 +482,16 @@ static void log_one_mismatch_for_capture(void)
static void test_log_mismatch_reports_logical_time_and_decision_detail(void)
{
char jobid_marker[80];
char jobid_buf[64];
char* buf = capture_stdout_around(log_one_mismatch_for_capture);
/* bug-1591: sim_verify_describe_{replay_,}decision() now render jobids
* via fmt_jobid(), not raw %llu -- this marker must match that same
* human-readable form. */
fmt_jobid(jobid_buf, sizeof(jobid_buf), 9001ULL);
snprintf(jobid_marker, sizeof(jobid_marker), "jobid=%s", jobid_buf);
TEST_ASSERT_NOT_NULL_MESSAGE(strstr(buf, "MISMATCH"),
"log_mismatch must report a MISMATCH");
TEST_ASSERT_NOT_NULL_MESSAGE(
@ -494,7 +502,7 @@ static void test_log_mismatch_reports_logical_time_and_decision_detail(void)
"log_mismatch must report the decision's position within the "
"session");
TEST_ASSERT_NOT_NULL_MESSAGE(
strstr(buf, "jobid=9001"),
strstr(buf, jobid_marker),
"log_mismatch must report actionable decision detail (jobid)");
free(buf);
@ -831,6 +839,8 @@ static void test_verify_against_self_reports_zero_mismatches(void)
static void test_verify_against_altered_copy_reports_exact_mismatch(void)
{
char path[PATH_MAX];
char jobid_marker[80];
char jobid_buf[64];
FILE* f;
int rc;
char* captured;
@ -846,13 +856,19 @@ static void test_verify_against_altered_copy_reports_exact_mismatch(void)
captured = run_sim_and_capture_stdout(path, true, &rc);
/* bug-1591: sim_verify_describe_{replay_,}decision() now render
* jobids via fmt_jobid(), not raw %llu -- this marker must match that
* same human-readable form. */
fmt_jobid(jobid_buf, sizeof(jobid_buf), 777ULL);
snprintf(jobid_marker, sizeof(jobid_marker), "jobid=%s", jobid_buf);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_NOT_NULL_MESSAGE(
strstr(captured, "MISMATCH"),
"verifying against a deliberately-altered recorded decision must "
"report a mismatch");
TEST_ASSERT_NOT_NULL_MESSAGE(
strstr(captured, "jobid=777"),
strstr(captured, jobid_marker),
"the mismatch report must name the specific job");
free(captured);