merge: sync Rongsong's scheduler-framework (122 commits, 070c7869..f01ae353)

Introduces WAL replay field-completeness fixes (bug-1687..1697, 1731), scheduler snapshot job-allocation restore, heap UAF fixes (vtask_enq, xcealloc, builtin_fcfs, limit), HA fixes (bug-1694/1695/1696), xceadmin retry, worker signal-exit severity, and their test suites.

Conflicts resolved keeping both sides (drop-audit passed):
- worker.c: mark_worker_jobs_exit (bug-1509 B1) + fetchfile_signal_worker_disconnect (bug-1693)
- handler.go: DefaultBroker.Publish (EventJobFinished) + bug-1731 logging
- config_repl.c / test_config_repl.c: our ns-resolution comment / unique-tmpdir
- test_dispatch_commit.c: both B3-UNKWN and bug-1697 start_time assertions

Excluded Rongsong's bookkeeping per policy: .wolf/, .kiro/, CHANGES.md, reports/;
.claude/ + CLAUDE.md kept ours (his settings.json has machine-absolute paths).
This commit is contained in:
lei.chen 2026-08-31 14:24:17 +00:00
commit 3b6f53bb65
168 changed files with 20744 additions and 568495 deletions

View file

@ -790,9 +790,16 @@ int restore_job_state(cluster_t* c, uint64_t jobid, uint32_t state)
* ownership of an optional decoded resource allocation (NULL for a
* state-only WAL_JOB_DISPATCH entry, e.g. one written before this fix) so a
* promoted standby recovers EXEC_HOST the same way apply_committed_entry's
* WAL_JOB_DISPATCH case already does for the primary's own boot replay. */
* WAL_JOB_DISPATCH case already does for the primary's own boot replay.
*
* bug-1697: also takes a wire-carried `start_time` (0 meaning "not carried
* -- leave job.start_time untouched", matching every sibling restore_job_*
* function's own "0 == not carried" convention). job.start_time is now
* DISPATCH-owned (see wal_types.h's WAL_JOB_START doc comment for the full
* rationale), so this is the seam that must restore it on a promoted
* standby, not WAL_JOB_START's own restore_job_start_state() below. */
int restore_job_dispatch_state(cluster_t* c, uint64_t jobid, uint32_t state,
xce_job_alloc_t* alloc)
uint64_t start_time, xce_job_alloc_t* alloc)
{
job_t* j = NULL;
lock(c->jobmgr, RTTI_LOCK_READ);
@ -806,6 +813,8 @@ int restore_job_dispatch_state(cluster_t* c, uint64_t jobid, uint32_t state,
lock(j, RTTI_LOCK_WRITE);
j->job.state = (int)state;
if (start_time != 0)
j->job.start_time = (time_t)start_time;
if (alloc != NULL) {
if (j->job.allocs != NULL)
free_JobAlloc_ptr(&(j->job.allocs));
@ -815,6 +824,310 @@ int restore_job_dispatch_state(cluster_t* c, uint64_t jobid, uint32_t state,
return 0;
}
/* bug-1687: WAL_JOB_START's own sibling of restore_job_dispatch_state()
* above (bug-1678), for apply_wal_entry()'s standby catch-up / replicated-
* apply seam -- takes ownership of an optional decoded job_pidinfo_t (NULL
* for a state-only WAL_JOB_START entry, e.g. one written before this fix)
* so a promoted standby recovers the worker-confirmed pids/pgids/exdata the
* same way apply_committed_entry's WAL_JOB_START case already does for the
* primary's own boot replay.
*
* bug-1697: no longer takes/touches start_time at all -- job.start_time is
* now exclusively DISPATCH-owned (restore_job_dispatch_state() above), so
* this function must leave it alone entirely rather than risk clobbering
* the correct dispatch-time value with a stale worker-ack-time one (see
* wal_types.h's WAL_JOB_START doc comment for the full rationale). */
int restore_job_start_state(cluster_t* c, uint64_t jobid, uint32_t state,
job_pidinfo_t* pidinfo)
{
job_t* j = NULL;
lock(c->jobmgr, RTTI_LOCK_READ);
key_find(c->jobmgr, (void*)jobid, (void**)&j);
unlock(c->jobmgr);
if (!j) {
if (pidinfo != NULL)
free_JobPIDInfo_ptr(&pidinfo);
return -ENOENT;
}
lock(j, RTTI_LOCK_WRITE);
j->job.state = (int)state;
if (pidinfo != NULL) {
if (j->job.pidinfo != NULL)
free_JobPIDInfo_ptr(&(j->job.pidinfo));
j->job.pidinfo = pidinfo;
}
unlock(j);
return 0;
}
/* bug-1689: WAL_JOB_FINISH's own sibling of restore_job_start_state()/
* restore_job_dispatch_state() above (bug-1687/bug-1678), for
* apply_wal_entry()'s standby catch-up / replicated-apply seam -- takes
* ownership of an optional decoded job_exitinfo_t (NULL for a state-only
* WAL_JOB_FINISH entry, e.g. one written before this fix) and a
* wire-carried finish_time (0 meaning "not carried -- leave
* job.finish_time/run_time untouched", matching this seam's own pre-fix
* behavior of never touching them for WAL_JOB_FINISH at all) so a promoted
* standby recovers the worker-confirmed exit_code and the true finish_time
* the same way apply_committed_entry's WAL_JOB_FINISH case already does for
* the primary's own boot replay. job_exitinfo_t has no nested pointers (a
* single int), so it is released with plain FREEUP, not a capnp
* free_*_ptr helper. */
int restore_job_finish_state(cluster_t* c, uint64_t jobid, uint32_t state,
uint64_t finish_time, job_exitinfo_t* exit_info)
{
job_t* j = NULL;
lock(c->jobmgr, RTTI_LOCK_READ);
key_find(c->jobmgr, (void*)jobid, (void**)&j);
unlock(c->jobmgr);
if (!j) {
FREEUP(exit_info);
return -ENOENT;
}
lock(j, RTTI_LOCK_WRITE);
j->job.state = (int)state;
if (finish_time != 0)
j->job.finish_time = (time_t)finish_time;
if (exit_info != NULL) {
FREEUP(j->job.exit_info);
j->job.exit_info = exit_info;
}
if (finish_time != 0)
j->job.run_time = (int)(j->job.finish_time - j->job.start_time);
unlock(j);
return 0;
}
/* bug-1690: merge (never wholesale free-and-replace) a freshly-decoded
* xce_job_runusage_t into *dst. Takes ownership of `src` -- always
* consumed/freed by this call (either adopted whole, partially cannibalized
* for its new-host entries, or fully released), regardless of outcome.
*
* WHY A MERGE, NOT A REPLACE: *dst can be the job's LIVE run_usage --
* update_job_rusages() (job.c) mutates it synchronously and in place, via
* update_host_runusage() (misc.c), BEFORE the corresponding WAL fact is even
* appended. On the primary, persist_apply_pump_run() (persist.c) later
* re-applies that SAME fact via apply_committed_entry() moments afterward,
* on the SAME live job object -- so *dst can genuinely be that live,
* already-current structure at the moment this runs, not just a stale
* replay artifact. Critically, update_host_runusage() sources each
* xce_host_runusage_t.host_name from makeString() (src/lib/xstring.c) -- an
* RTTI-managed, refcounted intern-table pointer, NOT a plain malloc()
* allocation. RTTI's own header-before-data layout (librtti's
* RTTI_DATA_TO_OBJ: the object header sits BEFORE the pointer callers hold)
* means a plain free() on that pointer -- which the capnp-generated
* free_JobRunusage_ptr()/free_HostRunusage_ptr() would do, being written for
* genuinely capnp-decoded, plain-malloc'd Text fields -- corrupts the heap.
* (job_destroy()'s own manual FREEUP-based walk over run_usage, jobj.c, has
* this exact same latent mismatch already, independent of this fix -- flagged
* separately, not fixed here, since jobj.c/misc.c are outside this fix's
* scope.)
*
* The merge below NEVER frees an existing host_name: an already-present
* host (matched by name) only has its scalar cpu_time/mem_usage overwritten
* in place; only a genuinely NEW host's own freshly-decoded (always
* plain-malloc'd) host_name is adopted, by ownership transfer, never a
* live/xstring one. `src`'s own shell plus every entry NOT transferred is
* released via the ordinary capnp free path at the end -- safe, since `src`
* is always capnp-decoded, end to end, regardless of what *dst was. */
void merge_job_runusage(xce_job_runusage_t** dst, xce_job_runusage_t* src)
{
xce_job_runusage_t* d;
int i, k;
if (*dst == NULL) {
/* Nothing live to preserve — adopt the decoded copy outright,
* exactly like every other restore_job_*_state() sibling. */
*dst = src;
return;
}
d = *dst;
/* Scalar job-level fields: no pointers, always safe to overwrite in
* place regardless of *dst's provenance. */
d->max_mem = src->max_mem;
d->avg_mem = src->avg_mem;
d->used_mem = src->used_mem;
d->cpu_time = src->cpu_time;
d->peak_cpu_time = src->peak_cpu_time;
d->peak_cpu_duration = src->peak_cpu_duration;
d->mem_efficiency = src->mem_efficiency;
d->avg_cpu_efficiency = src->avg_cpu_efficiency;
d->peak_cpu_efficiency = src->peak_cpu_efficiency;
for (i = 0; i < src->num_hosts; i++) {
xce_host_runusage_t* sh = src->host_runusage[i];
int matched = -1;
if (sh == NULL)
continue;
for (k = 0; k < d->num_hosts; k++) {
if (d->host_runusage[k] != NULL &&
d->host_runusage[k]->host_name != NULL &&
sh->host_name != NULL &&
strcmp(sh->host_name, d->host_runusage[k]->host_name) == 0) {
matched = k;
break;
}
}
if (matched >= 0) {
/* Existing host: overwrite scalars only. d's host_name pointer
* (possibly xstring-backed) is never touched or freed here. */
d->host_runusage[matched]->cpu_time = sh->cpu_time;
d->host_runusage[matched]->mem_usage = sh->mem_usage;
d->host_runusage[matched]->mem_swap = sh->mem_swap;
d->host_runusage[matched]->tasks_current = sh->tasks_current;
} else {
/* Genuinely new host: append, transferring ownership of sh's
* own plain-malloc'd host_name directly -- never xstring. */
xce_host_runusage_t** grown = (xce_host_runusage_t**)realloc(
d->host_runusage,
(size_t)(d->num_hosts + 1) * sizeof(xce_host_runusage_t*));
if (grown == NULL)
continue; /* OOM -- skip this host, keep the rest merged */
d->host_runusage = grown;
d->host_runusage[d->num_hosts] = sh;
d->num_hosts++;
src->host_runusage[i] = NULL; /* ownership transferred -- the
* free_JobRunusage_ptr() call
* below must not also free it */
}
}
/* Release src's shell + every entry whose ownership was NOT transferred
* above -- always safe, src is capnp-decoded end to end. */
free_JobRunusage_ptr(&src);
}
/* bug-1690: WAL_JOB_RUSAGE's own sibling of restore_job_state() above, for
* apply_wal_entry()'s standby catch-up / replicated-apply seam -- takes
* ownership of an optional decoded xce_job_runusage_t (NULL for a
* state-only WAL_JOB_RUSAGE entry -- either one written before this fix, or
* one fired while job.run_usage was still NULL at write time) so a promoted
* standby recovers the client-reported per-host CPU/mem usage the same way
* apply_committed_entry's WAL_JOB_RUSAGE case already does for the primary's
* own boot replay. Unconditional absolute state overwrite, matching every
* other restore_job_*_state() sibling on this seam -- no transition-result
* guarding happens on standby catch-up. Merges (never frees-and-replaces)
* run_usage -- see merge_job_runusage()'s doc comment above for why. */
int restore_job_rusage_state(cluster_t* c, uint64_t jobid, uint32_t state,
xce_job_runusage_t* runusage)
{
job_t* j = NULL;
lock(c->jobmgr, RTTI_LOCK_READ);
key_find(c->jobmgr, (void*)jobid, (void**)&j);
unlock(c->jobmgr);
if (!j) {
if (runusage != NULL)
free_JobRunusage_ptr(&runusage);
return -ENOENT;
}
lock(j, RTTI_LOCK_WRITE);
j->job.state = (int)state;
if (runusage != NULL)
merge_job_runusage(&(j->job.run_usage), runusage);
unlock(j);
return 0;
}
/* bug-1691: WAL_JOB_CKPT's own sibling of restore_job_state() above, for
* apply_wal_entry()'s standby catch-up / replicated-apply seam -- takes
* ownership of an optional decoded ckptinfo string (NULL for a state-only
* WAL_JOB_CKPT entry, e.g. one written before this fix, or one fired while
* job.ckptinfo was still NULL at write time) so a promoted standby recovers
* the checkpoint info the same way apply_committed_entry's WAL_JOB_CKPT
* branch already restores it on the primary's own boot replay. Absolute
* state overwrite, matching every other restore_job_*_state() sibling on
* this seam -- no transition-result guarding happens on standby catch-up.
*
* `ckptinfo` is a TRANSIENT, caller-owned decode buffer (persist.c's
* decode_ckptinfo()), NOT itself an xstring -- j->job.ckptinfo is
* xstring-backed (makeString(), xstring.c), so adoption always goes through
* makeString() (which copies its input into its own refcounted allocation)
* and the existing value is released via dereference() (the RTTI verb),
* never free()/FREEUP, mirroring on_job_ckpt()'s own
* dereference(j->job.ckptinfo) call. `ckptinfo` itself is always freed here
* before returning, regardless of whether adoption happened -- the value
* actually stored in j->job.ckptinfo is always a freshly-made makeString()
* copy, never the input pointer itself. */
int restore_job_ckpt_state(cluster_t* c, uint64_t jobid, uint32_t state,
char* ckptinfo)
{
job_t* j = NULL;
lock(c->jobmgr, RTTI_LOCK_READ);
key_find(c->jobmgr, (void*)jobid, (void**)&j);
unlock(c->jobmgr);
if (!j) {
FREEUP(ckptinfo);
return -ENOENT;
}
lock(j, RTTI_LOCK_WRITE);
j->job.state = (int)state;
if (ckptinfo != NULL) {
if (j->job.ckptinfo != NULL)
dereference(j->job.ckptinfo);
j->job.ckptinfo = makeString(ckptinfo);
}
unlock(j);
FREEUP(ckptinfo);
return 0;
}
/* bug-1692: WAL_JOB_PREASON's own sibling of restore_job_ckpt_state() above,
* for apply_wal_entry()'s standby catch-up / replicated-apply seam.
*
* has_field distinguishes a pre-fix bare {jobid,state} payload (has_field=0,
* pend_reasons left untouched -- full backward compatibility) from a
* post-fix payload that explicitly carries pend_reasons' new value
* (has_field=1): preason non-NULL adopts a freshly-decoded value, preason
* NULL means the field was genuinely cleared (do_clr_preason) and
* pend_reasons should be reset to NULL too -- pend_reasons is the one
* field-parity fact type in this family (bug-1678/1687/1689/1690/1691) with
* a real CLEAR operation, so unlike restore_job_ckpt_state()/
* restore_job_rusage_state() a NULL decoded value is NOT automatically
* "nothing to adopt".
*
* preason (when non-NULL) is always freshly heap-allocated by
* decode_jobpreason() (persist.c) -- plain calloc()/strdup() throughout
* (verified via erase_job_preason()'s FREEUP-only release and every
* add_*_preason() constructor in src/lib/preason.c, plus the
* capnp-generated decode_HostBasedReason/decode_ResourceBasedReason using
* STRING_DUP+calloc), NOT xstring/RTTI-backed like ckptinfo or rusage's
* host_name -- so a straightforward free-old-then-adopt-new via
* free_JobPendReason_ptr() is safe here, no merge-not-replace treatment
* needed (unlike bug-1690's WAL_JOB_RUSAGE). Ownership of preason always
* transfers to job->job.pend_reasons on success; released here if the job
* is gone. */
int restore_job_preason_state(cluster_t* c, uint64_t jobid, uint32_t state,
int has_field, xce_job_preason_t* preason)
{
job_t* j = NULL;
lock(c->jobmgr, RTTI_LOCK_READ);
key_find(c->jobmgr, (void*)jobid, (void**)&j);
unlock(c->jobmgr);
if (!j) {
if (preason != NULL)
free_JobPendReason_ptr(&preason);
return -ENOENT;
}
lock(j, RTTI_LOCK_WRITE);
j->job.state = (int)state;
if (has_field) {
if (j->job.pend_reasons != NULL)
free_JobPendReason_ptr(&(j->job.pend_reasons));
j->job.pend_reasons = preason; /* NULL (cleared) or the decoded value */
}
unlock(j);
return 0;
}
static int get_nslots_in_alloc(xce_host_alloc_t* alloc)
{
int i;
@ -1384,6 +1697,18 @@ static int dispatch_a_job_(cluster_t* c, endpoint_t* ep, xce_job_alloc_t* alloc)
j->job.state = JOB_STAT_RUN;
/* bug-1697: start_time is now stamped HERE, synchronously alongside
* the RUN transition, rather than left to the worker's later
* WAL_JOB_START report. dispatch_a_job_() setting job.state =
* JOB_STAT_RUN synchronously means any WAL_JOB_START fact for this
* job always finds state already == RUN by the time it applies
* (apply_job_update_transition() reports JOB_TRANSITION_NOOP_
* DUPLICATE), so it can never be the genuine first RUN transition
* and start_time adoption gated on that transition would be
* permanently dead code -- see wal_types.h's WAL_JOB_START doc
* comment for the full rationale. */
j->job.start_time = time(NULL);
/* Reset the consecutive-retryable-failure counter (task 1.3): a
* fresh successful dispatch clears any prior failure history, so a
* job that later fails again is evaluated against the bound as if
@ -1399,9 +1724,14 @@ static int dispatch_a_job_(cluster_t* c, endpoint_t* ep, xce_job_alloc_t* alloc)
* shutdown. j->job.allocs is read here, still under this same
* write lock, as the freshly-constructed canonical struct just
* stored above -- not the (now partially moved-from) local
* `alloc`/`allocs` variables. */
* `alloc`/`allocs` variables. bug-1697: also carries j->job.
* start_time, the same snapshot just stamped above under this same
* write lock, so a restart replay recovers the true dispatch-time
* clock instead of leaving it at 0 forever. */
(void)persist_submit_job_dispatch_fact(&c->persist, alloc->jobid,
JOB_STAT_RUN, j->job.allocs);
JOB_STAT_RUN,
(uint64_t)j->job.start_time,
j->job.allocs);
unlock(j);
@ -2190,9 +2520,25 @@ static int record_job_start_fact(cluster_t* c, job_t* job, job_pidinfo_t* info)
* 5.1, 5.3). JOB_STAT_RUN is the resultant state a START always
* produces once past the guard above; apply_committed_entry() will
* re-validate the same guard against whatever state is current AT
* APPLY TIME before setting it. */
(void)persist_submit_job_fact(&c->persist, WAL_JOB_START, job->job.jobid,
JOB_STAT_RUN);
* APPLY TIME before setting it.
*
* bug-1687: persist_submit_job_start_fact() carries `info` (the real
* worker-reported pids/pgids/exdata) and a receipt-time start_time
* alongside {jobid, state=RUN} -- see that function's doc comment and
* wal_types.h's WAL_JOB_START payload note for the full rationale
* (mirrors bug-1678's WAL_JOB_DISPATCH fix). Before this fix,
* persist_submit_job_fact()'s bare {jobid, state} payload silently
* dropped `info` entirely, so job.pidinfo replayed permanently NULL and
* job.start_time was re-fabricated (time(NULL)) fresh at every replay
* instead of carrying the true worker-reported time. Capturing
* start_time HERE, once, at receipt time (rather than re-deriving
* time(NULL) independently at each of apply_committed_entry's/
* apply_wal_entry's OWN apply-time moments) makes it the single
* authoritative value both apply seams converge on. */
time_t start_time = time(NULL);
(void)persist_submit_job_start_fact(&c->persist, job->job.jobid,
JOB_STAT_RUN, (uint64_t)start_time,
info);
return 0;
}
@ -2297,10 +2643,28 @@ static int record_job_finish_fact(cluster_t* c, job_t* job,
/* Durable append only — no mutation, no AFTER_EVENT firing here (Req
* 5.1, 5.3). The resultant state is deterministic from finish->exit_code
* alone, independent of current job state. */
(void)persist_submit_job_fact(&c->persist, WAL_JOB_FINISH, job->job.jobid,
(finish->exit_code == 0) ? JOB_STAT_DONE
: JOB_STAT_EXIT);
* alone, independent of current job state.
*
* bug-1689: persist_submit_job_finish_fact() carries a receipt-time
* finish_time and the worker-reported exit_code alongside {jobid,
* state} -- see that function's doc comment and wal_types.h's
* WAL_JOB_FINISH payload note for the full rationale (mirrors
* bug-1687's WAL_JOB_START fix). Before this fix, persist_submit_job_
* fact()'s bare {jobid, state} payload silently dropped both: job.
* exit_info replayed permanently NULL and job.finish_time (and its
* derived job.run_time) were re-fabricated (time(NULL)) fresh at every
* apply instead of carrying the true receipt-time value. Capturing
* finish_time HERE, once, at receipt time (rather than re-deriving
* time(NULL) independently at each of apply_committed_entry's/
* apply_wal_entry's OWN apply-time moments) makes it the single
* authoritative value both apply seams converge on. */
time_t finish_time = time(NULL);
(void)persist_submit_job_finish_fact(&c->persist, job->job.jobid,
(finish->exit_code == 0)
? JOB_STAT_DONE
: JOB_STAT_EXIT,
(uint64_t)finish_time,
finish->exit_code);
return 0;
}
@ -3124,13 +3488,21 @@ int update_job_rusages(cluster_t* c, endpoint_t* ep, xce_request_t* request)
update_host_runusage(&(j->job.run_usage), host, rusages->count,
rusages->usages);
/* bug-1735: derive job-level max_mem/avg_mem/used_mem/cpu_time from the
* per-host entries update_host_runusage() just updated -- replaces the
* standing "update other fields of job rusage later" FIXME. */
aggregate_job_runusage(j->job.run_usage);
update_pids(&(j->job.pidinfo), rusages->npids, rusages->pids);
update_pgids(&(j->job.pidinfo), rusages->npgids, rusages->pgids);
/* FIXME
* update other fields of job rusage later
*/
/* TEMP diagnostic (bug-1731 job-finish-notify-loss investigation).
* DEBUG3-gated -- see bug-1731's own "re-add before next occurrence"
* recommendation; the DEBUG3 log-retention bump (bug-1731 support,
* main.c) exists so this survives a live burst without rotating out. */
TRACE("DIAG update_job_rusages: jobid=%ld state=0x%x (about to fire "
"live JOB_UPDATE hook)",
(long)j->job.jobid, (unsigned)j->job.state);
xce_run_hooks(&(c->hooksys), XCE_CONTROLLER_HOOK_JOB, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_UPDATE, c, j);
@ -3265,6 +3637,11 @@ int do_clr_preason(cluster_t* c, endpoint_t* ep, xce_request_t* request)
typedef struct fetchfile_pending {
uint64_t jobid;
worker_t* worker; /* bug-1693: which worker this request was
* proxied to -- lets a disconnect be
* matched to the pending waiter(s) it
* affects; see
* fetchfile_signal_worker_disconnect(). */
pthread_mutex_t mu;
pthread_cond_t cv;
int done;
@ -3322,6 +3699,78 @@ void fetchfile_signal(uint64_t jobid, int status, xce_fetchfile_reply_t* reply)
pthread_mutex_unlock(&p->mu);
}
/* bug-1693 (Decision Log: "No client reply-wait timeout as a fix for a hung
* request -- fail loud at the real transport/source instead of masking with
* a deadline backstop"). Before this fix, handle_worker_leave() (worker.c)
* only ever nulled w->ep on a worker disconnect -- it never woke a pending
* on_fetchfile() waiter proxied to that worker, so a mid-wait disconnect
* could only ever be observed at on_fetchfile's own 10s deadline below.
* Called from handle_worker_leave() immediately on disconnect. Unlike
* fetchfile_signal() (keyed by jobid, at most one match), more than one
* fetchfile request can legitimately be in flight against the SAME worker
* at once, so this walks the whole list and signals every match -- a no-op
* if none are pending against `w`. Deliberately takes only g_fetchfile_mu +
* each matched waiter's own mu, never any worker_t lock, so callers do not
* need to hold (or avoid holding) `w`'s lock around this call. */
void fetchfile_signal_worker_disconnect(worker_t* w)
{
fetchfile_pending_t* p;
pthread_mutex_lock(&g_fetchfile_mu);
for (p = g_fetchfile_list; p != NULL; p = p->next) {
if (p->worker != w) continue;
pthread_mutex_lock(&p->mu);
p->status = -ECONNRESET;
p->reply = NULL;
p->done = 1;
pthread_cond_signal(&p->cv);
pthread_mutex_unlock(&p->mu);
}
pthread_mutex_unlock(&g_fetchfile_mu);
}
#ifdef XCE_TESTING
/* Test seam (bug-1693): exercises fetchfile_signal_worker_disconnect()'s
* demux against a real fetchfile_pending_t wait, without needing a full
* on_fetchfile()/real c->jobmgr+c->workermgr fixture (job/worker lookup is
* out of this fix's scope -- only the disconnect-to-waiter signal path is
* under test). Registers a bare pending entry keyed to `w`, blocks with a
* deadline exactly like on_fetchfile's own wait loop below, and reports the
* outcome via *out_status. Returns 1 if woken by a signal, 0 if the
* deadline lapsed first (mirrors pending.done). */
int fetchfile_wait_for_test(worker_t* w, long timeout_ms, int* out_status)
{
fetchfile_pending_t pending = {
.jobid = 0, .worker = w,
.mu = PTHREAD_MUTEX_INITIALIZER, .cv = PTHREAD_COND_INITIALIZER,
.done = 0, .status = -ETIMEDOUT, .reply = NULL, .next = NULL
};
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += timeout_ms / 1000;
deadline.tv_nsec += (timeout_ms % 1000) * 1000000L;
if (deadline.tv_nsec >= 1000000000L) {
deadline.tv_sec += 1;
deadline.tv_nsec -= 1000000000L;
}
fetchfile_register(&pending);
pthread_mutex_lock(&pending.mu);
while (!pending.done) {
int wrc = pthread_cond_timedwait(&pending.cv, &pending.mu, &deadline);
if (wrc == ETIMEDOUT) break;
}
pthread_mutex_unlock(&pending.mu);
fetchfile_unregister(&pending);
*out_status = pending.status;
return pending.done;
}
#endif
/* ---- on_fetchfile: proxy fetchfile from apiserver to worker ---- */
int on_fetchfile(cluster_t* c, endpoint_t* caller_ep, xce_request_t* request)
@ -3405,6 +3854,12 @@ int on_fetchfile(cluster_t* c, endpoint_t* caller_ep, xce_request_t* request)
return -ENOENT;
}
/* bug-1693: record which worker this request is proxied to (still
* holding w's read lock, so w->ep/identity are stable here) so a later
* disconnect can find and wake this waiter -- see
* fetchfile_signal_worker_disconnect()'s doc comment above. */
pending.worker = w;
/* Register pending before sending to avoid a race with the reply */
fetchfile_register(&pending);
rc = xce_fetchfile_req(w->ep, msg);