import asyncio import inspect import struct import time import unittest from types import SimpleNamespace from unittest.mock import patch import httpx from proxy.balancer import _Balancer from proxy.cf_h2 import ( CfH2Pool, MAX_CHANNEL_REQUESTS, MAX_PACKET, _HttpChannel, _HttpLane, _ReplayPacket, _encode_reply, _read_packet, bridge_h2, ) from proxy.utils import PROTO_TAG_ABRIDGED, PROTO_TAG_INTERMEDIATE, PROTO_TAG_SECURE class _IdentityCipher: def update(self, value): return value class NativeFramingTest(unittest.IsolatedAsyncioTestCase): async def _packet(self, wire, tag): reader = asyncio.StreamReader() async def feed(): for byte in wire: reader.feed_data(bytes([byte])) await asyncio.sleep(0) reader.feed_eof() task = asyncio.create_task(feed()) try: return await _read_packet(reader, _IdentityCipher(), tag) finally: task.cancel() await asyncio.gather(task, return_exceptions=True) async def test_fragmented_abridged_extended_and_quick_ack(self): body = b'a' * 512 decoded, quick = await self._packet(b'\xff\x80\x00\x00' + body, PROTO_TAG_ABRIDGED) self.assertEqual(decoded, body) self.assertTrue(quick) async def test_intermediate_quick_ack_does_not_change_body(self): body = b'a' * 40 decoded, quick = await self._packet(struct.pack('= len(expected): break await asyncio.sleep(0) self.assertEqual(writer.body, expected) finally: release_tail.set() reader.feed_eof() if not task.done(): task.cancel() await asyncio.gather(task, return_exceptions=True) async def test_native_frame_starts_only_after_entire_body_is_available(self): await self._packet_bridge() async def test_ready_reply_bypasses_incomplete_large_response_on_same_channel(self): await self._packet_bridge(concurrent_small=True) async def test_response_without_length_uses_buffered_framing(self): await self._packet_bridge(content_length=False) async def test_truncated_stream_closes_native_channel(self): await self._packet_bridge(truncate=True) async def test_recovery_pauses_during_body_receive_and_resumes_afterwards(self): started, release = asyncio.Event(), asyncio.Event() calls = [] class SlowBody(httpx.AsyncByteStream): async def __aiter__(self): yield b'r' * 20 started.set() await release.wait() yield b'r' * 20 def handler(request): calls.append(request.content) if len(calls) == 1: return httpx.Response(200, stream=SlowBody(), extensions={'http_version': b'HTTP/2'}) return httpx.Response(200, content=b's' * 40, extensions={'http_version': b'HTTP/2'}) self._transport(handler) channel = self._channel() await channel.send(b'x' * 40, False) await asyncio.wait_for(started.wait(), 1) with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005): recovery = asyncio.create_task(channel._recover()) try: await asyncio.sleep(.06) self.assertEqual(len(calls), 1) self.assertEqual(channel.receiving_http, 1) release.set() self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40) self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b's' * 40) self.assertEqual(calls, [b'x' * 40, b'x' * 40]) self.assertEqual(channel.receiving_http, 0) self.assertEqual(self.lane.reply_buffer_bytes, 0) finally: recovery.cancel() await asyncio.gather(recovery, return_exceptions=True) async def test_lane_reply_memory_bound_and_cancellation_cleanup(self): started = asyncio.Event() class SlowBody(httpx.AsyncByteStream): async def __aiter__(self): yield b'r' * 32 started.set() await asyncio.Event().wait() def handler(request): if request.content == b'a' * 40: return httpx.Response(200, stream=SlowBody(), extensions={'http_version': b'HTTP/2'}) return httpx.Response(200, content=b'r' * 16, extensions={'http_version': b'HTTP/2'}) self._transport(handler) slow = self._channel() with patch('proxy.cf_h2.MAX_LANE_REPLY_BYTES', 40): await slow.send(b'a' * 40, False) await asyncio.wait_for(started.wait(), 1) self.assertEqual(self.lane.reply_buffer_bytes, 32) with self.assertRaisesRegex(BufferError, 'response buffers'): await self.lane._post(b'b' * 40, 2) self.assertEqual(self.lane.reply_buffer_bytes, 32) self.assertEqual(self.lane.failed_until, 0) await slow.close() self.assertEqual(slow.receiving_http, 0) self.assertEqual(self.lane.reply_buffer_bytes, 0) self.assertEqual(await self.lane._post(b'b' * 40, 2), b'r' * 16) async def test_native_disconnect_does_not_cool_down_healthy_lane(self): self._transport(lambda request: httpx.Response( 200, content=b'r' * 65536, extensions={'http_version': b'HTTP/2'})) class DisconnectedWriter: def write(self, data): pass async def drain(self): raise ConnectionResetError('Connection lost') channel = self._channel() reader = asyncio.StreamReader() reader.feed_data(struct.pack(' 1: if len(calls) == 4: probes_waiting.set() await asyncio.Event().wait() return httpx.Response(200, content=b'r' * 40, extensions={'http_version': b'HTTP/2'}) self._transport(handler) channel = self._channel() bodies = [b'samekey!' + bytes([index]) * 32 for index in range(4)] for body in bodies[:3]: await channel.send(body, False) await asyncio.gather(*list(channel.pending)) await channel.receive() with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005): recovery = asyncio.create_task(channel._recover()) try: await asyncio.wait_for(probes_waiting.wait(), 1) await asyncio.sleep(.03) self.assertEqual(channel.replay_requests, 1) self.assertEqual(len(channel.replay_pending), 1) await asyncio.wait_for(channel.send(bodies[3], False), 1) self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40) self.assertEqual(calls[3:4], [bodies[2]]) finally: recovery.cancel() await asyncio.gather(recovery, return_exceptions=True) await asyncio.gather(channel.close(), channel.close()) self.assertEqual(self.lane.inflight, 0) self.assertEqual(channel.pending_bytes, 0) self.assertFalse(channel.replay_pending) async def test_handshake_and_expired_ciphertext_are_never_replayed(self): calls = [] async def handler(request): calls.append(request.content) return httpx.Response(200, content=b'r' * 40, extensions={'http_version': b'HTTP/2'}) self._transport(handler) channel = self._channel() for body in (b'\x00' * 8 + b'h' * 32, b'k' * 40): await channel.send(body, False) await asyncio.gather(*list(channel.pending)) await channel.receive() # Windows may give consecutive monotonic() calls the same timestamp; # an event wakeup need not advance the clock like the old sleep did. channel.replay_history[-1].sent_at -= 1 with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \ patch('proxy.cf_h2.REPLAY_MAX_AGE', 0): recovery = asyncio.create_task(channel._recover()) try: await asyncio.sleep(.04) self.assertEqual(len(calls), 2) finally: recovery.cancel() await asyncio.gather(recovery, return_exceptions=True) async def test_unhelpful_replies_do_not_cause_unbounded_recovery(self): calls = [] retries_finished = asyncio.Event() async def handler(request): calls.append((request.content, time.monotonic())) if len(calls) == 5: retries_finished.set() return httpx.Response(200, content=b'', extensions={'http_version': b'HTTP/2'}) self._transport(handler) channel = self._channel() bodies = [b'samekey!' + bytes([index]) * 32 for index in range(2)] for body in bodies: await channel.send(body, False) await asyncio.gather(*list(channel.pending)) with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .005), \ patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .002), \ patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .02): recovery = asyncio.create_task(channel._recover()) try: await asyncio.wait_for(retries_finished.wait(), 1) await asyncio.sleep(.06) older = [when for body, when in calls if body == bodies[0]] latest = [when for body, when in calls if body == bodies[1]] # Completed history is not scanned; only the latest is polled. self.assertEqual(len(older), 1) self.assertEqual(len(latest), 4) for first, second in zip(latest[1:], latest[2:]): self.assertGreaterEqual(second - first, .015) self.assertFalse(channel.pending) self.assertFalse(channel.replay_pending) self.assertEqual(self.lane.inflight, 0) finally: recovery.cancel() await asyncio.gather(recovery, return_exceptions=True) async def test_new_replies_refresh_idle_receiver_budget_without_native_upload(self): replies = [b'samekey!' + bytes([index]) * 32 for index in range(6)] calls = [] exhausted = asyncio.Event() def handler(request): calls.append(request.content) count = len(calls) if count == len(replies) + 3: exhausted.set() return httpx.Response(200, content=replies[count - 1] if count <= len(replies) else b'', extensions={'http_version': b'HTTP/2'}) self._transport(handler) channel = self._channel() body = b'samekey!' + b'a' * 32 with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .005), \ patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .002), \ patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .01): await channel.send(body, False) recovery = asyncio.create_task(channel._recover()) try: # The native client is waiting for a batch of replies and # sends no new packet between them. Three successful polls # must not use up the budget while more responses arrive. for reply in replies: self.assertEqual(await asyncio.wait_for(channel.receive(), 1), reply) await asyncio.wait_for(exhausted.wait(), 1) await asyncio.sleep(.05) self.assertEqual(calls, [body] * (len(replies) + 3)) self.assertFalse(channel.pending) self.assertFalse(channel.replay_pending) finally: recovery.cancel() await asyncio.gather(recovery, return_exceptions=True) async def test_duplicate_reply_does_not_refresh_idle_receiver_budget(self): calls = [] reply = b'r' * 40 def handler(request): calls.append(request.content) return httpx.Response(200, content=reply, extensions={'http_version': b'HTTP/2'}) self._transport(handler) channel = self._channel() with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .005), \ patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .002), \ patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .01): await channel.send(b'samekey!' + b'a' * 32, False) recovery = asyncio.create_task(channel._recover()) try: for _ in range(4): self.assertEqual(await asyncio.wait_for(channel.receive(), 1), reply) await asyncio.sleep(.05) self.assertEqual(len(calls), 4) self.assertFalse(channel.pending) self.assertFalse(channel.replay_pending) finally: recovery.cancel() await asyncio.gather(recovery, return_exceptions=True) async def test_old_pending_request_has_bounded_retries_after_newer_requests(self): body = b'samekey!' + b't' * 32 original_started = asyncio.Event() retries_finished = asyncio.Event() attempts = 0 async def handler(request): nonlocal attempts if request.content == body: attempts += 1 if attempts == 1: original_started.set() await asyncio.Event().wait() if attempts == 4: retries_finished.set() return httpx.Response(200, content=b'', extensions={'http_version': b'HTTP/2'}) self._transport(handler) channel = self._channel() await channel.send(body, False) await original_started.wait() original = next(iter(channel.pending)) await channel.send(b'samekey!' + b'n' * 32, False) with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \ patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \ patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \ patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .02): recovery = asyncio.create_task(channel._recover()) try: await asyncio.wait_for(retries_finished.wait(), 1) await asyncio.sleep(.06) self.assertEqual(attempts, 4) self.assertFalse(original.done()) self.assertFalse(channel.replay_pending) finally: recovery.cancel() await asyncio.gather(recovery, return_exceptions=True) async def test_closed_pool_cannot_open_new_channels(self): pool = CfH2Pool() await pool.close() with self.assertRaisesRegex(ConnectionError, 'pool closed'): await pool.open(4, 'owned-test') async def test_concurrent_upload_and_recovery_cannot_exceed_channel_request_bound(self): release = asyncio.Event() async def handler(request): await release.wait() return httpx.Response(200, content=request.content, extensions={'http_version': b'HTTP/2'}) self._transport(handler) channel = self._channel() for _ in range(MAX_CHANNEL_REQUESTS - 1): await channel.send(b'k' * 40, False) first = asyncio.create_task(channel.send(b'a' * 40, False)) second = asyncio.create_task(channel.send(b'b' * 40, False)) try: await asyncio.wait_for(first, 1) await asyncio.sleep(.02) self.assertEqual(len(channel.pending), MAX_CHANNEL_REQUESTS) self.assertFalse(second.done()) release.set() await asyncio.wait_for(second, 1) finally: release.set() first.cancel() second.cancel() await asyncio.gather(first, second, return_exceptions=True) async def test_failing_preflight_returns_to_ws_without_consuming_native_data(self): pool = CfH2Pool() balancer = _Balancer() balancer.update_domains_list(['example.org']) async def preflight(lane): raise OSError('owned connection failure') try: with patch('proxy.cf_h2.balancer', balancer), patch.object(_HttpLane, '_preflight', preflight): self.assertIsNone(await pool.open(4, 'owned-test')) self.assertFalse(pool.lanes) finally: await pool.close() async def test_hanging_setup_returns_to_ws_and_cleans_up_lane(self): pool = CfH2Pool() balancer = _Balancer() balancer.update_domains_list(['example.org']) lanes = [] async def preflight(lane): lanes.append(lane) await asyncio.Event().wait() try: with patch('proxy.cf_h2.balancer', balancer), \ patch('proxy.cf_h2.SETUP_TIMEOUT', .02), \ patch.object(_HttpLane, '_preflight', preflight): self.assertIsNone(await pool.open(4, 'owned-test')) self.assertIsNone(await pool.open(4, 'owned-test')) self.assertEqual(len(lanes), 1) self.assertTrue(lanes[0].closed) self.assertTrue(lanes[0].client.is_closed) self.assertFalse(pool.lanes) finally: await pool.close() async def test_recovery_does_not_wait_for_channel_or_lane_capacity(self): channel = self._channel() body = b'k' * 40 channel.replay_history.append(_ReplayPacket(0, body, 0)) with patch('proxy.cf_h2.MAX_CHANNEL_REQUESTS', 0): self.assertFalse(await asyncio.wait_for(channel.send(body, False, replay=True), .1)) with patch('proxy.cf_h2.MAX_LANE_REQUESTS', 0): self.assertFalse(await asyncio.wait_for(channel.send(body, False, replay=True), .1)) self.assertFalse(channel.send_lock.locked()) self.assertFalse(channel.replay_history[0].replayed) self.assertEqual(channel.requests, 0) self.assertEqual(self.lane.inflight, 0) async def test_recovery_does_not_queue_behind_native_upload(self): channel = self._channel() async with channel.send_lock: self.assertFalse(await asyncio.wait_for(channel.send(b'k' * 40, False, replay=True), .1)) self.assertEqual(channel.requests, 0) async def test_wait_diagnostics_combine_channels_and_are_rate_limited(self): async def handler(request): await asyncio.Event().wait() self._transport(handler) now = time.monotonic() for _ in range(2): channel = self._channel() await channel.send(b'k' * 40, False) channel.last_progress = now - 4 channel.pending_since = dict.fromkeys(channel.pending, now - 4) with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured: for _ in range(20): self.lane.log_wait(now) self.assertEqual(len(captured.records), 1) self.assertIn('H2 WAIT', captured.output[0]) self.assertIn('affected=2', captured.output[0]) self.assertIn('channel=1', captured.output[0]) self.assertIn('channel=2', captured.output[0]) self.assertIn('recovery_left=', captured.output[0]) async def test_new_request_after_idle_does_not_log_a_stall(self): async def handler(request): await asyncio.Event().wait() self._transport(handler) channel = self._channel() await channel.send(b'k' * 40, False) now = time.monotonic() channel.last_progress = now - 20 with patch('proxy.cf_h2.log.isEnabledFor', return_value=True), \ patch('proxy.cf_h2.log.debug') as debug: self.lane.log_wait(now) debug.assert_not_called() async def test_wait_diagnostics_include_old_request_while_other_replies_progress(self): async def handler(request): await asyncio.Event().wait() self._transport(handler) channel = self._channel() await channel.send(b'k' * 40, False) now = time.monotonic() channel.pending_since = dict.fromkeys(channel.pending, now - 4) channel.last_progress = now with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured: self.lane.log_wait(now) self.assertEqual(len(captured.records), 1) self.assertIn('waiting_headers=1', captured.output[0]) self.assertIn('idle_ms=0', captured.output[0]) async def test_slow_response_diagnostics_are_rate_limited(self): self._transport(lambda request: httpx.Response( 200, content=b'r' * 40, extensions={'http_version': b'HTTP/2'})) with patch('proxy.cf_h2.SLOW_RESPONSE_SECONDS', 0), \ self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured: for _ in range(10): await self.lane._post(b'k' * 40, 1) self.assertEqual(len(captured.records), 1) self.assertIn('H2 SLOW', captured.output[0]) self.assertIn('headers_ms=', captured.output[0]) self.assertIn('body_ms=', captured.output[0]) async def test_pending_details_link_replay_to_original_and_bound_history(self): calls = 0 original_waiting = asyncio.Event() async def handler(request): nonlocal calls calls += 1 if calls == 1: original_waiting.set() await asyncio.Event().wait() return httpx.Response(200, content=b'', extensions={'http_version': b'HTTP/2'}) self._transport(handler) channel = self._channel() with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured: await channel.send(b'samekey!' + b'p' * 32, False) await original_waiting.wait() original = next(iter(channel.pending)) with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', 0): await channel.send(b'samekey!' + b'p' * 32, False, replay=True) await asyncio.gather(*(task for task in channel.pending if task is not original)) trace = channel.request_traces[original] replay = channel.recent_traces[-1] self.assertEqual(replay.packet, trace.packet) self.assertNotEqual(replay.request, trace.request) self.assertTrue(replay.replay) now = time.monotonic() trace.started = now - 4 channel.pending_since[original] = now - 4 self.lane.log_wait(now) for index in range(24): await channel.send(b'samekey!' + bytes([index]) * 32, False) await asyncio.gather(*(task for task in channel.pending if task is not original)) self.assertEqual(len(channel.recent_traces), 16) self.assertEqual(len(channel.request_traces), 1) output = '\n'.join(captured.output) self.assertIn('H2 DETAIL', output) self.assertIn('pkt=1', output) self.assertIn('replay=1 phase=complete', output) self.assertNotIn('samekey!', output)