Skip to content

Commit ae5de7f

Browse files
authored
Merge pull request #1920 from OhOkThisIsFine/claude/focused-herschel-ee8e1c
fix(daemon): contain zombie generations from abandoned requests, name mute endpoint holders
2 parents 0a8a1f2 + fc1b1ee commit ae5de7f

8 files changed

Lines changed: 434 additions & 14 deletions

File tree

‎src/daemon/bootstrap.c‎

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -359,11 +359,20 @@ static cbm_daemon_bootstrap_status_t bootstrap_finish_probe(
359359

360360
static cbm_daemon_bootstrap_probe_status_t bootstrap_probe(
361361
const cbm_daemon_bootstrap_config_t *config, const cbm_daemon_bootstrap_ops_t *ops,
362-
cbm_daemon_runtime_client_t **client_out, cbm_daemon_runtime_connect_result_t *connect_result) {
362+
cbm_daemon_runtime_client_t **client_out, cbm_daemon_runtime_connect_result_t *connect_result,
363+
uint64_t *muted_holder_pid_io) {
363364
memset(connect_result, 0, sizeof(*connect_result));
364365
*client_out = NULL;
365-
return ops->probe(ops->context, config->endpoint, config->identity, config->connect_timeout_ms,
366-
client_out, connect_result);
366+
cbm_daemon_bootstrap_probe_status_t status =
367+
ops->probe(ops->context, config->endpoint, config->identity, config->connect_timeout_ms,
368+
client_out, connect_result);
369+
/* Sticky across probes: a later fast-path probe never attempts a connect
370+
* and reports no holder, but a mute holder seen once must survive into
371+
* the final timeout diagnostic. */
372+
if (muted_holder_pid_io && connect_result->muted_endpoint_holder_pid != 0) {
373+
*muted_holder_pid_io = connect_result->muted_endpoint_holder_pid;
374+
}
375+
return status;
367376
}
368377

369378
static bool bootstrap_probe_is_finishable(cbm_daemon_bootstrap_probe_status_t probe) {
@@ -432,8 +441,9 @@ cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute_with_ops(
432441

433442
cbm_daemon_runtime_client_t *client = NULL;
434443
cbm_daemon_runtime_connect_result_t connect_result;
444+
uint64_t muted_holder_pid = 0;
435445
cbm_daemon_bootstrap_probe_status_t probe =
436-
bootstrap_probe(config, ops, &client, &connect_result);
446+
bootstrap_probe(config, ops, &client, &connect_result, &muted_holder_pid);
437447
if (bootstrap_probe_is_finishable(probe)) {
438448
cbm_daemon_bootstrap_status_t status =
439449
bootstrap_finish_probe(probe, client, &connect_result, ops, result_out);
@@ -461,7 +471,7 @@ cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute_with_ops(
461471
* replacements from being launched. */
462472
generation_observed = true;
463473
bootstrap_pause(deadline);
464-
probe = bootstrap_probe(config, ops, &client, &connect_result);
474+
probe = bootstrap_probe(config, ops, &client, &connect_result, &muted_holder_pid);
465475
continue;
466476
}
467477

@@ -473,7 +483,7 @@ cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute_with_ops(
473483
}
474484
if (lock_status == 0) {
475485
bootstrap_pause(deadline);
476-
probe = bootstrap_probe(config, ops, &client, &connect_result);
486+
probe = bootstrap_probe(config, ops, &client, &connect_result, &muted_holder_pid);
477487
continue;
478488
}
479489
if (lock_status != 1 || !startup_lock) {
@@ -482,7 +492,7 @@ cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute_with_ops(
482492
}
483493

484494
lock_acquired = true;
485-
probe = bootstrap_probe(config, ops, &client, &connect_result);
495+
probe = bootstrap_probe(config, ops, &client, &connect_result, &muted_holder_pid);
486496
if (probe == CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED ||
487497
probe == CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL) {
488498
generation_observed = true;
@@ -516,7 +526,7 @@ cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute_with_ops(
516526
* bootstrap against a daemon that is trying to cleanly stand down. */
517527
do {
518528
bootstrap_pause(deadline);
519-
probe = bootstrap_probe(config, ops, &client, &connect_result);
529+
probe = bootstrap_probe(config, ops, &client, &connect_result, &muted_holder_pid);
520530
if (probe == CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED ||
521531
probe == CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL) {
522532
generation_observed = true;
@@ -545,7 +555,16 @@ cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute_with_ops(
545555
}
546556

547557
result_out->status = CBM_DAEMON_BOOTSTRAP_FAILED;
548-
if (generation_observed) {
558+
if (muted_holder_pid != 0) {
559+
/* The one diagnostic the 2026-08-29 zombie recovery had to assemble by
560+
* hand from process, pipe, and log correlation: name the pid that
561+
* holds the endpoint without answering, and say what to do with it. */
562+
(void)snprintf(result_out->message, sizeof(result_out->message),
563+
"CBM daemon endpoint is held by pid %llu but that process answered no "
564+
"rendezvous within %u ms; the daemon runtime is likely dead — stop that "
565+
"process, then retry",
566+
(unsigned long long)muted_holder_pid, config->startup_timeout_ms);
567+
} else if (generation_observed) {
549568
(void)snprintf(result_out->message, sizeof(result_out->message),
550569
"CBM daemon is active or starting but could not accept this client "
551570
"within %u ms",
@@ -578,6 +597,16 @@ cbm_daemon_bootstrap_probe_status_t cbm_daemon_bootstrap_classify_failed_connect
578597
* existing generation answered. Never reinterpret them as absence. */
579598
return CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED;
580599
}
600+
if (connect_result->status == CBM_DAEMON_RUNTIME_CONNECT_ERROR &&
601+
connect_result->muted_endpoint_holder_pid != 0) {
602+
/* The transport connected to a live process that answered nothing.
603+
* That endpoint is OWNED, whatever the advisory locks read right now
604+
* (a wedged generation can hold pipes while its lock files churn).
605+
* Classifying this as absence made the 2026-08-29 zombie invisible:
606+
* the starter spawned doomed competitors for 30 s and then reported a
607+
* bare timeout with no holder named. */
608+
return CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED;
609+
}
581610
if (lifetime_status == 1) {
582611
return CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED;
583612
}

‎src/daemon/frontend.c‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,19 @@ enum {
4040
* cancelled and the frontend exits cleanly. */
4141
FRONTEND_EOF_DRAIN_MS = 15000,
4242
FRONTEND_MAINTENANCE_POLL_MS = 10,
43+
/* Cadence for the quiet no-maintenance steady state. Every presence probe
44+
* try-acquires the maintenance marker lock, and on Windows that
45+
* revalidates the whole ancestor directory chain (a CreateFileW +
46+
* identity check per path component). At the old 10 ms cadence this
47+
* burned ~20% of a core for the life of every MCP session — 7762
48+
* CPU-seconds on one 9.5 h session in the 2026-08-29 incident. The
49+
* budget: an activation drains participants within
50+
* CLI_ACTIVATION_DRAIN_TIMEOUT_MS (15 s) and the monitor's own exit grace
51+
* is FRONTEND_MAINTENANCE_GRACE_MS (3 s), so a 250 ms detection latency
52+
* spends under 2% of that window while cutting the probe cost 25-fold.
53+
* The 10 ms cadence still paces the monitor's post-detection grace loop,
54+
* which does no lock probing. */
55+
FRONTEND_MAINTENANCE_IDLE_POLL_MS = 250,
4356
/* The owner thread may be draining a supervised process tree. Preserve the
4457
* supervisor's complete graceful + forced-settle window before the monitor
4558
* fail-stops the process, plus scheduling/teardown margin. */
@@ -162,7 +175,7 @@ static void *frontend_maintenance_monitor_worker(void *opaque) {
162175
cbm_version_cohort_maintenance_presence_t presence =
163176
frontend_observe_maintenance(monitor->manager, true);
164177
if (presence == CBM_VERSION_COHORT_MAINTENANCE_ABSENT) {
165-
cbm_usleep(FRONTEND_MAINTENANCE_POLL_MS * 1000U);
178+
cbm_usleep(FRONTEND_MAINTENANCE_IDLE_POLL_MS * 1000U);
166179
continue;
167180
}
168181

‎src/daemon/runtime.c‎

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ static atomic_bool runtime_force_peer_image_mismatch_seam;
3636
void cbm_daemon_runtime_force_peer_image_mismatch_for_testing(bool force) {
3737
atomic_store(&runtime_force_peer_image_mismatch_seam, force);
3838
}
39+
/* The abandoned-request containment path ends in process termination, which an
40+
* in-process harness cannot observe. The timeout override makes the ceiling
41+
* reachable in test time; the hook replaces termination with a recordable
42+
* callback. Both stay inert (zero/NULL) outside tests. */
43+
static _Atomic uint32_t runtime_abandoned_request_join_timeout_seam;
44+
void cbm_daemon_runtime_set_abandoned_request_join_timeout_for_testing(uint32_t timeout_ms) {
45+
atomic_store(&runtime_abandoned_request_join_timeout_seam, timeout_ms);
46+
}
47+
static _Atomic(cbm_daemon_runtime_containment_hook_t) runtime_containment_hook_seam;
48+
void cbm_daemon_runtime_set_containment_hook_for_testing(
49+
cbm_daemon_runtime_containment_hook_t hook) {
50+
atomic_store(&runtime_containment_hook_seam, hook);
51+
}
3952
#endif
4053

4154
#ifdef _WIN32
@@ -72,6 +85,16 @@ enum {
7285
* a local cooperative peer is already blocked in read and consumes the
7386
* rejection within milliseconds. */
7487
RUNTIME_REJECT_DRAIN_TIMEOUT_MS = 250,
88+
/* Ceiling on how long a disconnecting worker waits for its in-flight
89+
* application request to observe cancellation. session_cancel has already
90+
* run by the time this wait starts, so a compliant handler returns within
91+
* milliseconds; the wait is sized for a handler that only polls its cancel
92+
* flag between long pipeline stages. A handler that ignores cancellation
93+
* past this ceiling turned the daemon into a permanent zombie once
94+
* (2026-08-29): the unbounded join blocked the disconnect path, admission
95+
* wedged behind it, and the dead generation held the endpoint pipes and
96+
* the UI port for nine hours with nothing logged. */
97+
RUNTIME_ABANDONED_REQUEST_JOIN_TIMEOUT_MS = 30000,
7598
RUNTIME_PATH_CAP = 4096,
7699

77100
RENDEZVOUS_REQUEST_ABI_OFFSET = 0,
@@ -1313,13 +1336,62 @@ static void *runtime_application_worker(void *opaque) {
13131336
return NULL;
13141337
}
13151338

1339+
static _Noreturn void runtime_cleanup_fail_stop(const char *component);
1340+
1341+
static void runtime_contain_unresponsive_application(cbm_daemon_runtime_worker_t *worker) {
1342+
char peer_pid[32];
1343+
char token[32];
1344+
(void)snprintf(peer_pid, sizeof(peer_pid), "%llu", (unsigned long long)worker->peer_process_id);
1345+
(void)snprintf(token, sizeof(token), "%llu",
1346+
(unsigned long long)worker->application_request_token);
1347+
cbm_log_error("daemon.application_request_unresponsive", "peer_pid", peer_pid, "request_token",
1348+
token);
1349+
#ifdef CBM_ENABLE_TEST_SEAMS
1350+
cbm_daemon_runtime_containment_hook_t hook = atomic_load(&runtime_containment_hook_seam);
1351+
if (hook) {
1352+
hook("application_request_join");
1353+
return;
1354+
}
1355+
#endif
1356+
runtime_cleanup_fail_stop("application_request_join");
1357+
}
1358+
13161359
static bool runtime_worker_reap_application(cbm_daemon_runtime_worker_t *worker, bool wait) {
13171360
if (!worker->application_thread_started) {
13181361
return true;
13191362
}
13201363
if (!wait && !atomic_load_explicit(&worker->application_thread_done, memory_order_acquire)) {
13211364
return false;
13221365
}
1366+
if (!atomic_load_explicit(&worker->application_thread_done, memory_order_acquire)) {
1367+
/* The peer is gone and session_cancel already ran, so the handler owes
1368+
* a prompt return; only a handler that ignores cancellation reaches
1369+
* the ceiling. An unbounded join here once turned one wedged request
1370+
* into a whole-daemon zombie (2026-08-29): the disconnect path
1371+
* blocked, the worker slot never released, and the dead generation
1372+
* held the endpoint pipes and UI port for nine hours with nothing
1373+
* logged while every new client timed out bare. Containment
1374+
* terminates the process instead — the kernel releases every native
1375+
* claim and the next client starts a fresh generation. */
1376+
uint32_t timeout_ms = RUNTIME_ABANDONED_REQUEST_JOIN_TIMEOUT_MS;
1377+
#ifdef CBM_ENABLE_TEST_SEAMS
1378+
uint32_t seam_timeout = atomic_load(&runtime_abandoned_request_join_timeout_seam);
1379+
if (seam_timeout != 0) {
1380+
timeout_ms = seam_timeout;
1381+
}
1382+
#endif
1383+
uint64_t deadline = runtime_deadline_after(timeout_ms);
1384+
while (!atomic_load_explicit(&worker->application_thread_done, memory_order_acquire)) {
1385+
if (cbm_now_ms() >= deadline) {
1386+
/* Terminal in production. A test containment hook returns, and
1387+
* the join then waits for the harness to release the handler
1388+
* so teardown stays leak-free under the sanitizers. */
1389+
runtime_contain_unresponsive_application(worker);
1390+
deadline = UINT64_MAX;
1391+
}
1392+
runtime_wait_tick(deadline);
1393+
}
1394+
}
13231395
if (cbm_thread_join(&worker->application_thread) != 0) {
13241396
return false;
13251397
}
@@ -2569,8 +2641,11 @@ static bool runtime_control_request_send(const cbm_daemon_ipc_endpoint_t *endpoi
25692641
const cbm_daemon_build_identity_t *identity,
25702642
cbm_daemon_runtime_operation_t operation,
25712643
uint32_t timeout_ms, uint32_t response_size,
2572-
uint8_t **payload_out) {
2644+
uint8_t **payload_out, uint64_t *muted_holder_pid_out) {
25732645
*payload_out = NULL;
2646+
if (muted_holder_pid_out) {
2647+
*muted_holder_pid_out = 0;
2648+
}
25742649
if (!endpoint || !identity || !identity->build_fingerprint ||
25752650
timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER) {
25762651
return false;
@@ -2590,6 +2665,15 @@ static bool runtime_control_request_send(const cbm_daemon_ipc_endpoint_t *endpoi
25902665
int received = sent ? cbm_daemon_ipc_receive_frame_bounded(connection, timeout_ms,
25912666
response_size, &frame, &payload)
25922667
: 0;
2668+
if (muted_holder_pid_out && sent &&
2669+
(received != 1 || (frame.type == CBM_DAEMON_FRAME_RESPONSE && frame.flags != operation))) {
2670+
/* Connected but not served: either total silence (dead runtime), or a
2671+
* wrong-operation reject frame (a wedged generation whose accept path
2672+
* still answers inline while every worker slot is stuck). Both are
2673+
* the zombie class — surface the holder's kernel-reported pid so
2674+
* `daemon status` can name it instead of reporting "not running". */
2675+
*muted_holder_pid_out = cbm_daemon_ipc_connection_peer_pid(connection);
2676+
}
25932677
bool valid = received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE &&
25942678
frame.flags == operation && frame.length == response_size && payload &&
25952679
payload[0] == 1U;
@@ -2617,7 +2701,8 @@ bool cbm_daemon_runtime_request_status(const cbm_daemon_ipc_endpoint_t *endpoint
26172701
memset(status_out, 0, sizeof(*status_out));
26182702
uint8_t *payload = NULL;
26192703
if (!runtime_control_request_send(endpoint, identity, CBM_DAEMON_RUNTIME_OP_STATUS, timeout_ms,
2620-
CBM_DAEMON_STATUS_RESPONSE_SIZE, &payload)) {
2704+
CBM_DAEMON_STATUS_RESPONSE_SIZE, &payload,
2705+
&status_out->muted_endpoint_holder_pid)) {
26212706
return false;
26222707
}
26232708
status_out->permanent = (payload[1] & 0x01U) != 0U;
@@ -2648,7 +2733,7 @@ bool cbm_daemon_runtime_request_stop(const cbm_daemon_ipc_endpoint_t *endpoint,
26482733
memset(result_out, 0, sizeof(*result_out));
26492734
uint8_t *payload = NULL;
26502735
if (!runtime_control_request_send(endpoint, identity, CBM_DAEMON_RUNTIME_OP_STOP, timeout_ms,
2651-
CBM_DAEMON_STOP_RESPONSE_SIZE, &payload)) {
2736+
CBM_DAEMON_STOP_RESPONSE_SIZE, &payload, NULL)) {
26522737
return false;
26532738
}
26542739
result_out->accepted = (payload[1] & 0x01U) != 0U;
@@ -2691,6 +2776,13 @@ cbm_daemon_runtime_client_t *cbm_daemon_runtime_client_connect(
26912776
frame.length == CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE &&
26922777
runtime_hello_response_decode(payload, result_out);
26932778
free(payload);
2779+
if (received != 1) {
2780+
/* The kernel completed the pipe/socket connect, so a server process
2781+
* exists, yet the HELLO went unanswered. Name that holder: a dead
2782+
* runtime behind a live endpoint is otherwise indistinguishable from
2783+
* absence, and the 2026-08-29 zombie hid behind exactly that gap. */
2784+
result_out->muted_endpoint_holder_pid = cbm_daemon_ipc_connection_peer_pid(connection);
2785+
}
26942786
if (!valid || result_out->status != CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED) {
26952787
cbm_daemon_ipc_connection_close(connection);
26962788
return NULL;

‎src/daemon/runtime.h‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,12 @@ typedef struct {
214214
cbm_daemon_client_id_t client_id;
215215
/* Kernel-authenticated PID of this client as observed by the daemon. */
216216
uint64_t authenticated_process_id;
217+
/* Nonzero only when the transport connect succeeded but no valid response
218+
* arrived in time: the kernel-reported PID of the process that holds the
219+
* endpoint. A held-but-mute endpoint is a live process whose runtime is
220+
* wedged (2026-08-29 zombie incident) — callers must report this PID
221+
* rather than fold the failure into plain absence. */
222+
uint64_t muted_endpoint_holder_pid;
217223
cbm_daemon_conflict_t conflict;
218224
char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE];
219225
} cbm_daemon_runtime_connect_result_t;
@@ -265,6 +271,11 @@ typedef struct {
265271
uint32_t client_pids[CBM_DAEMON_CONTROL_CLIENT_CAP];
266272
char build_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE];
267273
char semantic_version[CBM_DAEMON_SEMVER_SIZE];
274+
/* Meaningful even when the status request itself FAILED: nonzero when the
275+
* endpoint accepted the transport connect but answered nothing — the
276+
* kernel-reported PID of the mute holder. `daemon status` uses this to
277+
* name a zombie generation instead of reporting bare "not running". */
278+
uint64_t muted_endpoint_holder_pid;
268279
} cbm_daemon_runtime_status_t;
269280

270281
typedef struct {
@@ -421,6 +432,15 @@ void cbm_daemon_runtime_force_peer_image_unverified_for_testing(bool force);
421432
* i.e. the tamper case that must still be rejected after unverifiable images
422433
* became admissible. */
423434
void cbm_daemon_runtime_force_peer_image_mismatch_for_testing(bool force);
435+
/* Abandoned-request containment seams (2026-08-29 zombie incident). The
436+
* timeout override shrinks the join ceiling to test scale; zero restores the
437+
* production constant. The hook replaces the terminal containment stop with a
438+
* recordable callback so an in-process harness survives the trigger; NULL
439+
* restores process termination. */
440+
typedef void (*cbm_daemon_runtime_containment_hook_t)(const char *component);
441+
void cbm_daemon_runtime_set_abandoned_request_join_timeout_for_testing(uint32_t timeout_ms);
442+
void cbm_daemon_runtime_set_containment_hook_for_testing(
443+
cbm_daemon_runtime_containment_hook_t hook);
424444
#endif
425445

426446
#endif /* CBM_DAEMON_RUNTIME_H */

‎src/daemon/version_cohort.c‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,11 @@ cbm_version_cohort_status_t cbm_version_cohort_acquire(cbm_version_cohort_manage
463463
* which a mismatched client can join. Name it: "was the lifetime lock
464464
* held?" is exactly what separates a local run (conflict raised) from
465465
* a CI run (client admitted), and it was not observable in any log. */
466-
cbm_log_info("version_cohort.claimed_unheld", "build",
466+
/* Key wording matters here: the old name "claimed_unheld" read as a
467+
* stale-claim anomaly and derailed the 2026-08-29 zombie diagnosis,
468+
* when the record actually marks the HEALTHY fresh-claim path that
469+
* every normal start logs. */
470+
cbm_log_info("version_cohort.claimed_fresh", "prior_holder", "none", "build",
467471
identity->build_fingerprint ? identity->build_fingerprint : "<null>");
468472
status = version_cohort_claim_new(lease, identity, deadline_ms);
469473
} else if (status == CBM_VERSION_COHORT_BUSY) {

0 commit comments

Comments
 (0)