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:
parent
4e5547ee55
commit
ff8d4f416e
15 changed files with 2110 additions and 36 deletions
|
|
@ -49,8 +49,26 @@
|
|||
* translation batch (rare, defensive path); or `xce_core_push_events()`'s
|
||||
* own negative return (e.g. -EBUSY if a session was unexpectedly active)
|
||||
* passed straight through.
|
||||
*
|
||||
* `record_ctx` (scheduler-core spec, task 9.2, Req 5 AC 10): an opaque
|
||||
* `void*`, cast back to `xce_scheduler_t*` inside driver.c, for the per-
|
||||
* event-batch recording-write hook -- same `void*`-instead-of-
|
||||
* `xce_scheduler_t*` idiom xce_sched_driver_on_decision()/
|
||||
* xce_sched_driver_run_normal_mode_round() below already use, and for the
|
||||
* same reason (this file's boundary comment: depends on core.h only).
|
||||
* Pass NULL to disable recording for this call (zero I/O, zero extra
|
||||
* allocation, Req 5 AC 10 "Performance") -- every existing caller that
|
||||
* does not care about recording does exactly this.
|
||||
*
|
||||
* Deliberately a SEPARATE parameter from `core->driver_ctx`
|
||||
* (xce_core_set_decision_cb()'s decision-callback context): that field is
|
||||
* only an `xce_scheduler_t*` in production wiring (main.c) -- some tests
|
||||
* register it as a different, smaller struct for their own decision-
|
||||
* callback spy, so this function must not assume it is safe to
|
||||
* reinterpret as `xce_scheduler_t*`.
|
||||
*/
|
||||
extern int xce_sched_driver_translate_pending_events(xce_sched_core_t* core);
|
||||
extern int xce_sched_driver_translate_pending_events(xce_sched_core_t* core,
|
||||
void* record_ctx);
|
||||
|
||||
/*
|
||||
* xce_sched_driver_on_decision - the normal-mode driver's decision-publish
|
||||
|
|
|
|||
|
|
@ -3,16 +3,21 @@
|
|||
#define _RECORDING_H_ 1
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "scheduler.h"
|
||||
|
||||
/*
|
||||
* recording.h - scheduler-core spec, task 9.1 (Req 5 AC 10, "Record file
|
||||
* path validation"). First piece of design.md's File Structure Plan
|
||||
* `recording.c` ("NEW (Phase 3): record start/stop/status, ReplayEvent/
|
||||
* ReplaySession writes") -- this task adds ONLY the validation function
|
||||
* every future write must pass through first; the actual record start/
|
||||
* stop/status lifecycle (design.md's NormalDriver service interface --
|
||||
* sched_driver_record_start()/_stop()/_status(), `xce_sched_driver_t`'s
|
||||
* recording-state fields) is task 9.2's job and is NOT implemented here.
|
||||
* recording.h - scheduler-core spec, tasks 9.1/9.2 (Req 5 AC 10, "Record
|
||||
* file path validation" and "Runtime recording control -- detailed
|
||||
* mechanism"). Task 9.1 added ONLY sched_validate_record_filename() below,
|
||||
* the validation function every write must pass through first. Task 9.2
|
||||
* (this revision) adds the actual record start/stop lifecycle
|
||||
* (sched_record_start()/sched_record_stop()) plus the recording-write hooks
|
||||
* driver.c's normal-mode loop calls into -- design.md's NormalDriver
|
||||
* service interface, adapted to this codebase's single combined
|
||||
* `xce_scheduler_t` driver-level struct (there is no separate
|
||||
* `xce_sched_driver_t` -- see scheduler.h's field comment on `recording`).
|
||||
*
|
||||
* sched_validate_record_filename - validates a caller-supplied, admin-
|
||||
* authenticated but UNTRUSTED record file name (controller.capnp's
|
||||
|
|
@ -113,19 +118,155 @@
|
|||
* rejection in step 1 returns -EINVAL without a single filesystem call,
|
||||
* even if `record_dir` itself does not exist.
|
||||
*
|
||||
* NOTE for task 9.2 (not implemented here -- this task is validation-only):
|
||||
* this function's containment/aliasing checks are only valid up to the
|
||||
* moment it returns -- a TOCTOU race could still swap a symlink or hard
|
||||
* link into `out_path` between this call returning and task 9.2's eventual
|
||||
* fopen(). When task 9.2 opens `out_path` for writing, it should (a) pass
|
||||
* O_NOFOLLOW so a symlink swapped in after validation is refused at
|
||||
* open() rather than followed, and (b) fstat() the resulting fd and
|
||||
* reject unless st_nlink == 1, since O_NOFOLLOW alone does not defend
|
||||
* against a hard link swapped in after validation (no symlink is involved
|
||||
* in that race, so O_NOFOLLOW never triggers).
|
||||
* TOCTOU note (closed by sched_record_start() below, task 9.2): this
|
||||
* function's containment/aliasing checks are only valid up to the moment it
|
||||
* returns -- a TOCTOU race could still swap a symlink or hard link into
|
||||
* `out_path` between this call returning and the eventual fopen()/open().
|
||||
* sched_record_start() closes that window by (a) passing O_NOFOLLOW so a
|
||||
* symlink swapped in after validation is refused at open() rather than
|
||||
* followed, and (b) fstat()-ing the resulting fd and rejecting unless
|
||||
* st_nlink == 1, since O_NOFOLLOW alone does not defend against a hard link
|
||||
* swapped in after validation (no symlink is involved in that race, so
|
||||
* O_NOFOLLOW never triggers).
|
||||
*/
|
||||
extern int sched_validate_record_filename(const char* record_dir,
|
||||
const char* filename, char* out_path,
|
||||
size_t out_path_len);
|
||||
|
||||
/*
|
||||
* sched_record_start - Req 5 AC 10 (scheduler-core spec, task 9.2,
|
||||
* "Recording lifecycle -- On record start"). Begins a new recording:
|
||||
*
|
||||
* 1. Resolves/validates `record_file` (a bare filename, or NULL/"" to
|
||||
* auto-generate one) via sched_validate_record_filename() above,
|
||||
* against `sched->record_dir`.
|
||||
* 2. Opens the resolved path with the TOCTOU-hardened sequence this
|
||||
* header's own doc comment describes (O_NOFOLLOW + fstat()+
|
||||
* st_nlink==1 check) -- closes the race window
|
||||
* sched_validate_record_filename() itself flags as still-open after
|
||||
* its own return.
|
||||
* 3. Takes a full core snapshot (xce_sched_core_save_snapshot(),
|
||||
* core.h) and writes `ReplayHeader` followed by the initial
|
||||
* `SnapshotEntry` as the first two messages of the file (Req 5 AC 20:
|
||||
* "the first data message after ReplayHeader MUST be a
|
||||
* SnapshotEntry").
|
||||
* 4. On success, sets `sched->recording = true`, stores the resolved
|
||||
* path in `sched->record_file_path` (strdup'd), resets
|
||||
* `sched->record_event_count = 0`, and stamps
|
||||
* `sched->record_start_ts = xce_core_now(&sched->core)`.
|
||||
*
|
||||
* Preconditions: fails with -EALREADY if `sched->recording` is already
|
||||
* true (design.md's NormalDriver contract) -- no file is touched. Fails
|
||||
* with -EINVAL if `sched` is NULL.
|
||||
*
|
||||
* Postconditions: on success, `ReplayHeader` + the initial `SnapshotEntry`
|
||||
* are durably on disk (fwrite completed) before this function returns.
|
||||
* On any failure after opening the file (write error, snapshot failure),
|
||||
* the partially-written file is closed and this function returns a
|
||||
* negative errno WITHOUT setting `sched->recording` -- the caller may
|
||||
* retry with a fresh sched_record_start() call. Never leaves `recording`
|
||||
* true with a NULL `record_file`, or vice versa (scheduler.h's own
|
||||
* invariant comment on `xce_scheduler_t.recording`).
|
||||
*
|
||||
* `record_file` may be NULL or empty to auto-generate a timestamped name
|
||||
* (`scheduler-replay-<UTC timestamp>.replay`) beneath `sched->record_dir`.
|
||||
*/
|
||||
extern int sched_record_start(xce_scheduler_t* sched, const char* record_file);
|
||||
|
||||
/*
|
||||
* sched_record_stop - Req 5 AC 10 (scheduler-core spec, task 9.2,
|
||||
* "Recording lifecycle -- On record stop"). Ends the current recording:
|
||||
* writes a `ReplayEvent{body=endSession}` message (the end-of-recording
|
||||
* marker -- there is no separate top-level footer message type, see
|
||||
* requirements.md Req 5 AC 20), then fflush()+fclose()s `sched->record_file`
|
||||
* and clears `sched->recording`/`sched->record_file`/`sched->record_file_path`
|
||||
* (freeing the latter) regardless of whether the marker write succeeded
|
||||
* (matches Req 5 AC 10's "Error handling" clause: a failed record write
|
||||
* disables recording rather than leaving it in a half-broken active state).
|
||||
*
|
||||
* Preconditions: fails with -EALREADY, no state touched, if
|
||||
* `sched->recording` is already false. Fails with -EINVAL if `sched` is
|
||||
* NULL.
|
||||
*
|
||||
* Returns 0 on a clean stop; a negative errno if the end-of-recording
|
||||
* marker failed to write (state is still fully cleared either way, per the
|
||||
* error-handling clause above -- the negative return is diagnostic only,
|
||||
* not a signal that recording is still active).
|
||||
*/
|
||||
extern int sched_record_stop(xce_scheduler_t* sched);
|
||||
|
||||
/*
|
||||
* sched_record_write_events - the per-event-batch recording-write hook
|
||||
* (Req 5 AC 10, "On each core_push_events() call ... For each event in the
|
||||
* batch, serialize and write the corresponding ReplayEvent message").
|
||||
* Called from xce_sched_driver_translate_pending_events() (driver.c) once
|
||||
* per drained batch, immediately after xce_core_push_events() and BEFORE
|
||||
* that function's own cleanup loop frees any translated payload (so every
|
||||
* pointer inside `events` is still guaranteed live).
|
||||
*
|
||||
* Boolean-gated: a NULL `sched`, a NULL/empty `events`, or
|
||||
* `sched->recording == false` is a zero-cost no-op (Req 5 AC 10,
|
||||
* "Performance"). On a write failure mid-batch, recording is disabled
|
||||
* (mirrors sched_record_stop()'s cleanup, minus the end-of-recording
|
||||
* marker -- the file is presumably broken already) and a negative errno is
|
||||
* returned; the caller (driver.c) does not need to react further -- normal
|
||||
* scheduling continues either way (Req 5 AC 10, "Error handling").
|
||||
*/
|
||||
extern int sched_record_write_events(xce_scheduler_t* sched,
|
||||
xce_core_event_t* events, int n);
|
||||
|
||||
/*
|
||||
* sched_record_session_ctx_t - opaque per-round accumulator for the
|
||||
* decisions a recording session collects between
|
||||
* sched_record_session_begin()/sched_record_session_end() (Req 5 AC 10,
|
||||
* "On core_go(): begin building a ReplaySession message" /
|
||||
* "In the decision drain loop: append a ReplayDecision" /
|
||||
* "On core_end_session(): finalize ... write the complete message").
|
||||
* Defined privately in recording.c; driver.c only ever holds a pointer.
|
||||
*/
|
||||
typedef struct sched_record_session_ctx_s sched_record_session_ctx_t;
|
||||
|
||||
/*
|
||||
* sched_record_session_begin - starts accumulating a ReplaySession for one
|
||||
* normal-mode round. Returns NULL (zero allocation) when `sched` is NULL,
|
||||
* `sched->recording` is false, or `sched->record_file` is NULL -- the
|
||||
* caller (xce_sched_driver_run_normal_mode_round(), driver.c) then simply
|
||||
* never calls sched_record_session_add_decision()/sched_record_session_end(),
|
||||
* satisfying Req 5 AC 10's "zero extra allocation when recording == false".
|
||||
* On success, returns a heap-allocated context stamped with
|
||||
* `begin_round_ts` (the ReplaySession.beginRound field).
|
||||
*/
|
||||
extern sched_record_session_ctx_t*
|
||||
sched_record_session_begin(xce_scheduler_t* sched, uint64_t begin_round_ts);
|
||||
|
||||
/*
|
||||
* sched_record_session_add_decision - appends one drained decision to
|
||||
* `ctx`'s in-progress ReplaySession.decisions list, tagged with `ts`. A
|
||||
* NULL `ctx` (recording inactive -- see sched_record_session_begin() above)
|
||||
* or NULL `decision` is a no-op. XCE_DECISION_PREEMPT is not yet supported
|
||||
* (mirrors driver.c's xce_sched_driver_on_decision() -- no live allocator
|
||||
* path produces one, and this is therefore currently unreachable, but is
|
||||
* handled loudly (ERROR log) rather than silently dropped or crashing) --
|
||||
* see driver.c for the shared rationale. XCE_DECISION_NONE/
|
||||
* XCE_DECISION_NEW_SESSION are likewise skipped (nothing to record).
|
||||
*/
|
||||
extern void sched_record_session_add_decision(sched_record_session_ctx_t* ctx,
|
||||
uint64_t ts,
|
||||
xce_decision_t* decision);
|
||||
|
||||
/*
|
||||
* sched_record_session_end - finalizes and writes the accumulated
|
||||
* ReplaySession message (begin_round + decisions + a SessionSummary
|
||||
* computed from `end_ts` and the tallies sched_record_session_add_decision()
|
||||
* collected) to `sched->record_file`, then frees `ctx` and everything it
|
||||
* owns. A NULL `ctx` is a no-op (0 is returned; nothing to free). On a
|
||||
* write failure, recording is disabled the same way
|
||||
* sched_record_write_events() does. Always frees `ctx` before returning,
|
||||
* even on failure or when `sched`'s recording state changed out from under
|
||||
* the caller between sched_record_session_begin() and this call.
|
||||
*/
|
||||
extern int sched_record_session_end(xce_scheduler_t* sched,
|
||||
sched_record_session_ctx_t* ctx,
|
||||
uint64_t end_ts);
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
#include <pthread.h>
|
||||
#include <rtti.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "client.h"
|
||||
|
|
@ -117,6 +119,37 @@ typedef struct {
|
|||
* xce_sched_driver_t (record_file_path/record_file/recording/
|
||||
* record_event_count/...) is task 9.2's job, not this field's. */
|
||||
char* record_dir;
|
||||
/* Req 5 AC 10 (scheduler-core spec, task 9.2): the recording lifecycle
|
||||
* state design.md sketches on a future xce_sched_driver_t -- per
|
||||
* record_dir's own comment above, this codebase keeps a single combined
|
||||
* driver-level struct (xce_scheduler_t), so these fields land here
|
||||
* instead of a new type. Owned exclusively by recording.c's
|
||||
* sched_record_start()/sched_record_stop() (recording.h) -- see those
|
||||
* functions' doc comments for the full lifecycle contract (path
|
||||
* validation + TOCTOU-hardened open on start, end-of-recording marker +
|
||||
* flush/close on stop). driver.c's normal-mode loop (driver.h) reads
|
||||
* `recording` to boolean-gate its per-event-batch/per-session recording
|
||||
* writes -- see xce_sched_driver_translate_pending_events()'s and
|
||||
* xce_sched_driver_run_normal_mode_round()'s own comments.
|
||||
*
|
||||
* Invariant: recording == true iff record_file != NULL. */
|
||||
bool recording;
|
||||
/* strdup'd copy of the resolved record file path; owned by the record
|
||||
* file lifecycle (strdup on start, free+NULL on stop or start/write
|
||||
* failure) -- lets a future status query (task 9.4, out of this task's
|
||||
* scope) answer with the current path even after record_file closes. */
|
||||
char* record_file_path;
|
||||
/* replay file handle; NULL when not recording. */
|
||||
FILE* record_file;
|
||||
/* count of ReplayEvent messages written since recording started (used
|
||||
* by task 9.3's periodic-checkpoint trigger; incremented by
|
||||
* recording.c's event-batch writer, task 9.2). */
|
||||
uint64_t record_event_count;
|
||||
/* N events between periodic snapshot checkpoints (0 = disabled).
|
||||
* Reserved for task 9.3 -- inert (never read) as of task 9.2. */
|
||||
uint64_t record_snapshot_interval;
|
||||
/* logical time (xce_core_now()) recording started; 0 = not recording. */
|
||||
uint64_t record_start_ts;
|
||||
} xce_scheduler_t;
|
||||
|
||||
extern int sched_init(xce_scheduler_t*, xce_client_config_t*,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue