Repository navigation
fix(daemon): re-issue the interrupted request against a replacement daemon - #2803
Minervaowl7 wants to merge 1 commit into
Conversation
|
Hi @Minervaowl7, welcome, and thank you for your first contribution here. This is careful work: you found exactly the right place for recovery (the worker already holds the request, so the stdin reader never needs to be woken), you kept the legacy behavior behind a NULL callback, you bounded the retries, and your commit message explains the reasoning clearly. Your report in #2796, with timelines from both the client pool and the daemon log, is one of the most useful lifecycle reports we have received. One thing you could not have known from the issue: an older PR, #1784, implements the same recovery path (replace the daemon client after a transport loss and retry on the worker). It has been through several rounds of maintainer review since August and is further along. Both PRs change the signature of cbm_daemon_frontend_mcp_run and touch the same four files, so they cannot both land as they are. We would rather tell you that now than let your PR wait without an answer. Because the two designs differ, here is what the review of #1784 settled, in case it helps you and for the record on this PR:
Two places where your help would make a real difference:
We will keep this PR open while #1784 goes through its decision and come back here once that is done. Thank you again, both for the fix and for explaining it so clearly. |
|
Thanks for opening this — it has been seen, and it is queued. This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence. Current review status: working through a backlog. What that means for this PR, concretely:
Things that will genuinely speed it up whenever review does happen:
If this fixes a bug, a reproduction we can run is worth more than a description of the symptom. Thanks for contributing, and sorry in advance for the wait. |
37b03c7 to
5647405
Compare
|
Revised after auditing the first revision (force-pushed to 5647405):
The real-daemon |
e577e43 to
a13b4a3
Compare
|
Reviewed this change with six OCR passes (high/medium effort) and fixed every critical/high/medium finding. Final commit: Fixed across passes
Verification after the final pass
Known residual gaps (documented, not correctness blockers)
|
…aemon When the shared daemon dies under a live MCP frontend (DeusData#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 reports a transport failure 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. Only transport errors retry: BUSY, UNAVAILABLE, REJECTED, HANDLER_ERROR and CANCELLED are daemon answers that keep their existing meaning, so a cancellation is still answered as a cancellation. Bounded by FRONTEND_DAEMON_RECONNECT_MAX; without a callback or once the budget is spent the old immediate-exit path stays. A replayed request may already have executed in the dead daemon, so the interrupted call has at-least-once semantics; frontend.h states this explicitly. The replaced client stays owned by the frontend and is only close_begin'd at swap time: the stdin reader and the maintenance monitor can still hold a pointer read before the swap, so close_finish runs in the run tail after the worker is joined and the monitor is stopped. All non-worker client reads are snapshotted under the state mutex. A supplier that returns the current or an already-retired client is rejected instead of being double-freed. Cancellation survives the reconnect: the published token is invalidated while the callback runs, a matching cancel is remembered in cancel_pending, and the retry delivers it against the freshly minted token. The retry reserves and publishes that token under one lock, and CANCEL_ERROR no longer tears down the reader — the in-flight exchange observes the same failure and the worker owns recovery. A maintenance stop that lands in the reconnect window now takes the monitor's bounded grace instead of exiting immediately. The EOF drain and the join watchdog share frontend_liveness, a deterministic no-progress policy. Each keeps its ordinary window (15 s drain, 5 s watchdog) and a replacement attempt extends both to one 120 s grace anchored at the attempt's real start; the timestamp and active flag are published before the attempt counter (release/acquire), so a waiter seeing a new attempt cannot pair it with the previous attempt's timestamp. A late re-sample grants the grace once (idempotent), item progress never extends it, and static asserts require the grace to cover both windows. main() supplies the callback: it re-runs the connect-or-spawn leg plus session context and returns the replacement. Initial startup and reconnect share main_establish_mcp_session(); session_live switches only bootstrap failure reporting. It returns NULL immediately once g_shutdown is set (SIGTERM/SIGINT), so shutdown never cold-starts a daemon, drops its global handle before the run so ownership follows the code, logs the reconnect through cbm_log_warn, and reports failures on stderr only — main_report_client_bootstrap_failure() would inject an unsolicited id:null JSON-RPC frame into the live session's stdout; an endpoint failure includes the validation detail. The POSIX fork child calls the shared cbm_posix_close_fds_from() (close_range/closefrom, with the 436 syscall fallback) and receives a cap from cbm_posix_fd_close_cap() read in the parent, keeping the fork child free of libc allocation because the callback runs on a worker thread. UI flags are not re-applied; the daemon persists its UI configuration. The fix also concentrates three policies it exposed: frontend_liveness owns the drain/watchdog arithmetic; foundation/subprocess owns the fork-child descriptor sweep and cap for both spawn paths; main.c owns one endpoint-to-session path. A frontend_session ownership type is left as a follow-up so this diff stays the bug fix. Verified on WSL/gcc 13.3: scripts/test.sh --suites daemon_frontend reports 20 passed; daemon_frontend,daemon_bootstrap,subprocess report 104 passed and 2 skipped. Coverage includes transport-failure reconnect, a two-replacement retry (budget loop and repeated retirement), a CANCELLED-no-retry case that fails when the retry gate is widened, a NULL supplier that fail-stops with a marker proving the callback ran, a supplier returning the dead client that is rejected without a double close, the liveness policy driven by a synthetic clock, and the shared fd cap bound. A real daemon kill -9 mid-session is served by a re-spawned daemon with one daemon_lost_reconnect warning; pre-fix the same scenario exits 1 and the request is lost. Fixes DeusData#2796 Signed-off-by: Minervaowl7 <127482476+Minervaowl7@users.noreply.github.com>
a13b4a3 to
9b280d4
Compare
Architecture pass on the reconnect fixRan an Deepened
Defects found and fixed by the second review round
Tests added
Verification
Residual gaps (not blockers)
|
|
Thanks for the detailed review — and for the pointer to #1784. I've read it, and I'm not going to push for both to land. If #1784 is the one you want, I'll close this; I'd rather the fix land than my version of it. A quick update since your comment: it was based on the first revision (5647405). The branch is now at 9b280d4, and three of your four points are addressed:
Point 2 is the one that's still a real difference, and you're right that #1784's rule is safer. Without runtime support I can't tell "never sent" from "reply lost", so this branch replays with at-least-once semantics — stated in On #2796: you're right that the current lab repro kills the daemon directly. The incident logs only show the victim endpoint vanishing without a graceful path; I haven't yet isolated "kill only endpoint A" or confirmed whether ZCode uses a process-tree/job kill. I can run both variants against #1784 on Windows and post the daemon/B outcome there. Two caveats: my lab currently has the keeper workaround holding the daemon, so I'd need to stop it for a clean run; and I don't have a native Windows build of #1784 yet (this branch was tested in WSL), so a Windows artifact or the preferred build command would help. Heads-up: the branch also has an architecture pass on top (shared drain/watchdog liveness, one fork-child fd path, one startup/reconnect session path) — details are in the comment above. Thanks again for the clear prior-art pointer and for keeping this open while #1784 is decided. |
Fixes #2796
Problem
When the shared daemon dies under a live MCP frontend, the worker's exchange fails and the current path calls
_Exit(EXIT_FAILURE)— "a failed daemon transport cannot wake a thread blocked in stdio portably". The agent sees server EOF, and every pooled connection to that daemon breaks at once. On Windows this triggers whenever a client reaps its spawned endpoint tree:DETACHED_PROCESSdetaches the console, not the process tree, so the shared daemon dies with the endpoint that spawned it and takes every attached frontend with it (two incidents with full client/server logs in #2796).Fix
Recover on the worker, where the request is already in hand — the stdin reader is never touched:
cbm_daemon_frontend_daemon_lost_fncallback oncbm_daemon_frontend_mcp_run(NULL keeps the legacy fatal behavior; all existing call sites pass NULL). The callback must return a fresh, open client distinct from every client returned earlier in the run; the current and already-retired clients are rejected, and the frontend takes ownership of a successful replacement.CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERRORretries.BUSY,UNAVAILABLE,REJECTED,HANDLER_ERRORandCANCELLEDare daemon answers and keep their existing meaning — in particular, a cancelled request is never re-issued as a fresh one. Bounded byFRONTEND_DAEMON_RECONNECT_MAX = 3.frontend.hstates this explicitly.close_begin'd at swap time but allocated until the run tail. The stdin reader and maintenance monitor can hold a pointer read before the swap, soclose_finishruns after the worker is joined and the monitor is stopped. All non-worker client reads are snapshotted under the state mutex.cancel_pending, and the retry delivers it against the freshly minted token. The retry reserves and publishes that token under one lock, andCANCEL_ERRORno longer tears down the reader — the in-flight exchange observes the same failure and the worker owns recovery. A maintenance stop that lands in the reconnect window takes the monitor's bounded grace instead of exiting immediately.frontend_livenesspolicy module. Each keeps its own ordinary window (15 s drain, 5 s watchdog); a replacement attempt extends both to one 120 s grace anchored at the attempt's real start, so a hung supplier cannot spend 2×120 s across the two waiters. A late re-sample grants the grace once (idempotent), completed-item progress never extends it, and_Static_asserts require the grace to cover both windows. The attempt timestamp and active flag are published before the attempt counter (release/acquire), so a waiter that observes a new attempt cannot pair it with the previous attempt's timestamp.main()supplies the callback. Initial startup and the reconnect supplier now sharemain_establish_mcp_session()(bootstrap + upgrade retry + session context);session_liveswitches only bootstrap failure reporting, so the reconnect path cannot drift from startup. The callback returns NULL immediately onceg_shutdownis set (SIGTERM/SIGINT), so shutdown never cold-starts a daemon. It logs the reconnect throughcbm_log_warnand reports failures on stderr only —main_report_client_bootstrap_failure()would inject an unsolicitedid:nullJSON-RPC frame into the live session's stdout; endpoint failures include the validation detail. The POSIX fork child calls the sharedcbm_posix_close_fds_from()(close_range→closefrom→ bounded loop, with the 436 syscall fallback) and receives a cap fromcbm_posix_fd_close_cap()read in the parent, so the child stays async-signal-safe while the callback runs on a worker thread. UI flags are not re-applied (the daemon persists its UI configuration).Architecture follow-up included here
The reconnect fix made three existing seams visible; this PR deepens them because they are all on the new code path:
src/daemon/frontend_liveness.{c,h}— one deterministic no-progress policy for both waiters. Previously the two loops disagreed: the drain granted a cold-spawn grace while the 5 s join watchdog killed the same worker.foundation/subprocess.{c,h}—cbm_posix_close_fds_from()andcbm_posix_fd_close_cap()are now the single fork-child descriptor policy shared by subprocess spawn and daemon bootstrap. The duplicate copy inbootstrap.c(which had already drifted: no test seam, a different cap source) is deleted.src/main.c— one endpoint-to-session path (main_establish_mcp_session) and one endpoint-failure reporter shared by startup and reconnect.Deferred deliberately: a
frontend_sessionvalue type owning the client pointer, token,cancel_pendingand retired clients is the right next refactor, but it moves ~200 lines and belongs in a follow-up where the diff is the refactor rather than the bug fix. A maintenance activation still fail-stops on the monitor's own shorter grace rather than waiting 120 s for a reconnect; that trade-off is documented infrontend.h.Verification
daemon_frontend_reconnects_after_transport_failure— the first service is stopped, the supplier returns a live replacement, and the request is answered exactly once by it.daemon_frontend_retries_across_two_replacements— the first replacement's daemon is also dead; the second callback supplies a live client (two swaps, two retirements, one answer).daemon_frontend_does_not_retry_daemon_cancellation— a CANCELLED answer reaches the client and the supplier is never called; fails if the retry gate is widened.daemon_frontend_fail_stops_without_a_replacement— a NULL supplier fail-stops, with a marker file proving the callback ran before_Exit.daemon_frontend_rejects_returning_the_dead_client— returningdead_clientis rejected without a double close.daemon_frontend_liveness_treats_reconnect_as_progress— drives the policy with a synthetic clock: ordinary expiry, item progress, grace without a counter change, a late waiter sharing the same attempt budget, a new attempt while the previous one was observed, a timestamp newer than the sample, idempotent late re-sampling, andUINT64_MAXsaturation.subprocess_fd_close_cap_is_bounded_for_fork_children— the shared cap never disables the sweep or exceeds the clamp.scripts/test.sh --suites daemon_frontend— 20 passed (sanitizer build).daemon_frontend,daemon_bootstrap,subprocess— 104 passed, 2 skipped.kill -9mid-session: the interrupted request is served by a re-spawned daemon and stderr carries onelevel=warn msg=mcp.frontend.daemon_lost_reconnect; pre-fix the same scenario exits 1 and the request is lost.scripts/build.shproduction build succeeds.clang-format --dry-run --Werrorclean on every changed source;test_daemon_frontend.chas pre-existing 18.1.3 drift outside the added block.Files
src/daemon/frontend.c,src/daemon/frontend.h,src/daemon/frontend_liveness.c,src/daemon/frontend_liveness.h,src/main.c,src/daemon/bootstrap.c,src/foundation/subprocess.c,src/foundation/subprocess.h,Makefile.cbm,tests/test_daemon_frontend.c,tests/test_subprocess.c. No public API or dependency changes.Residual gaps (for review, not blockers)