info limit was correctly implemented and tested but inert against a real replay: register_limit()'s only call site was normal-mode sched_register_ plugins(); sim mode's own plugin bootstrap (sim_bootstrap_picker()) never registered the limit plugin. The snapshot format already carries the original session's limit config (sim->core.config, a borrowed reference set by xce_sched_core_load_snapshot()) -- nothing needed to change in the recording/replay format. New sim_bootstrap_limit_plugin() (sim_limit.c, mirroring sim_picker.c's structure) registers the plugin via plugin_register_core() and seeds it via a new limit_seed_config(plugin, xce_sched_core_t* core, config) entry point, wired into sim_loop.c alongside the existing sim_bootstrap_picker() call. limit_new_plugin() extracted from register_limit() as a factory, mirroring builtin_fcfs_new_plugin()'s established pattern; register_limit() itself is behavior-preserving. First attempt set plugin->sched to a disguised xce_sched_sim_driver_t*, relying on it sharing xce_scheduler_t's first-member layout to reuse the existing config_hook path -- rejected on review: this repeats the exact cross-cast pattern that already corrupted memory once in this codebase (see core.h's xce_core_cache_clear_cb_t history). Fixed by making limit_seed_config() take xce_sched_core_t* as an honest parameter instead, with limit_update_config() (normal mode) and the new sim path both delegating to a shared limit_seed_config_impl() -- no cast, no aliasing risk, independently verified by two review rounds reading the actual struct layouts and call chains. Also added sim_limit.c to sched_sim_isolation_lib so Req 5 AC 17's controller-I/O-isolation check covers it (an initial gap, closed in the same remediation round). Two ripple waves of the same established link-stub pattern applied to the ~24 white-box test files that #include sim_loop.c. info limit now shows real configured-limit data against a genuine replay. Live usage will still correctly show "no active usage instance yet" until a separate, larger fix (logged as bug-1826) wires limit *matching* into the sim decision path -- deliberately out of this task's scope. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RLSNJhNoULvVcUhXEFafQC
15 KiB
Implementation Plan: scheduler-sim-diagnostics (Requirement Set 1 — Limit Inspection)
-
1. Add read-only accessors exposing the limit plugin's configured-limits and live-usage-instances tables
- Two new functions (
limit_get_configured_limits(),limit_get_usage_instances()) returning raw kvmgr handles (orNULLwhen the plugin is absent), declared inplugin.halongsideregister_limit(), defined inplugins/limit.c - Never allocate, mutate, or trigger plugin hooks — a pure read of already-existing pointers
- New/extended unit tests in
test_limit_plugin.ccover: plugin registered with configured limits (handle returned, iterable), plugin registered with zero limits, plugin not registered (NULL) - Observable: a test that registers the limit plugin with a known limit and confirms the accessor's returned handle, when iterated via
iter_make()/iter_next(), yields that same limit; a sibling test confirmsNULLwhen the plugin was never registered - Requirements: 1.1, 1.2, 1.4, 2.1, 3.1
- Boundary: Limit Plugin Accessor
- Two new functions (
-
6. Bootstrap the limit plugin in sim mode so
info limitshows real data against a replay (fixesbug-1825)- Extract
limit_new_plugin()factory out ofregister_limit()(plugins/limit.c), mirroringbuiltin_fcfs_new_plugin()/register_builtin_fcfs()'s existing factory-extraction pattern exactly;register_limit()itself must be behaviorally unchanged (pure refactor) - New
sim_bootstrap_limit_plugin(xce_sched_sim_driver_t* sim)(new filesim_limit.c, mirroringsim_picker.c's structure/header-comment convention):limit_new_plugin()→plugin_register_core(&(sim->core), plugin)→ ifsim->core.config != NULL, callplugin->hooks.config_hook(plugin, sim->core.config)directly (NOT the type-unsafeplugin_config_update(), which casts itsvoid*arg toxce_scheduler_t*and would misreadxce_sched_sim_driver_t's embeddedcorefield) - Wire the call into
sim_loop.cright alongside the existingsim_bootstrap_picker(sim)call site — by that pointsim->core.configalready holds the snapshot's borrowed-reference config (set duringsched_sim_driver_start()'s snapshot load, which runs first) - Observable: a real
xce_sched_sim_driver_tdriven throughsched_sim_driver_start()against a replay whose snapshot carries a realxce_limit_config_t, after the sim driver's own bootstrap sequence runs, haslimit_get_configured_limits()return a handle that iterates to that real limit — no test-only registration helper needed - Requirements: 1.1–1.7 (makes them exercisable against a real replay, not just via a test-only fixture helper)
- Boundary: Limit Plugin Accessor (factory extraction), new Sim Limit Bootstrap component
- Known deliberate non-goal, tracked separately: this task does NOT wire limit matching (
limit_job_election()) into the sim/replay decision path — that function is only ever reached viaplugin_job_election(), which is normal-mode-only (one caller,scheduler.c) and itself needs a core-safe variant before it could be called fromsim_loop.c. Live usage (Requirements 2.x/3.x) will still show "no active usage instance yet" against a real replay even after this task — only the configured-setting display (1.1-1.7) becomes real. Tracked as a separate follow-up bug, not bundled into this task.
- Extract
-
2. Core: Limit lookup and configured-setting display
-
2.1 Implement name/all resolution with correct absence messaging
- New white-box test file
test_repl_limit_table.ccreated (sibling oftest_repl_resource_table.c, own#includeset coveringplugins/limit.candresource.cfor real — not stubbed — perdesign.md's build-wiring risk note citing bug-1762's precedent) repl_info_limit(sim, arg):arg == "all"iterateslimit_get_configured_limits();NULL/empty prints "no limits configured" (Requirement 1.7);arg != "all"does akey_find()by name (not a scan) on the same handle, printing "no such limit ''" (Requirement 1.3) for aNULLhandle or a miss — aNULLhandle on this path is never treated as "no limits configured," matching that message's ownall-only scoping- Wired as a direct call for now (
repl_dispatch()/repl_cmd_info()wiring itself deferred to task 4); tests in this task callrepl_info_limit()directly, not throughrepl_dispatch() - Observable: REPL output shows "no such limit 'foo'" for an unconfigured name against a fixture with other limits configured, and "no limits configured" for
allagainst a fixture with zero configured limits - Requirements: 1.3, 1.7, 4.1, 4.2
- Depends: 1
- New white-box test file
-
2.2 Display configured setting including scope membership
- Matched limit's name, constrained resource, value/percent, and per-dimension scope print via the existing
fmt_limit()library formatter into a stack buffer, line-prefixed with the existing[sim]convention; all output viafprintf(stdout, ...), never bareprintf()(test_no_bare_printf.cregression-gates this) - Inclusion and exclusion group membership both render (
fmt_group()already handles this, adopted as-is) - Observable: a limit configured with an exclusion group ("all except X") shows both the inclusion and the exclusion in its output;
info limit allagainst a fixture with multiple configured limits displays every one's setting - Requirements: 1.1, 1.2, 1.5
- Matched limit's name, constrained resource, value/percent, and per-dimension scope print via the existing
-
2.3 Display effective host-scope for implicitly host-scoped limits
- After
fmt_limit()'s output, callis_host_based_limit(core->resmgr, limit); if it returns> 0AND the limit's own per-host consumer is not configured, print an explanatory host-scope note - Observable: two REPL fixtures — one percent-based limit on a per-host resource without explicit per-host scoping (note appears), one absolute-value or explicitly-global-scoped limit (note does not appear)
- Requirements: 1.6
- After
-
3. Core: Live accounted-usage display
-
3.1 Display usage for cluster-wide (non-host-scoped) limit instances
- Iterate
limit_get_usage_instances(), filter byvres->limit == matched_limit; for a GLOBAL/SHAREDvres, read capacity fromcore.sharedInst[idx]and accounted usage fromcore.sharedInUse[idx] - Existence-of-instance and zero-usage are distinct: zero matching
xce_vres_t(no capacity entry at all) prints "no active usage instance yet" (Requirement 2.2); a matchedxce_vres_twhosesharedInUse[idx]isNULL/absent instead displays a genuineaccounted=0(it has been evaluated and has a real capacity, just nothing charged against it yet) — existence is gated on the capacity read, never on the in-use read - Observable: a global limit that has matched at least one job shows an accounted/capacity pair reflecting current state; a configured-but-never-evaluated limit shows the explicit no-instance message instead; a matched-but-never-charged limit shows
accounted=0rather than the no-instance message - Requirements: 1.4, 2.1, 2.2, 2.3, 3.3
- Depends: 1
- Iterate
-
3.2 Display per-host usage for host-scoped limit instances
- For a host-scoped
vres(explicit or implicit per 2.3), iteratecore.workers; include a worker only if it has a populated capacity entry (w->resvals[idx]non-NULL) — read capacity fromw->resvals[idx]and accounted usage fromw->resInUse[idx](same existence-vs-zero-usage distinction as 3.1, applied per worker); label each instance with its scope values (user/queue/project from the vresname, plus host) - A limit varying by more than one scope dimension at once (e.g. per-user and per-host together) shows each distinct combination as its own labeled instance
- Observable: a limit matched against jobs on two distinct hosts shows two separate per-host usage lines each labeled with its own scope values, and a third configured-but-untouched host shows no line at all
- Requirements: 1.4, 2.1, 2.2, 2.3, 3.1, 3.2, 3.4
- For a host-scoped
-
4. Register
info limitas a recognizedinfosubcommand- New
else if (strcmp(tokens[1], "limit") == 0)branch inrepl_cmd_info()(repl_info.c) routes torepl_info_limit(); usage-string literal updated to listlimitalongside the existing subcommands test_repl_limit_table.cgains a full dispatch-round-trip test (viarepl_dispatch(), not a direct call) for bothinfo limit <name>andinfo limit all- Observable:
info limit <name>andinfo limit allboth work when typed as real REPL input (throughrepl_dispatch()), andhelp/usage output lists the new subcommand - Requirements: 4.1, 4.2
- Depends: 2.1, 3.1, 3.2
- New
-
5. Verify no mutation, replay determinism, and existing-subcommand regression
- Test: displaying limit information never alters configuration, accounting state, or scheduling decisions (assert config/accounting untouched before vs. after)
- Test: a replay run twice — once with
info limitissued mid-session, once without — produces identical scheduling decisions and identical final accounted state - Full existing
infosubcommand test suite (job/queue/worker/resource/summary) re-run to confirm zero output changes from this addition - Observable: the two new determinism/no-mutation tests pass, and the full pre-existing info-command test suite shows zero diffs
- Requirements: 5.1, 5.2, 5.3
- Depends: 4
Parallel Execution Note
No (P) markers are used anywhere in this plan. Tasks 2.1–2.3 and 3.1–3.2 all incrementally edit the same function (repl_info_limit()) in the same file (repl_info.c) and largely share the same new test file (test_repl_limit_table.c) — the parallel-analysis rules' "no conflicting files or shared mutable resources" criterion rules out marking any of them concurrent. Task 1 is the sole Foundation task (rarely (P) by rule, and has no peer to run alongside regardless). Tasks 4 and 5 are Integration/Validation work that structurally depends on Core being complete.
Implementation Notes
- Task 1:
test_limit_plugin.c's pre-existingfixture_init()never populatedcore->plugins(plugin_register()is stubbed to a no-op in that white-box TU). Closed with two new test-local fixture helpers that hand-build a realcore->pluginskvmgr andkey_add()the plugin under its real"xce_sched_limit"name — necessary to exercise the accessors' realkey_find()path, not a workaround.test_repl_limit_table.c(tasks 2.1+) is a different white-box TU with its own include set (per design.md's build-wiring risk note) — it should link realplugin.c/core.cmachinery whereregister_limit()/xce_sched_core_init()actually populatecore->plugins, so it likely does NOT need this same hand-built-kvmgr workaround; confirm this when starting 2.1 rather than assuming the same gap exists there. - Task 2.1: Making
repl_info.cgenuinely calllimit_get_configured_limits()broke the link for ~24 pre-existing white-box test files that#include "../src/repl_info.c"without also linkingplugins/limit.c— this codebase's white-box test convention means any new cross-file call from an included production file ripples to every file that includes the caller but not the callee. Fixed with an identical 5-line link-satisfying stub (return NULL) added to each, matching the established "duplicate, don't share" stub idiom already used throughout this file family; verified behavior-preserving since none of those 24 fixtures register the limit plugin (the real function would also returnNULLthere). Task 3 (which callslimit_get_usage_instances()) will very likely hit the identical ripple across an overlapping or identical file set — expect it, and apply the same fix pattern rather than treating it as a surprise. - Task 3.1: found and fixed a real latent bounds-check imprecision (negative/stale
idxnot safely rejected by a.count-based pre-check) while writing the new GLOBAL/SHARED usage-read code — logged asbug-1824in.wolf/buglog.jsonrather than fixed in the pre-existingrepl_info_resource_one()(out of this task's boundary); the new code uses the corrected pattern instead. Task 3.2 confirmed it did NOT repeat the same mistake in its own per-host reads. - Task 3.2: reviewer approved with one non-blocking suggestion — no test directly exercises Requirement 3.1's "per-user and per-host together" combined-dimension case end-to-end (structurally supported by the code, per independent review inspection, just not covered by a dedicated fixture). Worth adding if this area gets touched again; not required before shipping.
- Task 5 — significant follow-up needed (logged as
bug-1825): all 5 tasks are correctly implemented, thoroughly tested, and genuinely read-only/deterministic — butinfo limitis currently inert in real production usage.register_limit()has exactly one call site in the whole codebase (scheduler.c's normal-modesched_register_plugins()); sim mode's own plugin bootstrap (sim_bootstrap_picker(),sim_picker.c) only registers the picker plugin, never the limit plugin. So a real operator runningxce_scheduler -i/-s <replay>will always see "no limits configured"/"no such limit" frominfo limit, regardless of what the original live recording had configured — every positive-display test in this spec exercises the code via a test-only plugin-registration helper with no production equivalent in sim mode. This is a pre-existing sim-mode driver gap (same class asscheduler-driver-equivalence.md's other documented normal-vs-sim parity gaps), not a defect in this spec's own work, and was out of task 5's test-only boundary to fix. Fixed by task 6. - Task 6 — a real memory-safety lesson, caught by review before it shipped: the first implementation attempt set
plugin->sched = (void*)sim(axce_sched_sim_driver_t*, disguised) before invoking the limit plugin'sconfig_hook, relying on bothxce_scheduler_tandxce_sched_sim_driver_thappening to havexce_sched_core_t coreas their first struct member. An independent reviewer rejected this:core.h's ownxce_core_cache_clear_cb_tdoc comment documents that this EXACT cross-cast pattern already corrupted memory once before in this codebase (cache_clear()reinterpretingxce_sched_sim_driver_t'salt_events_fileas akvtab_t*), and there was no compiler/runtime guard against a future edit to the callee reading past.core. Fixed by addinglimit_seed_config(plugin, xce_sched_core_t* core, config)— an honestly-typed entry point takingcoreas a real field access, no cast — withlimit_update_config()(normal mode) andsim_bootstrap_limit_plugin()(sim mode) both delegating to a shared_implfunction. Lesson for future work in this codebase: when a plugin hook or helper is invoked from BOTH a normal-mode (xce_scheduler_t) and a sim-mode (xce_sched_sim_driver_t) context, never derive one struct type from the other via a first-member cast, even when it "happens to work" today — takexce_sched_core_t*as an honest parameter instead. Two independent reviewers (one per round) verified this by reading the actual struct definitions and the full call chain, not by trusting the report.