From 98ccbd4704bfa9895e6c8044f05426fcb45fc16d Mon Sep 17 00:00:00 2001 From: Tim Lingo <1timlingo@gmail.com> Date: Thu, 6 Aug 2026 18:23:12 -0500 Subject: [PATCH] fix(engine): a client that leaves must not kill the daemon, and a long round must say it started MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 9.1, spec §3 D + ADR 0006 items 2 and 4. Two small changes, both proven by measurement, both E2E-verified locally against a rebuilt brain. D1 — SIGPIPE/EPIPE survival (vendor/el-runtime el_runtime.c). Root cause, at the layer that owns it: the whole HTTP server lives in the C runtime; .el has no socket primitive. http_send_all() called send() with flags 0 and nothing anywhere in the runtime set a SIGPIPE disposition, so the default disposition — terminate the process — applied. When a handler finished after its client had gone (Tim's VM: reply at 116.9 s, client cancelled at 25.0 s), the second of the four sends that write one reply raised SIGPIPE and the daemon died: `exited due to SIGPIPE ... ran for 361177ms`, launchd respawn 4 ms later, every other in-flight session's work lost, user never told. Fix: SIGPIPE -> SIG_IGN at runtime init and at each http_serve* entry, plus per-connection SO_NOSIGPIPE / MSG_NOSIGNAL so the guard survives an embedder resetting dispositions. http_send_all now retries EINTR and preserves errno; http_send_response classifies it once — a departure is logged as routine ("client left before the reply was written ... reply discarded") and ANY other errno is logged as a real "send failed: ". Spec §5.3: the routine case must not mask a genuine write fault, and it does not. Proof (scratch HOME + free port, 3 disconnects mid-reply): round-9 shipped brain 4402179554… — DIED, exit 141 (128+13 = SIGPIPE), round 1 round-9 sources rebuilt with this exact recipe — DIED, exit 141, round 1 this build — SURVIVED 3/3, /health 200 after, still serving the full graph, three honest "client left" lines in the log naming Broken pipe / Connection reset by peer. D2 — the round-start marker (chat.el, agentic_loop). The ledger only ever appended AFTER a round returned, so a healthy first leg produced zero progress by construction; since server-side web_search moved inside the outbound call that leg is 60-120 s of silence, which is how a 25 s client watchdog came to kill a healthy mission. One entry, {"i":N,"t":"","tool":"__working__"}, written to the existing run_progress_ ledger BEFORE each round's outbound call — the wire shape ChatView.kt:1148 has handled as a life signal since 2026-07-13 and never received. No new key, no new route, no new lifecycle: a strict subset of WS3 item 3. WS3's run registry is untouched and stays Will's. Proof (live Anthropic key, real research mission, scratch HOME + free port): round-9 baseline — ledger EMPTY for the whole 59.7 s leg this build — {"i":0,"t":"","tool":"__working__"} visible at 18.6 s of a 70.0 s leg; both builds returned correct ~4.9 KB answers Regression: prompt-matrix gate 32/32 on this build (round-9 baseline also 32/32 under the same recipe, so the score is not a build artifact). Soul contract gate PASS — 27/27 routes, immutability clean. neuron#111 miscompile guard: 0 sites in the generated amalgam this binary was compiled from. NOT included, deliberately: the regenerated dist/soul.c. CI compiles that file, so production stays exposed until it is regenerated — the same open ask as neuron#111 / ui#209. The regen recipe is now known and recorded; landing it is Will's call, per BUILD-HYGIENE. Co-Authored-By: Claude Opus 5 (1M context) --- chat.el | 24 ++++ .../el-runtime/v1.0.0-20260501/el_runtime.c | 108 ++++++++++++++++-- 2 files changed, 121 insertions(+), 11 deletions(-) diff --git a/chat.el b/chat.el index e88bc60..7adae5e 100644 --- a/chat.el +++ b/chat.el @@ -2837,6 +2837,30 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json: + ",\"messages\":" + messages + "}" + // ── ROUND-START MARKER (2026-08-06, round 9.1 D2 / ADR 0006 item 2) ────────── + // The ledger below only ever appended AFTER a round returned, so a healthy + // first leg produced ZERO progress by construction. Since server-side + // web_search moved inside the outbound call (2026-08-04) that leg measures + // 84-117 s, and the client had no way to tell "working" from "dead" — which is + // how a 25 s client-side watchdog came to kill a healthy mission. + // + // Only this loop knows a round has started, so only this loop can say so. One + // entry, written BEFORE the call goes out, using the ledger and the wire shape + // that already exist: the app has handled tool == "__working__" as an + // Activity-only life signal since 2026-07-13 (ChatView.kt:1148) and never + // received one. Narration is deliberately empty - the marker means "a round + // started", nothing more, and the client renders it as a heartbeat, not prose. + // + // This is a strict subset of WS3 item 3 (push/poll progress). It builds none of + // WS3's run registry: no new state key, no new route, no new lifecycle. + if !str_eq(session_id, "") { + let start_key: String = "run_progress_" + session_id + let start_prev: String = state_get(start_key) + let start_entry: String = "{\"i\":" + int_to_str(iteration) + ",\"t\":\"\",\"tool\":\"__working__\"}" + let start_next: String = if str_eq(start_prev, "") { start_entry } else { start_prev + "," + start_entry } + state_set(start_key, start_next) + } + let raw_resp: String = http_post_with_headers(api_url, req_body, h) let is_error: Bool = str_starts_with(raw_resp, "{\"error\"") diff --git a/vendor/el-runtime/v1.0.0-20260501/el_runtime.c b/vendor/el-runtime/v1.0.0-20260501/el_runtime.c index 4cc00dd..765aa6e 100644 --- a/vendor/el-runtime/v1.0.0-20260501/el_runtime.c +++ b/vendor/el-runtime/v1.0.0-20260501/el_runtime.c @@ -41,6 +41,7 @@ #include #include #include +#include /* SIGPIPE disposition — see el_runtime_ignore_sigpipe */ #include #include @@ -1238,16 +1239,77 @@ static const char* http_reason_phrase(int status) { } } -/* Best-effort send with retry on partial writes. */ +/* ── A departing client MUST NOT be able to kill the daemon ────────────────── + * (2026-08-06, round 9.1 / ADR 0006 item 4.) + * + * Measured field failure: a client cancelled its request at 25 s; the handler + * finished its work at 116.9 s and wrote the reply into the departed client's + * socket. The second send() on a reset connection raised SIGPIPE, whose DEFAULT + * disposition terminates the process — `exited due to SIGPIPE ... ran for + * 361177ms`. launchd respawned 4 ms later, so EVERY other in-flight request on + * that daemon lost its work, silently. + * + * Two independent guards, because one of them can be undone from outside this + * file (an embedder may reset signal dispositions) and the other cannot: + * 1. process-wide SIGPIPE -> SIG_IGN, installed at runtime init; + * 2. per-send suppression at the syscall (MSG_NOSIGNAL where the platform has + * it, SO_NOSIGPIPE on the accepted socket on macOS/BSD). + * With either in force, send() reports the peer's departure as EPIPE and the + * caller decides — which is the point: this is an ordinary I/O outcome, not a + * fatal condition. + * + * It deliberately does NOT swallow the error. http_send_response() below + * classifies the errno and logs: "client left" for a departure, and a real + * "send failed: " for anything else, so a genuine write fault is + * still visible in the log (spec round-9.1 §5.3). */ + +#ifndef MSG_NOSIGNAL +#define MSG_NOSIGNAL 0 +#endif + +void el_runtime_ignore_sigpipe(void) { + static int done = 0; + if (done) return; + done = 1; + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = SIG_IGN; + sigemptyset(&sa.sa_mask); + sigaction(SIGPIPE, &sa, NULL); +} + +/* Suppress SIGPIPE for one accepted connection (macOS/BSD have no + * MSG_NOSIGNAL; they have the socket option instead). Best effort. */ +static void http_socket_nosigpipe(int fd) { +#ifdef SO_NOSIGPIPE + int on = 1; + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); +#else + (void)fd; +#endif +} + +/* Best-effort send with retry on partial writes. + * Returns 0 on success, -1 on failure with errno preserved for the caller. */ static int http_send_all(int fd, const char* p, size_t left) { while (left > 0) { - ssize_t w = send(fd, p, left, 0); - if (w <= 0) return -1; + ssize_t w = send(fd, p, left, MSG_NOSIGNAL); + if (w < 0) { + if (errno == EINTR) continue; /* not an error — retry */ + return -1; /* errno stays set for caller */ + } + if (w == 0) { errno = EPIPE; return -1; } p += w; left -= (size_t)w; } return 0; } +/* Did this write fail because the client is gone, or because something is + * actually wrong with the socket? Only the first is routine. */ +static int http_write_err_is_client_gone(int e) { + return e == EPIPE || e == ECONNRESET || e == ENOTCONN || e == ESHUTDOWN; +} + /* Discriminator that http_response() embeds at the start of its envelope. * A handler returning a string starting with this exact prefix is treated * as a structured response; anything else is treated as a raw body. */ @@ -1468,14 +1530,30 @@ static void http_send_response(int fd, const char* body) { free(env_body); free(hdrs.buf); return; } - if (http_send_all(fd, status_line, (size_t)sl) == 0 - && http_send_all(fd, hdrs.buf, hdrs.len) == 0 - && http_send_all(fd, tail, (size_t)tl) == 0 - && (head_only - /* HEAD requests echo headers + Content-Length but no body. */ - ? 1 - : http_send_all(fd, eff_body, blen) == 0)) { - /* sent successfully */ + /* The reply is written in four pieces; any of them can find the client + * already gone. errno is captured at the first failure, before any later + * library call can clobber it, and classified once below. */ + errno = 0; + int send_err = 0; + if (http_send_all(fd, status_line, (size_t)sl) != 0) send_err = errno; + else if (http_send_all(fd, hdrs.buf, hdrs.len) != 0) send_err = errno; + else if (http_send_all(fd, tail, (size_t)tl) != 0) send_err = errno; + else if (!head_only /* HEAD echoes headers + Content-Length, no body. */ + && http_send_all(fd, eff_body, blen) != 0) send_err = errno; + + if (send_err) { + if (http_write_err_is_client_gone(send_err)) { + /* ROUTINE. The user closed the window, quit the app, or cancelled. + * The work is done and the daemon keeps serving everyone else. */ + fprintf(stderr, "[http] client left before the reply was written " + "(%zu-byte body, %s) - request completed, reply discarded\n", + blen, strerror(send_err)); + } else { + /* NOT routine — a real write fault. Never let the client-gone case + * above hide this one. */ + fprintf(stderr, "[http] send failed: %s (%zu-byte body)\n", + strerror(send_err), blen); + } } if (env_parsed_root) el_release(env_parsed_root); @@ -1491,6 +1569,7 @@ static void* http_worker(void* arg) { HttpWorkerArg* a = (HttpWorkerArg*)arg; int fd = a->fd; free(a); + http_socket_nosigpipe(fd); char *method = NULL, *path = NULL, *body = NULL; if (http_read_request(fd, &method, &path, &body, NULL) == 0) { http_handler_fn h = http_lookup_active(); @@ -1531,6 +1610,7 @@ static void* http_worker(void* arg) { } void http_serve(el_val_t port, el_val_t handler) { + el_runtime_ignore_sigpipe(); /* serving implies clients that leave */ /* If `handler` looks like a string name, register it as the active handler. */ const char* hname = EL_CSTR(handler); if (hname && looks_like_string(handler)) { @@ -1634,6 +1714,7 @@ static void* _http_serve_async_loop(void* raw) { } void http_serve_async(el_val_t port, el_val_t handler) { + el_runtime_ignore_sigpipe(); /* serving implies clients that leave */ const char* hname = EL_CSTR(handler); if (hname && looks_like_string(handler)) { http_set_handler(handler); @@ -1821,6 +1902,7 @@ static void* http_worker_v2(void* arg) { HttpWorkerArg* a = (HttpWorkerArg*)arg; int fd = a->fd; free(a); + http_socket_nosigpipe(fd); char *method = NULL, *path = NULL, *body = NULL, *hdr_block = NULL; if (http_read_request(fd, &method, &path, &body, &hdr_block) == 0) { http_handler4_fn h = http_lookup_active_v2(); @@ -1858,6 +1940,7 @@ static void* http_worker_v2(void* arg) { } void http_serve_v2(el_val_t port, el_val_t handler) { + el_runtime_ignore_sigpipe(); /* serving implies clients that leave */ const char* hname = EL_CSTR(handler); if (hname && looks_like_string(handler)) { http_set_handler_v2(handler); @@ -5511,6 +5594,9 @@ el_val_t getpid_now(void) { static el_val_t _el_args_list = 0; void el_runtime_init_args(int argc, char** argv) { + /* First line of every generated main(): a client that leaves must never be + * able to signal this process to death. See el_runtime_ignore_sigpipe. */ + el_runtime_ignore_sigpipe(); _el_args_list = el_list_empty(); for (int i = 1; i < argc; i++) { _el_args_list = el_list_append(_el_args_list, EL_STR(argv[i]));