Compare commits

...

5 Commits

Author SHA1 Message Date
Flowseal 21aaeb3aba macos build fix 2026-07-28 11:43:28 +03:00
Flowseal e0230ebda7 Version bump 2026-07-28 11:33:42 +03:00
Flowseal 47b8db18d9 Added pool refill backoff; don't refill pool on .get if ip is blocked 2026-07-28 11:08:24 +03:00
Flowseal a0545cca64 don't retry fronting connect 2026-07-28 10:55:36 +03:00
Flowseal 129a760996 fixes #1079 #1083 2026-07-28 10:41:39 +03:00
6 changed files with 54 additions and 13 deletions
+4 -2
View File
@@ -236,6 +236,8 @@ jobs:
build-macos: build-macos:
runs-on: macos-latest runs-on: macos-latest
if: ${{ github.event.inputs.build_macos == 'true' }} if: ${{ github.event.inputs.build_macos == 'true' }}
env:
CFFI_VERSION: "2.0.0"
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
@@ -261,7 +263,7 @@ jobs:
--python-version 3.12 \ --python-version 3.12 \
--implementation cp \ --implementation cp \
-d wheelhouse/arm64 \ -d wheelhouse/arm64 \
'cffi>=2.0.0' \ "cffi==$CFFI_VERSION" \
Pillow==12.1.0 \ Pillow==12.1.0 \
psutil==7.0.0 psutil==7.0.0
@@ -271,7 +273,7 @@ jobs:
--python-version 3.12 \ --python-version 3.12 \
--implementation cp \ --implementation cp \
-d wheelhouse/x86_64 \ -d wheelhouse/x86_64 \
'cffi>=2.0.0' \ "cffi==$CFFI_VERSION" \
Pillow==12.1.0 Pillow==12.1.0
python3.12 -m pip download \ python3.12 -m pip download \
+4 -4
View File
@@ -4,8 +4,8 @@
# http://msdn.microsoft.com/en-us/library/ms646997.aspx # http://msdn.microsoft.com/en-us/library/ms646997.aspx
VSVersionInfo( VSVersionInfo(
ffi=FixedFileInfo( ffi=FixedFileInfo(
filevers=(1, 8, 1, 0), filevers=(1, 9, 0, 0),
prodvers=(1, 8, 1, 0), prodvers=(1, 9, 0, 0),
mask=0x3f, mask=0x3f,
flags=0x0, flags=0x0,
OS=0x40004, OS=0x40004,
@@ -21,12 +21,12 @@ VSVersionInfo(
[ [
StringStruct(u'CompanyName', u'Flowseal'), StringStruct(u'CompanyName', u'Flowseal'),
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'), StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
StringStruct(u'FileVersion', u'1.8.1.0'), StringStruct(u'FileVersion', u'1.9.0.0'),
StringStruct(u'InternalName', u'TgWsProxy'), StringStruct(u'InternalName', u'TgWsProxy'),
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'), StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'), StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
StringStruct(u'ProductName', u'TG WS Proxy'), StringStruct(u'ProductName', u'TG WS Proxy'),
StringStruct(u'ProductVersion', u'1.8.1.0'), StringStruct(u'ProductVersion', u'1.9.0.0'),
] ]
) )
] ]
+1 -1
View File
@@ -1,6 +1,6 @@
from .config import parse_dc_ip_list, proxy_config, coerce_domain_list from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
from .utils import get_link_host, build_github_opener from .utils import get_link_host, build_github_opener
__version__ = "1.8.1" __version__ = "1.9.0"
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"] __all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
+38 -4
View File
@@ -16,15 +16,20 @@ log = logging.getLogger('tg-mtproto-proxy')
class _WsPool: class _WsPool:
WS_POOL_MAX_AGE = 120.0 WS_POOL_MAX_AGE = 120.0
WS_POOL_CHECK_INTERVAL = 5.0 WS_POOL_CHECK_INTERVAL = 5.0
REFILL_BACKOFF_INITIAL = 60.0
REFILL_BACKOFF_MAX = 3600.0
def __init__(self): def __init__(self):
self._idle: Dict[Tuple[int, bool], deque] = {} self._idle: Dict[Tuple[int, bool], deque] = {}
self._refilling: Set[Tuple[int, bool]] = set() self._refilling: Set[Tuple[int, bool]] = set()
self._rotating: Dict[Tuple[int, bool], asyncio.Task] = {} self._rotating: Dict[Tuple[int, bool], asyncio.Task] = {}
self._refill_failures: Dict[Tuple[int, bool], int] = {}
self._refill_after: Dict[Tuple[int, bool], float] = {}
self.try_fronting_first = False self.try_fronting_first = False
async def get(self, dc: int, is_media: bool, async def get(self, dc: int, is_media: bool,
target_ip: str, domains: List[str] target_ip: str, domains: List[str],
*, allow_refill: bool = True
) -> Optional[RawWebSocket]: ) -> Optional[RawWebSocket]:
key = (dc, is_media) key = (dc, is_media)
now = time.monotonic() now = time.monotonic()
@@ -43,19 +48,28 @@ class _WsPool:
stats.pool_hits += 1 stats.pool_hits += 1
log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)", log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)",
dc, 'm' if is_media else '', age, len(bucket)) dc, 'm' if is_media else '', age, len(bucket))
self._schedule_refill(key, target_ip, domains) self.report_success(dc, is_media)
if allow_refill:
self._schedule_refill(key, target_ip, domains)
return ws return ws
stats.pool_misses += 1 stats.pool_misses += 1
self._schedule_refill(key, target_ip, domains) if allow_refill:
self._schedule_refill(key, target_ip, domains)
return None return None
def _schedule_refill(self, key, target_ip, domains): def _schedule_refill(self, key, target_ip, domains):
if key in self._refilling: if (key in self._refilling
or time.monotonic() < self._refill_after.get(key, 0)):
return return
self._refilling.add(key) self._refilling.add(key)
asyncio.create_task(self._refill(key, target_ip, domains)) asyncio.create_task(self._refill(key, target_ip, domains))
def report_success(self, dc: int, is_media: bool) -> None:
key = (dc, is_media)
self._refill_failures.pop(key, None)
self._refill_after.pop(key, None)
async def _refill(self, key, target_ip, domains): async def _refill(self, key, target_ip, domains):
dc, is_media = key dc, is_media = key
try: try:
@@ -63,6 +77,7 @@ class _WsPool:
needed = proxy_config.pool_size - len(bucket) needed = proxy_config.pool_size - len(bucket)
if needed <= 0: if needed <= 0:
return return
connected = 0
tasks = [asyncio.create_task( tasks = [asyncio.create_task(
self._connect_one(target_ip, domains)) self._connect_one(target_ip, domains))
for _ in range(needed)] for _ in range(needed)]
@@ -71,9 +86,24 @@ class _WsPool:
ws = await t ws = await t
if ws: if ws:
bucket.append((ws, time.monotonic())) bucket.append((ws, time.monotonic()))
connected += 1
self._schedule_rotation(key, target_ip, domains) self._schedule_rotation(key, target_ip, domains)
except Exception: except Exception:
pass pass
if connected:
self.report_success(dc, is_media)
else:
failures = self._refill_failures.get(key, 0) + 1
self._refill_failures[key] = failures
delay = min(
self.REFILL_BACKOFF_INITIAL
* (2 ** min(failures - 1, 6)),
self.REFILL_BACKOFF_MAX,
)
self._refill_after[key] = time.monotonic() + delay
log.info(
"WS pool refill failed for DC%d%s, retry in %.0fs",
dc, 'm' if is_media else '', delay)
log.debug("WS pool refilled DC%d%s: %d ready", log.debug("WS pool refilled DC%d%s: %d ready",
dc, 'm' if is_media else '', len(bucket)) dc, 'm' if is_media else '', len(bucket))
finally: finally:
@@ -136,6 +166,8 @@ class _WsPool:
self.try_fronting_first = False self.try_fronting_first = False
return ws return ws
except asyncio.TimeoutError: except asyncio.TimeoutError:
if self.try_fronting_first:
return None
return await self._connect_fronted(target_ip, domain) return await self._connect_fronted(target_ip, domain)
except WsHandshakeError as exc: except WsHandshakeError as exc:
if exc.is_redirect: if exc.is_redirect:
@@ -179,6 +211,8 @@ class _WsPool:
self._idle.clear() self._idle.clear()
self._refilling.clear() self._refilling.clear()
self._rotating.clear() self._rotating.clear()
self._refill_failures.clear()
self._refill_after.clear()
self.try_fronting_first = False self.try_fronting_first = False
+6 -1
View File
@@ -339,7 +339,11 @@ async def _handle_client(reader, writer, secret: bytes):
ws_timed_out = False ws_timed_out = False
all_redirects = True all_redirects = True
ws = await ws_pool.get(dc, is_media, target, domains) if not is_test_dc else None allow_pool_refill = now >= ip_fail_until.get(target, 0)
ws = await ws_pool.get(
dc, is_media, target, domains,
allow_refill=allow_pool_refill,
) if not is_test_dc else None
if ws: if ws:
log.info("[%s] DC%d%s -> pool hit via %s", log.info("[%s] DC%d%s -> pool hit via %s",
label, dc, media_tag, target) label, dc, media_tag, target)
@@ -413,6 +417,7 @@ async def _handle_client(reader, writer, secret: bytes):
dc_fail_until.pop(dc_key, None) dc_fail_until.pop(dc_key, None)
ip_fail_until.pop(target, None) ip_fail_until.pop(target, None)
ws_pool.report_success(dc, is_media)
stats.connections_ws += 1 stats.connections_ws += 1
splitter = None splitter = None
+1 -1
View File
@@ -525,7 +525,7 @@ def _edit_config_dialog() -> None:
merged["force_test_dc"] = _config.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"]) merged["force_test_dc"] = _config.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"])
_ui_only_keys = {"appearance", "autostart", "check_updates", "language"} _ui_only_keys = {"appearance", "autostart", "check_updates", "language"}
config_changed = any(merged.get(k) != _config.get(k) for k in merged) config_changed = any(merged.get(k) != cfg.get(k) for k in merged)
proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys) proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys)
if not config_changed: if not config_changed: