external-license-resources 3.3b: elim load-index collector in xcecd

Per-host user-script load indices (LSF elim model): a config-declared
script collector (external_resources: [{name, command, interval}]) in
xcecd registers getters into resource_sync's load-index registry, so
user-defined dynamic resources flow the existing resval chain
(collect -> xcecli_update_resvals -> controller -> scheduler) without
any new daemon.

- elim.c: libcyaml config parse (fail-loud at load), posix_spawn script
  runner with kill-watchdog timeout (mirrors esd_script.c, no fork in
  xcecd), first-line output parse (bare number or "<name> <value>"),
  per-source interval cache (getter serves last value until next_run),
  fail-open keep-last-value on script failure
- resource_sync.c: fix two init-order traps exposed by external
  registration -- init_builtin_load_indices no longer re-callocs a
  populated g_load_indices array (clobbered entries -> strcmp garbage
  -> segfault), and register self-allocates when called before the
  first collect (capacity-0 trap); new public
  resource_sync_ensure_builtin_load_indices() so elim_register_sources
  can register before lazy builtin init and builtin-name shadowing is
  a clean -EEXIST at startup
- main.c: xcecd -c/--config option (elim config file); ownership
  main.c -> xcecd_init -> resource_sync registration, with rollback on
  init failure and cleanup in xcecd_cleanup
- tests: 18-case white-box elim_test (config/parse/getter-cache/
  register/unregister/script-runner incl. timeout no-zombie), plus
  stubs for the startup-order test
- t1/t2/t3 E2E: per-host dynres1 values visible in CLI (11/22/33 from
  each node's own script); job with rusage[dynres1=15] RUNs on t2
  (22 >= 15, t1=11 correctly skipped), RUN->DONE; over-ask
  rusage[dynres1=50] stays PEND; second saturating job overflows to
  t3; kill frees the reservation and a new job re-fills t2

Note: rusage clauses use square-bracket syntax rusage[...]; the
LSF-style paren form rusage(...) is parsed as a select expression and
rejected by the select grammar (observed live, pre-existing).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-10-28 11:17:36 +00:00
parent 700f3d4e1a
commit 30c51ea147
11 changed files with 1471 additions and 7 deletions

83
src/worker/include/elim.h Normal file
View file

@ -0,0 +1,83 @@
#ifndef ELIM_H
#define ELIM_H
#include <stdbool.h>
#include <stddef.h>
/*
* elim -- the LSF-elim-compatible external resource collector for xcecd
* (external-license-resources design §3.3b). Each configured source names a
* command run on this host (posix_spawn, watchdog kill at
* elim_script_timeout_ms); the command prints "<name> <value>" (elim
* format) or a bare number on stdout, and the value registers as a custom
* load index on resource_sync's registry -- flowing the live per-host
* report chain (collect_load_indices -> xcecd_send_load_indices ->
* controller update_val_res -> scheduler RESVALS_DELTA) with zero
* downstream changes.
*
* Per-host semantics by design: an elim script answers "how much does THIS
* host have"; cluster-global pools (license servers) belong to xceesd.
*/
typedef struct elim_source_s {
char* name; /* resource name the script reports */
char* command; /* shell command line (run via /bin/sh -c) */
int interval; /* seconds between re-runs; 0 = every collect */
} elim_source_t;
typedef struct elim_config_s {
int nsrc;
elim_source_t* src; /* array of nsrc sources */
} elim_config_t;
/*
* Parse the elim section of the worker config file:
*
* external_resources:
* - name: dynres1
* command: /opt/xce/scripts/report-dynres1.sh
* interval: 30
*
* Returns 0, -ENOENT (file missing), -EINVAL (bad shape: a source without
* name or command can never report -- fail loudly at load, not silently at
* runtime), -ENOMEM.
*/
extern int elim_config_load(const char* path, elim_config_t* cfg_out);
extern int elim_config_load_path(const char* dir, const char* name,
elim_config_t* cfg_out);
extern void elim_config_fini(elim_config_t* cfg);
/*
* Register every configured source as a custom (non-builtin) load index on
* resource_sync's registry. Returns 0, or -EEXIST if a source's name is
* already taken (another source, or one of the 11 builtins -- a user script
* must not shadow "mem"/"r15s"/...), or -EINVAL/-ENOMEM. Registration
* happens at startup, before the sync thread's first collect -- the
* registry self-initializes for that (the pre-phase "capacity reached (0)"
* trap).
*/
extern int elim_register_sources(const elim_config_t* cfg);
extern void elim_unregister_sources(const elim_config_t* cfg);
/*
* Run one collection command and read its stdout. Returns 0 with `out`
* NUL-terminated, -ETIMEDOUT when the watchdog killed the child,
* -ENODATA on empty output, -EINVAL/-errno on spawn/read failure.
* The child gets an empty signal mask and default dispositions; stderr is
* /dev/null. Never blocks past elim_script_timeout_ms.
*/
extern int elim_script_collect(const char* command, char* out, size_t outsz);
/*
* Parse an elim-format collection output: the first non-empty line carries
* "<name> <value>" or a bare number; leading/trailing whitespace ignored.
* Returns the value, or NAN on garbage/empty (caller owns the fail-open
* policy).
*/
extern double elim_parse_script_output(const char* out);
/* Watchdog, milliseconds a collection command may run before it is killed
* (overridable from tests / future CLI). */
extern int elim_script_timeout_ms;
#endif // ELIM_H

View file

@ -19,4 +19,10 @@ bool resource_sync_register_load_index(const char* name, int valtype,
bool is_builtin);
bool resource_sync_unregister_load_index(const char* name);
/* Ensure the registry array exists and the 11 builtin load indices are in
* it -- for external registrars (elim) that run before the sync thread's
* first collect would lazily populate them, so their name-collision checks
* see the builtins. */
void resource_sync_ensure_builtin_load_indices(void);
#endif // RESOURCE_SYNC_H

View file

@ -2,6 +2,7 @@
#define XCECD_H
#include "controller_connector.h"
#include "elim.h"
#include "hardware_monitor.h"
#include "job_information.h"
#include "node_information.h"
@ -63,6 +64,11 @@ typedef struct {
* xcecd_start() already ran. candidates_count == 0 means no --peers was
* given (single-host default). */
xce_cluster_config_t cluster_cfg;
/* elim sources from the -c config file's external_resources section
* (design §3.3b). Loaded in main.c (elim_config_load); ownership moves
* to xcecd_ctx by xcecd_init(), which registers the sources with
* resource_sync at startup. */
elim_config_t elim;
} xcecd_config_t;
typedef struct {
@ -88,6 +94,9 @@ typedef struct {
char* log_dir;
char* db_path;
char* virtual_name;
/* elim collectors (design §3.3b): sources loaded from the -c config
* file, registered with resource_sync by xcecd_init(). */
elim_config_t elim;
/* Copied from xcecd_config_t by xcecd_init(); consumed by
* controller_connector_init() (xcecd_start()) to populate
* cc_ctx->cluster_cfg BEFORE controller_connector_start() fires the

View file

@ -16,6 +16,7 @@ xcecd_worker_srcs = [
'src/xcecd.c',
'src/connection_handler.c',
'src/resource_sync.c',
'src/elim.c',
'src/node_information.c',
'src/thread_pool.c',
'src/hardware_monitor.c',
@ -95,6 +96,7 @@ xcecd_srcs = [
'src/connection_handler.c',
'src/controller_connector.c',
'src/resource_sync.c',
'src/elim.c',
'src/xcecd_worker_utils.c',
'src/xcecd_resync.c',
'src/hardware_monitor.c',

656
src/worker/src/elim.c Normal file
View file

@ -0,0 +1,656 @@
/* elim.c -- the LSF-elim-compatible external resource collector for xcecd
* (external-license-resources design §3.3b).
*
* The worker already owns half the elim model as live code: resource_sync.c
* keeps a load-index registry (11 builtins shaped after LSF's load index
* name set) whose values flow the per-host report chain every 15 ticks --
* collect_load_indices() -> xcecd_send_load_indices() -> controller
* update_val_res (non-SHARED lands in w->host.resvals) -> scheduler
* RESVALS_DELTA -> sched_check_host_satisfy_. What was missing is the
* ENTRY: a mechanism for a user to register their own index. This module is
* that entry -- config-driven script collectors registered as custom
* (non-builtin) load indices at startup.
*
* posix_spawn(), not fork(): xcecd is multi-threaded (no-fork rule,
* .kiro/steering/tech.md "Process Creation") and a forked child that
* allocated or logged before exec could deadlock on a lock another thread
* held. The spawn/watchdog shape mirrors esd_script.c (the xceesd
* collector, same phase's global leg): pipe, poll(2) in 100ms slices,
* kill(SIGKILL) at elim_script_timeout_ms, waitpid reaps -- a hung script
* must not stall the sync thread.
*/
#define _GNU_SOURCE
#include "elim.h"
#include <errno.h>
#include <fcntl.h>
#include <math.h>
#include <poll.h>
#include <pthread.h>
#include <signal.h>
#include <spawn.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#include <cyaml/cyaml.h>
#include "resource_sync.h"
#include "xcelog.h"
/* Watchdog, milliseconds of wall clock a collection command may run before
* it is killed (design §3.3b: 脚本超时看门狗 kill). */
int elim_script_timeout_ms = 10 * 1000;
/* ── config parse (libcyaml, esd_config.c pattern) ────────────────────── */
typedef struct yaml_elim_source_s {
char* name;
char* command;
long interval; /* 0 = every collect tick */
} yaml_elim_source_t;
typedef struct yaml_elim_config_s {
yaml_elim_source_t* external_resources;
unsigned external_resources_count;
} yaml_elim_config_t;
static const cyaml_schema_field_t elim_source_fields_schema[] = {
CYAML_FIELD_STRING_PTR("name", CYAML_FLAG_POINTER, yaml_elim_source_t,
name, 1, CYAML_UNLIMITED),
CYAML_FIELD_STRING_PTR("command", CYAML_FLAG_POINTER, yaml_elim_source_t,
command, 1, CYAML_UNLIMITED),
CYAML_FIELD_INT("interval", CYAML_FLAG_OPTIONAL, yaml_elim_source_t,
interval),
CYAML_FIELD_END
};
static const cyaml_schema_value_t elim_source_schema = {
CYAML_VALUE_MAPPING(CYAML_FLAG_DEFAULT, yaml_elim_source_t,
elim_source_fields_schema)
};
static const cyaml_schema_field_t elim_config_fields_schema[] = {
CYAML_FIELD_SEQUENCE("external_resources", CYAML_FLAG_POINTER,
yaml_elim_config_t, external_resources,
&elim_source_schema, 0, CYAML_UNLIMITED),
CYAML_FIELD_END
};
static const cyaml_schema_value_t elim_config_schema = {
CYAML_VALUE_MAPPING(CYAML_FLAG_POINTER, yaml_elim_config_t,
elim_config_fields_schema)
};
int elim_config_load_path(const char* dir, const char* name,
elim_config_t* cfg_out)
{
static cyaml_config_t cyaml_cfg = {
.mem_fn = cyaml_mem,
.log_level = CYAML_LOG_WARNING,
.flags = CYAML_CFG_IGNORE_UNKNOWN_KEYS,
};
yaml_elim_config_t* ycfg = NULL;
cyaml_err_t err;
char* path = NULL;
elim_config_t cfg;
int i;
int rc;
if ((dir == NULL) || (name == NULL) || (cfg_out == NULL)) {
return -EINVAL;
}
memset(cfg_out, 0, sizeof(*cfg_out));
memset(&cfg, 0, sizeof(cfg));
if (asprintf(&path, "%s/%s", dir, name) < 0) {
return -ENOMEM;
}
err = cyaml_load_file(path, &cyaml_cfg, &elim_config_schema,
(void**)&ycfg, NULL);
free(path);
if (err != CYAML_OK) {
/* Map file-not-found apart from bad-file so the startup log can
* say which (esd_config.c's probe). */
char probe[512];
FILE* f;
snprintf(probe, sizeof(probe), "%s/%s", dir, name);
f = fopen(probe, "r");
if (f == NULL) {
return -ENOENT;
}
fclose(f);
return -EINVAL;
}
if (ycfg == NULL) {
return -EINVAL;
}
cfg.nsrc = (int)ycfg->external_resources_count;
cfg.src = NULL;
if (cfg.nsrc > 0) {
cfg.src = (elim_source_t*)calloc((size_t)cfg.nsrc,
sizeof(elim_source_t));
if (cfg.src == NULL) {
cyaml_free(&cyaml_cfg, &elim_config_schema, ycfg, 0);
return -ENOMEM;
}
}
rc = 0;
for (i = 0; i < cfg.nsrc; i++) {
yaml_elim_source_t* ys = &(ycfg->external_resources[i]);
/* A source without name or command can never report -- fail
* loudly at load, not silently at runtime. */
if ((ys->name == NULL) || (ys->command == NULL)) {
rc = -EINVAL;
break;
}
cfg.src[i].name = strdup(ys->name);
cfg.src[i].command = strdup(ys->command);
cfg.src[i].interval = (int)ys->interval;
if ((cfg.src[i].name == NULL) || (cfg.src[i].command == NULL)) {
rc = -ENOMEM;
break;
}
}
cyaml_free(&cyaml_cfg, &elim_config_schema, ycfg, 0);
if (rc < 0) {
elim_config_fini(&cfg);
return rc;
}
*cfg_out = cfg;
return 0;
}
int elim_config_load(const char* path, elim_config_t* cfg_out)
{
char* dir;
char* name;
char* slash;
int rc;
if ((path == NULL) || (cfg_out == NULL)) {
return -EINVAL;
}
dir = strdup(path);
if (dir == NULL) {
return -ENOMEM;
}
slash = strrchr(dir, '/');
if (slash == NULL) {
name = dir;
dir = strdup(".");
if (dir == NULL) {
free(name);
return -ENOMEM;
}
} else {
*slash = '\0';
name = slash + 1;
if (*name == '\0') {
free(dir);
return -EINVAL;
}
if (dir == name - 1) {
/* path was "/name" -- dir is "" */
free(dir);
dir = strdup("/");
if (dir == NULL) {
return -ENOMEM;
}
}
}
rc = elim_config_load_path(dir, name, cfg_out);
free(dir);
return rc;
}
void elim_config_fini(elim_config_t* cfg)
{
int i;
if (cfg == NULL) {
return;
}
for (i = 0; i < cfg->nsrc; i++) {
free(cfg->src[i].name);
free(cfg->src[i].command);
}
free(cfg->src);
memset(cfg, 0, sizeof(*cfg));
}
/* ── output parse (elim contract) ─────────────────────────────────────── */
/*
* The elim contract: the first non-empty line carries "<name> <value>" or
* a bare number; leading/trailing whitespace ignored; anything else is
* garbage -> NAN (the caller owns the fail-open policy). Same shape as
* esd_parse_script_output -- a number is a number wherever it stands.
*/
double elim_parse_script_output(const char* out)
{
const char* p;
char* endp = NULL;
double v;
if (out == NULL) {
return NAN;
}
p = out;
while (*p != '\0') {
while ((*p == ' ') || (*p == '\t') || (*p == '\r') || (*p == '\n')) {
p++;
}
if (*p == '\0') {
break;
}
v = strtod(p, &endp);
if ((endp != NULL) && (endp != p)) {
return v;
}
/* skip this non-numeric token and try the next one */
while ((*p != '\0') && (*p != ' ') && (*p != '\t') && (*p != '\r')
&& (*p != '\n')) {
p++;
}
}
return NAN;
}
/* ── script runner (posix_spawn + watchdog, esd_script.c shape) ───────── */
static int elim_script_spawn(const char* command, int out_fd, pid_t* pid_out)
{
posix_spawn_file_actions_t fa;
posix_spawnattr_t attr;
sigset_t none, all;
char* argv[] = { (char*)"sh", (char*)"-c", (char*)command, NULL };
extern char** environ;
pid_t pid;
int rc;
sigemptyset(&none);
sigfillset(&all);
rc = posix_spawn_file_actions_init(&fa);
if (rc == 0) {
rc = posix_spawn_file_actions_adddup2(&fa, out_fd, STDOUT_FILENO);
}
if (rc == 0) {
rc = posix_spawn_file_actions_addopen(&fa, STDERR_FILENO, "/dev/null",
O_WRONLY, 0);
}
if (rc == 0) {
rc = posix_spawnattr_init(&attr);
}
if (rc == 0) {
rc = posix_spawnattr_setflags(
&attr, POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_SETSIGDEF);
}
if (rc == 0) {
rc = posix_spawnattr_setsigmask(&attr, &none);
}
if (rc == 0) {
rc = posix_spawnattr_setsigdefault(&attr, &all);
}
if (rc == 0) {
rc = posix_spawn(&pid, "/bin/sh", &fa, &attr, argv, environ);
}
posix_spawnattr_destroy(&attr);
posix_spawn_file_actions_destroy(&fa);
if (rc != 0) {
return -rc;
}
*pid_out = pid;
return 0;
}
int elim_script_collect(const char* command, char* out, size_t outsz)
{
int fds[2];
pid_t pid = -1;
size_t total = 0;
int elapsed_ms = 0;
int rc;
int wstatus = 0;
struct pollfd pfd;
if ((command == NULL) || (*command == '\0') || (out == NULL)
|| (outsz == 0)) {
return -EINVAL;
}
out[0] = '\0';
if (pipe(fds) != 0) {
rc = -errno;
XCECD_LOG(XCE_LOG_ERROR, "elim: pipe failed (%s)", strerror(-rc));
return rc;
}
rc = elim_script_spawn(command, fds[1], &pid);
close(fds[1]); /* parent's copy -- child holds the write end now */
if (rc < 0) {
close(fds[0]);
XCECD_LOG(XCE_LOG_ERROR, "elim: spawn failed for \"%s\" (%s)", command,
strerror(-rc));
return rc;
}
/* Bound the read at the watchdog deadline: a script that hangs must
* not stall the sync thread -- the value it was going to report
* belongs to a dead world anyway. */
pfd.fd = fds[0];
pfd.events = POLLIN;
while (total < outsz - 1) {
int pret = poll(&pfd, 1, 100);
if (pret < 0) {
if (errno == EINTR) {
continue;
}
rc = -errno;
XCECD_LOG(XCE_LOG_ERROR, "elim: poll failed for \"%s\" (%s)",
command, strerror(-rc));
kill(pid, SIGKILL);
waitpid(pid, &wstatus, 0);
close(fds[0]);
return rc;
}
if (pret == 0) {
elapsed_ms += 100;
if (elapsed_ms >= elim_script_timeout_ms) {
XCECD_LOG(XCE_LOG_WARN,
"elim: command \"%s\" exceeded %d ms -- killing "
"it, value not refreshed this round",
command, elim_script_timeout_ms);
kill(pid, SIGKILL);
waitpid(pid, &wstatus, 0);
close(fds[0]);
return -ETIMEDOUT;
}
continue;
}
if (pfd.revents & (POLLERR | POLLHUP)) {
ssize_t n = read(fds[0], out + total, outsz - 1 - total);
if (n > 0) {
total += (size_t)n;
}
break;
}
if (pfd.revents & POLLIN) {
ssize_t n = read(fds[0], out + total, outsz - 1 - total);
if (n <= 0) {
break; /* EOF or error: done reading */
}
total += (size_t)n;
}
}
out[total] = '\0';
close(fds[0]);
/* reap regardless of which path brought us here */
while (waitpid(pid, &wstatus, 0) < 0) {
if (errno != EINTR) {
break;
}
}
if (total == 0) {
XCECD_LOG(XCE_LOG_WARN,
"elim: command \"%s\" produced no output", command);
return -ENODATA;
}
return 0;
}
/* ── getter plumbing: per-source cache behind the context-free API ────── */
/* resource_sync's getter signature is context-free (char* (*)(void)) -- the
* per-source state (command, cached value, next-refresh deadline) lives in
* this static array, indexed by registration order. MAX_LOAD_INDICES (64)
* bounds the registry; the config loader's own parse bounds us to a sane
* subset of that, and elim_register_sources' capacity failure surfaces at
* startup. */
typedef struct {
char* command;
int interval; /* seconds; 0 = refresh every collect */
double value; /* last good parse */
bool have_value;
time_t next_run; /* epoch after which the script re-runs */
} elim_source_state_t;
static elim_source_state_t* g_elim_states = NULL;
static int g_elim_nstates = 0;
static pthread_mutex_t g_elim_mutex = PTHREAD_MUTEX_INITIALIZER;
static char* elim_getter_run(int slot)
{
elim_source_state_t* st = &g_elim_states[slot];
char buf[4096];
int rc;
double v;
char* out_str;
time_t now;
/* interval cache: 0 = run the script on every collect; otherwise the
* script re-runs only once its interval has elapsed since the last
* good run, and the cached value is served in between (the send
* cadence is the sync thread's 15-tick cycle regardless -- the interval
* governs script-run frequency, not push frequency). */
now = time(NULL);
if (st->have_value && (st->interval > 0) && (now < st->next_run)) {
out_str = (char*)malloc(64);
if (out_str == NULL) {
return NULL;
}
snprintf(out_str, 64, "%f", st->value);
return out_str;
}
rc = elim_script_collect(st->command, buf, sizeof(buf));
if (rc < 0) {
/* Fail-open: keep the last known value (the collector died but the
* host is still there; the sync thread keeps reporting). */
if (!st->have_value) {
return strdup("0");
}
return strdup("");
}
v = elim_parse_script_output(buf);
if (isnan(v)) {
XCECD_LOG(XCE_LOG_WARN,
"elim: command \"%s\" output did not parse as a value "
"(got %.60s)", st->command, buf);
if (!st->have_value) {
return strdup("0");
}
return strdup("");
}
st->value = v;
st->have_value = true;
st->next_run = time(NULL) + st->interval;
/* collect_load_indices converts per valtype (fvalue: atof+free) and
* owns the string from here. */
out_str = (char*)malloc(64);
if (out_str == NULL) {
return NULL;
}
snprintf(out_str, 64, "%f", v);
return out_str;
}
/* One trampoline per possible slot; the getter table indexes the static
* state array. (The registry API carries no user pointer.) */
static char* elim_getter_0(void) { return elim_getter_run(0); }
static char* elim_getter_1(void) { return elim_getter_run(1); }
static char* elim_getter_2(void) { return elim_getter_run(2); }
static char* elim_getter_3(void) { return elim_getter_run(3); }
static char* elim_getter_4(void) { return elim_getter_run(4); }
static char* elim_getter_5(void) { return elim_getter_run(5); }
static char* elim_getter_6(void) { return elim_getter_run(6); }
static char* elim_getter_7(void) { return elim_getter_run(7); }
static char* elim_getter_8(void) { return elim_getter_run(8); }
static char* elim_getter_9(void) { return elim_getter_run(9); }
static char* elim_getter_10(void) { return elim_getter_run(10); }
static char* elim_getter_11(void) { return elim_getter_run(11); }
static char* elim_getter_12(void) { return elim_getter_run(12); }
static char* elim_getter_13(void) { return elim_getter_run(13); }
static char* elim_getter_14(void) { return elim_getter_run(14); }
static char* elim_getter_15(void) { return elim_getter_run(15); }
static char* (*const g_elim_getters[])(void) = {
elim_getter_0, elim_getter_1, elim_getter_2, elim_getter_3,
elim_getter_4, elim_getter_5, elim_getter_6, elim_getter_7,
elim_getter_8, elim_getter_9, elim_getter_10, elim_getter_11,
elim_getter_12, elim_getter_13, elim_getter_14, elim_getter_15,
};
#define ELIM_MAX_SOURCES ((int)(sizeof(g_elim_getters) / sizeof(g_elim_getters[0])))
/* There is exactly one getter call site -- collect_load_indices() on the
* sync thread -- but registration happens on main before that thread
* exists; a mutex keeps the state array vs. getter table relationship
* honest without serializing the runs themselves. */
int elim_register_sources(const elim_config_t* cfg)
{
int i;
int rc = 0;
if ((cfg == NULL) || (cfg->nsrc < 0)
|| ((cfg->nsrc > 0) && (cfg->src == NULL))) {
return -EINVAL;
}
if (cfg->nsrc == 0) {
return 0;
}
if (cfg->nsrc > ELIM_MAX_SOURCES) {
XCECD_LOG(XCE_LOG_ERROR,
"elim: %d external resources configured, at most %d "
"supported", cfg->nsrc, ELIM_MAX_SOURCES);
return -EINVAL;
}
pthread_mutex_lock(&g_elim_mutex);
/* The 11 builtins register lazily at collect time; an elim source named
* "mem" registered before them would win the name race and SHADOW a
* builtin (the builtin's own registration then bounces as a dup).
* Force the builtin init first, so a name collision is a clean -EEXIST
* here, at startup, where the operator sees it. */
resource_sync_ensure_builtin_load_indices();
g_elim_states = (elim_source_state_t*)calloc(
(size_t)cfg->nsrc, sizeof(elim_source_state_t));
if (g_elim_states == NULL) {
pthread_mutex_unlock(&g_elim_mutex);
return -ENOMEM;
}
g_elim_nstates = cfg->nsrc;
for (i = 0; i < cfg->nsrc; i++) {
if (!resource_sync_register_load_index(
cfg->src[i].name, Value_u_fvalue, g_elim_getters[i], false)) {
/* Duplicate name (another source, or one of the 11 builtins --
* a user script must not shadow "mem"/"r15s"/...): surface the
* misconfiguration at startup, not as a silent missing value. */
XCECD_LOG(XCE_LOG_ERROR,
"elim: cannot register external resource '%s' -- "
"name already in use",
cfg->src[i].name);
rc = -EEXIST;
break;
}
g_elim_states[i].command = strdup(cfg->src[i].command);
if (g_elim_states[i].command == NULL) {
resource_sync_unregister_load_index(cfg->src[i].name);
rc = -ENOMEM;
break;
}
g_elim_states[i].interval = cfg->src[i].interval;
g_elim_states[i].have_value = false;
g_elim_states[i].next_run = 0;
XCECD_LOG(XCE_LOG_INFO,
"elim: registered external resource '%s' (command: %s, "
"interval: %ds)",
cfg->src[i].name, g_elim_states[i].command,
g_elim_states[i].interval);
}
pthread_mutex_unlock(&g_elim_mutex);
if (rc < 0) {
elim_unregister_sources(cfg);
}
return rc;
}
void elim_unregister_sources(const elim_config_t* cfg)
{
int i;
if ((cfg == NULL) || (cfg->src == NULL)) {
return;
}
pthread_mutex_lock(&g_elim_mutex);
for (i = 0; i < cfg->nsrc; i++) {
resource_sync_unregister_load_index(cfg->src[i].name);
}
for (i = 0; i < g_elim_nstates; i++) {
free(g_elim_states[i].command);
}
free(g_elim_states);
g_elim_states = NULL;
g_elim_nstates = 0;
pthread_mutex_unlock(&g_elim_mutex);
}

View file

@ -394,10 +394,12 @@ int main(int argc, char* argv[])
char logfile[1024] = { 0 };
char* logdir = NULL;
const char* config_file = NULL;
static struct option long_opts[] = {
{ "peers", required_argument, NULL, OPT_PEERS },
{ "version", no_argument, NULL, OPT_VERSION },
{ "config", required_argument, NULL, 'c' },
{ "help", no_argument, NULL, 'h' },
{ NULL, 0, NULL, 0 }
};
@ -418,12 +420,15 @@ int main(int argc, char* argv[])
STR(VERSION_BUILD_TIME),
STR(VERSION_DIRTY_FINGERPRINT));
while ((opt = getopt_long(argc, argv, "n:l:vh", long_opts, NULL))
while ((opt = getopt_long(argc, argv, "n:l:c:vh", long_opts, NULL))
!= -1) {
switch (opt) {
case 'n':
config.virutal_name = optarg;
break;
case 'c':
config_file = optarg;
break;
case 'l':
if (optarg) {
if (logdir) {
@ -450,9 +455,13 @@ int main(int argc, char* argv[])
return 0;
case 'h':
default:
printf("Usage: %s [-n name] [-l log_dir] [-v] "
printf("Usage: %s [-n name] [-l log_dir] [-c config] [-v] "
"[--peers ctrl-1=host:port,...] "
"[-h help]\n"
" -c FILE elim collector config (external "
"resources reported by\n"
" scripts on this host; design "
"external-license-resources §3.3b)\n"
" --peers LIST HA controller candidate list: "
"[name=]host:port,...\n"
" When omitted, the XCE_CONTROLLER_PEERS "
@ -638,6 +647,41 @@ int main(int argc, char* argv[])
}
#endif
/* elim collectors (design §3.3b): load the external_resources config
* before xcecd_init() registers its sources. No -c (or a file without
* the section) means zero external resources -- plain worker behavior.
* A config that is present but BROKEN stops startup loudly: a worker
* whose operator asked for elim reporting must not silently run
* without it (same fail-loud-at-load rule as esd's config). */
if (config_file != NULL) {
elim_config_t elim_cfg;
int erc = elim_config_load(config_file, &elim_cfg);
if (erc == -ENOENT) {
XCECD_LOG(XCE_LOG_ERROR,
"elim config file not found: %s", config_file);
fprintf(stderr, "xcecd: elim config file not found: %s\n",
config_file);
FREEUP(logdir);
return 1;
}
if (erc < 0) {
XCECD_LOG(XCE_LOG_ERROR,
"invalid elim config file %s (rc=%d) -- a source "
"needs both name and command", config_file, erc);
fprintf(stderr,
"xcecd: invalid elim config file %s (rc=%d)\n",
config_file, erc);
FREEUP(logdir);
return 1;
}
config.elim.nsrc = elim_cfg.nsrc;
config.elim.src = elim_cfg.src; /* ownership -> xcecd_config_t */
XCECD_LOG(XCE_LOG_INFO,
"elim: %d external resource(s) from %s",
config.elim.nsrc, config_file);
}
if (xcecd_config_init(&config) != 0) {
XCECD_LOG(XCE_LOG_ERROR, "Failed to initialize daemon configuration.");
return 1;

View file

@ -77,8 +77,17 @@ static void init_builtin_load_indices(void)
* on resource_sync_thread()'s single dedicated pthread in production,
* or single-threaded test code). */
g_load_indices_capacity = MAX_LOAD_INDICES;
g_load_indices = (load_index_info_t*)calloc(g_load_indices_capacity,
sizeof(load_index_info_t));
if (g_load_indices == NULL) {
g_load_indices = (load_index_info_t*)calloc(g_load_indices_capacity,
sizeof(load_index_info_t));
}
/* Never realloc over a populated array: since resource_sync_ensure_
* builtin_load_indices() this function also runs with the array already
* alive (external entries registered first -- elim), and replacing it
* leaked the old one while leaving g_load_indices_count pointing past
* the fresh calloc (strcmp on garbage slots, segfault). The dup check
* in resource_sync_register_load_index() makes re-running the
* registration list idempotent. */
// register built-in load indices
resource_sync_register_load_index("r15s", Value_u_fvalue, node_info_get_15s,
@ -105,6 +114,28 @@ static void init_builtin_load_indices(void)
true);
}
/**
* @brief Ensures the registry exists AND the 11 builtin load indices are
* registered in it, whichever external module (elim) queries or
* registers first. Idempotent; single-threaded callers only in
* practice (startup registration / the sync thread's collect), but
* safe under the registry mutex discipline its own callers keep.
*/
void resource_sync_ensure_builtin_load_indices(void)
{
if (g_load_indices == NULL) {
init_builtin_load_indices();
return;
}
/* init_builtin_load_indices() ran but the array was later torn down
* (resource_sync_destroy's cleanup) and a register-before-init caller
* re-populated it with external entries only -- the builtins must go
* in before the first report. */
if (g_load_indices_count < BUILTIN_LOAD_INDICES_COUNT) {
init_builtin_load_indices();
}
}
/**
* @brief Registers a new load index.
* @param name The name of the load index.
@ -123,6 +154,27 @@ bool resource_sync_register_load_index(const char* name, int valtype,
pthread_mutex_lock(&g_load_indices_mutex);
/* elim (design §3.3b): external registration happens at daemon startup
* -- BEFORE the sync thread's first collect_load_indices() lazily
* allocated the array. A capacity-0 registry here used to reject every
* such registration with "Maximum load indices capacity reached (0)",
* silently dropping every configured external resource. Allocate the
* registry on first use, same as the lazy builtin init does; init_
* builtin_load_indices() itself stays where it is (collect-time) so the
* 11 builtins keep registering exactly once per populated-registry
* window (bug-1756's contract -- its only caller's NULL check). */
if (g_load_indices == NULL) {
g_load_indices_capacity = MAX_LOAD_INDICES;
g_load_indices = (load_index_info_t*)calloc(
g_load_indices_capacity, sizeof(load_index_info_t));
if (g_load_indices == NULL) {
XCECD_LOG(XCE_LOG_ERROR, "Failed to allocate load indices array");
g_load_indices_capacity = 0;
pthread_mutex_unlock(&g_load_indices_mutex);
return false;
}
}
for (int i = 0; i < g_load_indices_count; i++) {
if (strcmp(g_load_indices[i].name, name) == 0) {
XCECD_LOG(XCE_LOG_WARN, "Load index '%s' already exists", name);
@ -194,9 +246,7 @@ bool resource_sync_unregister_load_index(const char* name)
static xce_resval_t* collect_load_indices(int* count)
{
if (g_load_indices == NULL) {
init_builtin_load_indices();
}
resource_sync_ensure_builtin_load_indices();
pthread_mutex_lock(&g_load_indices_mutex);

View file

@ -395,6 +395,22 @@ int xcecd_init(const xcecd_config_t* config)
* xcecd_start() already ran. */
xcecd_ctx.cluster_cfg = config->cluster_cfg;
/* elim collectors (external-license-resources design §3.3b): register
* the configured external resources as custom load indices BEFORE the
* resource_sync thread starts (xcecd_start() -> resource_sync_start()),
* so the first 15-tick report already carries them. Ownership of
* config->elim moves here (config is read-only). */
xcecd_ctx.elim.nsrc = config->elim.nsrc;
xcecd_ctx.elim.src = config->elim.src;
if (xcecd_ctx.elim.nsrc > 0) {
if (elim_register_sources(&xcecd_ctx.elim) < 0) {
XCECD_LOG(XCE_LOG_ERROR,
"Failed to register elim external resources -- "
"check external_resources config");
goto cleanup_remote_host;
}
}
/* thread pool initializing */
xcecd_ctx.thread_pool = thread_pool_create(config->thread_pool_size);
if (!xcecd_ctx.thread_pool) {
@ -457,6 +473,7 @@ cleanup_process_manager:
cleanup_thread_pool:
thread_pool_destroy(xcecd_ctx.thread_pool);
cleanup_remote_host:
elim_unregister_sources(&xcecd_ctx.elim);
free(xcecd_ctx.remote_host);
free(xcecd_ctx.work_dir);
free(xcecd_ctx.db_path);
@ -575,6 +592,9 @@ void xcecd_cleanup(void)
resource_sync_stop(xcecd_ctx.resource_sync);
resource_sync_destroy(xcecd_ctx.resource_sync);
}
/* elim teardown AFTER resource_sync_destroy (which tears the registry
* down anyway -- this frees the source state and command strings). */
elim_unregister_sources(&xcecd_ctx.elim);
if (xcecd_ctx.db) {
xcecd_db_close(xcecd_ctx.db);
}

View file

@ -45,6 +45,23 @@ test('worker resource_sync test', resource_sync_test,
timeout: 60,
env: ['ASAN_OPTIONS=detect_leaks=0'])
# external-license-resources design §3.3b: the elim collector -- a
# config-driven script runner (posix_spawn + watchdog) that registers
# user-defined per-host load indices into resource_sync's registry at
# worker startup. White-box #includes elim.c AND resource_sync.c (the
# registry capacity-0-before-init trap is only observable against the real
# registry); see test_elim.c's header comment.
elim_test = executable('elim_test',
'test_elim.c',
include_directories: [worker_test_incdir],
dependencies: worker_test_deps,
install: false)
test('worker elim test', elim_test,
suite: 'worker',
timeout: 60,
env: ['ASAN_OPTIONS=detect_leaks=0'])
# bug-1554: real maxswap (sysinfo() totalswap)/maxtmp (statvfs("/tmp"))
# detection -- white-box #include of hardware_monitor.c, same convention as
# resource_sync_test above.

View file

@ -0,0 +1,566 @@
/*
* test_elim.c -- elim collector module (external-license-resources design
* §3.3b): xcecd gains a config-driven script collector that registers
* user-defined load indices into resource_sync's registry (the previously
* dead external-register API), so per-host "external resources" reported by
* user scripts on each host flow the live load-indices push chain
* (collect_load_indices -> xcecd_send_load_indices -> controller
* update_val_res -> scheduler RESVALS_DELTA -> sched_check_host_satisfy_).
*
* White-box #include of ../src/elim.c (same convention as
* test_resource_sync.c) plus ../src/esd_script-shaped spawn code lives in
* elim.c itself; the config parse is exercised against real temp files
* (esd_test's mkdtemp pattern). The spawn/watchdog half IS unit tested here
* (unlike esd's test, which deferred it to E2E): a real /bin/sh child with
* a real timeout kill is deterministic and cheap, and the watchdog is the
* one piece of elim.c that can hang the sync thread if it regresses.
*
* resource_sync.c is ALSO #included (after elim.c) to exercise the real
* registry rather than a stub: the capacity-0-before-init trap this phase
* fixes (elim registers at startup, before the sync thread's first collect
* lazily allocated the array) is only observable against the real code.
*/
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
#include <unity/unity.h>
#include "resource_sync.h"
#include "xcecd.h"
/* Real globals the white-box resource_sync.c references (test_resource_sync.c
* convention). */
xcecd_context_t xcecd_ctx;
/* Stubs for the 11 builtin load-index getters (node_information.c, not
* linked here). */
static char* stub_getter(void)
{
return strdup("1.0");
}
char* node_info_get_15s(void) { return stub_getter(); }
char* node_info_get_1m(void) { return stub_getter(); }
char* node_info_get_15m(void) { return stub_getter(); }
char* node_info_get_ut(void) { return stub_getter(); }
char* node_info_get_pg(void) { return stub_getter(); }
char* node_info_get_ls(void) { return stub_getter(); }
char* node_info_get_it(void) { return stub_getter(); }
char* node_info_get_swap(void) { return stub_getter(); }
char* node_info_get_mem(void) { return stub_getter(); }
char* node_info_get_tmp(void) { return stub_getter(); }
char* node_info_get_io(void) { return stub_getter(); }
/* Spy on the send (controller_connector.c, not linked here). */
static int g_send_load_indices_call_count = 0;
bool xcecd_send_load_indices(xce_resval_t** vals, int count)
{
(void)vals;
(void)count;
g_send_load_indices_call_count++;
return true;
}
/* resource_sync.c's resync gate (xcecd_resync.c, not linked here). */
static bool g_test_resync_pending = false;
bool xcecd_resync_is_pending(void) { return g_test_resync_pending; }
#include "../src/elim.c"
#include "../src/resource_sync.c"
static char g_tmpdir[256];
void setUp(void)
{
snprintf(g_tmpdir, sizeof(g_tmpdir), "/tmp/xce_elim_testXXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(g_tmpdir));
memset(&xcecd_ctx, 0, sizeof(xcecd_ctx));
xcecd_ctx.virtual_name = (char*)"t1";
g_test_resync_pending = false;
g_send_load_indices_call_count = 0;
}
void tearDown(void)
{
/* Drop any registrations this test left so the next test starts from a
* clean registry: resource_sync_destroy on a throwaway object runs
* resource_sync_cleanup_load_indices() (static in the #included
* resource_sync.c, reachable only through this public entry). */
resource_sync_destroy(resource_sync_create());
if (g_tmpdir[0] != '\0') {
char path[300];
snprintf(path, sizeof(path), "rm -rf %s", g_tmpdir);
if (system(path) != 0) {
/* best effort */
}
g_tmpdir[0] = '\0';
}
}
static void write_file(const char* dir, const char* name, const char* content)
{
char path[512];
FILE* f;
snprintf(path, sizeof(path), "%s/%s", dir, name);
f = fopen(path, "w");
TEST_ASSERT_NOT_NULL(f);
fputs(content, f);
fclose(f);
}
/* ── elim config parse ────────────────────────────────────────────────── */
static void test_elim_config_load_full_file(void)
{
elim_config_t cfg;
int rc;
write_file(g_tmpdir, "elim.yaml",
"external_resources:\n"
" - name: dynres1\n"
" command: /opt/xce/scripts/report-dynres1.sh\n"
" interval: 30\n"
" - name: gpu_free\n"
" command: /usr/local/bin/gpu-free.sh\n"
" interval: 60\n");
rc = elim_config_load_path(g_tmpdir, "elim.yaml", &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(2, cfg.nsrc);
TEST_ASSERT_EQUAL_STRING("dynres1", cfg.src[0].name);
TEST_ASSERT_EQUAL_STRING("/opt/xce/scripts/report-dynres1.sh",
cfg.src[0].command);
TEST_ASSERT_EQUAL_INT(30, cfg.src[0].interval);
TEST_ASSERT_EQUAL_STRING("gpu_free", cfg.src[1].name);
TEST_ASSERT_EQUAL_INT(60, cfg.src[1].interval);
elim_config_fini(&cfg);
}
static void test_elim_config_interval_defaults_when_missing(void)
{
elim_config_t cfg;
int rc;
write_file(g_tmpdir, "elim.yaml",
"external_resources:\n"
" - name: dynres1\n"
" command: /opt/xce/scripts/r.sh\n");
rc = elim_config_load_path(g_tmpdir, "elim.yaml", &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(1, cfg.nsrc);
/* 0 = "every send tick" -- the getter cache refreshes each time the
* sync thread collects. */
TEST_ASSERT_EQUAL_INT(0, cfg.src[0].interval);
elim_config_fini(&cfg);
}
static void test_elim_config_missing_file_is_enoent(void)
{
elim_config_t cfg;
int rc;
rc = elim_config_load_path(g_tmpdir, "no-such-file.yaml", &cfg);
TEST_ASSERT_EQUAL_INT(-ENOENT, rc);
}
static void test_elim_config_missing_command_is_einval(void)
{
elim_config_t cfg;
int rc;
/* A source without a command can never report -- fail loudly at load,
* not silently at runtime (esd_config.c's validation rule). */
write_file(g_tmpdir, "elim.yaml",
"external_resources:\n"
" - name: dynres1\n"
" interval: 30\n");
rc = elim_config_load_path(g_tmpdir, "elim.yaml", &cfg);
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
}
static void test_elim_config_missing_name_is_einval(void)
{
elim_config_t cfg;
int rc;
write_file(g_tmpdir, "elim.yaml",
"external_resources:\n"
" - command: /opt/xce/scripts/r.sh\n");
rc = elim_config_load_path(g_tmpdir, "elim.yaml", &cfg);
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
}
static void test_elim_config_empty_or_absent_section_is_ok(void)
{
elim_config_t cfg;
int rc;
/* A worker config with no external_resources key is the normal case for
* every host without elim scripts -- must load to zero sources, not
* error. */
write_file(g_tmpdir, "elim.yaml", "external_resources: []\n");
rc = elim_config_load_path(g_tmpdir, "elim.yaml", &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, cfg.nsrc);
TEST_ASSERT_NULL(cfg.src);
elim_config_fini(&cfg);
}
/* ── elim output parse ────────────────────────────────────────────────── */
static void test_elim_parse_output_name_value_line(void)
{
double v;
/* The elim contract: first non-empty line carries "<name> <value>". */
v = elim_parse_script_output("dynres1 42\n");
TEST_ASSERT_FLOAT_WITHIN(1e-6, 42.0, v);
v = elim_parse_script_output("\n\n# comment-ish noise 7 8\ngpu_free 5.5\n");
TEST_ASSERT_FLOAT_WITHIN(1e-6, 7.0, v);
}
static void test_elim_parse_output_bare_value_line(void)
{
double v;
v = elim_parse_script_output("12\n");
TEST_ASSERT_FLOAT_WITHIN(1e-6, 12.0, v);
v = elim_parse_script_output(" 3.5 \n");
TEST_ASSERT_FLOAT_WITHIN(1e-6, 3.5, v);
}
static void test_elim_parse_output_garbage_is_nan(void)
{
TEST_ASSERT(isnan(elim_parse_script_output("no numbers here\n")));
TEST_ASSERT(isnan(elim_parse_script_output("")));
TEST_ASSERT(isnan(elim_parse_script_output(NULL)));
}
/* ── registry wiring: register at startup, before any collect ─────────── */
/* THE trap this phase fixes: before it, resource_sync_register_load_index
* called at daemon startup (when g_load_indices is still NULL -- the array
* is only allocated by collect_load_indices' lazy init_builtin_load_indices,
* i.e. on the sync thread's first 15th tick) failed with "capacity reached
* (0)" and every elim source silently vanished. Registration must
* self-initialize. */
static void test_register_load_index_before_lazy_init_succeeds(void)
{
bool ok;
/* No collect_load_indices() call ever happened in this process yet. */
TEST_ASSERT_NULL_MESSAGE(g_load_indices,
"precondition: registry not yet initialized");
ok = resource_sync_register_load_index("dynres1", Value_u_fvalue,
stub_getter, false);
TEST_ASSERT_TRUE_MESSAGE(
ok, "registering before the lazy builtin init must not fail with "
"'capacity reached (0)'");
/* And the value must actually flow: collect_load_indices now includes
* the elim index among the builtins. */
xce_resval_t* vals;
int count = 0;
int found = 0;
vals = collect_load_indices(&count);
TEST_ASSERT_NOT_NULL(vals);
for (int i = 0; i < count; i++) {
if (strcmp(vals[i].name, "dynres1") == 0) {
found++;
TEST_ASSERT_EQUAL_STRING("t1", vals[i].host);
TEST_ASSERT_EQUAL_INT(Value_u_fvalue, vals[i].val->which);
TEST_ASSERT_FLOAT_WITHIN(1e-6, 1.0, vals[i].val->u.fvalue);
}
/* The host tag is the join identity for every report (bug-1569). */
TEST_ASSERT_EQUAL_STRING("t1", vals[i].host);
}
TEST_ASSERT_EQUAL_INT(1, found);
free_resvals(vals, count);
}
static void test_elim_sources_register_and_flow_through_registry(void)
{
elim_config_t cfg;
int rc;
write_file(g_tmpdir, "elim.yaml",
"external_resources:\n"
" - name: dynres1\n"
" command: echo dynres1 7\n"
" interval: 0\n");
rc = elim_config_load_path(g_tmpdir, "elim.yaml", &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
rc = elim_register_sources(&cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
/* The getter runs the script: first collect refreshes the cache. */
xce_resval_t* vals;
int count = 0;
int found = 0;
vals = collect_load_indices(&count);
TEST_ASSERT_NOT_NULL(vals);
for (int i = 0; i < count; i++) {
if (strcmp(vals[i].name, "dynres1") == 0) {
found++;
TEST_ASSERT_EQUAL_INT(Value_u_fvalue, vals[i].val->which);
/* "echo dynres1 7" -> name-value line -> 7.0 */
TEST_ASSERT_FLOAT_WITHIN(1e-6, 7.0, vals[i].val->u.fvalue);
}
}
TEST_ASSERT_EQUAL_INT_MESSAGE(
1, found, "the elim-registered index must reach collect_load_indices");
free_resvals(vals, count);
}
static void test_elim_register_rejects_duplicate_name(void)
{
elim_config_t cfg;
int rc;
write_file(g_tmpdir, "elim.yaml",
"external_resources:\n"
" - name: dynres1\n"
" command: echo dynres1 1\n"
" - name: dynres1\n"
" command: echo dynres1 2\n");
rc = elim_config_load_path(g_tmpdir, "elim.yaml", &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
/* The registry rejects the dup; elim_register_sources reports the
* failure so the operator sees the misconfiguration at startup. */
TEST_ASSERT_EQUAL_INT(-EEXIST, elim_register_sources(&cfg));
}
static void test_elim_register_rejects_builtin_name_override(void)
{
elim_config_t cfg;
int rc;
/* "mem" is one of the 11 builtins -- a user script must not shadow it
* (the registry rejects the dup; the error surfaces at startup). */
write_file(g_tmpdir, "elim.yaml",
"external_resources:\n"
" - name: mem\n"
" command: echo mem 999\n");
rc = elim_config_load_path(g_tmpdir, "elim.yaml", &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(-EEXIST, elim_register_sources(&cfg));
}
static void test_elim_unregister_sources_cleans_up(void)
{
elim_config_t cfg;
int rc;
write_file(g_tmpdir, "elim.yaml",
"external_resources:\n"
" - name: dynres1\n"
" command: echo dynres1 1\n");
rc = elim_config_load_path(g_tmpdir, "elim.yaml", &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, elim_register_sources(&cfg));
TEST_ASSERT_TRUE(resource_sync_unregister_load_index("dynres1"));
TEST_ASSERT_FALSE(resource_sync_unregister_load_index("dynres1"));
}
/* ── the script runner: real child, real watchdog ─────────────────────── */
static void test_elim_script_collect_runs_command(void)
{
char out[128];
int rc;
const char* script;
script = "/bin/sh -c 'echo dynres1 42'";
rc = elim_script_collect(script, out, sizeof(out));
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_FLOAT_WITHIN(1e-6, 42.0, elim_parse_script_output(out));
}
static void test_elim_script_collect_timeout_kills_child(void)
{
char out[128];
int rc;
int saved_timeout;
const char* script;
/* A script that hangs must be killed at the watchdog deadline, not
* stall the sync thread forever (the esd_script watchdog contract). */
script = "/bin/sh -c 'sleep 60'";
saved_timeout = elim_script_timeout_ms;
elim_script_timeout_ms = 500;
rc = elim_script_collect(script, out, sizeof(out));
elim_script_timeout_ms = saved_timeout;
TEST_ASSERT_EQUAL_INT_MESSAGE(-ETIMEDOUT, rc,
"a hung script must hit the watchdog");
/* And the child must actually be reaped -- no zombie left behind. */
TEST_ASSERT_EQUAL_INT_MESSAGE(-1, waitpid(-1, NULL, WNOHANG),
"no unreaped child may survive the kill");
}
static void test_elim_script_collect_empty_output_is_enodata(void)
{
char out[128];
int rc;
rc = elim_script_collect("/bin/sh -c 'exit 0'", out, sizeof(out));
TEST_ASSERT_EQUAL_INT(-ENODATA, rc);
}
/* ── getter cache semantics: interval ─────────────────────────────────── */
static void test_elim_getter_refreshes_per_interval(void)
{
elim_config_t cfg;
xce_resval_t* vals;
int count = 0;
static char counter_script[512];
static char yaml[1024];
int v1 = -1, v2 = -1;
resource_sync_t* sync;
/* A script whose output changes on every run: the getter must return
* the FRESH value after the interval elapses (refresh), and the cached
* one before it (no re-run). */
snprintf(counter_script, sizeof(counter_script), "%s/counter.sh", g_tmpdir);
{
FILE* f = fopen(counter_script, "w");
TEST_ASSERT_NOT_NULL(f);
fprintf(f,
"#!/bin/sh\n"
"if [ -f %s.n ]; then n=$(cat %s.n); n=$((n+1)); else n=1; fi\n"
"echo $n > %s.n\n"
"echo \"dynres1 $n\"\n",
counter_script, counter_script, counter_script);
fclose(f);
chmod(counter_script, 0755);
}
snprintf(yaml, sizeof(yaml),
"external_resources:\n"
" - name: dynres1\n"
" command: %s\n"
" interval: 3\n",
counter_script);
write_file(g_tmpdir, "elim.yaml", yaml);
TEST_ASSERT_EQUAL_INT(0, elim_config_load_path(g_tmpdir, "elim.yaml", &cfg));
TEST_ASSERT_EQUAL_INT(0, elim_register_sources(&cfg));
/* First collect: getter runs the script (counter=1). */
vals = collect_load_indices(&count);
TEST_ASSERT_NOT_NULL(vals);
for (int i = 0; i < count; i++) {
if (strcmp(vals[i].name, "dynres1") == 0) {
v1 = (int)vals[i].val->u.fvalue;
}
}
free_resvals(vals, count);
TEST_ASSERT_EQUAL_INT(1, v1);
/* Collect again immediately (interval=3 not yet elapsed): the cached
* value is returned, the counter script does NOT advance. */
vals = collect_load_indices(&count);
TEST_ASSERT_NOT_NULL(vals);
for (int i = 0; i < count; i++) {
if (strcmp(vals[i].name, "dynres1") == 0) {
v2 = (int)vals[i].val->u.fvalue;
}
}
free_resvals(vals, count);
TEST_ASSERT_EQUAL_INT_MESSAGE(
1, v2, "getter must serve the cached value within its interval");
/* After the interval elapses the getter re-runs the script. Drive it
* through a real resource_sync tick loop rather than sleeping 3s in
* wall-clock: seed the counter and run two 1s ticks. */
sync = resource_sync_create();
TEST_ASSERT_NOT_NULL(sync);
sync->load_report_counter = LOAD_INDICES_INTERVAL_DEFAULT - 1;
TEST_ASSERT_TRUE(resource_sync_start(sync));
/* Two ticks cover the 1s sleep-per-tick of the sync thread; interval 3
* seconds measured from the first run has now elapsed. */
sleep(4);
vals = collect_load_indices(&count);
TEST_ASSERT_NOT_NULL(vals);
for (int i = 0; i < count; i++) {
if (strcmp(vals[i].name, "dynres1") == 0) {
v2 = (int)vals[i].val->u.fvalue;
}
}
free_resvals(vals, count);
resource_sync_stop(sync);
resource_sync_destroy(sync);
TEST_ASSERT_EQUAL_INT_MESSAGE(
2, v2, "getter must refresh the value once its interval elapses");
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_elim_config_load_full_file);
RUN_TEST(test_elim_config_interval_defaults_when_missing);
RUN_TEST(test_elim_config_missing_file_is_enoent);
RUN_TEST(test_elim_config_missing_command_is_einval);
RUN_TEST(test_elim_config_missing_name_is_einval);
RUN_TEST(test_elim_config_empty_or_absent_section_is_ok);
RUN_TEST(test_elim_parse_output_name_value_line);
RUN_TEST(test_elim_parse_output_bare_value_line);
RUN_TEST(test_elim_parse_output_garbage_is_nan);
RUN_TEST(test_register_load_index_before_lazy_init_succeeds);
RUN_TEST(test_elim_sources_register_and_flow_through_registry);
RUN_TEST(test_elim_register_rejects_duplicate_name);
RUN_TEST(test_elim_register_rejects_builtin_name_override);
RUN_TEST(test_elim_unregister_sources_cleans_up);
RUN_TEST(test_elim_script_collect_runs_command);
RUN_TEST(test_elim_script_collect_timeout_kills_child);
RUN_TEST(test_elim_script_collect_empty_output_is_enodata);
RUN_TEST(test_elim_getter_refreshes_per_interval);
return UNITY_END();
}

View file

@ -161,6 +161,17 @@ bool resource_sync_start(resource_sync_t* sync)
}
void resource_sync_stop(resource_sync_t* sync) { (void)sync; }
/* elim surface (elim.c, not linked here -- this test stubs every subsystem
* constructor the same way): xcecd_init() registers the configured sources
* (zero sources in this test's configs, so the real impl would no-op, but
* the symbol must resolve); xcecd_cleanup() unregisters. */
int elim_register_sources(const elim_config_t* cfg)
{
(void)cfg;
return 0;
}
void elim_unregister_sources(const elim_config_t* cfg) { (void)cfg; }
/* Referenced by xcecd_process_command()/xcecd_cleanup() -- never invoked at
* runtime by this test (only xcecd_init()/xcecd_start() are called), but
* their bodies are compiled unconditionally so the symbols must resolve. */