commit 83a562f6b1687abe88c10f7ebb14fa8117298fa1
parent 24e2a52308d259c8cef849d871155b0c8733d3ba
Author: Hunter
Date: Thu, 23 Jul 2026 13:02:48 -0400
improve local server stability
Diffstat:
| M | https_serve.py | | | 31 | +++++++++++++++++++++++++++---- |
| M | serve.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}"