Stream delay results as candidates complete

This commit is contained in:
Shujakuin
2026-07-20 23:40:54 +08:00
parent 8094aa7233
commit 3139798e38
5 changed files with 114 additions and 16 deletions
+2 -1
View File
@@ -94,7 +94,8 @@ SOPS 文件、运行时 env、SSH key 或 mihomo 配置。
- `Enter`:仅在目标在线时进入其 controller 详情。 - `Enter`:仅在目标在线时进入其 controller 详情。
- `r`:刷新连通性状态。 - `r`:刷新连通性状态。
- `t`:对当前 Selector 组的候选节点做 mihomo delay 探测;默认使用 - `t`:对当前 Selector 组的候选节点做 mihomo delay 探测;默认使用
`https://www.gstatic.com/generate_204`,最多并发 4 个。 `https://www.gstatic.com/generate_204`,最多并发 4 个。每个 candidate 完成后会立即
更新 delay 和当前推荐,慢节点或超时节点不会阻塞先完成的结果显示。
- `s`:对当前选择的候选节点发起运行态切换。TUI 会显示机器、组、旧节点和新节点, - `s`:对当前选择的候选节点发起运行态切换。TUI 会显示机器、组、旧节点和新节点,
必须按 `y` 二次确认;成功后立即读取 controller 验证当前节点。 必须按 `y` 二次确认;成功后立即读取 controller 验证当前节点。
+79 -13
View File
@@ -8,7 +8,7 @@ import json
import os import os
from pathlib import Path from pathlib import Path
import sys import sys
from typing import Any from typing import Any, Iterator
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
@@ -287,7 +287,7 @@ def recommend_fastest(delay_results: dict[str, tuple[int | None, str]]) -> str |
return min(successful)[1] if successful else None return min(successful)[1] if successful else None
def measure_candidates( def iter_measure_candidates(
client: ProxyMonitorClient, client: ProxyMonitorClient,
target_name: str, target_name: str,
candidates: list[str], candidates: list[str],
@@ -295,9 +295,9 @@ def measure_candidates(
test_url: str, test_url: str,
timeout_ms: int, timeout_ms: int,
concurrency: int, concurrency: int,
) -> dict[str, tuple[int | None, str]]: ) -> Iterator[tuple[str, tuple[int | None, str]]]:
"""Yield each candidate result as soon as its controller request finishes."""
unique_candidates = list(dict.fromkeys(candidate for candidate in candidates if candidate)) unique_candidates = list(dict.fromkeys(candidate for candidate in candidates if candidate))
results: dict[str, tuple[int | None, str]] = {}
def measure(candidate: str) -> tuple[int | None, str]: def measure(candidate: str) -> tuple[int | None, str]:
try: try:
@@ -316,9 +316,32 @@ def measure_candidates(
for future in as_completed(futures): for future in as_completed(futures):
candidate = futures[future] candidate = futures[future]
try: try:
results[candidate] = future.result() yield candidate, future.result()
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
results[candidate] = (None, str(exc)) yield candidate, (None, str(exc))
def measure_candidates(
client: ProxyMonitorClient,
target_name: str,
candidates: list[str],
*,
test_url: str,
timeout_ms: int,
concurrency: int,
) -> dict[str, tuple[int | None, str]]:
"""Collect all candidate results for non-interactive callers."""
unique_candidates = list(dict.fromkeys(candidate for candidate in candidates if candidate))
results = dict(
iter_measure_candidates(
client,
target_name,
unique_candidates,
test_url=test_url,
timeout_ms=timeout_ms,
concurrency=concurrency,
)
)
return {candidate: results.get(candidate, (None, "not run")) for candidate in unique_candidates} return {candidate: results.get(candidate, (None, "not run")) for candidate in unique_candidates}
@@ -431,6 +454,7 @@ def run_textual(
self.target = target self.target = target
self.groups_by_name: dict[str, dict[str, Any]] = {} self.groups_by_name: dict[str, dict[str, Any]] = {}
self.delay_results: dict[str, dict[str, tuple[int | None, str]]] = {} self.delay_results: dict[str, dict[str, tuple[int | None, str]]] = {}
self.delay_test_runs: dict[str, int] = {}
self.selected_group = "" self.selected_group = ""
self.selected_node = "" self.selected_node = ""
self.pending_switch: tuple[str, str] | None = None self.pending_switch: tuple[str, str] | None = None
@@ -537,27 +561,69 @@ def run_textual(
if not group: if not group:
self._set_operation("choose a Selector group first") self._set_operation("choose a Selector group first")
return return
self._set_operation(f"testing {len(as_list(group.get('candidates')))} candidates...") group_name = str(group["name"])
self.test_selected_group(str(group["name"]), list(as_list(group.get("candidates")))) candidates = list(dict.fromkeys(str(candidate) for candidate in as_list(group.get("candidates")) if candidate))
run_id = self.delay_test_runs.get(group_name, 0) + 1
self.delay_test_runs[group_name] = run_id
self.delay_results[group_name] = {}
self._show_group(group_name)
self._set_operation(
f"testing {len(candidates)} candidates; each completed delay appears immediately..."
)
self.test_selected_group(group_name, candidates, run_id)
@work(thread=True, exclusive=True) @work(thread=True, exclusive=True)
def test_selected_group(self, group_name: str, candidates: list[str]) -> None: def test_selected_group(self, group_name: str, candidates: list[str], run_id: int) -> None:
results = measure_candidates( total = len(candidates)
for candidate, result in iter_measure_candidates(
client, client,
str(self.target["name"]), str(self.target["name"]),
candidates, candidates,
test_url=test_url, test_url=test_url,
timeout_ms=test_timeout_ms, timeout_ms=test_timeout_ms,
concurrency=delay_concurrency, concurrency=delay_concurrency,
):
self.app.call_from_thread(
self._record_delay_result,
group_name,
run_id,
candidate,
result,
total,
)
self.app.call_from_thread(self._finish_delay_test, group_name, run_id)
def _record_delay_result(
self,
group_name: str,
run_id: int,
candidate: str,
result: tuple[int | None, str],
total: int,
) -> None:
if self.delay_test_runs.get(group_name) != run_id:
return
results = self.delay_results.setdefault(group_name, {})
results[candidate] = result
completed = len(results)
recommended = recommend_fastest(results)
if group_name == self.selected_group:
self._show_group(group_name)
delay, detail = result
latest = f"{delay} ms" if delay is not None else detail
self._set_operation(
f"testing {completed}/{total}; {candidate}: {latest}; "
f"best so far: {recommended or 'none'}"
) )
self.app.call_from_thread(self._finish_delay_test, group_name, results)
def _finish_delay_test( def _finish_delay_test(
self, self,
group_name: str, group_name: str,
results: dict[str, tuple[int | None, str]], run_id: int,
) -> None: ) -> None:
self.delay_results[group_name] = results if self.delay_test_runs.get(group_name) != run_id:
return
results = self.delay_results.get(group_name, {})
recommended = recommend_fastest(results) recommended = recommend_fastest(results)
if group_name == self.selected_group: if group_name == self.selected_group:
self._show_group(group_name) self._show_group(group_name)
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "proxy-port-monitor-tui" name = "proxy-port-monitor-tui"
version = "0.1.1" version = "0.1.2"
description = "Terminal UI for proxy-port-monitor and mihomo selector groups" description = "Terminal UI for proxy-port-monitor and mihomo selector groups"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+31
View File
@@ -163,6 +163,37 @@ class ProxyMonitorTUITests(unittest.TestCase):
) )
self.assertEqual(recommended, "fast") self.assertEqual(recommended, "fast")
def test_delay_results_are_yielded_before_slower_candidates_finish(self) -> None:
class ControlledClient:
def __init__(self) -> None:
self.slow_started = threading.Event()
self.release_slow = threading.Event()
def measure_delay(self, _target: str, candidate: str, **_kwargs: object) -> int:
if candidate == "slow":
self.slow_started.set()
self.release_slow.wait(timeout=2)
return 300
self.slow_started.wait(timeout=2)
return 42
client = ControlledClient()
stream = self.mod.iter_measure_candidates(
client,
"pc",
["slow", "fast"],
test_url="https://www.gstatic.com/generate_204",
timeout_ms=5000,
concurrency=2,
)
try:
candidate, result = next(stream)
self.assertEqual(candidate, "fast")
self.assertEqual(result, (42, "42 ms"))
finally:
client.release_slow.set()
self.assertEqual(list(stream), [("slow", (300, "300 ms"))])
def test_machine_rows_preserve_monitor_order_and_status(self) -> None: def test_machine_rows_preserve_monitor_order_and_status(self) -> None:
rows = self.mod.normalize_machine_rows( rows = self.mod.normalize_machine_rows(
{ {
Generated
+1 -1
View File
@@ -63,7 +63,7 @@ wheels = [
[[package]] [[package]]
name = "proxy-port-monitor-tui" name = "proxy-port-monitor-tui"
version = "0.1.1" version = "0.1.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "textual" }, { name = "textual" },