feat(scheduler-core): implement REPL execution control (task 12.1)

Adds the interactive simulation REPL's execution-control slice (Req 5
AC 22): a single-threaded input loop (readline-based) supporting
step [N], continue, quit, and help, pausing before the first replay
event and blocking the driver loop while active.

Review round 1 raised two findings, both resolved here:

- GNU readline (GPL) was linked directly into xce_scheduler, a shipped
  production daemon. Switched the whole project to libedit (BSD) for
  line editing -- scheduler and controller (xce_test) both now use
  editline/readline.h, API-compatible (readline()/add_history()).

- Getting a clean regression run after the libedit swap required a
  full clean rebuild, which exposed a genuine pre-existing SIGSEGV
  (bug-1504): SnapshotData.internalState was a capnp Data field, and
  this codebase's capnpc-c backend silently no-ops Data-field
  encoding without zero-initializing the caller's stack struct,
  leaving an uninitialized capn_ptr written onto the wire. Fixed by
  switching internalState to List(UInt8) (with a new internalStateLen
  wire field), matching the working FetchFileReply.content/bytesRead
  precedent -- no hand-written accessor code needed.

A second, unrelated instance of the same bug class was found in the
controller's WAL-catchup path (WalCatchupEntry.payload) and
deliberately left unfixed as bug-1505, out of scheduler-core's
boundary; needs its own project-wide Data-field audit.

Scheduler suite: 34/34 clean. RED-phase reproduction recorded in
tasks.md (repl_should_pause() step-budget check disabled -> 2/4
tests fail as expected -> reverted -> 34/34 green).

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 08:17:35 +00:00
parent de00ccff37
commit 02f7f48ff9
Signed by: rshen
SSH key fingerprint: SHA256:8Vf2pZ3h6rJa7EhGK+WaAQ9Mta/apZvteW6g9U/WKKk
17 changed files with 1491 additions and 81 deletions

View file

@ -56,9 +56,12 @@ controller = executable('xce_controller',
xce_test_src = [ 'testsuite/test.c', 'testsuite/waldump.c', 'testsuite/walcompare.c', 'testsuite/snapverify.c', 'testsuite/snapdiff.c', 'testsuite/configcompare.c']
readline_dep = dependency('readline')
ncurses_dep = dependency('ncurses')
history_dep = dependency('history')
# BSD-licensed readline replacement (scheduler-core spec, task 12.1 review
# finding): GNU readline is GPL; xce_test's REPL only needs readline()/
# add_history(), both provided by libedit's editline/readline.h emulation
# header under a permissive license. Single dependency, no separate
# ncurses/history split (libedit's own .pc pulls ncurses in privately).
libedit_dep = dependency('libedit')
ssl_dep = dependency('openssl') # also needed by configcompare.c (SHA256());
# controller_unit_test_deps below re-assigns
# the same dependency() result, harmless
@ -67,9 +70,7 @@ xce_test_deps = [
libxce_dep,
librtti_dep,
struct2json_dep,
readline_dep,
history_dep,
ncurses_dep,
libedit_dep,
tinylog_dep,
zlib_dep, # waldump: crc32 WAL validation
c_capnproto_dep, # waldump: decode JOB_SUBMIT jobid

View file

@ -10,8 +10,9 @@
#include <time.h>
#include <unistd.h>
#include <readline/history.h>
#include <readline/readline.h>
#include <editline/readline.h> /* libedit (BSD) -- provides readline()/
* add_history(), same as GNU readline's
* split readline.h/history.h pair */
#include <cjson/cJSON.h>
#include <cjson/s2j.h>

View file

@ -1080,14 +1080,16 @@ static void test_replay_decision_dispatch_union_round_trip(void)
/* non-trivial data (including >=1 entry in each of the top-level */
/* List(...) fields: queues/users/projects) and round-trips them */
/* field-for-field through encode_SnapshotData_ptr/decode_SnapshotData */
/* -- EXCEPT internalState (a Data field), whose generated codec is a */
/* documented no-op (same limitation as every other Data field in this */
/* schema, e.g. ReplayHeader.configHash in Test 7) -- verified instead */
/* via the low-level SnapshotData_get/set_internalState accessor on the */
/* SAME struct pointer the codec bridge already populated, same hybrid */
/* technique used nowhere else in this file but safe because encode_/ */
/* set_ operate on independent field slots of one already-allocated */
/* capnp struct. */
/* -- INCLUDING internalState, which is List(UInt8) (task 12.1 review */
/* finding: was Data, whose generated codec is a silent no-op in this */
/* codebase's capnpc-c setup and left write_SnapshotData() writing an */
/* uninitialized stack capn_ptr onto the wire -- a real, reproducible */
/* crash; see controller.capnp's SnapshotData comment for the full */
/* story). List(UInt8) gets a real generated encode_/decode_ (same */
/* precedent as FetchFileReply.content/bytesRead), so internal_state/ */
/* internal_state_len round-trip through the ordinary codec bridge */
/* like every other field here -- no low-level accessor workaround */
/* needed any more. */
/* */
/* Also demonstrates this task's internalVersion Observable: "a */
/* snapshot encoded with one internal-state version and decoded */
@ -1228,14 +1230,6 @@ static void test_snapshot_data_round_trip(void)
SnapshotData_ptr p;
encode_SnapshotData_ptr(cs, &p, &src);
/* internalState (Data field): codec no-op, set via low-level accessor
* on the same already-allocated struct pointer (see comment above). */
capn_list8 state_list = capn_new_list8(cs, (int)sizeof(internal_bytes));
capn_setv8(state_list, 0, internal_bytes, sizeof(internal_bytes));
capn_data state_data;
state_data.p = state_list.p;
SnapshotData_set_internalState(p, state_data);
capn_setp(capn_root(&c), 0, p.p);
int64_t sz = capn_size(&c);
TEST_ASSERT_TRUE(sz > 0);
@ -1316,16 +1310,13 @@ static void test_snapshot_data_round_trip(void)
TEST_ASSERT_EQUAL_UINT64(123456789ULL, dst.prng_seed);
TEST_ASSERT_EQUAL_UINT32(2u, dst.internal_version);
/* internalState -- low-level accessor read-back (Data field, codec
* no-op; see comment above). */
capn_data out_state = SnapshotData_get_internalState(dp);
TEST_ASSERT_EQUAL_INT((int)sizeof(internal_bytes),
capn_len((capn_list8){ out_state.p }));
uint8_t out_state_bytes[sizeof(internal_bytes)];
TEST_ASSERT_EQUAL_INT((int)sizeof(internal_bytes),
capn_getv8((capn_list8){ out_state.p }, 0,
out_state_bytes, sizeof(internal_bytes)));
TEST_ASSERT_EQUAL_MEMORY(internal_bytes, out_state_bytes, sizeof(internal_bytes));
/* internalState -- ordinary codec-bridge read-back (List(UInt8), real
* generated decode; see comment above). */
TEST_ASSERT_EQUAL_UINT32((uint32_t)sizeof(internal_bytes),
dst.internal_state_len);
TEST_ASSERT_NOT_NULL(dst.internal_state);
TEST_ASSERT_EQUAL_MEMORY(internal_bytes, dst.internal_state,
sizeof(internal_bytes));
/* Observable: version-mismatch rejection. A reader that expects the
* SAME internal_version accepts; a reader that expects a DIFFERENT