From 7e8d0ddbeaad06aedf6a637efe84d291bc791871 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 3 Jun 2026 12:38:52 -0700 Subject: [PATCH] =?UTF-8?q?Release=20v0.51.239=20=E2=80=94=20Release=20HG?= =?UTF-8?q?=20(stage-q10)=20(#3494)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Release v0.51.239 — Release HG (stage-q10) Phase 3 MEDIUM-ring **salvage** from #3407. The source PR bundled a universal reliability fix with debug scaffolding + Android-specific work; this release ships only the clean, universal nugget. ### Fixed | Salvaged from | Author | Fix | |---|---|---| | #3407 | @PatrickNoFilter | `server.py` ignores `SIGPIPE` (`SIG_IGN`) at import time so a client dropping the connection mid-response (tab close mid-stream, network drop, mobile backgrounding, dropped long-poll, `/api/updates/check` timeout) can't silently `Term` the whole process. The broken write now surfaces as a catchable `BrokenPipeError`; the server keeps serving. | ### Why salvage, not merge whole #3407 (585L, 11 commits) bundles three groups: (1) the SIGPIPE fix + a 271-line `diag_shim.py` debug module, (2) an Android-cgroup-specific `os.fork`/`setsid` restart rewrite in `updates.py`, (3) personal deploy scripts (`start-webui.sh`/`watchdog-loop.sh`, which the author notes are "user-side infra, not in the server tree"). Only the SIGPIPE fix is universal, low-risk, and ship-ready — the rest is investigation tooling for a now-solved mystery or platform-specific. The source PR is held with a detailed split explanation. ### Added safety over the source PR The original used a bare `signal.signal(signal.SIGPIPE, ...)` which would `AttributeError` on Windows (no `SIGPIPE`). The salvaged version is `getattr`-guarded so it's a no-op on Windows, preserving the native-Windows support shipped in #1952 (HD). ### Gate results - **Full pytest suite**: 7498 passed, 9 skipped, 3 xpassed, **0 failed** - **ruff**: CLEAN · **browser-smoke**: CLEAN - **Codex (regression)**: SAFE TO SHIP — verified the getattr Windows-guard, that the ignore disposition lands correctly across the `os.execv` self-restart, and that subprocess children use `restore_signals=True` so the ignore doesn't leak to git/shell/editor children. Regression test `tests/test_issue3407_sigpipe_ignore.py` pins SIG_IGN on POSIX, no-raise import, and the getattr guard. Co-authored-by: PatrickNoFilter --- CHANGELOG.md | 5 +++ server.py | 21 ++++++++++ tests/test_issue3407_sigpipe_ignore.py | 56 ++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 tests/test_issue3407_sigpipe_ignore.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e77f14f5..03da5bb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] +## [v0.51.239] — 2026-06-03 — Release HG (stage-q10 — ignore SIGPIPE so a dropped client can't kill the server) + +### Fixed +- The server no longer dies silently when a client drops the connection mid-response. Python's default action for `SIGPIPE` is `Term`, so a single broken-pipe `socket.send()` in any `ThreadingHTTPServer` worker thread (browser tab closed mid-stream, network drop, mobile backgrounding, a dropped long-poll, an `/api/updates/check` timeout) could terminate the entire WebUI process — no exception, no log, no `/health` response. `server.py` now sets `SIGPIPE` to `SIG_IGN` at import time: the kernel surfaces the broken pipe as a catchable `BrokenPipeError`, the per-request handler unwinds, the connection closes, and the server keeps serving. The handler is `getattr`-guarded so it is a no-op on Windows, where `SIGPIPE` does not exist (preserves native-Windows support, #1952) (salvaged from #3407, @PatrickNoFilter). + ## [v0.51.238] — 2026-06-03 — Release HF (stage-q9 — New Conversation hits the fast path on cold start) ### Fixed diff --git a/server.py b/server.py index 95a28977..28b6530c 100644 --- a/server.py +++ b/server.py @@ -6,6 +6,7 @@ All business logic lives in api/*. import logging import os import re +import signal import socket import sys import threading @@ -13,6 +14,26 @@ import time import traceback from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +# ── SIGPIPE handling ──────────────────────────────────────────────────────── +# Ignore SIGPIPE so a client closing the connection mid-response (browser tab +# close, network drop, mobile backgrounding, a dropped long-poll, an +# `/api/updates/check` timeout, etc.) does not terminate the whole server +# process. Python's default action for SIGPIPE is `Term`, so a single dropped +# `socket.send()` in any request thread could kill the entire WebUI silently — +# no exception, no log, no `/health` response. With SIG_IGN the kernel still +# returns EPIPE to the offending write (surfaced as `BrokenPipeError`); the +# per-request handler unwinds and the connection just closes, while the server +# keeps serving. Set at import time so it is in effect before any +# ThreadingHTTPServer worker thread writes its first response. (Salvaged from +# #3407 @PatrickNoFilter — reproduced in production 2026-06-02.) +# +# SIGPIPE is POSIX-only; it does not exist on Windows (where there is no +# broken-pipe signal and writes to a dead socket raise an OSError directly), so +# guard with getattr to keep native-Windows support (#1952) working. +_SIGPIPE = getattr(signal, "SIGPIPE", None) +if _SIGPIPE is not None: + signal.signal(_SIGPIPE, signal.SIG_IGN) + # ── Test-mode network isolation ───────────────────────────────────────────── # When `HERMES_WEBUI_TEST_NETWORK_BLOCK=1` is set in the environment, refuse # outbound socket connections to anything that is not loopback / RFC1918 / diff --git a/tests/test_issue3407_sigpipe_ignore.py b/tests/test_issue3407_sigpipe_ignore.py new file mode 100644 index 00000000..cdf5ec1f --- /dev/null +++ b/tests/test_issue3407_sigpipe_ignore.py @@ -0,0 +1,56 @@ +"""Regression: server.py must ignore SIGPIPE so a client dropping the +connection mid-response cannot kill the whole WebUI process (salvaged from +#3407). + +Python's default SIGPIPE action is ``Term``: a single broken-pipe ``send()`` +in any ThreadingHTTPServer worker thread would terminate the entire server +silently — no exception, no log, no ``/health`` response. server.py sets +``SIGPIPE`` to ``SIG_IGN`` at import time so the kernel surfaces EPIPE as a +catchable ``BrokenPipeError`` and the server keeps serving. + +The handler is guarded with ``getattr(signal, "SIGPIPE", None)`` because +SIGPIPE is POSIX-only and does not exist on Windows (native-Windows support, +#1952) — importing server.py on Windows must not raise. +""" + +from __future__ import annotations + +import signal +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent + + +def test_sigpipe_set_to_ignore_after_import(): + """After importing server, SIGPIPE's handler must be SIG_IGN on POSIX.""" + if not hasattr(signal, "SIGPIPE"): + # Windows / no-SIGPIPE platform: nothing to assert, importing server + # must simply not raise (covered by test_import_does_not_raise below). + return + import server # noqa: F401 (import installs the handler at module load) + + current = signal.getsignal(signal.SIGPIPE) + assert current == signal.SIG_IGN, ( + "server.py must set SIGPIPE to SIG_IGN so a dropped client mid-response " + f"cannot Term the whole process; got handler {current!r}" + ) + + +def test_import_does_not_raise(): + """Importing server must not raise — proves the getattr guard works on + any platform (including a hypothetical no-SIGPIPE one).""" + import server # noqa: F401 + + assert server is not None + + +def test_sigpipe_handler_is_getattr_guarded_in_source(): + """The handler must be guarded with getattr(signal, 'SIGPIPE', None) so the + POSIX-only signal can't AttributeError on Windows (native-Windows support, + #1952).""" + src = (REPO_ROOT / "server.py").read_text(encoding="utf-8") + assert 'getattr(signal, "SIGPIPE"' in src or "getattr(signal, 'SIGPIPE'" in src, ( + "server.py must resolve SIGPIPE via getattr so it is Windows-safe; a " + "bare signal.SIGPIPE reference would AttributeError on native Windows." + ) + assert "SIG_IGN" in src, "server.py must set the SIGPIPE handler to SIG_IGN"