penguin/tinyproxy

An L4 proxy designed to act as a tiny transparent shim

tests/test_tcp_idle_timeout.py

raw ยท 9064 bytes

import asyncio
import os

from .support import (
	LISTEN_HOST,
	BACKEND_PORT,
	PROXY_PORT,
	SkipTest,
	run_tinyproxy_with_conf,
	start_tracked_stream_server,
)

async def delayed_response_upload_handler(
	reader: asyncio.StreamReader,
	writer: asyncio.StreamWriter,
) -> None:
	try:
		try:
			header = await reader.readuntil(b"\r\n\r\n")
		except asyncio.IncompleteReadError as e:
			# The test harness/proxy readiness check may open a TCP
			# connection and close it without sending any request bytes.
			# Ignore that. A partial HTTP request is still a real failure.
			if not e.partial:
				return
			raise AssertionError(f"incomplete request headers: {e.partial!r}") from e

		content_length = None
		for line in header.decode("latin1", errors="replace").split("\r\n"):
			if line.lower().startswith("content-length:"):
				content_length = int(line.split(":", 1)[1].strip())
				break

		assert content_length is not None, "missing Content-Length"

		received = 0
		while received < content_length:
			chunk = await reader.read(min(65536, content_length - received))
			if not chunk:
				break
			received += len(chunk)

		assert received == content_length, (
			f"upload body truncated: received={received} expected={content_length}"
		)

		body = b"ok\n"
		writer.write(
			b"HTTP/1.1 200 OK\r\n"
			+ f"Content-Length: {len(body)}\r\n".encode("ascii")
			+ b"Connection: close\r\n"
			+ b"\r\n"
			+ body
		)
		await writer.drain()

	finally:
		writer.close()
		await writer.wait_closed()

async def stalled_upload_handler(
	reader: asyncio.StreamReader,
	writer: asyncio.StreamWriter,
) -> None:
	try:
		try:
			await reader.readuntil(b"\r\n\r\n")
		except asyncio.IncompleteReadError as e:
			# Ignore readiness/preflight connections from the test harness.
			if not e.partial:
				return
			raise AssertionError(f"incomplete request headers: {e.partial!r}") from e

		# Read a little bit so the request is definitely established,
		# then stop consuming the upload body. This should eventually
		# backpressure tinyproxy and trigger the stalled I/O timeout.
		await reader.readexactly(64 * 1024)

		await asyncio.sleep(10.0)
	finally:
		writer.close()
		await writer.wait_closed()

async def echo_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
	try:
		while True:
			data = await reader.read(65536)
			if not data:
				break

			writer.write(data)
			await writer.drain()
	finally:
		writer.close()
		await writer.wait_closed()

async def close_writer(writer: asyncio.StreamWriter) -> None:
	try:
		writer.close()
		await writer.wait_closed()
	except OSError:
		pass

async def read_one_or_eof(reader: asyncio.StreamReader, timeout: float = 3.0) -> bytes:
	try:
		return await asyncio.wait_for(reader.read(1), timeout=timeout)
	except ConnectionResetError:
		return b""


async def test_tcp_idle_timeout_closes_idle_connection() -> None:
	proxy_bin = os.environ.get("TINYPROXY_BIN")
	if not proxy_bin:
		raise SkipTest("TINYPROXY_BIN is not set")

	conf_text = (
		f"listen"
		f" tcp {LISTEN_HOST}:{PROXY_PORT}"
		f" tcp {LISTEN_HOST}:{BACKEND_PORT}"
		f" idle_timeout=1\n"
	)

	backend_server = await start_tracked_stream_server(
		echo_handler,
		LISTEN_HOST,
		BACKEND_PORT,
		backlog=128,
	)

	try:
		async with run_tinyproxy_with_conf(
			proxy_bin=proxy_bin,
			conf_text=conf_text,
			listen_host=LISTEN_HOST,
			listen_port=PROXY_PORT,
			proto="tcp",
		):
			reader, writer = await asyncio.open_connection(LISTEN_HOST, PROXY_PORT)

			try:
				payload = b"before idle timeout\n"

				writer.write(payload)
				await writer.drain()

				got = await asyncio.wait_for(
					reader.readexactly(len(payload)),
					timeout=3.0,
				)
				assert got == payload, (
					f"initial roundtrip mismatch: got={got!r} expected={payload!r}"
				)

				await asyncio.sleep(2.0)

				got = await read_one_or_eof(reader, timeout=3.0)
				assert got == b"", (
					f"expected idle connection to close, got={got!r}"
				)
			finally:
				await close_writer(writer)
	finally:
		await backend_server.close()


async def test_tcp_idle_timeout_keeps_active_connection_open() -> None:
	proxy_bin = os.environ.get("TINYPROXY_BIN")
	if not proxy_bin:
		raise SkipTest("TINYPROXY_BIN is not set")

	conf_text = (
		f"listen"
		f" tcp {LISTEN_HOST}:{PROXY_PORT}"
		f" tcp {LISTEN_HOST}:{BACKEND_PORT}"
		f" idle_timeout=2\n"
	)

	backend_server = await start_tracked_stream_server(
		echo_handler,
		LISTEN_HOST,
		BACKEND_PORT,
		backlog=128,
	)

	try:
		async with run_tinyproxy_with_conf(
			proxy_bin=proxy_bin,
			conf_text=conf_text,
			listen_host=LISTEN_HOST,
			listen_port=PROXY_PORT,
			proto="tcp",
		):
			reader, writer = await asyncio.open_connection(LISTEN_HOST, PROXY_PORT)

			try:
				for i in range(3):
					payload = f"still-active-{i}\n".encode()

					writer.write(payload)
					await writer.drain()

					got = await asyncio.wait_for(
						reader.readexactly(len(payload)),
						timeout=3.0,
					)
					assert got == payload, (
						f"active roundtrip {i} mismatch: "
						f"got={got!r} expected={payload!r}"
					)

					await asyncio.sleep(1.0)
			finally:
				await close_writer(writer)
	finally:
		await backend_server.close()

async def test_tcp_idle_timeout_keeps_long_one_way_upload_open() -> None:
	proxy_bin = os.environ.get("TINYPROXY_BIN")
	if not proxy_bin:
		raise SkipTest("TINYPROXY_BIN is not set")

	conf_text = (
		f"listen"
		f" tcp {LISTEN_HOST}:{PROXY_PORT}"
		f" tcp {LISTEN_HOST}:{BACKEND_PORT}"
		f" idle_timeout=2\n"
	)

	backend_server = await start_tracked_stream_server(
		delayed_response_upload_handler,
		LISTEN_HOST,
		BACKEND_PORT,
		backlog=128,
	)

	try:
		async with run_tinyproxy_with_conf(
			proxy_bin=proxy_bin,
			conf_text=conf_text,
			listen_host=LISTEN_HOST,
			listen_port=PROXY_PORT,
			proto="tcp",
		):
			reader, writer = await asyncio.open_connection(LISTEN_HOST, PROXY_PORT)

			try:
				body_size = 6 * 1024 * 1024
				chunk_size = 64 * 1024
				delay = 0.05

				req = (
					b"PUT /v2/test/blobs/uploads/fake?digest=sha256:deadbeef HTTP/1.1\r\n"
					b"Host: fake-registry\r\n"
					b"User-Agent: test-buildkit/repro\r\n"
					b"Content-Type: application/octet-stream\r\n"
					+ f"Content-Length: {body_size}\r\n".encode("ascii")
					+ b"Connection: close\r\n"
					+ b"\r\n"
				)

				writer.write(req)
				await writer.drain()

				chunk = b"x" * chunk_size
				sent = 0

				while sent < body_size:
					n = min(chunk_size, body_size - sent)
					writer.write(chunk[:n])
					await writer.drain()
					sent += n
					await asyncio.sleep(delay)

				resp = await asyncio.wait_for(reader.read(), timeout=5.0)

				assert b"HTTP/1.1 200 OK" in resp, resp
				assert resp.endswith(b"\r\n\r\nok\n"), resp
			finally:
				await close_writer(writer)
	finally:
		await backend_server.close()

async def test_tcp_stalled_timeout_should_close() -> None:
	proxy_bin = os.environ.get("TINYPROXY_BIN")
	if not proxy_bin:
		raise SkipTest("TINYPROXY_BIN is not set")

	conf_text = (
		f"listen"
		f" tcp {LISTEN_HOST}:{PROXY_PORT}"
		f" tcp {LISTEN_HOST}:{BACKEND_PORT}"
		f" idle_timeout=2\n"
	)

	backend_server = await start_tracked_stream_server(
		stalled_upload_handler,
		LISTEN_HOST,
		BACKEND_PORT,
		backlog=128,
	)

	try:
		async with run_tinyproxy_with_conf(
			proxy_bin=proxy_bin,
			conf_text=conf_text,
			listen_host=LISTEN_HOST,
			listen_port=PROXY_PORT,
			proto="tcp",
		):
			reader, writer = await asyncio.open_connection(LISTEN_HOST, PROXY_PORT)

			try:
				body_size = 512 * 1024 * 1024
				chunk_size = 1024 * 1024

				req = (
					b"PUT /v2/test/blobs/uploads/stalled?digest=sha256:deadbeef HTTP/1.1\r\n"
					b"Host: fake-registry\r\n"
					b"User-Agent: test-buildkit/stalled-repro\r\n"
					b"Content-Type: application/octet-stream\r\n"
					+ f"Content-Length: {body_size}\r\n".encode("ascii")
					+ b"Connection: close\r\n"
					+ b"\r\n"
				)

				writer.write(req)
				await writer.drain()

				chunk = b"x" * chunk_size
				sent = 0
				closed = False

				deadline = asyncio.get_running_loop().time() + 8.0

				while asyncio.get_running_loop().time() < deadline:
					try:
						writer.write(chunk)
						await asyncio.wait_for(writer.drain(), timeout=3.0)
						sent += len(chunk)
					except (
						ConnectionResetError,
						BrokenPipeError,
						asyncio.TimeoutError,
					):
						closed = True
						break

				if not closed:
					got = await read_one_or_eof(reader, timeout=1.0)
					closed = got == b""

				assert closed, (
					f"expected stalled upload to be closed by idle timeout; "
					f"sent={sent}"
				)
			finally:
				await close_writer(writer)
	finally:
		await backend_server.close()

TESTS = [
	("test_tcp_idle_timeout_closes_idle_connection", test_tcp_idle_timeout_closes_idle_connection),
	("test_tcp_idle_timeout_keeps_active_connection_open", test_tcp_idle_timeout_keeps_active_connection_open),
	("test_tcp_idle_timeout_keeps_long_one_way_upload_open", test_tcp_idle_timeout_keeps_long_one_way_upload_open),
	("test_tcp_stalled_timeout_should_close", test_tcp_stalled_timeout_should_close),
]