Upload 12 files
Browse files- colab_tunnel/_diagnostics.py +10 -6
- colab_tunnel/_tunnels.py +9 -6
- colab_tunnel/_utils.py +80 -44
colab_tunnel/_diagnostics.py
CHANGED
|
@@ -84,15 +84,16 @@ def _stop_test_server(server_bin: Path) -> None:
|
|
| 84 |
def _check_url_latency(
|
| 85 |
url: str,
|
| 86 |
timeout: float = 10.0,
|
| 87 |
-
warmup_retries: int =
|
| 88 |
warmup_delay: float = 5.0,
|
| 89 |
) -> Optional[float]:
|
| 90 |
"""
|
| 91 |
Проверяет доступность публичного URL и возвращает RTT.
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
| 96 |
|
| 97 |
Returns:
|
| 98 |
RTT в секундах, или None если URL недоступен после всех попыток.
|
|
@@ -102,7 +103,9 @@ def _check_url_latency(
|
|
| 102 |
|
| 103 |
for attempt in range(warmup_retries):
|
| 104 |
if attempt > 0:
|
| 105 |
-
logger.debug(
|
|
|
|
|
|
|
| 106 |
time.sleep(warmup_delay)
|
| 107 |
try:
|
| 108 |
t = time.time()
|
|
@@ -112,11 +115,12 @@ def _check_url_latency(
|
|
| 112 |
return elapsed
|
| 113 |
logger.debug(f'HTTP {resp.status_code} от {clean_url}')
|
| 114 |
except Exception as e:
|
| 115 |
-
logger.debug(f'Попытка {attempt + 1}
|
| 116 |
|
| 117 |
return None
|
| 118 |
|
| 119 |
|
|
|
|
| 120 |
def _print_report(results: list[TunnelBenchmarkResult]) -> None:
|
| 121 |
"""Выводит форматированную таблицу результатов."""
|
| 122 |
W = {'name': 14, 'status': 10, 'tunnel': 14, 'latency': 12}
|
|
|
|
| 84 |
def _check_url_latency(
|
| 85 |
url: str,
|
| 86 |
timeout: float = 10.0,
|
| 87 |
+
warmup_retries: int = 3,
|
| 88 |
warmup_delay: float = 5.0,
|
| 89 |
) -> Optional[float]:
|
| 90 |
"""
|
| 91 |
Проверяет доступность публичного URL и возвращает RTT.
|
| 92 |
|
| 93 |
+
Некоторые провайдеры (cloudflared, tunnelite) анонсируют URL раньше,
|
| 94 |
+
чем маршрут фактически распространяется по их сети. cloudflared
|
| 95 |
+
обычно требует 5–15 секунд после получения URL.
|
| 96 |
+
Делаем несколько попыток с паузой между ними.
|
| 97 |
|
| 98 |
Returns:
|
| 99 |
RTT в секундах, или None если URL недоступен после всех попыток.
|
|
|
|
| 103 |
|
| 104 |
for attempt in range(warmup_retries):
|
| 105 |
if attempt > 0:
|
| 106 |
+
logger.debug(
|
| 107 |
+
f'Повтор проверки доступности {attempt + 1}/{warmup_retries}: {clean_url}'
|
| 108 |
+
)
|
| 109 |
time.sleep(warmup_delay)
|
| 110 |
try:
|
| 111 |
t = time.time()
|
|
|
|
| 115 |
return elapsed
|
| 116 |
logger.debug(f'HTTP {resp.status_code} от {clean_url}')
|
| 117 |
except Exception as e:
|
| 118 |
+
logger.debug(f'Попытка {attempt + 1}: {e}')
|
| 119 |
|
| 120 |
return None
|
| 121 |
|
| 122 |
|
| 123 |
+
|
| 124 |
def _print_report(results: list[TunnelBenchmarkResult]) -> None:
|
| 125 |
"""Выводит форматированную таблицу результатов."""
|
| 126 |
W = {'name': 14, 'status': 10, 'tunnel': 14, 'latency': 12}
|
colab_tunnel/_tunnels.py
CHANGED
|
@@ -37,6 +37,7 @@ def get_revproxy_url(
|
|
| 37 |
write_link: bool = False,
|
| 38 |
stdin_input: str | None = None,
|
| 39 |
extra_env: dict[str, str] | None = None,
|
|
|
|
| 40 |
) -> str:
|
| 41 |
"""
|
| 42 |
Универсальный запуск бинарного туннельного провайдера.
|
|
@@ -56,6 +57,7 @@ def get_revproxy_url(
|
|
| 56 |
stdin_input: Строка для отправки в stdin процесса.
|
| 57 |
extra_env: Дополнительные переменные окружения для процесса.
|
| 58 |
Накладываются поверх os.environ (не заменяют его).
|
|
|
|
| 59 |
|
| 60 |
Returns:
|
| 61 |
Публичный URL туннеля.
|
|
@@ -119,6 +121,7 @@ def get_revproxy_url(
|
|
| 119 |
url_pattern=url_pattern,
|
| 120 |
timeout=timeout,
|
| 121 |
read_from_stderr=read_from_stderr,
|
|
|
|
| 122 |
)
|
| 123 |
except RuntimeError:
|
| 124 |
kill_process_by_name(bin_path.name)
|
|
@@ -315,7 +318,6 @@ def get_beeceptor_url(port: int) -> str:
|
|
| 315 |
|
| 316 |
@tunnel_provider('bore')
|
| 317 |
def get_bore_url(port: int) -> str:
|
| 318 |
-
# Для актуальной версии запрашиваем GitHub API, только если бинарника ещё нет
|
| 319 |
bin_url = 'https://github.com/ekzhang/bore/releases/download/v0.6.0/bore-v0.6.0-x86_64-unknown-linux-musl.tar.gz'
|
| 320 |
if not bore_bin.exists():
|
| 321 |
try:
|
|
@@ -332,16 +334,17 @@ def get_bore_url(port: int) -> str:
|
|
| 332 |
need_unpack=True,
|
| 333 |
bin_path=bore_bin,
|
| 334 |
start_commands=[str(bore_bin), 'local', str(port), '--to', 'bore.pub'],
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
| 336 |
url_pattern=r'bore\.pub:\d+',
|
| 337 |
-
timeout=
|
| 338 |
-
# Bore использует Rust-крейт `tracing`. Без RUST_LOG=info строка
|
| 339 |
-
# "listening at bore.pub:XXXXX" (INFO-уровень) не выводится совсем.
|
| 340 |
-
extra_env={'RUST_LOG': 'info'},
|
| 341 |
)
|
| 342 |
return f'http://{raw}/'
|
| 343 |
|
| 344 |
|
|
|
|
| 345 |
@tunnel_provider('boredigital')
|
| 346 |
def get_boredigital_url(port: int) -> str:
|
| 347 |
return get_revproxy_url(
|
|
|
|
| 37 |
write_link: bool = False,
|
| 38 |
stdin_input: str | None = None,
|
| 39 |
extra_env: dict[str, str] | None = None,
|
| 40 |
+
read_both_streams: bool = False,
|
| 41 |
) -> str:
|
| 42 |
"""
|
| 43 |
Универсальный запуск бинарного туннельного провайдера.
|
|
|
|
| 57 |
stdin_input: Строка для отправки в stdin процесса.
|
| 58 |
extra_env: Дополнительные переменные окружения для процесса.
|
| 59 |
Накладываются поверх os.environ (не заменяют его).
|
| 60 |
+
read_both_streams: Читать ли оба потока.
|
| 61 |
|
| 62 |
Returns:
|
| 63 |
Публичный URL туннеля.
|
|
|
|
| 121 |
url_pattern=url_pattern,
|
| 122 |
timeout=timeout,
|
| 123 |
read_from_stderr=read_from_stderr,
|
| 124 |
+
read_both_streams=read_both_streams, # ← передаём дальше
|
| 125 |
)
|
| 126 |
except RuntimeError:
|
| 127 |
kill_process_by_name(bin_path.name)
|
|
|
|
| 318 |
|
| 319 |
@tunnel_provider('bore')
|
| 320 |
def get_bore_url(port: int) -> str:
|
|
|
|
| 321 |
bin_url = 'https://github.com/ekzhang/bore/releases/download/v0.6.0/bore-v0.6.0-x86_64-unknown-linux-musl.tar.gz'
|
| 322 |
if not bore_bin.exists():
|
| 323 |
try:
|
|
|
|
| 334 |
need_unpack=True,
|
| 335 |
bin_path=bore_bin,
|
| 336 |
start_commands=[str(bore_bin), 'local', str(port), '--to', 'bore.pub'],
|
| 337 |
+
# read_both_streams: bore пишет в stderr через tracing-subscriber,
|
| 338 |
+
# но читаем оба потока — на случай если конкретная сборка пишет в stdout
|
| 339 |
+
read_from_stderr=False,
|
| 340 |
+
read_both_streams=True,
|
| 341 |
url_pattern=r'bore\.pub:\d+',
|
| 342 |
+
timeout=20.0,
|
|
|
|
|
|
|
|
|
|
| 343 |
)
|
| 344 |
return f'http://{raw}/'
|
| 345 |
|
| 346 |
|
| 347 |
+
|
| 348 |
@tunnel_provider('boredigital')
|
| 349 |
def get_boredigital_url(port: int) -> str:
|
| 350 |
return get_revproxy_url(
|
colab_tunnel/_utils.py
CHANGED
|
@@ -192,84 +192,120 @@ def read_until_pattern(
|
|
| 192 |
read_both_streams: bool = False,
|
| 193 |
) -> tuple[str, str]:
|
| 194 |
"""
|
| 195 |
-
|
| 196 |
|
| 197 |
-
Использует
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
Args:
|
| 201 |
process: Запущенный Popen с открытыми потоками PIPE.
|
| 202 |
-
url_pattern:
|
| 203 |
-
timeout: Максимальное время ожидания
|
| 204 |
read_from_stderr: Читать из stderr (иначе stdout).
|
| 205 |
-
read_both_streams: Читать из обоих потоков (для SSH).
|
| 206 |
|
| 207 |
Returns:
|
| 208 |
-
Кортеж (matched_url,
|
| 209 |
|
| 210 |
Raises:
|
| 211 |
-
RuntimeError: Паттерн не найден за
|
| 212 |
"""
|
|
|
|
|
|
|
| 213 |
if read_both_streams:
|
| 214 |
-
|
| 215 |
elif read_from_stderr:
|
| 216 |
-
|
| 217 |
else:
|
| 218 |
-
|
| 219 |
|
| 220 |
-
if not
|
| 221 |
raise RuntimeError('Потоки вывода процесса недоступны.')
|
| 222 |
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
raw = getattr(stream, 'raw', stream)
|
| 226 |
try:
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
except Exception:
|
| 229 |
pass
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
buffer = b''
|
| 233 |
deadline = time.time() + timeout
|
|
|
|
| 234 |
|
| 235 |
while time.time() < deadline:
|
|
|
|
| 236 |
try:
|
| 237 |
-
|
| 238 |
-
except
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
except (BlockingIOError, ValueError):
|
| 247 |
-
pass
|
| 248 |
-
except Exception:
|
| 249 |
-
pass
|
| 250 |
|
|
|
|
| 251 |
text = buffer.decode('utf-8', errors='ignore')
|
| 252 |
match = re.search(url_pattern, text)
|
| 253 |
if match:
|
|
|
|
| 254 |
return match.group(), text
|
| 255 |
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
remaining = raw.read(65536)
|
| 261 |
-
if remaining:
|
| 262 |
-
buffer += remaining
|
| 263 |
-
except Exception:
|
| 264 |
-
pass
|
| 265 |
-
text = buffer.decode('utf-8', errors='ignore')
|
| 266 |
-
match = re.search(url_pattern, text)
|
| 267 |
-
if match:
|
| 268 |
-
return match.group(), text
|
| 269 |
-
break
|
| 270 |
|
| 271 |
-
output_str = buffer.decode('utf-8', errors='ignore')
|
| 272 |
-
raise RuntimeError(f'URL не найден за {timeout} сек. Вывод процесса:\n{output_str}')
|
| 273 |
|
| 274 |
|
| 275 |
def move_path(old_path: Path | str, new_path: Path | str) -> None:
|
|
|
|
| 192 |
read_both_streams: bool = False,
|
| 193 |
) -> tuple[str, str]:
|
| 194 |
"""
|
| 195 |
+
Читает вывод процесса с таймаутом, ища URL-паттерн.
|
| 196 |
|
| 197 |
+
Использует блокирующие read() в daemon-потоках вместо select() +
|
| 198 |
+
неблокирующих read(). select()-подход даёт гонку при быстрых коротких
|
| 199 |
+
записях (< 1мс): select сигнализирует «readable», но к моменту вызова
|
| 200 |
+
read() данные ещё не попали в буфер пайпа — read возвращает пусто.
|
| 201 |
+
Блокирующий read в потоке этой гонки не имеет: он просто ждёт данных.
|
| 202 |
+
|
| 203 |
+
После нахождения URL потоки автоматически переходят в режим дренирования:
|
| 204 |
+
продолжают читать и отбрасывать данные, предотвращая переполнение
|
| 205 |
+
системного буфера пайпа (обычно 64 КБ) за время жизни туннеля.
|
| 206 |
|
| 207 |
Args:
|
| 208 |
process: Запущенный Popen с открытыми потоками PIPE.
|
| 209 |
+
url_pattern: Regex для поиска URL.
|
| 210 |
+
timeout: Максимальное время ожидания (сек).
|
| 211 |
read_from_stderr: Читать из stderr (иначе stdout).
|
| 212 |
+
read_both_streams: Читать из обоих потоков одновременно (для SSH).
|
| 213 |
|
| 214 |
Returns:
|
| 215 |
+
Кортеж (matched_url, full_output_so_far).
|
| 216 |
|
| 217 |
Raises:
|
| 218 |
+
RuntimeError: Паттерн не найден за timeout секунд.
|
| 219 |
"""
|
| 220 |
+
import queue as _queue
|
| 221 |
+
|
| 222 |
if read_both_streams:
|
| 223 |
+
active = [s for s in (process.stdout, process.stderr) if s]
|
| 224 |
elif read_from_stderr:
|
| 225 |
+
active = [process.stderr] if process.stderr else []
|
| 226 |
else:
|
| 227 |
+
active = [process.stdout] if process.stdout else []
|
| 228 |
|
| 229 |
+
if not active:
|
| 230 |
raise RuntimeError('Потоки вывода процесса недоступны.')
|
| 231 |
|
| 232 |
+
q: _queue.Queue[bytes | None] = _queue.Queue()
|
| 233 |
+
drain_mode = threading.Event()
|
| 234 |
+
|
| 235 |
+
def _reader(stream) -> None:
|
| 236 |
+
"""
|
| 237 |
+
Блокирующий читатель в daemon-потоке.
|
| 238 |
+
Пока URL не найден — пушит чанки в очередь.
|
| 239 |
+
После установки drain_mode — читает и отбрасывает (дренирование).
|
| 240 |
+
"""
|
| 241 |
raw = getattr(stream, 'raw', stream)
|
| 242 |
try:
|
| 243 |
+
# Явно возвращаем в блокирующий режим на случай,
|
| 244 |
+
# если где-то раньше FD был переведён в неблокирующий
|
| 245 |
+
os.set_blocking(raw.fileno(), True)
|
| 246 |
+
except Exception:
|
| 247 |
+
pass
|
| 248 |
+
try:
|
| 249 |
+
while True:
|
| 250 |
+
chunk = raw.read(4096)
|
| 251 |
+
if not chunk: # EOF — процесс закрыл поток
|
| 252 |
+
break
|
| 253 |
+
if drain_mode.is_set():
|
| 254 |
+
pass # Дренирование: читаем и отбрасываем
|
| 255 |
+
else:
|
| 256 |
+
q.put(chunk) # Ищем URL: передаём в очередь
|
| 257 |
except Exception:
|
| 258 |
pass
|
| 259 |
+
finally:
|
| 260 |
+
# Сигнал EOF только если мы всё ещё ищем URL
|
| 261 |
+
if not drain_mode.is_set():
|
| 262 |
+
q.put(None)
|
| 263 |
+
|
| 264 |
+
for stream in active:
|
| 265 |
+
t = threading.Thread(target=_reader, args=(stream,), daemon=True)
|
| 266 |
+
t.start()
|
| 267 |
|
| 268 |
buffer = b''
|
| 269 |
deadline = time.time() + timeout
|
| 270 |
+
n_sentinels = 0
|
| 271 |
|
| 272 |
while time.time() < deadline:
|
| 273 |
+
remaining = max(0.01, deadline - time.time())
|
| 274 |
try:
|
| 275 |
+
item = q.get(timeout=min(remaining, 0.5))
|
| 276 |
+
except _queue.Empty:
|
| 277 |
+
if process.poll() is not None:
|
| 278 |
+
# Процесс завершился — дочитываем остатки из очереди
|
| 279 |
+
while True:
|
| 280 |
+
try:
|
| 281 |
+
item = q.get_nowait()
|
| 282 |
+
except _queue.Empty:
|
| 283 |
+
break
|
| 284 |
+
if item is None:
|
| 285 |
+
n_sentinels += 1
|
| 286 |
+
else:
|
| 287 |
+
buffer += item
|
| 288 |
+
break
|
| 289 |
+
continue
|
| 290 |
|
| 291 |
+
if item is None:
|
| 292 |
+
n_sentinels += 1
|
| 293 |
+
if n_sentinels >= len(active):
|
| 294 |
+
break # Все читатели достигли EOF
|
| 295 |
+
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
|
| 297 |
+
buffer += item
|
| 298 |
text = buffer.decode('utf-8', errors='ignore')
|
| 299 |
match = re.search(url_pattern, text)
|
| 300 |
if match:
|
| 301 |
+
drain_mode.set() # Переключаем потоки в режим дренирования
|
| 302 |
return match.group(), text
|
| 303 |
|
| 304 |
+
raise RuntimeError(
|
| 305 |
+
f'URL не найден за {timeout} сек. Вывод процесса:\n'
|
| 306 |
+
+ buffer.decode('utf-8', errors='ignore')
|
| 307 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
|
|
|
|
|
|
|
| 309 |
|
| 310 |
|
| 311 |
def move_path(old_path: Path | str, new_path: Path | str) -> None:
|