From 2cee44b1b285cd3e4670c1e222b8680572761b2d Mon Sep 17 00:00:00 2001 From: someaka Date: Fri, 22 May 2026 16:34:08 +0200 Subject: [PATCH] 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. --- api/helpers.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/api/helpers.py b/api/helpers.py index 0fe91a2e..05fbd2f6 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -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