multiplexing media through cfproxy

This commit is contained in:
Flowseal committed 2026-10-04 15:57:33 +03:00
1 parent fe1580cd83
commit 48954ff9ab
26 files changed
+3598 -23

No files matched your search

+90 -1
View File
@@ -1,8 +1,12 @@
import os
import asyncio
import time
import unittest
from types import SimpleNamespace
from proxy._aes import Cipher, algorithms, modes
from proxy.bridge import MsgSplitter
from proxy.bridge import MsgSplitter, bridge_ws_reencrypt
from proxy.network_debug import WsActivity, log_ws_flow
from proxy.utils import (
PROTO_ABRIDGED_INT,
PROTO_INTERMEDIATE_INT,
@@ -112,5 +116,90 @@ class MsgSplitterTest(unittest.TestCase):
self.assertEqual(splitter.flush(), [])
class WsDiagnosticsTest(unittest.IsolatedAsyncioTestCase):
async def test_flow_distinguishes_upstream_send_from_native_backpressure(self):
class Cipher:
def update(self, data):
return data
class Socket:
def __init__(self):
self.queue = asyncio.Queue()
self.sending = asyncio.Event()
self.release = asyncio.Event()
self.sent = []
async def send(self, data):
self.sent.append(data)
self.sending.set()
await self.release.wait()
async def recv(self):
return await self.queue.get()
async def close(self):
pass
class Writer:
def __init__(self):
self.data = bytearray()
self.written = asyncio.Event()
self.release = asyncio.Event()
def write(self, data):
self.data.extend(data)
self.written.set()
async def drain(self):
await self.release.wait()
def close(self):
pass
async def wait_closed(self):
pass
ws, writer = Socket(), Writer()
reader = asyncio.StreamReader()
ctx = SimpleNamespace(clt_dec=Cipher(), clt_enc=Cipher(), tg_dec=Cipher(), tg_enc=Cipher())
with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured:
bridge = asyncio.create_task(bridge_ws_reencrypt(reader, writer, ws, 'owned-test', ctx, dc=203))
try:
reader.feed_data(b'private-native-payload')
await asyncio.wait_for(ws.sending.wait(), 1)
activity = next(item for item in WsActivity.active if item.label == 'owned-test')
self.assertIsNotNone(activity.sending)
self.assertEqual(activity.ws_up, 0)
log_ws_flow(time.monotonic())
# A reply arriving before send drain finishes must not create
# a spurious wait for another response when drain resumes.
await ws.queue.put(b'private-upstream-payload')
await asyncio.wait_for(writer.written.wait(), 1)
self.assertEqual(activity.ws_down, len(writer.data))
self.assertEqual(activity.native_down, 0)
self.assertIsNotNone(activity.writing_native)
log_ws_flow(time.monotonic())
ws.release.set()
writer.release.set()
for _ in range(100):
if activity.native_down and activity.ws_up:
break
await asyncio.sleep(.001)
self.assertEqual(activity.native_down, len(writer.data))
self.assertIsNone(activity.awaiting_rx)
self.assertEqual(ws.sent, [b'private-native-payload'])
self.assertEqual(bytes(writer.data), b'private-upstream-payload')
reader.feed_eof()
await asyncio.wait_for(bridge, 1)
finally:
bridge.cancel()
await asyncio.gather(bridge, return_exceptions=True)
self.assertFalse(WsActivity.active)
output = '\n'.join(captured.output)
self.assertIn('WS FLOW DC203', output)
self.assertIn('WS END DC203', output)
self.assertNotIn('private-', output)
if __name__ == '__main__':
unittest.main()
+912
View File
@@ -0,0 +1,912 @@
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('<I', len(body) | 0x80000000) + body,
PROTO_TAG_INTERMEDIATE)
self.assertEqual(decoded, body)
self.assertTrue(quick)
async def test_padded_plaintext_removes_all_fifteen_padding_lengths(self):
body = b'\x00' * 8 + b'm' * 8 + struct.pack('<I', 20) + b'p' * 20
for padding in range(16):
wire = body + b'z' * padding
decoded, quick = await self._packet(struct.pack('<I', len(wire)) + wire, PROTO_TAG_SECURE)
self.assertEqual(decoded, body, padding)
self.assertFalse(quick)
async def test_padded_ciphertext_removes_full_padding_blocks(self):
body = b'k' * 8 + b'm' * 16 + b'c' * 32
for padding in range(16):
wire = body + b'z' * padding
decoded, _ = await self._packet(struct.pack('<I', len(wire)) + wire, PROTO_TAG_SECURE)
self.assertEqual(decoded, body, padding)
async def test_oversize_header_rejected_before_body_read(self):
reader = asyncio.StreamReader()
reader.feed_data(struct.pack('<I', MAX_PACKET + 4))
with self.assertRaisesRegex(ValueError, 'length'):
await _read_packet(reader, _IdentityCipher(), PROTO_TAG_INTERMEDIATE)
def test_negative_transport_error_keeps_native_framing(self):
error = struct.pack('<i', -404)
self.assertEqual(_encode_reply(error, PROTO_TAG_ABRIDGED), b'\x01' + error)
self.assertEqual(_encode_reply(error, PROTO_TAG_INTERMEDIATE), b'\x04\x00\x00\x00' + error)
class HttpMultiplexTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.lane = _HttpLane('kws4.example.org', 1)
await self.lane.client.aclose()
self.channels = []
async def asyncTearDown(self):
await self.lane.close()
def _channel(self):
channel = _HttpChannel(self.lane, len(self.channels) + 1, 'owned-test')
self.channels.append(channel)
return channel
def _transport(self, handler, *, trace_send=True):
async def handle(request):
if trace_send and 'trace' in request.extensions:
# MockTransport receives the complete body before calling us.
await request.extensions['trace']('http2.send_request_body.complete', {'stream_id': 3})
result = handler(request)
return await result if inspect.isawaitable(result) else result
self.lane.client = httpx.AsyncClient(http2=True, transport=httpx.MockTransport(handle))
async def test_same_auth_prefix_out_of_order_replies_stay_with_source_channel(self):
first_started, release_first = asyncio.Event(), asyncio.Event()
body_a, body_b = b'samekey!' + b'a' * 32, b'samekey!' + b'b' * 32
async def handler(request):
if request.content == body_a:
first_started.set()
await release_first.wait()
return httpx.Response(200, content=request.content, extensions={
'http_version': b'HTTP/2', 'stream_id': 3 if request.content == body_a else 5})
self._transport(handler)
first, second = self._channel(), self._channel()
await first.send(body_a, False)
await first_started.wait()
await second.send(body_b, False)
self.assertEqual(await asyncio.wait_for(second.receive(), 1), body_b)
release_first.set()
self.assertEqual(await asyncio.wait_for(first.receive(), 1), body_a)
self.assertEqual(self.lane.max_inflight, 2)
async def test_close_one_channel_does_not_cancel_another(self):
async def handler(request):
if request.content[:1] == b'a':
await asyncio.Event().wait()
return httpx.Response(200, content=request.content, extensions={'http_version': b'HTTP/2'})
self._transport(handler)
first, second = self._channel(), self._channel()
await first.send(b'a' * 40, False)
await first.close()
await second.send(b'b' * 40, False)
self.assertEqual(await asyncio.wait_for(second.receive(), 1), b'b' * 40)
await second.close()
self.assertEqual(self.lane.inflight, 0)
self.assertEqual(self.lane.queued_bytes, 0)
self.assertFalse(self.lane.closed)
async def test_backpressure_waits_instead_of_dropping_requests(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):
await channel.send(b'x' * 40, False)
extra = asyncio.create_task(channel.send(b'y' * 40, False))
await asyncio.sleep(0.01)
self.assertFalse(extra.done())
self.assertEqual(len(channel.pending), MAX_CHANNEL_REQUESTS)
release.set()
await asyncio.wait_for(extra, 1)
received = [await asyncio.wait_for(channel.receive(), 1) for _ in range(MAX_CHANNEL_REQUESTS + 1)]
self.assertEqual(received.count(b'y' * 40), 1)
async def test_http_error_closes_only_its_channel_without_replaying_packet(self):
calls = []
async def handler(request):
calls.append(request.content)
return httpx.Response(403, extensions={'http_version': b'HTTP/2'})
self._transport(handler)
channel = self._channel()
await channel.send(b'x' * 40, False)
with self.assertRaisesRegex(ConnectionError, '403'):
await asyncio.wait_for(channel.receive(), 1)
self.assertEqual(calls, [b'x' * 40])
self.assertTrue(channel.closed)
self.assertFalse(self.lane.closed)
async def test_long_polls_cannot_block_fresh_request_and_retired_packet_recovers(self):
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(MAX_CHANNEL_REQUESTS)]
fresh = b'samekey!' + b'n' * 32
calls = []
async def handler(request):
calls.append(request.content)
await request.extensions['trace']('http2.send_request_body.complete', {'stream_id': len(calls) * 2 + 1})
if request.content == fresh:
return httpx.Response(200, content=b'n' * 40, extensions={'http_version': b'HTTP/2'})
if request.content == bodies[0] and calls.count(bodies[0]) == 2:
return httpx.Response(200, content=b'r' * 40, extensions={'http_version': b'HTTP/2'})
await asyncio.Event().wait()
self._transport(handler)
channel = self._channel()
for body in bodies:
await channel.send(body, False)
await asyncio.sleep(0)
original = next(iter(channel.pending))
with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
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(channel.send(fresh, False), 1)
received = [await asyncio.wait_for(channel.receive(), 1) for _ in range(2)]
self.assertCountEqual(received, [b'n' * 40, b'r' * 40])
self.assertTrue(original.cancelled())
self.assertTrue(channel.replay_history[0].retired)
self.assertEqual(calls.count(bodies[0]), 2)
self.assertEqual(channel.capacity_rotations, 1)
self.assertEqual(self.lane.max_inflight, MAX_CHANNEL_REQUESTS)
self.assertEqual(self.lane.errors, 0)
self.assertFalse(channel.closed)
finally:
recovery.cancel()
await asyncio.gather(recovery, return_exceptions=True)
async def test_shared_lane_long_polls_cannot_block_new_channel(self):
fresh = b'samekey!' + b'n' * 32
async def handler(request):
await request.extensions['trace']('http2.send_request_body.complete', {'stream_id': 3})
if request.content == fresh:
return httpx.Response(200, content=b'n' * 40, extensions={'http_version': b'HTTP/2'})
await asyncio.Event().wait()
self._transport(handler)
first, second, waiting = self._channel(), self._channel(), self._channel()
with patch('proxy.cf_h2.MAX_LANE_REQUESTS', 2), \
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
await first.send(b'samekey!' + b'a' * 32, False)
await second.send(b'samekey!' + b'b' * 32, False)
await asyncio.sleep(0)
originals = [next(iter(channel.pending)) for channel in (first, second)]
recovery = asyncio.create_task(waiting._recover())
try:
await asyncio.wait_for(waiting.send(fresh, False), 1)
self.assertEqual(await asyncio.wait_for(waiting.receive(), 1), b'n' * 40)
self.assertEqual(sum(task.cancelled() for task in originals), 1)
self.assertEqual(sum(channel.capacity_rotations for channel in (first, second)), 1)
self.assertEqual(self.lane.capacity_rotations, 1)
self.assertEqual(self.lane.max_inflight, 2)
finally:
recovery.cancel()
await asyncio.gather(recovery, return_exceptions=True)
async def test_upload_room_accounts_for_waiting_packet_size(self):
for limit in ('MAX_CHANNEL_BYTES', 'MAX_LANE_BYTES'):
with self.subTest(limit=limit):
started = asyncio.Event()
async def handler(request):
await request.extensions['trace']('http2.send_request_body.complete', {'stream_id': 3})
if request.content == b'k' * 40:
started.set()
await asyncio.Event().wait()
return httpx.Response(200, content=b'n' * 40, extensions={'http_version': b'HTTP/2'})
self._transport(handler)
channel = self._channel()
with patch('proxy.cf_h2.' + limit, 60), \
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
await channel.send(b'k' * 40, False)
await started.wait()
recovery = asyncio.create_task(channel._recover())
try:
# 20 bytes are free, but the pending upload needs 40.
await asyncio.wait_for(channel.send(b'n' * 40, False), .5)
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'n' * 40)
self.assertEqual(channel.capacity_rotations, 1)
finally:
recovery.cancel()
await asyncio.gather(recovery, return_exceptions=True)
await channel.close()
async def test_close_releases_request_cancelled_before_coroutine_start_once(self):
self._transport(lambda request: self.fail('cancelled request reached HTTP'))
channel = self._channel()
create_task = asyncio.create_task
def cancelled_task(coroutine):
task = create_task(coroutine)
task.cancel()
return task
with patch('proxy.cf_h2.asyncio.create_task', cancelled_task):
await channel.send(b'k' * 40, False)
packet = channel.replay_history[0]
await channel.close()
await channel.close()
self.assertEqual((self.lane.inflight, self.lane.queued_bytes, channel.pending_bytes), (0, 0, 0))
self.assertFalse(channel.pending)
self.assertFalse(channel.pending_since)
self.assertFalse(channel.sent_since)
self.assertFalse(packet.originals)
async def test_capacity_rotation_keeps_upload_and_started_response_intact(self):
uploading, receiving, waiting = [b'samekey!' + bytes([index]) * 32 for index in range(3)]
fresh = b'samekey!' + b'n' * 32
body_started = asyncio.Event()
class SlowBody(httpx.AsyncByteStream):
async def __aiter__(self):
yield b'r' * 20
body_started.set()
await asyncio.Event().wait()
async def handler(request):
if request.content == uploading:
await asyncio.Event().wait() # No send_request_body.complete yet.
await request.extensions['trace']('http2.send_request_body.complete', {'stream_id': 3})
if request.content == receiving:
return httpx.Response(200, stream=SlowBody(), extensions={'http_version': b'HTTP/2'})
if request.content == waiting:
await asyncio.Event().wait()
return httpx.Response(200, content=b'n' * 40, extensions={'http_version': b'HTTP/2'})
self._transport(handler, trace_send=False)
channel = self._channel()
with patch('proxy.cf_h2.MAX_CHANNEL_REQUESTS', 3), \
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
for body in (uploading, receiving, waiting):
await channel.send(body, False)
await asyncio.wait_for(body_started.wait(), 1)
tasks = list(channel.pending)
recovery = asyncio.create_task(channel._recover())
try:
await asyncio.wait_for(channel.send(fresh, False), 1)
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'n' * 40)
self.assertFalse(tasks[0].done())
self.assertFalse(tasks[1].done())
self.assertTrue(tasks[2].cancelled())
finally:
recovery.cancel()
await asyncio.gather(recovery, return_exceptions=True)
async def test_capacity_rotation_never_retires_unretained_or_plaintext_request(self):
release = asyncio.Event()
async def handler(request):
await request.extensions['trace']('http2.send_request_body.complete', {'stream_id': 3})
await release.wait()
return httpx.Response(200, content=b'r' * 40, extensions={'http_version': b'HTTP/2'})
self._transport(handler)
channel = self._channel()
with patch('proxy.cf_h2.MAX_CHANNEL_REQUESTS', 2), \
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .005), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .002):
await channel.send(b'\0' * 8 + b'p' * 32, False)
await channel.send(b'samekey!' + b'u' * (64 * 1024), False)
extra = asyncio.create_task(channel.send(b'samekey!' + b'n' * 32, False))
recovery = asyncio.create_task(channel._recover())
try:
await asyncio.sleep(.04)
self.assertFalse(extra.done())
self.assertEqual(channel.capacity_rotations, 0)
self.assertTrue(all(not task.done() for task in channel.pending))
release.set()
await asyncio.wait_for(extra, 1)
finally:
release.set()
extra.cancel()
recovery.cancel()
await asyncio.gather(extra, recovery, return_exceptions=True)
async def test_http1_rejected_by_preflight(self):
self._transport(lambda request: httpx.Response(200, extensions={'http_version': b'HTTP/1.1'}))
with self.assertRaisesRegex(ValueError, 'HTTP/1.1'):
await self.lane._preflight()
async def test_telegram_head_501_still_allows_http2_preflight(self):
self._transport(lambda request: httpx.Response(501, extensions={'http_version': b'HTTP/2'}))
await self.lane._preflight()
async def _packet_bridge(self, *, content_length=True, truncate=False, concurrent_small=False):
first_chunk_read, release_tail = asyncio.Event(), asyncio.Event()
body = b'x' * (64 * 1024 + 40)
first_chunk = body[:16384]
class ResponseStream(httpx.AsyncByteStream):
async def __aiter__(self):
yield first_chunk
first_chunk_read.set()
await release_tail.wait()
if not truncate:
yield body[len(first_chunk):]
async def handler(request):
if request.content == b's' * 40:
return httpx.Response(200, content=b'z' * 40,
extensions={'http_version': b'HTTP/2'})
headers = {'content-length': str(len(body))} if content_length else {}
return httpx.Response(200, headers=headers, stream=ResponseStream(),
extensions={'http_version': b'HTTP/2'})
self._transport(handler)
class Writer:
def __init__(self):
self.body = bytearray()
self.changed = asyncio.Event()
def write(self, data):
self.body.extend(data)
self.changed.set()
async def drain(self):
await asyncio.sleep(0)
reader, writer = asyncio.StreamReader(), Writer()
channel = self._channel()
ctx = SimpleNamespace(clt_enc=_IdentityCipher(), clt_dec=_IdentityCipher())
task = asyncio.create_task(bridge_h2(reader, writer, channel, ctx, PROTO_TAG_INTERMEDIATE))
reader.feed_data(struct.pack('<I', 40) + b'l' * 40)
try:
await asyncio.wait_for(first_chunk_read.wait(), 1)
self.assertEqual(writer.body, b'')
if concurrent_small:
reader.feed_data(struct.pack('<I', 40) + b's' * 40)
await asyncio.wait_for(writer.changed.wait(), 1)
self.assertEqual(writer.body, struct.pack('<I', 40) + b'z' * 40)
release_tail.set()
if truncate:
await asyncio.wait_for(task, 1)
self.assertTrue(channel.closed)
self.assertEqual(writer.body, b'')
else:
expected = struct.pack('<I', len(body)) + body
if concurrent_small:
expected = struct.pack('<I', 40) + b'z' * 40 + expected
for _ in range(100):
if len(writer.body) >= 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('<I', 40) + b'x' * 40)
ctx = SimpleNamespace(clt_enc=_IdentityCipher(), clt_dec=_IdentityCipher())
await asyncio.wait_for(bridge_h2(reader, DisconnectedWriter(), channel, ctx,
PROTO_TAG_INTERMEDIATE), 1)
self.assertTrue(channel.closed)
self.assertEqual(self.lane.failed_until, 0)
self.assertEqual(self.lane.errors, 0)
healthy = self._channel()
await healthy.send(b'h' * 40, False)
self.assertEqual(await asyncio.wait_for(healthy.receive(), 1), b'r' * 65536)
async def test_slow_receiver_queue_overflow_does_not_break_other_channels(self):
async def handler(request):
return httpx.Response(200, content=request.content, extensions={'http_version': b'HTTP/2'})
self._transport(handler)
slow, healthy = self._channel(), self._channel()
with patch('proxy.cf_h2.MAX_CHANNEL_BYTES', 80):
for _ in range(3):
await slow.send(b's' * 40, False)
await asyncio.gather(*list(slow.pending))
with self.assertRaises(BufferError):
await asyncio.wait_for(slow.receive(), 1)
await healthy.send(b'h' * 40, False)
self.assertEqual(await asyncio.wait_for(healthy.receive(), 1), b'h' * 40)
self.assertFalse(self.lane.closed)
async def test_simultaneous_pool_opens_preflight_once_and_share_client(self):
pool = CfH2Pool()
balancer = _Balancer()
balancer.update_domains_list(['example.org'])
started = []
async def preflight(lane):
started.append(lane)
await asyncio.sleep(0)
try:
with patch('proxy.cf_h2.balancer', balancer), patch.object(_HttpLane, '_preflight', preflight):
channels = await asyncio.gather(*(pool.open(4, 'owned-test') for _ in range(6)))
self.assertEqual(len(started), 1)
self.assertEqual(len({id(channel.lane.client) for channel in channels}), 1)
self.assertEqual(len({channel.channel_id for channel in channels}), 6)
finally:
await pool.close()
async def test_recovery_reuses_exact_ciphertext_when_original_http_is_still_pending(self):
calls = []
original_waiting = asyncio.Event()
body = b'opaque-key-and-message-id' + b'x' * 16
async def handler(request):
calls.append(request.content)
if len(calls) == 1:
original_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()
await channel.send(body, False)
await original_waiting.wait()
with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .01), patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
recovery = asyncio.create_task(channel._recover())
try:
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
self.assertEqual(calls, [body, body])
self.assertEqual(channel.replay_requests, 1)
self.assertEqual(self.lane.max_inflight, 2)
finally:
recovery.cancel()
await asyncio.gather(recovery, return_exceptions=True)
await channel.close()
self.assertFalse(channel.pending)
self.assertEqual(self.lane.inflight, 0)
async def test_recovery_after_http_finished_without_native_reply(self):
calls = []
async def handler(request):
calls.append(request.content)
return httpx.Response(200, content=b'' if len(calls) == 1 else b'r' * 40,
extensions={'http_version': b'HTTP/2'})
self._transport(handler)
channel = self._channel()
body = b'k' * 40
await channel.send(body, False)
await asyncio.gather(*list(channel.pending))
self.assertTrue(channel.queue.empty())
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
recovery = asyncio.create_task(channel._recover())
try:
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
await asyncio.sleep(.03)
self.assertEqual(calls, [body, body])
finally:
recovery.cancel()
await asyncio.gather(recovery, return_exceptions=True)
async def test_idle_receiver_preserves_capacity_for_new_native_request(self):
calls = []
probes_waiting = asyncio.Event()
async def handler(request):
calls.append(request.content)
if calls.count(request.content) > 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_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)
+75
View File
@@ -0,0 +1,75 @@
"""H2 belongs to CF fallback, never to the direct route."""
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
from proxy import bridge, tg_ws_proxy
from proxy.config import proxy_config
from proxy.utils import PROTO_TAG_INTERMEDIATE
class H2RoutingTest(unittest.IsolatedAsyncioTestCase):
async def test_direct_pool_hit_never_opens_h2(self):
reader = Mock()
writer = Mock()
writer.get_extra_info.return_value = None
writer.wait_closed = AsyncMock()
ws = Mock(send=AsyncMock())
pool = SimpleNamespace(open=AsyncMock())
with patch.object(proxy_config, 'dc_redirects', {2: '149.154.167.51'}), \
patch.object(proxy_config, 'force_test_dc', False), \
patch.object(tg_ws_proxy, 'cf_h2_pool', pool), \
patch.object(tg_ws_proxy, 'ws_blacklist', set()), \
patch.object(tg_ws_proxy, 'ip_fail_until', {}), \
patch.object(tg_ws_proxy, 'set_sock_opts'), \
patch.object(tg_ws_proxy, '_read_client_init', AsyncMock(
return_value=(b'', reader, writer, 'test'))), \
patch.object(tg_ws_proxy, '_try_handshake', return_value=(
2, True, PROTO_TAG_INTERMEDIATE, b'')), \
patch.object(tg_ws_proxy, '_build_crypto_ctx'), \
patch.object(tg_ws_proxy.ws_pool, 'get', AsyncMock(return_value=ws)), \
patch.object(tg_ws_proxy.ws_pool, 'report_success'), \
patch.object(tg_ws_proxy, 'bridge_ws_reencrypt', AsyncMock()) as direct, \
patch.object(tg_ws_proxy, 'do_fallback', AsyncMock()) as fallback:
await tg_ws_proxy._handle_client(reader, writer, b'')
direct.assert_awaited_once()
fallback.assert_not_awaited()
pool.open.assert_not_awaited()
async def test_cf_fallback_order_and_route_guards(self):
cases = [
# media, test DC, CF enabled, Worker succeeds, H2 opens, expected
(True, False, True, False, True, ['h2', 'bridge']),
(True, False, True, False, False, ['h2', 'ws']),
(False, False, True, False, True, ['ws']),
(True, True, True, False, True, ['tcp']),
(True, False, False, False, True, ['tcp']),
(True, False, True, True, True, ['worker']),
]
for media, test_dc, cf, worker, opens, expected in cases:
with self.subTest(expected=expected, media=media, test_dc=test_dc, cf=cf):
calls = []
async def open_h2(*args):
calls.append('h2')
return object() if opens else None
def route(name):
async def run(*args, **kwargs):
calls.append(name)
return True
return run
pool = SimpleNamespace(open=AsyncMock(side_effect=open_h2))
with patch.object(proxy_config, 'fallback_cfproxy', cf), \
patch.object(proxy_config, 'cfproxy_worker_domains',
['worker.example'] if worker else []), \
patch.object(bridge, 'bridge_h2', side_effect=route('bridge')), \
patch.object(bridge, '_cfproxy_fallback', side_effect=route('ws')), \
patch.object(bridge, '_cfproxy_worker_fallback', side_effect=route('worker')), \
patch.object(bridge, '_tcp_fallback', side_effect=route('tcp')):
result = await bridge.do_fallback(
Mock(), Mock(), b'', 'test', 2, test_dc, media, '', Mock(),
h2_pool=pool, proto_tag=PROTO_TAG_INTERMEDIATE)
self.assertTrue(result)
self.assertEqual(calls, expected)
+127
View File
@@ -0,0 +1,127 @@
"""Default/CLI/GUI configuration must select the same effective H2 route."""
import asyncio
import contextlib
import io
import json
import logging
import tempfile
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
from proxy.config import ProxyConfig
from proxy import tg_ws_proxy
from utils import tray_common
from utils.default_config import default_tray_config
from ui.settings import prepare_settings
class H2SettingsTest(unittest.TestCase):
def test_cli_default_opt_out_and_existing_launch_script(self):
cases = [([], True), (['--no-h2'], False),
(['--cfproxy-h2-media'], True), (['--no-cfproxy'], False),
(['--no-secure'], False), (['--force-test-dc'], False)]
for flags, enabled in cases:
with self.subTest(flags=flags):
config = ProxyConfig()
observed = []
async def run():
observed.append(config.h2_enabled)
root = logging.getLogger()
previous_handlers, previous_level = root.handlers[:], root.level
try:
with patch.object(tg_ws_proxy, 'proxy_config', config), \
patch.object(tg_ws_proxy, '_run', run), \
patch('sys.argv', ['proxy', '--secret', 'ab' * 16] + flags):
tg_ws_proxy.main()
self.assertEqual(observed, [enabled])
finally:
for handler in root.handlers[:]:
if handler not in previous_handlers:
root.removeHandler(handler)
handler.close()
root.setLevel(previous_level)
def test_help_advertises_opt_out_only(self):
output = io.StringIO()
with patch('sys.argv', ['proxy', '--help']), contextlib.redirect_stdout(output):
with self.assertRaises(SystemExit) as raised:
tg_ws_proxy.main()
self.assertEqual(raised.exception.code, 0)
self.assertIn('--no-h2', output.getvalue())
self.assertNotIn('--cfproxy-h2-media', output.getvalue())
def test_saved_setting_migration_and_runtime_restart(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'config.json'
path.write_text(json.dumps({'port': 1444}), encoding='utf-8')
with patch.object(tray_common, 'CONFIG_FILE', path), \
patch.object(tray_common, 'ensure_dirs'), \
patch.object(tray_common, '_apply_ui_language'):
config = tray_common.load_config()
self.assertTrue(config['h2'])
change = prepare_settings(config, {'h2': False}, default_tray_config())
self.assertTrue(change.requires_restart)
tray_common.save_config(change.config)
saved = tray_common.load_config()
self.assertFalse(saved['h2'])
runtime = ProxyConfig()
with patch.object(tray_common, 'proxy_config', runtime):
self.assertTrue(tray_common.apply_proxy_config(saved))
self.assertFalse(runtime.h2_enabled)
saved['h2'] = True
self.assertTrue(tray_common.apply_proxy_config(saved))
self.assertTrue(runtime.h2_enabled)
for key, value in [('cfproxy', False), ('no_secure', True),
('force_test_dc', True)]:
self.assertTrue(tray_common.apply_proxy_config({**saved, key: value}))
self.assertFalse(runtime.h2_enabled)
self.assertTrue(runtime.cfproxy_h2_media)
class H2ServerLifecycleTest(unittest.IsolatedAsyncioTestCase):
async def test_stop_and_cancellation_close_default_h2_and_listener_tasks(self):
for cancel in (False, True):
with self.subTest(cancel=cancel):
config = ProxyConfig(port=0, dc_redirects={}, pool_size=0)
stop = asyncio.Event()
before = asyncio.all_tasks()
with patch.object(tg_ws_proxy, 'proxy_config', config), \
patch.object(tg_ws_proxy, 'start_cfproxy_domain_refresh'), \
patch.object(tg_ws_proxy.ws_pool, 'warmup', AsyncMock()), \
patch.object(tg_ws_proxy.cf_worker_pool, 'warmup', AsyncMock()):
server = asyncio.create_task(tg_ws_proxy._run(stop))
writer = None
try:
async def listening():
while tg_ws_proxy._server_instance is None:
await asyncio.sleep(.005)
await asyncio.wait_for(listening(), 1)
pool = tg_ws_proxy.cf_h2_pool
self.assertIsNotNone(pool)
port = tg_ws_proxy._server_instance.sockets[0].getsockname()[1]
_, writer = await asyncio.open_connection('127.0.0.1', port)
if cancel:
server.cancel()
with self.assertRaises(asyncio.CancelledError):
await asyncio.wait_for(server, 2)
else:
stop.set()
await asyncio.wait_for(server, 2)
self.assertTrue(pool.closed)
self.assertIsNone(tg_ws_proxy.cf_h2_pool)
self.assertIsNone(tg_ws_proxy._server_instance)
self.assertFalse(tg_ws_proxy._client_tasks)
self.assertFalse(asyncio.all_tasks() - before)
finally:
server.cancel()
await asyncio.gather(server, return_exceptions=True)
if writer is not None:
writer.close()
await writer.wait_closed()
if __name__ == '__main__':
unittest.main()
+858
View File
@@ -0,0 +1,858 @@
"""Wire-level regressions: real TLS sockets, ALPN and HTTP/2 flow control."""
import asyncio
import datetime
import ipaddress
import ssl
import struct
import tempfile
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import httpx
from h2.config import H2Configuration
from h2.connection import H2Connection
from h2.events import DataReceived, RequestReceived, StreamEnded, StreamReset
from h2.settings import SettingCodes
from proxy.h2_transport import H2Transport, STREAM_RECEIVE_WINDOW, _Connection
from proxy.cf_h2 import _HttpChannel, _HttpLane, bridge_h2
from proxy.utils import PROTO_TAG_INTERMEDIATE
class H2WireTest(unittest.IsolatedAsyncioTestCase):
@classmethod
def setUpClass(cls):
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
cls.folder = tempfile.TemporaryDirectory(prefix='tg-h2-tests-')
cls.addClassCleanup(cls.folder.cleanup)
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'localhost')])
now = datetime.datetime.now(datetime.timezone.utc)
cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name)
.public_key(key.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=1))
.not_valid_after(now + datetime.timedelta(hours=1))
.add_extension(x509.SubjectAlternativeName([
x509.IPAddress(ipaddress.ip_address('127.0.0.1'))]), critical=False)
.sign(key, hashes.SHA256()))
cls.cert_path = Path(cls.folder.name) / 'cert.pem'
key_path = Path(cls.folder.name) / 'key.pem'
cls.cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption()))
cls.server_ssl = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
cls.server_ssl.load_cert_chain(str(cls.cert_path), str(key_path))
cls.server_ssl.set_alpn_protocols(['h2'])
async def asyncSetUp(self):
self.requests = asyncio.Queue()
self.resets = asyncio.Queue()
self.connections = []
self.handlers = []
self.tasks = []
self.server_max_streams = 64
self.server = await asyncio.start_server(self.serve, '127.0.0.1', 0, ssl=self.server_ssl)
port = self.server.sockets[0].getsockname()[1]
self.lane = _HttpLane('127.0.0.1:%d' % port, 1)
await self.lane.client.aclose()
self.transport = H2Transport(ssl.create_default_context(cafile=str(self.cert_path)))
self.lane.client = httpx.AsyncClient(transport=self.transport, trust_env=False,
timeout=httpx.Timeout(2, read=1))
async def asyncTearDown(self):
for task in self.tasks:
task.cancel()
await asyncio.gather(*self.tasks, return_exceptions=True)
await self.lane.close()
self.server.close()
await self.server.wait_closed()
for connection in self.connections:
connection.writer.close()
await asyncio.wait_for(asyncio.gather(*self.handlers), 3)
async def serve(self, reader, writer):
self.handlers.append(asyncio.current_task())
conn = SimpleNamespace(h2=H2Connection(config=H2Configuration(client_side=False)),
writer=writer, bodies={}, pending={})
self.connections.append(conn)
conn.h2.initiate_connection()
conn.h2.update_settings({SettingCodes.MAX_CONCURRENT_STREAMS: self.server_max_streams})
writer.write(conn.h2.data_to_send())
try:
while True:
data = await reader.read(65536)
if not data:
break
for event in conn.h2.receive_data(data):
if isinstance(event, RequestReceived):
conn.bodies[event.stream_id] = bytearray()
elif isinstance(event, DataReceived):
conn.bodies[event.stream_id].extend(event.data)
conn.h2.acknowledge_received_data(event.flow_controlled_length, event.stream_id)
elif isinstance(event, StreamEnded):
await self.requests.put((conn, event.stream_id, bytes(conn.bodies[event.stream_id])))
elif isinstance(event, StreamReset):
conn.pending.pop(event.stream_id, None)
await self.resets.put((event.stream_id, event.error_code))
self.flush(conn)
await writer.drain()
except (ConnectionError, ssl.SSLError):
pass
finally:
writer.close()
try:
await writer.wait_closed()
except (ConnectionError, ssl.SSLError):
pass
def flush(self, conn):
for sid, (body, end) in list(conn.pending.items()):
while body:
count = min(len(body), conn.h2.max_outbound_frame_size,
conn.h2.local_flow_control_window(sid))
if count <= 0:
break
chunk, body = body[:count], body[count:]
conn.h2.send_data(sid, chunk, end_stream=end and not body)
if body:
conn.pending[sid] = (body, end)
else:
del conn.pending[sid]
conn.writer.write(conn.h2.data_to_send())
def respond(self, request, body=b'r' * 40, *, end=True, length=None, status=200):
conn, sid, _ = request
conn.h2.send_headers(sid, [(b':status', str(status).encode()),
(b'content-type', b'application/octet-stream'),
(b'content-length', str(len(body) if length is None else length).encode())],
end_stream=end and not body)
if body:
conn.pending[sid] = (body, end)
self.flush(conn)
async def post(self, body=b'x' * 40):
task = asyncio.create_task(self.lane._post(body, len(self.tasks) + 1))
self.tasks.append(task)
request = await asyncio.wait_for(self.requests.get(), 2)
self.assertEqual(request[2], body)
return task, request
async def test_fast_response_does_not_wait_for_unrelated_long_polls(self):
slow1, request1 = await self.post()
slow2, request2 = await self.post()
fast, request3 = await self.post()
self.respond(request3)
self.assertEqual(await asyncio.wait_for(fast, .5), b'r' * 40)
self.assertFalse(slow1.done())
self.assertFalse(slow2.done())
self.respond(request1)
self.respond(request2)
await asyncio.gather(slow1, slow2)
self.assertEqual(len(self.connections), 1)
async def test_debug_trace_follows_real_stream_without_logging_payload(self):
channel = _HttpChannel(self.lane, 1, 'trace-test')
body = b'private-payload-' + b'x' * 24
with self.assertLogs('tg-mtproto-proxy', level='DEBUG') as captured:
await channel.send(body, False)
request = await asyncio.wait_for(self.requests.get(), 1)
trace = next(iter(channel.request_traces.values()))
async def phase(expected):
while trace.phase != expected:
await asyncio.sleep(.001)
await asyncio.wait_for(phase('headers'), .5)
self.assertEqual(trace.stream, request[1])
self.assertEqual(trace.packet, 1)
self.assertEqual(trace.request, 1)
self.assertIsNotNone(trace.sent_at)
self.assertIsNone(trace.headers_at)
self.respond(request, b'r' * 20, end=False, length=40)
await asyncio.wait_for(phase('body'), .5)
self.assertIsNotNone(trace.headers_at)
conn, sid, _ = request
conn.h2.send_data(sid, b'r' * 20, end_stream=True)
self.flush(conn)
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'r' * 40)
await asyncio.gather(*list(channel.pending))
self.assertEqual(trace.received, 40)
self.assertEqual(trace.phase, 'complete')
self.assertFalse(channel.request_traces)
self.assertIs(channel.recent_traces[-1], trace)
self.lane.log_flow(trace.finished_at)
await channel.close()
output = '\n'.join(captured.output)
self.assertIn('http_down=40', output)
self.assertIn('small=1 large=0', output)
self.assertNotIn('private-payload', output)
self.assertFalse(channel.recent_traces)
async def test_stale_recovery_slot_replaced_without_resetting_original_request(self):
channel = _HttpChannel(self.lane, 1, 'recovery-wire-test')
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(3)]
originals = []
for body in bodies[:2]:
await channel.send(body, False)
originals.append(await asyncio.wait_for(self.requests.get(), 1))
with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
patch('proxy.cf_h2.REPLAY_SLOT_SECONDS', .1):
recovery = asyncio.create_task(channel._recover())
self.tasks.append(recovery)
old_poll = await asyncio.wait_for(self.requests.get(), 1)
other_poll = await asyncio.wait_for(self.requests.get(), 1)
self.assertEqual([old_poll[2], other_poll[2]], bodies[:2])
# The two duplicate requests remain silent. A new native request
# also gets no reply until its own duplicate solicits one.
await channel.send(bodies[2], False)
original = await asyncio.wait_for(self.requests.get(), 1)
probe = await asyncio.wait_for(self.requests.get(), 1)
self.assertEqual(probe[2], bodies[2])
reset_sid, reset_code = await asyncio.wait_for(self.resets.get(), 1)
self.assertEqual((reset_sid, reset_code), (old_poll[1], 8))
self.assertIn(original[1], self.transport.connection.streams)
for request in originals:
self.assertIn(request[1], self.transport.connection.streams)
self.assertIn(other_poll[1], self.transport.connection.streams)
self.assertEqual(len(channel.replay_pending), 2)
self.respond(probe, b'r' * 40)
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
# The original stream remains usable, even after recovery.
self.respond(original, b'o' * 40)
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'o' * 40)
self.assertEqual(channel.replay_rotations, 1)
self.assertEqual(self.lane.failed_until, 0)
self.assertEqual(len(self.connections), 1)
async def test_normal_long_poll_survives_six_seconds_and_delivers_late_reply(self):
channel = _HttpChannel(self.lane, 1, 'normal-long-poll-test')
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(3)]
for body in bodies[:2]:
await channel.send(body, False)
self.respond(await asyncio.wait_for(self.requests.get(), 1))
await asyncio.wait_for(channel.receive(), 1)
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .01), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
recovery = asyncio.create_task(channel._recover())
self.tasks.append(recovery)
poll = await asyncio.wait_for(self.requests.get(), 1)
# Advance request ages, keeping the real TLS/H2 streams open.
# Six seconds is still inside Telegram's default 25s HTTP wait.
for task in channel.replay_pending:
channel.pending_since[task] = time.monotonic() - 6
channel.sent_since[task] = time.monotonic() - 6
await asyncio.sleep(.03)
self.assertTrue(self.resets.empty(), 'normal HTTP wait was reset before its delayed result')
await channel.send(bodies[2], False)
fresh = await asyncio.wait_for(self.requests.get(), 1)
self.assertEqual(fresh[2], bodies[2])
self.respond(fresh, b'n' * 40)
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'n' * 40)
self.respond(poll, b'd' * (128 * 1024))
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'd' * (128 * 1024))
self.assertEqual(channel.replay_rotations, 0)
self.assertEqual(channel.replay_requests, 1)
async def test_replay_rotation_does_not_interrupt_upload(self):
channel = _HttpChannel(self.lane, 1, 'replay-upload-test')
body = b'samekey!' + b'p' * 32
await channel.send(body, False)
self.respond(await asyncio.wait_for(self.requests.get(), 1))
await asyncio.wait_for(channel.receive(), 1)
upload_started, resume = asyncio.Event(), asyncio.Event()
send_body = _Connection.send_body
async def paused_send(connection, request, stream):
upload_started.set()
await resume.wait()
await send_body(connection, request, stream)
with patch.object(_Connection, 'send_body', paused_send), \
patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .01), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
recovery = asyncio.create_task(channel._recover())
self.tasks.append(recovery)
await asyncio.wait_for(upload_started.wait(), 1)
probe = next(iter(channel.replay_pending))
channel.pending_since[probe] = time.monotonic() - 40
await asyncio.sleep(.03)
self.assertFalse(probe.done())
self.assertTrue(self.resets.empty())
self.assertEqual(channel.replay_rotations, 0)
resume.set()
request = await asyncio.wait_for(self.requests.get(), 1)
self.respond(request)
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
async def test_recovery_deadline_starts_after_original_upload_finishes(self):
channel = _HttpChannel(self.lane, 1, 'unfinished-upload-test')
body = b'samekey!' + b'q' * 32
started, resume = asyncio.Event(), asyncio.Event()
send_body = _Connection.send_body
async def paused_send(connection, request, stream):
started.set()
await resume.wait()
await send_body(connection, request, stream)
with patch.object(_Connection, 'send_body', paused_send), \
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .02), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
await channel.send(body, False)
await asyncio.wait_for(started.wait(), .5)
self.tasks.append(asyncio.create_task(channel._recover()))
await asyncio.sleep(.06)
self.assertEqual(channel.requests, 1, 'unsent original was duplicated')
self.assertTrue(self.requests.empty())
resume.set()
original = await asyncio.wait_for(self.requests.get(), .5)
probe = await asyncio.wait_for(self.requests.get(), .5)
self.assertEqual(original[2], body)
self.assertEqual(probe[2], body)
self.respond(probe)
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'r' * 40)
self.assertTrue(self.resets.empty())
async def test_body_tail_does_not_wait_for_unrelated_long_polls(self):
slow1, _ = await self.post()
slow2, _ = await self.post()
fast, request = await self.post()
self.respond(request, b'a' * 20, end=False, length=40)
await asyncio.sleep(.03)
self.assertFalse(fast.done())
conn, sid, _ = request
conn.h2.send_data(sid, b'b' * 20, end_stream=True)
self.flush(conn)
self.assertEqual(await asyncio.wait_for(fast, .5), b'a' * 20 + b'b' * 20)
self.assertFalse(slow1.done())
self.assertFalse(slow2.done())
async def test_receiving_body_is_marked_before_request_coroutine_resumes(self):
channel = _HttpChannel(self.lane, 1, 'early-headers-test')
resume = asyncio.Event()
send_body = _Connection.send_body
async def paused_send(connection, request, stream):
await send_body(connection, request, stream)
await resume.wait()
with patch.object(_Connection, 'send_body', paused_send):
await channel.send(b'k' * 40, False)
request = await asyncio.wait_for(self.requests.get(), 1)
self.respond(request, b'a' * 20, end=False, length=40)
async def headers_received():
while channel.receiving_http == 0:
await asyncio.sleep(.005)
try:
await asyncio.wait_for(headers_received(), .5)
self.assertEqual(channel.receiving_http, 1)
self.assertTrue(channel.queue.empty())
finally:
resume.set()
conn, sid, _ = request
conn.h2.send_data(sid, b'b' * 20, end_stream=True)
self.flush(conn)
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'a' * 20 + b'b' * 20)
self.assertEqual(channel.receiving_http, 0)
async def test_native_channel_delivers_ready_reply_while_large_body_is_pending(self):
class IdentityCipher:
def update(self, data):
return data
class Writer:
def __init__(self):
self.data = bytearray()
self.changed = asyncio.Event()
def write(self, data):
self.data.extend(data)
self.changed.set()
async def drain(self):
pass
reader, writer = asyncio.StreamReader(), Writer()
channel = _HttpChannel(self.lane, 1, 'wire-test')
ctx = SimpleNamespace(clt_enc=IdentityCipher(), clt_dec=IdentityCipher())
bridge = asyncio.create_task(bridge_h2(reader, writer, channel, ctx, PROTO_TAG_INTERMEDIATE))
self.tasks.append(bridge)
reader.feed_data(struct.pack('<I', 40) + b'x' * 40)
slow = await asyncio.wait_for(self.requests.get(), 2)
self.respond(slow, b'a' * 65536, end=False, length=65576)
reader.feed_data(struct.pack('<I', 40) + b'y' * 40)
fast = await asyncio.wait_for(self.requests.get(), 2)
self.respond(fast, b'f' * 40)
await asyncio.wait_for(writer.changed.wait(), .5)
self.assertEqual(writer.data, struct.pack('<I', 40) + b'f' * 40)
writer.changed.clear()
conn, sid, _ = slow
# Finish after the first 64 KiB have passed through flow control.
for _ in range(100):
if sid not in conn.pending:
break
await asyncio.sleep(.001)
self.assertNotIn(sid, conn.pending)
conn.pending[sid] = (b'b' * 40, True)
self.flush(conn)
await asyncio.wait_for(writer.changed.wait(), .5)
self.assertEqual(writer.data, struct.pack('<I', 40) + b'f' * 40
+ struct.pack('<I', 65576) + b'a' * 65536 + b'b' * 40)
async def test_parallel_first_windows_do_not_wait_for_connection_credit(self):
first, request1 = await self.post()
second, request2 = await self.post()
self.respond(request1, b'a' * 65532)
self.respond(request2, b'b' * 65532)
# No event-loop yield between responses: the server has not received
# any WINDOW_UPDATE credit for either body yet.
self.assertFalse(request1[0].pending)
self.assertEqual(await first, b'a' * 65532)
self.assertEqual(await second, b'b' * 65532)
async def test_file_chunk_fits_first_window_without_extra_network_round_trip(self):
# A 128 KiB file part plus its MTProto envelope must fit before any
# WINDOW_UPDATE arrives. This matters even when the consumer is fast.
body = b'f' * (128 * 1024 + 168)
task, request = await self.post()
self.respond(request, body)
self.assertNotIn(request[1], list(request[0].pending))
self.assertEqual(await asyncio.wait_for(task, 1), body)
async def test_stalled_body_does_not_block_recovery_of_another_native_request(self):
channel = _HttpChannel(self.lane, 1, 'stalled-body-test')
await channel.send(b'k' * 8 + b'a' * 32, False)
slow = await asyncio.wait_for(self.requests.get(), 1)
self.respond(slow, b'a' * 20, end=False, length=40)
fresh_body = b'k' * 8 + b'b' * 32
await channel.send(fresh_body, False)
original = await asyncio.wait_for(self.requests.get(), 1)
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .05):
recovery = asyncio.create_task(channel._recover())
self.tasks.append(recovery)
probe = await asyncio.wait_for(self.requests.get(), .5)
self.assertEqual(probe[2], fresh_body)
self.respond(probe, b'r' * 40)
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'r' * 40)
self.assertIn(slow[1], self.transport.connection.streams)
self.assertIn(original[1], self.transport.connection.streams)
self.assertTrue(self.resets.empty())
conn, sid, _ = slow
conn.h2.send_data(sid, b'a' * 20, end_stream=True)
self.flush(conn)
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'a' * 40)
self.assertEqual(self.lane.failed_until, 0)
async def test_first_recovery_without_result_does_not_end_recovery(self):
channel = _HttpChannel(self.lane, 1, 'second-recovery-test')
body = b'k' * 40
await channel.send(body, False)
self.respond(await asyncio.wait_for(self.requests.get(), 1), b'')
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .02):
recovery = asyncio.create_task(channel._recover())
self.tasks.append(recovery)
first = await asyncio.wait_for(self.requests.get(), .5)
self.respond(first, b'')
second = await asyncio.wait_for(self.requests.get(), .5)
self.assertEqual(first[2], body)
self.assertEqual(second[2], body)
self.respond(second, b'r' * 40)
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'r' * 40)
self.assertEqual(channel.replay_requests, 2)
self.assertEqual(len(self.connections), 1)
async def test_completed_http_ack_does_not_add_idle_delay_to_each_chunk(self):
channel = _HttpChannel(self.lane, 1, 'chunked-download-poll-test')
# An HTTP reply can be only an MTProto acknowledgement. The queued
# file chunk needs another HTTP receiver, with no new native upload.
# Keep the production 1s idle threshold; shorten only scheduler ticks.
with patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
self.tasks.append(asyncio.create_task(channel._recover()))
for index in range(4):
body = b'samekey!' + bytes([index]) * 32
await channel.send(body, False)
original = await asyncio.wait_for(self.requests.get(), 1)
self.assertEqual(original[2], body)
self.respond(original, b'a' * 40)
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'a' * 40)
poll = await asyncio.wait_for(self.requests.get(), .5)
self.assertEqual(poll[2], body)
reply = bytes([index]) * (128 * 1024)
self.respond(poll, reply)
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), reply)
self.assertTrue(self.resets.empty())
self.assertEqual(len(self.connections), 1)
async def test_drained_receiver_wakes_first_poll_without_waiting_for_timer(self):
channel = _HttpChannel(self.lane, 1, 'receiver-wakeup-test')
body = b'samekey!' + b'q' * 32
with patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', 10):
self.tasks.append(asyncio.create_task(channel._recover()))
await channel.send(body, False)
original = await asyncio.wait_for(self.requests.get(), 1)
self.respond(original, b'a' * 40)
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'a' * 40)
poll = await asyncio.wait_for(self.requests.get(), .5)
self.assertEqual(poll[2], body)
self.respond(poll, b'f' * 40)
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'f' * 40)
await asyncio.sleep(.03)
self.assertTrue(self.requests.empty(), 'a wakeup bypassed the retry backoff')
self.assertEqual(channel.replay_requests, 1)
async def test_native_write_completion_wakes_poll_but_backpressure_does_not(self):
class IdentityCipher:
def update(self, data):
return data
class Writer:
def __init__(self):
self.started, self.resume = asyncio.Event(), asyncio.Event()
def write(self, data):
self.started.set()
async def drain(self):
await self.resume.wait()
channel = _HttpChannel(self.lane, 1, 'native-wakeup-test')
reader, writer = asyncio.StreamReader(), Writer()
ctx = SimpleNamespace(clt_enc=IdentityCipher(), clt_dec=IdentityCipher())
body = b'samekey!' + b'q' * 32
with patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', 10):
self.tasks.append(asyncio.create_task(
bridge_h2(reader, writer, channel, ctx, PROTO_TAG_INTERMEDIATE)))
reader.feed_data(struct.pack('<I', len(body)) + body)
original = await asyncio.wait_for(self.requests.get(), 1)
self.respond(original)
await asyncio.wait_for(writer.started.wait(), .5)
await asyncio.sleep(.03)
self.assertTrue(self.requests.empty())
writer.resume.set()
poll = await asyncio.wait_for(self.requests.get(), .5)
self.assertEqual(poll[2], body)
self.assertIsNone(channel.delivering_since)
self.assertEqual(channel.down, 40)
async def test_idle_poll_preserves_slot_for_new_overdue_request(self):
channel = _HttpChannel(self.lane, 1, 'idle-poll-capacity-test')
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(4)]
for body in bodies[:3]:
await channel.send(body, False)
self.respond(await asyncio.wait_for(self.requests.get(), 1))
await asyncio.wait_for(channel.receive(), 1)
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .01), \
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .06), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005):
self.tasks.append(asyncio.create_task(channel._recover()))
poll = await asyncio.wait_for(self.requests.get(), .5)
self.assertEqual(poll[2], bodies[2])
await asyncio.sleep(.04)
self.assertTrue(self.requests.empty(), 'completed history occupied the spare recovery slot')
await channel.send(bodies[3], False)
original = await asyncio.wait_for(self.requests.get(), .5)
recovery = await asyncio.wait_for(self.requests.get(), .5)
self.assertEqual(original[2], bodies[3])
self.assertEqual(recovery[2], bodies[3])
self.respond(recovery, b'n' * 40)
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'n' * 40)
# Neither the old receiver nor the new original was cancelled.
self.assertTrue(self.resets.empty())
self.respond(poll, b'p' * 40)
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'p' * 40)
self.respond(original, b'o' * 40)
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'o' * 40)
self.assertEqual(channel.replay_rotations, 0)
async def test_busy_channel_recovers_old_request_despite_newer_replies(self):
channel = _HttpChannel(self.lane, 1, 'busy-recovery-test')
body = b'samekey!' + b't' * 32
original = None
recovered = asyncio.Event()
chatter_reply = asyncio.Event()
chatter_requests = 0
async def server():
nonlocal original, chatter_requests
while True:
request = await self.requests.get()
if request[2] == body:
if original is None:
original = request
else:
self.respond(request, b't' * 40)
else:
chatter_requests += 1
self.respond(request, b'c' * 40)
async def chatter():
index = 0
while True:
chatter_reply.clear()
await channel.send(b'samekey!' + struct.pack('<I', index) + b'c' * 28, False)
index += 1
await chatter_reply.wait()
await asyncio.sleep(.005)
async def receiver():
while True:
if await channel.receive() == b't' * 40:
recovered.set()
else:
chatter_reply.set()
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .05), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .12), \
patch('proxy.cf_h2.REPLAY_HISTORY_PACKETS', 3):
await channel.send(body, False)
self.tasks.extend(asyncio.create_task(coro) for coro in (
server(), chatter(), receiver(), channel._recover()))
await asyncio.wait_for(recovered.wait(), .6)
self.assertGreater(chatter_requests, 3)
self.assertLessEqual(len(channel.replay_history), 3)
self.assertIn(original[1], self.transport.connection.streams)
self.assertTrue(self.resets.empty())
self.assertEqual(len(self.connections), 1)
async def test_receiving_other_body_does_not_mask_overdue_request(self):
channel = _HttpChannel(self.lane, 1, 'parallel-body-recovery-test')
body = b'samekey!' + b't' * 32
await channel.send(body, False)
original = await asyncio.wait_for(self.requests.get(), 1)
await channel.send(b'samekey!' + b'b' * 32, False)
other = await asyncio.wait_for(self.requests.get(), 1)
self.respond(other, b'b' * 4, end=False, length=400)
async def body_progress():
for _ in range(90):
await asyncio.sleep(.01)
conn, sid, _ = other
conn.h2.send_data(sid, b'b' * 4)
self.flush(conn)
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .03), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .07):
self.tasks.extend(asyncio.create_task(coro) for coro in (body_progress(), channel._recover()))
probe = await asyncio.wait_for(self.requests.get(), .5)
self.assertEqual(probe[2], body)
self.assertEqual(channel.receiving_http, 1)
self.respond(probe, b't' * 40)
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b't' * 40)
self.assertIn(original[1], self.transport.connection.streams)
self.assertIn(other[1], self.transport.connection.streams)
self.assertTrue(self.resets.empty())
async def test_silent_recovery_polls_refresh_without_new_native_input(self):
channel = _HttpChannel(self.lane, 1, 'refresh-recovery-test')
bodies = [b'samekey!' + bytes([index]) * 32 for index in range(2)]
for body in bodies:
await channel.send(body, False)
await asyncio.wait_for(self.requests.get(), 1)
with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .01), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
patch('proxy.cf_h2.REPLAY_SLOT_SECONDS', .2), \
patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .02):
recovery = asyncio.create_task(channel._recover())
self.tasks.append(recovery)
latest = await asyncio.wait_for(self.requests.get(), .5)
older = await asyncio.wait_for(self.requests.get(), .5)
channel.sent_since[channel.replay_history[0].pending] = time.monotonic() - 1
refreshed = await asyncio.wait_for(self.requests.get(), .5)
self.assertEqual(refreshed[2], bodies[0])
self.assertEqual(latest[2], bodies[0])
self.assertEqual(await asyncio.wait_for(self.resets.get(), .5), (latest[1], 8))
self.assertIn(older[1], self.transport.connection.streams)
self.assertEqual(len(channel.replay_pending), 2)
self.respond(refreshed)
self.assertEqual(await asyncio.wait_for(channel.receive(), .5), b'r' * 40)
self.assertEqual(channel.replay_rotations, 1)
async def test_recovery_never_resets_replays_with_partially_received_bodies(self):
channel = _HttpChannel(self.lane, 1, 'protected-body-test')
bodies = [b'k' * 8 + bytes([index]) * 32 for index in range(3)]
for body in bodies[:2]:
await channel.send(body, False)
await asyncio.wait_for(self.requests.get(), 1)
with patch('proxy.cf_h2.REPLAY_REQUEST_SECONDS', .01), \
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .005), \
patch('proxy.cf_h2.REPLAY_SLOT_SECONDS', .04):
recovery = asyncio.create_task(channel._recover())
self.tasks.append(recovery)
probes = [await asyncio.wait_for(self.requests.get(), 1) for _ in range(2)]
for probe in probes:
self.respond(probe, b'p' * 20, end=False, length=40)
await channel.send(bodies[2], False)
original = await asyncio.wait_for(self.requests.get(), 1)
await asyncio.sleep(.1)
self.assertEqual(channel.receiving_http, 2)
self.assertTrue(self.requests.empty())
self.assertTrue(self.resets.empty())
self.assertIn(original[1], self.transport.connection.streams)
# Finishing a partial response frees a slot without throwing its
# MTProto result away, and recovery can then use that slot.
conn, sid, _ = probes[0]
conn.h2.send_data(sid, b'p' * 20, end_stream=True)
self.flush(conn)
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'p' * 40)
fresh_probe = await asyncio.wait_for(self.requests.get(), .5)
self.assertEqual(fresh_probe[2], bodies[2])
self.respond(fresh_probe)
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), b'r' * 40)
self.assertEqual(channel.replay_rotations, 0)
async def test_preflight_and_posts_use_same_tls_connection(self):
preflight = asyncio.create_task(self.lane._preflight())
self.tasks.append(preflight)
request = await asyncio.wait_for(self.requests.get(), 2)
self.respond(request, b'', status=501)
await preflight
task, request = await self.post()
self.respond(request)
await task
self.assertEqual(self.lane.tcp_connections, 1)
self.assertEqual(len(self.connections), 1)
async def test_concurrent_waiters_respect_remote_stream_limit(self):
self.server_max_streams = 2
pending = [asyncio.create_task(self.lane._post(b'x' * 40, i)) for i in range(12)]
self.tasks.extend(pending)
for _ in range(6):
batch = [await asyncio.wait_for(self.requests.get(), 2) for _ in range(2)]
await asyncio.sleep(.01)
self.assertTrue(self.requests.empty())
for request in batch:
self.respond(request)
self.assertEqual(await asyncio.gather(*pending), [b'r' * 40] * 12)
self.assertEqual(len(self.connections), 1)
async def test_cancellation_resets_stream_and_reuses_server_capacity(self):
self.server_max_streams = 2
healthy, healthy_request = await self.post()
for _ in range(5):
task, request = await self.post()
task.cancel()
await asyncio.gather(task, return_exceptions=True)
self.assertEqual(await asyncio.wait_for(self.resets.get(), .5), (request[1], 8))
self.respond(healthy_request)
self.assertEqual(await asyncio.wait_for(healthy, .5), b'r' * 40)
self.assertEqual(len(self.connections), 1)
async def test_cancel_while_waiting_for_stream_slot_does_not_leak_capacity(self):
self.server_max_streams = 1
first, request = await self.post()
waiting = asyncio.create_task(self.lane._post(b'w' * 40, 2))
self.tasks.append(waiting)
await asyncio.sleep(.03)
self.assertTrue(self.requests.empty())
waiting.cancel()
await asyncio.gather(waiting, return_exceptions=True)
self.respond(request)
await first
next_task, next_request = await self.post()
self.respond(next_request)
await next_task
self.assertFalse(self.transport.connection.streams)
async def test_large_upload_and_download_cross_flow_control_windows(self):
body = b'x' * (256 * 1024)
task, request = await self.post(body)
self.respond(request, body)
self.assertEqual(await asyncio.wait_for(task, 2), body)
self.assertEqual(len(self.connections), 1)
async def test_stalled_body_consumer_does_not_block_other_streams(self):
# Keep a streaming response open without reading it. Its window must
# bound buffering while other responses can exceed a connection window.
opening = asyncio.create_task(self.lane.client.send(
self.lane.client.build_request('POST', 'https://' + self.lane.host + '/api', content=b'x' * 40),
stream=True))
self.tasks.append(opening)
request = await asyncio.wait_for(self.requests.get(), 2)
self.respond(request, b's' * (1024 * 1024))
response = await opening
fast, fast_request = await self.post()
self.respond(fast_request, b'f' * (128 * 1024))
self.assertEqual(await asyncio.wait_for(fast, .5), b'f' * (128 * 1024))
stream = self.transport.connection.streams[request[1]]
self.assertLessEqual(sum(len(data) for data, _ in stream.chunks), STREAM_RECEIVE_WINDOW)
await response.aclose()
self.assertEqual(await asyncio.wait_for(self.resets.get(), .5), (request[1], 8))
async def test_read_timeout_resets_only_its_stream(self):
self.lane.client.timeout = httpx.Timeout(2, read=.15)
stalled, request = await self.post()
healthy, healthy_request = await self.post()
self.respond(healthy_request)
self.assertEqual(await healthy, b'r' * 40)
with self.assertRaises(httpx.ReadTimeout):
await stalled
self.assertEqual(await asyncio.wait_for(self.resets.get(), .5), (request[1], 8))
self.assertIsNone(self.transport.connection.error)
async def test_peer_reset_does_not_break_other_streams(self):
bad, request = await self.post()
good, good_request = await self.post()
conn, sid, _ = request
conn.h2.reset_stream(sid, error_code=8)
self.flush(conn)
with self.assertRaises(httpx.RemoteProtocolError):
await bad
self.respond(good_request)
self.assertEqual(await good, b'r' * 40)
async def test_connection_loss_fails_waiters_and_next_request_reconnects(self):
tasks = [await self.post() for _ in range(3)]
self.connections[0].writer.close()
for task, _ in tasks:
with self.assertRaises(httpx.HTTPError):
await asyncio.wait_for(task, .5)
task, request = await self.post()
self.respond(request)
self.assertEqual(await task, b'r' * 40)
self.assertEqual(len(self.connections), 2)
async def test_goaway_fails_pending_without_replaying_and_reconnects(self):
task, request = await self.post()
conn, _, _ = request
conn.h2.close_connection(error_code=0)
self.flush(conn)
with self.assertRaisesRegex(httpx.RemoteProtocolError, 'GOAWAY'):
await task
self.assertTrue(self.requests.empty())
next_task, next_request = await self.post()
self.respond(next_request)
await next_task
self.assertEqual(len(self.connections), 2)
async def test_close_stops_reader_and_wakes_pending_requests(self):
task, _ = await self.post()
connection = self.transport.connection
await self.lane.close()
with self.assertRaises(httpx.HTTPError):
await asyncio.wait_for(task, .5)
self.assertTrue(connection.reader_task.done())
self.assertTrue(connection.writer.is_closing())
if __name__ == '__main__':
unittest.main()
+18
View File
@@ -20,6 +20,24 @@ def _open_ws():
class WsPoolRotationTest(unittest.IsolatedAsyncioTestCase):
async def test_warmup_preserves_media_ws_with_or_without_h2(self):
for cf, secure, opted_out, media in [(True, True, False, True),
(False, True, False, True),
(True, False, False, True),
(True, True, True, True)]:
with self.subTest(cf=cf, secure=secure, opted_out=opted_out), \
mock.patch.object(proxy_config, 'dc_redirects', {2: '149.154.167.220'}), \
mock.patch.object(proxy_config, 'fallback_cfproxy', cf), \
mock.patch.object(proxy_config, 'disable_secure', not secure), \
mock.patch.object(proxy_config, 'cfproxy_h2_media', not opted_out), \
mock.patch.object(proxy_config, 'force_test_dc', False):
pool = _WsPool()
with mock.patch.object(pool, '_schedule_refill') as refill:
await pool.warmup()
keys = [call.args[0] for call in refill.call_args_list]
self.assertIn((2, False), keys)
self.assertEqual((2, True) in keys, media)
async def test_refills_partially_populated_bucket(self):
pool = _WsPool()
key = (2, False)