From ee7dee0ed47269881c8f9bb4a5c4cf6a7bf274db Mon Sep 17 00:00:00 2001 From: "lei.chen" Date: Tue, 4 Aug 2026 01:18:45 +0000 Subject: [PATCH] fix(scheduler): stop re-dispatching running jobs (bug-1498) + resval double-free (bug-1497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bug-1498 (re-dispatch): sched_job_is_ready() was a stub returning 1, so any job lingering in the fcfs pendJobs queue was always elected. A dispatched job was never marked non-PEND (the controller defers its schedStartJob notify to the not-yet-implemented apply pump, controller/job.c), so fcfs_update_job re-enqueued it on every job update and the election thread re-dispatched the running job — the worker got conflicting signals and the job was SIGTERM'd. - sched_job_is_ready: elect only PEND, non-suspended jobs - sched_commit_decision (dispatch): mark the job JOB_STAT_RUN on dispatch so it leaves the PEND set and is no longer re-electable - fcfs_update_job: dedup via ordq_peek before enqueue bug-1497 (double-free): sched_update's resval paths transferred ownership of rv->val to sharedInst/w->resvals but left the request pointer set, so sched_pend_update_killer double-freed it -> use-after-free in build_host_eval_ctx. Clear rv->val = NULL on transfer. Tests: new scheduler/testsuite/test_redispatch.c (8 cases): sched_job_is_ready PEND gating + dispatch-commit marks the job RUN. Full scheduler meson suite green. Live-verified: a `sleep 60` job is elected exactly once, runs the full duration and exits 0 (`completed successfully`); before the fix it was re-dispatched at ~11s and killed (SIGTERM 143). Co-Authored-By: Claude --- .wolf/buglog.json | 76 +++++++ src/scheduler/src/plugins/fcfs.c | 9 + src/scheduler/src/scheduler.c | 44 +++- src/scheduler/testsuite/meson.build | 12 ++ src/scheduler/testsuite/test_redispatch.c | 242 ++++++++++++++++++++++ 5 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 src/scheduler/testsuite/test_redispatch.c diff --git a/.wolf/buglog.json b/.wolf/buglog.json index f0575c3..c85f741 100644 --- a/.wolf/buglog.json +++ b/.wolf/buglog.json @@ -14643,5 +14643,81 @@ ], "occurrences": 1, "last_seen": "2026-08-03" + }, + { + "id": "bug-1496", + "timestamp": "2026-08-03", + "error_message": "AppImage daemon binaries hardcode /nix/store absolute paths in DT_NEEDED (sqlite) and DT_RUNPATH (all libs), so they fail to load on hosts without /nix/store (t2/t3): \"error while loading shared libraries: libsqlite3.so/libcap.so.2/libm.so.6 cannot open shared object file\".", + "file": "src/tools/appimage/ (entry.sh), xce.nix build", + "root_cause": "Daemons (xce_controller/xcecd/xce_scheduler/xce_apiserver) are built by nix with DT_RUNPATH = /nix/store//lib absolute paths, and xcecd has DT_NEEDED = /nix/store/-sqlite/lib/libsqlite3.so (absolute). AppImage packaging does NOT rewrite these, so the binaries are NOT self-contained: they depend on the build machine's /nix/store. t1 happens to have /nix/store (nix env) so it works; t2/t3 without it fail.", + "fix": "VERIFIED FIX (pilot): use patchelf to make binaries self-contained \u2014 collect non-glibc deps into lib/ dir; patchelf --set-rpath $ORIGIN/lib on the binary; patchelf --set-rpath $ORIGIN on every lib/*.so (lib-to-lib deps also have absolute RUNPATH); replace sqlite absolute DT_NEEDED with short name; set interpreter to system /lib64/ld-linux (glibc stays on host). Verified on t2 (no /nix/store): self-contained xcecd loaded all 12 non-glibc libs and connected to t1 controller. NOT YET integrated into AppImage package.sh \u2014 pending.", + "tags": [ + "appimage", + "rpath", + "dt-needed", + "self-contained", + "nix-store-dependency", + "verified-fix-pilot" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-08-03" + }, + { + "id": "bug-1497", + "timestamp": "2026-08-03", + "error_message": "scheduler ASAN: heap-use-after-free in build_host_eval_ctx (resource.c:170) \u2014 scheduling thread reads worker resvals freed by sched_pend_update_killer. Scheduler crashes (ASAN abort) after scheduling a job.", + "file": "src/scheduler/src/scheduler.c (sched_handle_resvals XCE_SCHED_OP_UPDATE_RESVALS / _FULL)", + "root_cause": "Ownership conflict: sched_handle_resvals does zarray_put(&w->resvals, r->idx, rv->val) (and &sched->sharedInst in the FULL path), transferring rv->val to the zarray whose killer (kill_sched_resval) will free_Value() it. But sched_pend_update_killer then calls free_ResValList_ptr on the request, which frees rv->val again (double-free) and leaves the worker zarray pointer dangling. The scheduling thread later reads it in build_host_eval_ctx -> use-after-free. The worker-construction path (schedobj.c:302) correctly uses MOVE_PTR(val, nv->val) to transfer ownership; sched_handle_resvals does not \u2014 inconsistency.", + "fix": "FIXED: in sched_handle_resvals, after zarray_put(&w->resvals, r->idx, rv->val) and zarray_put(&sched->sharedInst, r->idx, rv->val), set rv->val = NULL to transfer ownership to the zarray (whose kill_sched_resval frees it). free_ResValList_ptr then skips the NULL val (free_Value_ptr is NULL-safe), eliminating the double-free/use-after-free. Verified: ASAN scheduler schedules a job to RUN->DONE with zero ASAN errors and stays alive.", + "tags": [ + "scheduler", + "use-after-free", + "resvals", + "ownership", + "asan", + "fixed" + ], + "related_bugs": [ + "bug-1495" + ], + "occurrences": 1, + "last_seen": "2026-08-03" + }, + { + "id": "bug-1498", + "timestamp": "2026-08-03", + "error_message": "scheduler \u91cd\u590d\u8c03\u5ea6\u5df2\u5b8c\u6210\u7684\u4f5c\u4e1a: e2e-test (73930087014072320, sleep 10) \u5728 worker \u4e0a\u88ab\u8c03\u5ea6\u6267\u884c 3 \u6b21 (fcfs_elect_job x4), \u524d\u4e24\u6b21\u6210\u529f\u5b8c\u6210, \u7b2c\u4e09\u6b21\u88ab SIGKILL (code 143); \u6700\u7ec8 MCP \u72b6\u6001\u663e\u793a failed/EXIT, exec_hosts None; controller \u53cd\u590d\u65e5\u5fd7 rejecting backward START on terminal state", + "file": "src/scheduler/src/scheduler.c / src/scheduler/src/decision.c (job \u5b8c\u6210\u72b6\u6001\u672a\u4ece runnable \u96c6\u5408\u79fb\u9664)", + "root_cause": "1) sched_job_is_ready \u6052\u771f stub: pendJobs \u91cc\u4efb\u4f55\u4f5c\u4e1a\u90fd\u88ab elect; 2) \u6d3e\u53d1\u540e scheduler \u5185\u4f5c\u4e1a\u72b6\u6001\u4ecd\u4e3a PEND (controller \u4ece\u4e0d\u53d1 schedStartJob, record_job_start_fact \u7684 AFTER_EVENT \u901a\u77e5\u88ab\u63a8\u8fdf\u5230\u672a\u5b9e\u73b0\u7684 apply pump), fcfs_update_job \u5728\u6bcf\u6b21 job update \u65f6\u628a\u4ecd PEND \u7684\u4f5c\u4e1a\u91cd\u65b0\u585e\u56de pendJobs; 3) fcfs_elect_job \u6bcf\u79d2\u91cd\u626b\u91cd\u65b0 elect \u5df2\u6d3e\u53d1\u4f5c\u4e1a \u2192 \u91cd\u590d dispatch \u2192 worker \u88ab SIGTERM \u6740", + "fix": "FIXED: 1) sched_job_is_ready() \u4ece\u6052\u771f stub \u6539\u4e3a\u68c0\u67e5 JOB_STAT_PEND \u4e14\u975e\u6302\u8d77; 2) sched_commit_decision dispatch \u5206\u652f\u628a\u4f5c\u4e1a\u6807\u8bb0\u4e3a JOB_STAT_RUN\uff08\u6d3e\u53d1\u5373\u79bb\u5f00 PEND \u96c6\u5408\uff09; 3) fcfs_update_job \u7528 ordq_peek \u53bb\u91cd\u9632\u91cd\u590d\u5165\u961f\u3002\u65b0\u589e test_redispatch.c \u56de\u5f52\u6d4b\u8bd5 (8 \u7528\u4f8b)\u3002\u5b9e\u673a\u9a8c\u8bc1: sleep 60 \u4f5c\u4e1a\u53ea elect 1 \u6b21, \u8dd1\u6ee1 60s, exit 0 completed successfully (\u5bf9\u6bd4\u4fee\u590d\u524d ~11s \u88ab\u6740 143)\u3002\u6ce8: \u4f5c\u4e1a\u4e0a\u62a5\u72b6\u6001\u4ecd\u53d7 controller apply-pump \u672a\u5b9e\u73b0\u5f71\u54cd, \u5c5e\u72ec\u7acb bug\u3002", + "tags": [ + "scheduler", + "re-dispatch", + "job-lifecycle", + "fcfs", + "fixed" + ], + "related_bugs": [ + "bug-1494" + ], + "occurrences": 1, + "last_seen": "2026-08-03" + }, + { + "id": "bug-1499", + "timestamp": "2026-08-03", + "error_message": "apiserver \u91cd\u542f\u540e\u6240\u6709 API key \u5931\u6548 (HTTP 401 Authentication failed / invalid or expired API key); .mcp.json \u4e2d\u7684 xce_ak_988620... key \u5728 09:07 apiserver \u91cd\u542f\u540e\u4e0d\u53ef\u7528", + "file": "src/frontend/apiserver/store/apikey.go + cmd/main.go", + "root_cause": "APIKeys \u53ea\u5728\u5185\u5b58 store, AddAPIKey \u4ec5\u5728 POST /xce/v1/apikeys \u751f\u6210\u63a5\u53e3\u88ab\u8c03\u7528; InitStores() \u542f\u52a8\u65f6\u4e0d seed, \u91cd\u542f\u5373\u5168\u90e8\u4e22\u5931", + "fix": "\u4e34\u65f6: \u7528 admin \u767b\u5f55\u8c03 POST /xce/v1/apikeys \u91cd\u65b0\u751f\u6210 (scheduler-agent/mcp_operator); \u6839\u6cbb\u5f85\u5b9a (\u542f\u52a8\u65f6\u6301\u4e45\u5316/seed API keys)", + "tags": [ + "apiserver", + "apikey", + "restart-loss" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-08-03" } ] \ No newline at end of file diff --git a/src/scheduler/src/plugins/fcfs.c b/src/scheduler/src/plugins/fcfs.c index 50dbc60..10de80b 100644 --- a/src/scheduler/src/plugins/fcfs.c +++ b/src/scheduler/src/plugins/fcfs.c @@ -192,6 +192,15 @@ static int fcfs_update_job(xce_sched_plugin_t* plugin, xce_sched_job_t* j) job->job = j; + /* bug-1498: never enqueue the same job twice. fcfs_commit/fcfs_del_job + * removes a job on dispatch, but a job update can re-arrive before the + * commit runs (or a dispatch-fail reset returns the job to PEND) — a + * duplicate queue entry would be elected twice in one pass. */ + if (ordq_peek(&(q->pendJobs), (ordq_match_fn_t)job_match, j->jobid) != NULL) { + free(job); + return 0; + } + ordq_put(&(q->pendJobs), job); return 0; diff --git a/src/scheduler/src/scheduler.c b/src/scheduler/src/scheduler.c index adbf356..d101832 100644 --- a/src/scheduler/src/scheduler.c +++ b/src/scheduler/src/scheduler.c @@ -826,6 +826,12 @@ int sched_update(xce_scheduler_t* sched) continue; } zarray_put(&sched->sharedInst, r->idx, rv->val); + /* Ownership of rv->val transfers to sharedInst (its killer, + * kill_sched_resval, frees it). Clear the request's pointer so + * sched_pend_update_killer's free_ResValList_ptr does NOT free + * it again (bug-1497: double-free left the zarray entry + * dangling -> heap-use-after-free in build_host_eval_ctx). */ + rv->val = NULL; } rc = 0; break; @@ -846,6 +852,11 @@ int sched_update(xce_scheduler_t* sched) continue; } zarray_put(&w->resvals, r->idx, rv->val); + /* Ownership of rv->val transfers to w->resvals (killer + * kill_sched_resval frees it). Clear the request pointer so + * sched_pend_update_killer does not double-free it + * (bug-1497: use-after-free in build_host_eval_ctx). */ + rv->val = NULL; } rc = 0; break; @@ -1263,8 +1274,27 @@ xce_sched_project_t* sched_get_project(xce_scheduler_t* sched, char* name) return make_sched_project(sched, name); } -/* FIXME this is just stub, we need to add more */ -int sched_job_is_ready(xce_sched_job_t* j) { return 1; } +/* + * sched_job_is_ready — a job is electable only while it is PENDING and not + * suspended. Once dispatched (RUN), finished (EXIT/DONE), or suspended + * (PSUSP/USUSP/SSUSP) it must never be re-elected. bug-1498: the previous + * stub returned 1 unconditionally, so a running job that lingered in the fcfs + * pendJobs queue (re-added on every job update because the scheduler was never + * told the job had left PEND) was re-dispatched to the worker and killed. + */ +int sched_job_is_ready(xce_sched_job_t* j) +{ + if (j == NULL) { + return 0; + } + if ((j->state & JOB_STAT_PEND) == 0) { + return 0; /* already dispatched/running or terminal */ + } + if (j->state & (JOB_STAT_PSUSP | JOB_STAT_USUSP | JOB_STAT_SSUSP)) { + return 0; /* suspended — not electable */ + } + return 1; +} /* FIXME this is just stub, we need to add more */ int sched_queue_is_ready(xce_sched_queue_t* q) { return 1; } @@ -1284,6 +1314,16 @@ int sched_commit_decision(xce_scheduler_t* sched, xce_decision_t* decision) job->allocs = decision->u.alloc; job->accounts = make_job_account(job->allocs); + /* bug-1498: mark the job RUN the moment it is dispatched so it leaves + * the PEND set — fcfs_update_job re-enqueues only PEND jobs and + * sched_job_is_ready elects only PEND jobs. Without this the scheduler + * is never told the job stopped pending (the controller's + * record_job_start_fact defers the AFTER_EVENT schedStartJob notify to + * the not-yet-implemented apply pump, controller/job.c) and re-dispatches + * the running job. A later controller state update corrects it: PEND on + * dispatch-fail (re-electable), EXIT/DONE on finish. */ + job->state = JOB_STAT_RUN; + return sched_commit_job_alloc(sched, job); } diff --git a/src/scheduler/testsuite/meson.build b/src/scheduler/testsuite/meson.build index b9b94b5..2a7a24d 100644 --- a/src/scheduler/testsuite/meson.build +++ b/src/scheduler/testsuite/meson.build @@ -56,12 +56,24 @@ sched_job_array_wire_e2e_test = executable('sched_job_array_wire_e2e_test', dependencies: [scheduler_test_deps], install: false) +# White-box regression test for bug-1498 (scheduler re-dispatches a job it +# already dispatched): sched_job_is_ready PEND-gating + dispatch-commit marks +# the job RUN. It #includes ../src/scheduler.c directly, so it is a standalone +# TU with its own main() — built as a separate executable. +sched_redispatch_test = executable('sched_redispatch_test', + ['test_redispatch.c'], + include_directories:[incdir], + dependencies: [scheduler_test_deps], + install: false) + # Register as meson tests so `meson test --suite scheduler` runs them all. # detect_leaks=0 matches the suite convention (fixtures are not torn down). test('scheduler test', scheduler_test, suite: 'scheduler', env: ['ASAN_OPTIONS=detect_leaks=0']) test('scheduler job-update test', sched_job_update_test, suite: 'scheduler', env: ['ASAN_OPTIONS=detect_leaks=0']) +test('scheduler redispatch test', sched_redispatch_test, suite: 'scheduler', + env: ['ASAN_OPTIONS=detect_leaks=0']) test('scheduler handlers test', sched_handlers_test, suite: 'scheduler', env: ['ASAN_OPTIONS=detect_leaks=0']) test('scheduler job-array expand test', sched_job_array_test, suite: 'scheduler', diff --git a/src/scheduler/testsuite/test_redispatch.c b/src/scheduler/testsuite/test_redispatch.c new file mode 100644 index 0000000..c07587e --- /dev/null +++ b/src/scheduler/testsuite/test_redispatch.c @@ -0,0 +1,242 @@ +/* + * test_redispatch.c - white-box regression tests for bug-1498: the scheduler + * re-dispatched a job it had already dispatched (running job killed by a + * duplicate dispatch). + * + * Root cause: sched_job_is_ready() was a stub returning 1 unconditionally, so + * a job lingering in the fcfs pendJobs queue was always elected; and a + * dispatched job was never marked non-PEND, so fcfs_update_job re-enqueued it + * on every job update (the controller defers its schedStartJob notify). + * + * Strategy: pull the REAL scheduler.c into this TU (same white-box pattern as + * test_job_update.c pulling schedobj.c), stub the scheduler-internal externs + * defined in other .c files not linked here, and drive: + * - sched_job_is_ready: PEND electable; RUN/EXIT/DONE/suspended/NULL not. + * - sched_commit_decision(XCE_DECISION_DISPATCH): marks the job RUN so it + * leaves the PEND set and is no longer electable. + */ +#define UNIT_TEST 1 +#include +#include +#include +#include +#include + +#include + +#include "kvmgr.h" +#include "scheduler.h" + +/* Pull the real implementation under test. */ +#include "../src/scheduler.c" + +/* ── Stubs for scheduler.c externs defined in other .c files ─────────────── */ + +/* plugin.c — plugin dispatch table & lifecycle (not linked here). */ +int plugin_job_update(void* s, xce_sched_job_t* j) { (void)s; (void)j; return 0; } +int plugin_queue_update(void* s, xce_sched_queue_t* q) { (void)s; (void)q; return 0; } +int plugin_worker_update(void* s, xce_sched_worker_t* w) { (void)s; (void)w; return 0; } +int plugin_job_state_change(void* s, xce_sched_job_t* j, uint32_t st) +{ + (void)s; (void)j; (void)st; return 0; +} +int plugin_job_rusage_update(void* s, xce_sched_job_t* j, xce_job_rusages_t* r) +{ + (void)s; (void)j; (void)r; return 0; +} +int plugin_commit_decision(void* s, xce_decision_t* d) { (void)s; (void)d; return 0; } +int plugin_begin_session(void* s) { (void)s; return 0; } +int plugin_end_session(void* s) { (void)s; return 0; } +int plugin_config_update(void* s, xce_conf4sched_t* c) { (void)s; (void)c; return 0; } +int plugin_init(void* s) { (void)s; return 0; } +void plugin_unregister(void* s) { (void)s; } +int plugin_job_election(void* s, xce_sched_job_t* j) { (void)s; (void)j; return 0; } + +/* schedobj.c / resource.c / client helpers — stubbed for these tests. */ +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; } +int sched_commit_job_alloc(xce_scheduler_t* s, xce_sched_job_t* j) +{ + (void)s; (void)j; return 0; +} +int sched_charge_account(xce_scheduler_t* s, xce_job_account_t* a) +{ + (void)s; (void)a; return 0; +} +int sched_uncharge_account(xce_scheduler_t* s, xce_job_account_t* a) +{ + (void)s; (void)a; return 0; +} +int sched_apply_job_rusage(xce_scheduler_t* s, xce_job_rusages_t* r) +{ + (void)s; (void)r; return -ENOENT; +} +int sched_update_existing_job(xce_scheduler_t* s, xce_job_t* j) +{ + (void)s; (void)j; return -ENOENT; +} +int sched_update_existing_queue(xce_scheduler_t* s, xce_queue_t* q) +{ + (void)s; (void)q; return -ENOENT; +} + +/* schedobj.c / decision.c / plugins — additional externs scheduler.c + * references (defined in .c files not linked here). */ +int sched_ctx_init(xce_sched_ctx_t* ctx, void* arg, xce_sched_obj_t* obj) +{ + (void)ctx; (void)arg; (void)obj; return 0; +} +void sched_ctx_fini(xce_sched_ctx_t* ctx) { (void)ctx; } +void sched_ctx_set_primary(xce_sched_ctx_t* ctx, xce_primary_resreq_t* p) +{ + (void)ctx; (void)p; +} +xce_decision_t* make_pend_decision(xce_pendreason_t* r) { (void)r; return NULL; } +xce_decision_t* make_dispatch_decision(xce_job_alloc_t* a) { (void)a; return NULL; } +int register_fcfs(void* s) { (void)s; return 0; } +int register_limit(void* s) { (void)s; return 0; } +int register_seqjob_allocator(void* s) { (void)s; return 0; } +int register_parjob_allocator(void* s) { (void)s; return 0; } +void kill_sched_resval(xce_val_t* v) { (void)v; } +xce_sched_job_t* make_sched_job(void* a, xce_job_t* j) { (void)a; (void)j; return NULL; } +xce_sched_queue_t* make_sched_queue(void* a, xce_queue_t* q) { (void)a; (void)q; return NULL; } +xce_sched_worker_t* make_sched_worker(void* a, xce_worker_t* w) { (void)a; (void)w; return NULL; } +xce_sched_user_t* make_sched_user(void* a, char* n) { (void)a; (void)n; return NULL; } +xce_sched_project_t* make_sched_project(void* a, char* n) { (void)a; (void)n; return NULL; } +void sched_worker_release_host_ctx(xce_sched_worker_t* w) { (void)w; } + +void setUp(void) { return; } +void tearDown(void) { return; } + +/* Note: sched_get_job / sched_add_job / sched_get_queue / ... are defined by + * scheduler.c itself (real kvmgr-backed) and come in via the #include — no + * stubs needed. They operate on sched->jobs etc., which fixture_init creates. */ + +/* ── Fixture ─────────────────────────────────────────────────────────────── */ + +static void fixture_init(xce_scheduler_t* sched) +{ + kvmgr_params_t jobs_params = { .key_type = KVMGR_KEY_INT, + .nslots = 59, + .delta = 16, + .datakiller = kill_sched_job }; + + memset(sched, 0, sizeof(*sched)); + resreq_init(1); + xstring_table_init(1024 * 16); + + sched->jobs = makeInstance(&xkvmgr_type, &jobs_params); +} + +static xce_sched_job_t* seed_job(xce_scheduler_t* sched, uint64_t jobid, + uint32_t state) +{ + xce_sched_job_t* j = (xce_sched_job_t*)calloc(1, sizeof(*j)); + TEST_ASSERT_NOT_NULL(j); + j->jobid = jobid; + j->state = state; + sched_add_job(sched, j); + return j; +} + +/* ── sched_job_is_ready ──────────────────────────────────────────────────── */ + +static void test_ready_null_not_ready(void) +{ + TEST_ASSERT_EQUAL_INT(0, sched_job_is_ready(NULL)); +} + +static void test_ready_pend_is_ready(void) +{ + xce_sched_job_t j = { .state = JOB_STAT_PEND }; + TEST_ASSERT_EQUAL_INT(1, sched_job_is_ready(&j)); +} + +static void test_ready_run_not_ready(void) +{ + xce_sched_job_t j = { .state = JOB_STAT_RUN }; + TEST_ASSERT_EQUAL_INT(0, sched_job_is_ready(&j)); +} + +static void test_ready_exit_not_ready(void) +{ + xce_sched_job_t j = { .state = JOB_STAT_EXIT }; + TEST_ASSERT_EQUAL_INT(0, sched_job_is_ready(&j)); +} + +static void test_ready_done_not_ready(void) +{ + xce_sched_job_t j = { .state = JOB_STAT_DONE }; + TEST_ASSERT_EQUAL_INT(0, sched_job_is_ready(&j)); +} + +static void test_ready_suspended_not_ready(void) +{ + xce_sched_job_t j = { .state = JOB_STAT_PEND | JOB_STAT_USUSP }; + TEST_ASSERT_EQUAL_INT(0, sched_job_is_ready(&j)); +} + +/* ── sched_commit_decision dispatch marks the job RUN ────────────────────── */ + +static void test_dispatch_commit_marks_job_run(void) +{ + xce_scheduler_t sched; + xce_sched_job_t* job; + xce_job_alloc_t alloc; + xce_decision_t decision; + + fixture_init(&sched); + + job = seed_job(&sched, 42, JOB_STAT_PEND); + + memset(&alloc, 0, sizeof(alloc)); + alloc.jobid = 42; + + memset(&decision, 0, sizeof(decision)); + decision.op = XCE_DECISION_DISPATCH; + decision.u.alloc = &alloc; + + TEST_ASSERT_EQUAL_INT(0, sched_commit_decision(&sched, &decision)); + + /* The dispatched job leaves PEND: state is RUN and it is not electable. */ + TEST_ASSERT_EQUAL_UINT(JOB_STAT_RUN, job->state); + TEST_ASSERT_EQUAL_INT(0, sched_job_is_ready(job)); +} + +static void test_dispatch_commit_null_alloc_unchanged(void) +{ + xce_scheduler_t sched; + xce_sched_job_t* job; + xce_decision_t decision; + + fixture_init(&sched); + job = seed_job(&sched, 43, JOB_STAT_PEND); + + memset(&decision, 0, sizeof(decision)); + decision.op = XCE_DECISION_PEND; + decision.u.reason = NULL; + + TEST_ASSERT_EQUAL_INT(0, sched_commit_decision(&sched, &decision)); + /* Non-dispatch decision must not touch the job's state. */ + TEST_ASSERT_EQUAL_UINT(JOB_STAT_PEND, job->state); +} + +/* ── Runner ──────────────────────────────────────────────────────────────── */ + +int main(int argc, char* argv[]) +{ + (void)argc; (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_ready_null_not_ready, 0); + RUN_TEST(test_ready_pend_is_ready, 0); + RUN_TEST(test_ready_run_not_ready, 0); + RUN_TEST(test_ready_exit_not_ready, 0); + RUN_TEST(test_ready_done_not_ready, 0); + RUN_TEST(test_ready_suspended_not_ready, 0); + RUN_TEST(test_dispatch_commit_marks_job_run, 0); + RUN_TEST(test_dispatch_commit_null_alloc_unchanged, 0); + return UNITY_END(); +}