fix(helpers): catch client disconnect errors in response write path

Extract _safe_write() helper that wraps end_headers() + wfile.write()
in try/except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError,
TimeoutError, ssl.SSLError).  Both j() and t() now use _safe_write()
instead of raw wfile calls.

Fixes cascading BrokenPipeError + SSL BAD_LENGTH crash when a client
disconnects mid-response and the error handler tries to write a 500
status through the same broken socket.
This commit is contained in:
someaka authored and nesquena-hermes committed 2026-05-31 01:56:39 +00:00
1 parent 5915d675a0
commit 2cee44b1b2
1 file changed
+24 -4
+24 -4
View File
@@ -4,10 +4,23 @@ Hermes Web UI -- HTTP helper functions.
import json as _json
import os
import re as _re
import ssl
from pathlib import Path
from api.config import IMAGE_EXTS, MD_EXTS
# Treat stalled/closed HTTP clients as normal disconnects. Long-lived SSE
# connections often end this way when a browser tab sleeps, a phone switches
# networks, or Tailscale leaves the socket half-closed.
_CLIENT_DISCONNECT_ERRORS = (
BrokenPipeError,
ConnectionResetError,
ConnectionAbortedError,
TimeoutError,
ssl.SSLError,
)
def require(body: dict, *fields) -> None:
"""Phase D: Validate required fields. Raises ValueError with clean message."""
missing = [f for f in fields if not body.get(f) and body.get(f) != 0]
@@ -65,6 +78,15 @@ def _accepts_gzip(handler) -> bool:
return 'gzip' in ae
def _safe_write(handler, body: bytes) -> None:
"""Write response body, silently ignoring client disconnect errors."""
try:
handler.end_headers()
handler.wfile.write(body)
except _CLIENT_DISCONNECT_ERRORS:
pass
def j(handler, payload, status: int=200, extra_headers: dict=None) -> None:
"""Send a JSON response.
@@ -89,8 +111,7 @@ def j(handler, payload, status: int=200, extra_headers: dict=None) -> None:
if extra_headers:
for k, v in extra_headers.items():
handler.send_header(k, v)
handler.end_headers()
handler.wfile.write(body)
_safe_write(handler, body)
def t(handler, payload, status: int=200, content_type: str='text/plain; charset=utf-8') -> None:
@@ -101,8 +122,7 @@ def t(handler, payload, status: int=200, content_type: str='text/plain; charset=
handler.send_header('Content-Length', str(len(body)))
handler.send_header('Cache-Control', 'no-store')
_security_headers(handler)
handler.end_headers()
handler.wfile.write(body)
_safe_write(handler, body)
MAX_BODY_BYTES = 20 * 1024 * 1024 # 20MB limit for non-upload POST bodies