Compare commits

..

4 Commits

Author SHA1 Message Date
babin d95948dbba ci(android): make the emulator smoke survive a slow emulator, not app failures
Evidence from runs 39866280662, 39870244857 and 40091145294 shows the red
smoke runs were the emulator, not TGLock: on the software GPU the WebView
paints about one frame per second while the page re-renders every second,
so a tap's down/up can straddle a re-render and Chromium drops the click
(no setEnabled call at all in the failed Stop run). Play services and Pixel
Launcher post-boot work also starved system_server into foreign ANR dialogs.

- AOSP image, 4 cores, 4 GB RAM, no snapshot save; wait for CPU to settle
  after boot before launching.
- Answer foreign "isn't responding" dialogs with Wait and record them; a
  TGLock ANR/error dialog, crash or missing process still fails.
- Re-tap only when the button is unchanged 10s after a tap (at most 3 taps);
  foreground-service and SOCKS listener effects stay strictly asserted with
  a 45s budget sized for this emulator.
- Timed step markers in stdout and logcat, failure screenshot and window
  dump, one evidence artifact per run attempt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:17:05 +03:00
Никита Sonic 3aa6f413a3 fix(worker): type-clean frame handling; name a Telegram close before any reply (#67)
Cloudflare's dashboard editor type-checks the script and underlined four
lines of the Blob handling, because a separate `blob` flag did not narrow
`event.data` (#66). binaryFrame() narrows each branch with instanceof; the
file now type-checks cleanly against current @cloudflare/workers-types.

When Telegram closes TCP without sending a byte, the Worker used to end the
WebSocket with a plain 1000. It now closes with 1011 "Telegram closed before
replying" and logs the destination and the bytes the client sent, so the
client log and the Cloudflare log both say where the tunnel broke (#53).

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:03:26 +00:00
Никита Sonic f82543e44e ci(android): retry UIAutomator dumps that write nothing (#64)
On the loaded API 35 emulator `uiautomator dump` intermittently writes no
file ("could not get idle state"). tap_label passed that straight to
`cat` and failed the whole smoke on the first miss, while the launch loop
already retried. The beta.16 commit failed four runs in a row this way;
the identical tree passed on its PR. Only the dump is retried — crash,
ANR, service and listener assertions are unchanged.

Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:12:23 +00:00
Никита Sonic 9529df3fdf fix(worker): туннели через Worker рвались с 1003 на новых compatibility date; 2.0.0-beta.16 (#63)
* worker: set binaryType = "arraybuffer" to fix 1003 "binary messages required"

Since compatibility date 2026-03-17 Cloudflare delivers binary WebSocket messages as Blob (websocket_standard_binary_type). The message handler accepts only ArrayBuffer and closes every tunnel with 1003. Setting binaryType before accept() restores ArrayBuffer delivery regardless of the compatibility date.

* fix(worker): accept Blob frames and name an outdated Worker in the log; 2.0.0-beta.16

Since compatibility date 2026-03-17 Cloudflare delivers binary WebSocket
frames as Blob unless binaryType is "arraybuffer", and the Worker closed
every tunnel with 1003 right after the handshake. wrangler.toml itself pins
2026-09-19, so every fresh deployment was broken (#58, #50, #53).

On top of #61 (binaryType before accept) the Worker now also relays Blob
frames in order, and the client log tells the user to redeploy the script
when a Worker closes with "1003 binary messages required".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitry Sankevich <16187308+avost@users.noreply.github.com>
Co-authored-by: babin <Thartewerner536e@engineer.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:37:15 +00:00
13 changed files with 310 additions and 57 deletions
+10 -5
View File
@@ -79,7 +79,7 @@ jobs:
name: Android 15 emulator smoke
needs: apk
runs-on: ubuntu-22.04
timeout-minutes: 15
timeout-minutes: 25
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v4
@@ -91,20 +91,25 @@ jobs:
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
# The AOSP image has no Play services or Pixel Launcher. Their post-boot
# work starved system_server on the 4-vCPU runner and produced foreign ANR
# dialogs over the app. The app needs neither; WebView is still present.
- name: Launch and exercise the native app
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 35
arch: x86_64
target: google_apis
target: default
profile: pixel_2
cores: 4
ram-size: 4096M
disable-animations: true
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
script: timeout 180 python3 scripts/android_smoke.py emulator-apk
emulator-options: -no-window -no-snapshot -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
script: timeout 420 python3 scripts/android_smoke.py emulator-apk
- uses: actions/upload-artifact@v4
if: always()
with:
name: android-emulator-smoke-evidence
name: android-emulator-smoke-evidence-attempt-${{ github.run_attempt }}
path: android-smoke-evidence/
if-no-files-found: warn
retention-days: 14
Generated
+1 -1
View File
@@ -3822,7 +3822,7 @@ dependencies = [
[[package]]
name = "tglock"
version = "2.0.0-beta.15"
version = "2.0.0-beta.16"
dependencies = [
"aes",
"cipher",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tglock"
version = "2.0.0-beta.15"
version = "2.0.0-beta.16"
edition = "2021"
rust-version = "1.88"
description = "Telegram unblock via local WebSocket tunnel"
+7 -2
View File
@@ -57,8 +57,13 @@ restart, and explicit force-stop/relaunch. It verifies the foreground service an
performs a real SOCKS5 greeting through ADB port forwarding to the Rust listener.
If buttons are inaccessible after a bounded wait, CI explicitly reports the
lifecycle checks as skipped; a launch-only pass is not lifecycle evidence.
`android-emulator-smoke-evidence` retains the exact result, UI dumps, service
state and logcat. This does not test automatic low-memory eviction, battery
`android-emulator-smoke-evidence-attempt-N` (one per run attempt) retains the
exact result, timed steps, UI dumps, service state, logcat and, on failure, a
screenshot. The emulator is the AOSP image without Play services; foreign
system ANR dialogs are answered with Wait and recorded, while a crash, ANR or
error dialog of TGLock itself fails the job. A tap the WebView dropped (the
button is unchanged ten seconds later) is repeated at most twice; the service
and listener effects are then asserted with the same strictness. This does not test automatic low-memory eviction, battery
behavior, Telegram connectivity, or a physical phone.
Checked-in `gen/android` contains the native source and Gradle wrapper. Tauri's
+2
View File
@@ -41,6 +41,8 @@
Если вернулось `not found` — проверь, что путь именно `/apiws`. Если ошибка про `cloudflare:sockets` — у воркера слишком старая дата совместимости, поставь в **Settings → Compatibility date** сегодняшнюю.
> **Обнови скрипт, если Worker создан или менял compatibility date после 17 марта 2026.** С этой даты Cloudflare отдаёт бинарные WebSocket-кадры как `Blob`, и скрипт до beta.16 закрывал каждый туннель сразу после рукопожатия. В журнале TGLock это строка `WebSocket закрыт: 1003 binary messages required`, в диагностике туннели мигают 0 → 1 → 0. Текущий скрипт выставляет `binaryType = "arraybuffer"` и принимает `Blob` (исправление от [@avost](https://github.com/by-sonic/tglock/pull/61)).
> **Обнови скрипт при переходе с beta.14.** Теперь Worker ждёт открытия TCP к Telegram перед ответом 101: отказ возвращает 502, таймаут за 3 секунды — 504. Очередь записи ограничена 1 МиБ / 256 сообщениями; переполнение закрывает соединение с кодом 1009, ошибки сокета — 1011. Старый Worker мог показать успешное соединение ещё до попытки подключения к Telegram и не ограничивал очередь сообщений.
Для установки через Wrangler есть [`worker/wrangler.toml`](../worker/wrangler.toml). Из каталога `worker` можно выполнить `npx wrangler deploy` в своём Cloudflare-аккаунте. Эта команда публикует Worker; локальные тесты ничего не публикуют.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "tglock-ui",
"version": "2.0.0-beta.15",
"version": "2.0.0-beta.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tglock-ui",
"version": "2.0.0-beta.15",
"version": "2.0.0-beta.16",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tglock-ui",
"private": true,
"version": "2.0.0-beta.15",
"version": "2.0.0-beta.16",
"type": "module",
"scripts": {
"test:worker": "node --experimental-vm-modules --test worker/tglock-worker.test.js",
+166 -29
View File
@@ -14,41 +14,122 @@ PACKAGE = "com.bysonic.tglock"
COMPONENT = f"{PACKAGE}/.MainActivity"
EVIDENCE = Path("android-smoke-evidence")
EVIDENCE.mkdir(exist_ok=True)
RESULT = {"launch": "not_run", "lifecycle": "not_run"}
RESULT = {"launch": "not_run", "lifecycle": "not_run", "foreign_dialogs": [], "taps": {}}
STARTED = time.monotonic()
# Budgets measured on the GitHub-hosted emulator, not on a phone. With the
# software GPU the WebView paints about one frame per second, and dumpsys
# itself can take seconds while system_server catches up after boot.
SETTLE_SECONDS = 120
VISIBLE_SECONDS = 60
EFFECT_SECONDS = 45
# A click that reaches the handler re-renders the button as "Подождите…" or
# the opposite action within a frame. Only a button left unchanged this long
# was never clicked.
RETAP_AFTER_SECONDS = 10
MAX_TAPS = 3
SYSTEM_DIALOG = re.compile(r"(Application Not Responding|Application Error): ([\w.:]+)")
def adb(*args, check=True, timeout=20):
def adb(*args, check=True, timeout=30):
return subprocess.run(
["adb", *args], check=check, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=timeout,
).stdout.strip()
def wait_for(description, predicate, seconds=20):
def step(message):
# The same marker in stdout and logcat lines up the job log with evidence.
line = f"[{time.monotonic() - STARTED:6.1f}s] {message}"
print(line, flush=True)
with open(EVIDENCE / "steps.txt", "a", encoding="utf-8") as steps:
steps.write(line + "\n")
adb("shell", "log", "-t", "tglock-smoke", json.dumps(message), check=False)
def system_dialog():
try:
windows = adb("shell", "dumpsys", "window", "windows", check=False)
except subprocess.SubprocessError:
return None
match = SYSTEM_DIALOG.search(windows)
return (match.group(1), match.group(2)) if match else None
def clear_foreign_dialogs():
# A loaded emulator regularly declares its own launcher or system apps
# unresponsive, and that dialog covers the WebView. Ours is a failure.
dialog = system_dialog()
if dialog is None:
return
kind, process = dialog
if process.split(":")[0] == PACKAGE:
raise AssertionError(f"{kind} dialog for {process}")
button = "android:id/aerr_wait" if kind == "Application Not Responding" else "android:id/aerr_close"
try:
point = node_center(ui_dump(f"dialog-{len(RESULT['foreign_dialogs'])}"),
lambda node: node.get("resource-id") == button)
except (subprocess.SubprocessError, ET.ParseError):
point = None
if point is not None:
adb("shell", "input", "tap", str(point[0]), str(point[1]))
outcome = f"tapped {button}" if point else "button not dumped yet"
RESULT["foreign_dialogs"].append(f"{kind}: {process}, {outcome}")
step(f"foreign dialog {kind}: {process}, {outcome}")
def wait_for(description, predicate, seconds=EFFECT_SECONDS):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
clear_foreign_dialogs()
if predicate():
step(f"ok: {description}")
return
time.sleep(1)
raise AssertionError(f"Timed out: {description}")
raise AssertionError(f"Timed out after {seconds}s: {description}")
def cpu_idle_share(interval=3):
def sample():
values = list(map(int, adb("shell", "head", "-1", "/proc/stat").split()[1:]))
return values[3] + values[4], sum(values)
idle1, total1 = sample()
time.sleep(interval)
idle2, total2 = sample()
return (idle2 - idle1) / max(1, total2 - total1)
def settle():
# boot_completed fires long before package, launcher and dexopt work
# drains. Start the app on a quiet device instead of racing that work.
deadline = time.monotonic() + SETTLE_SECONDS
quiet = 0
idle = 0.0
while time.monotonic() < deadline and quiet < 2:
clear_foreign_dialogs()
idle = cpu_idle_share()
quiet = quiet + 1 if idle >= 0.6 else 0
RESULT["settle"] = f"{'quiet' if quiet >= 2 else 'still busy'} at {time.monotonic() - STARTED:.0f}s, {idle:.0%} idle"
step(f"settle: {RESULT['settle']}")
def ui_dump(label):
# Dump first, then read the file; never tap coordinates inferred from a
# screenshot, a previous Activity, or assumed phone dimensions.
adb("shell", "rm", "-f", "/sdcard/tglock-ui.xml")
adb("shell", "uiautomator", "dump", "/sdcard/tglock-ui.xml")
status = adb("shell", "uiautomator", "dump", "/sdcard/tglock-ui.xml", check=False)
if not adb("shell", "ls", "/sdcard/tglock-ui.xml", check=False).endswith("tglock-ui.xml"):
# On a loaded emulator UIAutomator intermittently reports "could not get
# idle state" and writes nothing. That is the tool, not the app.
raise subprocess.SubprocessError(f"uiautomator dump failed: {status}")
text = adb("shell", "cat", "/sdcard/tglock-ui.xml")
(EVIDENCE / f"{label}.xml").write_text(text, encoding="utf-8")
return ET.fromstring(text)
def label_node(tree, label):
def node_center(tree, matches):
for node in tree.iter("node"):
# Android aggregates the decorative arrow into the button's accessible
# text (observed in the API35 CI dump). Accept only that exact suffix.
names = (node.get("text", "").strip(), node.get("content-desc", "").strip())
if any(name in (label, f"{label} →") for name in names):
if matches(node):
bounds = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.get("bounds", ""))
if bounds:
x1, y1, x2, y2 = map(int, bounds.groups())
@@ -57,13 +138,53 @@ def label_node(tree, label):
return None
def tap_label(label, stage):
for attempt in range(3):
point = label_node(ui_dump(f"{stage}-{attempt}"), label)
if point is not None:
adb("shell", "input", "tap", str(point[0]), str(point[1]))
def label_node(tree, label):
# Android aggregates the decorative arrow into the button's accessible
# text (observed in the API35 CI dump). Accept only that exact suffix.
return node_center(tree, lambda node: any(
name in (label, f"{label} →")
for name in (node.get("text", "").strip(), node.get("content-desc", "").strip())
))
def find_label(label, stage):
clear_foreign_dialogs()
try:
return label_node(ui_dump(stage), label)
except (subprocess.SubprocessError, ET.ParseError) as error:
(EVIDENCE / f"{stage}.error.txt").write_text(str(error), encoding="utf-8")
return None
def press(label, stage):
"""Tap a visible WebView action until the page shows it was clicked.
The page re-renders every second. At one frame per second the touch down
and up can straddle that re-render, and Chromium then drops the click: the
button stays exactly as it was and the handler never runs. Callers still
assert the service and listener effect strictly afterwards.
"""
deadline = time.monotonic() + VISIBLE_SECONDS
taps = 0
last_tap = 0.0
attempt = 0
while time.monotonic() < deadline:
point = find_label(label, f"{stage}-{attempt}")
attempt += 1
if point is None and taps:
RESULT["taps"][stage] = taps
step(f"{label}: clicked after {taps} tap(s)")
return
if point is not None and (not taps or time.monotonic() - last_tap >= RETAP_AFTER_SECONDS):
if taps == MAX_TAPS:
raise AssertionError(f"{label} stayed unchanged after {taps} taps")
adb("shell", "input", "tap", str(point[0]), str(point[1]))
taps += 1
last_tap = time.monotonic()
step(f"{label}: tap {taps} at {point}")
time.sleep(1)
if taps:
raise AssertionError(f"{label} stayed unchanged {VISIBLE_SECONDS}s after {taps} tap(s)")
raise AssertionError(f"Visible action not found: {label}")
@@ -77,7 +198,7 @@ def proxy_ready():
# ADB forwards to emulator loopback. A real SOCKS5 greeting proves the
# Rust listener is serving, beyond just a notification being displayed.
try:
with socket.create_connection(("127.0.0.1", 11080), timeout=1) as peer:
with socket.create_connection(("127.0.0.1", 11080), timeout=3) as peer:
peer.sendall(bytes([5, 1, 0]))
return peer.recv(2) == bytes([5, 0])
except (OSError, TimeoutError):
@@ -85,7 +206,7 @@ def proxy_ready():
def launch():
adb("shell", "am", "start", "-W", "-n", COMPONENT)
adb("shell", "am", "start", "-W", "-n", COMPONENT, timeout=60)
wait_for("Activity resumed", lambda: any(
"ResumedActivity" in line and PACKAGE in line
for line in adb("shell", "dumpsys", "activity", "activities").splitlines()
@@ -96,17 +217,20 @@ def launch():
def main():
apks = sorted(Path(sys.argv[1]).rglob("*.apk"))
assert len(apks) == 1, f"Expected one x86_64 APK, got {len(apks)}"
adb("install", "-r", str(apks[0]), timeout=60)
adb("install", "-r", str(apks[0]), timeout=120)
adb("shell", "pm", "grant", PACKAGE, "android.permission.POST_NOTIFICATIONS")
adb("logcat", "-c")
adb("forward", "tcp:11080", "tcp:1080")
settle()
adb("logcat", "-c")
step("launch")
launch()
RESULT["launch"] = "passed"
deadline = time.monotonic() + 20
deadline = time.monotonic() + VISIBLE_SECONDS
tree = None
start_point = None
attempt = 0
while time.monotonic() < deadline:
clear_foreign_dialogs()
try:
tree = ui_dump(f"launched-{attempt}")
start_point = label_node(tree, "Включить защиту")
@@ -124,23 +248,25 @@ def main():
for node in tree.iter("node")
), "App Activity is resumed but its WebView is absent"
if start_point is None:
RESULT["lifecycle"] = "skipped: WebView Start not accessible after 20s"
print("::warning::Activity launch passed; lifecycle skipped because UIAutomator did not expose Start after 20s")
RESULT["lifecycle"] = f"skipped: WebView Start not accessible after {VISIBLE_SECONDS}s"
print(f"::warning::Activity launch passed; lifecycle skipped because UIAutomator did not expose Start after {VISIBLE_SECONDS}s")
return
RESULT["lifecycle"] = "failed: lifecycle assertions incomplete"
tap_label("Включить защиту", "before-start")
press("Включить защиту", "before-start")
wait_for("foreground service after Start", service_running)
wait_for("Rust SOCKS listener after Start", proxy_ready)
adb("shell", "input", "keyevent", "KEYCODE_HOME")
time.sleep(5)
clear_foreign_dialogs()
assert service_running() and proxy_ready(), "Proxy stopped after backgrounding"
step("ok: still serving after 5s in background")
launch()
tap_label("Выключить", "before-stop")
press("Выключить", "before-stop")
wait_for("foreground service after Stop", lambda: not service_running())
wait_for("Rust listener after Stop", lambda: not proxy_ready())
# Explicit restart and user force-stop, then relaunch. This tests the
# user-stop contract, not Android's automatic low-memory process eviction.
tap_label("Включить защиту", "before-restart")
press("Включить защиту", "before-restart")
wait_for("Rust listener after restart", proxy_ready)
adb("shell", "am", "force-stop", PACKAGE)
launch()
@@ -152,10 +278,21 @@ try:
main()
except Exception as error:
RESULT["failure"] = str(error)
for name, args in (("failure-windows.txt", ("dumpsys", "window", "windows")),
("failure-activities.txt", ("dumpsys", "activity", "activities"))):
try:
(EVIDENCE / name).write_text(adb("shell", *args, check=False), encoding="utf-8")
except subprocess.SubprocessError:
pass
try:
(EVIDENCE / "failure.png").write_bytes(subprocess.run(
["adb", "exec-out", "screencap", "-p"], capture_output=True, timeout=30).stdout)
except subprocess.SubprocessError:
pass
raise
finally:
try:
logs = adb("logcat", "-d", "-v", "threadtime")
logs = adb("logcat", "-d", "-v", "threadtime", timeout=60)
(EVIDENCE / "logcat.txt").write_text(logs, encoding="utf-8")
crashes = adb("logcat", "-b", "crash", "-d")
(EVIDENCE / "crash.txt").write_text(crashes, encoding="utf-8")
@@ -163,9 +300,9 @@ finally:
RESULT["launch"] = "failed: application crash, ANR, or missing process"
raise AssertionError("Application did not remain healthy")
finally:
(EVIDENCE / "result.json").write_text(json.dumps(RESULT, indent=2), encoding="utf-8")
(EVIDENCE / "result.json").write_text(json.dumps(RESULT, indent=2, ensure_ascii=False), encoding="utf-8")
summary = os.environ.get("GITHUB_STEP_SUMMARY")
if summary:
with open(summary, "a", encoding="utf-8") as report:
report.write("\nAndroid emulator smoke: " + json.dumps(RESULT) + "\n")
print(json.dumps(RESULT))
report.write("\nAndroid emulator smoke: " + json.dumps(RESULT, ensure_ascii=False) + "\n")
print(json.dumps(RESULT, ensure_ascii=False))
+30 -3
View File
@@ -795,9 +795,7 @@ async fn ws_tunnel(
});
if !received_payload || transport_error {
let reason = match frame {
Some(frame) => {
format!("WebSocket закрыт: {} {}", frame.code, frame.reason)
}
Some(frame) => describe_ws_close(frame.code.into(), &frame.reason),
None => "WebSocket закрыт без ответа Telegram".to_owned(),
};
return Err(tunnel_route_error(stats, &connected, dc, &reason));
@@ -938,6 +936,24 @@ async fn cdn_tcp_tunnel(
}
}
/// Текст для журнала, когда WebSocket закрыла другая сторона.
///
/// `1003 binary messages required` отвечает только старый скрипт Worker'а: с
/// compatibility date 2026-03-17 Cloudflare отдаёт бинарные кадры как Blob, и
/// такой скрипт рвал каждый туннель сразу после рукопожатия (by-sonic/tglock#61).
/// Обновление TGLock код в чужом Cloudflare-аккаунте не меняет, поэтому
/// пользователю надо сказать прямо, что делать.
fn describe_ws_close(code: u16, reason: &str) -> String {
let mut text = format!("WebSocket закрыт: {code} {reason}");
if code == 1003 && reason.contains("binary messages required") {
text.push_str(
" — скрипт Cloudflare Worker устарел: опубликуйте заново \
worker/tglock-worker.js из репозитория",
);
}
text
}
fn tunnel_route_error(
stats: &Stats,
connected: &crate::transport::ConnectedRoute,
@@ -969,6 +985,17 @@ mod tests {
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
use tokio_tungstenite::tungstenite::Message;
#[test]
fn an_outdated_worker_close_says_to_redeploy_it() {
let outdated = describe_ws_close(1003, "binary messages required");
assert!(outdated.starts_with("WebSocket закрыт: 1003 binary messages required"));
assert!(outdated.contains("worker/tglock-worker.js"));
assert_eq!(
describe_ws_close(1011, "Telegram socket failed"),
"WebSocket закрыт: 1011 Telegram socket failed"
);
}
/// Start the proxy on a kernel-assigned port.
///
/// Binding first and reading the port back removes the reserve-then-rebind
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "TGLock",
"version": "2.0.0-beta.15",
"version": "2.0.0-beta.16",
"identifier": "com.bysonic.tglock",
"mainBinaryName": "tglock",
"build": {
+1 -1
View File
@@ -451,7 +451,7 @@ async function copyDiagnostics(): Promise<void> {
// Public reports exclude proxy links, worker domains and free-form logs.
const report = {
application: "TGLock",
version: "2.0.0-beta.15",
version: "2.0.0-beta.16",
running: status.running,
activeConnections: status.activeConnections,
tunnels: status.tunnels,
+50 -8
View File
@@ -34,6 +34,32 @@ const CONNECT_TIMEOUT_MS = 3000;
const MAX_PENDING_BYTES = 1024 * 1024;
const MAX_PENDING_MESSAGES = 256;
// Размер бинарного кадра и способ достать его байты; null — текстовый кадр.
//
// С compatibility date 2026-03-17 бинарные кадры приходят как Blob, если
// binaryType не "arraybuffer". Свойство выставлено при accept(); Blob всё
// равно принимается — на случай рантайма, который его не учитывает. Раньше
// такой кадр закрывал туннель кодом 1003 сразу после рукопожатия (#61).
//
// Каждая ветка проверяет тип напрямую, чтобы проверка типов в редакторе
// Cloudflare не подчёркивала доступ к полям (by-sonic/tglock#66).
/** @param {unknown} data */
function binaryFrame(data) {
if (typeof Blob !== "undefined" && data instanceof Blob) {
const blob = data;
return { size: blob.size, read: async () => new Uint8Array(await blob.arrayBuffer()) };
}
if (data instanceof ArrayBuffer) {
const bytes = new Uint8Array(data);
return { size: bytes.byteLength, read: async () => bytes };
}
if (ArrayBuffer.isView(data)) {
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
return { size: bytes.byteLength, read: async () => bytes };
}
return null;
}
export default {
async fetch(request, env = {}) {
const url = new URL(request.url);
@@ -85,6 +111,7 @@ export default {
}
const [client, server] = Object.values(new WebSocketPair());
server.binaryType = "arraybuffer";
server.accept();
const writer = upstream.writable.getWriter();
@@ -113,31 +140,35 @@ export default {
let pending = Promise.resolve();
let pendingBytes = 0;
let pendingMessages = 0;
let uploaded = 0;
server.addEventListener("message", (event) => {
if (closed) return;
if (!(event.data instanceof ArrayBuffer) && !ArrayBuffer.isView(event.data)) {
const frame = binaryFrame(event.data);
if (!frame) {
shutdown(1003, "binary messages required");
return;
}
const chunk = event.data instanceof ArrayBuffer
? new Uint8Array(event.data)
: new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength);
if (pendingBytes + chunk.byteLength > MAX_PENDING_BYTES || pendingMessages >= MAX_PENDING_MESSAGES) {
const size = frame.size;
if (pendingBytes + size > MAX_PENDING_BYTES || pendingMessages >= MAX_PENDING_MESSAGES) {
shutdown(1009, "Telegram write queue full");
return;
}
pendingBytes += chunk.byteLength;
pendingBytes += size;
pendingMessages += 1;
pending = pending
.then(async () => {
if (closed) return;
const bytes = await frame.read();
await writer.ready;
if (!closed) await writer.write(chunk);
if (!closed) {
await writer.write(bytes);
uploaded += bytes.byteLength;
}
})
.catch(() => shutdown(1011, "Telegram write failed"))
.finally(() => {
pendingBytes -= chunk.byteLength;
pendingBytes -= size;
pendingMessages -= 1;
});
});
@@ -148,10 +179,12 @@ export default {
// Обратное направление: всё, что приходит от Telegram, уходит клиенту.
(async () => {
const reader = upstream.readable.getReader();
let downloaded = 0;
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
downloaded += value.byteLength;
server.send(value);
}
} catch {
@@ -159,6 +192,15 @@ export default {
} finally {
reader.releaseLock();
}
// Telegram закрыл TCP, не ответив ни байтом. Раньше это было обычное
// закрытие 1000 без причины, и в журнале клиента не было видно, кто и на
// каком шаге оборвал туннель (by-sonic/tglock#53). Строка уходит и в
// журнал Worker'а в панели Cloudflare.
if (!downloaded && !closed) {
console.warn(`Telegram ${destination} closed TCP before replying (client sent ${uploaded} bytes)`);
shutdown(1011, "Telegram closed before replying");
return;
}
shutdown();
})();
+38 -3
View File
@@ -20,9 +20,9 @@ async function fixture({ opened = Promise.resolve(), write, timeout = false } =
const closed = deferred();
const writes = [];
const events = new Map();
const state = { connects: 0, accepted: false, socketCloses: 0, sent: [], closes: [] };
const state = { connects: 0, accepted: false, socketCloses: 0, sent: [], closes: [], warnings: [] };
const server = {
accept() { state.accepted = true; },
accept() { state.accepted = true; state.binaryTypeOnAccept = this.binaryType; },
addEventListener(name, callback) { events.set(name, callback); },
close(code, reason) { state.closes.push({ code, reason }); },
send(chunk) { state.sent.push(Array.from(chunk)); },
@@ -41,7 +41,7 @@ async function fixture({ opened = Promise.resolve(), write, timeout = false } =
},
};
const context = vm.createContext({
URL, Uint8Array, ArrayBuffer, setTimeout: timeout ? (fn) => setTimeout(fn, 0) : setTimeout, clearTimeout,
URL, Uint8Array, ArrayBuffer, Blob, console: { warn: (line) => state.warnings.push(line) }, setTimeout: timeout ? (fn) => setTimeout(fn, 0) : setTimeout, clearTimeout,
Response: class {
constructor(body, options) { this.body = body; Object.assign(this, options); }
},
@@ -133,6 +133,27 @@ test("serialize binary writes, preserve bytes and view offsets", async () => {
f.event("close");
});
test("binary frames arrive as ArrayBuffer on new compatibility dates", async () => {
// With compatibility date 2026-03-17+ Cloudflare delivers Blob unless
// binaryType is set before accept(); the Worker then closed every tunnel
// with 1003 right after the handshake (#61).
const f = await fixture();
await f.fetch();
assert.equal(f.state.binaryTypeOnAccept, "arraybuffer");
f.event("close");
});
test("Blob frames are still relayed in order, never closed as text", async () => {
const f = await fixture();
await f.fetch();
f.message(new Blob([new Uint8Array([7, 8])]));
f.message(new Uint8Array([9]).buffer);
for (let i = 0; i < 5; i++) await tick();
assert.deepEqual(f.writes, [[7, 8], [9]]);
assert.deepEqual(f.state.closes, []);
f.event("close");
});
test("overflow closes socket and discards queued writes", async () => {
const f = await fixture();
await f.fetch();
@@ -187,6 +208,20 @@ test("Telegram bytes reach WebSocket unchanged and EOF closes both directions",
assert.equal(f.state.socketCloses, 1);
});
test("Telegram closing TCP before any reply is named, not a silent 1000", async () => {
// A tunnel that never carried a byte from Telegram used to end as a plain
// 1000 close, which hid where it broke (#53). The reason also reaches the
// Worker log in the Cloudflare dashboard.
const f = await fixture();
await f.fetch();
f.message(new Uint8Array([1, 2, 3]).buffer);
await tick();
f.eof();
for (let i = 0; i < 5; i++) await tick();
assert.deepEqual(f.state.closes[0], { code: 1011, reason: "Telegram closed before replying" });
assert.match(f.state.warnings[0], /149\.154\.167\.51 closed TCP before replying \(client sent 3 bytes\)/);
});
test("CDN worker keeps the requested DC203 destination", async () => {
const f = await fixture();
await f.fetch("/apiws?dst=91.105.192.100&dc=203");