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.
This commit is contained in:
2026-11-06 05:23:02 +01:00
parent 4e4fff87d4
commit 1bf3d6a592
2 changed files with 34 additions and 0 deletions
+14
View File
@@ -93,6 +93,15 @@ def _read_head(sock: socket.socket) -> bytes | None:
def _pipe(a: socket.socket, b: socket.socket) -> 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] socks = [a, b]
while True: while True:
ready, _, _ = select.select(socks, [], [], _IDLE_S) ready, _, _ = select.select(socks, [], [], _IDLE_S)
@@ -196,6 +205,11 @@ class _Gate(socketserver.ThreadingTCPServer):
def url(self) -> str: def url(self) -> str:
return f"http://127.0.0.1:{self.server_address[1]}" 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( def start_gate(
*, resolve: Resolver = socket.getaddrinfo, forbidden: Forbidden = urlguard.is_forbidden *, resolve: Resolver = socket.getaddrinfo, forbidden: Forbidden = urlguard.is_forbidden
+20
View File
@@ -10,7 +10,9 @@ import http.server
import ipaddress import ipaddress
import socket import socket
import socketserver import socketserver
import struct
import threading import threading
import time
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -178,6 +180,24 @@ def test_a_tunnel_into_the_network_is_refused_before_it_is_dialled(gate):
internal.close() 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 ---------------------------------------------------------------------- # --- a real yt-dlp through it ----------------------------------------------------------------------