From 97c182cbe419a71f6285c7bde04d7ef76a533660 Mon Sep 17 00:00:00 2001 From: Rongsong Shen Date: Thu, 17 Sep 2026 15:23:49 +0000 Subject: [PATCH] test(scheduler-sim-diagnostics): verify info limit is read-only and deterministic Four new tests in test_repl_limit_table.c close out Requirement 5: - test_info_limit_never_mutates_global_limit_config_or_accounting_state / ..._host_scoped_... : snapshot a matched limit's config fields and its vres capacity/in-use zarray entries (both pointer identity and value) before and after repeated info limit calls; asserts byte-identical state and byte-identical repeated stdout output. - test_info_limit_on_empty_registry_is_repeatable_and_never_registers_plugin: confirms info limit never causes the plugin to become spuriously registered as a side effect of being queried. - test_repl_step_continue_with_info_limit_interleaved_matches_baseline_decision_sequence: a step/continue sequence run twice (once with info limit interleaved, once without) produces an identical decision sequence. Verified via real mutation testing: three targeted fake mutations were temporarily applied to repl_info.c, confirmed each new test fails exactly as predicted, then cleanly reverted (repl_info.c shows zero diff from HEAD). Zero production code changed -- this is a test-only validation task. Full 14-binary regression sweep + 78/78 meson aggregate confirm zero impact on any pre-existing info subcommand (Requirement 5.3). This closes out scheduler-sim-diagnostics' first capability (limit inspection, Requirements 1-5) end to end. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RLSNJhNoULvVcUhXEFafQC --- .../specs/scheduler-sim-diagnostics/tasks.md | 2 +- .../testsuite/test_repl_limit_table.c | 796 ++++++++++++++++++ 2 files changed, 797 insertions(+), 1 deletion(-) diff --git a/.kiro/specs/scheduler-sim-diagnostics/tasks.md b/.kiro/specs/scheduler-sim-diagnostics/tasks.md index 73fa2902..52ebe7d0 100644 --- a/.kiro/specs/scheduler-sim-diagnostics/tasks.md +++ b/.kiro/specs/scheduler-sim-diagnostics/tasks.md @@ -49,7 +49,7 @@ - _Requirements: 4.1, 4.2_ - _Depends: 2.1, 3.1, 3.2_ -- [ ] 5. Verify no mutation, replay determinism, and existing-subcommand regression +- [x] 5. Verify no mutation, replay determinism, and existing-subcommand regression - Test: displaying limit information never alters configuration, accounting state, or scheduling decisions (assert config/accounting untouched before vs. after) - Test: a replay run twice — once with `info limit` issued mid-session, once without — produces identical scheduling decisions and identical final accounted state - Full existing `info` subcommand test suite (job/queue/worker/resource/summary) re-run to confirm zero output changes from this addition diff --git a/src/scheduler/testsuite/test_repl_limit_table.c b/src/scheduler/testsuite/test_repl_limit_table.c index 562d8d7b..14c22b80 100644 --- a/src/scheduler/testsuite/test_repl_limit_table.c +++ b/src/scheduler/testsuite/test_repl_limit_table.c @@ -1607,6 +1607,794 @@ test_info_limit_dispatch_all_reaches_repl_info_limit_via_real_dispatch(void) "dispatch: PASS\n"); } +/* ---------------------------------------------------------------------- */ +/* Task 5 (Requirement Set 1's own Validation task, Requirement 5): proves */ +/* -- rather than merely asserting by construction, as every prior task's */ +/* own tests above already do -- that displaying limit information never */ +/* mutates configuration or accounting state (5.1), that issuing it mid- */ +/* replay never perturbs scheduling decisions (5.2), and (via the full */ +/* regression sweep reported in this task's own status report, not a new */ +/* test here) that every pre-existing `info` subcommand's behavior is */ +/* unchanged (5.3). */ +/* ---------------------------------------------------------------------- */ + +/* + * Requirement 5.1 (GLOBAL/SHARED path): snapshots the matched limit's own + * configured fields (val, all four consumers) BY VALUE, and the matched + * vres's capacity/in-use zarray slots BY POINTER IDENTITY (a `put_resvals`- + * style accidental write swaps in a NEW xce_val_t* at that index even if + * the new value happened to coincide numerically, so pointer identity is a + * strictly stronger check than value equality alone) -- then calls + * `info limit` repeatedly, in every arg form this fixture can produce a + * match or non-match for, through BOTH the direct entry point + * (repl_info_limit(), task 2's own call shape) and the real repl_dispatch() + * entry point (task 4's own call shape), and re-checks every snapshot + * afterward. + */ +static void +test_info_limit_never_mutates_global_limit_config_or_accounting_state(void) +{ + xce_sched_sim_driver_t sim; + xce_sched_plugin_t plugin; + xce_scheduler_t group_sched; + xce_limit_t* matched = NULL; + var limits_handle; + xce_vres_t* vres; + xce_val_t* cap_before; + xce_val_t* used_before; + xce_limit_val_t val_before; + char* name_before; + xce_consumer_t consumer_before[CONSUMER_MAX]; + char* captured1; + char* captured2; + char* captured3; + char* captured4; + char* captured5; + int rc; + int i; + + memset(&sim, 0, sizeof(sim)); + rc = xce_sched_core_init(&(sim.core)); + TEST_ASSERT_EQUAL_INT(0, rc); + + register_per_host_resource(&sim, "mem"); + register_limit_plugin(&sim, &plugin, &group_sched); + add_configured_limit(&plugin, "guarded_limit"); + + limits_handle = limit_get_configured_limits(&(sim.core)); + rc = key_find(limits_handle, (void*)"guarded_limit", (void**)&matched); + TEST_ASSERT_TRUE(rc > 0); + TEST_ASSERT_NOT_NULL(matched); + + vres = instantiate_and_register_global_vres(&sim, &plugin, matched); + put_shared_fvalue(&(sim.core.sharedInst), vres->vres->idx, 10.0); + put_shared_fvalue(&(sim.core.sharedInUse), vres->vres->idx, 4.0); + + /* ---- Snapshot BEFORE. ---- */ + name_before = matched->name; + val_before = matched->val; + for (i = 0; i < CONSUMER_MAX; i++) { + if (matched->consumers[i] != NULL) { + consumer_before[i] = *(matched->consumers[i]); + } else { + memset(&consumer_before[i], 0, sizeof(consumer_before[i])); + } + } + cap_before + = (xce_val_t*)zarray_get(&(sim.core.sharedInst), vres->vres->idx); + used_before + = (xce_val_t*)zarray_get(&(sim.core.sharedInUse), vres->vres->idx); + TEST_ASSERT_NOT_NULL(cap_before); + TEST_ASSERT_NOT_NULL(used_before); + + /* ---- Exercise `info limit` repeatedly, in every arg form this + * fixture supports (named hit, "all", named miss), both via the direct + * call and via the real repl_dispatch() entry point -- Requirement 5.1 + * must hold for every one of these call shapes. ---- */ + captured1 = capture_info_limit(&sim, "guarded_limit"); + captured2 = capture_info_limit(&sim, "guarded_limit"); + captured3 = capture_info_limit(&sim, "all"); + captured4 = capture_dispatch(&sim, "info limit guarded_limit"); + captured5 = capture_info_limit(&sim, "does_not_exist"); + + TEST_ASSERT_EQUAL_STRING_MESSAGE( + captured1, captured2, + "Requirement 5.2 (no intervening step): 'info limit ' issued " + "twice in a row must produce byte-identical output"); + + /* ---- Snapshot AFTER -- must be byte-for-byte/pointer-for-pointer + * identical to BEFORE. ---- */ + TEST_ASSERT_TRUE_MESSAGE( + matched->name == name_before, + "Requirement 5.1: the matched limit's own name pointer must not be " + "reallocated/reassigned by displaying it"); + TEST_ASSERT_EQUAL_MEMORY_MESSAGE( + &val_before, &(matched->val), sizeof(val_before), + "Requirement 5.1: the matched limit's configured value (resname/" + "percent/val) must be bit-for-bit unchanged after displaying it"); + for (i = 0; i < CONSUMER_MAX; i++) { + if (matched->consumers[i] != NULL) { + TEST_ASSERT_EQUAL_MEMORY_MESSAGE( + &consumer_before[i], matched->consumers[i], + sizeof(consumer_before[i]), + "Requirement 5.1: each consumer's per_consumer flag/group " + "pointer must be unchanged after displaying the limit"); + } + } + TEST_ASSERT_TRUE_MESSAGE( + cap_before + == (xce_val_t*)zarray_get(&(sim.core.sharedInst), + vres->vres->idx), + "Requirement 5.1: the capacity zarray slot must still hold the " + "SAME xce_val_t* -- no accidental write/reallocation"); + TEST_ASSERT_TRUE_MESSAGE( + used_before + == (xce_val_t*)zarray_get(&(sim.core.sharedInUse), + vres->vres->idx), + "Requirement 5.1: the in-use zarray slot must still hold the SAME " + "xce_val_t* -- no accidental charge/write"); + TEST_ASSERT_EQUAL_FLOAT(10.0f, (float)cap_before->u.fvalue); + TEST_ASSERT_EQUAL_FLOAT(4.0f, (float)used_before->u.fvalue); + + free(captured1); + free(captured2); + free(captured3); + free(captured4); + free(captured5); + + printf("test_info_limit_never_mutates_global_limit_config_or_" + "accounting_state: PASS\n"); +} + +/* + * Requirement 5.1, host-scoped path: mirrors the GLOBAL/SHARED no-mutation + * test above, but for the per-worker fan-out path + * (repl_print_limit_usage_per_host()) -- a host-scoped limit's capacity/ + * in-use zarray entries live on EACH worker's OWN resvals/resInUse, not on + * core.sharedInst/sharedInUse, so this is a genuinely distinct read path + * needing its own no-mutation proof. + */ +static void +test_info_limit_never_mutates_host_scoped_limit_accounting_state(void) +{ + xce_sched_sim_driver_t sim; + xce_sched_plugin_t plugin; + xce_scheduler_t group_sched; + xce_limit_t* matched = NULL; + var limits_handle; + xce_vres_t* vres; + xce_sched_worker_t* wA; + xce_sched_worker_t* wB; + xce_val_t* capA_before; + xce_val_t* usedA_before; + xce_val_t* capB_before; + xce_val_t* usedB_before; + char* captured1; + char* captured2; + char* captured3; + int rc; + + memset(&sim, 0, sizeof(sim)); + rc = xce_sched_core_init(&(sim.core)); + TEST_ASSERT_EQUAL_INT(0, rc); + + register_per_host_resource(&sim, "mem"); + register_limit_plugin(&sim, &plugin, &group_sched); + add_configured_percent_limit(&plugin, "guarded_host_limit", "mem"); + + limits_handle = limit_get_configured_limits(&(sim.core)); + rc = key_find(limits_handle, (void*)"guarded_host_limit", + (void**)&matched); + TEST_ASSERT_TRUE(rc > 0); + TEST_ASSERT_NOT_NULL(matched); + + vres = instantiate_and_register_host_vres(&sim, &plugin, matched); + wA = build_bare_worker(&sim, "hostA"); + wB = build_bare_worker(&sim, "hostB"); + + put_shared_fvalue(&(wA->resvals), vres->vres->idx, 5.0); + put_shared_fvalue(&(wA->resInUse), vres->vres->idx, 2.0); + put_shared_fvalue(&(wB->resvals), vres->vres->idx, 5.0); + put_shared_fvalue(&(wB->resInUse), vres->vres->idx, 3.0); + + capA_before = (xce_val_t*)zarray_get(&(wA->resvals), vres->vres->idx); + usedA_before = (xce_val_t*)zarray_get(&(wA->resInUse), vres->vres->idx); + capB_before = (xce_val_t*)zarray_get(&(wB->resvals), vres->vres->idx); + usedB_before = (xce_val_t*)zarray_get(&(wB->resInUse), vres->vres->idx); + TEST_ASSERT_NOT_NULL(capA_before); + TEST_ASSERT_NOT_NULL(usedA_before); + TEST_ASSERT_NOT_NULL(capB_before); + TEST_ASSERT_NOT_NULL(usedB_before); + + captured1 = capture_info_limit(&sim, "guarded_host_limit"); + captured2 = capture_info_limit(&sim, "guarded_host_limit"); + captured3 = capture_dispatch(&sim, "info limit guarded_host_limit"); + + TEST_ASSERT_EQUAL_STRING_MESSAGE( + captured1, captured2, + "Requirement 5.2: 'info limit ' issued twice in a row with " + "no intervening step must produce byte-identical output for a " + "host-scoped limit too"); + + TEST_ASSERT_TRUE_MESSAGE( + capA_before + == (xce_val_t*)zarray_get(&(wA->resvals), vres->vres->idx), + "Requirement 5.1: hostA's capacity zarray slot must still hold the " + "SAME xce_val_t*"); + TEST_ASSERT_TRUE_MESSAGE( + usedA_before + == (xce_val_t*)zarray_get(&(wA->resInUse), vres->vres->idx), + "Requirement 5.1: hostA's in-use zarray slot must still hold the " + "SAME xce_val_t*"); + TEST_ASSERT_TRUE_MESSAGE( + capB_before + == (xce_val_t*)zarray_get(&(wB->resvals), vres->vres->idx), + "Requirement 5.1: hostB's capacity zarray slot must still hold the " + "SAME xce_val_t*"); + TEST_ASSERT_TRUE_MESSAGE( + usedB_before + == (xce_val_t*)zarray_get(&(wB->resInUse), vres->vres->idx), + "Requirement 5.1: hostB's in-use zarray slot must still hold the " + "SAME xce_val_t*"); + TEST_ASSERT_EQUAL_FLOAT(5.0f, (float)capA_before->u.fvalue); + TEST_ASSERT_EQUAL_FLOAT(2.0f, (float)usedA_before->u.fvalue); + TEST_ASSERT_EQUAL_FLOAT(5.0f, (float)capB_before->u.fvalue); + TEST_ASSERT_EQUAL_FLOAT(3.0f, (float)usedB_before->u.fvalue); + + free(captured1); + free(captured2); + free(captured3); + + printf("test_info_limit_never_mutates_host_scoped_limit_accounting_" + "state: PASS\n"); +} + +/* + * Requirement 5.1/5.2, the degenerate "nothing configured at all" case: + * calling `info limit` (named and "all") against a sim whose limit plugin + * was never registered must be safely repeatable and must not, e.g., + * spuriously register/create the plugin as a side effect of merely being + * asked about it -- key_find(core->plugins, "xce_sched_limit", ...) must + * keep missing after these calls, exactly as it did before. + */ +static void +test_info_limit_on_empty_registry_is_repeatable_and_never_registers_plugin( + void) +{ + xce_sched_sim_driver_t sim; + xce_sched_plugin_t* found_before = NULL; + xce_sched_plugin_t* found_after = NULL; + char* captured1; + char* captured2; + char* captured3; + char* captured4; + int rc_before; + int rc_after; + int rc; + + memset(&sim, 0, sizeof(sim)); + rc = xce_sched_core_init(&(sim.core)); + TEST_ASSERT_EQUAL_INT(0, rc); + + rc_before = key_find(sim.core.plugins, "xce_sched_limit", + (void**)&found_before); + + captured1 = capture_info_limit(&sim, "all"); + captured2 = capture_info_limit(&sim, "all"); + captured3 = capture_info_limit(&sim, "anything"); + captured4 = capture_dispatch(&sim, "info limit all"); + + TEST_ASSERT_EQUAL_STRING_MESSAGE( + captured1, captured2, + "Requirement 5.2: repeated 'info limit all' calls against a never-" + "registered plugin must produce byte-identical output"); + + rc_after = key_find(sim.core.plugins, "xce_sched_limit", + (void**)&found_after); + + TEST_ASSERT_TRUE_MESSAGE( + rc_before <= 0, + "sanity: the limit plugin must genuinely be absent before any " + "'info limit' call"); + TEST_ASSERT_EQUAL_INT_MESSAGE( + rc_before, rc_after, + "Requirement 5.1: 'info limit' must never spuriously register the " + "limit plugin as a side effect of being asked about it"); + TEST_ASSERT_NULL_MESSAGE( + found_after, + "Requirement 5.1: the limit plugin must still be absent after " + "every form of 'info limit' has been called against it"); + + free(captured1); + free(captured2); + free(captured3); + free(captured4); + + printf("test_info_limit_on_empty_registry_is_repeatable_and_never_" + "registers_plugin: PASS\n"); +} + +/* ---------------------------------------------------------------------- */ +/* Requirement 5.2 (determinism): `info limit` issued mid-replay must not */ +/* perturb the replay's own scheduling decisions -- extends the */ +/* established REPL-vs-batch equivalence discipline */ +/* (test_repl_equivalence.c) to this new read path specifically, per */ +/* design.md's own Integration Tests bullet and */ +/* scheduler-driver-equivalence.md's testing discipline. Every helper */ +/* below is duplicated (not shared) from test_repl_equivalence.c, per this */ +/* file family's own established "duplicate, don't share" convention (no */ +/* shared test-utility header exists across this directory) -- prefixed */ +/* det_ to avoid any symbol collision within this TU. */ +/* ---------------------------------------------------------------------- */ + +static void det_make_tmp_dir(char* out, size_t out_len, const char* suffix) +{ + char tmpl[PATH_MAX]; + + snprintf(tmpl, sizeof(tmpl), "/tmp/sched_repllimitdet%s_XXXXXX", suffix); + TEST_ASSERT_NOT_NULL(mkdtemp(tmpl)); + TEST_ASSERT_TRUE(strlen(tmpl) < out_len); + strcpy(out, tmpl); +} + +static void det_build_fixture_scheduler(xce_scheduler_t* sched, + char* record_dir) +{ + int rc; + + memset(sched, 0, sizeof(*sched)); + mylock_init(&(sched->record_lock)); + + rc = xce_sched_core_init(&(sched->core)); + TEST_ASSERT_EQUAL_INT(0, rc); + + sched->core.job_pick_plugin = (char*)"builtin_fcfs"; + sched->record_dir = record_dir; +} + +static FILE* det_write_header_and_snapshot(char* out_path, + size_t out_path_len, + const char* suffix) +{ + char record_dir[PATH_MAX]; + xce_scheduler_t sched; + int rc; + + det_make_tmp_dir(record_dir, sizeof(record_dir), suffix); + det_build_fixture_scheduler(&sched, record_dir); + + rc = sched_record_start(&sched, "fixture.replay"); + TEST_ASSERT_EQUAL_INT(0, rc); + TEST_ASSERT_TRUE(strlen(sched.record_file_path) < out_path_len); + strcpy(out_path, sched.record_file_path); + + return sched.record_file; +} + +static void det_append_queue_update_event(FILE* f, uint64_t ts, + const char* queue_name, + int32_t priority) +{ + xce_queue_t queue; + replay_event_t src; + struct capn c; + struct capn_segment* cs; + ReplayEvent_ptr p; + + memset(&queue, 0, sizeof(queue)); + queue.queue = (char*)queue_name; + queue.status = QUEUE_STAT_OPEN; + queue.priority = priority; + + memset(&src, 0, sizeof(src)); + src.ts = ts; + src.body_which = ReplayEvent_body_queueUpdate; + src.body.queue_update = &queue; + + capn_init_malloc(&c); + cs = capn_root(&c).seg; + encode_ReplayEvent_ptr(cs, &p, &src); + capn_setp(capn_root(&c), 0, p.p); + + TEST_ASSERT_EQUAL_INT(0, write_capnp_message(f, &c)); + capn_free(&c); +} + +static void det_append_jobs_push_event(FILE* f, uint64_t ts, uint64_t jobid, + const char* queue_name) +{ + job_submit_data_t submit; + xce_job_t job; + xce_job_t* jobs[1]; + xce_jobs_data_t jobs_data; + replay_event_t src; + struct capn c; + struct capn_segment* cs; + ReplayEvent_ptr p; + + memset(&submit, 0, sizeof(submit)); + submit.queue = (char*)queue_name; + submit.user = (char*)"alice"; + submit.mintasks = 1; + submit.maxtasks = 1; + + memset(&job, 0, sizeof(job)); + job.jobid = jobid; + job.state = JOB_STAT_PEND; + job.submit = &submit; + + jobs[0] = &job; + + memset(&jobs_data, 0, sizeof(jobs_data)); + jobs_data.n_total = 1; + jobs_data.count = 1; + jobs_data.jobs = jobs; + + memset(&src, 0, sizeof(src)); + src.ts = ts; + src.body_which = ReplayEvent_body_jobsPush; + src.body.jobs_push = &jobs_data; + + capn_init_malloc(&c); + cs = capn_root(&c).seg; + encode_ReplayEvent_ptr(cs, &p, &src); + capn_setp(capn_root(&c), 0, p.p); + + TEST_ASSERT_EQUAL_INT(0, write_capnp_message(f, &c)); + capn_free(&c); +} + +static void det_append_replay_session(FILE* f, uint64_t begin_round) +{ + struct capn c; + struct capn_segment* cs; + ReplaySession_ptr sp; + + capn_init_malloc(&c); + cs = capn_root(&c).seg; + sp = new_ReplaySession(cs); + ReplaySession_set_beginRound(sp, begin_round); + capn_setp(capn_root(&c), 0, sp.p); + + TEST_ASSERT_EQUAL_INT(0, write_capnp_message(f, &c)); + capn_free(&c); +} + +static void det_append_end_of_stream_marker(FILE* f) +{ + replay_event_t src; + struct capn c; + struct capn_segment* cs; + ReplayEvent_ptr p; + + memset(&src, 0, sizeof(src)); + src.body_which = ReplayEvent_body_endSession; + + capn_init_malloc(&c); + cs = capn_root(&c).seg; + encode_ReplayEvent_ptr(cs, &p, &src); + capn_setp(capn_root(&c), 0, p.p); + + TEST_ASSERT_EQUAL_INT(0, write_capnp_message(f, &c)); + capn_free(&c); +} + +static void det_write_text_file(const char* path, const char* content) +{ + FILE* f = fopen(path, "w"); + + TEST_ASSERT_NOT_NULL(f); + TEST_ASSERT_TRUE(fputs(content, f) >= 0); + fclose(f); +} + +static void det_decode_all_sessions(const char* path, replay_session_t*** out, + int* out_count) +{ + FILE* f = fopen(path, "r"); + replay_session_t** sessions = NULL; + int count = 0; + + TEST_ASSERT_NOT_NULL(f); + + for (;;) { + struct capn c; + capn_ptr root; + + if (capn_init_fp(&c, f, 0) != 0) { + break; /* EOF */ + } + + if (msg_root_has_shape(&c, &root, ReplaySession_word_count, + ReplaySession_pointer_count)) { + ReplaySession_ptr sp; + replay_session_t* decoded = NULL; + replay_session_t** grown; + + sp.p = root; + decode_ReplaySession_ptr(&decoded, sp); + capn_free(&c); + + TEST_ASSERT_NOT_NULL(decoded); + + grown = (replay_session_t**)realloc( + sessions, (size_t)(count + 1) * sizeof(*sessions)); + TEST_ASSERT_NOT_NULL(grown); + sessions = grown; + sessions[count] = decoded; + count++; + continue; + } + + capn_free(&c); + } + + fclose(f); + + *out = sessions; + *out_count = count; +} + +static void det_free_all_sessions(replay_session_t** sessions, int count) +{ + int i; + + for (i = 0; i < count; i++) { + free_ReplaySession_ptr(&(sessions[i])); + } + free(sessions); +} + +static int det_replay_decision_content_eq(const replay_decision_t* a, + const replay_decision_t* b) +{ + if ((a == NULL) || (b == NULL)) { + return 0; + } + if (a->body_which != b->body_which) { + return 0; + } + + switch (a->body_which) { + case ReplayDecision_body_dispatch: + if ((a->body.dispatch == NULL) || (b->body.dispatch == NULL)) { + return 0; + } + return sim_verify_job_alloc_eq(a->body.dispatch->alloc, + b->body.dispatch->alloc); + + case ReplayDecision_body_pend: + if ((a->body.pend == NULL) || (b->body.pend == NULL)) { + return 0; + } + return (a->body.pend->jobid == b->body.pend->jobid) + && sim_verify_job_preason_eq(a->body.pend->reason, + b->body.pend->reason); + + default: + return 0; + } +} + +/* + * det_run_scripted_repl_session - runs sched_sim_driver_run() against + * `fixture_path` in interactive mode, driven by `script_content` fed as + * stdin, writing its own decision stream to a fresh temp file whose path is + * returned in `out_path`. Forked, matching test_repl_equivalence.c's own + * rationale verbatim: `continue` re-enters the REPL at replay completion + * (bug-1594) rather than returning, so the script's trailing `quit` always + * exit(0)s -- this must happen in a child, or it would tear down this + * whole test binary. + */ +static void det_run_scripted_repl_session(const char* fixture_path, + const char* script_content, + char* out_path, + size_t out_path_len) +{ + char script_dir[PATH_MAX]; + char script_path[PATH_MAX]; + int out_fd; + sched_sim_args_t args; + pid_t pid; + int status; + + snprintf(out_path, out_path_len, "/tmp/xce_repllimitdet_out_XXXXXX"); + out_fd = mkstemp(out_path); + TEST_ASSERT_TRUE(out_fd >= 0); + close(out_fd); + + det_make_tmp_dir(script_dir, sizeof(script_dir), "_script"); + snprintf(script_path, sizeof(script_path), "%s/script.txt", script_dir); + det_write_text_file(script_path, script_content); + + memset(&args, 0, sizeof(args)); + args.replay_path = (char*)fixture_path; + args.sim_start_ts = SCHED_SIM_START_TS_LATEST; + args.sim_speed = 0; + args.output_path = out_path; + args.interactive = true; + + pid = fork(); + TEST_ASSERT_TRUE_MESSAGE(pid >= 0, "fork() must succeed"); + + if (pid == 0) { + xce_sched_sim_driver_t sim; + + if (freopen(script_path, "r", stdin) == NULL) { + _exit(120); + } + memset(&sim, 0, sizeof(sim)); + (void)sched_sim_driver_run(&sim, &args); + _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"); +} + +/* + * THE central determinism assertion (Requirement 5.2, design.md's own + * Integration Tests bullet): the SAME `.replay` fixture, driven through the + * SAME `step 1`/`continue` script, must produce byte-identical scheduling + * decisions whether or not `info limit` is issued at several points along + * the way. No limit plugin is registered for this fixture (this task's own + * scope is proving that ISSUING the command has zero effect on the replay, + * not re-proving the config/usage display correctness the no-mutation + * tests above and tasks 2-3's own tests already cover) -- every `info + * limit` call below still reaches the real repl_info_limit()/ + * limit_get_configured_limits() code path and resolves through its genuine + * "no limits configured"/"no such limit" short-circuits, so this proves + * THOSE paths are exactly as non-perturbing as the matched-limit path. + */ +static void +test_repl_step_continue_with_info_limit_interleaved_matches_baseline_decision_sequence( + void) +{ + char fixture_path[PATH_MAX]; + char baseline_out_path[PATH_MAX]; + char infolimit_out_path[PATH_MAX]; + FILE* f; + replay_session_t** baseline_sessions = NULL; + replay_session_t** infolimit_sessions = NULL; + int n_baseline = 0; + int n_infolimit = 0; + int total_baseline_decisions; + int saw_jobid_501; + int i; + int j; + + /* Same fixture shape test_repl_equivalence.c's own decision-parity test + * establishes (two queues at different priorities, four jobs split + * across three ReplaySession boundaries) -- reused verbatim rather than + * re-derived, since this task validates an ADDITIONAL invariant (info + * limit's own non-interference) against an already-proven decision- + * producing fixture shape, not a new one. */ + f = det_write_header_and_snapshot(fixture_path, sizeof(fixture_path), + "_fixture"); + TEST_ASSERT_NOT_NULL(f); + + det_append_queue_update_event(f, 1000ULL, "high", 5); + det_append_queue_update_event(f, 1001ULL, "normal", 1); + + det_append_jobs_push_event(f, 1500ULL, 501ULL, "high"); + det_append_jobs_push_event(f, 1501ULL, 502ULL, "normal"); + det_append_replay_session(f, 2000ULL); /* session 1 */ + + det_append_jobs_push_event(f, 2500ULL, 503ULL, "high"); + det_append_replay_session(f, 3000ULL); /* session 2 */ + + det_append_jobs_push_event(f, 3500ULL, 504ULL, "normal"); + det_append_replay_session(f, 4000ULL); /* session 3 */ + + det_append_end_of_stream_marker(f); + fclose(f); + + /* ---- Run 1: baseline -- step/continue to completion, info limit + * never issued. ---- */ + det_run_scripted_repl_session(fixture_path, "step 1\ncontinue\nquit\n", + baseline_out_path, + sizeof(baseline_out_path)); + + /* ---- Run 2: the SAME fixture and SAME step/continue script, but with + * `info limit` issued before the pre-replay pause, between step and + * continue, and after continue completes every remaining session -- + * every position an operator could type it at, in both arg forms this + * feature accepts (a name and "all"). ---- */ + det_run_scripted_repl_session( + fixture_path, + "info limit all\nstep 1\ninfo limit all\ncontinue\ninfo limit " + "ug1_slots\nquit\n", + infolimit_out_path, sizeof(infolimit_out_path)); + + det_decode_all_sessions(baseline_out_path, &baseline_sessions, + &n_baseline); + det_decode_all_sessions(infolimit_out_path, &infolimit_sessions, + &n_infolimit); + + TEST_ASSERT_EQUAL_INT_MESSAGE(3, n_baseline, + "the fixture pushes exactly 3 " + "ReplaySession boundaries"); + TEST_ASSERT_EQUAL_INT_MESSAGE( + n_baseline, n_infolimit, + "Requirement 5.2: issuing 'info limit' at several points in the " + "REPL session must not change how many ReplaySession boundaries " + "the replay produces"); + + /* Non-tautology guard #1 (mirrors test_repl_equivalence.c's own): an + * unambiguous floor independent of any 'info limit' interference -- + * session 1 always has exactly 2 pending candidates. */ + TEST_ASSERT_EQUAL_INT(2, baseline_sessions[0]->n_decisions); + + /* Non-tautology guard #2: pin real decision CONTENT on the baseline + * side, not merely "both sides agree" (a shared bug could satisfy that + * too). */ + saw_jobid_501 = 0; + for (j = 0; j < baseline_sessions[0]->n_decisions; j++) { + replay_decision_t* d = baseline_sessions[0]->decisions[j]; + + TEST_ASSERT_EQUAL_INT((int)ReplayDecision_body_pend, + (int)d->body_which); + if ((d->body.pend != NULL) && (d->body.pend->jobid == 501ULL)) { + saw_jobid_501 = 1; + } + } + TEST_ASSERT_TRUE_MESSAGE(saw_jobid_501, + "session 1's baseline decisions must " + "genuinely include job 501"); + + /* Non-tautology guard #3: a genuinely non-trivial total decision + * count. */ + total_baseline_decisions = 0; + for (i = 0; i < n_baseline; i++) { + total_baseline_decisions += baseline_sessions[i]->n_decisions; + } + TEST_ASSERT_TRUE_MESSAGE(total_baseline_decisions >= 4, + "the fixture must produce a genuinely " + "non-trivial decision sequence"); + + /* ---- The central assertion: per session, per decision, the + * info-limit-interleaved run must match the baseline exactly. ---- */ + for (i = 0; i < n_baseline; i++) { + TEST_ASSERT_EQUAL_INT_MESSAGE( + baseline_sessions[i]->n_decisions, + infolimit_sessions[i]->n_decisions, + "Requirement 5.2: session decision COUNT must match whether or " + "not 'info limit' was issued during the replay"); + + for (j = 0; j < baseline_sessions[i]->n_decisions; j++) { + replay_decision_t* bd = baseline_sessions[i]->decisions[j]; + replay_decision_t* id = infolimit_sessions[i]->decisions[j]; + + TEST_ASSERT_EQUAL_INT((int)ReplayDecision_body_pend, + (int)bd->body_which); + TEST_ASSERT_EQUAL_INT((int)ReplayDecision_body_pend, + (int)id->body_which); + + TEST_ASSERT_TRUE_MESSAGE( + det_replay_decision_content_eq(bd, id), + "Requirement 5.2: 'info limit' issued mid-session must " + "produce a byte-identical decision to the baseline at this " + "session and position"); + } + } + + det_free_all_sessions(baseline_sessions, n_baseline); + det_free_all_sessions(infolimit_sessions, n_infolimit); + + unlink(baseline_out_path); + unlink(infolimit_out_path); + + printf( + "test_repl_step_continue_with_info_limit_interleaved_matches_" + "baseline_decision_sequence: PASS\n"); +} + int main(void) { UNITY_BEGIN(); @@ -1647,5 +2435,13 @@ int main(void) test_info_limit_dispatch_named_reaches_repl_info_limit_via_real_dispatch); RUN_TEST( test_info_limit_dispatch_all_reaches_repl_info_limit_via_real_dispatch); + RUN_TEST( + test_info_limit_never_mutates_global_limit_config_or_accounting_state); + RUN_TEST( + test_info_limit_never_mutates_host_scoped_limit_accounting_state); + RUN_TEST( + test_info_limit_on_empty_registry_is_repeatable_and_never_registers_plugin); + RUN_TEST( + test_repl_step_continue_with_info_limit_interleaved_matches_baseline_decision_sequence); return UNITY_END(); }