fix(esd): re-join after transparent reconnect -- bug-1978

A controller restart dropped xceesd's resval pushes forever: the fresh
endpoint is created by handle_connection() with priv->role =
HostRole_client, and only OPCode_join sets HostRole_extres -- but the
old esd_on_reconnect cleared only g_needs_full_push, so the daemon
re-PUSHED onto an un-joined endpoint and every push landed in
do_update_resvals's neither-branch, refused -EPERM forever (live
sighting: pushes_ok frozen, rc=-1 every 10s).

Mirror the worker's needs_rejoin contract: the reconnect handler now
sets BOTH g_needs_rejoin and g_needs_full_push; the main loop consumes
the re-join first (xcecli_join(HostRole_extres) on the main thread,
outside the failover lock -- the xcecd_on_reconnect no-I/O constraint),
retrying next tick on failure, then does the full re-push.

Live t1/t2 proof: controller restart -> "esd: re-joined after
reconnect (rc=0)" + "full re-push: 1/1 source(s)" + extlic back in
`xcecli resources distribution list`. Two new white-box unit tests pin
the both-flags contract (13/13 in the esd suite).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-10-28 14:07:28 +00:00
parent b38ef6de63
commit b471ce31ca
5 changed files with 219 additions and 7 deletions

View file

@ -23,10 +23,10 @@ xceesd = executable('xceesd',
dependencies: esd_deps,
install: true)
# Unit tests: white-box #include of the pure-core sources (config parse,
# script-output parse) -- same convention as the worker's
# resource_sync_test. The join/reconnect wiring (esd.c) drives the live
# client library and is covered by the t1 E2E, not unit tests.
# Unit tests: white-box #include of the core sources (config parse,
# script-output parse, status writer, and -- since bug-1978 -- esd.c's
# reconnect-flag contract; esd_run's live-join sequence is still covered by
# the t1 E2E) -- same convention as the worker's resource_sync_test.
esd_test_incdir = include_directories('include', '../include', '../lib', '.')
esd_test_deps = [libxce_dep, yaml_deps, tinylog_dep, unity_dep]

View file

@ -159,14 +159,24 @@ int esd_push_all(void)
/* --rejoin hook (client library contract): the library calls this while
* holding its failover lock -- no I/O, no xcecli_* calls (would self-
* deadlock, the worker's xcecd_on_reconnect lesson, controller_connector.c).
* We only set a flag; the main loop consumes it after the lock is free. */
* We only set flags; the main loop consumes them after the lock is free. */
static volatile int g_needs_full_push = 0;
static volatile int g_needs_rejoin = 0;
static int esd_on_reconnect(xcecli_ctx_t ctx, void* arg)
{
(void)ctx;
(void)arg;
/* bug-1978: a re-PUSH alone is not enough -- the fresh controller
* endpoint was created by handle_connection() with priv->role =
* HostRole_client, and only OPCode_join sets it to HostRole_extres
* (join.c). Without the re-join every resval push lands in
* do_update_resvals's neither-branch and is refused -EPERM forever
* (the live sighting: pushes_ok frozen, rc=-1 on every attempt after
* a controller restart). Mirror the worker's needs_rejoin contract:
* the main loop re-issues the role join, then does the full re-push. */
g_needs_rejoin = 1;
g_needs_full_push = 1;
return 0;
@ -250,6 +260,28 @@ int esd_run(void)
while (g_esd.running) {
int i;
if (g_needs_rejoin) {
/* bug-1978: a transparent reconnect just landed -- the fresh
* controller endpoint defaults to HostRole_client, so the role
* join must be re-issued BEFORE any push (an un-joined push is
* refused -EPERM). This runs on the main loop, outside the
* library's failover lock (the xcecd_on_reconnect constraint).
* Failure keeps the flag set: the next tick retries, mirroring
* the worker's needs_rejoin self-healing cadence. rc >= 0 is
* success for the same reason as the worker's rejoin (the
* controller's reply.status for a plain extres join is 0, but
* the failover wrapper surfaces its own codes on success). */
int rc = xcecli_join(g_esd.apictx, HostRole_extres, NULL);
if (rc >= 0) {
g_needs_rejoin = 0;
INFO("esd: re-joined after reconnect (rc=%d)", rc);
} else {
WARN("esd: re-join after reconnect failed (rc=%d) -- "
"retrying next tick",
rc);
}
}
if (g_needs_full_push) {
/* A transparent reconnect just landed (new primary after
* failover, or the old controller restarted): it has no
@ -272,8 +304,10 @@ int esd_run(void)
* The broken-connection flag is set asynchronously by the
* transport's RDHUP handler (bug-1760's pure predicate): when it
* latches, force one full re-push -- the write attempt triggers the
* rotate/reconnect, and esd_on_reconnect's flag keeps the full
* re-push contract for the failover path too. */
* rotate/reconnect, and esd_on_reconnect's flags keep the
* re-join + full re-push contract for the failover path too
* (bug-1978: the re-push alone was refused -EPERM on the un-joined
* fresh endpoint). */
if (g_esd.cfg.nsrc > 0 && xcecli_is_connection_broken(g_esd.apictx)) {
INFO("esd: connection broken -- forcing a full re-push to "
"reconnect");

View file

@ -26,6 +26,13 @@
/* White-box: the stale predicate and the status-file writer (Phase 2,
* design.md 3.5 "运维面"). */
#include "../src/esd_status.c"
/* White-box: the reconnect-flag contract (bug-1978). esd.c's main loop
* drives the live client library, but the reconnect handler's flag contract
* -- set BOTH rejoin and full-push, and the loop consuming rejoin first --
* is unit-testable here without a controller: esd_run() itself is not
* linked (its join sequence would need a live primary); we drive
* esd_on_reconnect and read the loop's guard statics directly. */
#include "../src/esd.c"
static char g_tmpdir[256];
@ -373,6 +380,38 @@ static void test_status_write_marks_stale(void)
free(cfg.src);
}
/* ── bug-1978: the reconnect-flag contract ───────────────────────────── */
static void test_reconnect_sets_both_rejoin_and_full_push(void)
{
g_needs_rejoin = 0;
g_needs_full_push = 0;
TEST_ASSERT_EQUAL_INT(0, esd_on_reconnect((xcecli_ctx_t)0x1, NULL));
/* BOTH flags: the re-join must happen before any re-push (an un-joined
* push is refused -EPERM on the fresh endpoint), and the re-push must
* still happen (the fresh controller knows no values). The bug-1978
* defect cleared only g_needs_full_push -- a daemon that re-pushed onto
* an un-joined HostRole_client endpoint, every push -EPERM forever. */
TEST_ASSERT_EQUAL_INT(1, g_needs_rejoin);
TEST_ASSERT_EQUAL_INT(1, g_needs_full_push);
}
static void test_reconnect_flags_recover_from_any_previous_state(void)
{
/* The handler is level-set, not level-toggle: regardless of what a
* previous (already consumed) reconnect left behind, a new callback
* invocation re-arms both flags. */
g_needs_rejoin = 0;
g_needs_full_push = 1; /* stale leftover from an unconsumed cycle */
TEST_ASSERT_EQUAL_INT(0, esd_on_reconnect((xcecli_ctx_t)0x2, NULL));
TEST_ASSERT_EQUAL_INT(1, g_needs_rejoin);
TEST_ASSERT_EQUAL_INT(1, g_needs_full_push);
}
int main(void)
{
UNITY_BEGIN();
@ -388,6 +427,8 @@ int main(void)
RUN_TEST(test_stale_predicate);
RUN_TEST(test_status_write_basic);
RUN_TEST(test_status_write_marks_stale);
RUN_TEST(test_reconnect_sets_both_rejoin_and_full_push);
RUN_TEST(test_reconnect_flags_recover_from_any_previous_state);
return UNITY_END();
}