test(scheduler-core): prove snapshot -EBUSY guard against a genuine active session (task 8.2)
Task 8.1's own -EBUSY guard test only proved rejection against a synthetic single-thread session_active flag flip, with a comment misattributing the technique to test_core_full_cycle.c's actually- genuine-session precedent. Real gap, not busywork: xce_core_go() publishes session_active via memory_order_release while the snapshot guards read it via plain atomic_load() -- the synthetic same-thread test never exercised any real cross-thread visibility of that flag. Adds a genuine driver-driven session test (real picker thread + real xce_core_go()) proving both save and load reject with -EBUSY during the same active session, leaving state genuinely unchanged (sentinel bytes for save; a distinguishable non-existent queue + unchanged candidate count for load). Zero production changes -- task 8.1's guards were already correct. Closes out Group 8 (Core: Snapshot Serialization) entirely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dw5WnN3DXStaEaPYfACU1
This commit is contained in:
parent
1a32e63a86
commit
31ef5d7093
2 changed files with 144 additions and 1 deletions
|
|
@ -207,11 +207,12 @@
|
||||||
- _Depends: 7.3_
|
- _Depends: 7.3_
|
||||||
- _Verified: 2026-08-04 — xce_sched_core_save_snapshot()/xce_sched_core_load_snapshot() implemented, split across snapshot.c/snapshot_wire.c/snapshot_load.c (800-line cap) along real logical boundaries (save/load/shared-helpers). New prng_seed field added to xce_sched_core_t (a genuine, well-investigated gap requirements.md/design.md both presupposed but no prior task added), seeded to 0, no PRNG consumer wired yet (documented, deliberate). Cross-core behavioral-equivalence Observable proven for real: two independent cores, each with its own registered picker, save on one/load into a freshly-initialized other, both driven through an identical real session (picker thread + decision drain), decision sequences asserted identical including a pinned spot-check (201-PEND/202-DISPATCH/203-PEND on both). -EBUSY/-EINVAL guards checked before any mutation, tested directly. Found and fixed two real bugs along the way: a missing prng_seed restore in this task's own new code, and a genuine, independently-traced-to-the-primitive-level guard bug in already-committed core.c (sched_get_user/sched_get_project treating key_find()'s expected -ENOENT as fatal, silently breaking get-or-create for any new name) that also affected the live production path (make_sched_job(), JOBS_PUSH) — narrowly scoped two-line fix, folded into this task's diff since only this task's own test path discovered it. 23/23 scheduler suite, full project build clean, review APPROVED first pass with every claim independently re-derived._
|
- _Verified: 2026-08-04 — xce_sched_core_save_snapshot()/xce_sched_core_load_snapshot() implemented, split across snapshot.c/snapshot_wire.c/snapshot_load.c (800-line cap) along real logical boundaries (save/load/shared-helpers). New prng_seed field added to xce_sched_core_t (a genuine, well-investigated gap requirements.md/design.md both presupposed but no prior task added), seeded to 0, no PRNG consumer wired yet (documented, deliberate). Cross-core behavioral-equivalence Observable proven for real: two independent cores, each with its own registered picker, save on one/load into a freshly-initialized other, both driven through an identical real session (picker thread + decision drain), decision sequences asserted identical including a pinned spot-check (201-PEND/202-DISPATCH/203-PEND on both). -EBUSY/-EINVAL guards checked before any mutation, tested directly. Found and fixed two real bugs along the way: a missing prng_seed restore in this task's own new code, and a genuine, independently-traced-to-the-primitive-level guard bug in already-committed core.c (sched_get_user/sched_get_project treating key_find()'s expected -ENOENT as fatal, silently breaking get-or-create for any new name) that also affected the live production path (make_sched_job(), JOBS_PUSH) — narrowly scoped two-line fix, folded into this task's diff since only this task's own test path discovered it. 23/23 scheduler suite, full project build clean, review APPROVED first pass with every claim independently re-derived._
|
||||||
|
|
||||||
- [ ] 8.2 Enforce the snapshot session-idle precondition
|
- [x] 8.2 Enforce the snapshot session-idle precondition
|
||||||
- Reject snapshot save/load calls made while a scheduling session is active, returning a distinct busy error rather than capturing or restoring torn state
|
- Reject snapshot save/load calls made while a scheduling session is active, returning a distinct busy error rather than capturing or restoring torn state
|
||||||
- Observable: attempting a snapshot save/load mid-session returns the busy error and leaves core state unchanged
|
- Observable: attempting a snapshot save/load mid-session returns the busy error and leaves core state unchanged
|
||||||
- _Requirements: 5.21_
|
- _Requirements: 5.21_
|
||||||
- _Depends: 8.1_
|
- _Depends: 8.1_
|
||||||
|
- _Verified: 2026-08-04 — task 8.1's own -EBUSY guard test only proved rejection against a synthetic single-thread session_active flag flip, and its comment misattributed the technique to test_core_full_cycle.c's actually-genuine-session precedent. Real gap, not busywork: xce_core_go() publishes session_active via memory_order_release while the snapshot guards read it via plain atomic_load() — the synthetic same-thread test never exercised any real cross-thread visibility of that flag. Added a genuine driver-driven session test (real picker thread + real xce_core_go()) proving both save and load reject with -EBUSY during the same active session, leaving state genuinely unchanged (sentinel bytes for save; distinguishable non-existent queue + unchanged candidate count for load) -- zero production changes, task 8.1's guards were already correct. 23/23 scheduler suite, 10/10 standalone flakiness-clean, review APPROVED first pass with an even sharper independent justification than the implementer's own. Closes out Group 8 (Core: Snapshot Serialization) entirely -- tasks 8.1-8.2 both complete._
|
||||||
|
|
||||||
- [ ] 9. Core: Recording Control
|
- [ ] 9. Core: Recording Control
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -515,6 +515,146 @@ test_snapshot_guards_reject_null_args_and_busy_session_without_mutating_state(
|
||||||
atomic_store(&(core.session_active), 0);
|
atomic_store(&(core.session_active), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* Guard tests against a GENUINE active session (task 8.2, Req 5 AC 21) -- */
|
||||||
|
/* the guard test above proves the check fires against a hand-set atomic; */
|
||||||
|
/* this one drives a real session the same way */
|
||||||
|
/* test_full_cycle_push_go_drain_end_with_precondition_rejections() */
|
||||||
|
/* (test_core_full_cycle.c) proves xce_core_go()/xce_core_push_events()'s */
|
||||||
|
/* own -EBUSY/-EINVAL guards reject reentrant calls while GENUINELY */
|
||||||
|
/* active -- a real picker pthread plus a real xce_core_go() call, not a */
|
||||||
|
/* synthetic session_active flip. */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
static void
|
||||||
|
test_snapshot_save_and_load_reject_during_genuinely_active_session(void)
|
||||||
|
{
|
||||||
|
xce_sched_core_t core;
|
||||||
|
pthread_t picker_tid;
|
||||||
|
xce_core_event_t events[2];
|
||||||
|
xce_queue_t wire_queue;
|
||||||
|
xce_job_t* job;
|
||||||
|
sched_snapshot_data_t snap;
|
||||||
|
sched_snapshot_data_t reject_in;
|
||||||
|
xce_queue_t reject_wire_queue;
|
||||||
|
xce_queue_t* reject_queues[1];
|
||||||
|
xce_sched_queue_t* q_after;
|
||||||
|
xce_pendreason_t* reason;
|
||||||
|
xce_decision_t* decision;
|
||||||
|
test_driver_ctx_t driver_ctx;
|
||||||
|
int ncandidates_before;
|
||||||
|
int rc;
|
||||||
|
uint64_t jobid;
|
||||||
|
|
||||||
|
memset(&driver_ctx, 0, sizeof(driver_ctx));
|
||||||
|
memset(&g_stub_state_a, 0, sizeof(g_stub_state_a));
|
||||||
|
fixture_init(&core, &g_stub_plugin_a);
|
||||||
|
|
||||||
|
rc = xce_core_set_decision_cb(&core, test_on_decision, &driver_ctx);
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
* Phase 1: real state while the session is inactive -- one open queue,
|
||||||
|
* one pending job (same shape as test_core_full_cycle.c's own Phase 1).
|
||||||
|
* =================================================================== */
|
||||||
|
memset(&wire_queue, 0, sizeof(wire_queue));
|
||||||
|
wire_queue.queue = "normal";
|
||||||
|
wire_queue.priority = 1;
|
||||||
|
wire_queue.status = QUEUE_STAT_OPEN;
|
||||||
|
|
||||||
|
job = make_job(301, JOB_STAT_PEND, "normal", "alice", "proj1");
|
||||||
|
|
||||||
|
memset(events, 0, sizeof(events));
|
||||||
|
events[0].type = XCE_CORE_EVENT_QUEUE_UPDATE;
|
||||||
|
events[0].u.queue_update.queue = &wire_queue;
|
||||||
|
events[1].type = XCE_CORE_EVENT_JOBS_PUSH;
|
||||||
|
events[1].u.jobs_push.njobs = 1;
|
||||||
|
events[1].u.jobs_push.jobs = &job;
|
||||||
|
|
||||||
|
rc = xce_core_push_events(&core, events, 2);
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, g_stub_state_a.ncandidates);
|
||||||
|
ncandidates_before = g_stub_state_a.ncandidates;
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
* Phase 2: spawn the real picker thread, then start a GENUINE session
|
||||||
|
* via xce_core_go(). While it is active, both snapshot functions must
|
||||||
|
* be rejected with -EBUSY (distinct from the -EINVAL NULL-arg case
|
||||||
|
* task 8.1's own guard test above already covers), and must leave
|
||||||
|
* state genuinely untouched -- not just "returned an error code".
|
||||||
|
* =================================================================== */
|
||||||
|
rc = pthread_create(&picker_tid, NULL, test_picker_thread_fn, &core);
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||||
|
|
||||||
|
rc = xce_core_go(&core);
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||||
|
TEST_ASSERT_EQUAL_INT(
|
||||||
|
1, atomic_load_explicit(&(core.session_active), memory_order_acquire));
|
||||||
|
|
||||||
|
/* save: sentinel bytes untouched -- proves the guard runs before
|
||||||
|
* save_snapshot()'s own memset(out, 0, ...), against a real session. */
|
||||||
|
memset(&snap, 0xAA, sizeof(snap));
|
||||||
|
rc = xce_sched_core_save_snapshot(&core, &snap);
|
||||||
|
TEST_ASSERT_EQUAL_INT(-EBUSY, rc);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0xAA, ((uint8_t*)&snap)[0]);
|
||||||
|
|
||||||
|
/* load: a distinguishable payload (a queue name never pushed to this
|
||||||
|
* core) proves the load never ran -- if the guard were bypassed, this
|
||||||
|
* queue would be added and the picker would gain a 2nd candidate. */
|
||||||
|
memset(&reject_wire_queue, 0, sizeof(reject_wire_queue));
|
||||||
|
reject_wire_queue.queue = "should-not-appear";
|
||||||
|
reject_wire_queue.priority = 99;
|
||||||
|
reject_wire_queue.status = QUEUE_STAT_OPEN;
|
||||||
|
reject_queues[0] = &reject_wire_queue;
|
||||||
|
|
||||||
|
memset(&reject_in, 0, sizeof(reject_in));
|
||||||
|
reject_in.queues = reject_queues;
|
||||||
|
reject_in.n_queues = 1;
|
||||||
|
|
||||||
|
rc = xce_sched_core_load_snapshot(&core, &reject_in);
|
||||||
|
TEST_ASSERT_EQUAL_INT(-EBUSY, rc);
|
||||||
|
TEST_ASSERT_NULL(sched_get_queue(&core, "should-not-appear"));
|
||||||
|
|
||||||
|
q_after = sched_get_queue(&core, "normal");
|
||||||
|
TEST_ASSERT_NOT_NULL(q_after);
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, q_after->priority);
|
||||||
|
TEST_ASSERT_EQUAL_INT(ncandidates_before, g_stub_state_a.ncandidates);
|
||||||
|
|
||||||
|
/* both rejections happened without the session ever having ended. */
|
||||||
|
TEST_ASSERT_EQUAL_INT(
|
||||||
|
1, atomic_load_explicit(&(core.session_active), memory_order_acquire));
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
* Phase 3: drain the still-running real session to completion -- same
|
||||||
|
* drain protocol run_identical_session() above uses (decision content
|
||||||
|
* is not under test here, only that the session ends cleanly).
|
||||||
|
* =================================================================== */
|
||||||
|
for (;;) {
|
||||||
|
decision = NULL;
|
||||||
|
|
||||||
|
cbuf_get(&(core.jbuf), &jobid);
|
||||||
|
if (jobid == 0) {
|
||||||
|
cbuf_put(&(core.decisions), &decision);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
reason = make_pendreason(jobid);
|
||||||
|
decision = make_pend_decision(reason);
|
||||||
|
TEST_ASSERT_NOT_NULL(decision);
|
||||||
|
cbuf_put(&(core.decisions), &decision);
|
||||||
|
}
|
||||||
|
|
||||||
|
rc = pthread_join(picker_tid, NULL);
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||||
|
|
||||||
|
(void)drain_and_dispatch(&core);
|
||||||
|
|
||||||
|
rc = xce_core_end_session(&core);
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||||
|
TEST_ASSERT_EQUAL_INT(
|
||||||
|
0, atomic_load_explicit(&(core.session_active), memory_order_acquire));
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------- */
|
/* ---------------------------------------------------------------------- */
|
||||||
/* The Observable, end to end. */
|
/* The Observable, end to end. */
|
||||||
/* ---------------------------------------------------------------------- */
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
@ -840,6 +980,8 @@ int main(void)
|
||||||
UNITY_BEGIN();
|
UNITY_BEGIN();
|
||||||
RUN_TEST(
|
RUN_TEST(
|
||||||
test_snapshot_guards_reject_null_args_and_busy_session_without_mutating_state);
|
test_snapshot_guards_reject_null_args_and_busy_session_without_mutating_state);
|
||||||
|
RUN_TEST(
|
||||||
|
test_snapshot_save_and_load_reject_during_genuinely_active_session);
|
||||||
RUN_TEST(
|
RUN_TEST(
|
||||||
test_snapshot_save_then_load_produces_behaviorally_equivalent_core);
|
test_snapshot_save_then_load_produces_behaviorally_equivalent_core);
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue