runtime: a client hanging up must not kill the server #153
Closed
will.anderson
wants to merge 1 commits from
fix/sigpipe-kills-the-server into dev
pull from: fix/sigpipe-kills-the-server
merge into: :dev
:main
:dev
:fix/runtime-stack-on-dev
:fix/runtime-growth-guard
:fix/runtime-shim-retire
:fix/runtime-extract-text
:feat/el-speaks
:wire/write-realizes-signal
:fix/singleton-guards-the-state
:fix/transduce-decomposition
:design/correspondence-and-censorship
:docs/correspondence-and-ownership-2026-08-16
:fix/geometry-readable
:docs/builtin-recipe-gate
:fix/sigpipe
:feat/grounding-gradient
:fix/utf8-truncation
:fix/ground-echo-and-self
:fix/think-stance
:fix/cross-cutting-concerns
:feat/el-geometry-transduce
:fix/awareness-thread-engram-race
:fix/think-anchor
:fix/geometry-ingest
:fix/state-get-leak
:wt/soul-runtime-reconcile
:fix/bool-is-int-like
:fix/eq-operand-inference
:fix/missing-import-is-an-error
:feat/alloc-accounting-containers
:fix/math-log-base10
:fix/compiler-quadratic-strlen
:feat/alloc-accounting
:fix/elc-rebuildable-compiler-builtins
:fix/engram-query-param-and-seed-link
:merge-pr103-v2
:merge-swarm-ccr-v2
:feat/engram-ggml-cosine-batch
:improve/ggml-cosine-fp32-and-init
:fix/nsbx-tooling-hardening
:stage
:feat/transduce-unify
:fix/engram-search-latency-reconciled
:feat/engram-metal-cosine-batch
:feat/reframe-region-setop
:worktree-agent-a1bb8ac67d9006e08
:feat/neuron-sandbox
:worktree-agent-af50f3458d7754f19
:worktree-agent-acc02900ef4ade35e
:worktree-agent-aaf04b0a9714c4070
:worktree-agent-a6577c8211c332c5b
:worktree-agent-a6177cda24c71d1df
:worktree-agent-a55d5c2d0e8f2c88b
:worktree-agent-a7e7a591a07291058
:worktree-agent-a456e0cf8cd2ee361
:worktree-agent-a0dc4a33cf5558d4e
:worktree-agent-ac2381b0b9615ab20
:wt/swarm-ccr
:integration/langfaculty-20260814
:feat/nsbx-dev-env
:stage-elp-native-lang
:docs/operator-naming-convention
:stage-elp-lang-consolidation
:stage-elp-es-port
:engram-tiered-storage
:feat/engram-reseed-route
:feat/el-route-decorators
:test/dev-ci-baseline
:fix/cgi-identity-emission-clean
:fix/cgi-identity-emission
:reconcile/el-cluster-windows-runtime
:fix/durable-response-truncation
:fix/engram-lexical-tokenized-search
:fix/http-fs-read-len
:hotfix/ci-stage-main-publish-hardening
:hotfix/ci-dev-publish-hardening
:hotfix/stage-elc-engram-integration
:feat/ranked-engram-search
:hotfix/win-runtime-portability
:hotfix/runtime-engram-get-node-by-label
:feat/engram-semantic-search
:hotfix/elc-fixes
:hotfix/el-runtime-leak-and-persist
:integrate/local-main-commits
:fix/runtime-load-merge-2026-06-30
:fix/windows-rusage-guard
:fix/http-response-truncation
:salvage/tim-wip-presync-20260625
:feat/windows-el-runtime
:fix/runtime-integrity-reconcile
:fix/engram-save-atomic-darwin
:chore/live-darwin-runtime
:feat/wm-api-and-http-serve-async
:fix/engram-node-full-field-corruption
:fix/llm-model-and-utf8
:fix/elb-monolithic-link
:fix/ci-gcloud-install-order
:fix/native-test-precompile-runtime
:fix/ci-base-dev-first-run
:fix/elc-parser-elb-build
:fix/elc-oom-checkout
:fix/css-str-join-separator
:fix/html-template-if-style-script
:fix/elb-gcc-bracket-depth
:fix/ci-openssl-linker
:feat/ci-hook-test
:feat/native-testing
:runtime/integrate
:fix/http-serve-1-arg-compat
:feat/el-html-templates
:feat/js-browser-runtime
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "fix/sigpipe-kills-the-server"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Prod has been crash-looping every ~10 minutes for three days. This is the cause, and it is one line.
Root cause
lang/runtime/el_runtime.c:1341Flags
0, and zero SIGPIPE suppression anywhere in the runtime —grep -rn "SIGPIPE\|MSG_NOSIGNAL\|SO_NOSIGPIPE\|sigaction" lang/runtime/ engram/src/returns 0 hits.send()to a socket whose peer has closed raises SIGPIPE; default disposition is terminate. Any abandoned request kills the server.The chain, each link measured
ai.neuron.engram-tick.plisthasStartInterval 600curl -s **-m10** -X POST /api/tick/api/tickregularly exceeds 10s, so curl hangs uphttp_send_all→send(fd, …, 0)launchctl list ai.neuron.engram→LastExitStatus = 13(raw wait status 13 = signal 13)KeepAlive: truerestarts itWhy nothing was ever logged: a signal-killed process never reaches a line where it could log.
~/.neuron/logs/engram.logis 2.7 MB of nothing but repeated[http] listening on [::]:8742. That is this bug's signature, not a logging gap.The detail worth pausing on
http_send_all's dead-peer branch was already correct:It had never once executed. The signal killed the process before
send()could return-1/EPIPE. The error handling was written and unreachable.The fix
Per-socket / per-call suppression —
SO_NOSIGPIPE(macOS/BSD),MSG_NOSIGNAL(Linux) — not a globalsignal(SIGPIPE, SIG_IGN). Both stop the crash; only this one has zero blast radius. A global handler would also change the disposition of writes to ordinary pipes in the exec/fs paths, which this fix has no business touching. Every HTTP response funnels throughhttp_send_all, so one function covers all three accept loops.Negative control (invariant §8.6)
Same data, same endpoint, same probe, two builds:
The obvious version of this probe is useless, and I shipped it first. Closing with
SO_LINGER=0emits RST; the server's first write then returnsECONNRESETrather than raising SIGPIPE,http_send_allstops cleanly, and the test passes on the unpatched build. It has to be a normal close (FIN): first write succeeds, peer answers RST, second write raises the signal. And liveness must be checked ~45s later — the kill lands when the server reaches its write, not when the client leaves. My first probe got both wrong and reported a false pass on the broken binary. Both traps are documented in the test.engram/test/test_http_sigpipe.c+run_http_sigpipe_test.sh. C and shell, no Python.Not fixed here
/api/activatetakes ~16s and/api/tickexceeds 10s at all; the heartbeat's-m10is shorter than the work it asks for. Fixing SIGPIPE stops the crash but not the empty responses — that is a separate and still-open problem.Also worth someone deciding: prod runs
~/.neuron/bin/engram.memguard-20260815-210116, a hand-labelled binary with no commit behind it. "memguard" was a guess that this was a memory leak. It was not — RSS 2.34 GB is just the resident 2.2 GB.egm, stable across restarts. The wrong hypothesis has been carrying prod for a day because a filename got the same durability as a fact.Nothing was deployed. All work ran against APFS clones on ports 18752/18753.
send() to a socket whose peer has closed raises SIGPIPE, whose default disposition is terminate, and nothing in this runtime suppressed it. grep for SIGPIPE|MSG_NOSIGNAL|SO_NOSIGPIPE|sigaction across lang/runtime/ and engram/src/ returned zero hits. So any abandoned request could kill the process, and one did, every ten minutes, for three days. Measured in production, not inferred: - 254 restarts between 2026-08-13T19:37Z and 2026-08-16T18:16Z - `launchctl list ai.neuron.engram` -> LastExitStatus = 13 (raw wait status 13 = killed by signal 13 = SIGPIPE) - trigger: ai.neuron.engram-tick.plist, StartInterval 600 — matching the restart cadence exactly — running `curl -s -m10 -X POST /api/tick`. When /api/tick exceeded curl's 10s timeout the client hung up, and the eventual response write killed the server. - the tick log recorded an EMPTY response in 279 of 448 ticks. - launchd KeepAlive:true restarted it each time, so the loop was invisible except as a PID that kept changing. Nothing was ever logged about it and nothing could have been: a signal-killed process never reaches a line where it could write one. engram.log is 2.7 MB of nothing but repeated "[http] listening on [::]:8742". That is the signature of this bug, not a gap in logging. Note what was already correct: http_send_all's `w <= 0` branch handles a dead peer properly. It had never once executed, because the signal killed the process before send() could return -1/EPIPE. The error handling was written and unreachable. The suppression is per-socket/per-call (SO_NOSIGPIPE on macOS/BSD, MSG_NOSIGNAL on Linux) rather than a global signal(SIGPIPE, SIG_IGN). Both fix the crash; only this one has zero blast radius. A global handler would also change the disposition of writes to ordinary pipes in the exec/fs paths, which this fix has no business touching. Every HTTP response funnels through http_send_all, so one function covers all three accept loops. Negative control (invariant 8.6). Same data, same endpoint, same probe: unpatched -> SERVER DIED 43.3s after the hang-up (exit 1) patched -> survived 2 rounds, still listening, still serving (exit 0) The probe is C, not Python, and it is worth saying why the obvious version of it is useless: closing with SO_LINGER=0 emits RST, the server's FIRST write returns ECONNRESET rather than raising SIGPIPE, http_send_all stops cleanly, and the test PASSES ON THE UNPATCHED BUILD. It has to be a normal close (FIN) — first write succeeds, peer answers RST, second write raises the signal. And liveness must be checked ~45s later, not immediately: the kill lands when the server reaches its write, not when the client leaves. My first probe got both wrong and reported a false pass. Not fixed here, and worth separate attention: /api/activate takes ~16s and /api/tick exceeds 10s at all, and the heartbeat's -m10 is shorter than the work it asks for.Closing as a duplicate. #151 landed the same fix at 13:25:14 while I was building this, from another session under the same identity — same diagnosis, same LastExitStatus 13, same StartInterval 600 trigger. Theirs is a superset of mine: per-socket SO_NOSIGPIPE + MSG_NOSIGNAL, plus a process-wide SIG_IGN backstop I had deliberately left out for blast radius. Their portability argument is better than my purity argument. Nothing here needs to land.
Two things survive and I am handling them separately:
The fix shipped with no test.
git show cace6a5 --name-onlyis one file,el_runtime.c, andengram/test/has nothing matching sigpipe or http. That is invariant §8.6 unmet on a fix for a bug that killed production 254 times. I have a probe that fails on the pre-fix build (SERVER DIED 43.3s after the hang-up) and passes on merged dev (survived 2 rounds, still serving) — so #151 is now independently verified, which it was not before. I am holding it until the testing-framework question below is settled, because EL now has native testing and a C probe may be the wrong artifact.Two sessions did this same work in parallel, neither aware of the other. That is the same failure the spec names in §7 — 'dreaming has seven implementations' — arriving in the workflow rather than the code. Worth someone's attention independently of this PR.
Pull request closed