improve local server stability
Hunter
Thursday, July 23rd, 2026 at 1:02 PM
301 return httpd
302
303
304 +class TLSThreadingServer(socketserver.ThreadingTCPServer):
305 + """Threading server that negotiates TLS in the worker thread, so
306 + handshakes run in parallel."""
307 + daemon_threads = True
308 + allow_reuse_address = True
309 + # default backlog of 5 drops connections when a device precaches in bursts
310 + request_queue_size = 128
311 +
312 + def __init__(self, server_address, handler_cls, ssl_context):
313 + self.ssl_context = ssl_context
314 + super().__init__(server_address, handler_cls)
315 +
316 + def process_request_thread(self, request, client_address):
317 + try:
318 + request.settimeout(20) # cap how long a dead client's handshake lingers
319 + request = self.ssl_context.wrap_socket(request, server_side=True)
320 + request.settimeout(None)
321 + except (ssl.SSLError, OSError):
322 + # Untrusted CA, a probe, or a client that walked away.
323 + self.shutdown_request(request)
324 + return
325 + super().process_request_thread(request, client_address)
326 +
327 +
328 def serve_https(local_ip, base_path, ca_httpd=None):
329 """Serve SCRIPT_DIR over HTTPS for PWA installation, mounted under
330 base_path so the installed app's start_url resolves. Runs until Ctrl+C,
351 def handle(self):
352 try:
353 super().handle()
- except (BrokenPipeError, ConnectionResetError):
354 + except (BrokenPipeError, ConnectionResetError, ssl.SSLError):
355 pass
356
357 def translate_path(self, path):
365 if base_path != "/" and self.path in ("/", "/index.html"):
366 self.send_response(302)
367 self.send_header("Location", base_path)
368 + self.send_header("Content-Length", "0")
369 self.end_headers()
370 return
371 # super() is serve.TrackRequestHandler.do_GET, which handles
383 ca_httpd.server_close()
384 sys.exit(1)
385
- with socketserver.ThreadingTCPServer(("", port), QuietHandler) as httpd:
- httpd.daemon_threads = True
- httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
386 + with TLSThreadingServer(("", port), QuietHandler, ctx) as httpd:
387 https_url = f"https://{local_ip}:{port}{base_path}"
388
389 print("\n" + "=" * 59)
222
223
224 class TrackRequestHandler(http.server.SimpleHTTPRequestHandler):
225 + # keep-alive; every response below needs a Content-Length or must close
226 + protocol_version = 'HTTP/1.1'
227 + # don't let idle connections pin threads forever
228 + timeout = 60
229 + # Nagle would stall the body write behind the header write
230 + disable_nagle_algorithm = True
231 +
232 def do_GET(self):
233 # Rescan /mix on every tracks.json fetch so the page always sees
234 # what's actually on disk (added via rip.py/buy.py or by hand).
247 self.send_response(200)
248 self.send_header('Content-Type', 'text/event-stream')
249 self.send_header('Cache-Control', 'no-cache')
- self.send_header('Connection', 'keep-alive')
250 + # The stream has no Content-Length, so under HTTP/1.1 it has to be
251 + # delimited by closing the socket. Sending this also flips
252 + # close_connection, so the handler won't try to reuse the socket.
253 + self.send_header('Connection', 'close')
254 self.send_header('X-Accel-Buffering', 'no')
255 self.end_headers()
256
281 with sse_subscribers_lock:
282 sse_subscribers.discard(q)
283
284 + def _reply_status(self, status, body=b''):
285 + """Short reply with a Content-Length. Errors also close the socket,
286 + since an early return can leave an unread body in the buffer."""
287 + if isinstance(body, str):
288 + body = body.encode('utf-8')
289 + self.send_response(status)
290 + self.send_header('Content-Length', str(len(body)))
291 + if status >= 400:
292 + self.send_header('Connection', 'close')
293 + self.end_headers()
294 + if body:
295 + try:
296 + self.wfile.write(body)
297 + except (BrokenPipeError, ConnectionResetError):
298 + pass
299 +
300 def _reply_json(self, status, payload):
301 body = json.dumps(payload).encode('utf-8')
302 self.send_response(status)
314 def do_POST(self):
315 # Local-only: receive a reordered tracks array and overwrite tracks.json
316 if self.path != '/tracks':
- self.send_response(404)
- self.end_headers()
317 + self._reply_status(404)
318 return
319 length = int(self.headers.get('Content-Length', '0'))
320 payload = json.loads(self.rfile.read(length))
333 # desired insertion index (X-Insert-After: -1 means prepend).
334 prefix = '/upload/'
335 if not self.path.startswith(prefix):
- self.send_response(404)
- self.end_headers()
336 + self._reply_status(404)
337 return
338
339 raw_name = urllib.parse.unquote(self.path[len(prefix):])
341 filename = Path(raw_name).name
342 ext = Path(filename).suffix.lower()
343 if not filename or ext not in SUPPORTED_UPLOAD_EXTENSIONS:
- self.send_response(415)
- self.end_headers()
344 + self._reply_status(415)
345 return
346
347 length = int(self.headers.get('Content-Length', '0'))
348 if length <= 0 or length > MAX_UPLOAD_BYTES:
- self.send_response(413)
- self.end_headers()
349 + self._reply_status(413)
350 return
351
352 try:
369 remaining -= len(chunk)
370 if remaining != 0:
371 tmp_path.unlink(missing_ok=True)
- self.send_response(400)
- self.end_headers()
372 + self._reply_status(400)
373 return
374
375 with tracks_lock:
408 self._reply_json(200, {'filename': new_name, 'final_index': final_index})
409 except Exception as e:
410 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
411 + self._reply_status(500, str(e))
412
413 def do_DELETE(self):
414 # Local-only: remove a track's audio file from /mix and prune
415 # tracks.json. Path is /tracks/<url-encoded-filename>.
416 prefix = '/tracks/'
417 if not self.path.startswith(prefix):
- self.send_response(404)
- self.end_headers()
418 + self._reply_status(404)
419 return
420
421 filename = urllib.parse.unquote(self.path[len(prefix):])
423 try:
424 target.relative_to(MIX_DIR.resolve())
425 except ValueError:
- self.send_response(400)
- self.end_headers()
426 + self._reply_status(400)
427 return
428 if target == TRACKS_PATH.resolve() or target.name != filename:
- self.send_response(400)
- self.end_headers()
429 + self._reply_status(400)
430 return
431 with tracks_lock:
432 try:
434 except FileNotFoundError:
435 pass
436 except OSError as e:
- self.send_response(500)
- self.end_headers()
- self.wfile.write(str(e).encode('utf-8'))
437 + self._reply_status(500, str(e))
438 return
439 tracks = [t for t in read_tracks() if t.get('filename') != filename]
440 TRACKS_PATH.write_text(
484 pass
485
486
487 + class ThreadedServer(socketserver.ThreadingTCPServer):
488 + daemon_threads = True
489 + # default backlog of 5 drops connections when a browser opens several at once
490 + request_queue_size = 128
491 +
492 try:
- with socketserver.ThreadingTCPServer(("", port), QuietHandler) as httpd:
- httpd.daemon_threads = True
493 + with ThreadedServer(("", port), QuietHandler) as httpd:
494 local_url = f"http://localhost:{port}"
495 network_url = f"http://{local_ip}:{port}"
496