Files
el/engram/test/test_http_sigpipe.c
T
Neuron dab081c8f8
El SDK CI - dev / build-and-test (pull_request) Failing after 3m47s
runtime: a client hanging up must not kill the server
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.
2026-08-16 13:37:49 -05:00

120 lines
4.9 KiB
C

/* test_http_sigpipe.c — a client that hangs up mid-response must not kill the
* server. Integration probe: point it at a running engram.
*
* WHAT THIS REPRODUCES. `send()` to a socket whose peer has closed raises
* SIGPIPE, whose default disposition is terminate. Nothing in the runtime
* suppressed it, so any abandoned request could kill the process. In production
* the abandoning client was the heartbeat: ai.neuron.engram-tick.plist runs on
* StartInterval 600 and calls `curl -s -m10 -X POST /api/tick`; when the endpoint
* exceeded ten seconds curl hung up, and the eventual response write killed the
* server. 254 restarts between 2026-08-13T19:37 and 2026-08-16T18:16, with
* `launchctl list ai.neuron.engram` reporting LastExitStatus = 13 — raw wait
* status 13, killed by signal 13, SIGPIPE.
*
* TWO THINGS THIS PROBE GETS RIGHT, both of which a naive version gets wrong and
* both of which cost me a false PASS on the unpatched build before I caught them:
*
* 1. CLOSE MODE. Closing with SO_LINGER=0 emits RST, and the server's FIRST
* write then returns ECONNRESET rather than raising SIGPIPE: http_send_all
* sees w <= 0, returns -1, and stops. No signal, no crash — the test passes
* on the UNPATCHED build and proves nothing. A NORMAL close (FIN) is what
* kills: the first write succeeds, the peer answers RST because it is fully
* closed, and the SECOND write raises SIGPIPE. A response is emitted as four
* http_send_all calls (status line, headers, tail, body), so the sequence is
* always reached.
*
* 2. TIMING. The kill does not land when the client closes. It lands when the
* server reaches its write, which on a heavy endpoint is many seconds later
* (~16s measured on /api/activate over a 13,632-node store). Checking
* liveness a second after the hang-up finds the process healthy and reports
* a false pass.
*
* Usage: test_http_sigpipe <port> <pid> [rounds] [settle_seconds]
* Exit 0 = server survived every round (PASS), 1 = server died (FAIL),
* 2 = bad precondition.
*/
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <signal.h>
#include <time.h>
#include <netdb.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
/* The request is deliberately expensive: the body must be large enough and slow
* enough that the server is still working when the client walks away. */
static const char* REQ_PATH =
"/api/activate?q=will%20anderson&depth=3&_auth=ntn-user-2026";
static int alive(pid_t pid) { return kill(pid, 0) == 0 || errno == EPERM; }
static void sleep_ms(long ms) {
struct timespec ts = { ms / 1000, (ms % 1000) * 1000000L };
nanosleep(&ts, NULL);
}
/* Connect, ask for the expensive body, then hang up NORMALLY without reading. */
static int abort_request(int port) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) return -1;
struct sockaddr_in a;
memset(&a, 0, sizeof a);
a.sin_family = AF_INET;
a.sin_port = htons((uint16_t)port);
a.sin_addr.s_addr = inet_addr("127.0.0.1");
if (connect(fd, (struct sockaddr*)&a, sizeof a) != 0) { close(fd); return -1; }
char req[512];
int n = snprintf(req, sizeof req,
"GET %s HTTP/1.1\r\nHost: probe\r\nConnection: close\r\nAccept: */*\r\n\r\n",
REQ_PATH);
if (write(fd, req, (size_t)n) != n) { close(fd); return -1; }
sleep_ms(200); /* ensure the request landed and work has begun */
close(fd); /* NORMAL close -> FIN. Never SO_LINGER=0 / RST. */
return 0;
}
int main(int argc, char** argv) {
if (argc < 3) {
fprintf(stderr,
"usage: %s <port> <pid> [rounds=1] [settle_seconds=45]\n", argv[0]);
return 2;
}
int port = atoi(argv[1]);
pid_t pid = (pid_t)atoi(argv[2]);
int rounds = argc > 3 ? atoi(argv[3]) : 1;
int settle = argc > 4 ? atoi(argv[4]) : 45;
if (!alive(pid)) {
printf(" PRECONDITION FAILED: pid %d is not running before the probe\n", pid);
return 2;
}
printf(" precondition: pid %d alive, target port %d\n", pid, port);
for (int i = 1; i <= rounds; i++) {
if (abort_request(port) != 0) {
printf(" round %d: could not reach the server on port %d\n", i, port);
return 2;
}
printf(" round %d: client hung up; waiting up to %ds for the server to "
"reach its write...\n", i, settle);
for (int s = 0; s < settle * 2; s++) {
if (!alive(pid)) {
printf(" round %d: SERVER DIED %.1fs after the hang-up *** FAIL ***\n",
i, s / 2.0);
return 1;
}
sleep_ms(500);
}
printf(" round %d: server survived\n", i);
}
printf(" PASS: the server survived %d abandoned request(s)\n", rounds);
return 0;
}