feat(scheduler-core): implement simulation-mode startup (task 11.1)

Add sim.h/sim.c implementing AC 13 steps 1-4: open+validate ReplayHeader,
locate the most recent SnapshotEntry at/before --sim-start-ts, load it via
xce_sched_core_load_snapshot(), and set the virtual clock anchors. Wire
all seven simulation CLI flags into main.c's task-5.2 mode-selection
branch. Event loop itself (step 5) deferred to task 11.2. Fixes two real
bugs found during TDD: a SnapshotEntry_get_ts() use-after-free and a
pend_updates NULL-deref in xce_sched_core_fini() for bare-core callers.
27/27 scheduler suite green, full project build clean.
This commit is contained in:
Rongsong Shen 2026-08-06 09:22:04 +00:00
parent 16d756aca3
commit 5d587ecf32
Signed by: rshen
SSH key fingerprint: SHA256:8Vf2pZ3h6rJa7EhGK+WaAQ9Mta/apZvteW6g9U/WKKk
7 changed files with 1366 additions and 21 deletions

228
src/scheduler/include/sim.h Normal file
View file

@ -0,0 +1,228 @@
#if !defined(_SIM_H_)
#define _SIM_H_ 1
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include "core.h"
/*
* sim.h - simulation-mode driver (scheduler-core spec, task 11.1, Req 5 AC
* 13/14/17). NEW file per design.md's File Structure Plan ("sim_driver.h --
* NEW (Phase 3): xce_sched_sim_driver_t"; named `sim.h`/`sim.c` here,
* matching this codebase's established one-focused-file-per-subsystem
* pattern -- driver.h/driver.c for normal mode, recording.h/recording.c for
* recording, snapshot.c for snapshot serialization).
*
* Boundary (Req 5 AC 17, "Simulation isolation"): `xce_sched_sim_driver_t`
* below contains no `xcecli_ctx_t`, no `xce_cluster_config_t`, no capnp
* socket, and no controller RPC of any kind -- confirmed by the struct
* definition itself (every field is either the pure-core `xce_sched_core_t`,
* a `FILE*`, or a scalar). Task 11.3 (not this task) builds the formal
* build-time isolation audit; this task's own obligation is simply to never
* introduce such a dependency here.
*
* This header only depends on core.h (+ stdbool/stdint/stdio) -- no
* scheduler.h, no client.h -- matching driver.h's own documented boundary
* comment for the same reason.
*/
/*
* xce_sched_sim_driver_t - design.md's SimDriver Service Interface struct,
* verbatim field-for-field:
*
* xce_sched_core_t core; -- the restored scheduling state
* FILE *replay_file; -- input .replay stream (task 11.1)
* FILE *alt_events_file; -- optional --sim-alt-events input
* (task 11.5 consumes its content;
* this task only opens it)
* FILE *record_file; -- optional --sim-output stream
* (task 11.5 writes to it; this
* task only opens it)
* uint64_t sim_speed_num/sim_speed_den -- the SAME two numbers this task
* also stamps onto
* `core.speed_num`/`core.speed_den`
* (kept here too per design.md's
* literal struct, e.g. for the
* REPL/diagnostics to read without
* reaching into `core`)
* bool verify; -- --sim-verify (task 11.4 acts on
* it; this task only stores it)
* bool interactive; -- --interactive/-i (task 12 acts on
* it; this task only stores it)
*/
typedef struct {
xce_sched_core_t core;
FILE* replay_file;
FILE* alt_events_file;
FILE* record_file;
uint64_t sim_speed_num;
uint64_t sim_speed_den;
bool verify;
bool interactive;
} xce_sched_sim_driver_t;
/*
* SCHED_SIM_START_TS_LATEST - default value for `sched_sim_args_t.sim_start_ts`
* when the caller does not pass --sim-start-ts (main.c). AC 13's own
* pseudocode ("find the most recent SnapshotEntry with ts <= sim_start_ts")
* has no documented default -- 0 would perversely mean "the earliest
* possible snapshot" and reject nearly every real (non-zero, wall-clock-
* derived) recording unless the user always passes an explicit value.
* UINT64_MAX instead means "no upper bound" -- the most recent SnapshotEntry
* in the whole file, i.e. resume simulation from wherever the recording (or
* a prior simulation's own --sim-output) left off. A deliberate, documented
* engineering decision (not specified verbatim in requirements.md/design.md),
* consistent with the AC's own filtering formula.
*/
#define SCHED_SIM_START_TS_LATEST UINT64_MAX
/*
* sched_sim_args_t - CLI arguments for simulation mode (task 11.1, Req 5 AC
* 13's "The scheduler shall support simulation mode activated by --sim FILE
* and --sim-start-ts TIMESTAMP", plus this task's own "Add the simulation-
* mode CLI flags (replay file path, start timestamp, speed, verify,
* alt-events file, output file, interactive)" bullet -- all seven listed
* there are fields below). Populated by main.c's argp parsing, passed by
* const pointer into sched_sim_driver_start()/sched_sim_driver_run().
*
* Deliberately a plain, self-contained struct -- only primitive/string
* fields, zero dependency on client.h/xce/cluster.h/scheduler.h -- so this
* header stays inside core.h's own dependency boundary (Req 5 AC 17).
* design.md's Service Interface literally writes `sched_sim_driver_run(...,
* const void* args)`, but a concrete, named struct type costs nothing extra
* here (no circular-include risk the way driver.h's `void* driver_ctx`
* idiom is solving for) and is safer -- both for this task's own test suite
* and for main.c's call site -- than an opaque void* that every caller must
* blindly cast.
*/
typedef struct {
const char* replay_path; /* --sim FILE (required) */
uint64_t sim_start_ts; /* --sim-start-ts TIMESTAMP (optional;
* default SCHED_SIM_START_TS_LATEST) */
uint64_t sim_speed; /* --sim-speed N: 0=as-fast-as-possible,
* 1=real-time, N>1=N x real-time
* (optional; default 1) */
bool verify; /* --sim-verify (task 11.4) */
const char*
alt_events_path; /* --sim-alt-events FILE (optional, task 11.5) */
const char* output_path; /* --sim-output FILE (optional, task 11.5) */
bool interactive; /* --interactive / -i (task 12) */
} sched_sim_args_t;
/*
* sched_sim_driver_start - Req 5 AC 13's five-step startup sequence, steps
* 1-4 ONLY (step 5, "enter event loop", is task 11.2's job -- see
* sched_sim_driver_run() below for the full entry point design.md
* documents). Independently exported (unlike design.md's single
* `sched_sim_driver_run()`) so this task's own Observable -- "starting
* simulation against a valid fixture file reaches a ready-to-run state" --
* is directly, narrowly unit-testable without needing task 11.2's event
* loop to exist first. Matches this codebase's established pattern of
* splitting a driver's lifecycle into independently-testable pieces (e.g.
* driver.h's xce_sched_driver_translate_pending_events()/
* xce_sched_driver_on_decision()/xce_sched_driver_run_normal_mode_round()).
*
* Step 1 (Open and validate replay file): opens `args->replay_path`, reads
* the first capnp message, and validates it is a ReplayHeader BEFORE
* touching anything else. Also threads `ReplayHeader.schedPolicy` (if
* present) onto the freshly-initialized `sim->core.job_pick_plugin` -- the
* ONLY place in the whole `.replay` stream that carries the picker-policy
* name (SnapshotData has no such field); this is simply completing the
* decode this step already performs, not separate new work. Registering a
* picker PLUGIN under that name is explicitly NOT this function's job (see
* "Known scope boundary" below).
*
* Step 2 (Locate starting snapshot): the file's SECOND message (the first
* data message after ReplayHeader) MUST itself be a SnapshotEntry -- Req 5
* AC 20's stream-ordering rule and this task's own "Reject a replay file
* whose first data message isn't a snapshot entry" requirement. If it is
* not (including the case where the file simply ends right after the
* header), this function fails immediately, before scanning any further and
* before any ReplayEvent is ever decoded. Given that check passes, this
* function then scans every remaining message to EOF, decoding every
* SnapshotEntry's timestamp (cheaply, via SnapshotEntry_get_ts(), no
* allocation) and keeping the most recent one with `ts <=
* args->sim_start_ts` as the running best candidate (materialized fully
* only when it becomes the new best). If no SnapshotEntry anywhere in the
* file qualifies, the file is rejected (AC 13: "Without initial state,
* simulation has no starting point"). `sim->replay_file`'s read position is
* left immediately after the CHOSEN SnapshotEntry's message -- ready for
* task 11.2's event loop to resume sequential reads from exactly that point
* (every message before it, if any, is already reflected in the loaded
* snapshot's state and must never be replayed a second time).
*
* Step 3 (Load snapshot and rebuild picker): xce_sched_core_load_snapshot()
* (core.h, task 8.1) is called with the chosen candidate's decoded
* SnapshotData. That function ALREADY performs the picker-state rebuild
* walk (on_queue_update per queue, then on_job_push per pending job)
* internally as documented on its own core.h doc comment -- this function
* does not duplicate that walk.
*
* Step 4 (Set virtual clock): stamps `sim->core.virtual_clock_base` to the
* chosen snapshot's own timestamp, `sim->core.real_clock_base` to a fresh
* `clock_gettime(CLOCK_MONOTONIC)` wall-clock reading taken right here (Req
* 5 AC 13's own pseudocode explicitly performs this read from the DRIVER,
* not from core.c -- core.c's "zero wall-clock calls" isolation boundary,
* test_core_isolation.c, is scoped to core.c specifically, not to driver-
* level code that legitimately seeds the core's clock anchors), and
* `sim->core.speed_num`/`speed_den` to `args->sim_speed`/1. From this point,
* `xce_core_now(&sim->core)` returns virtual time computed from the
* snapshot's own logical timestamp -- this function's own Observable.
*
* Known scope boundary: this function does NOT register any picker plugin
* into `sim->core.plugins` (builtin_fcfs and friends are only registrable
* via plugin_register(), which requires a genuine `xce_scheduler_t*` --
* `xce_sched_sim_driver_t` deliberately has no such field, Req 5 AC 17 --
* and this codebase has no core.h-scoped plugin registration entry point).
* xce_sched_core_load_snapshot()'s own documented graceful degradation
* (WARN + skip the picker-rebuild hooks when no picker resolves) keeps this
* a non-fatal gap for THIS task's Observable, which only requires reaching
* a ready-to-run state with the correct virtual clock -- not a working
* picker (task 11.2, which actually drives scheduling rounds, is where a
* resolvable picker becomes load-bearing).
*
* Returns 0 on success (`*sim` is fully populated and ready). Returns a
* negative errno on failure; `*sim` is left in an indeterminate state, and
* IN THAT CASE ONLY, all resources this function itself opened (files,
* `sim->core`) have already been released before it returns -- the caller
* must not call anything else on `*sim` and must not fclose()/finalize it
* again itself. -EINVAL for NULL `sim`/`args`/`args->replay_path`; -ENOENT/
* -EACCES/etc. (errno passthrough) for an unopenable replay/alt-events/
* output file; -EIO for an empty/unreadable/malformed first capnp message;
* -EINVAL for a first message that is not ReplayHeader-shaped, or a first
* data message that is not SnapshotEntry-shaped, or a file with no
* SnapshotEntry anywhere satisfying `ts <= args->sim_start_ts`.
*/
extern int sched_sim_driver_start(xce_sched_sim_driver_t* sim,
const sched_sim_args_t* args);
/*
* sched_sim_driver_run - design.md's literal SimDriver Service Interface
* entry point (`int sched_sim_driver_run(xce_sched_sim_driver_t*, const
* void* args)`, adapted to take the concrete sched_sim_args_t* this header
* defines rather than an opaque void* -- see sched_sim_args_t's own doc
* comment for why). This is what main.c's mode-selection branch calls when
* `--sim` is present (design.md's own Integration note).
*
* Calls sched_sim_driver_start() above (task 11.1's own work, this task's
* Observable) then -- since task 11.2's event loop (Req 5 AC 13 step 5 /
* AC 14/18) does not exist yet -- fails loudly with -ENOSYS rather than
* silently returning success having processed zero events. This is the
* same "honest, temporary placeholder" precedent main.c's own pre-task-11.1
* `--sim` stub already established (task 5.2): startup is now genuinely
* real, working behavior; only the event loop remains a documented gap for
* task 11.2 to fill in, without requiring another main.c edit when it does
* (task 11.2 only needs to extend THIS function's body, after the
* sched_sim_driver_start() call, with the real event loop).
*
* Returns 0 (once task 11.2 lands) or a negative errno: whatever
* sched_sim_driver_start() returned on a startup failure, or -ENOSYS if
* startup succeeded but the event loop is not yet implemented.
*/
extern int sched_sim_driver_run(xce_sched_sim_driver_t* sim,
const sched_sim_args_t* args);
#endif