feat(scheduler-core): implement record start/stop lifecycle (task 9.2)

Adds recording-state fields directly to xce_scheduler_t (no new
xce_sched_driver_t type, per task 9.1's precedent). sched_record_start()/
sched_record_stop() implement AC 10: validate via task 9.1's path
validator, open with a TOCTOU-hardened sequence (O_NOFOLLOW, no O_TRUNC,
fstat()+st_nlink==1 check before ftruncate()), snapshot the core, write
ReplayHeader+SnapshotEntry on start, ReplayEvent{endSession} on stop.

driver.c gains boolean-gated recording hooks in
xce_sched_driver_translate_pending_events() (per-event-batch ReplayEvent
writes, new recording_events.c) and xce_sched_driver_run_normal_mode_round()
(per-session ReplaySession writes, new recording_session.c) -- zero I/O/
allocation when inactive.

Two real bugs found and fixed during implementation: bug-1498 (O_TRUNC
would have truncated a hard-linked victim file before the TOCTOU check
could reject it) and bug-1499 (a transient edit dropped
xce_core_signal_go(), causing a 3-way thread deadlock).

test_record_lifecycle.c decodes the full start->events->session->stop
cycle with the real capnp decoders and asserts the exact message
sequence; TOCTOU regression tests use real symlink(2)/link(2) syscalls.

25/25 scheduler suite, clang-format clean.
This commit is contained in:
Rongsong Shen 2026-08-05 09:07:51 +00:00
parent 4e5547ee55
commit ff8d4f416e
Signed by: rshen
SSH key fingerprint: SHA256:8Vf2pZ3h6rJa7EhGK+WaAQ9Mta/apZvteW6g9U/WKKk
15 changed files with 2110 additions and 36 deletions

View file

@ -90,6 +90,53 @@ int sched_publish_pend_reason(xce_scheduler_t* sched, xce_preason_set_t* r)
return 0;
}
/* Link-satisfying stubs for recording.c/recording_events.c/
* recording_session.c's real functions -- as of scheduler-core spec task
* 9.2, driver.c's (real, #included above)
* xce_sched_driver_translate_pending_events() calls sched_record_write_events()
* unconditionally (behind a NULL `record_ctx` check, not a call this test ever
* takes since every call site below passes NULL), and
* xce_sched_driver_run_normal_mode_round() (out of THIS file's scope, same as
* the core_session.c/plugin.c/ scheduler.c stubs above) calls the
* sched_record_session_* trio. This file never links recording.c's real
* implementations (they need xce_sched_core_save_snapshot(), snapshot.c, an
* even bigger dependency surface this file's own header comment already avoids
* pulling in for sched_publish_dispatch()/sched_publish_pend_reason()). Never
* actually invoked at runtime by any test in this file. */
int sched_record_write_events(xce_scheduler_t* sched, xce_core_event_t* events,
int n)
{
(void)sched;
(void)events;
(void)n;
return 0;
}
sched_record_session_ctx_t* sched_record_session_begin(xce_scheduler_t* sched,
uint64_t begin_round_ts)
{
(void)sched;
(void)begin_round_ts;
return NULL;
}
void sched_record_session_add_decision(sched_record_session_ctx_t* ctx,
uint64_t ts, xce_decision_t* decision)
{
(void)ctx;
(void)ts;
(void)decision;
}
int sched_record_session_end(xce_scheduler_t* sched,
sched_record_session_ctx_t* ctx, uint64_t end_ts)
{
(void)sched;
(void)ctx;
(void)end_ts;
return 0;
}
/* Link-satisfying stubs: as of scheduler-core spec task 5.2,
* xce_sched_driver_run_normal_mode_round() (driver.c, real, #included
* above, alongside xce_sched_driver_translate_pending_events() this file
@ -696,7 +743,7 @@ static void test_drain_translates_and_pushes_all_ten_op_types(void)
r10->u.config = config;
enqueue(&core, r10);
rc = xce_sched_driver_translate_pending_events(&core);
rc = xce_sched_driver_translate_pending_events(&core, NULL);
TEST_ASSERT_EQUAL_INT(10, rc);
@ -746,7 +793,7 @@ static void test_accumulates_between_drains_and_empty_drain_is_noop(void)
fixture_init(&core);
/* Nothing pending yet. */
rc = xce_sched_driver_translate_pending_events(&core);
rc = xce_sched_driver_translate_pending_events(&core, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
/* One record enqueued -- drained on the next call. Heap-allocated (see
@ -764,13 +811,13 @@ static void test_accumulates_between_drains_and_empty_drain_is_noop(void)
req->u.workerupdate.workers = workers;
enqueue(&core, req);
rc = xce_sched_driver_translate_pending_events(&core);
rc = xce_sched_driver_translate_pending_events(&core, NULL);
TEST_ASSERT_EQUAL_INT(1, rc);
TEST_ASSERT_NOT_NULL(sched_get_worker(&core, "hostB"));
}
/* Nothing new pending -- must not re-process anything. */
rc = xce_sched_driver_translate_pending_events(&core);
rc = xce_sched_driver_translate_pending_events(&core, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
}
@ -780,7 +827,7 @@ static void test_accumulates_between_drains_and_empty_drain_is_noop(void)
static void test_null_core_returns_einval(void)
{
int rc = xce_sched_driver_translate_pending_events(NULL);
int rc = xce_sched_driver_translate_pending_events(NULL, NULL);
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
}
@ -790,7 +837,7 @@ static void test_uninitialized_pend_updates_returns_einval(void)
int rc;
memset(&core, 0, sizeof(core));
rc = xce_sched_driver_translate_pending_events(&core);
rc = xce_sched_driver_translate_pending_events(&core, NULL);
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
}