feat(scheduler-sim-decision-parity): reconstruct job allocation with safe ownership (task 2.3)

Extends sim_reconcile_unaccounted_jobs()'s FOUND branch: takes ownership of
a matched recorded DISPATCH decision's allocation (job->allocs = entry's
alloc, not a copy -- mirrors scheduler.c's sched_commit_decision()
precedent exactly), builds job->accounts via make_job_account(), then
safely consumes the recorded entry -- nulling the source alloc pointer
BEFORE freeing the entry (preventing a double-free through job->allocs),
then compacting decoded_session->decisions via shift-down (memmove, never
swap-with-last, which would reorder the remaining entries and break the
positional --sim-verify comparison a later task depends on).

Fixed a real correctness bug found while implementing: the "not found"
check (i == n_decisions) goes stale once n_decisions can be decremented
mid-loop by compaction -- replaced with an explicit found flag.

Primary correctness bar: reconcile-then-release-through-normal-job-cleanup
is ASAN-clean. Verified with ASAN_OPTIONS=detect_leaks=1 (beyond this
suite's registered detect_leaks=0): zero double-free/UAF/overflow. Found
and logged (not fixed, out of boundary) a real pre-existing leak in
src/lib/misc.c's free_host_account() -- equally present on the live
dispatch path, bug-1621.

46/47 scheduler tests pass; the 1 failure is pre-existing bug-1560
(unrelated).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-08-22 08:36:03 +00:00
parent c38207458f
commit eccba84112
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
3 changed files with 306 additions and 26 deletions

View file

@ -266,6 +266,42 @@ static replay_session_t* make_decoded_session(uint64_t begin_round,
return s;
}
/* task 2.3 fixture helper: a real, one-host/one-resource xce_job_alloc_t --
* same field shape sim_verify_job_alloc_eq() already validates (design.md's
* Allowed Dependencies), built via the real xcealloc.c production helpers
* (make_alloc/make_allocs/make_host_alloc/make_host_allocs/make_job_alloc),
* not a hand-rolled struct literal -- so a reconciled job's `job->accounts`
* shape can be checked against real, non-trivial resource values. */
static xce_job_alloc_t* make_test_job_alloc_with_host(uint64_t jobid,
const char* host,
const char* resname,
float value)
{
xce_alloc_t* a = make_alloc((char*)resname, NULL, 0, value, 0);
xce_alloc_t** allocs = make_allocs(1, a);
xce_host_alloc_t* h = make_host_alloc((char*)host, 1, allocs);
xce_host_alloc_t** hallocs = make_host_allocs(1, h);
return make_job_alloc(jobid, 1, hallocs);
}
/* task 2.3 fixture helper: wraps an already-built xce_job_alloc_t (as
* opposed to make_dispatch_entry() above, which builds its own empty
* (nhosts=0) alloc) into a DISPATCH-shaped replay_decision_t. */
static replay_decision_t* make_dispatch_entry_from_alloc(xce_job_alloc_t* alloc)
{
sched_dispatch_decision_t* dispatch
= (sched_dispatch_decision_t*)calloc(1, sizeof(*dispatch));
replay_decision_t* rd = (replay_decision_t*)calloc(1, sizeof(*rd));
dispatch->alloc = alloc;
rd->body_which = ReplayDecision_body_dispatch;
rd->body.dispatch = dispatch;
return rd;
}
/* ---------------------------------------------------------------------- */
/* Test 1 (the task's own primary Observable): a RUN-state job with no */
/* known allocation and no matching recorded decision in this round's */
@ -446,6 +482,175 @@ static void test_reconcile_skips_non_run_state_job(void)
printf("test_reconcile_skips_non_run_state_job: PASS\n");
}
/* ---------------------------------------------------------------------- */
/* Test 7 (task 2.3): a matching recorded DISPATCH entry is reconstructed */
/* into job->allocs (ownership TAKEOVER -- same pointer, not a copy) and */
/* job->accounts (built from it), and the consumed entry is removed from */
/* decoded_session (Requirement 1.1, 1.2, 3.2). */
/* ---------------------------------------------------------------------- */
static void
test_reconcile_reconstructs_allocs_and_accounts_from_matching_dispatch(void)
{
xce_sched_sim_driver_t sim;
xce_sched_job_t* job;
xce_job_alloc_t* jalloc;
replay_session_t* decoded;
fixture_init(&sim);
job = add_job(&(sim.core), 7010ULL, JOB_STAT_RUN, NULL);
TEST_ASSERT_NULL(job->accounts);
TEST_ASSERT_NULL(job->allocs);
jalloc = make_test_job_alloc_with_host(7010ULL, "worker1", "slots", 2.0f);
decoded
= make_decoded_session(1000ULL, make_dispatch_entry_from_alloc(jalloc));
sim_reconcile_unaccounted_jobs(&sim, decoded);
TEST_ASSERT_EQUAL_PTR_MESSAGE(
jalloc, job->allocs,
"job->allocs must take OWNERSHIP of the recorded entry's alloc "
"(identical pointer), not a duplicate copy -- same pattern "
"scheduler.c's sched_commit_decision() uses");
TEST_ASSERT_NOT_NULL_MESSAGE(
job->accounts,
"job->accounts must be built (make_job_account()) from the "
"reconstructed job->allocs");
TEST_ASSERT_EQUAL_INT(1, job->accounts->nhosts);
TEST_ASSERT_EQUAL_STRING("worker1", job->accounts->accounts[0]->host);
TEST_ASSERT_EQUAL_FLOAT(2.0f,
job->accounts->accounts[0]->accounts[0]->reserved);
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, decoded->n_decisions,
"the now-fully-consumed recorded entry must be removed from "
"decoded_session->decisions");
printf("test_reconcile_reconstructs_allocs_and_accounts_from_matching_"
"dispatch: PASS\n");
}
/* ---------------------------------------------------------------------- */
/* Test 8 (task 2.3): compaction is SHIFT-DOWN, not swap-with-last -- the */
/* remaining recorded entries must keep their ORIGINAL RELATIVE ORDER, per */
/* design.md's Implementation Notes (a later --sim-verify positional walk */
/* depends on this). A 4-entry fixture with the match in the middle */
/* distinguishes the two strategies: swap-with-last would relocate the */
/* last entry (C) into the removed slot, producing [A, C, B] instead of the */
/* correct [A, B, C]. */
/* ---------------------------------------------------------------------- */
static void test_reconcile_compacts_via_shift_down_preserving_order(void)
{
xce_sched_sim_driver_t sim;
xce_sched_job_t* job;
replay_session_t* decoded;
fixture_init(&sim);
job = add_job(&(sim.core), 7011ULL, JOB_STAT_RUN, NULL);
decoded = (replay_session_t*)calloc(1, sizeof(*decoded));
decoded->begin_round = 1000ULL;
decoded->summary = NULL;
decoded->decisions
= (replay_decision_t**)calloc(4, sizeof(*(decoded->decisions)));
decoded->decisions[0] = make_pend_entry(9001ULL); /* A */
decoded->decisions[1] = make_dispatch_entry(7011ULL); /* matched */
decoded->decisions[2] = make_pend_entry(9002ULL); /* B */
decoded->decisions[3] = make_pend_entry(9003ULL); /* C */
decoded->n_decisions = 4;
sim_reconcile_unaccounted_jobs(&sim, decoded);
TEST_ASSERT_NOT_NULL(job->accounts);
TEST_ASSERT_EQUAL_INT_MESSAGE(
3, decoded->n_decisions,
"exactly one entry (the matched DISPATCH) must be consumed");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(9001ULL,
decoded->decisions[0]->body.pend->jobid,
"entry A must stay first");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(
9002ULL, decoded->decisions[1]->body.pend->jobid,
"entry B must be second (shift-down) -- swap-with-last would put C "
"here instead");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(9003ULL,
decoded->decisions[2]->body.pend->jobid,
"entry C must be third, untouched");
printf("test_reconcile_compacts_via_shift_down_preserving_order: "
"PASS\n");
}
/* ---------------------------------------------------------------------- */
/* Test 9 (task 2.3, the task's own PRIMARY correctness bar): reconciling a */
/* job's allocation from a recorded DISPATCH entry, then releasing that */
/* job's allocation through its NORMAL end-of-life path -- */
/* XCE_CORE_EVENT_JOB_STATE_CHANGE (core_events.c) -- is clean: no */
/* double-free, no use-after-free. This directly exercises the */
/* move-then-null-then-free ownership handling design.md's Implementation */
/* Notes require: `entry->body.dispatch->alloc` must be nulled out BEFORE */
/* free_ReplayDecision(entry) frees the rest of the entry, or that free */
/* would free the exact memory job->allocs now owns, and this exact */
/* end-of-life release would then double-free it. Meaningful evidence for */
/* this only under the project's ASAN build (see meson.build/tasks.md), */
/* not as a plain functional assertion. */
/* ---------------------------------------------------------------------- */
static void test_reconcile_then_release_via_job_state_change_is_asan_clean(void)
{
xce_sched_sim_driver_t sim;
xce_sched_job_t* job;
replay_session_t* decoded;
xce_core_event_t ev;
int push_rc;
fixture_init(&sim);
job = add_job(&(sim.core), 7012ULL, JOB_STAT_RUN, NULL);
decoded = make_decoded_session(
1000ULL, make_dispatch_entry_from_alloc(make_test_job_alloc_with_host(
7012ULL, "worker1", "slots", 4.0f)));
sim_reconcile_unaccounted_jobs(&sim, decoded);
TEST_ASSERT_NOT_NULL(job->accounts);
TEST_ASSERT_NOT_NULL(job->allocs);
TEST_ASSERT_EQUAL_INT(0, decoded->n_decisions);
memset(&ev, 0, sizeof(ev));
ev.type = XCE_CORE_EVENT_JOB_STATE_CHANGE;
ev.u.job_state_change.jobid = 7012ULL;
ev.u.job_state_change.old_state = JOB_STAT_RUN;
ev.u.job_state_change.new_state = JOB_STAT_DONE;
push_rc = xce_core_push_events(&(sim.core), &ev, 1);
TEST_ASSERT_EQUAL_INT(0, push_rc);
TEST_ASSERT_NULL_MESSAGE(
job->accounts, "released cleanly via the normal end-of-life path");
TEST_ASSERT_NULL_MESSAGE(
job->allocs, "released cleanly via the normal end-of-life path");
/* Also exercise decoded_session's own round-end cleanup path
* (free_ReplaySession_ptr(), the same call sched_sim_driver_run() makes
* every round) now that n_decisions correctly reflects only the
* remaining, still-owned entries (here: none) -- ASAN-clean under a
* leak-detecting run (this suite's registered meson test uses
* detect_leaks=0 per its own established fixture-teardown convention;
* this call is exercised here regardless, and was additionally verified
* with ASAN_OPTIONS=detect_leaks=1 against the standalone binary as
* part of this task's own validation). */
free_ReplaySession_ptr(&decoded);
printf("test_reconcile_then_release_via_job_state_change_is_asan_clean: "
"PASS\n");
}
/* ---------------------------------------------------------------------- */
/* Test 6: NULL `sim`/`decoded_session` is a safe no-op (design.md's */
/* Service Interface preconditions -- "never load-bearing" convention). */
@ -478,6 +683,10 @@ int main(void)
RUN_TEST(test_reconcile_skips_job_that_already_has_accounts);
RUN_TEST(test_reconcile_ignores_non_dispatch_entry_with_matching_jobid);
RUN_TEST(test_reconcile_skips_non_run_state_job);
RUN_TEST(
test_reconcile_reconstructs_allocs_and_accounts_from_matching_dispatch);
RUN_TEST(test_reconcile_compacts_via_shift_down_preserving_order);
RUN_TEST(test_reconcile_then_release_via_job_state_change_is_asan_clean);
RUN_TEST(test_reconcile_null_args_are_a_safe_noop);
return UNITY_END();
}