test(scheduler-core): REPL step/continue equivalence to batch simulation (task 12.4)

Adds the final task of "Core: Interactive REPL" (Req 5 AC 22): proves
the REPL's own documented invariant -- "step/continue to completion
produces byte-identical output to batch --sim" (design.md SimDriver
Invariants).

test_repl_equivalence.c runs the same sched_sim_driver_run() on the
same 3-session/9-decision fixture twice: once batch (interactive=
false), once REPL-driven via freopen()'d scripted "step 1\ncontinue\n"
stdin into the real repl_command_loop()/readline() path (same
mechanism test_repl_execution_control.c established) -- then
structurally decodes both runs' --sim-output and compares decision
content. Test-only; zero production code changed.

RED-phase probe (planted in xce_repl_on_event(), reverted, zero
residue) confirmed the comparison is real: corrupting one job's id
under interactive mode broke exactly this test plus the 3 other REPL
tests that share the same hook, none else.

Scheduler suite: 37/37 clean (was 36/36). Task group 12 (Interactive
REPL, tasks 12.1-12.4) is now complete.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPPXeKSUXUqcqvzNXY1rzJ
This commit is contained in:
Rongsong Shen 2026-08-07 10:49:08 +00:00
parent 2329dbf2a8
commit 829fbe41dc
Signed by: rshen
SSH key fingerprint: SHA256:8Vf2pZ3h6rJa7EhGK+WaAQ9Mta/apZvteW6g9U/WKKk
4 changed files with 717 additions and 1 deletions

View file

@ -718,3 +718,7 @@ Root-caused a `committed_job_loss=1` chaotic-soak FAIL entirely from logs/timest
## Do-Not-Repeat (2026-08-07, scheduler-core task 12.1) — when a defensive guard is added based on an INITIAL crash theory that later turns out to be wrong (root-caused via gdb to something else entirely), re-evaluate whether the guard is still independently justified before keeping it. Here: a `core->config == NULL` fail-loud guard was added to `xce_sched_core_save_snapshot()` believing NULL config caused the SnapshotData crash — it didn't (the generated `encode_Conf4Sched_ptr()` already null-checks and safely emits `capn_null`); the real crash was the internalState `Data`-field UB above. The guard, though "defensively reasonable" in isolation, broke 8 other test fixtures across the suite that never set up a config (a much bigger regression than the 1 crash it prevented, which wasn't actually preventable via that path anyway) — reverted cleanly once the real root cause was found. Lesson: verify a defensive fix against the ACTUAL proven root cause (gdb backtrace + field values), not against a plausible-sounding first theory, before letting it ship.
## Do-Not-Repeat (2026-08-07, bug-1506/1507, scheduler-core task 12.2) — two makeString() gotchas hit building repl_break.c's breakpoint storage, both caught by this task's own RED-phase test run (not by inspection): (1) makeString() (lib/xstring.c) silently returns NULL rather than crashing when its backing xstr_table hasn't been initialized via xstring_table_init() (only ever called transitively by xce_sched_core_init(), core.c) — a test fixture that builds a bare xce_sched_sim_driver_t via memset(0) and drives repl_dispatch()'s `break` command WITHOUT first calling xce_sched_core_init(&sim.core) gets silently-NULL breakpoint token/filter fields instead of a loud failure (bug-1506). Always call xce_sched_core_init() first in any repl.c-family test fixture, even one that never touches core state otherwise — it's the same precondition the real driver flow always satisfies (sched_sim_driver_start() runs before xce_repl_start()). (2) Confirms and extends bug-1503's existing "makeString() is not strdup(), never free() its result directly" lesson: this task's own repl_breakpoint_free() called a bare FREEUP()/free() on a makeString()-produced xstring, producing "free(): invalid pointer" (glibc heap-corruption abort). The correct precedent for a field that is NEVER meant to be individually released is what kill_sched_job()/kill_sched_queue() (schedobj.c) already do: don't free it at all — makeString()'d fields on scheduler-core objects live for the process's lifetime, matching this whole test suite's `detect_leaks=0`-by-design convention. Only call free() on a makeString() result if you can point to the SPECIFIC rtti-aware teardown path that produced it (bug-1503's own narrower case); when in doubt, follow kill_sched_job()/kill_sched_queue()'s "never free it" default instead.
## Key Learning (2026-08-07, scheduler-core task 12.4) — a RED-phase probe for a "REPL never alters WHAT is processed, only WHEN" invariant must corrupt CONTENT, not TIMING: defeating repl_should_pause() (task 12.1's own already-verified probe, which flips the `step N` pause decision) does NOT turn a REPL-vs-batch decision-sequence equivalence test red, because it only changes when stdin is read/control returns — every session still eventually runs to completion regardless of pause schedule, and any script built on CONTINUE's sticky non-pausing behavior tolerates arbitrary pause timing. The correct probe has to touch a hook that can affect content and fires ONLY under `sim->interactive`: `xce_repl_on_event(sim, ev)` (repl_break.c) takes a non-const `xce_core_event_t*` and is called strictly before the event joins the batch `xce_core_push_events()` later applies — a one-line mutation there (e.g. corrupting a JOBS_PUSH job's jobid) is invisible to batch mode entirely and produces a genuine, real divergence in the recorded output file. Confirmed the probe correctly also failed 3 OTHER pre-existing REPL tests that exercise JOBS_PUSH under interactive mode (test_repl_execution_control.c/test_repl_snapshot_params.c/test_repl_breakpoints_inspection.c) — a real production bug at this hook site would legitimately be caught by the whole REPL test family, not just one file, which is expected and reassuring, not a signal of over-broad blast radius from the probe itself.
## Decision Log (2026-08-07, scheduler-core task 12.4) — task 12.4's "byte-identical decision sequences" is a DIFFERENT comparison than task 11.6's test_sim_equivalence.c: 11.6 compares the NORMAL-mode driver against the SIMULATION driver on the same recorded fixture; 12.4 compares the SAME simulation driver (sched_sim_driver_run()) against itself, run twice on the identical `.replay` fixture, differing only in `sim->interactive` (REPL-driven via scripted stdin vs straight batch). Both runs set `sched_sim_args_t.output_path` directly (confirmed unconditional on `interactive` — sim.c's fopen() gate has no interactive check), so no `set output` REPL command is needed; multi-session fixtures (more than one ReplaySession boundary, needed to meaningfully exercise BOTH `step` and `continue` per the task's literal text) require scanning the whole output file structurally for ReplaySession-shaped messages (sim.c's shared msg_root_has_shape(), each `.replay` message type has a distinct (word,ptr) struct shape) rather than reusing test_sim_equivalence.c's fixed-skip-count decode technique (valid only for exactly one session).