commit 83a562f6b1687abe88c10f7ebb14fa8117298fa1
parent 24e2a52308d259c8cef849d871155b0c8733d3ba
Author: Hunter
Date:   Thu, 23 Jul 2026 13:02:48 -0400

improve local server stability

Diffstat:
Mhttps_serve.py | 31+++++++++++++++++++++++++++----
Mserve.py | 71+++++++++++++++++++++++++++++++++++++++++++----------------------------
2 files changed, 70 insertions(+), 32 deletions(-)

diff --git a/https_serve.py b/https_serve.py @@ -301,6 +301,30 @@ def start_ca_server(local_ip): return httpd +class TLSThreadingServer(socketserver.ThreadingTCPServer): + """Threading server that negotiates TLS in the worker thread, so + handshakes run in parallel.""" + daemon_threads = True + allow_reuse_address = True + # default backlog of 5 drops connections when a device precaches in bursts + request_queue_size = 128 + + def __init__(self, server_address, handler_cls, ssl_context): + self.ssl_context = ssl_context + super().__init__(server_address, handler_cls) + + def process_request_thread(self, request, client_address): + try: + request.settimeout(20) # cap how long a dead client's handshake lingers + request = self.ssl_context.wrap_socket(request, server_side=True) + request.settimeout(None) + except (ssl.SSLError, OSError): + # Untrusted CA, a probe, or a client that walked away. + self.shutdown_request(request) + return + super().process_request_thread(request, client_address) + + def serve_https(local_ip, base_path, ca_httpd=None): """Serve SCRIPT_DIR over HTTPS for PWA installation, mounted under base_path so the installed app's start_url resolves. Runs until Ctrl+C, @@ -327,7 +351,7 @@ def serve_https(local_ip, base_path, ca_httpd=None): def handle(self): try: super().handle() - except (BrokenPipeError, ConnectionResetError): + except (BrokenPipeError, ConnectionResetError, ssl.SSLError): pass def translate_path(self, path): @@ -341,6 +365,7 @@ def serve_https(local_ip, base_path, ca_httpd=None): if base_path != "/" and self.path in ("/", "/index.html"): self.send_response(302) self.send_header("Location", base_path) + self.send_header("Content-Length", "0") self.end_headers() return # super() is serve.TrackRequestHandler.do_GET, which handles @@ -358,9 +383,7 @@ def serve_https(local_ip, base_path, ca_httpd=None): ca_httpd.server_close() sys.exit(1) - with socketserver.ThreadingTCPServer(("", port), QuietHandler) as httpd: - httpd.daemon_threads = True - httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True) + with TLSThreadingServer(("", port), QuietHandler, ctx) as httpd: https_url = f"https://{local_ip}:{port}{base_path}" print("\n" + "=" * 59) diff --git a/serve.py b/serve.py @@ -222,6 +222,13 @@ def start_rescan_ticker(interval=1.0): class TrackRequestHandler(http.server.SimpleHTTPRequestHandler): + # keep-alive; every response below needs a Content-Length or must close + protocol_version = 'HTTP/1.1' + # don't let idle connections pin threads forever + timeout = 60 + # Nagle would stall the body write behind the header write + disable_nagle_algorithm = True + def do_GET(self): # Rescan /mix on every tracks.json fetch so the page always sees # what's actually on disk (added via rip.py/buy.py or by hand). @@ -240,7 +247,10 @@ class TrackRequestHandler(http.server.SimpleHTTPRequestHandler): self.send_response(200) self.send_header('Content-Type', 'text/event-stream') self.send_header('Cache-Control', 'no-cache') - self.send_header('Connection', 'keep-alive') + # The stream has no Content-Length, so under HTTP/1.1 it has to be + # delimited by closing the socket. Sending this also flips + # close_connection, so the handler won't try to reuse the socket. + self.send_header('Connection', 'close') self.send_header('X-Accel-Buffering', 'no') self.end_headers() @@ -271,6 +281,22 @@ class TrackRequestHandler(http.server.SimpleHTTPRequestHandler): with sse_subscribers_lock: sse_subscribers.discard(q) + def _reply_status(self, status, body=b''): + """Short reply with a Content-Length. Errors also close the socket, + since an early return can leave an unread body in the buffer.""" + if isinstance(body, str): + body = body.encode('utf-8') + self.send_response(status) + self.send_header('Content-Length', str(len(body))) + if status >= 400: + self.send_header('Connection', 'close') + self.end_headers() + if body: + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass + def _reply_json(self, status, payload): body = json.dumps(payload).encode('utf-8') self.send_response(status) @@ -288,8 +314,7 @@ class TrackRequestHandler(http.server.SimpleHTTPRequestHandler): def do_POST(self): # Local-only: receive a reordered tracks array and overwrite tracks.json if self.path != '/tracks': - self.send_response(404) - self.end_headers() + self._reply_status(404) return length = int(self.headers.get('Content-Length', '0')) payload = json.loads(self.rfile.read(length)) @@ -308,8 +333,7 @@ class TrackRequestHandler(http.server.SimpleHTTPRequestHandler): # desired insertion index (X-Insert-After: -1 means prepend). prefix = '/upload/' if not self.path.startswith(prefix): - self.send_response(404) - self.end_headers() + self._reply_status(404) return raw_name = urllib.parse.unquote(self.path[len(prefix):]) @@ -317,14 +341,12 @@ class TrackRequestHandler(http.server.SimpleHTTPRequestHandler): filename = Path(raw_name).name ext = Path(filename).suffix.lower() if not filename or ext not in SUPPORTED_UPLOAD_EXTENSIONS: - self.send_response(415) - self.end_headers() + self._reply_status(415) return length = int(self.headers.get('Content-Length', '0')) if length <= 0 or length > MAX_UPLOAD_BYTES: - self.send_response(413) - self.end_headers() + self._reply_status(413) return try: @@ -347,8 +369,7 @@ class TrackRequestHandler(http.server.SimpleHTTPRequestHandler): remaining -= len(chunk) if remaining != 0: tmp_path.unlink(missing_ok=True) - self.send_response(400) - self.end_headers() + self._reply_status(400) return with tracks_lock: @@ -387,20 +408,14 @@ class TrackRequestHandler(http.server.SimpleHTTPRequestHandler): self._reply_json(200, {'filename': new_name, 'final_index': final_index}) except Exception as e: tmp_path.unlink(missing_ok=True) - self.send_response(500) - self.end_headers() - try: - self.wfile.write(str(e).encode('utf-8')) - except Exception: - pass + self._reply_status(500, str(e)) def do_DELETE(self): # Local-only: remove a track's audio file from /mix and prune # tracks.json. Path is /tracks/<url-encoded-filename>. prefix = '/tracks/' if not self.path.startswith(prefix): - self.send_response(404) - self.end_headers() + self._reply_status(404) return filename = urllib.parse.unquote(self.path[len(prefix):]) @@ -408,12 +423,10 @@ class TrackRequestHandler(http.server.SimpleHTTPRequestHandler): try: target.relative_to(MIX_DIR.resolve()) except ValueError: - self.send_response(400) - self.end_headers() + self._reply_status(400) return if target == TRACKS_PATH.resolve() or target.name != filename: - self.send_response(400) - self.end_headers() + self._reply_status(400) return with tracks_lock: try: @@ -421,9 +434,7 @@ class TrackRequestHandler(http.server.SimpleHTTPRequestHandler): except FileNotFoundError: pass except OSError as e: - self.send_response(500) - self.end_headers() - self.wfile.write(str(e).encode('utf-8')) + self._reply_status(500, str(e)) return tracks = [t for t in read_tracks() if t.get('filename') != filename] TRACKS_PATH.write_text( @@ -473,9 +484,13 @@ def start_server(): pass + class ThreadedServer(socketserver.ThreadingTCPServer): + daemon_threads = True + # default backlog of 5 drops connections when a browser opens several at once + request_queue_size = 128 + try: - with socketserver.ThreadingTCPServer(("", port), QuietHandler) as httpd: - httpd.daemon_threads = True + with ThreadedServer(("", port), QuietHandler) as httpd: local_url = f"http://localhost:{port}" network_url = f"http://{local_ip}:{port}"