fix(scheduler): repeat WORKERS_PUSH with a NULL resval clobbers good values (bug-1688)

info worker all/<name> in the interactive/sim REPL initially printed
full per-resource values correctly, then lost them after a later
WORKERS_PUSH event replayed. core_apply_worker_resvals()'s per-resval
loop only guarded against nv==NULL/nv->name==NULL; a resval entry with
a NULL nv->val (a legitimate "no update this round" marker some
producers send) fell through and overwrote the worker's existing good
value with nothing, instead of being skipped -- same defensive pattern
already used elsewhere in this function, just missing here. A related
count-doubling gap in the same event path is fixed by switching an
iteration bound from `.count` to `.caps`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgYRU3zDTqNGV5Jk8aiKLg
This commit is contained in:
Rongsong Shen 2026-08-27 08:26:20 +00:00
parent 47532f0a76
commit 0ebf3a817f
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
4 changed files with 286 additions and 3 deletions

View file

@ -644,6 +644,147 @@ static void test_workers_push_twice_updates_existing_worker_in_place(void)
TEST_ASSERT_EQUAL_FLOAT(200.0f, (float)applied->u.fvalue);
}
/*
* bug-1688 regression: a WORKERS_PUSH re-announcing an already-registered
* worker's resource NAME list can legitimately carry nv->val == NULL for
* some/all entries (controller.capnp.c's decode_Value_ptr() decodes a wire
* NameValue.val CAPN_NULL pointer to exactly this -- live-traced against a
* real replay: a repeat push for worker 'pc13v' carried 20 resource names
* with nv->val == NULL for every one). Before this fix,
* core_apply_worker_resvals() unconditionally zarray_put()'d this NULL,
* silently clobbering a previously-known-good resource value with NULL.
* Because zarray_put_ex() (zarray.c) only increments array->count when the
* slot being written was NULL beforehand, that first NULL-write left
* resvals.count unchanged (the old slot was non-NULL) while zeroing the
* slot's DATA -- so a THIRD push, now finding every slot already NULL, hit
* the "was NULL, count++" branch for each one, doubling resvals.count (the
* literal 20 -> 40 anomaly this bug was reported with) despite carrying the
* exact same resource names both times. Two resources (not one) are used
* here so the count-doubling assertion is meaningful, not a 1-vs-2
* coincidence.
*/
static void test_workers_push_with_null_val_does_not_clobber_existing_resval(
void)
{
xce_sched_core_t core;
xce_core_event_t event;
xce_resource_t wire_mem;
xce_resource_t wire_r1m;
xce_worker_t wire_worker;
xce_worker_t* worker_ptrs[1];
xce_val_t val_mem;
xce_val_t val_r1m;
xce_name_val_t nv_mem;
xce_name_val_t nv_r1m;
xce_name_val_t* nv_ptrs[2];
xce_name_val_t nv_mem_null;
xce_name_val_t nv_r1m_null;
xce_name_val_t* nv_null_ptrs[2];
xce_sched_worker_t* w;
xce_resource_t* r_mem = NULL;
xce_resource_t* r_r1m = NULL;
xce_val_t* applied;
int rc;
fixture_init(&core, &g_full_plugin);
memset(&wire_mem, 0, sizeof(wire_mem));
wire_mem.name = "mem";
TEST_ASSERT_NOT_NULL(makeResource(core.resmgr, &wire_mem));
memset(&wire_r1m, 0, sizeof(wire_r1m));
wire_r1m.name = "r1m";
TEST_ASSERT_NOT_NULL(makeResource(core.resmgr, &wire_r1m));
TEST_ASSERT_TRUE(key_find(core.resmgr, (void*)"mem", (void**)&r_mem) >= 0);
TEST_ASSERT_NOT_NULL(r_mem);
TEST_ASSERT_TRUE(key_find(core.resmgr, (void*)"r1m", (void**)&r_r1m) >= 0);
TEST_ASSERT_NOT_NULL(r_r1m);
/* First push: worker "hostA" joins with real mem/r1m values. */
memset(&val_mem, 0, sizeof(val_mem));
val_mem.which = Value_u_fvalue;
val_mem.u.fvalue = 100.0;
memset(&nv_mem, 0, sizeof(nv_mem));
nv_mem.name = "mem";
nv_mem.val = &val_mem;
memset(&val_r1m, 0, sizeof(val_r1m));
val_r1m.which = Value_u_fvalue;
val_r1m.u.fvalue = 5.0;
memset(&nv_r1m, 0, sizeof(nv_r1m));
nv_r1m.name = "r1m";
nv_r1m.val = &val_r1m;
nv_ptrs[0] = &nv_mem;
nv_ptrs[1] = &nv_r1m;
memset(&wire_worker, 0, sizeof(wire_worker));
wire_worker.name = "hostA";
wire_worker.status = 0;
wire_worker.nresvals_to_sched = 2;
wire_worker.resvals_to_sched = nv_ptrs;
worker_ptrs[0] = &wire_worker;
memset(&event, 0, sizeof(event));
event.type = XCE_CORE_EVENT_WORKERS_PUSH;
event.u.workers_push.nworkers = 1;
event.u.workers_push.workers = worker_ptrs;
rc = xce_core_push_events(&core, &event, 1);
TEST_ASSERT_EQUAL_INT(0, rc);
w = sched_get_worker(&core, "hostA");
TEST_ASSERT_NOT_NULL(w);
TEST_ASSERT_EQUAL_INT(2, w->resvals.count);
applied = (xce_val_t*)zarray_get(&(w->resvals), r_mem->idx);
TEST_ASSERT_NOT_NULL(applied);
TEST_ASSERT_EQUAL_FLOAT(100.0f, (float)applied->u.fvalue);
applied = (xce_val_t*)zarray_get(&(w->resvals), r_r1m->idx);
TEST_ASSERT_NOT_NULL(applied);
TEST_ASSERT_EQUAL_FLOAT(5.0f, (float)applied->u.fvalue);
/* Second push: SAME worker, SAME resource names, but nv->val == NULL
* for both -- the exact "re-announce names, no fresh values" shape
* live-traced from the real replay. Must NOT clobber the existing
* values, and must NOT change resvals.count. */
memset(&nv_mem_null, 0, sizeof(nv_mem_null));
nv_mem_null.name = "mem";
nv_mem_null.val = NULL;
memset(&nv_r1m_null, 0, sizeof(nv_r1m_null));
nv_r1m_null.name = "r1m";
nv_r1m_null.val = NULL;
nv_null_ptrs[0] = &nv_mem_null;
nv_null_ptrs[1] = &nv_r1m_null;
wire_worker.resvals_to_sched = nv_null_ptrs;
rc = xce_core_push_events(&core, &event, 1);
TEST_ASSERT_EQUAL_INT(0, rc);
w = sched_get_worker(&core, "hostA");
TEST_ASSERT_NOT_NULL(w);
TEST_ASSERT_EQUAL_INT(2, w->resvals.count);
applied = (xce_val_t*)zarray_get(&(w->resvals), r_mem->idx);
TEST_ASSERT_NOT_NULL(applied);
TEST_ASSERT_EQUAL_FLOAT(100.0f, (float)applied->u.fvalue);
applied = (xce_val_t*)zarray_get(&(w->resvals), r_r1m->idx);
TEST_ASSERT_NOT_NULL(applied);
TEST_ASSERT_EQUAL_FLOAT(5.0f, (float)applied->u.fvalue);
/* Third push: SAME shape again (still NULL vals) -- if the bug were
* present, resvals.count would double here (20 -> 40 in the live
* trace; 2 -> 4 here) because the SECOND push (above) would have
* already nulled the slots, making THIS push's writes look like
* fresh/first-time occupancy. Confirms the fix holds across repeats,
* not just once. */
rc = xce_core_push_events(&core, &event, 1);
TEST_ASSERT_EQUAL_INT(0, rc);
w = sched_get_worker(&core, "hostA");
TEST_ASSERT_NOT_NULL(w);
TEST_ASSERT_EQUAL_INT(2, w->resvals.count);
}
/* JOB_RUSAGE's on_job_rusage hook is OPTIONAL (Req 5 AC 4) -- a picker
* without it must still apply the state mutation and must not error or
* crash. */
@ -834,6 +975,7 @@ int main(void)
RUN_TEST(test_jobs_push_fires_on_job_push_once_per_job);
RUN_TEST(test_non_picker_events_mutate_state_but_never_call_hooks);
RUN_TEST(test_workers_push_twice_updates_existing_worker_in_place);
RUN_TEST(test_workers_push_with_null_val_does_not_clobber_existing_resval);
RUN_TEST(test_job_rusage_applies_state_when_hook_is_null);
RUN_TEST(test_config_push_decomposes_queues_resources_and_resvals);
RUN_TEST(test_push_events_rejects_with_ebusy_when_session_active_no_mutation);