Release v0.51.239 — Release HG (stage-q10) (#3494)

## 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 <PatrickNoFilter@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-06-03 12:38:52 -07:00
committed by GitHub
parent 1fe8950022
commit 7e8d0ddbea
3 changed files with 82 additions and 0 deletions
+5
View File
@@ -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
+21
View File
@@ -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 /
+56
View File
@@ -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"