Skip to content

Commit 37b03c7

Browse files
committed
fix(daemon): re-issue the interrupted request against a replacement daemon
When the shared daemon dies under a live MCP frontend (#2796), the worker's daemon exchange fails and the current failure path calls _Exit(EXIT_FAILURE) — "a failed daemon transport cannot wake a thread blocked in stdio portably". The agent then sees server EOF and every pooled connection to the same daemon breaks at once. On Windows this happens whenever a client reaps its spawned endpoint tree: DETACHED_PROCESS detaches the console, not the process tree, so the shared daemon dies with the endpoint that spawned it. Keep the reader untouched and recover on the worker instead: when cbm_daemon_application_client_mcp_tagged fails and the caller supplied a replacement supplier (new daemon_lost callback on mcp_run), swap in the replacement client, re-mint the request token (tokens are scoped to the client that reserved them), and re-issue the very request whose exchange was interrupted. Bounded by FRONTEND_DAEMON_RECONNECT_MAX; without a callback or once the budget is spent the old immediate-exit path stays. A cancellation that arrives mid-reconnect targets the old token and is dropped — the re-issued request completes instead. main() supplies the callback: it owns the dead client (closes it), re-runs the connect-or-spawn leg plus session context, and returns the replacement. UI flags are not re-applied; the daemon persists its UI configuration. Verified on WSL/gcc 13.3 with a real daemon: two stdio endpoints sharing one daemon, kill -9 the daemon, the next request on the survivor is served by a re-spawned daemon (stderr: one daemon_lost_reconnect warning); pre-fix the same scenario exits 1 and the request is lost. scripts/test.sh --suites daemon_frontend stays green. Fixes #2796 Signed-off-by: Minervaowl7 <127482476+Minervaowl7@users.noreply.github.com>
1 parent 10ab613 commit 37b03c7

4 files changed

Lines changed: 158 additions & 18 deletions

File tree

‎src/daemon/frontend.c‎

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ enum {
3939
* receiving every response while a genuinely stuck request still gets
4040
* cancelled and the frontend exits cleanly. */
4141
FRONTEND_EOF_DRAIN_MS = 15000,
42+
/* #2796: replacement-daemon attempts per frontend run once the transport
43+
* fails mid-session. Bounded so a daemon that cannot come back still
44+
* terminates the process exactly like the pre-reconnect behavior. */
45+
FRONTEND_DAEMON_RECONNECT_MAX = 3,
4246
FRONTEND_MAINTENANCE_POLL_MS = 10,
4347
/* Cadence for the quiet no-maintenance steady state. Every presence probe
4448
* try-acquires the maintenance marker lock, and on Windows that
@@ -90,6 +94,12 @@ typedef struct {
9094
const char *active_id_str;
9195
cbm_daemon_runtime_application_token_t active_request_token;
9296
bool failed;
97+
/* #2796: replacement supplier for a dead daemon transport, plus the retry
98+
* budget already spent in this run. daemon_lost==NULL keeps the legacy
99+
* fatal behavior. */
100+
cbm_daemon_frontend_daemon_lost_fn daemon_lost;
101+
void *daemon_lost_context;
102+
unsigned int daemon_reconnects;
93103
/* Monotonic count of fully processed queue items (responses written or
94104
* cancellations acknowledged). The EOF drain below watches it to tell a
95105
* batch that is still making progress from a genuinely stuck request. */
@@ -502,6 +512,65 @@ static frontend_cancellation_route_t frontend_route_cancellation(
502512
return route;
503513
}
504514

515+
/* #2796: swap in a replacement daemon client after a transport failure. The
516+
* request token minted for the interrupted exchange was scoped to the dead
517+
* client, so the caller must re-reserve one before re-issuing the request.
518+
* The callback runs unmutexed (a cold daemon spawn takes seconds); the swap
519+
* itself refuses once teardown has begun so no replacement outlives the run.
520+
* Every other state->client reader either holds the mutex or is this worker
521+
* thread between exchanges. */
522+
static bool frontend_daemon_reconnect(frontend_state_t *state) {
523+
if (!state->daemon_lost || state->daemon_reconnects >= FRONTEND_DAEMON_RECONNECT_MAX ||
524+
state->stopping) {
525+
return false;
526+
}
527+
cbm_daemon_runtime_client_t *dead = state->client;
528+
state->daemon_reconnects++;
529+
cbm_daemon_runtime_client_t *replacement = state->daemon_lost(state->daemon_lost_context, dead);
530+
if (!replacement) {
531+
return false;
532+
}
533+
cbm_mutex_lock(&state->mutex);
534+
if (state->stopping || state->client != dead) {
535+
cbm_mutex_unlock(&state->mutex);
536+
(void)cbm_daemon_runtime_client_close(replacement, FRONTEND_CLOSE_TIMEOUT_MS);
537+
return false;
538+
}
539+
state->client = replacement;
540+
cbm_mutex_unlock(&state->mutex);
541+
return true;
542+
}
543+
544+
/* #2796: one request exchange with replacement-daemon retries. Returns the
545+
* final status; response and response_length are set only on OK. */
546+
static cbm_daemon_runtime_application_status_t frontend_exchange_with_reconnect(
547+
frontend_state_t *state, frontend_item_t *item, uint8_t **response, uint32_t *response_length) {
548+
cbm_daemon_runtime_application_status_t status;
549+
for (;;) {
550+
status = cbm_daemon_application_client_mcp_tagged(state->client, item->request_token,
551+
item->message, response, response_length,
552+
FRONTEND_REQUEST_TIMEOUT_MS);
553+
if (status == CBM_DAEMON_RUNTIME_APPLICATION_OK) {
554+
return status;
555+
}
556+
/* A dead daemon transport is not this session's death: swap in a
557+
* replacement client, re-mint the request token (tokens are scoped to
558+
* the client that reserved them), and re-issue the very request whose
559+
* exchange was interrupted. */
560+
if (!frontend_daemon_reconnect(state) ||
561+
!cbm_daemon_runtime_client_application_token_reserve(state->client,
562+
&item->request_token)) {
563+
return status;
564+
}
565+
cbm_mutex_lock(&state->mutex);
566+
state->active_request_token = item->request_token;
567+
cbm_mutex_unlock(&state->mutex);
568+
free(*response);
569+
*response = NULL;
570+
*response_length = 0;
571+
}
572+
}
573+
505574
static void *frontend_worker(void *opaque) {
506575
frontend_state_t *state = opaque;
507576
for (;;) {
@@ -523,9 +592,7 @@ static void *frontend_worker(void *opaque) {
523592
failed = !frontend_write_cancelled_response(state->out, &item);
524593
} else {
525594
cbm_daemon_runtime_application_status_t status =
526-
cbm_daemon_application_client_mcp_tagged(state->client, item.request_token,
527-
item.message, &response, &response_length,
528-
FRONTEND_REQUEST_TIMEOUT_MS);
595+
frontend_exchange_with_reconnect(state, &item, &response, &response_length);
529596
if (status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED) {
530597
failed = !frontend_write_cancelled_response(state->out, &item);
531598
} else {
@@ -544,12 +611,14 @@ static void *frontend_worker(void *opaque) {
544611
atomic_fetch_add_explicit(&state->completed_items, 1, memory_order_release);
545612
if (failed) {
546613
if (!expected_stop) {
547-
/* A failed daemon transport cannot wake a thread blocked in
548-
* stdio portably. This frontend owns no state: immediate
549-
* process exit closes the kernel IPC handle, which cancels
550-
* daemon session ownership without a detached reader or an
551-
* unsafe cross-thread fclose. Logging or flushing here could
552-
* itself block on agent-owned stdout/stderr. */
614+
/* #2796: reached only without a daemon_lost callback or after
615+
* its retry budget is spent. A failed daemon transport cannot
616+
* wake a thread blocked in stdio portably. This frontend owns
617+
* no state: immediate process exit closes the kernel IPC
618+
* handle, which cancels daemon session ownership without a
619+
* detached reader or an unsafe cross-thread fclose. Logging or
620+
* flushing here could itself block on agent-owned
621+
* stdout/stderr. */
553622
_Exit(EXIT_FAILURE);
554623
}
555624
break;
@@ -657,14 +726,18 @@ static bool frontend_cancel_for_maintenance(void *opaque) {
657726
}
658727

659728
int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client,
660-
cbm_version_cohort_manager_t *cohort_manager, FILE *in, FILE *out) {
729+
cbm_version_cohort_manager_t *cohort_manager, FILE *in, FILE *out,
730+
cbm_daemon_frontend_daemon_lost_fn daemon_lost,
731+
void *daemon_lost_context) {
661732
if (!client || !cohort_manager || !in || !out) {
662733
return -1;
663734
}
664735
frontend_state_t state = {
665736
.client = client,
666737
.cohort_manager = cohort_manager,
667738
.out = out,
739+
.daemon_lost = daemon_lost,
740+
.daemon_lost_context = daemon_lost_context,
668741
};
669742
cbm_mutex_init(&state.mutex);
670743
atomic_init(&state.completed_items, 0);

‎src/daemon/frontend.h‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ typedef struct cbm_daemon_maintenance_monitor cbm_daemon_maintenance_monitor_t;
1717
* outlive the bounded grace period. */
1818
typedef bool (*cbm_daemon_maintenance_cancel_fn)(void *context);
1919

20+
/* #2796: invoked on the frontend worker thread when the daemon transport
21+
* fails while the MCP client's session is still live. The callback owns the
22+
* dead client (it must close it), establishes a replacement (connect-or-spawn
23+
* plus session context), and returns it — NULL leaves the transport failure
24+
* fatal. It must not touch the frontend's stdio streams. */
25+
typedef cbm_daemon_runtime_client_t *(*cbm_daemon_frontend_daemon_lost_fn)(
26+
void *context, cbm_daemon_runtime_client_t *dead_client);
27+
2028
/* Parse one JSON-RPC message and recognize only the exact cancellation
2129
* notification method. String contents, nested fields, method prefixes, and
2230
* requests carrying an id are not cancellation notifications. */
@@ -66,9 +74,14 @@ uint64_t cbm_daemon_frontend_test_worker_idle_cycles(void);
6674
* either thread is blocked in stdio, requests cooperative cancellation for the
6775
* exact active request, and then bounds process exit. Kernel IPC close cancels
6876
* only this session's daemon work. EOF/parse failure closes the authenticated
69-
* session. An unexpected daemon transport failure likewise terminates the
70-
* process so an agent waiting with stdin still open observes server EOF. */
77+
* session. An unexpected daemon transport failure is retried against a
78+
* replacement daemon through daemon_lost (bounded, #2796); without the
79+
* callback — or once the retry budget is spent — the worker terminates the
80+
* process immediately so an agent waiting with stdin still open observes
81+
* server EOF. */
7182
int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client,
72-
cbm_version_cohort_manager_t *cohort_manager, FILE *in, FILE *out);
83+
cbm_version_cohort_manager_t *cohort_manager, FILE *in, FILE *out,
84+
cbm_daemon_frontend_daemon_lost_fn daemon_lost,
85+
void *daemon_lost_context);
7386

7487
#endif /* CBM_DAEMON_FRONTEND_H */

‎src/main.c‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2785,6 +2785,53 @@ static int main_run_daemon_ctl(int argc, char **argv, const cbm_daemon_ipc_endpo
27852785
return ui_result;
27862786
}
27872787

2788+
/* #2796: supplier for the MCP frontend's daemon_lost callback. Owns the dead
2789+
* client (closes it), re-runs the whole connect-or-spawn leg with the session
2790+
* context, and hands the frontend the replacement. Runs on the frontend worker
2791+
* thread; reports to stderr only and never touches the agent's stdio. UI flags
2792+
* are not re-applied: the daemon persists its UI configuration and a re-spawned
2793+
* daemon reads it back. */
2794+
typedef struct {
2795+
cbm_daemon_process_role_t role;
2796+
const cbm_daemon_build_identity_t *identity;
2797+
const char *executable_path;
2798+
cbm_mcp_tool_profile_t tool_profile;
2799+
} main_mcp_daemon_lost_context_t;
2800+
2801+
static cbm_daemon_runtime_client_t *main_mcp_daemon_lost(void *context,
2802+
cbm_daemon_runtime_client_t *dead_client) {
2803+
main_mcp_daemon_lost_context_t *lost = context;
2804+
(void)cbm_daemon_runtime_client_close(dead_client, MAIN_CLOSE_TIMEOUT_MS);
2805+
(void)fprintf(stderr, "level=warn msg=mcp.frontend.daemon_lost_reconnect\n");
2806+
cbm_daemon_ipc_endpoint_t *endpoint = main_daemon_endpoint_new();
2807+
if (!endpoint) {
2808+
return NULL;
2809+
}
2810+
cbm_daemon_bootstrap_config_t config = {
2811+
.role = lost->role,
2812+
.endpoint = endpoint,
2813+
.identity = lost->identity,
2814+
.executable_path = lost->executable_path,
2815+
.connect_timeout_ms = MAIN_CONNECT_TIMEOUT_MS,
2816+
.startup_timeout_ms = main_startup_timeout_ms(),
2817+
};
2818+
cbm_daemon_bootstrap_result_t result;
2819+
cbm_daemon_bootstrap_status_t status =
2820+
main_client_bootstrap_with_upgrade(&config, &result, true);
2821+
cbm_daemon_ipc_endpoint_free(endpoint);
2822+
if (status != CBM_DAEMON_BOOTSTRAP_CONNECTED || !result.client) {
2823+
main_report_client_bootstrap_failure(lost->role, &result);
2824+
return NULL;
2825+
}
2826+
if (!main_set_client_context(result.client, NULL, lost->tool_profile, NULL, NULL,
2827+
config.startup_timeout_ms)) {
2828+
(void)fprintf(stderr, "codebase-memory-mcp: daemon session context was rejected\n");
2829+
(void)cbm_daemon_runtime_client_close(result.client, MAIN_CLOSE_TIMEOUT_MS);
2830+
return NULL;
2831+
}
2832+
return result.client;
2833+
}
2834+
27882835
int main(int argc, char **argv) {
27892836
/* Must remain the first statement: see allocator binding contract above. */
27902837
cbm_alloc_init();
@@ -3433,7 +3480,14 @@ int main(int argc, char **argv) {
34333480
}
34343481

34353482
setup_signal_handlers();
3436-
int result = cbm_daemon_frontend_mcp_run(g_daemon_client, client_cohort_manager, stdin, stdout);
3483+
main_mcp_daemon_lost_context_t daemon_lost_context = {
3484+
.role = role,
3485+
.identity = &identity,
3486+
.executable_path = executable_path,
3487+
.tool_profile = tool_profile,
3488+
};
3489+
int result = cbm_daemon_frontend_mcp_run(g_daemon_client, client_cohort_manager, stdin, stdout,
3490+
main_mcp_daemon_lost, &daemon_lost_context);
34373491
g_daemon_client = NULL; /* frontend consumed the handle */
34383492
bool client_cohort_cleanup =
34393493
main_version_cohort_close(&client_cohort_lease, &client_cohort_manager);

‎tests/test_daemon_frontend.c‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ static int frontend_test_close_fd(int fd) {
213213
static void *frontend_idle_run(void *opaque) {
214214
frontend_idle_run_t *run = opaque;
215215
run->result =
216-
cbm_daemon_frontend_mcp_run(run->client, run->manager, run->input, run->output);
216+
cbm_daemon_frontend_mcp_run(run->client, run->manager, run->input, run->output, NULL, NULL);
217217
return NULL;
218218
}
219219
#endif
@@ -579,7 +579,7 @@ static int frontend_eof_child_run(const char *parent, bool overflow) {
579579

580580
cbm_daemon_runtime_client_t *frontend_client = fixture.client;
581581
fixture.client = NULL; /* cbm_daemon_frontend_mcp_run consumes it. */
582-
int result = cbm_daemon_frontend_mcp_run(frontend_client, fixture.manager, input, output);
582+
int result = cbm_daemon_frontend_mcp_run(frontend_client, fixture.manager, input, output, NULL, NULL);
583583
bool joined = cbm_thread_join(&writer_thread) == 0;
584584
bool writer_ok = atomic_load_explicit(&writer.finished, memory_order_acquire) &&
585585
atomic_load_explicit(&writer.succeeded, memory_order_acquire);
@@ -920,7 +920,7 @@ static int frontend_backpressure_frontend_run(const char *parent, int input_fd,
920920
return 81;
921921
}
922922

923-
int result = cbm_daemon_frontend_mcp_run(client, manager, input, output);
923+
int result = cbm_daemon_frontend_mcp_run(client, manager, input, output, NULL, NULL);
924924
bool joined = cbm_thread_join(&writer_thread) == 0;
925925
bool writer_ok = atomic_load_explicit(&writer.finished, memory_order_acquire) &&
926926
atomic_load_explicit(&writer.succeeded, memory_order_acquire);
@@ -1419,7 +1419,7 @@ TEST(daemon_frontend_maintenance_exits_while_stdio_reader_is_blocked) {
14191419
_exit(70);
14201420
}
14211421
int result = cbm_daemon_frontend_mcp_run((cbm_daemon_runtime_client_t *)(uintptr_t)1,
1422-
manager, input, output);
1422+
manager, input, output, NULL, NULL);
14231423
(void)result;
14241424
_exit(71);
14251425
}

0 commit comments

Comments
 (0)