Skip to content
This repository was archived by the owner on Apr 5, 2026. It is now read-only.

Commit eb22417

Browse files
committed
fix: limit DRS delays to slow-start phases, add media download E2E tests
DRS inter-record delays still throttled sustained downloads in v3.5.1 — the burst threshold (16KB) fired at batch boundaries during streaming. Now delays only apply during phases 1+2 (first ~60 records, ~140KB). Phase 3 (max-size records) runs at full TCP speed. The DRS counter resets after 1s inactivity so new responses still get realistic timing. Add multi-size media download E2E tests (1/20/100 MB) through both obfuscated2 and fake-TLS proxy modes with SHA256 integrity verification. These catch DRS delay regressions that throttle large transfers. Closes #70
1 parent 9191e44 commit eb22417

5 files changed

Lines changed: 396 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [3.5.2] - 2026-03-28
6+
7+
### Fixed
8+
- **DRS delays still throttled sustained downloads** — delays are now limited to slow-start phases only (first ~60 records, ~140KB). Once the connection reaches phase 3 (max-size TLS records), all delays are skipped. The DRS counter resets after 1s of inactivity, so new responses still get realistic slow-start timing. This fully resolves media download degradation reported in [#70](https://github.com/GetPageSpeed/MTProxy/issues/70)
9+
10+
### Added
11+
- **Media download E2E tests** — CI now downloads pre-saved files (1 MB, 20 MB, 100 MB) through both obfuscated2 and fake-TLS proxy modes, verifying data integrity and throughput. Catches DRS delay regressions that throttle large transfers
12+
- DRS bulk transfer delay bounds test in Docker test suite
13+
514
## [3.5.1] - 2026-03-27
615

716
### Fixed

net/net-tcp-drs.c

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,11 @@ int cpu_tcp_aes_crypto_ctr128_encrypt_output_drs (connection_job_t C) /* {{{ */
147147

148148
assert (rwm_encrypt_decrypt_to (&c->out, &c->out_p, len, T->write_aeskey, 1) == len);
149149

150-
/* Inter-record delay: skip during bulk transfers for full throughput */
150+
/* Inter-record delay: skip during bulk transfers and sustained transfers
151+
(phase 3 = max-size records = no real server adds delays here).
152+
Delays only apply during slow-start phases 1+2 (~140KB, ~60 records). */
151153
if (drs_delays_enabled && (c->flags & C_IS_TLS) && c->out.total_bytes > 0) {
152-
if (c->out.total_bytes > DRS_BURST_THRESHOLD) {
154+
if (c->out.total_bytes > DRS_BURST_THRESHOLD || drs->record_index >= DRS_PHASE2_END) {
153155
drs_delays_skipped++;
154156
continue;
155157
}

tests/seed_test_media.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
#!/usr/bin/env python3
2+
"""One-time utility: upload test files to Saved Messages for E2E download tests.
3+
4+
Connects using the asian bot's SQLite session and uploads 1 MB, 20 MB, and
5+
100 MB random files. Prints message IDs and SHA256 hashes to hard-code in
6+
test_direct_e2e.py.
7+
8+
Usage:
9+
python3 tests/seed_test_media.py [--session PATH]
10+
11+
Requires: telethon
12+
"""
13+
14+
import argparse
15+
import asyncio
16+
import hashlib
17+
import io
18+
import os
19+
import shutil
20+
import sys
21+
import tempfile
22+
23+
# Same credentials used in test_direct_e2e.py / CI.
24+
API_ID = 2834
25+
API_HASH = "68875f756c9b437a8b916ca3de215815"
26+
27+
DEFAULT_SESSION = os.path.expanduser("~/Projects/tgp/sessions/asian.session")
28+
29+
TEST_FILES = [
30+
("mtproxy_test_1mb.bin", 1 * 1024 * 1024),
31+
("mtproxy_test_20mb.bin", 20 * 1024 * 1024),
32+
("mtproxy_test_100mb.bin", 100 * 1024 * 1024),
33+
]
34+
35+
36+
async def main(session_path: str):
37+
from telethon import TelegramClient
38+
39+
# Copy session to a temp file to avoid locking the original.
40+
tmp_dir = tempfile.mkdtemp(prefix="seed_media_")
41+
tmp_session = os.path.join(tmp_dir, "session")
42+
shutil.copy2(session_path, tmp_session + ".session")
43+
44+
client = TelegramClient(tmp_session, api_id=API_ID, api_hash=API_HASH)
45+
46+
try:
47+
await client.connect()
48+
if not await client.is_user_authorized():
49+
print("ERROR: session is not authorized", file=sys.stderr)
50+
sys.exit(1)
51+
52+
me = await client.get_me()
53+
print(f"Connected as user_id={me.id} ({me.first_name})\n")
54+
55+
print("Uploading test files to Saved Messages...\n")
56+
results = []
57+
58+
for name, size in TEST_FILES:
59+
print(f" Generating {name} ({size / 1024 / 1024:.0f} MB)...")
60+
data = os.urandom(size)
61+
sha = hashlib.sha256(data).hexdigest()
62+
63+
print(f" Uploading {name}...")
64+
msg = await client.send_file(
65+
"me",
66+
io.BytesIO(data),
67+
file_name=name,
68+
caption=f"MTProxy E2E test file — {size} bytes — do not delete",
69+
force_document=True,
70+
)
71+
print(f" Done: msg_id={msg.id}\n")
72+
results.append((name, size, msg.id, sha))
73+
74+
print("=" * 60)
75+
print("Hard-code these in tests/test_direct_e2e.py:\n")
76+
print("TEST_FILES = {")
77+
for name, size, msg_id, sha in results:
78+
label = name.replace("mtproxy_test_", "").replace(".bin", "")
79+
print(f' "{label}": {{"msg_id": {msg_id}, "size": {size}, "sha256": "{sha}"}},')
80+
print("}")
81+
print()
82+
83+
finally:
84+
await client.disconnect()
85+
shutil.rmtree(tmp_dir, ignore_errors=True)
86+
87+
88+
if __name__ == "__main__":
89+
parser = argparse.ArgumentParser(description="Seed test media for MTProxy E2E tests")
90+
parser.add_argument(
91+
"--session",
92+
default=DEFAULT_SESSION,
93+
help=f"Path to Telethon SQLite session (default: {DEFAULT_SESSION})",
94+
)
95+
args = parser.parse_args()
96+
97+
if not os.path.exists(args.session):
98+
print(f"ERROR: session file not found: {args.session}", file=sys.stderr)
99+
sys.exit(1)
100+
101+
asyncio.run(main(args.session))

tests/test_direct_e2e.py

Lines changed: 185 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
get_me() to verify the full data path works. Tests both obfuscated2
66
(dd-prefix) and fake-TLS (ee-prefix) transport modes.
77
8+
Also downloads pre-saved test files (1 MB, 20 MB, 100 MB) through the proxy
9+
to verify sustained data transfer works correctly — catches DRS delay
10+
regressions that throttle large downloads.
11+
812
Requires the TG_STRING_SESSION environment variable (Telethon StringSession).
913
Skips gracefully when the secret is absent (fork PRs, external contributors).
1014
@@ -21,13 +25,27 @@
2125
"""
2226

2327
import asyncio
28+
import hashlib
29+
import io
2430
import os
2531
import sys
32+
import time
2633

2734
# Official Telegram macOS client credentials (public, well-known).
2835
API_ID = 2834
2936
API_HASH = "68875f756c9b437a8b916ca3de215815"
3037

38+
# Pre-saved test files in Saved Messages of the test account (user 8326553636).
39+
# Seeded by tests/seed_test_media.py — do not delete from Saved Messages.
40+
TEST_FILES = {
41+
"1mb": {"msg_id": 116459, "size": 1048576, "sha256": "2c331300901dc5a4a58fcce075212f9506da9cdb281203067760b0c2715eab59"},
42+
"20mb": {"msg_id": 116462, "size": 20971520, "sha256": "30ff0930d860a323c4e9e1f31a6758a7d208f0c53e57c4ea4361b8397e277816"},
43+
"100mb": {"msg_id": 116467, "size": 104857600, "sha256": "ca81cb2e62c70e488b74a347caea415989bfdaf64d47aa80e898d01974fb2ed0"},
44+
}
45+
46+
# Per-file download timeouts (seconds).
47+
DOWNLOAD_TIMEOUTS = {"1mb": 60, "20mb": 180, "100mb": 600}
48+
3149

3250
def _patch_telethon_faketls():
3351
"""Patch TelethonFakeTLS bugs.
@@ -69,6 +87,78 @@ def _writer_write_with_ccs(self, data, extra={}):
6987
tls_io.FakeTLSStreamWriter.write = _writer_write_with_ccs
7088

7189

90+
async def _download_test_files(client, transport_label):
91+
"""Download pre-saved test files and verify integrity.
92+
93+
Returns True if all files downloaded and verified successfully.
94+
"""
95+
all_ok = True
96+
for label, info in TEST_FILES.items():
97+
timeout = DOWNLOAD_TIMEOUTS[label]
98+
print(f" [{transport_label}] downloading {label} "
99+
f"(msg_id={info['msg_id']}, timeout={timeout}s)...",
100+
flush=True)
101+
102+
try:
103+
msg = await asyncio.wait_for(
104+
client.get_messages("me", ids=info["msg_id"]),
105+
timeout=15,
106+
)
107+
except Exception as e:
108+
print(f" [{transport_label}] {label}: SKIP — "
109+
f"could not fetch message: {type(e).__name__}: {e}")
110+
continue
111+
112+
if msg is None or msg.media is None:
113+
print(f" [{transport_label}] {label}: SKIP — "
114+
f"message {info['msg_id']} not found (test data not seeded)")
115+
continue
116+
117+
buf = io.BytesIO()
118+
start = time.monotonic()
119+
try:
120+
await asyncio.wait_for(
121+
client.download_media(msg, file=buf),
122+
timeout=timeout,
123+
)
124+
except asyncio.TimeoutError:
125+
elapsed = time.monotonic() - start
126+
got = len(buf.getvalue())
127+
print(f" [{transport_label}] {label}: FAIL — "
128+
f"timed out after {elapsed:.0f}s "
129+
f"({got}/{info['size']} bytes, "
130+
f"{got / elapsed / 1048576:.2f} MB/s)")
131+
all_ok = False
132+
continue
133+
except Exception as e:
134+
print(f" [{transport_label}] {label}: FAIL — "
135+
f"{type(e).__name__}: {e}")
136+
all_ok = False
137+
continue
138+
139+
elapsed = time.monotonic() - start
140+
data = buf.getvalue()
141+
actual_hash = hashlib.sha256(data).hexdigest()
142+
143+
if len(data) != info["size"]:
144+
print(f" [{transport_label}] {label}: FAIL — "
145+
f"size mismatch: got {len(data)}, expected {info['size']}")
146+
all_ok = False
147+
continue
148+
149+
if actual_hash != info["sha256"]:
150+
print(f" [{transport_label}] {label}: FAIL — "
151+
f"SHA256 mismatch")
152+
all_ok = False
153+
continue
154+
155+
throughput_mb = len(data) / elapsed / 1048576
156+
print(f" [{transport_label}] {label}: OK — "
157+
f"{elapsed:.1f}s, {throughput_mb:.1f} MB/s")
158+
159+
return all_ok
160+
161+
72162
async def test_obfs2_get_me(host, port, secret, session_str):
73163
"""Connect via obfuscated2 (dd-prefix) and call get_me()."""
74164
from telethon import TelegramClient
@@ -110,6 +200,41 @@ async def test_obfs2_get_me(host, port, secret, session_str):
110200
pass
111201

112202

203+
async def test_obfs2_media_download(host, port, secret, session_str):
204+
"""Download pre-saved files through obfuscated2 proxy."""
205+
from telethon import TelegramClient
206+
from telethon.network.connection import (
207+
ConnectionTcpMTProxyRandomizedIntermediate,
208+
)
209+
from telethon.sessions import StringSession
210+
211+
print(f"[obfs2-media] Connecting to {host}:{port} ...", flush=True)
212+
213+
client = TelegramClient(
214+
StringSession(session_str),
215+
api_id=API_ID,
216+
api_hash=API_HASH,
217+
connection=ConnectionTcpMTProxyRandomizedIntermediate,
218+
proxy=(host, port, "dd" + secret),
219+
)
220+
221+
try:
222+
await asyncio.wait_for(client.connect(), timeout=30)
223+
if not client.is_connected():
224+
print("[obfs2-media] FAIL: client did not connect")
225+
return False
226+
227+
return await _download_test_files(client, "obfs2-media")
228+
except Exception as e:
229+
print(f"[obfs2-media] FAIL: {type(e).__name__}: {e}")
230+
return False
231+
finally:
232+
try:
233+
await client.disconnect()
234+
except Exception:
235+
pass
236+
237+
113238
async def test_faketls_get_me(host, port, secret, domain, session_str):
114239
"""Connect via fake-TLS (ee-prefix) and call get_me()."""
115240
try:
@@ -158,6 +283,48 @@ async def test_faketls_get_me(host, port, secret, domain, session_str):
158283
pass
159284

160285

286+
async def test_faketls_media_download(host, port, secret, domain, session_str):
287+
"""Download pre-saved files through fake-TLS proxy (exercises DRS)."""
288+
try:
289+
from TelethonFakeTLS import ConnectionTcpMTProxyFakeTLS
290+
except ImportError:
291+
print("[fake-tls-media] SKIP: TelethonFakeTLS not installed")
292+
return True
293+
294+
from telethon import TelegramClient
295+
from telethon.sessions import StringSession
296+
297+
_patch_telethon_faketls()
298+
299+
proxy_secret = secret + domain.encode().hex()
300+
print(f"[fake-tls-media] Connecting to {host}:{port} (domain={domain}) ...",
301+
flush=True)
302+
303+
client = TelegramClient(
304+
StringSession(session_str),
305+
api_id=API_ID,
306+
api_hash=API_HASH,
307+
connection=ConnectionTcpMTProxyFakeTLS,
308+
proxy=(host, port, proxy_secret),
309+
)
310+
311+
try:
312+
await asyncio.wait_for(client.connect(), timeout=30)
313+
if not client.is_connected():
314+
print("[fake-tls-media] FAIL: client did not connect")
315+
return False
316+
317+
return await _download_test_files(client, "fake-tls-media")
318+
except Exception as e:
319+
print(f"[fake-tls-media] FAIL: {type(e).__name__}: {e}")
320+
return False
321+
finally:
322+
try:
323+
await client.disconnect()
324+
except Exception:
325+
pass
326+
327+
161328
def main():
162329
session_str = os.environ.get("TG_STRING_SESSION", "")
163330
if not session_str:
@@ -178,16 +345,30 @@ def main():
178345

179346
print("=== Direct Mode E2E Tests ===\n")
180347

181-
# Test 1: obfuscated2
348+
# Test 1: obfuscated2 get_me
182349
ok = asyncio.run(test_obfs2_get_me(host, obfs2_port, secret, session_str))
183-
results.append(("obfs2", ok))
350+
results.append(("obfs2-get_me", ok))
184351
print()
185352

186-
# Test 2: fake-TLS
353+
# Test 2: fake-TLS get_me
187354
ok = asyncio.run(
188355
test_faketls_get_me(host, tls_port, secret, domain, session_str)
189356
)
190-
results.append(("fake-tls", ok))
357+
results.append(("fake-tls-get_me", ok))
358+
print()
359+
360+
# Test 3: obfuscated2 media download (control — no DRS delays)
361+
ok = asyncio.run(
362+
test_obfs2_media_download(host, obfs2_port, secret, session_str)
363+
)
364+
results.append(("obfs2-media", ok))
365+
print()
366+
367+
# Test 4: fake-TLS media download (exercises DRS delays)
368+
ok = asyncio.run(
369+
test_faketls_media_download(host, tls_port, secret, domain, session_str)
370+
)
371+
results.append(("fake-tls-media", ok))
191372

192373
print("\n=== Results ===")
193374
all_ok = True

0 commit comments

Comments
 (0)