From 1bf3d6a592d9b0a02e340d1480393065e2d9d24e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 6 Nov 2026 05:23:02 +0100 Subject: [PATCH] fix(downloads): a download that hangs up mid-transfer is not an error at the egress gate Seen live: yt-dlp closes a connection once it has read what it needed, and the gate printed a traceback for each. A reset ends the tunnel quietly; anything else the gate cannot finish is a log line with its code, not bare stderr. --- backend/app/downloads/egress.py | 14 ++++++++++++++ backend/tests/test_egress.py | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/backend/app/downloads/egress.py b/backend/app/downloads/egress.py index 991e8ce8..a5649c06 100644 --- a/backend/app/downloads/egress.py +++ b/backend/app/downloads/egress.py @@ -93,6 +93,15 @@ def _read_head(sock: socket.socket) -> bytes | None: def _pipe(a: socket.socket, b: socket.socket) -> None: + try: + _relay(a, b) + except OSError: + # silent-ok: one side hung up mid-transfer (yt-dlp closes a connection once it has read a + # file's size, or gives up on a slow host); whichever side cared reports it itself. + return + + +def _relay(a: socket.socket, b: socket.socket) -> None: socks = [a, b] while True: ready, _, _ = select.select(socks, [], [], _IDLE_S) @@ -196,6 +205,11 @@ class _Gate(socketserver.ThreadingTCPServer): def url(self) -> str: return f"http://127.0.0.1:{self.server_address[1]}" + def handle_error(self, request, client_address) -> None: + # socketserver's default prints a bare traceback to stderr, outside the log; a connection the + # gate could not finish is a line with its code instead. + log.exception("egress gate dropped a connection", extra=logs.event("download.egress.error")) + def start_gate( *, resolve: Resolver = socket.getaddrinfo, forbidden: Forbidden = urlguard.is_forbidden diff --git a/backend/tests/test_egress.py b/backend/tests/test_egress.py index 4ce83b2d..012dd3d1 100644 --- a/backend/tests/test_egress.py +++ b/backend/tests/test_egress.py @@ -10,7 +10,9 @@ import http.server import ipaddress import socket import socketserver +import struct import threading +import time from pathlib import Path import pytest @@ -178,6 +180,24 @@ def test_a_tunnel_into_the_network_is_refused_before_it_is_dialled(gate): internal.close() +def test_a_client_that_hangs_up_mid_transfer_is_not_an_error(gate, monkeypatch): + # yt-dlp closes a connection once it has read what it needed; seen live as a traceback the gate + # printed for every such download. + errors_seen = [] + monkeypatch.setattr(gate, "handle_error", lambda *a: errors_seen.append(a)) + public = _public_server({"/big": (200, {}, b"x" * 4_000_000)}) + try: + s = socket.create_connection(("127.0.0.1", gate.server_address[1])) + s.sendall(f"GET http://{PUBLIC}:{public.server_address[1]}/big HTTP/1.1\r\n\r\n".encode()) + s.recv(1024) + s.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) + s.close() # a reset, not a polite close + time.sleep(0.5) + assert errors_seen == [] + finally: + public.shutdown() + + # --- a real yt-dlp through it ----------------------------------------------------------------------