Add Herdr update and live handoff menu actions
The Ctrl+A h command menu gains two host-scoped actions: a plain `herdr update` that streams updater output into the popup, and an experimental update-and-hot-restart flow that verifies a live server handoff and reconnects the originating Hub tab once the popup closes. Remote updates use the remote host's own proxy settings. A standalone helper reads literal proxy assignments from shell profiles and running Clash/Mihomo listeners without executing startup files; Windows reads process, user and machine proxy variables through PowerShell.
This commit is contained in:
@@ -110,7 +110,7 @@ Hub 使用独立 tmux socket `herdr-hub`,不会接管普通 tmux server。外
|
||||
### Fork 当前 Agent 到新 Tab
|
||||
|
||||
按 **Ctrl+A h** 打开独立的 Herdr 命令菜单,选择“Fork 当前会话到新 Tab”,
|
||||
按 Enter 或单击执行,Esc 关闭。菜单只显示当前 Agent、Workspace、主操作和必要的状态信息。
|
||||
按 Enter 或单击执行,Esc 关闭。菜单显示当前 Agent、Workspace、Fork 和更新操作及必要的状态信息。
|
||||
操作正在执行时需等待结果后再关闭,以免将关闭弹窗误认为取消已经发送的请求。
|
||||
Sessions 弹窗仍使用 `Ctrl+A g`;两个菜单都使用 Hub 外层的 `Ctrl+A` 前缀。
|
||||
快捷键集中定义在 `herdr_hub/control.py` 的 `CONTROL_KEY` 常量中。
|
||||
@@ -124,6 +124,9 @@ Codex 执行 `codex fork UUID`;Claude 执行 `claude --resume SESSION_ID --for
|
||||
支持本地 default / named session,以及通过 SSH 控制的远端 native / Windows SSH endpoint。
|
||||
控制目标取自触发菜单的 Hub window metadata,不使用 popup 继承的 Herdr socket 或 pane 环境变量。
|
||||
目标必须运行 Herdr 0.9+,并安装对应 Agent;当前 pane 必须已报告可用的 Agent session ID。
|
||||
Fork 显示灰色时,菜单会说明原因;按 `Ctrl+R` 可重新读取当前 Agent 信息。
|
||||
若提示尚未上报会话 ID,在该主机检查 `herdr integration status`,确认对应 Agent 的集成已加载。
|
||||
识别出 Codex / Claude 并不代表已经取得 conversation ID;Hub 不会根据目录猜测会话。
|
||||
远端控制使用非交互 SSH(需要已信任的主机密钥和可用认证);Windows 需要 PowerShell。
|
||||
启动 blocked、失败、超时或响应不完整时,菜单保留错误信息,**不会自动关闭任何 Tab**。
|
||||
超时并不代表远端没有创建或启动成功,请先检查提示中的新 Tab,再重新打开菜单重试。
|
||||
@@ -132,6 +135,54 @@ CLI 入口是 `herdr-hub control`。弹窗打开时会锁定 Hub 当前激活的
|
||||
再从其 Endpoint 对应的 Herdr server 读取当前页面的 Agent conversation。
|
||||
更新后运行 `herdr-hub refresh` 配置现有 Hub 的快捷键。`herdr-hub doctor` 会显示入口和版本要求。
|
||||
|
||||
### 从命令菜单更新 Herdr
|
||||
|
||||
需要让运行中的服务器也使用新版本时,按 **Ctrl+A h → 更新并热重启 Herdr**。
|
||||
此操作先更新当前 Tab 所在主机的 Herdr,再对当前 Session 执行实验性的 live handoff,
|
||||
尝试保留 Agent、shell 和其他 pane 进程。它会核对服务器版本;确认使用更新后的版本后,
|
||||
自动关闭菜单并重新连接打开菜单时的那个 Hub Tab。即使此前已安装新版本、服务器仍是旧版,
|
||||
也会尝试热切换。该 Session 内的所有 Workspace/Tab 共用服务器,因此都会随之切换。
|
||||
|
||||
更新阶段使用 `herdr --session NAME update --handoff`,兼容 0.8.x 在非交互 SSH 下的更新要求。
|
||||
原生更新已经尝试热切换却未切换成功时,不会再次发送请求;仅在安装文件未变化、旧服务器仍运行时补做热切换。
|
||||
|
||||
热切换适用于支持该能力的 Linux/macOS Herdr 服务器;远端还需有 `python3`。
|
||||
Windows 不支持此入口,可以使用下面的普通更新。失败、超时、服务器不支持或未确认新版本时,
|
||||
菜单保留诊断,不自动执行普通重启,也不重试热切换。连接中断时操作可能已经完成,请先检查
|
||||
目标 Session。普通重启会结束 pane 进程;缺少会话 ID 的 Agent 不能保证自动恢复。
|
||||
|
||||
按 **Ctrl+A h**,选择“更新 Herdr”,按 Enter 或单击执行。更新目标跟随打开菜单时的
|
||||
当前 Hub Tab:本地 Session 更新本机,远端 Session 通过 SSH 更新对应主机(包括 Windows)。
|
||||
菜单会显示目标主机;更新作用于该主机的 Herdr 安装,而非单个 Session。当前 pane 没有
|
||||
Agent 或无法读取 Agent 信息时,仍可使用更新操作。
|
||||
|
||||
该操作执行 `herdr update`,沿用目标主机已有的更新渠道,不传入 `--handoff`。
|
||||
它适用于 Herdr 自带安装器管理的安装;Homebrew、mise、Nix 等安装需使用对应包管理器更新。
|
||||
这个普通更新入口不自动重启 Session 或重新连接客户端。完成后可关闭菜单,按 `Ctrl+A R` 重新连接;
|
||||
服务器端新功能可能仍需另行重启服务器。兼容的服务器会继续运行;旧版本升级若要求停止
|
||||
Session 或交互确认,本菜单不会代为确认,请根据命令输出在终端处理。
|
||||
|
||||
更新在后台执行,实时显示命令输出,最长等待 5 分钟。小窗口自动使用紧凑布局,保证结果区可见。
|
||||
下载器没有新输出时,菜单显示已执行时间和无新输出时间。
|
||||
读取 Agent 信息期间也可选择更新。结果留在菜单内,可用 Page Up/Down 翻阅,Esc 关闭。
|
||||
执行期间需等待结果;同一次菜单内更新只能执行一次,失败或超时也不会自动重试。
|
||||
超时或 SSH 断开不代表目标主机已取消更新,请检查目标状态后再重新打开菜单。
|
||||
|
||||
远端更新使用远端自己的代理。优先保留 SSH 进程已有的 `http_proxy`、`https_proxy`、
|
||||
`all_proxy`、`no_proxy`(兼容大写);Linux/macOS 还通过远端 `python3` 读取
|
||||
`/etc/environment`、`/etc/profile`、`/etc/profile.d/*.sh`、`~/.config/environment.d/*.conf`
|
||||
及用户 profile、bash/zsh 配置中的独立、无缩进的字面量赋值或 `export` 行。
|
||||
不会执行整个 shell 配置文件,也不解析命令替换、变量引用或条件表达式。
|
||||
没有上述代理变量时,还会读取远端 Clash Verge、Mihomo/Clash 常见运行配置中的
|
||||
`mixed-port`、`port` 或 `socks-port`,确认对应回环端口可连接后用于本次更新。
|
||||
Linux 下会优先从正在运行的 Mihomo/Clash 进程读取 `-f` / `-d` 指定的配置路径,支持 systemd 服务使用自定义目录。
|
||||
这覆盖了 TUN 模式下没有环境变量的情况,不会修改桌面系统代理开关。
|
||||
需要认证的应用代理、不在已支持路径中的配置及未运行的端口不会自动采用。
|
||||
Windows 普通更新会补读远端用户和系统的代理环境变量。
|
||||
检测结果只显示配置来源,不显示代理地址或凭据;配置仅传给本次更新进程。
|
||||
没有检测到代理时沿用远端网络和 curl 自身配置,不自动借用本机代理。
|
||||
“未检测到代理”只是网络方式说明;更新失败时,菜单会先显示末尾的具体错误,再列完整输出。
|
||||
|
||||
## 远端 Session 弹窗
|
||||
|
||||
弹窗由 tmux `display-popup` 和 Hub 内置选择器组成,不依赖 fzf。它会立即显示已打开的
|
||||
|
||||
+11
-1
@@ -1452,7 +1452,17 @@ def command_control(arguments: argparse.Namespace) -> int:
|
||||
# Endpoint metadata is authoritative; popup environment may belong to another server.
|
||||
binary = resolve_herdr() if window.endpoint.kind == "local" else "herdr"
|
||||
control = HerdrControl(window.endpoint, binary, hub.ssh_bin)
|
||||
return curses.wrapper(ControlMenu(control).run)
|
||||
menu = ControlMenu(control)
|
||||
result = curses.wrapper(menu.run)
|
||||
if menu.reconnect_requested:
|
||||
# Close curses first; respawning the client must not destroy its popup.
|
||||
current = next((item for item in hub.managed_windows() if item.window_id == window.window_id), None)
|
||||
if current is None or current.endpoint != window.endpoint:
|
||||
raise HubError("服务器已更新,但原 Hub Tab 已改变;请手动连接目标 Session")
|
||||
hub.reconnect(window.window_id)
|
||||
hub.wait_for_window_startup(window.window_id)
|
||||
hub.display_message("Herdr 服务器已更新,当前 Tab 已重新连接")
|
||||
return result
|
||||
|
||||
|
||||
def command_keys(arguments: argparse.Namespace) -> int:
|
||||
|
||||
+244
-35
@@ -1,4 +1,4 @@
|
||||
"""Herdr command menu and non-destructive conversation forks."""
|
||||
"""Herdr command menu, updates, and conversation forks."""
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -6,7 +6,11 @@ import curses
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Callable, Sequence
|
||||
@@ -26,6 +30,7 @@ if TYPE_CHECKING:
|
||||
CONTROL_KEY = "h"
|
||||
CONTROL_SHORTCUT = "C-a h"
|
||||
ROUTING_ENV = (
|
||||
"HERDR_ENV",
|
||||
"HERDR_SOCKET_PATH", "HERDR_CLIENT_SOCKET_PATH", "HERDR_SESSION",
|
||||
"HERDR_PANE_ID", "HERDR_TAB_ID", "HERDR_WORKSPACE_ID",
|
||||
"HERDR_ACTIVE_PANE_ID", "HERDR_ACTIVE_TAB_ID", "HERDR_ACTIVE_WORKSPACE_ID",
|
||||
@@ -40,6 +45,16 @@ class ControlCancelled(RuntimeError):
|
||||
"""A read-only control request cancelled while closing the popup."""
|
||||
|
||||
|
||||
def update_failure(header: str, output: str) -> str:
|
||||
"""Put the final diagnostic ahead of setup chatter in small popups."""
|
||||
lines = [line.strip() for line in output.splitlines() if line.strip()]
|
||||
diagnostic = next((line for line in reversed(lines) if line.startswith("curl:")), "")
|
||||
if not diagnostic:
|
||||
diagnostic = next((line for line in reversed(lines)
|
||||
if not line.startswith("没有自动执行普通重启")), "无错误输出")
|
||||
return f"{header}\n{diagnostic}\n\n完整命令输出(可翻页):\n{output}"
|
||||
|
||||
|
||||
def object_field(data: dict, name: str) -> dict:
|
||||
value = data.get(name)
|
||||
if not isinstance(value, dict):
|
||||
@@ -99,7 +114,12 @@ def parse_snapshot(payload: object) -> ForkSource:
|
||||
raise ControlError("当前 pane 没有运行 Agent")
|
||||
if kind not in ("codex", "claude"):
|
||||
raise ControlError(f"暂不支持 Fork {kind};支持 Codex / Claude")
|
||||
session = object_field(pane, "agent_session")
|
||||
session = pane.get("agent_session")
|
||||
if not isinstance(session, dict):
|
||||
raise ControlError(
|
||||
f"当前 {kind.capitalize()} 尚未向 Herdr 上报会话 ID,暂时无法 Fork。\n"
|
||||
f"在目标主机检查 herdr integration status;确认集成已加载后按 Ctrl+R 重读。"
|
||||
)
|
||||
if session.get("kind") != "id" or session.get("agent") != kind:
|
||||
raise ControlError("当前 Agent 尚未报告可 Fork 的 conversation ID")
|
||||
session_id = string_field(session, "value")
|
||||
@@ -135,8 +155,53 @@ class HerdrControl:
|
||||
command: Sequence[str],
|
||||
timeout: float,
|
||||
cancel_event: threading.Event | None = None,
|
||||
progress: Callable[[str], None] | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
environment = {k: v for k, v in os.environ.items() if k not in ROUTING_ENV}
|
||||
if progress is not None:
|
||||
# A file avoids pipe deadlocks and lets us show output before exit,
|
||||
# including progress indicators that do not end in a newline.
|
||||
try:
|
||||
with tempfile.TemporaryFile() as output:
|
||||
process = subprocess.Popen(command, stdout=output, stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.DEVNULL, env=environment)
|
||||
deadline = time.monotonic() + timeout
|
||||
started = time.monotonic()
|
||||
last_output = started
|
||||
last_notice = started
|
||||
position = 0
|
||||
captured = b""
|
||||
try:
|
||||
while True:
|
||||
finished = process.poll() is not None
|
||||
chunk = os.pread(output.fileno(), 65536, position)
|
||||
if chunk:
|
||||
position += len(chunk)
|
||||
captured = (captured + chunk)[-65536:]
|
||||
progress(decode_remote_output(captured).replace("\r", "\n"))
|
||||
last_output = time.monotonic()
|
||||
now = time.monotonic()
|
||||
if not finished and now - last_notice >= 1:
|
||||
progress(
|
||||
decode_remote_output(captured).replace("\r", "\n")
|
||||
+ f"\n已执行 {int(now - started)} 秒 · {int(now - last_output)} 秒无新输出"
|
||||
)
|
||||
last_notice = now
|
||||
if finished and not chunk:
|
||||
return subprocess.CompletedProcess(command, process.returncode, captured, b"")
|
||||
if time.monotonic() >= deadline:
|
||||
raise ControlError(
|
||||
f"命令超时({timeout:g}s);操作可能已完成,请检查 Herdr\n"
|
||||
+ decode_remote_output(captured)
|
||||
)
|
||||
if not chunk:
|
||||
time.sleep(0.1)
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
except OSError as error:
|
||||
raise ControlError(f"无法执行 Herdr 命令: {error}") from error
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise ControlCancelled()
|
||||
if cancel_event is not None:
|
||||
@@ -231,15 +296,7 @@ class HerdrControl:
|
||||
cancel_event: threading.Event | None = None,
|
||||
) -> dict:
|
||||
argv = ["--session", self.endpoint.session, *arguments]
|
||||
if self.endpoint.kind == "remote":
|
||||
entry = RemoteEntry(self.endpoint.target or "", self.endpoint.remote_keybindings,
|
||||
self.endpoint.remote_transport)
|
||||
command = remote_control_command(self.ssh_bin, entry, argv,
|
||||
platform=self._remote_platform(entry, cancel_event),
|
||||
unset_environment=ROUTING_ENV)
|
||||
else:
|
||||
command = [self.herdr_bin, *argv]
|
||||
result = self._execute(command, timeout, cancel_event)
|
||||
result = self._execute(self._command(argv, cancel_event), timeout, cancel_event)
|
||||
stdout, stderr = decode_remote_output(result.stdout), decode_remote_output(result.stderr)
|
||||
if result.returncode != 0:
|
||||
raise ControlError((stderr or stdout or f"Herdr exited {result.returncode}").strip())
|
||||
@@ -248,6 +305,85 @@ class HerdrControl:
|
||||
except json.JSONDecodeError as error:
|
||||
raise ControlError(f"Herdr 返回无效 JSON: {error}") from error
|
||||
|
||||
def _command(
|
||||
self, argv: Sequence[str], cancel_event: threading.Event | None = None,
|
||||
) -> list[str]:
|
||||
if self.endpoint.kind == "remote":
|
||||
entry = RemoteEntry(self.endpoint.target or "", self.endpoint.remote_keybindings,
|
||||
self.endpoint.remote_transport)
|
||||
return remote_control_command(self.ssh_bin, entry, argv,
|
||||
platform=self._remote_platform(entry, cancel_event),
|
||||
unset_environment=ROUTING_ENV)
|
||||
return [self.herdr_bin, *argv]
|
||||
|
||||
def update(self, progress: Callable[[str], None] | None = None) -> str:
|
||||
"""Run the host updater once; its output is text, not control API JSON."""
|
||||
if progress:
|
||||
progress("正在连接更新目标…")
|
||||
command = self._command(["update"])
|
||||
if self.endpoint.kind == "remote":
|
||||
command = self._update_proxy(command, progress)
|
||||
result = self._execute(command, timeout=300, progress=progress)
|
||||
stdout, stderr = decode_remote_output(result.stdout), decode_remote_output(result.stderr)
|
||||
output = "\n".join(part.strip() for part in (stdout, stderr) if part.strip())
|
||||
if result.returncode != 0:
|
||||
raise ControlError(update_failure(f"更新失败(退出码 {result.returncode})", output))
|
||||
return "更新命令已完成。重新连接可使用更新后的客户端。\n" + output
|
||||
|
||||
def _update_proxy(
|
||||
self, command: list[str], progress: Callable[[str], None] | None,
|
||||
) -> list[str]:
|
||||
"""Load proxy settings on the remote host, never from the Hub host."""
|
||||
if self.platform == "windows":
|
||||
entry = RemoteEntry(self.endpoint.target or "", self.endpoint.remote_keybindings,
|
||||
self.endpoint.remote_transport)
|
||||
return remote_control_command(
|
||||
self.ssh_bin, entry, ["update"], platform="windows",
|
||||
unset_environment=ROUTING_ENV, load_proxy_environment=True,
|
||||
)
|
||||
if self.platform != "posix":
|
||||
return command
|
||||
if progress:
|
||||
progress("正在检测远端自身的代理配置…")
|
||||
helper = Path(__file__).with_name("proxy_env.py").read_text(encoding="utf-8")
|
||||
result = list(command)
|
||||
result[-1] = (
|
||||
"if command -v python3 >/dev/null 2>&1; then exec python3 -u -c "
|
||||
+ shlex.quote(helper) + " " + shlex.quote(command[-1])
|
||||
+ "; else echo '远端没有 python3,沿用远端现有代理环境及 curl 配置'; "
|
||||
+ command[-1] + "; fi"
|
||||
)
|
||||
return result
|
||||
|
||||
def update_and_restart(self, progress: Callable[[str], None]) -> str:
|
||||
"""Update and verify a live server handoff on the selected POSIX session."""
|
||||
helper = str(Path(__file__).with_name("restart.py"))
|
||||
if self.endpoint.kind == "local":
|
||||
command = [sys.executable, helper, self.herdr_bin, self.endpoint.session]
|
||||
else:
|
||||
entry = RemoteEntry(self.endpoint.target or "", self.endpoint.remote_keybindings,
|
||||
self.endpoint.remote_transport)
|
||||
progress("正在检查远端热切换支持…")
|
||||
if self._remote_platform(entry) != "posix":
|
||||
raise ControlError("Windows 不支持热切换;请使用“更新 Herdr”,再安排普通重启。")
|
||||
# Reuse the SSH destination/options, but run the standalone helper
|
||||
# on the host that owns the Unix socket. No socket crosses SSH.
|
||||
command = remote_control_command(self.ssh_bin, entry, [], platform="posix")
|
||||
script = Path(helper).read_text(encoding="utf-8")
|
||||
command[-1] = (
|
||||
"".join(f"unset {key}; " for key in ROUTING_ENV)
|
||||
+ 'if [ -x "$HOME/.local/bin/herdr" ]; then hub_herdr="$HOME/.local/bin/herdr"; '
|
||||
'else hub_herdr=$(command -v herdr) || exit 127; fi; '
|
||||
+ "exec python3 -u -c " + shlex.quote(script)
|
||||
+ ' "$hub_herdr" ' + shlex.quote(self.endpoint.session)
|
||||
)
|
||||
command = self._update_proxy(command, progress)
|
||||
result = self._execute(command, timeout=400, progress=progress)
|
||||
output = decode_remote_output(result.stdout)
|
||||
if result.returncode != 0:
|
||||
raise ControlError(update_failure("更新/热切换失败;未自动执行普通重启。", output))
|
||||
return output
|
||||
|
||||
def source(
|
||||
self,
|
||||
cancel_event: threading.Event | None = None,
|
||||
@@ -319,10 +455,13 @@ class ControlMenu:
|
||||
"Fork 当前会话到新 Tab",
|
||||
"保留当前 Tab,在同一 Workspace 和工作目录继续",
|
||||
),
|
||||
("update", "更新 Herdr", "更新当前主机的 Herdr(使用已配置的更新渠道)"),
|
||||
("update-restart", "更新并热重启 Herdr", "实验性热切换,保留进程;成功后自动重连当前 Tab"),
|
||||
)
|
||||
|
||||
def __init__(self, control: HerdrControl) -> None:
|
||||
self.control = control
|
||||
self.reconnect_requested = False
|
||||
|
||||
def run(self, screen) -> int: # type: ignore[no-untyped-def]
|
||||
import queue
|
||||
@@ -336,8 +475,13 @@ class ControlMenu:
|
||||
except (AttributeError, curses.error):
|
||||
pass
|
||||
source: ForkSource | None = None
|
||||
source_error = ""
|
||||
status = ""
|
||||
selected = 0
|
||||
update_attempted = False
|
||||
pending_update: str | None = None
|
||||
status_offset = 0
|
||||
target = "本机" if self.control.endpoint.kind == "local" else self.control.endpoint.target
|
||||
cancel_loading = threading.Event()
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(self.control.source, cancel_loading)
|
||||
@@ -346,22 +490,46 @@ class ControlMenu:
|
||||
while not messages.empty():
|
||||
status = messages.get()
|
||||
if future is not None and future.done():
|
||||
# The worker is finished: discard queued progress before
|
||||
# publishing its final result so stale output cannot replace it.
|
||||
while not messages.empty():
|
||||
messages.get()
|
||||
status_offset = 0
|
||||
try:
|
||||
value = future.result()
|
||||
if phase == "load":
|
||||
source = value
|
||||
source_error = ""
|
||||
status = ""
|
||||
elif phase == "update":
|
||||
status = value
|
||||
elif phase == "update-restart":
|
||||
self.reconnect_requested = True
|
||||
return 0
|
||||
else:
|
||||
return 0
|
||||
except ControlCancelled:
|
||||
return 0
|
||||
if not pending_update:
|
||||
return 0
|
||||
except Exception as error:
|
||||
status = str(error)
|
||||
if phase == "load":
|
||||
source_error = status
|
||||
if phase == "fork":
|
||||
source = None # Avoid accidental duplicate forks after ambiguous failure.
|
||||
source_error = status
|
||||
future = None
|
||||
if pending_update and future is None:
|
||||
phase = pending_update
|
||||
pending_update = None
|
||||
update_attempted = True
|
||||
method = self.control.update if phase == "update" else self.control.update_and_restart
|
||||
future = executor.submit(method, messages.put)
|
||||
status = f"正在更新 {target} 的 Herdr…"
|
||||
status_offset = 0
|
||||
screen.erase()
|
||||
height, width = screen.getmaxyx()
|
||||
compact = height < 16
|
||||
if source is not None:
|
||||
context = (
|
||||
f"当前:{source.kind.capitalize()} · "
|
||||
@@ -371,11 +539,18 @@ class ControlMenu:
|
||||
context = "正在读取当前页面…"
|
||||
else:
|
||||
context = "无法读取当前页面"
|
||||
self._add(screen, 1, 2, context, curses.A_BOLD)
|
||||
action_top = 3
|
||||
for index, (_, label, detail) in enumerate(self.ACTIONS):
|
||||
y = action_top + index * 2
|
||||
enabled = source is not None and future is None
|
||||
if not compact:
|
||||
self._add(screen, 1, 2, context, curses.A_BOLD)
|
||||
heading = context if compact and selected == 0 else f"更新目标:{target}"
|
||||
self._add(screen, 0 if compact else 2, 2, heading, curses.A_BOLD)
|
||||
action_top = 1 if compact else 3
|
||||
action_stride = 1 if compact else 2
|
||||
for index, (action, label, detail) in enumerate(self.ACTIONS):
|
||||
y = action_top + index * action_stride
|
||||
enabled = (
|
||||
source is not None and future is None if action == "fork"
|
||||
else not update_attempted and (future is None or phase == "load")
|
||||
)
|
||||
attribute = (
|
||||
curses.A_REVERSE
|
||||
if enabled and index == selected
|
||||
@@ -383,21 +558,27 @@ class ControlMenu:
|
||||
if enabled
|
||||
else curses.A_DIM
|
||||
)
|
||||
if action == "fork" and source is None:
|
||||
label += "(读取中)" if phase == "load" and future is not None else "(不可用)"
|
||||
self._add(screen, y, 2, f" {label} ", attribute)
|
||||
self._add(screen, y + 1, 4, detail, curses.A_DIM)
|
||||
if not compact:
|
||||
self._add(screen, y + 1, 4, detail, curses.A_DIM)
|
||||
|
||||
status_top = action_top + len(self.ACTIONS) * 2 + 1
|
||||
for offset, line in enumerate(
|
||||
display_chunks(status, max(1, width - 5)) if status else []
|
||||
):
|
||||
status_top = action_top + len(self.ACTIONS) * action_stride + (0 if compact else 1)
|
||||
status_lines = display_chunks(status, max(1, width - 5)) if status else []
|
||||
visible_lines = max(1, height - status_top - 1)
|
||||
status_offset = min(status_offset, max(0, len(status_lines) - visible_lines))
|
||||
if future is not None and phase in ("update", "update-restart"):
|
||||
status_offset = max(0, len(status_lines) - visible_lines)
|
||||
for offset, line in enumerate(status_lines[status_offset:status_offset + visible_lines]):
|
||||
self._add(screen, status_top + offset, 2, line, curses.A_DIM)
|
||||
|
||||
footer = (
|
||||
"操作执行中,请等待结果"
|
||||
if future is not None and phase == "fork"
|
||||
else "Enter 执行 · Esc 关闭"
|
||||
if future is not None and phase != "load"
|
||||
else "Enter 执行 · Ctrl+R 重读 · PgUp/PgDn 结果 · Esc 关闭"
|
||||
)
|
||||
self._add(screen, height - 2, 2, footer, curses.A_DIM)
|
||||
self._add(screen, height - 1, 2, footer, curses.A_DIM)
|
||||
screen.refresh()
|
||||
key = screen.getch()
|
||||
if key == 27:
|
||||
@@ -406,7 +587,19 @@ class ControlMenu:
|
||||
return 0
|
||||
if future is None:
|
||||
return 0
|
||||
status = "操作执行中;完成后可关闭(避免误以为已取消 Fork)。"
|
||||
status = "操作执行中,请等待结果;关闭菜单不会取消已发送的操作。"
|
||||
elif key == 18 and future is None:
|
||||
source = None
|
||||
source_error = ""
|
||||
cancel_loading = threading.Event()
|
||||
future = executor.submit(self.control.source, cancel_loading)
|
||||
phase = "load"
|
||||
status = "正在重新读取当前页面…"
|
||||
status_offset = 0
|
||||
elif key in (curses.KEY_PPAGE, curses.KEY_NPAGE):
|
||||
status_offset = max(0, status_offset + (
|
||||
visible_lines if key == curses.KEY_NPAGE else -visible_lines
|
||||
))
|
||||
elif key in (curses.KEY_UP, curses.KEY_DOWN):
|
||||
selected = (selected + (1 if key == curses.KEY_DOWN else -1)) % len(self.ACTIONS)
|
||||
else:
|
||||
@@ -414,14 +607,10 @@ class ControlMenu:
|
||||
if key == curses.KEY_MOUSE:
|
||||
try:
|
||||
_, x, y, _, state = curses.getmouse()
|
||||
action_index = (y - action_top) // 2
|
||||
action_index = (y - action_top) // action_stride
|
||||
execute = (
|
||||
1 <= x < width - 1
|
||||
and 0 <= action_index < len(self.ACTIONS)
|
||||
and y in {
|
||||
action_top + action_index * 2,
|
||||
action_top + action_index * 2 + 1,
|
||||
}
|
||||
and bool(
|
||||
state
|
||||
& (
|
||||
@@ -434,10 +623,30 @@ class ControlMenu:
|
||||
selected = action_index
|
||||
except curses.error:
|
||||
execute = False
|
||||
if execute and future is None and source is not None:
|
||||
phase = "fork"
|
||||
future = executor.submit(self.control.fork, source, messages.put)
|
||||
status = "正在 Fork…"
|
||||
if execute:
|
||||
action = self.ACTIONS[selected][0]
|
||||
if action in ("update", "update-restart") and not update_attempted and phase == "load" and future is not None:
|
||||
pending_update = action
|
||||
cancel_loading.set()
|
||||
status = "正在准备更新…"
|
||||
elif action == "fork" and source is not None and future is None:
|
||||
phase = "fork"
|
||||
future = executor.submit(self.control.fork, source, messages.put)
|
||||
status = "正在 Fork…"
|
||||
status_offset = 0
|
||||
elif action == "fork":
|
||||
if future is None:
|
||||
status = source_error or "尚未读取 Fork 会话信息,请按 Ctrl+R 重读。"
|
||||
status_offset = 0
|
||||
elif phase == "load":
|
||||
status = "正在读取 Fork 会话信息,请稍候再执行。"
|
||||
elif action in ("update", "update-restart") and not update_attempted and future is None:
|
||||
phase = action
|
||||
update_attempted = True
|
||||
method = self.control.update if action == "update" else self.control.update_and_restart
|
||||
future = executor.submit(method, messages.put)
|
||||
status = f"正在更新 {target} 的 Herdr,请等待…"
|
||||
status_offset = 0
|
||||
|
||||
@staticmethod
|
||||
def _add(
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Read literal remote proxy settings without executing shell startup files.
|
||||
|
||||
This standalone helper runs on the remote host; proxy values never leave it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import socket
|
||||
import sys
|
||||
|
||||
|
||||
PROXY_KEYS = {"http_proxy", "https_proxy", "all_proxy", "no_proxy"}
|
||||
|
||||
|
||||
def proxy_assignments(text: str) -> dict[str, str]:
|
||||
values = {}
|
||||
for line in text.splitlines():
|
||||
# Only standalone, unindented literal assignments. Do not evaluate
|
||||
# functions, shell commands, substitutions, or conditional expressions.
|
||||
if not re.match(r"^(?:export\s+)?(?:https?_proxy|all_proxy|no_proxy)=", line, re.I):
|
||||
continue
|
||||
if any(marker in line for marker in ("$", "`", ";", "&&", "||")):
|
||||
continue
|
||||
try:
|
||||
words = shlex.split(line, comments=True)
|
||||
except ValueError:
|
||||
continue
|
||||
if words and words[0] == "export":
|
||||
words = words[1:]
|
||||
assignments = [word.split("=", 1) for word in words]
|
||||
if not assignments or any(len(item) != 2 or item[0].lower() not in PROXY_KEYS for item in assignments):
|
||||
continue
|
||||
for key, value in assignments:
|
||||
if "\0" not in value:
|
||||
values[key.lower()] = value
|
||||
return values
|
||||
|
||||
|
||||
def config_paths(home: Path, shell: str) -> list[Path]:
|
||||
paths = [Path("/etc/environment"), Path("/etc/profile")]
|
||||
paths.extend(sorted(Path("/etc/profile.d").glob("*.sh")))
|
||||
paths.extend(sorted((home / ".config/environment.d").glob("*.conf")))
|
||||
paths.append(home / ".profile")
|
||||
if Path(shell).name in ("zsh",):
|
||||
paths.extend([home / ".zshenv", home / ".zprofile", home / ".zshrc"])
|
||||
else:
|
||||
paths.extend([home / ".bash_profile", home / ".bash_login", home / ".bashrc"])
|
||||
return paths
|
||||
|
||||
|
||||
def configure_proxy(environment: dict[str, str], paths: list[Path]) -> list[str]:
|
||||
# An existing remote SSH environment (even an explicit empty value) wins.
|
||||
inherited = {key.lower() for key in environment if key.lower() in PROXY_KEYS}
|
||||
loaded = {}
|
||||
sources = []
|
||||
for path in paths:
|
||||
try:
|
||||
values = proxy_assignments(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError):
|
||||
continue
|
||||
values = {key: value for key, value in values.items() if key not in inherited}
|
||||
if values:
|
||||
loaded.update(values)
|
||||
sources.append(str(path))
|
||||
environment.update(loaded)
|
||||
# curl ignores uppercase HTTP_PROXY; normalize remote values, preserving
|
||||
# explicit lowercase precedence and NO_PROXY exclusions.
|
||||
for key in PROXY_KEYS:
|
||||
if key not in environment and key.upper() in environment:
|
||||
environment[key] = environment[key.upper()]
|
||||
return sources
|
||||
|
||||
|
||||
def application_proxy_paths(home: Path) -> list[Path]:
|
||||
"""Known runtime config locations; never inspect downloaded subscriptions."""
|
||||
return running_proxy_paths() + [
|
||||
home / ".local/share/io.github.clash-verge-rev.clash-verge-rev/clash-verge.yaml",
|
||||
home / ".config/io.github.clash-verge-rev.clash-verge-rev/clash-verge.yaml",
|
||||
home / ".config/clash-verge/clash-verge.yaml",
|
||||
home / ".config/mihomo/config.yaml",
|
||||
home / ".config/clash/config.yaml",
|
||||
]
|
||||
|
||||
|
||||
def running_proxy_paths(proc_root: Path = Path("/proc")) -> list[Path]:
|
||||
"""Find the config explicitly used by a running Clash/Mihomo core."""
|
||||
paths = []
|
||||
try:
|
||||
processes = list(proc_root.iterdir())
|
||||
except OSError:
|
||||
return paths
|
||||
for process in processes:
|
||||
if not process.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
arguments = (process / "cmdline").read_bytes().decode().rstrip("\0").split("\0")
|
||||
except (OSError, UnicodeError):
|
||||
continue
|
||||
if not arguments or not re.fullmatch(r"(?:mihomo|clash)(?:[-.][A-Za-z0-9_.-]+)?", Path(arguments[0]).name):
|
||||
continue
|
||||
config = None
|
||||
directory = None
|
||||
for index, argument in enumerate(arguments[1:], 1):
|
||||
flag, separator, value = argument.partition("=")
|
||||
if not separator and index + 1 < len(arguments):
|
||||
value = arguments[index + 1]
|
||||
if flag in ("-f", "--config") and value:
|
||||
config = Path(value)
|
||||
elif flag in ("-d", "--config-dir") and value:
|
||||
directory = Path(value)
|
||||
if config is None and directory is not None:
|
||||
config = directory / "config.yaml"
|
||||
if config is None:
|
||||
continue
|
||||
if not config.is_absolute():
|
||||
try:
|
||||
config = (process / "cwd").resolve(strict=True) / config
|
||||
except OSError:
|
||||
continue
|
||||
if config not in paths:
|
||||
paths.append(config)
|
||||
return paths
|
||||
|
||||
|
||||
def configure_application_proxy(environment: dict[str, str], paths: list[Path]) -> str | None:
|
||||
# An explicit environment setting (including an empty/disabled value)
|
||||
# takes precedence over a proxy application's independent listener.
|
||||
if any(key.lower() in {"http_proxy", "https_proxy", "all_proxy"} for key in environment):
|
||||
return None
|
||||
for path in paths:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError):
|
||||
continue
|
||||
# This deliberately reads only simple top-level listener settings,
|
||||
# not arbitrary YAML, provider credentials or subscription profiles.
|
||||
fields = dict(re.findall(
|
||||
r"^(mixed-port|port|socks-port|bind-address|authentication):[ \t]*(.*)$",
|
||||
text, re.M,
|
||||
))
|
||||
if "authentication" in fields and fields["authentication"].strip() != "[]":
|
||||
continue
|
||||
try:
|
||||
bind = shlex.split(fields.get("bind-address", "'*'"), comments=True)
|
||||
except ValueError:
|
||||
continue
|
||||
if len(bind) != 1 or bind[0] not in ("*", "0.0.0.0", "127.0.0.1", "::", "::1", "localhost"):
|
||||
continue
|
||||
host = "::1" if bind[0] in ("::", "::1") else "127.0.0.1"
|
||||
for key, scheme in (("mixed-port", "http"), ("port", "http"), ("socks-port", "socks5h")):
|
||||
match = re.fullmatch(r"[\"']?(\d+)[\"']?\s*(?:#.*)?", fields.get(key, ""))
|
||||
if not match:
|
||||
continue
|
||||
port = int(match[1])
|
||||
if not 1 <= port <= 65535:
|
||||
continue
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=1):
|
||||
pass
|
||||
except OSError:
|
||||
continue
|
||||
address = f"[{host}]" if host == "::1" else host
|
||||
proxy = f"{scheme}://{address}:{port}"
|
||||
environment.update(http_proxy=proxy, https_proxy=proxy, all_proxy=proxy)
|
||||
return str(path)
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
environment = dict(os.environ)
|
||||
sources = configure_proxy(environment, config_paths(Path.home(), environment.get("SHELL", "bash")))
|
||||
application_source = configure_application_proxy(environment, application_proxy_paths(Path.home()))
|
||||
if application_source:
|
||||
sources.append(application_source + "(应用代理端口可连接)")
|
||||
active = any(environment.get(key) for key in ("https_proxy", "all_proxy", "http_proxy"))
|
||||
if active:
|
||||
source = "、".join(sources) if sources else "SSH 环境变量"
|
||||
print("使用远端自身代理,配置来源:" + source, flush=True)
|
||||
else:
|
||||
print("网络方式:远端直连(未发现可用代理配置;此行不是错误)。", flush=True)
|
||||
os.execve("/bin/sh", ["sh", "-c", sys.argv[1]], environment)
|
||||
+15
-1
@@ -717,6 +717,7 @@ def remote_control_command(
|
||||
*,
|
||||
platform: str,
|
||||
unset_environment: Sequence[str] = (),
|
||||
load_proxy_environment: bool = False,
|
||||
) -> list[str]:
|
||||
"""Build one non-replayed mutation with platform-specific argv encoding.
|
||||
|
||||
@@ -746,8 +747,21 @@ def remote_control_command(
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
unset = "".join(f"Remove-Item Env:{key} -ErrorAction SilentlyContinue; " for key in unset_environment)
|
||||
proxy_setup = ""
|
||||
if load_proxy_environment:
|
||||
proxy_setup = (
|
||||
"foreach ($key in @('http_proxy','https_proxy','all_proxy','no_proxy')) { "
|
||||
"$value=[Environment]::GetEnvironmentVariable($key,'Process'); "
|
||||
"if ([string]::IsNullOrEmpty($value)) { "
|
||||
"$value=[Environment]::GetEnvironmentVariable($key,'User'); "
|
||||
"if ([string]::IsNullOrEmpty($value)) { $value=[Environment]::GetEnvironmentVariable($key,'Machine') }; "
|
||||
"if (![string]::IsNullOrEmpty($value)) { [Environment]::SetEnvironmentVariable($key,$value,'Process') } } }; "
|
||||
"if ($env:https_proxy -or $env:http_proxy -or $env:all_proxy) { "
|
||||
"Write-Output 'Using remote proxy environment (process/user/machine).' "
|
||||
"} else { Write-Output 'No remote proxy environment found; using remote network defaults.' }; "
|
||||
)
|
||||
script_text = (
|
||||
"$ErrorActionPreference='Stop'; " + unset
|
||||
"$ErrorActionPreference='Stop'; " + unset + proxy_setup
|
||||
+ "$exe=Join-Path $HOME '.local\\bin\\herdr.exe'; "
|
||||
"if (!(Test-Path -LiteralPath $exe)) { $exe=(Get-Command herdr -ErrorAction Stop).Source }; "
|
||||
"$p=New-Object System.Diagnostics.Process; "
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Standalone POSIX update/handoff helper, also sent to remote Python over SSH."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
||||
def request(socket_path: str, method: str, params: dict, timeout: float = 10) -> dict:
|
||||
request_id = uuid.uuid4().hex
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(timeout)
|
||||
connection.connect(socket_path)
|
||||
connection.sendall((json.dumps({"id": request_id, "method": method, "params": params}) + "\n").encode())
|
||||
with connection.makefile("rb") as stream:
|
||||
raw = stream.readline(1024 * 1024)
|
||||
if not raw.endswith(b"\n"):
|
||||
raise RuntimeError("Herdr 控制响应不完整;请检查 Session 状态后再重试")
|
||||
response = json.loads(raw)
|
||||
if not isinstance(response, dict) or response.get("id") != request_id:
|
||||
raise RuntimeError("Herdr 控制响应 ID 不匹配")
|
||||
if response.get("error"):
|
||||
raise RuntimeError(json.dumps(response["error"], ensure_ascii=False))
|
||||
result = response.get("result")
|
||||
if not isinstance(result, dict):
|
||||
raise RuntimeError("Herdr 控制响应缺少结果")
|
||||
return result
|
||||
|
||||
|
||||
def installed_version(binary: str) -> str:
|
||||
version = subprocess.check_output([binary, "--version"], text=True, timeout=10).strip()
|
||||
if not version.startswith("herdr ") or not version[6:].strip():
|
||||
raise RuntimeError("无法识别已安装的 Herdr 版本:" + version)
|
||||
return version[6:].strip()
|
||||
|
||||
|
||||
def binary_identity(binary: str) -> tuple[int, int, int, int]:
|
||||
info = os.stat(binary)
|
||||
return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns
|
||||
|
||||
|
||||
def update_and_handoff(binary: str, session: str) -> None:
|
||||
binary = str(Path(binary).absolute())
|
||||
status = subprocess.check_output(
|
||||
[binary, "--session", session, "status", "server"], text=True, timeout=10,
|
||||
)
|
||||
fields = dict(line.split(": ", 1) for line in status.splitlines() if ": " in line)
|
||||
socket_path = fields.get("socket", "")
|
||||
if fields.get("status") != "running" or not os.path.isabs(socket_path):
|
||||
raise RuntimeError("当前 Session 没有可用的运行中服务器,不能执行热切换")
|
||||
before = request(socket_path, "ping", {})
|
||||
if before.get("capabilities", {}).get("live_handoff") is not True:
|
||||
raise RuntimeError("当前服务器不支持热切换;可先使用“更新 Herdr”,再安排普通重启")
|
||||
print(f"更新目标 Session:{session};当前服务器:{before.get('version', '未知')}", flush=True)
|
||||
identity_before = binary_identity(binary)
|
||||
# 0.8.x plain update refuses to complete with live sessions and no TTY.
|
||||
# Let the native updater perform its opt-in handoff, scoped to this session.
|
||||
subprocess.run([binary, "--session", session, "update", "--handoff"], check=True, timeout=300)
|
||||
version = installed_version(binary)
|
||||
current = request(socket_path, "ping", {})
|
||||
if current.get("version") != version:
|
||||
if binary_identity(binary) != identity_before:
|
||||
raise RuntimeError(
|
||||
f"已安装 {version},但原生热切换后服务器仍是 {current.get('version', '未知')};"
|
||||
"不会重复发送热切换请求或强制重启,请检查上方原生命令输出"
|
||||
)
|
||||
print(f"正在热切换服务器到 {version},保留运行中的进程…", flush=True)
|
||||
# An unchanged binary means the updater may have returned 'up to date'
|
||||
# before touching an older running server. Only then send a handoff.
|
||||
request(socket_path, "server.live_handoff", {
|
||||
"import_exe": str(Path(binary).resolve()), "expected_version": version,
|
||||
}, timeout=40)
|
||||
deadline = time.monotonic() + 20
|
||||
while True:
|
||||
try:
|
||||
current = request(socket_path, "ping", {}, timeout=2)
|
||||
if current.get("version") == version:
|
||||
break
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
pass
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError("热切换后未确认新服务器就绪;请检查状态,不会自动停止 Session")
|
||||
time.sleep(.2)
|
||||
print(f"服务器已确认使用 {version},可以重新连接。", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
update_and_handoff(sys.argv[1], sys.argv[2])
|
||||
except Exception as error:
|
||||
print(f"更新/热切换未完成:{error}\n没有自动执行普通重启;请检查当前 Session。", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1)
|
||||
@@ -6,6 +6,9 @@ import curses
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
@@ -18,6 +21,7 @@ from herdr_hub.control import (
|
||||
ControlMenu,
|
||||
HerdrControl,
|
||||
parse_snapshot,
|
||||
update_failure,
|
||||
)
|
||||
from herdr_hub.remotes import RemoteEntry, remote_control_command
|
||||
|
||||
@@ -41,6 +45,13 @@ def completed(payload, code=0):
|
||||
|
||||
|
||||
class SnapshotTests(unittest.TestCase):
|
||||
def test_missing_session_reports_identity_problem_not_version(self):
|
||||
payload = snapshot()
|
||||
del payload["result"]["snapshot"]["panes"][0]["agent_session"]
|
||||
with self.assertRaisesRegex(ControlError, "尚未向 Herdr 上报会话 ID") as caught:
|
||||
parse_snapshot(payload)
|
||||
self.assertNotIn("需要 Herdr 0.9", str(caught.exception))
|
||||
|
||||
def test_focused_pane_and_exact_native_arguments(self):
|
||||
for kind, args in (("codex", ["fork", SESSION_ID]),
|
||||
("claude", ["--resume", SESSION_ID, "--fork-session"])):
|
||||
@@ -68,6 +79,99 @@ class SnapshotTests(unittest.TestCase):
|
||||
|
||||
|
||||
class ControlTests(unittest.TestCase):
|
||||
def test_windows_hot_restart_rejected_before_update(self):
|
||||
control = HerdrControl(Endpoint.remote("windows-host", remote_transport="ssh"), "unused")
|
||||
control.platform = "windows"
|
||||
with patch.object(control, "_execute") as execute:
|
||||
with self.assertRaisesRegex(ControlError, "Windows 不支持热切换"):
|
||||
control.update_and_restart(lambda _: None)
|
||||
execute.assert_not_called()
|
||||
|
||||
def test_remote_hot_restart_runs_helper_on_exact_host(self):
|
||||
control = HerdrControl(Endpoint.remote("ssh://me@host:2222", "named"), "unused", "fake-ssh")
|
||||
control.platform = "posix"
|
||||
with patch.object(control, "_execute", return_value=subprocess.CompletedProcess([], 0, b"ok", b"")) as execute:
|
||||
control.update_and_restart(lambda _: None)
|
||||
command = execute.call_args.args[0]
|
||||
self.assertEqual(command[0], "fake-ssh")
|
||||
self.assertIn("host", command)
|
||||
self.assertIn("2222", command)
|
||||
self.assertIn("exec python3 -u -c", command[-1])
|
||||
self.assertIn(' "$hub_herdr" named', command[-1])
|
||||
self.assertIn("unset HERDR_ENV;", command[-1])
|
||||
|
||||
def test_streaming_shows_unterminated_output_before_process_exits(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
marker = Path(directory) / "seen"
|
||||
script = (
|
||||
"import os, sys, time\n"
|
||||
"from pathlib import Path\n"
|
||||
"os.write(2, b'downloading 10%')\n"
|
||||
"deadline = time.monotonic() + 3\n"
|
||||
"while not Path(sys.argv[1]).exists():\n"
|
||||
" if time.monotonic() > deadline: sys.exit(9)\n"
|
||||
" time.sleep(.01)\n"
|
||||
"print(' done')\n"
|
||||
)
|
||||
updates = []
|
||||
def progress(value):
|
||||
updates.append(value)
|
||||
marker.touch()
|
||||
result = HerdrControl(Endpoint.local(), "unused")._execute(
|
||||
[sys.executable, "-c", script, str(marker)], 5, progress=progress
|
||||
)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertIn("downloading 10%", updates[0])
|
||||
self.assertIn(b"done", result.stdout)
|
||||
|
||||
def test_streaming_timeout_preserves_diagnostic(self):
|
||||
with self.assertRaisesRegex(ControlError, "waiting for network"):
|
||||
HerdrControl(Endpoint.local(), "unused")._execute(
|
||||
[sys.executable, "-c", "import os,time; os.write(2,b'waiting for network'); time.sleep(10)"],
|
||||
.3, progress=lambda _: None,
|
||||
)
|
||||
|
||||
def test_update_local_is_host_scoped_and_accepts_plain_text(self):
|
||||
result = subprocess.CompletedProcess([], 0, b"Already up to date", b"notice")
|
||||
with patch.dict(os.environ, {"HERDR_SESSION": "wrong"}), patch(
|
||||
"herdr_hub.control.subprocess.run", return_value=result
|
||||
) as run:
|
||||
output = HerdrControl(Endpoint.local("named"), "/fake/herdr").update()
|
||||
self.assertIn("Already up to date", output)
|
||||
self.assertIn("notice", output)
|
||||
self.assertEqual(run.call_args.args[0], ["/fake/herdr", "update"])
|
||||
self.assertEqual(run.call_args.kwargs["timeout"], 300)
|
||||
self.assertEqual(run.call_args.kwargs["stdin"], subprocess.DEVNULL)
|
||||
self.assertNotIn("HERDR_SESSION", run.call_args.kwargs["env"])
|
||||
|
||||
def test_update_remote_routes_once_on_both_platforms(self):
|
||||
for transport, path in (("native", "/home/me/sessions"),
|
||||
("ssh", "C:\\Users\\me\\sessions")):
|
||||
listing = {"sessions": [{"name": "named", "running": True, "session_dir": path}]}
|
||||
failure = subprocess.CompletedProcess([], 1, b"download started", b"network error")
|
||||
with self.subTest(transport=transport), patch(
|
||||
"herdr_hub.control.subprocess.run", side_effect=[completed(listing), failure]
|
||||
) as run:
|
||||
control = HerdrControl(Endpoint.remote("host", "named", remote_transport=transport), "unused", "fake-ssh")
|
||||
with self.assertRaisesRegex(ControlError, "network error"):
|
||||
control.update()
|
||||
self.assertEqual(run.call_count, 2)
|
||||
command = run.call_args.args[0]
|
||||
self.assertEqual(command[0], "fake-ssh")
|
||||
self.assertIn("host", command)
|
||||
script = command[-1]
|
||||
if transport == "ssh":
|
||||
script = base64.b64decode(script.split()[-1]).decode("utf-16le")
|
||||
self.assertIn("update", script)
|
||||
self.assertNotIn("--session", script)
|
||||
self.assertNotIn("--handoff", script)
|
||||
|
||||
def test_update_timeout_is_not_replayed(self):
|
||||
with patch("herdr_hub.control.subprocess.run", side_effect=subprocess.TimeoutExpired("herdr", 300)) as run:
|
||||
with self.assertRaisesRegex(ControlError, "操作可能已完成"):
|
||||
HerdrControl(Endpoint.local(), "herdr").update()
|
||||
run.assert_called_once()
|
||||
|
||||
def test_local_explicit_session_and_clean_environment(self):
|
||||
for session in ("default", "named"):
|
||||
with self.subTest(session=session), patch.dict(os.environ, {
|
||||
@@ -234,6 +338,35 @@ class ImmediateExecutor:
|
||||
|
||||
|
||||
class MenuTests(unittest.TestCase):
|
||||
def test_small_popup_shows_download_failure_before_proxy_notice(self):
|
||||
self.screen.getmaxyx.return_value = (8, 71)
|
||||
self.control.update_and_restart.side_effect = ControlError(update_failure(
|
||||
"更新失败", "未检测到代理\n正在下载\ncurl: (28) Connection timed out\n没有自动执行普通重启;请检查当前 Session。"
|
||||
))
|
||||
self.run_menu([curses.KEY_DOWN, curses.KEY_DOWN, 10, 27])
|
||||
rendered = str(self.screen.addstr.call_args_list)
|
||||
self.assertIn("curl: (28) Connection timed out", rendered)
|
||||
self.assertNotIn("未检测到代理", rendered)
|
||||
|
||||
def test_hot_restart_success_requests_reconnect(self):
|
||||
self.control.update_and_restart.return_value = "verified"
|
||||
self.screen.getch.side_effect = [curses.KEY_DOWN, curses.KEY_DOWN, 10]
|
||||
menu = ControlMenu(self.control)
|
||||
with patch("herdr_hub.control.ThreadPoolExecutor", ImmediateExecutor), patch("curses.set_escdelay"), patch("curses.curs_set"), patch("curses.mousemask"):
|
||||
self.assertEqual(menu.run(self.screen), 0)
|
||||
self.assertTrue(menu.reconnect_requested)
|
||||
self.control.update_and_restart.assert_called_once()
|
||||
|
||||
def test_hot_restart_failure_stays_visible_without_reconnect(self):
|
||||
self.control.update_and_restart.side_effect = ControlError("handoff failed")
|
||||
self.screen.getch.side_effect = [curses.KEY_DOWN, curses.KEY_DOWN, 10, 10, 27]
|
||||
menu = ControlMenu(self.control)
|
||||
with patch("herdr_hub.control.ThreadPoolExecutor", ImmediateExecutor), patch("curses.set_escdelay"), patch("curses.curs_set"), patch("curses.mousemask"):
|
||||
self.assertEqual(menu.run(self.screen), 0)
|
||||
self.assertFalse(menu.reconnect_requested)
|
||||
self.control.update_and_restart.assert_called_once()
|
||||
self.assertIn("handoff failed", str(self.screen.addstr.call_args_list))
|
||||
|
||||
def setUp(self):
|
||||
self.control = Mock(endpoint=Endpoint.local())
|
||||
self.control.source.return_value = parse_snapshot(snapshot())
|
||||
@@ -268,8 +401,102 @@ class MenuTests(unittest.TestCase):
|
||||
self.run_menu([10, 27])
|
||||
self.control.fork.assert_not_called()
|
||||
|
||||
def test_refresh_restores_fork_after_session_identity_becomes_available(self):
|
||||
self.screen.getmaxyx.return_value = (8, 71)
|
||||
self.control.source.side_effect = [ControlError("尚未上报会话 ID"), parse_snapshot(snapshot())]
|
||||
self.run_menu([10, 18, 10])
|
||||
self.assertEqual(self.control.source.call_count, 2)
|
||||
self.control.fork.assert_called_once()
|
||||
self.assertIn("不可用", str(self.screen.addstr.call_args_list))
|
||||
|
||||
def test_compact_mouse_fork_still_executes(self):
|
||||
self.screen.getmaxyx.return_value = (8, 71)
|
||||
with patch("curses.getmouse", return_value=(0, 3, 1, 0, curses.BUTTON1_PRESSED)):
|
||||
self.run_menu([curses.KEY_MOUSE])
|
||||
self.control.fork.assert_called_once()
|
||||
self.control.update.assert_not_called()
|
||||
|
||||
def test_update_without_agent_keeps_result_and_prevents_repeat(self):
|
||||
self.control.source.side_effect = ControlError("没有 Agent")
|
||||
self.control.update.return_value = "更新成功"
|
||||
self.run_menu([curses.KEY_DOWN, 10, 10, 27])
|
||||
self.control.update.assert_called_once()
|
||||
self.control.fork.assert_not_called()
|
||||
rendered = str(self.screen.addstr.call_args_list)
|
||||
self.assertIn("更新成功", rendered)
|
||||
self.assertIn("更新目标:本机", rendered)
|
||||
|
||||
def test_click_update_reports_remote_target_and_failure(self):
|
||||
self.control.endpoint = Endpoint.remote("my-host")
|
||||
self.control.update.side_effect = ControlError("更新失败:连接断开")
|
||||
with patch("curses.getmouse", return_value=(0, 3, 5, 0, curses.BUTTON1_PRESSED)):
|
||||
self.run_menu([curses.KEY_MOUSE, 10, 27])
|
||||
self.control.update.assert_called_once()
|
||||
rendered = str(self.screen.addstr.call_args_list)
|
||||
self.assertIn("更新目标:my-host", rendered)
|
||||
self.assertIn("更新失败:连接断开", rendered)
|
||||
|
||||
def test_update_output_can_be_scrolled(self):
|
||||
self.control.update.return_value = "\n".join(f"line-{i}" for i in range(60))
|
||||
self.run_menu([curses.KEY_DOWN, 10, curses.KEY_NPAGE, curses.KEY_PPAGE, 27])
|
||||
self.assertIn("line-20", str(self.screen.addstr.call_args_list))
|
||||
|
||||
def test_update_result_visible_in_actual_small_popup(self):
|
||||
self.screen.getmaxyx.return_value = (8, 71)
|
||||
self.control.update.return_value = "更新命令已完成\nAlready up to date"
|
||||
self.run_menu([curses.KEY_DOWN, 10, 27])
|
||||
calls = self.screen.addstr.call_args_list
|
||||
results = [call.args for call in calls if "Already up to date" in call.args[2]]
|
||||
self.assertTrue(results, "结果必须在真实的 8 行弹窗内绘制")
|
||||
self.assertTrue(all(3 <= args[0] < 7 for args in results))
|
||||
self.assertTrue(all(0 <= call.args[0] < 8 for call in calls))
|
||||
|
||||
def test_compact_mouse_update_row(self):
|
||||
self.screen.getmaxyx.return_value = (8, 71)
|
||||
self.control.update.return_value = "完成"
|
||||
with patch("curses.getmouse", return_value=(0, 3, 2, 0, curses.BUTTON1_PRESSED)):
|
||||
self.run_menu([curses.KEY_MOUSE, 27])
|
||||
self.control.update.assert_called_once()
|
||||
self.control.fork.assert_not_called()
|
||||
|
||||
def test_update_during_snapshot_load_is_not_ignored(self):
|
||||
cancel = None
|
||||
class LoadingFuture:
|
||||
def done(self):
|
||||
return cancel.is_set()
|
||||
def result(self):
|
||||
raise ControlCancelled()
|
||||
class LoadingExecutor(ImmediateExecutor):
|
||||
def submit(inner, fn, *args):
|
||||
nonlocal cancel
|
||||
if fn == self.control.source:
|
||||
cancel = args[0]
|
||||
return LoadingFuture()
|
||||
return ImmediateFuture(fn, *args)
|
||||
self.control.update.return_value = "完成"
|
||||
self.screen.getch.side_effect = [curses.KEY_DOWN, 10, -1, 27]
|
||||
with patch("herdr_hub.control.ThreadPoolExecutor", LoadingExecutor), patch("curses.set_escdelay"), patch("curses.curs_set"), patch("curses.mousemask"):
|
||||
ControlMenu(self.control).run(self.screen)
|
||||
self.control.update.assert_called_once()
|
||||
|
||||
|
||||
class CliControlTests(unittest.TestCase):
|
||||
def test_hot_restart_reconnects_original_window_after_popup_closes(self):
|
||||
args = build_parser().parse_args(["control"])
|
||||
endpoint = Endpoint.local()
|
||||
original = ManagedWindow("@17", endpoint.key, "Local", False, True, endpoint)
|
||||
other = ManagedWindow("@18", "local:other", "Other", False, True, Endpoint.local("other"))
|
||||
hub = Mock(ssh_bin="fake-ssh")
|
||||
hub.managed_windows.side_effect = [[original], [original, other]]
|
||||
def close_popup(run):
|
||||
hub.reconnect.assert_not_called()
|
||||
run.__self__.reconnect_requested = True
|
||||
return 0
|
||||
with patch("herdr_hub.cli.make_hub", return_value=hub), patch("herdr_hub.cli.curses.wrapper", side_effect=close_popup):
|
||||
self.assertEqual(command_control(args), 0)
|
||||
hub.reconnect.assert_called_once_with("@17")
|
||||
hub.wait_for_window_startup.assert_called_once_with("@17")
|
||||
|
||||
def test_active_window_metadata_selects_control_endpoint(self):
|
||||
args = build_parser().parse_args(["control"])
|
||||
hub = Mock(ssh_bin="fake-ssh")
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from herdr_hub.cli import Endpoint
|
||||
from herdr_hub.control import HerdrControl
|
||||
from herdr_hub.proxy_env import configure_application_proxy, configure_proxy, proxy_assignments, running_proxy_paths
|
||||
|
||||
|
||||
class RemoteProxyTests(unittest.TestCase):
|
||||
def test_running_mihomo_custom_config_is_discovered(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
core = root / "1277"
|
||||
core.mkdir()
|
||||
(core / "cmdline").write_bytes(
|
||||
b"/opt/mihomo-linux-arm64\0-d\0/home/orangepi/repo/clash\0-f\0/home/orangepi/repo/clash/config-systemd.yaml\0"
|
||||
)
|
||||
unrelated = root / "1278"
|
||||
unrelated.mkdir()
|
||||
(unrelated / "cmdline").write_bytes(b"python3\0-c\0mihomo\0-f\0/irrelevant.yaml\0")
|
||||
self.assertEqual(running_proxy_paths(root), [Path("/home/orangepi/repo/clash/config-systemd.yaml")])
|
||||
|
||||
def test_running_clash_listener_used_when_shell_has_no_proxy(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "clash-verge.yaml"
|
||||
path.write_text("mixed-port: 7897\nbind-address: '*'\nallow-lan: false\n")
|
||||
environment = {"no_proxy": "localhost,internal"}
|
||||
with patch("herdr_hub.proxy_env.socket.create_connection") as connect:
|
||||
source = configure_application_proxy(environment, [path])
|
||||
self.assertEqual(source, str(path))
|
||||
self.assertEqual(environment["https_proxy"], "http://127.0.0.1:7897")
|
||||
self.assertEqual(environment["no_proxy"], "localhost,internal")
|
||||
connect.assert_called_once_with(("127.0.0.1", 7897), timeout=1)
|
||||
|
||||
def test_stopped_application_and_authenticated_proxy_not_adopted(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "config.yaml"
|
||||
for contents in ("mixed-port: 7897\n", "mixed-port: 7897\nauthentication:\n - user:secret\n"):
|
||||
path.write_text(contents)
|
||||
environment = {}
|
||||
with patch("herdr_hub.proxy_env.socket.create_connection", side_effect=ConnectionRefusedError):
|
||||
self.assertIsNone(configure_application_proxy(environment, [path]))
|
||||
self.assertEqual(environment, {})
|
||||
|
||||
def test_application_detection_respects_existing_proxy_and_disable(self):
|
||||
for value in ("", "http://configured:8080"):
|
||||
environment = {"HTTPS_PROXY": value}
|
||||
with patch("herdr_hub.proxy_env.socket.create_connection") as connect:
|
||||
self.assertIsNone(configure_application_proxy(environment, [Path("/unused")]))
|
||||
connect.assert_not_called()
|
||||
self.assertEqual(environment, {"HTTPS_PROXY": value})
|
||||
|
||||
def test_literal_exports_and_uppercase_are_read_without_shell_evaluation(self):
|
||||
values = proxy_assignments('''
|
||||
export https_proxy="http://remote-user:secret@127.0.0.1:7890" # local to remote
|
||||
HTTP_PROXY=http://remote-host:8080
|
||||
export no_proxy='localhost,127.0.0.1'
|
||||
export all_proxy=$(dangerous_command)
|
||||
export all_proxy=http://other:8080; dangerous_command
|
||||
export all_proxy=http://inside-function:8080
|
||||
''')
|
||||
self.assertEqual(values, {
|
||||
"https_proxy": "http://remote-user:secret@127.0.0.1:7890",
|
||||
"http_proxy": "http://remote-host:8080",
|
||||
"no_proxy": "localhost,127.0.0.1",
|
||||
})
|
||||
|
||||
def test_remote_environment_wins_including_explicit_disable(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / ".bashrc"
|
||||
path.write_text("export https_proxy=http://file:7890\nexport all_proxy=http://fallback:7890\n")
|
||||
environment = {"HTTPS_PROXY": "http://remote-env:7890", "all_proxy": ""}
|
||||
configure_proxy(environment, [path])
|
||||
self.assertEqual(environment["https_proxy"], "http://remote-env:7890")
|
||||
self.assertEqual(environment["all_proxy"], "")
|
||||
|
||||
def test_wrapper_loads_remote_bashrc_and_never_embeds_local_proxy(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
marker = root / "should-not-exist"
|
||||
remote_proxy = "http://remote-user:remote-secret@127.0.0.1:7891"
|
||||
(root / ".bashrc").write_text(
|
||||
"case $- in *i*) ;; *) return;; esac\n"
|
||||
+ f"export https_proxy={shlex.quote(remote_proxy)}\n"
|
||||
+ f"touch {shlex.quote(str(marker))}\n"
|
||||
)
|
||||
controller = HerdrControl(Endpoint.remote("host"), "unused")
|
||||
controller.platform = "posix"
|
||||
child = shlex.join([sys.executable, "-c", "import os; assert os.environ['https_proxy'] == " + repr(remote_proxy)])
|
||||
with patch.dict(os.environ, {"https_proxy": "http://local-secret@127.0.0.1:9999"}):
|
||||
command = controller._update_proxy(["ssh", "host", child], None)
|
||||
self.assertEqual(command[:-1], ["ssh", "host"])
|
||||
self.assertNotIn("local-secret", command[-1])
|
||||
environment = {k: v for k, v in os.environ.items() if k.lower() not in {
|
||||
"http_proxy", "https_proxy", "all_proxy", "no_proxy",
|
||||
}}
|
||||
environment.update(HOME=directory, SHELL="/bin/bash")
|
||||
result = subprocess.run(["sh", "-c", command[-1]], env=environment, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("使用远端自身代理", result.stdout)
|
||||
self.assertNotIn("remote-secret", result.stdout + result.stderr)
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
def test_windows_loads_only_remote_environment_scopes(self):
|
||||
controller = HerdrControl(Endpoint.remote("windows-host"), "unused")
|
||||
controller.platform = "windows"
|
||||
with patch.dict(os.environ, {"https_proxy": "http://local-secret@127.0.0.1:9999"}):
|
||||
command = controller._update_proxy(["ssh", "windows-host", "unused"], None)
|
||||
script = base64.b64decode(command[-1].split()[-1]).decode("utf-16le")
|
||||
for scope in ("Process", "User", "Machine"):
|
||||
self.assertIn(f"GetEnvironmentVariable($key,'{scope}')", script)
|
||||
self.assertNotIn("local-secret", script)
|
||||
self.assertNotIn("-R", command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from herdr_hub.cli import Endpoint
|
||||
from herdr_hub.control import ControlError, HerdrControl
|
||||
from herdr_hub.restart import update_and_handoff
|
||||
|
||||
|
||||
class HandoffTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
identity = patch("herdr_hub.restart.binary_identity", return_value=(1, 1, 1, 1))
|
||||
identity.start()
|
||||
self.addCleanup(identity.stop)
|
||||
|
||||
def test_failed_update_never_sends_handoff(self):
|
||||
with patch("herdr_hub.restart.subprocess.check_output", return_value="status: running\nsocket: /tmp/fake.sock\n"), patch(
|
||||
"herdr_hub.restart.request", return_value={"capabilities": {"live_handoff": True}}
|
||||
) as call, patch("herdr_hub.restart.subprocess.run", side_effect=subprocess.CalledProcessError(1, "update")), contextlib.redirect_stdout(io.StringIO()):
|
||||
with self.assertRaises(subprocess.CalledProcessError):
|
||||
update_and_handoff("/fake/herdr", "named")
|
||||
self.assertEqual([c.args[1] for c in call.call_args_list], ["ping"])
|
||||
|
||||
def test_native_082_update_uses_handoff_and_does_not_repeat_it(self):
|
||||
old = {"version": "0.8.2", "capabilities": {"live_handoff": True}}
|
||||
with patch("herdr_hub.restart.subprocess.check_output", return_value="status: running\nsocket: /tmp/fake.sock\n"), patch(
|
||||
"herdr_hub.restart.request", side_effect=[old, {"version": "0.9.1"}]
|
||||
) as call, patch("herdr_hub.restart.subprocess.run") as run, patch(
|
||||
"herdr_hub.restart.installed_version", return_value="0.9.1"
|
||||
), contextlib.redirect_stdout(io.StringIO()):
|
||||
update_and_handoff("/fake/herdr", "named")
|
||||
self.assertEqual(run.call_args.args[0], ["/fake/herdr", "--session", "named", "update", "--handoff"])
|
||||
self.assertEqual([c.args[1] for c in call.call_args_list], ["ping", "ping"])
|
||||
|
||||
def test_native_handoff_failure_after_install_is_not_replayed(self):
|
||||
old = {"version": "0.8.2", "capabilities": {"live_handoff": True}}
|
||||
with patch("herdr_hub.restart.subprocess.check_output", return_value="status: running\nsocket: /tmp/fake.sock\n"), patch(
|
||||
"herdr_hub.restart.request", side_effect=[old, old]
|
||||
) as call, patch("herdr_hub.restart.subprocess.run"), patch(
|
||||
"herdr_hub.restart.installed_version", return_value="0.9.1"
|
||||
), patch("herdr_hub.restart.binary_identity", side_effect=[(1, 1, 1, 1), (1, 2, 1, 1)]), contextlib.redirect_stdout(io.StringIO()):
|
||||
with self.assertRaisesRegex(RuntimeError, "不会重复发送热切换"):
|
||||
update_and_handoff("/fake/herdr", "named")
|
||||
self.assertEqual([c.args[1] for c in call.call_args_list], ["ping", "ping"])
|
||||
|
||||
def test_unverified_handoff_is_failure_not_success(self):
|
||||
old = {"version": "0.9.0", "capabilities": {"live_handoff": True}}
|
||||
with patch("herdr_hub.restart.subprocess.check_output", return_value="status: running\nsocket: /tmp/fake.sock\n"), patch(
|
||||
"herdr_hub.restart.request", side_effect=[old, old, {}, old]
|
||||
) as call, patch("herdr_hub.restart.subprocess.run"), patch(
|
||||
"herdr_hub.restart.installed_version", return_value="0.9.1"
|
||||
), patch("herdr_hub.restart.time.monotonic", side_effect=[0, 21]), contextlib.redirect_stdout(io.StringIO()):
|
||||
with self.assertRaisesRegex(RuntimeError, "未确认新服务器就绪"):
|
||||
update_and_handoff("/fake/herdr", "named")
|
||||
self.assertEqual([c.args[1] for c in call.call_args_list].count("server.live_handoff"), 1)
|
||||
|
||||
def test_real_helper_updates_old_server_even_when_binary_already_latest(self):
|
||||
self._run_real_helper()
|
||||
|
||||
def test_real_helper_failure_does_not_stop_or_retry_server(self):
|
||||
self._run_real_helper(fail_handoff=True)
|
||||
|
||||
def _run_real_helper(self, fail_handoff=False):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
socket_path = root / "api.sock"
|
||||
record = root / "commands.jsonl"
|
||||
binary = root / "herdr"
|
||||
binary.write_text(
|
||||
f"#!{sys.executable}\n"
|
||||
"import json, os, sys\n"
|
||||
f"with open({str(record)!r}, 'a') as f: f.write(json.dumps(sys.argv[1:])+'\\n')\n"
|
||||
"if os.environ.get('HERDR_ENV') == '1': sys.exit(8)\n"
|
||||
"if sys.argv[1:] == ['--version']: print('herdr 0.9.1')\n"
|
||||
"elif sys.argv[1:] == ['--session', 'named', 'status', 'server']:\n"
|
||||
f" print('status: running\\nsocket: {socket_path}')\n"
|
||||
"elif sys.argv[1:] == ['--session', 'named', 'update', '--handoff']:\n"
|
||||
" print('already up to date (0.9.1)', flush=True)\n"
|
||||
"else: sys.exit(9)\n"
|
||||
)
|
||||
binary.chmod(0o755)
|
||||
methods = []
|
||||
done = threading.Event()
|
||||
errors = []
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(str(socket_path))
|
||||
server.listen()
|
||||
server.settimeout(.1)
|
||||
def serve():
|
||||
version = "0.9.0"
|
||||
try:
|
||||
while not done.is_set():
|
||||
try:
|
||||
connection, _ = server.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
with connection, connection.makefile("rb") as stream:
|
||||
message = json.loads(stream.readline())
|
||||
methods.append(message)
|
||||
response = {"id": message["id"]}
|
||||
if message["method"] == "server.live_handoff":
|
||||
if fail_handoff:
|
||||
response["error"] = {"message": "handoff refused"}
|
||||
else:
|
||||
version = "0.9.1"
|
||||
response["result"] = {}
|
||||
elif message["method"] == "ping":
|
||||
response["result"] = {"version": version, "capabilities": {"live_handoff": True}}
|
||||
else:
|
||||
raise AssertionError(message)
|
||||
connection.sendall((json.dumps(response) + "\n").encode())
|
||||
except Exception as error:
|
||||
errors.append(error)
|
||||
thread = threading.Thread(target=serve)
|
||||
thread.start()
|
||||
output = []
|
||||
try:
|
||||
with patch.dict(os.environ, {"HERDR_ENV": "1", "HERDR_SESSION": "wrong"}):
|
||||
control = HerdrControl(Endpoint.local("named"), str(binary))
|
||||
if fail_handoff:
|
||||
with self.assertRaisesRegex(ControlError, "handoff refused"):
|
||||
control.update_and_restart(output.append)
|
||||
else:
|
||||
result = control.update_and_restart(output.append)
|
||||
self.assertIn("服务器已确认使用 0.9.1", result)
|
||||
finally:
|
||||
done.set()
|
||||
thread.join(3)
|
||||
server.close()
|
||||
self.assertFalse(errors)
|
||||
handoffs = [m for m in methods if m["method"] == "server.live_handoff"]
|
||||
self.assertEqual(len(handoffs), 1)
|
||||
self.assertEqual(handoffs[0]["params"]["import_exe"], str(binary.resolve()))
|
||||
self.assertEqual(handoffs[0]["params"]["expected_version"], "0.9.1")
|
||||
commands = [json.loads(line) for line in record.read_text().splitlines()]
|
||||
self.assertEqual(commands.count(["--session", "named", "update", "--handoff"]), 1)
|
||||
self.assertFalse(any("stop" in command for command in commands))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,6 +8,7 @@ import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
from herdr_hub.cli import (
|
||||
BOOTSTRAP_WINDOW_NAME,
|
||||
@@ -15,11 +16,32 @@ from herdr_hub.cli import (
|
||||
HubError,
|
||||
TmuxHub,
|
||||
WINDOW_SHORT_LABEL_OPTION,
|
||||
build_parser,
|
||||
command_control,
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("tmux"), "tmux is required")
|
||||
class TmuxIntegrationTests(unittest.TestCase):
|
||||
def test_verified_handoff_reconnects_only_originating_client(self) -> None:
|
||||
self.hub.ensure_server()
|
||||
original = self.hub.create_window(Endpoint.local("first"), str(self.fake_herdr))
|
||||
other = self.hub.create_window(Endpoint.local("second"), str(self.fake_herdr))
|
||||
self.hub.select_window(original.window_id)
|
||||
def pid(window):
|
||||
return self.hub.run(["display-message", "-p", "-t", window.window_id, "#{pane_pid}"]).stdout.strip()
|
||||
original_pid, other_pid = pid(original), pid(other)
|
||||
def close_popup(run):
|
||||
self.hub.select_window(other.window_id)
|
||||
run.__self__.reconnect_requested = True
|
||||
return 0
|
||||
with patch("herdr_hub.cli.make_hub", return_value=self.hub), patch(
|
||||
"herdr_hub.cli.resolve_herdr", return_value=str(self.fake_herdr)
|
||||
), patch("herdr_hub.cli.curses.wrapper", side_effect=close_popup):
|
||||
self.assertEqual(command_control(build_parser().parse_args(["control"])), 0)
|
||||
self.assertNotEqual(pid(original), original_pid)
|
||||
self.assertEqual(pid(other), other_pid)
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary_directory.cleanup)
|
||||
|
||||
Reference in New Issue
Block a user