feat(scheduler-core): implement scheduler-side admin request handler (task 9.4)
Implements the scheduler's first request-reply opcode (OPCode_schedAdmin). sched_handle_admin_request() (handler.c) decodes SchedAdminRequest and dispatches by action: recordStatus answers synchronously (pure read); recordStart/recordStop are deferred onto the existing pend_updates queue (XCE_SCHED_OP_ADMIN_RECORD) since sched_record_start()'s snapshot call requires the session_active==0 precondition only guaranteed on the driver thread, not the async request-handling thread. driver.c's xce_sched_driver_translate_pending_events() performs the deferred action and sends the reply via a new xcecli_reply_schedadmin() (client.c/client.h), correlated by seq copied before the original request is freed. Adversarial review found a genuine cross-thread race: sched_record_status() read driver-owned recording state (including a pointer the driver thread frees) from the vtask thread with no lock -- the codebase's first such race, missed by ASan (doesn't detect races). Fixed with a dedicated record_lock scoped strictly to in-memory field access, never spanning file I/O; sched_record_status() now strdup()s under the lock instead of returning a borrowed live pointer. Verified with a real two-thread lock-contention test (independently confirmed non-vacuous by removing the lock and observing the test fail) plus a deterministic use-after-free reproduction. 26/26 scheduler suite, clang-format clean.
This commit is contained in:
parent
7494542a60
commit
e819a50600
19 changed files with 1761 additions and 115 deletions
|
|
@ -170,6 +170,12 @@ extern int sched_validate_record_filename(const char* record_dir,
|
|||
*
|
||||
* `record_file` may be NULL or empty to auto-generate a timestamped name
|
||||
* (`scheduler-replay-<UTC timestamp>.replay`) beneath `sched->record_dir`.
|
||||
*
|
||||
* Concurrency (task 9.4 fix round): step 4's field commit takes
|
||||
* `sched->record_lock` (scheduler.h's field comments) -- see
|
||||
* sched_record_status()'s doc comment below for the cross-thread reader
|
||||
* this protects against. The lock is held only around the five assignments
|
||||
* themselves, never across steps 1-3's file I/O.
|
||||
*/
|
||||
extern int sched_record_start(xce_scheduler_t* sched, const char* record_file);
|
||||
|
||||
|
|
@ -192,6 +198,13 @@ extern int sched_record_start(xce_scheduler_t* sched, const char* record_file);
|
|||
* 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).
|
||||
*
|
||||
* Concurrency (task 9.4 fix round): the fflush()+fclose()+state-clear
|
||||
* sequence above takes `sched->record_lock` (scheduler.h's field
|
||||
* comments) around the state-clear portion only -- see
|
||||
* sched_record_status()'s doc comment below for the cross-thread reader
|
||||
* this protects against. In particular, freeing `record_file_path` and
|
||||
* setting it NULL happens atomically from that reader's point of view.
|
||||
*/
|
||||
extern int sched_record_stop(xce_scheduler_t* sched);
|
||||
|
||||
|
|
@ -225,6 +238,13 @@ extern int sched_record_stop(xce_scheduler_t* sched);
|
|||
* rationale. A checkpoint write failure disables recording via
|
||||
* sched_record_abort_on_error(), the exact same path as any other
|
||||
* recording write failure -- never special-cased.
|
||||
*
|
||||
* Concurrency (task 9.4 fix round): the per-event increment of
|
||||
* `sched->record_event_count` takes `sched->record_lock` (scheduler.h's
|
||||
* field comments), scoped to just the increment -- see
|
||||
* sched_record_status()'s doc comment below for the cross-thread reader
|
||||
* this protects against. `record_snapshot_interval` is driver-thread-only
|
||||
* and not protected by this lock (scheduler.h's field comment).
|
||||
*/
|
||||
extern int sched_record_write_events(xce_scheduler_t* sched,
|
||||
xce_core_event_t* events, int n);
|
||||
|
|
@ -283,4 +303,48 @@ extern int sched_record_session_end(xce_scheduler_t* sched,
|
|||
sched_record_session_ctx_t* ctx,
|
||||
uint64_t end_ts);
|
||||
|
||||
/*
|
||||
* sched_record_status - Req 5 AC 10 (scheduler-core spec, task 9.4,
|
||||
* "Reply field semantics by action" -- the `recordStatus` column). A pure
|
||||
* read of `sched`'s CURRENT recording-state fields into a
|
||||
* `sched_admin_reply_t` the caller (handler.c's scheduler_req_handler())
|
||||
* sends back over the wire immediately -- no file I/O, no state mutation,
|
||||
* and (unlike sched_record_start()/sched_record_stop()) no session_active
|
||||
* dependency, so it is safe to answer directly from the request-handling
|
||||
* thread rather than deferring through the driver-thread pend_updates
|
||||
* queue the way recordStart/recordStop must.
|
||||
*
|
||||
* Concurrency (task 9.4 fix round): this function is called from a vtask
|
||||
* worker pthread (handler.c's scheduler_req_handler(), invoked with no
|
||||
* lock held -- lib/xcectx.c), which runs concurrently with the
|
||||
* single-threaded driver loop's sched_record_start()/sched_record_stop()/
|
||||
* sched_record_write_events()/sched_record_abort_on_error() -- all of
|
||||
* which mutate the same fields this function reads, with no other
|
||||
* synchronization. This function therefore takes `sched->record_lock`
|
||||
* (scheduler.h's field comments) around every read of those fields,
|
||||
* INCLUDING the copy of `record_file_path` described next.
|
||||
*
|
||||
* `reply->file` is a fresh, caller-owned heap copy of
|
||||
* `sched->record_file_path` (via strdup(), taken while still holding
|
||||
* `sched->record_lock` -- copying the bytes before the lock is released is
|
||||
* what makes this safe), NOT a borrowed alias of the live pointer. Unlike
|
||||
* every other capnp encode call in this codebase, this reply crosses a
|
||||
* function-return boundary before it is ever encoded, so a borrowed
|
||||
* pointer into `sched`'s live, mutable state cannot be handed back safely
|
||||
* -- the driver thread could free() it the instant `record_lock` is
|
||||
* released, well before the caller's eventual xcecli_reply_schedadmin()
|
||||
* call. The caller MUST free(reply->file) once it is done (NULL when not
|
||||
* recording -- nothing to free).
|
||||
*
|
||||
* Returns 0 on success, populating every field of `*reply`
|
||||
* (`reply->ok = true` unconditionally). Returns -EINVAL if `sched` or
|
||||
* `reply` is NULL; `*reply` is left untouched in that case. Returns
|
||||
* -ENOMEM if the internal strdup() of `record_file_path` fails; `*reply`
|
||||
* is left zeroed (via the initial memset()) in that case -- this is the
|
||||
* one way reading status can now itself fail, a direct consequence of no
|
||||
* longer handing back a borrowed pointer.
|
||||
*/
|
||||
extern int sched_record_status(xce_scheduler_t* sched,
|
||||
sched_admin_reply_t* reply);
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -39,7 +39,22 @@ typedef enum {
|
|||
/* Notify-only single-job in-place field update. Distinct from
|
||||
* XCE_SCHED_OP_UPDATE_JOB (the dataset bulk-create path) so a notify
|
||||
* update to an existing job mutates it in place and never creates a job. */
|
||||
XCE_SCHED_OP_UPDATE_JOB_FIELDS
|
||||
XCE_SCHED_OP_UPDATE_JOB_FIELDS,
|
||||
/* Scheduler admin recording control (scheduler-core spec, task 9.4, Req
|
||||
* 5 AC 10): a `recordStart`/`recordStop` SchedAdminRequest, deferred
|
||||
* from the request-handling thread (handler.c's scheduler_req_handler())
|
||||
* to the driver thread. sched_record_start()/sched_record_stop() do
|
||||
* real file I/O and sched_record_start() calls
|
||||
* xce_sched_core_save_snapshot(), which requires the core's
|
||||
* session_active precondition (task 8.2) -- a guarantee only held on
|
||||
* the single-threaded driver loop (main.c), not the async request-
|
||||
* handling thread. See xce_sched_update_request_t's `adminrecord`
|
||||
* member below and driver.c's xce_sched_driver_translate_pending_events()
|
||||
* for the full deferred-dispatch + deferred-reply design.
|
||||
* `recordStatus` is a pure read of already-current struct fields (no
|
||||
* file I/O, no session_active dependency) and is answered synchronously
|
||||
* from the handler thread instead -- it never reaches this queue. */
|
||||
XCE_SCHED_OP_ADMIN_RECORD
|
||||
} xce_sched_op_t;
|
||||
|
||||
typedef struct {
|
||||
|
|
@ -79,6 +94,27 @@ typedef struct {
|
|||
struct {
|
||||
xce_job_rusages_t* rusages;
|
||||
} job_rusage;
|
||||
struct {
|
||||
/* SchedAdminAction_recordStart or SchedAdminAction_recordStop
|
||||
* (controller.capnp.h) -- recordStatus never reaches this
|
||||
* queue (answered synchronously, see xce_sched_op_t's own
|
||||
* comment on XCE_SCHED_OP_ADMIN_RECORD above). */
|
||||
int action;
|
||||
/* strdup'd copy of SchedAdminRequest.recordFile for
|
||||
* recordStart (NULL for auto-generate, and always NULL for
|
||||
* recordStop, which ignores it) -- the original xce_request_t
|
||||
* is freed by the handler-thread caller (xcectx.c's
|
||||
* vtask_entry()) immediately after scheduler_req_handler()
|
||||
* returns, so this must be an independent, owned copy for the
|
||||
* driver thread to read later. Freed by the deferred
|
||||
* dispatcher (driver.c) once the action completes. */
|
||||
char* record_file;
|
||||
/* The original request's seq -- the only field of the
|
||||
* now-freed xce_request_t the deferred reply needs
|
||||
* (init_reply_by_req()'s other field, opcode, is always
|
||||
* OPCode_schedAdmin here, so it is not worth storing). */
|
||||
uint64_t seq;
|
||||
} adminrecord;
|
||||
} u;
|
||||
} xce_sched_update_request_t;
|
||||
|
||||
|
|
@ -132,27 +168,63 @@ typedef struct {
|
|||
* 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. */
|
||||
* Invariant: recording == true iff record_file != NULL.
|
||||
*
|
||||
* Concurrency (scheduler-core spec, task 9.4 fix round): `recording`,
|
||||
* `record_file_path`, `record_file`, `record_event_count`, and
|
||||
* `record_start_ts` are written on the single-threaded driver loop
|
||||
* (main.c) but ALSO read by sched_record_status() (recording.c), which
|
||||
* runs on whichever vtask worker pthread handled the synchronous
|
||||
* `recordStatus` SchedAdminRequest (handler.c's
|
||||
* scheduler_req_handler(), invoked with no lock held --
|
||||
* lib/xcectx.c). That makes this a genuine cross-thread reader/writer
|
||||
* pair with no other synchronization, so every read or write of these
|
||||
* five fields MUST hold `record_lock` below -- see its own comment. */
|
||||
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. */
|
||||
* failure) -- lets a status query (task 9.4) answer with the current
|
||||
* path even after record_file closes. Protected by `record_lock` (see
|
||||
* `recording`'s comment above and `record_lock`'s own comment below) --
|
||||
* sched_record_status() must copy this string's bytes (e.g. strdup())
|
||||
* while still holding the lock rather than handing back the live
|
||||
* pointer, since the driver thread may free() it the instant the lock
|
||||
* is released. */
|
||||
char* record_file_path;
|
||||
/* replay file handle; NULL when not recording. */
|
||||
/* replay file handle; NULL when not recording. Protected by
|
||||
* `record_lock` for the same reason as `record_file_path` above. */
|
||||
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). */
|
||||
* recording.c's event-batch writer, task 9.2). Protected by
|
||||
* `record_lock`. */
|
||||
uint64_t record_event_count;
|
||||
/* N events between periodic snapshot checkpoints (0 = disabled). Read
|
||||
* by recording_events.c's sched_record_write_events() (task 9.3):
|
||||
* every time cumulative `record_event_count` lands exactly on a
|
||||
* multiple of this value, a fresh SnapshotEntry is appended to the
|
||||
* record file (Req 5 AC 11). */
|
||||
* record file (Req 5 AC 11). Driver-thread-only (never read by
|
||||
* sched_record_status()) -- not protected by `record_lock`. */
|
||||
uint64_t record_snapshot_interval;
|
||||
/* logical time (xce_core_now()) recording started; 0 = not recording. */
|
||||
/* logical time (xce_core_now()) recording started; 0 = not recording.
|
||||
* Protected by `record_lock`. */
|
||||
uint64_t record_start_ts;
|
||||
/* Dedicated mutex (scheduler-core spec, task 9.4 fix round) guarding
|
||||
* `recording`/`record_file_path`/`record_file`/`record_event_count`/
|
||||
* `record_start_ts` -- see `recording`'s comment above for the
|
||||
* cross-thread reader/writer hazard this closes. Deliberately a NEW,
|
||||
* separate lock rather than reusing `core.lock`: `core`
|
||||
* (xce_sched_core_t) is the conceptually distinct core-vs-driver piece
|
||||
* of state this struct's own header comment describes, and these
|
||||
* recording fields are driver-level (like `record_dir`/`apictx`/
|
||||
* `cluster_cfg` above), so widening core.lock's scope to also cover
|
||||
* them would blur that boundary for no benefit. Held only around the
|
||||
* struct-field reads/writes themselves -- never across the actual file
|
||||
* I/O (fopen()/fwrite()/capn_write_mem() etc.) -- matching this
|
||||
* codebase's low-contention-lock-scope convention. Initialized by
|
||||
* sched_init(), destroyed by sched_fini() (scheduler.c) -- same
|
||||
* lifecycle pairing as `core.lock` (core.c). */
|
||||
mylock_t record_lock;
|
||||
} 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