From baee179edaece4aa41fa7be57394a9618fd03d0f Mon Sep 17 00:00:00 2001 From: 370025263 <370025263@qq.com> Date: Sun, 12 Jul 2026 17:54:45 +0800 Subject: [PATCH 1/9] =?UTF-8?q?docs(openspec):=20=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=B8=B8=E9=A9=BB=E5=85=BC=E5=AE=B9=E6=80=A7=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E6=8F=90=E6=A1=88=20|=20cross-platform=20persistence?= =?UTF-8?q?=20proposal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 能力探测式后端选择、WSL 去武断化(废除无 systemd 硬失败)、鸿蒙识别、 supervised watchdog 崩溃自愈、cron/WSL-interop 开机自启、updater 健康 检查+回滚+坏版本拉黑,以及多端测试矩阵设计。 Co-Authored-By: Claude Fable 5 --- .../cross-platform-persistence/.openspec.yaml | 2 + .../cross-platform-persistence/design.md | 84 ++++++++++++++++++ .../cross-platform-persistence/proposal.md | 88 +++++++++++++++++++ .../cross-platform-persistence/tasks.md | 14 +++ 4 files changed, 188 insertions(+) create mode 100644 openspec/changes/cross-platform-persistence/.openspec.yaml create mode 100644 openspec/changes/cross-platform-persistence/design.md create mode 100644 openspec/changes/cross-platform-persistence/proposal.md create mode 100644 openspec/changes/cross-platform-persistence/tasks.md diff --git a/openspec/changes/cross-platform-persistence/.openspec.yaml b/openspec/changes/cross-platform-persistence/.openspec.yaml new file mode 100644 index 00000000..8803b473 --- /dev/null +++ b/openspec/changes/cross-platform-persistence/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-12 diff --git a/openspec/changes/cross-platform-persistence/design.md b/openspec/changes/cross-platform-persistence/design.md new file mode 100644 index 00000000..30e5c756 --- /dev/null +++ b/openspec/changes/cross-platform-persistence/design.md @@ -0,0 +1,84 @@ +# design — cross-platform-persistence + +## 1. 能力探测层(service.py) + +| 探测函数 | 判定 | 备注 | +|---|---|---| +| `_is_wsl()` | 现有实现不变 | env + /proc osrelease | +| `_is_harmony()` | os-release `ID`/`ID_LIKE` ∈ {harmonyos, openharmony, ohos} 或 `uname -r` 含 `ohos` | 只读文件,无子进程 | +| `_linux_flavor()` | `"wsl" \| "harmony" \| "linux"` | wsl 优先(WSL 里跑鸿蒙容器不现实) | +| `_systemd_user_available()` | 现有实现不变 | `systemctl --user show-environment` | +| `_crontab_available()` | `which crontab` 且 `crontab -l` 退出码 ∈ {0,1}(1=「no crontab for user」) | timeout 5s | +| `_wsl_interop_available()` | `which wsl.exe` 且 `which schtasks.exe`(WSL interop 开启时 Windows PATH 自动追加) | 不主动执行 .exe | + +所有探测失败 = 能力缺失,不抛异常。探测结果进 status 的 `degraded` 说明。 + +## 2. SupervisedProcessBackend(新增,method="supervised") + +- `install_and_start()`: + 1. 已 running 则幂等返回; + 2. detach 拉起 watchdog:` -m xskill connect --supervise`(`start_new_session=True`, + 日志 `~/.xskill/logs/connect-supervisor.log`); + 3. 按 flavor 挂开机自启(见 §3),失败仅记 `degraded`; + 4. 等待 state 文件出现 child_pid(最多 10s)后返回 status。 +- watchdog 主体在新模块 `supervisor.py::run_supervisor()`: + - 循环 spawn ` -m xskill connect --foreground`(env 加 `XSKILL_SUPERVISED=1`), + 每次 spawn 后把 `child_pid` 写回 daemon state; + - 退避:初值 1s ×2 递增、封顶 300s;子进程存活 ≥600s 则退避归零; + - **停止语义**:收到 SIGTERM → 给 child SIGTERM(5s 后 SIGKILL)→ 清 state → 退出; + - **防双跑**:启动时若 state 里 watchdog_pid 存活则直接退出(幂等)。 +- `stop()`:SIGTERM watchdog(兜底再杀 child)+ 卸开机自启挂载 + 清 state。 +- `status()`:`running` = watchdog 活 && child 活;单侧死亡分别汇报 + (`watchdog_alive` / `child_alive`),便于诊断。 + +## 3. 开机自启挂载(可插拔,随 supervised/systemd 后端组合) + +| flavor | 机制 | 装 | 卸 | +|---|---|---|---| +| linux/harmony | crontab `@reboot -m xskill start --quiet # xskill-connect` | 读现 crontab,去重后追加 marker 行 | 按 marker 过滤重写 | +| wsl | `schtasks.exe /Create /TN Xskill_WSL_Boot /SC ONLOGON /TR "wsl.exe -d -u -- -m xskill start --quiet"` | interop 调用 | `schtasks.exe /Delete` | +| 任一失败 | 记 `boot_autostart: "none"` + `degraded` 警告 | — | — | + +`xskill start --quiet`:已 running 时静默退出 0(幂等),使自启触发器可无脑重复执行。 +WSL + systemd 组合同样挂 Windows 任务(systemd 只解决「VM 内」自启;VM 本身要 Windows 拉)。 + +## 4. LinuxServiceBackend 选择链(重写) + +``` +install: systemd? ──yes─► SystemdUserBackend(linger 失败→警告不阻断) + └─no──► SupervisedProcessBackend +override: XSKILL_CONNECT_BACKEND ∈ {systemd, supervised, detached} +stop/status: 先按 state.method 路由(接管旧安装态),state 缺失再按探测链 +``` + +`WSLSystemdRequiredBackend` 删除;`DetachedProcessBackend` 保留但仅显式 override 可达。 + +## 5. updater 加固 + +- `_health_check(python) -> bool`:`subprocess.run([python, "-m", "xskill", "--version"], + timeout=60)`,returncode==0。 +- `_install` 成功后:health check 失败 → `pip install xskill==<升级前版本>` 回滚(同一 + `_PIP_TIMEOUT` 约束)→ journal 记 `bad_versions[target] = {ts, reason}`;回滚也失败则 + critical 日志 + 不重启(宁可跑旧代码在内存里,也不重启进坏版本)。 +- journal:`~/.xskill/update_journal.json`,损坏容忍(解析失败视为空);`_check_and_update` + 对 PyPI/server 候选版本先过 `bad_versions` 滤网。健康升级成功记 `last_good`。 +- `_restart()`:`XSKILL_SUPERVISED=1` 时全平台统一 `os._exit(1)` 交给 watchdog; + 其余路径维持现状(execv / schtasks exit-1 / startup_folder spawn)。 + +## 6. 兼容与迁移 + +- state 文件新增键(watchdog_pid/child_pid/boot_autostart/flavor)全部增量;旧 state 的 + `method ∈ {systemd-user, detached, schtasks, startup_folder}` 继续被识别。 +- 旧 detached 安装态在下次 `xskill start` 时被停掉并迁移到新链路(沿用现有迁移逻辑)。 +- Windows startup_folder 的 .vbs 内容从 `connect --foreground` 换成 `connect --supervise`; + 旧 .vbs 无需主动迁移,下次 start 重写。 + +## 7. 测试设计 + +- **单测**(全 OS 可跑,无真进程):探测矩阵、选择链矩阵、cron marker 幂等、interop 命令 + 拼装、journal 读写与滤网、_restart 分支路由。 +- **进程级 e2e**(Linux):supervised 全生命周期 + kill child 自愈 + stop 全清理; + 伪 crontab(PATH shim)验证 @reboot 装卸。 +- **windows e2e**(CI windows-latest):schtasks 真装卸 + status。 +- **docker 矩阵**:ubuntu:24.04 / debian:12 / openEuler(+os-release 覆写模拟鸿蒙), + 容器内无 systemd → 必然落 supervised 链,跑同一套 e2e 用例。 diff --git a/openspec/changes/cross-platform-persistence/proposal.md b/openspec/changes/cross-platform-persistence/proposal.md new file mode 100644 index 00000000..2411e85d --- /dev/null +++ b/openspec/changes/cross-platform-persistence/proposal.md @@ -0,0 +1,88 @@ +# 跨平台常驻兼容性升级:能力探测式 fallback + 产品级自愈与更新回滚 + +## Why + +`xskill connect` 的常驻能力目前按「平台名」硬编码策略,存在四个武断点,导致 +Windows / WSL / Ubuntu / 鸿蒙 / 其他 Linux 上体验参差: + +1. **WSL 无 systemd 直接硬失败**(`WSLSystemdRequiredBackend`)。这个策略双重错误: + - 过苛:用户明明可以以「会话内常驻 + 崩溃自愈」的降级模式运行,却被一刀切拒绝; + - 没解决真问题:即使 systemd + linger 齐备,**Windows 重启后 WSL VM 也不会自动拉起** + ——WSL 的开机自启只能靠 Windows 侧触发器(计划任务/启动项经 interop 调 `wsl.exe`), + 现行实现完全没有这一层。 +2. **detached 降级无崩溃自愈**(`restart_policy: none`)。无 systemd 的 Linux(精简容器、 + 老发行版、鸿蒙)上进程一崩就死透,与「常驻」的承诺不符。 +3. **鸿蒙(HarmonyOS/OpenHarmony)零适配**。鸿蒙终端是 Linux 内核 + 无 systemd 用户态, + 现行代码把它当普通 Linux,直接掉进无自愈的 detached。 +4. **自动更新无健康检查、无回滚**。pip 装上一个坏 wheel(半残依赖、二进制不兼容 + ——鸿蒙/老 glibc 上很现实)后直接重启,进程起不来 → systemd/schtasks 无限重启循环, + 且 updater 永远不会重试回好版本。 + +## What Changes + +### 1. 设计原则:按「能力探测」选择后端,平台名只用于提示与遥测 + +后端选择不再 `if 平台名 == X 则拒绝/允许`,而是逐项探测能力(systemd --user 可用? +crontab 可用?WSL interop 可用?),按优先级取第一个可用项;每一级降级都在 +`xskill status` 里如实汇报(新增 `crash_recovery` / `boot_autostart` / `degraded` 字段), +**绝不伪装成完整常驻,也绝不因为不完美而拒绝服务**。 + +### 2. Linux 族(linux / wsl / harmony 统一链路) + +``` +systemd --user 可用 ──► SystemdUserBackend(自愈=systemd,自启=linger) + │ linger 失败 → 降级警告,不再硬失败 + ▼ 不可用 +SupervisedProcessBackend(新增) + watchdog 进程托管 connect --foreground,指数退避自动重启(自愈=watchdog) + 开机自启按 flavor 补挂: + wsl → Windows 计划任务经 interop 调 `wsl.exe -d … xskill start` + linux/harmony → crontab @reboot(marker 管理,幂等装卸) + 探测不可用 → status.degraded 明示「不随开机自启」,仍正常常驻 +``` + +- WSL + systemd 场景同样补挂 Windows 侧计划任务(否则 Windows 重启后 unit 不会跑)。 +- 鸿蒙识别:`/etc/os-release` 的 `ID/ID_LIKE ∈ {harmonyos, openharmony, ohos}` 或 + `uname -r` 带 `-ohos`;识别结果仅影响提示文案与自启挂载方式,主链路与 Linux 一致。 +- `XSKILL_CONNECT_BACKEND` 支持 `systemd|supervised|detached` 显式覆盖(原有 `detached` + 语义保留:裸 detached 仍可选,但不再是默认降级)。 + +### 3. Windows 原生路径加固 + +schtasks 主路径不变;Group Policy 拒绝后的「启动文件夹」降级从裸 `connect --foreground` +改为拉 supervisor —— 降级路径同样获得崩溃自愈。 + +### 4. 自动更新产品级加固(updater) + +- **升级后健康检查**:pip 安装成功后、重启前,用子进程跑 ` -m xskill --version` + 验证新版本可导入可执行;失败即 **pip 回滚到升级前版本**。 +- **坏版本拉黑**:健康检查失败/回滚的版本记入 `~/.xskill/update_journal.json`, + 后续检查跳过该版本,杜绝「升级→崩→回滚→再升级」死循环。 +- **supervisor 感知的重启**:被 watchdog 托管时(`XSKILL_SUPERVISED=1`)统一以非零退出码 + 重启,由 watchdog 用新版本拉起,不再自行 spawn 孤儿进程。 + +### 5. 多端测试 + +- 单测:平台×能力矩阵(wsl/harmony/linux × systemd/cron/interop/裸),全部 monkeypatch, + 三大 OS 的 CI ut-it 矩阵均可跑;重写 `test_wsl_persistence_policy.py` 以匹配新策略。 +- e2e(Linux CI + 本地):supervised 链路「start → 杀 connect 子进程 → watchdog 自动拉起 + → stop 全清理」真实进程验证;updater 回滚用假 PyPI + 坏包验证。 +- **docker 发行版矩阵**(`tests/docker_e2e/platform_matrix/`):ubuntu:24.04、debian:12、 + openEuler(鸿蒙用户态最近似,另以覆写 os-release 模拟 harmony 识别),容器内跑同一套 + lifecycle + 自愈 e2e。 +- CI:connect-lifecycle e2e 扩到 windows-latest(真 schtasks);docker 矩阵挂 + nightly/workflow_dispatch。 + +## Impact + +- 受影响模块:`team/client/service.py`(重构选择链)、新增 `team/client/supervisor.py`、 + `team/client/updater.py`(健康检查/回滚/journal)、`cli.py`(`--supervise` 隐藏 flag、 + status 新字段渲染)、CI workflow。 +- 行为变化(面向用户): + - WSL 无 systemd:从报错拒绝 → 正常常驻(自愈 by watchdog)+ 尽力挂 Windows 自启; + - 无 systemd Linux/鸿蒙:崩溃自愈从无到有; + - `xskill status` 多出 `flavor/crash_recovery/boot_autostart/degraded` 字段(增量, + 不破坏既有字段); + - 坏版本更新不再导致服务瘫痪。 +- 兼容性:既有 systemd/schtasks 安装态可原位接管(state 文件 `method` 向后兼容); + `XSKILL_CONNECT_BACKEND=detached` 行为不变。 diff --git a/openspec/changes/cross-platform-persistence/tasks.md b/openspec/changes/cross-platform-persistence/tasks.md new file mode 100644 index 00000000..0141da65 --- /dev/null +++ b/openspec/changes/cross-platform-persistence/tasks.md @@ -0,0 +1,14 @@ +# tasks — cross-platform-persistence + +- [x] 1. 能力探测层:`_is_harmony` / `_linux_flavor` / `_crontab_available` / `_wsl_interop_available` +- [x] 2. `supervisor.py`:watchdog 循环(退避、child_pid 回写、SIGTERM 级联、防双跑) +- [x] 3. `SupervisedProcessBackend`(装/停/看 + 幂等) +- [x] 4. 开机自启挂载:cron @reboot marker 管理 + WSL interop schtasks 任务 +- [x] 5. `LinuxServiceBackend` 选择链重写;删 `WSLSystemdRequiredBackend`;linger 失败降级为警告 +- [x] 6. Windows startup_folder 降级改走 supervisor +- [x] 7. `cli.py`:`connect --supervise` 隐藏 flag、`start --quiet`、status 渲染新字段 +- [x] 8. updater:health check + 回滚 + update_journal 拉黑 + supervisor 感知 `_restart` +- [x] 9. 单测:探测/选择链/cron/interop/journal 矩阵;重写 `test_wsl_persistence_policy.py` +- [x] 10. e2e:supervised 自愈生命周期(Linux);windows-latest lifecycle +- [x] 11. docker 发行版矩阵(ubuntu/debian/openEuler+鸿蒙模拟)脚本与文档 +- [x] 12. CI 接线:windows lifecycle job、platform-matrix nightly job From c4db88b68faa0b499db72ef8b2bf3100c8fe503a Mon Sep 17 00:00:00 2001 From: 370025263 <370025263@qq.com> Date: Sun, 12 Jul 2026 17:54:45 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat(client):=20=E8=83=BD=E5=8A=9B=E6=8E=A2?= =?UTF-8?q?=E6=B5=8B=E5=BC=8F=E5=B8=B8=E9=A9=BB=20fallback=20=E9=93=BE=20+?= =?UTF-8?q?=20supervisor=20=E5=B4=A9=E6=BA=83=E8=87=AA=E6=84=88=20|=20capa?= =?UTF-8?q?bility-probed=20persistence=20chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 supervisor.py:watchdog 指数退避拉起 connect --foreground, 无 systemd 平台(精简 Linux/未启 systemd 的 WSL/鸿蒙)的自愈层 - LinuxServiceBackend 重写:systemd → supervised 能力降级;废除 WSLSystemdRequiredBackend 硬失败;linger 失败降级为警告 - 开机自启挂载与后端正交:WSL 经 interop 挂 Windows 计划任务 (systemd+linger 也管不了 Windows 重启后 VM 不自启,必须 Windows 侧触发);linux/鸿蒙用 crontab @reboot(marker 幂等管理) - 鸿蒙识别(os-release ID/ID_LIKE ∈ harmonyos/openharmony/ohos), 仅影响提示与自启方式,主链路与 Linux 一致 - Windows startup_folder 降级改拉 supervisor——降级路径也有自愈 - status 新增 flavor/crash_recovery/boot_autostart/degraded 字段, 每级降级如实汇报 - _pid_alive 识别僵尸态:容器里 PID1 不收割孤儿,Z 态误判存活会让 status 误报 running - cli: connect --supervise(内部)、start --quiet(自启触发器幂等入口) Co-Authored-By: Claude Fable 5 --- src/xskill/cli.py | 59 ++- src/xskill/team/client/service.py | 533 +++++++++++++++++++++++---- src/xskill/team/client/supervisor.py | 178 +++++++++ 3 files changed, 690 insertions(+), 80 deletions(-) create mode 100644 src/xskill/team/client/supervisor.py diff --git a/src/xskill/cli.py b/src/xskill/cli.py index 2fa38638..81a2cb4c 100644 --- a/src/xskill/cli.py +++ b/src/xskill/cli.py @@ -188,6 +188,17 @@ def cmd_connect(args) -> int: state_path = get_team_client_state_path() + # watchdog 模式:不握手,直接进 supervisor 循环(由它拉 --foreground + # 子进程并崩溃自愈)。仅供 service 后端内部拉起,不面向用户。 + if getattr(args, "supervise", False): + if not state_path.is_file(): + print("error: 尚未连接过 server,supervisor 无从拉起。先跑:\n" + " xskill connect --token ", + file=sys.stderr) + return 2 + from xskill.team.client.supervisor import run_supervisor + return run_supervisor() + if args.address: state = _connect_handshake(args, state_path) if state is None: @@ -325,12 +336,21 @@ def _print_connect_status(st: dict, as_json: bool) -> None: print(f" method : {st['method']}") if st.get("pid"): print(f" pid : {st['pid']}") + if st.get("watchdog_pid"): + print(f" watchdog : {st['watchdog_pid']}" + + ("" if st.get("watchdog_alive", True) else " (dead)")) + if st.get("crash_recovery"): + print(f" 自愈 : {st['crash_recovery']}") + if st.get("boot_autostart"): + print(f" 开机自启 : {st['boot_autostart']}") if st.get("log_path"): print(f" log : {st['log_path']}") if st.get("server_url"): print(f" server : {st['server_url']}") if st.get("client_id"): print(f" client_id: {st['client_id']}") + for msg in st.get("degraded") or []: + print(f" degraded : {msg}") if st.get("warning"): print(f" warning : {st['warning']}") @@ -339,13 +359,27 @@ def cmd_start(args) -> int: """安装并启动 connect 常驻任务(未 connect 过则提示先 connect)。""" from xskill.config import get_team_client_state_path from xskill.team.client.service import ServiceError, get_backend + quiet = getattr(args, "quiet", False) if not get_team_client_state_path().is_file(): - print("error: 尚未连接过 server。先跑一次:\n" - " xskill connect --token ", - file=sys.stderr) + if not quiet: + print("error: 尚未连接过 server。先跑一次:\n" + " xskill connect --token ", + file=sys.stderr) return 2 + backend = get_backend() + if quiet: + # 自启触发器(cron @reboot / Windows 计划任务)幂等入口:已在跑 + # 什么都不做;没在跑就装起,但保持静默(触发器无人看输出)。 + try: + if backend.status().get("running"): + return 0 + backend.install_and_start() + return 0 + except ServiceError as e: + print(f"error: {e}", file=sys.stderr) + return 1 try: - st = get_backend().install_and_start() + st = backend.install_and_start() except ServiceError as e: print(f"error: {e}", file=sys.stderr) return 1 @@ -376,8 +410,11 @@ def cmd_update(args) -> int: except Exception: pass print(f"发现新版本: {latest},开始升级...") - if not AutoUpdater()._install(latest): - print("error: 升级失败,请检查 pip 配置和日志", file=sys.stderr) + # install_and_verify:装完先健康检查,坏版本自动回滚 + 拉黑, + # 手动 update 与后台自动更新同一套安全网。 + if not AutoUpdater().install_and_verify(latest, current): + print("error: 升级失败(或新版本健康检查未通过已回滚)," + "详见日志", file=sys.stderr) return 1 print(f"升级到 {latest} 成功,正在重启...") _restart() @@ -662,11 +699,21 @@ def build_parser() -> argparse.ArgumentParser: "--no-auto-update", action="store_true", dest="no_auto_update", help="禁用自动更新检查(默认每小时查一次 PyPI,有新版则升级重启)。", ) + p_conn.add_argument( + # 内部形态:supervisor watchdog 主体(team.client.supervisor)。由 + # service 后端在无 systemd 平台上 detach 拉起,用户无需手动使用。 + "--supervise", action="store_true", help=argparse.SUPPRESS, + ) p_start = sub.add_parser( "start", help="把 connect 装成后台常驻(开机自启 + 崩溃自愈)", ) p_start.add_argument("--json", action="store_true", help="机读 JSON 输出") + p_start.add_argument( + "--quiet", action="store_true", + help="已在运行则静默退出 0;供开机自启触发器(cron @reboot / Windows" + " 计划任务)幂等调用。", + ) p_stop = sub.add_parser("stop", help="停止并撤销 connect 常驻任务") p_stop.add_argument("--json", action="store_true", help="机读 JSON 输出") diff --git a/src/xskill/team/client/service.py b/src/xskill/team/client/service.py index d253c746..88217507 100644 --- a/src/xskill/team/client/service.py +++ b/src/xskill/team/client/service.py @@ -5,14 +5,21 @@ 的原生守护设施。本模块把这层抽象成可插拔后端: ConnectServiceBackend 抽象基类(含共享 pid/state 读写 + 存活校验) - └─ WindowsTaskSchedulerBackend Windows「计划任务」(schtasks) —— 本 MR 完整实现 - └─ LinuxServiceBackend Linux/WSL 平台选择 - ├─ SystemdUserBackend systemd --user(WSL 必需) - └─ DetachedProcessBackend 仅普通 Linux 的降级 + └─ WindowsTaskSchedulerBackend Windows「计划任务」(schtasks) + + Group Policy 拒绝时降级启动文件夹(supervisor) + └─ LinuxServiceBackend Linux 族(linux/wsl/harmony)能力探测选择 + ├─ SystemdUserBackend systemd --user 可用时的首选 + ├─ SupervisedProcessBackend 无 systemd 的降级:watchdog 崩溃自愈 + └─ DetachedProcessBackend 裸 detached,仅显式 override 可达 └─ LaunchdBackend macOS launchd LaunchAgent —— TODO(占位) CLI (``xskill start/stop/status``) 只跟 ``get_backend()`` 打交道,不关心平台。 +选择原则:按「能力探测」(systemd 可用?crontab 可用?WSL interop 可用?)逐级 +降级,平台名(wsl/harmony/linux)只影响提示文案与开机自启的挂载方式;每一级降级 +都在 status 的 ``crash_recovery`` / ``boot_autostart`` / ``degraded`` 里如实汇报, +不伪装成完整常驻,也不因为不完美而拒绝服务。 + 设计约定 ──────── - 常驻任务实际执行的是 `` -m xskill connect --foreground``:``--foreground`` @@ -49,6 +56,9 @@ SYSTEMD_UNIT_NAME = "xskill-connect.service" +WINDOWS_WSL_BOOT_TASK = "Xskill_WSL_Boot" + + class ServiceError(RuntimeError): """后端操作失败(含平台不支持)。CLI 捕获后打印 message 即可。""" @@ -58,7 +68,12 @@ class ServiceError(RuntimeError): # ═══════════════════════════════════════════════════════════════ def _pid_alive(pid: Optional[int]) -> bool: - """pid 是否存活。与 runtime._alive 同款:signal 0 探测,权限错也算活。""" + """pid 是否存活。signal 0 探测,权限错也算活;Linux 上僵尸视为死。 + + 容器/精简环境里 PID 1 常不收割孤儿(bash/应用直接当 init),被停掉的 + watchdog 会长期滞留为僵尸——signal 0 对僵尸返回成功,若不识别 Z 态, + status 会误报 running、stop 会对尸体空等 + 无谓 SIGKILL。 + """ if not isinstance(pid, int) or pid <= 0: return False if sys.platform == "win32": @@ -69,6 +84,14 @@ def _pid_alive(pid: Optional[int]) -> bool: return False except PermissionError: return True + if sys.platform.startswith("linux"): + try: + stat = Path(f"/proc/{pid}/stat").read_text(encoding="ascii") + # comm 可含空格/括号,状态字段取最后一个 ')' 之后的首个 token + if stat.rsplit(")", 1)[1].split()[0] == "Z": + return False + except (OSError, IndexError): + pass return True @@ -125,6 +148,33 @@ def clear_daemon_state() -> None: pass +def update_daemon_state(**fields) -> None: + """合并写运行态:保留已有键,仅覆盖传入键。 + + 后端与 supervisor watchdog 会先后写同一个 state 文件(后端写 method/ + backend,watchdog 补 watchdog_pid/child_pid)——整文件覆盖会互相抹掉 + 对方的键,必须 read-merge-write。文件损坏时退化为全新写入。 + """ + path = get_connect_daemon_state_path() + current: dict = {} + if path.is_file(): + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + current = loaded + except (OSError, ValueError): + current = {} + current.pop("running", None) # 派生字段不落盘 + current.update(fields) + current.setdefault("started_at", int(time.time())) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(current, ensure_ascii=False), + encoding="utf-8") + except OSError: + logger.debug("update connect daemon state failed", exc_info=True) + + def _foreground_argv() -> list[str]: """常驻任务真正执行的命令:`` -m xskill connect --foreground``。 @@ -138,6 +188,12 @@ def _foreground_argv() -> list[str]: return [exe, "-m", "xskill", "connect", "--foreground"] +def _supervise_argv() -> list[str]: + """watchdog 进程的命令:`` -m xskill connect --supervise``。""" + argv = _foreground_argv() + return argv[:-1] + ["--supervise"] + + # ═══════════════════════════════════════════════════════════════ # 后端抽象 # ═══════════════════════════════════════════════════════════════ @@ -355,9 +411,11 @@ def _install_startup_folder_and_spawn(self, argv: list[str]) -> dict: - 持久化:.vbs 在 %APPDATA%\\...\\Startup\\,用户登录即自动执行 - 无窗口:WScript.Shell.Run(..., 0, False) 隐藏 CMD 窗口 - 立即启动:用 subprocess.Popen CREATE_NO_WINDOW|DETACHED_PROCESS - - 缺点(相比计划任务):无崩溃自愈重启,但日常运行足够稳定。 + - 崩溃自愈:.vbs 与 detach 拉起的都是 supervisor watchdog + (connect --supervise),schtasks RestartOnFailure 的用户态等价物。 """ + del argv # 调主任务用 foreground argv;本降级路径固定走 supervisor。 + watchdog_argv = _supervise_argv() vbs_path = _startup_vbs_path() if vbs_path is None: raise ServiceError( @@ -367,7 +425,8 @@ def _install_startup_folder_and_spawn(self, argv: list[str]) -> dict: ) try: vbs_path.parent.mkdir(parents=True, exist_ok=True) - vbs_path.write_text(_build_startup_vbs(argv), encoding="utf-8") + vbs_path.write_text(_build_startup_vbs(watchdog_argv), + encoding="utf-8") except OSError as e: raise ServiceError(f"写开机启动脚本失败:{e}") from e @@ -376,7 +435,7 @@ def _install_startup_folder_and_spawn(self, argv: list[str]) -> dict: DETACHED_PROCESS = 0x00000008 try: proc = subprocess.Popen( - argv, + watchdog_argv, creationflags=CREATE_NO_WINDOW | DETACHED_PROCESS, close_fds=True, stdin=subprocess.DEVNULL, @@ -388,8 +447,10 @@ def _install_startup_folder_and_spawn(self, argv: list[str]) -> dict: raise ServiceError(f"启动进程失败:{e}") from e write_daemon_state(method="startup_folder", backend=self.name, - vbs_path=str(vbs_path), argv=argv, pid=pid) - logger.info("startup folder 方案安装成功:vbs=%s pid=%s", vbs_path, pid) + vbs_path=str(vbs_path), argv=watchdog_argv, + watchdog_pid=pid, pid=pid) + logger.info("startup folder 方案安装成功:vbs=%s watchdog pid=%s", + vbs_path, pid) return self.status() def stop(self) -> dict: @@ -405,14 +466,16 @@ def stop(self) -> dict: Path(vbs).unlink(missing_ok=True) except OSError: pass - # 2. 按 pid 杀进程 - pid = state.get("pid") - if pid and _pid_alive(pid): - try: - subprocess.run(["taskkill", "/PID", str(pid), "/F"], - capture_output=True, check=False) - except OSError: - pass + # 2. 杀 watchdog 进程树(/T 连 connect 子进程一起) + for pid in {state.get("watchdog_pid"), state.get("pid"), + state.get("child_pid")}: + if pid and _pid_alive(pid): + try: + subprocess.run( + ["taskkill", "/PID", str(pid), "/T", "/F"], + capture_output=True, check=False) + except OSError: + pass clear_daemon_state() return {"running": False, "backend": self.name, "method": method} @@ -432,7 +495,7 @@ def status(self) -> dict: method = state.get("method", "schtasks") if method == "startup_folder": - pid = state.get("pid") + wpid = state.get("watchdog_pid") or state.get("pid") vbs = state.get("vbs_path") or str(_startup_vbs_path() or "") installed = bool(vbs and Path(vbs).is_file()) return { @@ -440,8 +503,11 @@ def status(self) -> dict: "backend": self.name, "method": method, "vbs_path": vbs, - "pid": pid, - "running": _pid_alive(pid), + "pid": state.get("child_pid") or wpid, + "watchdog_pid": wpid, + "child_alive": _pid_alive(state.get("child_pid")), + "running": _pid_alive(wpid), + "crash_recovery": "watchdog", "server_url": state.get("server_url"), "client_id": state.get("client_id"), "started_at": state.get("started_at"), @@ -464,6 +530,7 @@ def status(self) -> dict: "server_url": state.get("server_url"), "client_id": state.get("client_id"), "started_at": state.get("started_at"), + "crash_recovery": "schtasks", "schtasks_query": q.stdout.strip(), } @@ -482,7 +549,7 @@ def _query_pid(self) -> Optional[int]: # ═══════════════════════════════════════════════════════════════ -# Linux / WSL:systemd --user + detached 降级 +# Linux 族(linux / wsl / harmony):平台与能力探测 # ═══════════════════════════════════════════════════════════════ def _is_wsl() -> bool: @@ -496,28 +563,50 @@ def _is_wsl() -> bool: return "microsoft" in release.lower() -def _linux_platform_name() -> str: - return "wsl" if _is_wsl() else "linux" +_HARMONY_IDS = {"harmonyos", "openharmony", "ohos"} -def _wsl_systemd_required_message() -> str: - return ( - "WSL 常驻要求启用 systemd,不能降级为 detached。\n" - " 请在 /etc/wsl.conf 中配置:\n" - " [boot]\n" - " systemd=true\n" - " 然后从 Windows 执行 `wsl --shutdown`,重新进入 WSL 后再运行 " - "`xskill start`。" - ) +def _is_harmony(os_release_path: str = "/etc/os-release") -> bool: + """当前 Linux 是否鸿蒙用户态(HarmonyOS / OpenHarmony)。 + + 鸿蒙终端 = Linux 内核 + 自有 init(无 systemd)。识别只影响提示文案与 + 开机自启挂载方式,常驻主链路与普通无 systemd Linux 完全一致。 + """ + try: + text = Path(os_release_path).read_text(encoding="utf-8") + except OSError: + text = "" + for line in text.splitlines(): + key, sep, value = line.partition("=") + if sep and key.strip() in ("ID", "ID_LIKE"): + values = value.strip().strip('"').lower().split() + if _HARMONY_IDS & set(values): + return True + return "ohos" in os.uname().release.lower() if hasattr(os, "uname") else False + + +def _linux_flavor() -> str: + """``"wsl" | "harmony" | "linux"``。wsl 判定优先(interop 语义更特殊)。""" + if _is_wsl(): + return "wsl" + if _is_harmony(): + return "harmony" + return "linux" + + +def _linux_platform_name() -> str: + return _linux_flavor() def _systemd_user_available() -> bool: """用户级 systemd manager 是否可用。 - WSL 只有在 /etc/wsl.conf 启用 systemd 后才满足;普通 Linux 的精简容器或 - 没有 user bus 的 SSH 环境也会返回 False,随后使用 detached 后端。 + WSL 只有在 /etc/wsl.conf 启用 systemd 后才满足;普通 Linux 的精简容器、 + 没有 user bus 的 SSH 环境、鸿蒙终端都会返回 False,随后落到 supervised + watchdog 链路。 """ - if os.environ.get("XSKILL_CONNECT_BACKEND", "").strip().lower() == "detached": + if (os.environ.get("XSKILL_CONNECT_BACKEND", "").strip().lower() + in ("detached", "supervised")): return False if not shutil.which("systemctl"): return False @@ -531,6 +620,164 @@ def _systemd_user_available() -> bool: return cp.returncode == 0 +# ═══════════════════════════════════════════════════════════════ +# 开机自启挂载(与 systemd/supervised 后端正交的能力层) +# ═══════════════════════════════════════════════════════════════ +# +# WSL 的关键事实:即使发行版内 systemd + linger 齐备,Windows 重启后 WSL VM +# 也不会自动拉起——「开机自启」只能靠 Windows 侧触发器经 interop 调 wsl.exe。 +# 普通 Linux/鸿蒙无 systemd 时则用 crontab @reboot。两者挂的都是幂等的 +# ``xskill start --quiet``(已在跑则静默退出),触发器可无脑重复执行。 + +_CRON_MARKER = "# xskill-connect-boot" + + +def _boot_start_command() -> str: + return shlex.join([sys.executable or "python", "-m", "xskill", + "start", "--quiet"]) + + +def _crontab_available() -> bool: + """crontab 可用 = 命令存在且能读(退出码 0 或 1=「no crontab for user」)。""" + if not shutil.which("crontab"): + return False + try: + cp = subprocess.run(["crontab", "-l"], capture_output=True, + text=True, check=False, timeout=5) + except (OSError, subprocess.SubprocessError): + return False + return cp.returncode in (0, 1) + + +def _read_crontab_lines() -> list[str]: + try: + cp = subprocess.run(["crontab", "-l"], capture_output=True, + text=True, check=False, timeout=5) + except (OSError, subprocess.SubprocessError): + return [] + return cp.stdout.splitlines() if cp.returncode == 0 else [] + + +def _write_crontab_lines(lines: list[str]) -> bool: + text = "\n".join(lines) + if text and not text.endswith("\n"): + text += "\n" + try: + cp = subprocess.run(["crontab", "-"], input=text, capture_output=True, + text=True, check=False, timeout=5) + except (OSError, subprocess.SubprocessError): + return False + return cp.returncode == 0 + + +def _install_cron_boot() -> bool: + """幂等挂 ``@reboot … xskill start --quiet`` 行(marker 去重)。""" + if not _crontab_available(): + return False + lines = [ln for ln in _read_crontab_lines() if _CRON_MARKER not in ln] + lines.append(f"@reboot {_boot_start_command()} {_CRON_MARKER}") + return _write_crontab_lines(lines) + + +def _remove_cron_boot() -> None: + if not _crontab_available(): + return + lines = _read_crontab_lines() + kept = [ln for ln in lines if _CRON_MARKER not in ln] + if kept != lines: + _write_crontab_lines(kept) + + +def _wsl_interop_available() -> bool: + """WSL interop 是否可调 Windows 侧工具(wsl.exe + schtasks.exe 在 PATH)。 + + WSL 默认把 Windows PATH 追加进来;interop 被 /etc/wsl.conf 关闭或用户 + 精简了 PATH 时探测失败——此时开机自启降级为「无」并在 status 里明示。 + """ + return bool(shutil.which("wsl.exe") and shutil.which("schtasks.exe")) + + +def _install_wsl_boot_task() -> bool: + """经 interop 在 Windows 侧挂登录触发任务:wsl.exe 里跑 xskill start。 + + schtasks.exe /SC ONLOGON 对当前用户无需管理员;被 Group Policy 拒绝时 + 返回 False(调用方记 degraded,不阻断常驻本身)。 + """ + distro = os.environ.get("WSL_DISTRO_NAME", "").strip() + if not distro or not _wsl_interop_available(): + return False + import getpass + try: + user = getpass.getuser() + except Exception: + user = "" + inner = _boot_start_command() + user_part = f"-u {user} " if user else "" + tr = f"wsl.exe -d {distro} {user_part}-- {inner}" + try: + cp = subprocess.run( + ["schtasks.exe", "/Create", "/TN", WINDOWS_WSL_BOOT_TASK, + "/SC", "ONLOGON", "/TR", tr, "/F"], + capture_output=True, text=True, check=False, timeout=15, + ) + except (OSError, subprocess.SubprocessError): + return False + if cp.returncode != 0: + logger.info("WSL boot task 创建失败(degraded 继续):%s", + (cp.stderr or cp.stdout or "").strip()) + return cp.returncode == 0 + + +def _remove_wsl_boot_task() -> None: + if not _wsl_interop_available(): + return + try: + subprocess.run( + ["schtasks.exe", "/Delete", "/TN", WINDOWS_WSL_BOOT_TASK, "/F"], + capture_output=True, text=True, check=False, timeout=15, + ) + except (OSError, subprocess.SubprocessError): + pass + + +def _install_boot_autostart(flavor: str, *, + systemd_linger: bool = False + ) -> tuple[str, list[str]]: + """按 flavor 挂开机自启。返回 (boot_autostart 标识, degraded 警告列表)。 + + 任何失败都只降级不抛错——常驻本身(自愈)已就位,自启缺失是可接受的 + 降级,必须让用户看得见(degraded),但不能因此拒绝服务。 + """ + warnings: list[str] = [] + if flavor == "wsl": + # systemd/linger 只覆盖「VM 内」自启;VM 本身要 Windows 侧拉起。 + if _install_wsl_boot_task(): + return "windows-task", warnings + warnings.append( + "未能注册 Windows 侧开机任务(interop 不可用或被策略拒绝):Windows" + " 重启后需手动进一次 WSL 或跑 `xskill start`。") + if systemd_linger: + return "systemd-linger", warnings # 至少 VM 内自启还在 + return "none", warnings + if systemd_linger: + return "systemd-linger", warnings + if _install_cron_boot(): + return "cron", warnings + warnings.append( + "未能注册开机自启(无 systemd linger,且 crontab 不可用):重启后需" + "手动跑 `xskill start`。") + return "none", warnings + + +def _remove_boot_autostart(state: dict) -> None: + """卸载开机自启挂载。按 state 记录的方式卸,兜底两种都试(幂等)。""" + mode = state.get("boot_autostart") + if mode == "windows-task" or _is_wsl(): + _remove_wsl_boot_task() + if mode == "cron" or mode is None: + _remove_cron_boot() + + class SystemdUserBackend(ConnectServiceBackend): """用 ``systemd --user`` 托管 connect,支持自启和崩溃自动重启。""" @@ -586,12 +833,11 @@ def _enable_linger() -> bool: return linger.returncode == 0 def install_and_start(self) -> dict: + # linger 失败不再硬失败(旧版对 WSL 直接 raise):常驻与崩溃自愈由 + # unit 本身保证,linger 只影响「重启后无登录也自启」——那属于 + # boot_autostart 层的降级,由 LinuxServiceBackend 补 cron/Windows + # 任务并记 degraded。 linger_enabled = self._enable_linger() - if _is_wsl() and not linger_enabled: - raise ServiceError( - "WSL 无法启用 user linger,不能保证发行版启动时自动运行 xskill。\n" - " 请确认 `loginctl enable-linger $USER` 可执行后重试。" - ) try: self.unit_path.parent.mkdir(parents=True, exist_ok=True) self.unit_path.write_text(self._unit_text(), encoding="utf-8") @@ -674,6 +920,7 @@ def status(self) -> dict: "client_id": state.get("client_id"), "started_at": state.get("started_at"), "linger_enabled": state.get("linger_enabled"), + "crash_recovery": "systemd", } @@ -759,73 +1006,211 @@ def status(self) -> dict: } -class WSLSystemdRequiredBackend(ConnectServiceBackend): - """WSL 未启用 systemd 时的明确失败后端,不允许伪装成常驻成功。""" +class SupervisedProcessBackend(ConnectServiceBackend): + """无 systemd 平台的常驻:detach 一个 watchdog,由它自愈 connect 子进程。 + + 适用于未启 systemd 的 WSL、精简/老 Linux、鸿蒙终端。watchdog 主体见 + supervisor.py(指数退避重启、SIGTERM 级联、child_pid 回写 state)。 + ``running`` 以 watchdog 存活为准——子进程崩溃是 watchdog 的正常工况 + (退避窗口内 child 短暂不在),单侧状态另以 child_alive 汇报。 + """ name = "linux" - method = "systemd-required" + method = "supervised" def install_and_start(self) -> dict: - raise ServiceError(_wsl_systemd_required_message()) + current = self.status() + if current.get("running"): + return current + argv = _supervise_argv() + state_path = get_connect_daemon_state_path() + log_path = state_path.parent / "logs" / "connect-supervisor.log" + # 静态字段在 spawn 前整写;spawn 后 watchdog 会合并写 watchdog_pid/ + # child_pid——若 spawn 后再整写会与 watchdog 的合并写竞态互抹。 + write_daemon_state( + backend=self.name, method=self.method, argv=argv, + platform=_linux_flavor(), log_path=str(log_path), + ) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("ab") as log_file: + proc = subprocess.Popen( + argv, cwd=str(Path.home()), stdin=subprocess.DEVNULL, + stdout=log_file, stderr=subprocess.STDOUT, + start_new_session=True, close_fds=True, + ) + except OSError as e: + clear_daemon_state() + raise ServiceError(f"启动 supervisor watchdog 失败:{e}") from e + update_daemon_state(watchdog_pid=proc.pid) + # 等 watchdog 把首个 connect 子进程拉起来(最多 10s)——让 start 的 + # 返回状态里就带上 child_pid,用户不必二次 status 确认。 + deadline = time.time() + 10 + while time.time() < deadline: + st = read_daemon_state() + if _pid_alive(st.get("child_pid")): + break + if not _pid_alive(proc.pid): + raise ServiceError( + "supervisor watchdog 启动后立即退出;" + f"请查看日志 {log_path} 排查。") + time.sleep(0.2) + return self.status() def stop(self) -> dict: - return self.status() + state = read_daemon_state() + warning = "" + wpid = state.get("watchdog_pid") + if _pid_alive(wpid): + try: + os.kill(wpid, signal.SIGTERM) + # watchdog 收 SIGTERM 后最多 5s 宽限杀 child,再留余量。 + deadline = time.time() + 8 + while _pid_alive(wpid) and time.time() < deadline: + time.sleep(0.05) + if _pid_alive(wpid): + os.kill(wpid, signal.SIGKILL) + except OSError as e: + warning = str(e) + # 兜底:watchdog 已死但 child 还挂着(如 watchdog 被 SIGKILL 过)。 + cpid = state.get("child_pid") + if _pid_is_connect_daemon(cpid): + try: + os.kill(cpid, signal.SIGTERM) + deadline = time.time() + 5 + while _pid_alive(cpid) and time.time() < deadline: + time.sleep(0.05) + if _pid_alive(cpid): + os.kill(cpid, signal.SIGKILL) + except OSError as e: + warning = warning or str(e) + clear_daemon_state() + st = { + "running": False, "installed": False, "backend": self.name, + "method": self.method, "platform": _linux_flavor(), + } + if warning: + st["warning"] = warning + return st def status(self) -> dict: + state = read_daemon_state() + if state.get("method") != self.method: + return {"installed": False, "running": False, + "backend": self.name, "method": self.method, + "platform": _linux_flavor()} + wpid = state.get("watchdog_pid") + cpid = state.get("child_pid") + watchdog_alive = _pid_alive(wpid) + child_alive = _pid_is_connect_daemon(cpid) return { - "installed": False, - "running": False, + "installed": bool(wpid), + "running": watchdog_alive, "backend": self.name, "method": self.method, - "platform": "wsl", - "warning": _wsl_systemd_required_message(), + "platform": _linux_flavor(), + "pid": cpid, + "watchdog_pid": wpid, + "watchdog_alive": watchdog_alive, + "child_alive": child_alive, + "server_url": state.get("server_url"), + "client_id": state.get("client_id"), + "started_at": state.get("started_at"), + "log_path": state.get("log_path"), + "crash_recovery": "watchdog", + "boot_autostart": state.get("boot_autostart"), } class LinuxServiceBackend(ConnectServiceBackend): - """Linux/WSL 入口:WSL 必须 systemd,普通 Linux 可 detached 降级。""" + """Linux 族入口:能力探测选择 systemd/supervised,并编排开机自启挂载。 + + 旧版曾对「WSL 无 systemd」硬失败(WSLSystemdRequiredBackend)——策略过苛 + 且没解决真问题(systemd+linger 也管不了 Windows 重启后 VM 不自启)。现在: + 崩溃自愈由 systemd 或 watchdog 保证,开机自启由 _install_boot_autostart + 按能力尽力挂载,挂不上只记 degraded。 + """ name = "linux" + @staticmethod + def _select_for_install() -> ConnectServiceBackend: + override = os.environ.get("XSKILL_CONNECT_BACKEND", "").strip().lower() + if override == "detached": + return DetachedProcessBackend() + if override == "supervised": + return SupervisedProcessBackend() + if override == "systemd" or _systemd_user_available(): + return SystemdUserBackend() + return SupervisedProcessBackend() + @staticmethod def _from_state() -> ConnectServiceBackend: method = read_daemon_state().get("method") if method == SystemdUserBackend.method: return SystemdUserBackend() + if method == SupervisedProcessBackend.method: + return SupervisedProcessBackend() if method == DetachedProcessBackend.method: return DetachedProcessBackend() - if _is_wsl() and not _systemd_user_available(): - return WSLSystemdRequiredBackend() - return (SystemdUserBackend() if _systemd_user_available() - else DetachedProcessBackend()) + return LinuxServiceBackend._select_for_install() def install_and_start(self) -> dict: - systemd_available = _systemd_user_available() - if _is_wsl() and not systemd_available: - return WSLSystemdRequiredBackend().install_and_start() + target = self._select_for_install() + # 换后端(如旧 detached → systemd/supervised)先停旧进程防双 daemon。 state = read_daemon_state() - if systemd_available: - # 从旧版 detached 迁移到 systemd 前先停旧进程,避免双 daemon。 - if (state.get("method") == DetachedProcessBackend.method - and state.get("running")): - DetachedProcessBackend().stop() + old_method = state.get("method") + if old_method and old_method != target.method: try: - return SystemdUserBackend().install_and_start() + self._from_state().stop() except ServiceError: - if _is_wsl(): - raise - logger.warning( - "systemd user 安装失败,普通 Linux 降级为 detached", - exc_info=True, - ) - return DetachedProcessBackend().install_and_start() + logger.warning("停止旧 %s 后端失败,继续安装 %s", + old_method, target.method, exc_info=True) + + try: + st = target.install_and_start() + except ServiceError: + if isinstance(target, SystemdUserBackend): + # systemd 探测通过但安装失败(unit 拒载等)→ 降级 supervised, + # 任何 Linux 族平台一视同仁(旧版 WSL 在此硬 raise)。 + logger.warning("systemd user 安装失败,降级为 supervised", + exc_info=True) + st = SupervisedProcessBackend().install_and_start() + else: + raise + + # 开机自启挂载 + 降级如实记录(detached 是显式 override 的裸模式, + # 保持历史语义:不挂自启)。 + if st.get("method") != DetachedProcessBackend.method: + flavor = _linux_flavor() + linger = bool(read_daemon_state().get("linger_enabled")) + boot, warnings = _install_boot_autostart( + flavor, systemd_linger=linger) + update_daemon_state(boot_autostart=boot, flavor=flavor, + degraded=warnings) + return self.status() def stop(self) -> dict: - return self._from_state().stop() + state = read_daemon_state() + st = self._from_state().stop() + _remove_boot_autostart(state) + return st def status(self) -> dict: - return self._from_state().status() + st = self._from_state().status() + state = read_daemon_state() + st.setdefault("flavor", state.get("flavor") or _linux_flavor()) + if state.get("boot_autostart") is not None: + st["boot_autostart"] = state.get("boot_autostart") + degraded = state.get("degraded") or [] + if degraded: + st["degraded"] = degraded + if "crash_recovery" not in st: + st["crash_recovery"] = ( + "systemd" if st.get("method") == SystemdUserBackend.method + else "none") + return st # ═══════════════════════════════════════════════════════════════ diff --git a/src/xskill/team/client/supervisor.py b/src/xskill/team/client/supervisor.py new file mode 100644 index 00000000..3dd108e2 --- /dev/null +++ b/src/xskill/team/client/supervisor.py @@ -0,0 +1,178 @@ +"""supervisor.py — 无 init 系统平台上的 connect 崩溃自愈 watchdog + +``xskill connect --supervise`` 的进程主体:循环拉起 ``connect --foreground`` +子进程,子进程退出后按指数退避重启。适用于没有 systemd 的 Linux(精简容器、 +鸿蒙、老发行版)、未启 systemd 的 WSL,以及 Windows 上 schtasks 被 Group +Policy 禁用后的启动文件夹降级——这些环境里操作系统不提供崩溃自愈,watchdog +就是自愈层(systemd Restart= / schtasks RestartOnFailure 的用户态等价物)。 + +行为约定 +──────── +- 子进程 env 注入 ``XSKILL_SUPERVISED=1``:updater 升级完成后据此统一以非零 + 退出码退出,由本 watchdog 用新版本代码拉起(见 updater._restart)。 +- 每次 spawn 都把 child_pid 合并写回 daemon state(update_daemon_state), + ``xskill status`` / stop 靠它定位子进程。 +- 退避:1s 起步 ×2 递增、封顶 300s;子进程存活 ≥600s 视为健康,退避归零。 + 这样偶发崩溃秒级恢复,持续崩溃(坏版本/坏配置)不会空转烧 CPU。 +- SIGTERM/SIGINT → 先 SIGTERM 子进程(5s 宽限后 SIGKILL)再退出,保证 + ``xskill stop`` 一次杀干净。 +- 防双跑:启动时 state 里已有存活的其他 watchdog 则直接退出 0(幂等)。 +""" +from __future__ import annotations + +import logging +import os +import signal +import subprocess +import sys +import time +from typing import Optional + +logger = logging.getLogger("xskill.team.client.supervisor") + +BACKOFF_INITIAL = 1.0 +BACKOFF_FACTOR = 2.0 +BACKOFF_CAP = 300.0 +HEALTHY_RUNTIME = 600.0 + +SUPERVISED_ENV = "XSKILL_SUPERVISED" + + +def next_backoff(current: float, child_runtime: float) -> float: + """给定当前退避值与子进程本次存活时长,算下一次重启前的等待秒数。""" + if child_runtime >= HEALTHY_RUNTIME: + return BACKOFF_INITIAL + return min(max(current, BACKOFF_INITIAL) * BACKOFF_FACTOR, BACKOFF_CAP) + + +def _foreground_child_argv() -> list[str]: + return [sys.executable or "python", "-m", "xskill", "connect", "--foreground"] + + +class Supervisor: + """watchdog 主体。run() 阻塞直到收到停止信号。""" + + def __init__(self, spawn=None, monotonic=time.monotonic, sleep=None): + # spawn/monotonic/sleep 可注入,单测不必起真进程、不必真等退避。 + self._spawn = spawn or self._default_spawn + self._monotonic = monotonic + self._sleep = sleep or self._interruptible_sleep + self._stop_requested = False + self._child: Optional[subprocess.Popen] = None + + # ── 进程操作(可注入替身) ───────────────────────────────── + + @staticmethod + def _default_spawn() -> subprocess.Popen: + from xskill.config import get_connect_daemon_state_path + env = dict(os.environ) + env[SUPERVISED_ENV] = "1" + log_path = (get_connect_daemon_state_path().parent + / "logs" / "connect-daemon.log") + log_path.parent.mkdir(parents=True, exist_ok=True) + log_file = open(log_path, "ab") + try: + return subprocess.Popen( + _foreground_child_argv(), env=env, + stdin=subprocess.DEVNULL, stdout=log_file, + stderr=subprocess.STDOUT, close_fds=True, + ) + finally: + # 子进程已持有 fd,父进程侧句柄立即关闭防泄漏。 + log_file.close() + + def _interruptible_sleep(self, seconds: float) -> None: + """0.2s 粒度轮询 stop 标志的 sleep——SIGTERM 到达后最多 0.2s 内退出。""" + deadline = self._monotonic() + seconds + while not self._stop_requested and self._monotonic() < deadline: + time.sleep(0.2) + + # ── 信号 ────────────────────────────────────────────────── + + def _request_stop(self, signum, frame) -> None: # noqa: ARG002 + self._stop_requested = True + + def _install_signal_handlers(self) -> None: + for sig_name in ("SIGTERM", "SIGINT", "SIGBREAK"): + sig = getattr(signal, sig_name, None) + if sig is None: + continue + try: + signal.signal(sig, self._request_stop) + except (OSError, ValueError): + pass + + def _terminate_child(self) -> None: + child = self._child + if child is None or child.poll() is not None: + return + try: + child.terminate() + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + child.kill() + child.wait(timeout=5) + except (OSError, subprocess.SubprocessError): + logger.warning("supervisor: 终止子进程失败", exc_info=True) + + # ── 主循环 ──────────────────────────────────────────────── + + def run(self) -> int: + from xskill.team.client.service import ( + _pid_alive, read_daemon_state, update_daemon_state, + ) + + state = read_daemon_state() + existing = state.get("watchdog_pid") + if (isinstance(existing, int) and existing != os.getpid() + and _pid_alive(existing)): + logger.info("supervisor: 已有 watchdog (pid %s) 在跑,本进程退出", + existing) + return 0 + + self._install_signal_handlers() + update_daemon_state(watchdog_pid=os.getpid()) + backoff = BACKOFF_INITIAL + logger.info("supervisor: watchdog 启动 (pid %s)", os.getpid()) + + while not self._stop_requested: + started = self._monotonic() + try: + self._child = self._spawn() + except OSError: + # spawn 本身失败(fd 耗尽/内存不足等瞬态)也走退避,不退出。 + logger.error("supervisor: 拉起子进程失败", exc_info=True) + self._sleep(backoff) + backoff = next_backoff(backoff, 0.0) + continue + update_daemon_state(child_pid=self._child.pid, + child_started_at=int(time.time())) + logger.info("supervisor: connect 子进程已拉起 (pid %s)", + self._child.pid) + + while self._child.poll() is None and not self._stop_requested: + time.sleep(0.2) + + if self._stop_requested: + break + runtime = self._monotonic() - started + backoff = next_backoff(backoff, runtime) + logger.warning( + "supervisor: 子进程退出 (code=%s, 存活 %.0fs),%.0fs 后重启", + self._child.returncode, runtime, backoff, + ) + self._sleep(backoff) + + self._terminate_child() + logger.info("supervisor: watchdog 退出") + return 0 + + +def run_supervisor() -> int: + """``xskill connect --supervise`` 的入口。""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(name)s %(levelname)s %(message)s", + ) + return Supervisor().run() From 23d68183e30b390ca9fee82fd4784e2490228733 Mon Sep 17 00:00:00 2001 From: 370025263 <370025263@qq.com> Date: Sun, 12 Jul 2026 17:54:45 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat(updater):=20=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E5=90=8E=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=20+=20=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=9B=9E=E6=BB=9A=20+=20=E5=9D=8F=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E6=8B=89=E9=BB=91=20|=20post-install=20health=20check,=20rollb?= =?UTF-8?q?ack,=20blacklist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pip 装完先子进程验证 python -m xskill --version,失败回滚旧版: 坏 wheel 不再把常驻进程带进重启即崩死循环 - 健康检查失败的版本记 ~/.xskill/update_journal.json 黑名单, PyPI/server 两条渠道都跳过,杜绝升级-崩-回滚空转;成功记 last_good - pip 安装失败(网络抖动)不拉黑,下轮重试 - supervisor 托管(XSKILL_SUPERVISED=1)时全平台统一非零退出交 watchdog 重启;手动 xskill update 同样走验证+回滚 Co-Authored-By: Claude Fable 5 --- src/xskill/team/client/updater.py | 158 ++++++++++++++++++++++++++++-- 1 file changed, 150 insertions(+), 8 deletions(-) diff --git a/src/xskill/team/client/updater.py b/src/xskill/team/client/updater.py index 521c164a..36c529a1 100644 --- a/src/xskill/team/client/updater.py +++ b/src/xskill/team/client/updater.py @@ -6,14 +6,21 @@ 重启机制 ──────── -- Linux / macOS:``os.execv`` 原地替换进程(同 PID,守护进程/systemd 不感知) -- Windows:spawn 新 detach 进程 + 退出当前进程(schtasks/Startup 文件夹会保持常驻) +- supervisor 托管(XSKILL_SUPERVISED=1:无 systemd 的 Linux/WSL/鸿蒙,以及 + Windows startup_folder 降级):以非零退出码退出,watchdog 用新版本拉起 +- Linux / macOS(systemd 直管):``os.execv`` 原地替换进程(同 PID) +- Windows schtasks:非零退出,RestartOnFailure 在 1 分钟内重启 -版本策略 +版本策略与健壮性 ──────── - 包含预发版(a/b/rc),因为内部用 alpha 版本 - 严格大于当前版本才升级,不降级 - 网络/PyPI/server 故障不会打断主循环 +- **升级后健康检查**:pip 装完先用子进程验证 `` -m xskill --version`` + 可跑,失败则回滚到升级前版本——坏 wheel(半残依赖/二进制不兼容,鸿蒙与 + 老 glibc 上很现实)不会把常驻进程带进「重启即崩」的死循环 +- **坏版本拉黑**:健康检查失败的版本记入 ``~/.xskill/update_journal.json``, + 之后的检查跳过该版本,杜绝「升级→崩→回滚→再升级」空转 """ from __future__ import annotations @@ -130,14 +137,20 @@ def _download_server_wheel( def _restart() -> None: """升级成功后重启进程,加载新版本代码。 + - supervisor 托管(XSKILL_SUPERVISED=1):全平台统一以非零退出码退出, + watchdog 用新版本拉起;不自行 spawn,watchdog 始终是唯一管理者 - Linux/macOS:``os.execv`` 原地替换,PID 不变,对 systemd 透明 - Windows schtasks:以非零退出码退出,schtasks RestartOnFailure 在 1 分钟内用新版本重启进程;不另起 detach 进程,避免孤立进程脱管 - - Windows startup_folder:spawn detach 新进程 + 以 0 退出;.vbs - 无重启能力,必须自己起新进程才能立即用上新版本 + - Windows startup_folder(旧版无 supervisor 的存量安装):spawn detach + 新进程 + 以 0 退出 """ import time logger.info("updater: 升级完成,即将重启...") + if os.environ.get("XSKILL_SUPERVISED") == "1": + logger.info("updater: supervisor 托管 — 以退出码 1 退出,等 watchdog 重启") + time.sleep(1) + os._exit(1) if sys.platform == "win32": method = _windows_persistence_method() if method == "schtasks": @@ -167,6 +180,79 @@ def _restart() -> None: os.execv(sys.executable, [sys.executable] + sys.argv) +def _journal_path() -> Path: + from xskill.config import get_connect_daemon_state_path + return get_connect_daemon_state_path().parent / "update_journal.json" + + +def load_update_journal() -> dict: + """读更新日志(坏版本黑名单 + last_good)。缺失/损坏容忍为空。""" + import json + try: + d = json.loads(_journal_path().read_text(encoding="utf-8")) + return d if isinstance(d, dict) else {} + except (OSError, ValueError): + return {} + + +def save_update_journal(journal: dict) -> None: + import json + try: + path = _journal_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(journal, ensure_ascii=False, indent=2), + encoding="utf-8") + except OSError: + logger.debug("updater: 写 update journal 失败", exc_info=True) + + +def _blacklist_version(version: str, reason: str) -> None: + import time + journal = load_update_journal() + bad = journal.setdefault("bad_versions", {}) + bad[version] = {"ts": int(time.time()), "reason": reason} + save_update_journal(journal) + logger.warning("updater: 版本 %s 已拉黑(%s),后续检查将跳过", version, reason) + + +def _is_blacklisted(version: str) -> bool: + return version in (load_update_journal().get("bad_versions") or {}) + + +def _record_last_good(version: str) -> None: + journal = load_update_journal() + journal["last_good"] = version + save_update_journal(journal) + + +# 健康检查的子进程超时。--version 只 import 包 + 打印,正常几秒内返回; +# 超时视为坏版本(import 挂死同样是坏)。 +_HEALTH_CHECK_TIMEOUT = 60 + + +def _health_check() -> bool: + """新版本装完后、重启前,用干净子进程验证包可导入可执行。 + + 当前进程内存里跑的还是旧代码,import 状态不能证明新安装是好的; + 必须起新解释器让它真正加载磁盘上的新版本。 + """ + try: + cp = subprocess.run( + [sys.executable, "-m", "xskill", "--version"], + capture_output=True, text=True, timeout=_HEALTH_CHECK_TIMEOUT, + ) + except subprocess.TimeoutExpired: + logger.warning("updater: 健康检查超时(%ds)", _HEALTH_CHECK_TIMEOUT) + return False + except Exception: + logger.warning("updater: 健康检查执行失败", exc_info=True) + return False + if cp.returncode != 0: + logger.warning("updater: 健康检查失败 (rc=%s):\n%s", cp.returncode, + (cp.stderr or cp.stdout or "").strip()[:2000]) + return cp.returncode == 0 + + def _windows_persistence_method() -> str: """读 daemon state 取 Windows 持久化方式(schtasks / startup_folder)。 @@ -257,6 +343,13 @@ def _check_and_update(self) -> None: except Exception: return + if _is_blacklisted(latest_str): + # 该版本此前健康检查失败被回滚过——跳过,等再新的版本。 + logger.info("updater: PyPI 最新 %s 在坏版本黑名单中,跳过", latest_str) + self._check_server_fallback(current_str, current, + reason="pypi_blacklisted") + return + if latest <= current: logger.debug("updater: 当前版本 %s 不低于 PyPI 最新 %s", current_str, latest_str) @@ -270,9 +363,9 @@ def _check_and_update(self) -> None: logger.info("updater: 发现新版本 %s(当前 %s),开始升级...", latest_str, current_str) - if self._install(latest_str): + if self.install_and_verify(latest_str, current_str): _restart() # 升级成功后重启,不会走到这行之后的代码 - # (_restart 在 Windows 上 os._exit;Linux 上 execv) + # (_restart 在 supervisor/Windows 下 os._exit;Linux 上 execv) return self._check_server_fallback(current_str, current, reason="pypi_install_failed") @@ -281,6 +374,37 @@ def _check_and_update(self) -> None: # subprocess.run 挂死 = 之后每小时的检查全部消失,自动更新静默死亡。 _PIP_TIMEOUT = 600 + def install_and_verify(self, target_version: str, + current_version: str) -> bool: + """升级到 target 并做健康检查;失败回滚到 current 并拉黑 target。 + + 返回 True = 新版本已装好且健康,可以重启。 + 返回 False = 未升级成功;若发生过回滚,当前磁盘上仍是(或已回到) + current_version,进程可安全继续跑内存里的旧代码。 + """ + if not self._install(target_version): + return False + if _health_check(): + _record_last_good(target_version) + return True + _blacklist_version(target_version, "health_check_failed") + logger.warning("updater: 新版本 %s 健康检查失败,回滚到 %s...", + target_version, current_version) + if self._install(current_version): + if _health_check(): + logger.info("updater: 已回滚到 %s", current_version) + else: + logger.critical( + "updater: 回滚到 %s 后健康检查仍失败——环境可能已损坏," + "请人工介入(pip install xskill==%s)", + current_version, current_version) + else: + logger.critical( + "updater: 回滚安装失败!磁盘上可能是坏版本 %s;本进程继续以" + "内存中的旧代码运行,且不会重启。请人工执行 " + "pip install xskill==%s", target_version, current_version) + return False + def _install(self, target_version: str) -> bool: """用 pip 升级到指定版本。返回是否成功。""" cmd = [ @@ -331,6 +455,10 @@ def _check_server_fallback(self, current_str: str, current, *, reason: str) -> N logger.debug("updater: server 版本 %s 不高于当前版本 %s", server_version_str, current_str) return + if _is_blacklisted(server_version_str): + logger.info("updater: server 版本 %s 在坏版本黑名单中,跳过", + server_version_str) + return if not info.get("wheel_available"): logger.warning("updater: server 版本 %s 可用,但未提供 wheel", server_version_str) @@ -348,8 +476,22 @@ def _check_server_fallback(self, current_str: str, current, *, reason: str) -> N return logger.info("updater: PyPI 不可用(%s),改用 server wheel 升级到 %s", reason, server_version_str) - if self._install_wheel(wheel): + if not self._install_wheel(wheel): + return + if _health_check(): + _record_last_good(server_version_str) _restart() + return + # server wheel 健康检查失败:拉黑 + 尽力回滚(回滚走 pip 索引, + # 纯内网机若无镜像可能失败——critical 留痕,进程不重启保命)。 + _blacklist_version(server_version_str, "health_check_failed") + logger.warning("updater: server wheel %s 健康检查失败,回滚到 %s...", + server_version_str, current_str) + if not (self._install(current_str) and _health_check()): + logger.critical( + "updater: 回滚失败或仍不健康——请人工执行 " + "pip install xskill==%s;本进程继续跑内存旧代码,不重启", + current_str) def _install_wheel(self, wheel_path: Path) -> bool: """用 pip 安装 server 下载的 wheel。返回是否成功。""" From cee67c84dca368fa25621e2ea2f9c612a357c437 Mon Sep 17 00:00:00 2001 From: 370025263 <370025263@qq.com> Date: Sun, 12 Jul 2026 17:54:45 +0800 Subject: [PATCH 4/9] =?UTF-8?q?test(multi-platform):=20=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E7=9F=A9=E9=98=B5=E5=8D=95=E6=B5=8B=20+=20=E8=87=AA=E6=84=88?= =?UTF-8?q?=20e2e=20+=20docker=20=E5=8F=91=E8=A1=8C=E7=89=88=E7=9F=A9?= =?UTF-8?q?=E9=98=B5=20+=20CI=20|=20matrix=20tests=20&=20CI=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重写 test_wsl_persistence_policy:WSL 无 systemd 落 supervised 而非 拒绝;interop 任务拼装;自启决策表;鸿蒙识别;cron marker 幂等 - 新增 test_supervisor(退避/respawn/防双跑)、 test_updater_health_rollback(回滚/拉黑/journal 容损) - e2e: supervised 真进程自愈(kill 子进程→watchdog 拉起→stop 全清理, 假 crontab shim 不动真 crontab);Windows 真机 schtasks 生命周期 (XSKILL_WIN_E2E=1 门控) - docker 平台矩阵 ubuntu:24.04/debian:12/openEuler/鸿蒙模拟,容器内 跑同一套自愈 e2e;CI: windows e2e job + nightly 矩阵 job Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 32 +++ tests/docker_e2e/platform_matrix/run.sh | 108 ++++++++++ tests/e2e/test_supervised_selfheal_e2e.py | 219 +++++++++++++++++++ tests/e2e/test_windows_connect_e2e.py | 115 ++++++++++ tests/test_connect_service.py | 67 +++++- tests/test_supervisor.py | 115 ++++++++++ tests/test_updater_health_rollback.py | 116 ++++++++++ tests/test_wsl_persistence_policy.py | 252 +++++++++++++++++++--- 8 files changed, 996 insertions(+), 28 deletions(-) create mode 100755 tests/docker_e2e/platform_matrix/run.sh create mode 100644 tests/e2e/test_supervised_selfheal_e2e.py create mode 100644 tests/e2e/test_windows_connect_e2e.py create mode 100644 tests/test_supervisor.py create mode 100644 tests/test_updater_health_rollback.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68dec521..d368027e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,38 @@ jobs: run: pip install -e .[dev] - name: Connect lifecycle E2E (local stub, no network) run: pytest tests/e2e/test_connect_lifecycle_e2e.py -v + - name: Supervised self-heal E2E (watchdog, no-systemd path) + run: pytest tests/e2e/test_supervised_selfheal_e2e.py -v + + connect-e2e-windows: + name: connect e2e (windows, real schtasks) + needs: ut-it + runs-on: windows-latest + env: + XSKILL_WIN_E2E: "1" # 一次性 runner,允许写真实用户 Profile + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - name: Install xskill (with dev extras) + run: pip install -e .[dev] + - name: Windows connect lifecycle E2E (real Task Scheduler) + run: pytest tests/e2e/test_windows_connect_e2e.py -v + + platform-matrix-e2e: + # 多发行版容器矩阵(ubuntu/debian/openEuler/鸿蒙模拟)验证 supervised + # 常驻链路(自愈 + cron 自启 + stop 清理)。拉镜像+装依赖较重,只在 + # nightly / 手动触发跑;本地随时可 bash tests/docker_e2e/platform_matrix/run.sh all + name: platform matrix e2e (docker, nightly) + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + needs: ut-it + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run distro matrix (supervised self-heal in containers) + run: bash tests/docker_e2e/platform_matrix/run.sh all real-llm-e2e: name: real-llm-e2e (linux) diff --git a/tests/docker_e2e/platform_matrix/run.sh b/tests/docker_e2e/platform_matrix/run.sh new file mode 100755 index 00000000..6dc29523 --- /dev/null +++ b/tests/docker_e2e/platform_matrix/run.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# 平台矩阵 E2E — 多发行版容器里验证 supervised 常驻链路(无 systemd 环境) +# +# 容器天然没有 systemd user manager,正好逼出 supervised watchdog 降级链, +# 与「未启 systemd 的 WSL / 精简 Linux / 鸿蒙终端」同构。矩阵: +# ubuntu ubuntu:24.04 现代 Debian 系 +# debian debian:12-slim 稳定 Debian 系 +# openeuler openeuler/openeuler:24.03-lts 鸿蒙服务器用户态最近似 +# harmony openeuler + 覆写 /etc/os-release 鸿蒙识别与链路模拟 +# +# 每个容器内跑 tests/e2e/test_supervised_selfheal_e2e.py(connect 常驻 → +# SIGKILL 子进程 → watchdog 自愈 → stop 全清理),harmony 额外断言 +# _linux_flavor() == "harmony"。 +# +# 用法: +# run.sh [ubuntu|debian|openeuler|harmony|all] 缺省 all +# 环境: +# PIP_INDEX_URL / PIP_TRUSTED_HOST 透传进容器(内网/镜像加速) +# XSKILL_MATRIX_IMAGE_PREFIX 镜像仓库前缀(如 +# docker.m.daocloud.io/,直连 +# docker.io 受限的内网机用) +set -uo pipefail + +THIS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$THIS_DIR/../../.." && pwd)" + +TARGET="${1:-all}" +if [ "$TARGET" = "all" ]; then + MATRIX=(ubuntu debian openeuler harmony) +else + MATRIX=("$TARGET") +fi + +PREFIX="${XSKILL_MATRIX_IMAGE_PREFIX:-}" + +image_of() { + case "$1" in + ubuntu) echo "${PREFIX}ubuntu:24.04" ;; + debian) echo "${PREFIX}debian:12-slim" ;; + openeuler|harmony) echo "${PREFIX}openeuler/openeuler:24.03-lts" ;; + *) echo "unknown distro: $1" >&2; return 2 ;; + esac +} + +# 容器内 bootstrap:装 python → venv → 装 xskill → 跑 e2e。 +# 源码只读挂载在 /src;tar 复制到 /work(排除 .git 与宿主产物),避免容器 +# 写宿主目录(egg-info / .pytest_cache)。 +inner_script() { + local distro="$1" + cat <<'EOS' +set -euo pipefail +case "$DISTRO" in + ubuntu|debian) + export DEBIAN_FRONTEND=noninteractive + apt-get update -q && apt-get install -yq python3 python3-pip python3-venv >/dev/null + ;; + openeuler|harmony) + dnf install -yq python3 python3-pip >/dev/null + ;; +esac +if [ "$DISTRO" = harmony ]; then + printf 'NAME="HarmonyOS"\nID=harmonyos\nVERSION_ID="5.1"\n' > /etc/os-release +fi +mkdir -p /work +tar -C /src --exclude=.git --exclude='*.egg-info' --exclude=.pytest_cache \ + --exclude=node_modules -cf - . | tar -C /work -xf - +python3 -m venv /venv +/venv/bin/pip install -q --upgrade pip +/venv/bin/pip install -q '/work[dev]' +echo "== python: $(/venv/bin/python -V) distro: $DISTRO ==" +if [ "$DISTRO" = harmony ]; then + /venv/bin/python - <<'PY' +from xskill.team.client.service import _linux_flavor, _is_harmony +assert _is_harmony(), "os-release 覆写后应识别为鸿蒙" +assert _linux_flavor() == "harmony", _linux_flavor() +print("harmony flavor detection OK") +PY +fi +cd /work && /venv/bin/python -m pytest tests/e2e/test_supervised_selfheal_e2e.py -v -p no:cacheprovider +EOS +} + +FAILED=() +for distro in "${MATRIX[@]}"; do + image="$(image_of "$distro")" || exit 2 + echo + echo "───────────────────────────────────────────────" + echo "▶ platform_matrix: $distro ($image)" + echo "───────────────────────────────────────────────" + if docker run --rm \ + -v "$REPO":/src:ro \ + -e DISTRO="$distro" \ + ${PIP_INDEX_URL:+-e PIP_INDEX_URL} \ + ${PIP_TRUSTED_HOST:+-e PIP_TRUSTED_HOST} \ + "$image" bash -c "$(inner_script "$distro")"; then + echo "✔ $distro PASSED" + else + echo "✘ $distro FAILED" + FAILED+=("$distro") + fi +done + +echo +if [ "${#FAILED[@]}" -gt 0 ]; then + echo "platform_matrix FAILED: ${FAILED[*]}" >&2 + exit 1 +fi +echo "platform_matrix: all ${#MATRIX[@]} distro(s) passed." diff --git a/tests/e2e/test_supervised_selfheal_e2e.py b/tests/e2e/test_supervised_selfheal_e2e.py new file mode 100644 index 00000000..40758c79 --- /dev/null +++ b/tests/e2e/test_supervised_selfheal_e2e.py @@ -0,0 +1,219 @@ +"""supervised 链路真实进程 e2e:常驻 → 杀子进程 → watchdog 自愈 → stop 全清理。 + +无 systemd 的环境(精简容器 / 未启 systemd 的 WSL / 鸿蒙)里这条链就是常驻的 +唯一保障,必须用真进程验证: +- connect 后 watchdog 与 connect 子进程都在; +- SIGKILL 子进程后 watchdog 在退避窗口内拉起新子进程(pid 变化); +- cron @reboot 自启条目装上(经 PATH shim 的假 crontab,不动真 crontab); +- stop 后 watchdog、子进程全部退出,cron 条目移除,二次 status 不误报。 + +docker 平台矩阵(tests/docker_e2e/platform_matrix/)在 ubuntu/debian/openEuler +(含鸿蒙模拟)容器里跑的正是本文件。 +""" +from __future__ import annotations + +import json +import os +import signal +import stat +import subprocess +import sys +import site +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest + +from xskill import __version__ + + +class _StubHandler(BaseHTTPRequestHandler): + def _json(self, payload: dict, status: int = 200) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self) -> None: # noqa: N802 + length = int(self.headers.get("Content-Length", "0")) + if length: + self.rfile.read(length) + if self.path == "/api/v1/team/register": + self._json({"client_id": "e2e-selfheal"}) + return + if self.path == "/api/v1/team/upload": + self._json({"accepted": []}) + return + self._json({"detail": "not found"}, status=404) + + def do_GET(self) -> None: # noqa: N802 + if self.path == "/pypi/xskill/json": + self._json({"releases": {__version__: [{}]}}) + return + self._json({"detail": "not found"}, status=404) + + def log_message(self, format: str, *args) -> None: + return + + +def _run_cli(repo: Path, env: dict[str, str], *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-m", "xskill", *args], cwd=repo, env=env, + capture_output=True, text=True, timeout=30, + ) + + +def _json_out(result: subprocess.CompletedProcess) -> dict: + assert result.returncode == 0, result.stderr or result.stdout + return json.loads(result.stdout) + + +def _pid_alive(pid: int) -> bool: + """与 service._pid_alive 同口径:容器里孤儿僵尸(Z 态)视为死。""" + try: + os.kill(pid, 0) + except (ProcessLookupError, TypeError): + return False + except PermissionError: + return True + try: + stat = Path(f"/proc/{pid}/stat").read_text(encoding="ascii") + if stat.rsplit(")", 1)[1].split()[0] == "Z": + return False + except (OSError, IndexError): + pass + return True + + +def _make_fake_crontab(shim_dir: Path, store: Path) -> None: + """PATH shim:假 crontab 读写文件,e2e 绝不碰真用户 crontab。""" + script = shim_dir / "crontab" + script.write_text( + "#!/bin/sh\n" + f'STORE="{store}"\n' + 'if [ "$1" = "-l" ]; then\n' + ' [ -f "$STORE" ] || exit 1\n' + ' cat "$STORE"; exit 0\n' + 'fi\n' + 'if [ "$1" = "-" ]; then cat > "$STORE"; exit 0; fi\n' + 'exit 0\n', + encoding="utf-8", + ) + script.chmod(script.stat().st_mode | stat.S_IEXEC) + + +def _wait_until(predicate, timeout: float, interval: float = 0.3): + deadline = time.time() + timeout + last = None + while time.time() < deadline: + last = predicate() + if last: + return last + time.sleep(interval) + return last + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux service E2E") +def test_supervised_selfheal_lifecycle(tmp_path): + """ + AC: 无 systemd 平台 connect 常驻具备崩溃自愈与开机自启挂载,stop 全清理。 + Behavior: connect → status → kill child → auto respawn → stop → clean. + @category: service-integration-e2e + @lane: service-integration-e2e + @dependency: local HTTP stub, subprocess CLI, fake crontab shim + @complexity: medium + ROI: 90 + """ + repo = Path(__file__).resolve().parents[2] + server = ThreadingHTTPServer(("127.0.0.1", 0), _StubHandler) + threading.Thread(target=server.serve_forever, daemon=True).start() + address = f"127.0.0.1:{server.server_port}" + + shim_dir = tmp_path / "bin" + shim_dir.mkdir() + cron_store = tmp_path / "crontab.store" + _make_fake_crontab(shim_dir, cron_store) + + env = os.environ.copy() + python_paths = [str(repo / "src"), site.getusersitepackages()] + if env.get("PYTHONPATH"): + python_paths.append(env["PYTHONPATH"]) + env.update({ + "HOME": str(tmp_path), + "PATH": f"{shim_dir}{os.pathsep}{env.get('PATH', '')}", + "PYTHONPATH": os.pathsep.join(python_paths), + "XDG_CONFIG_HOME": str(tmp_path / ".config"), + "XSKILL_CONNECT_BACKEND": "supervised", + "XSKILL_PYPI_JSON_URL": ( + f"http://127.0.0.1:{server.server_port}/pypi/{{package}}/json" + ), + }) + state_file = tmp_path / ".xskill" / "connect_daemon.json" + + def read_state() -> dict: + try: + return json.loads(state_file.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + try: + connected = _run_cli(repo, env, "connect", address, + "--token", "t", "--name", "m00000002") + assert connected.returncode == 0, connected.stderr or connected.stdout + + st = _json_out(_run_cli(repo, env, "status", "--json")) + assert st["running"] is True + assert st["method"] == "supervised" + assert st["crash_recovery"] == "watchdog" + watchdog_pid = st["watchdog_pid"] + assert _pid_alive(watchdog_pid) + + # 首个 connect 子进程就位 + child1 = _wait_until( + lambda: (read_state().get("child_pid") + if _pid_alive(read_state().get("child_pid") or -1) else None), + timeout=15) + assert child1, f"首个子进程未出现: state={read_state()}" + + # cron @reboot 自启已挂(假 crontab) + assert "@reboot" in cron_store.read_text(encoding="utf-8") + assert "xskill-connect-boot" in cron_store.read_text(encoding="utf-8") + + # ── 自愈:SIGKILL 子进程,watchdog 应拉起新的 ── + os.kill(child1, signal.SIGKILL) + child2 = _wait_until( + lambda: (read_state().get("child_pid") + if (read_state().get("child_pid") not in (None, child1) + and _pid_alive(read_state().get("child_pid"))) + else None), + timeout=30) + assert child2, f"watchdog 未拉起新子进程: state={read_state()}" + assert child2 != child1 + assert _pid_alive(watchdog_pid) + + st = _json_out(_run_cli(repo, env, "status", "--json")) + assert st["running"] is True + + # ── stop:watchdog + 子进程全退,cron 条目移除 ── + stopped = _json_out(_run_cli(repo, env, "stop", "--json")) + assert stopped["running"] is False + assert _wait_until(lambda: not _pid_alive(watchdog_pid), timeout=10) + assert _wait_until(lambda: not _pid_alive(child2), timeout=10) + assert "xskill-connect-boot" not in cron_store.read_text(encoding="utf-8") + assert _json_out(_run_cli(repo, env, "status", "--json"))["running"] is False + + # ── start --quiet 幂等:再拉起后重复调用不重复起 watchdog ── + started = _json_out(_run_cli(repo, env, "start", "--json")) + assert started["running"] is True + wpid = started["watchdog_pid"] + quiet = _run_cli(repo, env, "start", "--quiet") + assert quiet.returncode == 0 and quiet.stdout.strip() == "" + assert _json_out(_run_cli(repo, env, "status", "--json"))["watchdog_pid"] == wpid + finally: + _run_cli(repo, env, "stop", "--json") + server.shutdown() + server.server_close() diff --git a/tests/e2e/test_windows_connect_e2e.py b/tests/e2e/test_windows_connect_e2e.py new file mode 100644 index 00000000..c3ef3cfd --- /dev/null +++ b/tests/e2e/test_windows_connect_e2e.py @@ -0,0 +1,115 @@ +"""Windows 真机 connect 常驻 e2e(schtasks / startup_folder 降级均可过)。 + +用真实用户 Profile(schtasks 任务进程不继承测试进程的 env 重定向,HOME 假 +不了),所以默认跳过,只有显式 ``XSKILL_WIN_E2E=1`` 才跑——CI windows-latest +的一次性 runner 上开启;本地 Windows 开发机自担 ~/.xskill 状态被覆盖。 +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest + +from xskill import __version__ + +pytestmark = pytest.mark.skipif( + sys.platform != "win32" or os.environ.get("XSKILL_WIN_E2E") != "1", + reason="Windows 真机 e2e:仅 win32 且 XSKILL_WIN_E2E=1(写真实用户 Profile)", +) + + +class _StubHandler(BaseHTTPRequestHandler): + def _json(self, payload: dict, status: int = 200) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self) -> None: # noqa: N802 + length = int(self.headers.get("Content-Length", "0")) + if length: + self.rfile.read(length) + if self.path == "/api/v1/team/register": + self._json({"client_id": "e2e-win"}) + return + if self.path == "/api/v1/team/upload": + self._json({"accepted": []}) + return + self._json({"detail": "not found"}, status=404) + + def do_GET(self) -> None: # noqa: N802 + if self.path == "/pypi/xskill/json": + self._json({"releases": {__version__: [{}]}}) + return + self._json({"detail": "not found"}, status=404) + + def log_message(self, format: str, *args) -> None: + return + + +def _run_cli(repo: Path, env: dict, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-m", "xskill", *args], cwd=repo, env=env, + capture_output=True, text=True, timeout=60, + ) + + +def _json_out(result: subprocess.CompletedProcess) -> dict: + assert result.returncode == 0, result.stderr or result.stdout + return json.loads(result.stdout) + + +def test_windows_connect_start_stop_lifecycle(): + """ + AC: Windows 上 connect 默认后台常驻(schtasks 或启动文件夹降级), + status 可见、stop 全清理。 + Behavior: connect → status(running) → stop → status(not running)。 + @category: service-integration-e2e + @lane: service-integration-e2e + @dependency: local HTTP stub, real schtasks/Startup folder + @complexity: medium + ROI: 86 + """ + repo = Path(__file__).resolve().parents[2] + server = ThreadingHTTPServer(("127.0.0.1", 0), _StubHandler) + threading.Thread(target=server.serve_forever, daemon=True).start() + address = f"127.0.0.1:{server.server_port}" + + env = os.environ.copy() # 真实 Profile:任务进程读同一份 ~/.xskill + env["XSKILL_PYPI_JSON_URL"] = ( + f"http://127.0.0.1:{server.server_port}/pypi/{{package}}/json") + + try: + connected = _run_cli(repo, env, "connect", address, + "--token", "t", "--name", "m00000003") + assert connected.returncode == 0, connected.stderr or connected.stdout + + # 计划任务是异步拉起的:轮询直到 running(最多 90s,容忍慢 runner) + deadline = time.time() + 90 + st: dict = {} + while time.time() < deadline: + st = _json_out(_run_cli(repo, env, "status", "--json")) + if st.get("running"): + break + time.sleep(3) + assert st.get("running") is True, f"常驻未进入 running: {st}" + assert st.get("method") in ("schtasks", "startup_folder") + assert st.get("crash_recovery") in ("schtasks", "watchdog") + + stopped = _json_out(_run_cli(repo, env, "stop", "--json")) + assert stopped["running"] is False + final = _json_out(_run_cli(repo, env, "status", "--json")) + assert final["running"] is False + finally: + _run_cli(repo, env, "stop", "--json") + server.shutdown() + server.server_close() diff --git a/tests/test_connect_service.py b/tests/test_connect_service.py index 27b1c075..58b080d8 100644 --- a/tests/test_connect_service.py +++ b/tests/test_connect_service.py @@ -79,6 +79,27 @@ def test_foreground_argv_uses_dash_m(monkeypatch): assert argv == ["/usr/bin/python3", "-m", "xskill", "connect", "--foreground"] +@pytest.mark.skipif(not __import__("sys").platform.startswith("linux"), + reason="fork/zombie 语义仅 Linux") +def test_pid_alive_treats_zombie_as_dead(): + """容器里 PID 1 不收割孤儿:僵尸必须判死,否则 status 误报 running。""" + import os as _os + import time as _time + pid = _os.fork() + if pid == 0: + _os._exit(0) + try: + deadline = _time.time() + 5 + while _time.time() < deadline: + stat = open(f"/proc/{pid}/stat").read() + if stat.rsplit(")", 1)[1].split()[0] == "Z": + break + _time.sleep(0.02) + assert svc._pid_alive(pid) is False + finally: + _os.waitpid(pid, 0) + + def test_pid_alive_windows_uses_exact_tasklist_pid(monkeypatch): class _Result: stdout = '"python.exe","4242","Console","1","10,000 K"\n' @@ -126,14 +147,50 @@ def test_systemd_install_writes_unit_and_starts(tmp_path, monkeypatch): assert st["method"] == "systemd-user" -def test_linux_selects_detached_when_systemd_unavailable(monkeypatch): +def test_linux_selects_supervised_when_systemd_unavailable(monkeypatch, tmp_path): + """无 systemd 的 Linux 落 supervised watchdog(自愈),不再是裸 detached。""" + monkeypatch.setattr(svc, "get_connect_daemon_state_path", + lambda: tmp_path / "connect_daemon.json") monkeypatch.setattr(svc, "_systemd_user_available", lambda: False) + monkeypatch.setattr(svc, "_install_boot_autostart", + lambda flavor, systemd_linger=False: ("cron", [])) + monkeypatch.setattr( + svc.SupervisedProcessBackend, "install_and_start", + lambda self: svc.write_daemon_state(method=self.method) + or {"running": True, "method": self.method}, + ) + monkeypatch.setattr( + svc.SupervisedProcessBackend, "status", + lambda self: {"running": True, "method": self.method, + "crash_recovery": "watchdog"}, + ) + st = svc.LinuxServiceBackend().install_and_start() + assert st["method"] == "supervised" + assert st["running"] is True + assert st["crash_recovery"] == "watchdog" + assert st["boot_autostart"] == "cron" + + +def test_linux_detached_only_via_explicit_override(monkeypatch, tmp_path): + """XSKILL_CONNECT_BACKEND=detached 仍可选裸 detached(历史语义保留)。""" + monkeypatch.setattr(svc, "get_connect_daemon_state_path", + lambda: tmp_path / "connect_daemon.json") + monkeypatch.setenv("XSKILL_CONNECT_BACKEND", "detached") monkeypatch.setattr( svc.DetachedProcessBackend, "install_and_start", + lambda self: svc.write_daemon_state(method=self.method) + or {"running": True, "method": self.method}, + ) + monkeypatch.setattr( + svc.DetachedProcessBackend, "status", lambda self: {"running": True, "method": self.method}, ) + installed_boot = [] + monkeypatch.setattr(svc, "_install_boot_autostart", + lambda *a, **kw: installed_boot.append(a) or ("cron", [])) st = svc.LinuxServiceBackend().install_and_start() - assert st == {"running": True, "method": "detached"} + assert st["method"] == "detached" + assert installed_boot == [] # 裸模式不挂自启 # ─────────────────── task XML ─────────────────── @@ -295,12 +352,14 @@ def __init__(self, *a, **kw): # .vbs 应被写入 assert fake_startup_vbs.is_file() assert "xskill" in fake_startup_vbs.read_text(encoding="utf-8").lower() - # 进程应被 detach 启动 + # 进程应被 detach 启动,且是 supervisor watchdog(降级路径也有崩溃自愈) assert len(spawned) == 1 - assert "--foreground" in spawned[0] + assert "--supervise" in spawned[0] + assert "--supervise" in fake_startup_vbs.read_text(encoding="utf-8") # status 反映 startup_folder 方法 assert st["method"] == "startup_folder" assert st["running"] is True + assert st["crash_recovery"] == "watchdog" def test_access_denied_stop_cleans_vbs_and_kills_pid( diff --git a/tests/test_supervisor.py b/tests/test_supervisor.py new file mode 100644 index 00000000..c9801e77 --- /dev/null +++ b/tests/test_supervisor.py @@ -0,0 +1,115 @@ +"""supervisor watchdog 单测:退避、respawn、state 回写、防双跑。 + +不起真进程——spawn/monotonic/sleep 全部注入替身;真实进程级验证见 +tests/e2e/test_supervised_selfheal_e2e.py。 +""" +from __future__ import annotations + +import os + +import xskill.team.client.service as svc +import xskill.team.client.supervisor as sup + + +def test_next_backoff_progression_and_cap(): + b = sup.BACKOFF_INITIAL + seq = [] + for _ in range(12): + b = sup.next_backoff(b, child_runtime=5.0) + seq.append(b) + assert seq[0] == sup.BACKOFF_INITIAL * sup.BACKOFF_FACTOR + assert seq[-1] == sup.BACKOFF_CAP # 持续崩溃封顶,不无限翻倍 + assert all(x <= sup.BACKOFF_CAP for x in seq) + + +def test_next_backoff_resets_after_healthy_run(): + assert sup.next_backoff(sup.BACKOFF_CAP, + child_runtime=sup.HEALTHY_RUNTIME) == sup.BACKOFF_INITIAL + + +class _FakeChild: + """poll() 立即返回退出码的假子进程。""" + + def __init__(self, pid: int, returncode: int = 1): + self.pid = pid + self.returncode = returncode + + def poll(self): + return self.returncode + + def terminate(self): + pass + + def kill(self): + pass + + def wait(self, timeout=None): + return self.returncode + + +def test_supervisor_respawns_crashed_child_and_writes_state(tmp_path, monkeypatch): + state_path = tmp_path / "connect_daemon.json" + monkeypatch.setattr(svc, "get_connect_daemon_state_path", lambda: state_path) + + spawned: list[_FakeChild] = [] + + def fake_spawn(): + child = _FakeChild(pid=1000 + len(spawned)) + spawned.append(child) + return child + + s = sup.Supervisor(spawn=fake_spawn, sleep=None) + + sleeps: list[float] = [] + + def fake_sleep(seconds: float): + sleeps.append(seconds) + if len(sleeps) >= 3: # 三次退避后请求停止 + s._stop_requested = True + + s._sleep = fake_sleep + assert s.run() == 0 + + assert len(spawned) == 3 # 崩 3 次拉 3 次 + assert sleeps == [2.0, 4.0, 8.0] # 指数退避(起步 1s 已 ×2) + state = svc.read_daemon_state() + assert state["watchdog_pid"] == os.getpid() + assert state["child_pid"] == spawned[-1].pid # 每次 spawn 都回写 + + +def test_supervisor_refuses_duplicate_watchdog(tmp_path, monkeypatch): + state_path = tmp_path / "connect_daemon.json" + monkeypatch.setattr(svc, "get_connect_daemon_state_path", lambda: state_path) + svc.write_daemon_state(method="supervised", watchdog_pid=12345) + monkeypatch.setattr(svc, "_pid_alive", lambda pid: pid == 12345) + + spawned = [] + s = sup.Supervisor(spawn=lambda: spawned.append(1)) + assert s.run() == 0 + assert spawned == [] # 已有 watchdog,幂等退出 + assert svc.read_daemon_state()["watchdog_pid"] == 12345 # 不抢占 + + +def test_supervisor_spawn_failure_backs_off_instead_of_exiting(tmp_path, + monkeypatch): + """spawn 本身 OSError(fd 耗尽等瞬态)也走退避重试,watchdog 不退出。""" + state_path = tmp_path / "connect_daemon.json" + monkeypatch.setattr(svc, "get_connect_daemon_state_path", lambda: state_path) + + attempts = [] + + def failing_spawn(): + attempts.append(1) + raise OSError("too many open files") + + s = sup.Supervisor(spawn=failing_spawn) + sleeps = [] + + def fake_sleep(seconds): + sleeps.append(seconds) + if len(sleeps) >= 2: + s._stop_requested = True + + s._sleep = fake_sleep + assert s.run() == 0 + assert len(attempts) == 2 diff --git a/tests/test_updater_health_rollback.py b/tests/test_updater_health_rollback.py new file mode 100644 index 00000000..c2316141 --- /dev/null +++ b/tests/test_updater_health_rollback.py @@ -0,0 +1,116 @@ +"""updater 健康检查 + 回滚 + 坏版本拉黑(cross-platform-persistence)。 + +坏 wheel 装上后若直接重启,常驻进程会进入「重启即崩」死循环且 updater 永远 +不会自愈——这里验证三道防线:装后健康检查、失败回滚、坏版本不再重试。 +""" +from __future__ import annotations + +import json + +import pytest + +import xskill.team.client.updater as upd + + +@pytest.fixture +def journal_path(tmp_path, monkeypatch): + p = tmp_path / "update_journal.json" + monkeypatch.setattr(upd, "_journal_path", lambda: p) + return p + + +def test_install_and_verify_success_records_last_good(journal_path, monkeypatch): + monkeypatch.setattr(upd.AutoUpdater, "_install", lambda self, v: True) + monkeypatch.setattr(upd, "_health_check", lambda: True) + + assert upd.AutoUpdater().install_and_verify("2.0.0", "1.0.0") is True + assert upd.load_update_journal()["last_good"] == "2.0.0" + assert not upd._is_blacklisted("2.0.0") + + +def test_install_and_verify_bad_version_rolls_back_and_blacklists( + journal_path, monkeypatch, +): + installed: list[str] = [] + monkeypatch.setattr(upd.AutoUpdater, "_install", + lambda self, v: installed.append(v) or True) + # 新版本健康检查失败;回滚后的检查成功 + health = iter([False, True]) + monkeypatch.setattr(upd, "_health_check", lambda: next(health)) + + assert upd.AutoUpdater().install_and_verify("2.0.0", "1.0.0") is False + assert installed == ["2.0.0", "1.0.0"] # 先装新版,失败后装回旧版 + assert upd._is_blacklisted("2.0.0") + assert upd.load_update_journal().get("last_good") != "2.0.0" + + +def test_install_failure_does_not_blacklist(journal_path, monkeypatch): + """pip 安装失败(网络/镜像抖动)≠ 坏版本,下次还应重试。""" + monkeypatch.setattr(upd.AutoUpdater, "_install", lambda self, v: False) + assert upd.AutoUpdater().install_and_verify("2.0.0", "1.0.0") is False + assert not upd._is_blacklisted("2.0.0") + + +def test_check_and_update_skips_blacklisted_pypi_version(journal_path, monkeypatch): + upd._blacklist_version("2.0.0", "health_check_failed") + + monkeypatch.setattr(upd, "_current_version", lambda pkg: "1.0.0") + monkeypatch.setattr(upd, "_latest_pypi_version", lambda pkg: "2.0.0") + installs = [] + monkeypatch.setattr(upd.AutoUpdater, "install_and_verify", + lambda self, t, c: installs.append(t) or True) + restarted = [] + monkeypatch.setattr(upd, "_restart", lambda: restarted.append(1)) + server_checked = [] + monkeypatch.setattr( + upd.AutoUpdater, "_check_server_fallback", + lambda self, cs, c, *, reason: server_checked.append(reason)) + + upd.AutoUpdater()._check_and_update() + assert installs == [] # 拉黑版本不再安装 + assert restarted == [] + assert server_checked == ["pypi_blacklisted"] # 但 server 渠道仍会查 + + +def test_server_fallback_skips_blacklisted_version(journal_path, monkeypatch): + upd._blacklist_version("3.0.0", "health_check_failed") + monkeypatch.setattr( + upd, "_server_version", + lambda *a: {"version": "3.0.0", "wheel_available": True, + "wheel_filename": "x.whl"}) + downloads = [] + monkeypatch.setattr(upd, "_download_server_wheel", + lambda *a, **kw: downloads.append(1) or None) + + from packaging.version import Version + u = upd.AutoUpdater(server_url="http://s", client_id="c", join_token="t") + u._check_server_fallback("1.0.0", Version("1.0.0"), reason="pypi_query_failed") + assert downloads == [] # 连 wheel 都不必下 + + +def test_journal_corruption_tolerated(journal_path): + journal_path.write_text("{broken json", encoding="utf-8") + assert upd.load_update_journal() == {} + upd._blacklist_version("2.0.0", "x") # 损坏文件被健康内容覆盖 + assert json.loads(journal_path.read_text(encoding="utf-8"))["bad_versions"] + + +def test_restart_under_supervisor_exits_nonzero(monkeypatch): + """XSKILL_SUPERVISED=1 时统一走「非零退出,交 watchdog 重启」路径。""" + monkeypatch.setenv("XSKILL_SUPERVISED", "1") + exit_codes = [] + + def fake_exit(code): + # 真 os._exit 不返回;替身必须抛异常阻断后续 execv 分支。 + exit_codes.append(code) + raise SystemExit(code) + + monkeypatch.setattr(upd.os, "_exit", fake_exit) + monkeypatch.setattr(upd, "_windows_persistence_method", + lambda: pytest.fail("supervised 分支不应查 Windows 方法")) + import time as _time + monkeypatch.setattr(_time, "sleep", lambda s: None) + + with pytest.raises(SystemExit): + upd._restart() + assert exit_codes == [1] diff --git a/tests/test_wsl_persistence_policy.py b/tests/test_wsl_persistence_policy.py index bb7c0718..8bf2cd58 100644 --- a/tests/test_wsl_persistence_policy.py +++ b/tests/test_wsl_persistence_policy.py @@ -1,3 +1,9 @@ +"""WSL / 鸿蒙 / 无 systemd Linux 的常驻策略(cross-platform-persistence)。 + +旧策略「WSL 无 systemd 直接硬失败」已废除:崩溃自愈由 supervised watchdog +兜底,开机自启按能力(WSL interop / crontab / linger)尽力挂载,挂不上只记 +degraded——按能力探测降级,不按平台名一刀切。 +""" from __future__ import annotations import types @@ -7,36 +13,72 @@ import xskill.team.client.service as svc -def test_wsl_without_systemd_refuses_detached_success(monkeypatch): +@pytest.fixture +def state_path(tmp_path, monkeypatch): + p = tmp_path / "connect_daemon.json" + monkeypatch.setattr(svc, "get_connect_daemon_state_path", lambda: p) + return p + + +def _stub_supervised(monkeypatch): + """把 SupervisedProcessBackend 打成不起真进程的替身。""" + monkeypatch.setattr( + svc.SupervisedProcessBackend, "install_and_start", + lambda self: svc.write_daemon_state(method=self.method) + or {"running": True, "method": self.method, + "crash_recovery": "watchdog"}, + ) + monkeypatch.setattr( + svc.SupervisedProcessBackend, "status", + lambda self: {"running": True, "method": self.method, + "crash_recovery": "watchdog"}, + ) + + +# ─────────────── WSL:无 systemd 不再拒绝,落 supervised ─────────────── + +def test_wsl_without_systemd_falls_back_to_supervised(monkeypatch, state_path): + monkeypatch.setattr(svc, "_is_wsl", lambda: True) + monkeypatch.setattr(svc, "_systemd_user_available", lambda: False) + monkeypatch.setattr(svc, "_install_wsl_boot_task", lambda: True) + _stub_supervised(monkeypatch) + + st = svc.LinuxServiceBackend().install_and_start() # 不抛 ServiceError + assert st["running"] is True + assert st["method"] == "supervised" + assert st["crash_recovery"] == "watchdog" + assert st["boot_autostart"] == "windows-task" + + +def test_wsl_without_systemd_nor_interop_degrades_visibly(monkeypatch, state_path): + """interop 也不可用:仍常驻(自愈),但 degraded 必须明示自启缺失。""" monkeypatch.setattr(svc, "_is_wsl", lambda: True) monkeypatch.setattr(svc, "_systemd_user_available", lambda: False) - monkeypatch.setattr(svc, "read_daemon_state", lambda: {"running": False}) + monkeypatch.setattr(svc, "_install_wsl_boot_task", lambda: False) + _stub_supervised(monkeypatch) - backend = svc.LinuxServiceBackend() - with pytest.raises(svc.ServiceError, match="systemd"): - backend.install_and_start() - status = backend.status() - assert status["running"] is False - assert status["platform"] == "wsl" - assert status["method"] == "systemd-required" + st = svc.LinuxServiceBackend().install_and_start() + assert st["running"] is True + assert st["boot_autostart"] == "none" + assert any("开机" in w or "start" in w for w in st["degraded"]) -def test_plain_linux_without_systemd_keeps_detached_fallback(monkeypatch): +def test_plain_linux_without_systemd_uses_supervised(monkeypatch, state_path): monkeypatch.setattr(svc, "_is_wsl", lambda: False) + monkeypatch.setattr(svc, "_is_harmony", lambda: False) monkeypatch.setattr(svc, "_systemd_user_available", lambda: False) - monkeypatch.setattr( - svc.DetachedProcessBackend, - "install_and_start", - lambda self: {"running": True, "method": self.method}, - ) + monkeypatch.setattr(svc, "_install_cron_boot", lambda: True) + _stub_supervised(monkeypatch) - assert svc.LinuxServiceBackend().install_and_start() == { - "running": True, - "method": "detached", - } + st = svc.LinuxServiceBackend().install_and_start() + assert st["method"] == "supervised" + assert st["boot_autostart"] == "cron" -def test_wsl_systemd_install_requires_linger(monkeypatch, tmp_path): +# ─────────────── WSL + systemd:linger 失败降级而非硬失败 ─────────────── + +def test_wsl_systemd_linger_failure_no_longer_fatal(monkeypatch, tmp_path, + state_path): monkeypatch.setattr(svc, "_is_wsl", lambda: True) monkeypatch.setattr(svc.shutil, "which", lambda name: f"/usr/bin/{name}") monkeypatch.setenv("USER", "alice") @@ -44,11 +86,173 @@ def test_wsl_systemd_install_requires_linger(monkeypatch, tmp_path): def fake_run(args, **kwargs): if args[:2] == ["loginctl", "enable-linger"]: return types.SimpleNamespace(returncode=1, stdout="", stderr="denied") - return types.SimpleNamespace(returncode=0, stdout="", stderr="") + stdout = "" + if "show" in args: + stdout = ("LoadState=loaded\nActiveState=active\n" + "SubState=running\nMainPID=2468\n") + return types.SimpleNamespace(returncode=0, stdout=stdout, stderr="") monkeypatch.setattr(svc.subprocess, "run", fake_run) unit = tmp_path / "xskill-connect.service" - with pytest.raises(svc.ServiceError, match="linger"): - svc.SystemdUserBackend(unit_path=unit).install_and_start() - assert not unit.exists() + st = svc.SystemdUserBackend(unit_path=unit).install_and_start() # 不抛 + assert unit.exists() + assert st["running"] is True + assert st["linger_enabled"] is False + + +# ─────────────── WSL + systemd:仍要挂 Windows 侧开机任务 ─────────────── + +def test_wsl_systemd_still_installs_windows_boot_task(monkeypatch, state_path): + """systemd+linger 只管 VM 内自启;Windows 重启后 VM 要 Windows 侧拉起。""" + monkeypatch.setattr(svc, "_is_wsl", lambda: True) + monkeypatch.setattr(svc, "_systemd_user_available", lambda: True) + monkeypatch.setattr( + svc.SystemdUserBackend, "install_and_start", + lambda self: svc.write_daemon_state(method=self.method, + linger_enabled=True) + or {"running": True, "method": self.method}, + ) + monkeypatch.setattr( + svc.SystemdUserBackend, "status", + lambda self: {"running": True, "method": self.method}, + ) + calls = [] + monkeypatch.setattr(svc, "_install_wsl_boot_task", + lambda: calls.append(1) or True) + + st = svc.LinuxServiceBackend().install_and_start() + assert calls == [1] + assert st["boot_autostart"] == "windows-task" + + +# ─────────────── 开机自启挂载决策表 ─────────────── + +@pytest.mark.parametrize( + "flavor,linger,interop_ok,cron_ok,expect", + [ + ("wsl", False, True, False, "windows-task"), + ("wsl", True, False, False, "systemd-linger"), # interop 挂不上,VM 内自启兜底 + ("wsl", False, False, False, "none"), + ("linux", True, False, False, "systemd-linger"), + ("linux", False, False, True, "cron"), + ("harmony", False, False, True, "cron"), + ("harmony", False, False, False, "none"), + ], +) +def test_boot_autostart_decision(monkeypatch, flavor, linger, interop_ok, + cron_ok, expect): + monkeypatch.setattr(svc, "_install_wsl_boot_task", lambda: interop_ok) + monkeypatch.setattr(svc, "_install_cron_boot", lambda: cron_ok) + mode, warnings = svc._install_boot_autostart(flavor, systemd_linger=linger) + assert mode == expect + if expect == "none": + assert warnings # 降到无自启必须有人类可读的警告 + + +# ─────────────── WSL interop:Windows 侧任务命令拼装 ─────────────── + +def test_wsl_boot_task_command_assembly(monkeypatch): + calls = [] + monkeypatch.setenv("WSL_DISTRO_NAME", "Ubuntu-22.04") + monkeypatch.setattr(svc.shutil, "which", lambda name: f"/mnt/c/win/{name}") + + def fake_run(args, **kw): + calls.append(list(args)) + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(svc.subprocess, "run", fake_run) + import getpass + monkeypatch.setattr(getpass, "getuser", lambda: "alice") + + assert svc._install_wsl_boot_task() is True + (create,) = calls + assert create[:2] == ["schtasks.exe", "/Create"] + assert svc.WINDOWS_WSL_BOOT_TASK in create + tr = create[create.index("/TR") + 1] + assert "wsl.exe -d Ubuntu-22.04" in tr + assert "-u alice" in tr + assert "xskill" in tr and "start" in tr and "--quiet" in tr + + svc._remove_wsl_boot_task() + assert calls[1][:2] == ["schtasks.exe", "/Delete"] + assert svc.WINDOWS_WSL_BOOT_TASK in calls[1] + + +def test_wsl_boot_task_requires_distro_and_interop(monkeypatch): + monkeypatch.delenv("WSL_DISTRO_NAME", raising=False) + assert svc._install_wsl_boot_task() is False # 无发行版名 + monkeypatch.setenv("WSL_DISTRO_NAME", "Ubuntu") + monkeypatch.setattr(svc.shutil, "which", lambda name: None) + assert svc._install_wsl_boot_task() is False # interop 不可用 + + +# ─────────────── 鸿蒙识别 ─────────────── + +@pytest.mark.parametrize( + "os_release,expected", + [ + ('NAME="HarmonyOS"\nID=harmonyos\nVERSION_ID=5.1\n', True), + ('NAME=OpenHarmony\nID=openharmony\n', True), + ('ID=euleros\nID_LIKE="openharmony linux"\n', True), + ('NAME="Ubuntu"\nID=ubuntu\nID_LIKE=debian\n', False), + ("", False), + ], +) +def test_is_harmony_from_os_release(tmp_path, os_release, expected): + p = tmp_path / "os-release" + p.write_text(os_release, encoding="utf-8") + assert svc._is_harmony(str(p)) is expected + + +def test_linux_flavor_priority(monkeypatch): + monkeypatch.setattr(svc, "_is_wsl", lambda: True) + monkeypatch.setattr(svc, "_is_harmony", lambda: True) + assert svc._linux_flavor() == "wsl" # wsl 判定优先 + monkeypatch.setattr(svc, "_is_wsl", lambda: False) + assert svc._linux_flavor() == "harmony" + monkeypatch.setattr(svc, "_is_harmony", lambda: False) + assert svc._linux_flavor() == "linux" + + +# ─────────────── cron @reboot marker 幂等 ─────────────── + +class _FakeCrontab: + """内存版 crontab:crontab -l 读、crontab - 写。""" + + def __init__(self, initial: str = ""): + self.content = initial + + def __call__(self, args, **kw): + if args[:2] == ["crontab", "-l"]: + rc = 0 if self.content else 1 + return types.SimpleNamespace(returncode=rc, stdout=self.content, + stderr="" if rc == 0 else "no crontab") + if args[:2] == ["crontab", "-"]: + self.content = kw.get("input", "") + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + + +def test_cron_boot_install_is_idempotent(monkeypatch): + fake = _FakeCrontab("0 3 * * * /usr/bin/backup.sh\n") + monkeypatch.setattr(svc.shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr(svc.subprocess, "run", fake) + + assert svc._install_cron_boot() is True + assert svc._install_cron_boot() is True # 再装一次不重复 + lines = [ln for ln in fake.content.splitlines() if svc._CRON_MARKER in ln] + assert len(lines) == 1 + assert lines[0].startswith("@reboot ") + assert "xskill" in lines[0] and "--quiet" in lines[0] + assert "backup.sh" in fake.content # 用户已有条目不被动 + + svc._remove_cron_boot() + assert svc._CRON_MARKER not in fake.content + assert "backup.sh" in fake.content + + +def test_cron_unavailable_probe(monkeypatch): + monkeypatch.setattr(svc.shutil, "which", lambda name: None) + assert svc._crontab_available() is False + assert svc._install_cron_boot() is False From d9ecb94273c17c4375308865ac07bbe3c40e0b81 Mon Sep 17 00:00:00 2001 From: 370025263 <370025263@qq.com> Date: Sun, 12 Jul 2026 18:08:15 +0800 Subject: [PATCH 5/9] =?UTF-8?q?ci:=20=E8=BF=9E=E6=8E=A5=E7=B1=BB=20e2e=20?= =?UTF-8?q?=E6=94=B9=E4=BE=9D=E8=B5=96=20verify-build=E2=80=94=E2=80=94?= =?UTF-8?q?=E4=B8=8D=E8=A2=AB=20macOS/Windows=20=E5=AD=98=E9=87=8F=20ut-it?= =?UTF-8?q?=20=E7=BA=A2=E7=81=AF=E8=BF=9E=E5=9D=90=20|=20decouple=20connec?= =?UTF-8?q?t=20e2e=20from=20pre-existing=20ut-it=20reds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main 最近 3 次 CI 本就 failure(test_skill_tools_atom /tmp 符号链接、 test_dashboard_console_p2 WinError 5,均与常驻链路无关),needs: ut-it 会让本 PR 的关键验证(Windows schtasks e2e、自愈 e2e)永远 skipped。 Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d368027e..6e2a1641 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,7 +90,10 @@ jobs: connect-lifecycle-e2e: name: connect lifecycle e2e (linux/wsl mode) - needs: ut-it + # 只依赖 verify-build:ut-it 在 macOS/Windows 存在与常驻链路无关的存量 + # 红(test_skill_tools_atom 路径解析等),连接类 e2e 是持久化改动的关键 + # 验证,不应被无关红灯连坐跳过。 + needs: verify-build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -107,7 +110,7 @@ jobs: connect-e2e-windows: name: connect e2e (windows, real schtasks) - needs: ut-it + needs: verify-build runs-on: windows-latest env: XSKILL_WIN_E2E: "1" # 一次性 runner,允许写真实用户 Profile @@ -128,7 +131,7 @@ jobs: # nightly / 手动触发跑;本地随时可 bash tests/docker_e2e/platform_matrix/run.sh all name: platform matrix e2e (docker, nightly) if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - needs: ut-it + needs: verify-build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 From 7b5d8664bd2c9b7a4044b8285e6c6cbde5976682 Mon Sep 17 00:00:00 2001 From: 370025263 <370025263@qq.com> Date: Sun, 12 Jul 2026 18:13:32 +0800 Subject: [PATCH 6/9] =?UTF-8?q?fix(cli):=20Windows=20=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E5=8F=B0=20UTF-8=20=E9=87=8D=E9=85=8D=E2=80=94=E2=80=94?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E8=BE=93=E5=87=BA=E4=B8=8D=E5=86=8D=20Unicod?= =?UTF-8?q?eEncodeError=20|=20UTF-8=20stdout=20on=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI windows e2e 首跑抓到的真 bug:cp1252 控制台下 cmd_connect 的中文提示 直接炸 UnicodeEncodeError,且炸点在 schtasks 任务已装好之后——用户看到 traceback + 退出码 1,实际却成功了。main() 入口统一 reconfigure utf-8。 附带:lifecycle e2e 的 stub PyPI 补非 dev 版 0.0.0(CI 浅克隆下本地版本 是 dev 版会被 updater 过滤,releases 只剩 dev 版时查询结果为空)。该 e2e 此前在 main 上一直被 ut-it 存量红灯连坐 skip,从未暴露。 Co-Authored-By: Claude Fable 5 --- src/xskill/cli.py | 11 +++++++++++ tests/e2e/test_connect_lifecycle_e2e.py | 6 +++++- tests/e2e/test_windows_connect_e2e.py | 3 +++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/xskill/cli.py b/src/xskill/cli.py index 81a2cb4c..363fbc33 100644 --- a/src/xskill/cli.py +++ b/src/xskill/cli.py @@ -795,6 +795,17 @@ def _setup_logging(debug: bool, quiet: bool, *, command: str = "") -> None: # ═══════════════════════════════════════════════════════════════ def main() -> int: + # Windows 默认控制台编码常是 cp1252/GBK 之外的单字节页(如英文系统 + # cp1252),CLI 的中文输出会 UnicodeEncodeError 直接炸——而且炸点在 + # schtasks 任务已装好之后,用户看到 traceback + 退出码 1,实际却成功了。 + # 统一重配为 UTF-8(errors=replace 兜底),POSIX 上是 no-op。 + if sys.platform == "win32": + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, OSError, ValueError): + pass + parser = build_parser() args = parser.parse_args() if not args.command: diff --git a/tests/e2e/test_connect_lifecycle_e2e.py b/tests/e2e/test_connect_lifecycle_e2e.py index 3e134d01..fa5c6e54 100644 --- a/tests/e2e/test_connect_lifecycle_e2e.py +++ b/tests/e2e/test_connect_lifecycle_e2e.py @@ -37,7 +37,11 @@ def do_POST(self) -> None: # noqa: N802 def do_GET(self) -> None: # noqa: N802 if self.path == "/pypi/xskill/json": - self._json({"releases": {__version__: [{}]}}) + # 额外给一个非 dev 的 0.0.0:CI 浅克隆下 setuptools-scm 的本地 + # 版本是 dev 版(0.0.1.dev1+unknown...),updater 会过滤 dev + # 版——若 releases 里只有它,查询结果为空,「已是最新版本」断言 + # 就变成「查询失败」。0.0.0 恒不高于当前版本,语义不变。 + self._json({"releases": {"0.0.0": [{}], __version__: [{}]}}) return self._json({"detail": "not found"}, status=404) diff --git a/tests/e2e/test_windows_connect_e2e.py b/tests/e2e/test_windows_connect_e2e.py index c3ef3cfd..a65e4a08 100644 --- a/tests/e2e/test_windows_connect_e2e.py +++ b/tests/e2e/test_windows_connect_e2e.py @@ -57,9 +57,12 @@ def log_message(self, format: str, *args) -> None: def _run_cli(repo: Path, env: dict, *args: str) -> subprocess.CompletedProcess: + # CLI 已把 stdout/stderr 重配为 UTF-8(cli.main),读取端也按 UTF-8 解码; + # errors=replace 防个别系统消息混入非 UTF-8 字节炸掉测试。 return subprocess.run( [sys.executable, "-m", "xskill", *args], cwd=repo, env=env, capture_output=True, text=True, timeout=60, + encoding="utf-8", errors="replace", ) From 7c43aeda93203654c5a4ca5b50823d247b8002c8 Mon Sep 17 00:00:00 2001 From: 370025263 <370025263@qq.com> Date: Sun, 12 Jul 2026 18:19:15 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix(cli):=20pythonw=20=E4=B8=8B=20None=20?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E6=B5=81=E5=85=9C=E5=BA=95=E2=80=94=E2=80=94?= =?UTF-8?q?Windows=20schtasks=20=E5=B8=B8=E9=A9=BB=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=A7=92=E5=B4=A9=20|=20devnull=20streams=20?= =?UTF-8?q?under=20pythonw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows e2e 第二轮抓出的存量 bug:schtasks/启动文件夹常驻用 pythonw 免 弹窗,但 pythonw 的 sys.stdout/stderr 是 None,connect --foreground 开头 的 print 直接 AttributeError——常驻进程秒死、schtasks 每分钟空转重启、 pid 永远为 None。main() 入口补 devnull 流(与 UTF-8 重配同处兜底), updater 健康检查子进程同样受益。 Co-Authored-By: Claude Fable 5 --- src/xskill/cli.py | 17 +++++++++--- tests/test_cli_windows_streams.py | 44 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 tests/test_cli_windows_streams.py diff --git a/src/xskill/cli.py b/src/xskill/cli.py index 363fbc33..f03b7f16 100644 --- a/src/xskill/cli.py +++ b/src/xskill/cli.py @@ -795,11 +795,20 @@ def _setup_logging(debug: bool, quiet: bool, *, command: str = "") -> None: # ═══════════════════════════════════════════════════════════════ def main() -> int: - # Windows 默认控制台编码常是 cp1252/GBK 之外的单字节页(如英文系统 - # cp1252),CLI 的中文输出会 UnicodeEncodeError 直接炸——而且炸点在 - # schtasks 任务已装好之后,用户看到 traceback + 退出码 1,实际却成功了。 - # 统一重配为 UTF-8(errors=replace 兜底),POSIX 上是 no-op。 + # Windows 两个输出流暗坑,都会把进程直接带崩,必须在最入口兜底: + # 1. pythonw(schtasks/启动文件夹常驻用它免弹窗)下 sys.stdout/stderr + # 是 None——任何 print 即 AttributeError,`connect --foreground` + # 开头那句 "reconnecting: ..." 就足以让常驻进程秒崩、pid 永远拿 + # 不到(Windows e2e 实测抓出)。补 devnull 流。 + # 2. 控制台编码常是 cp1252 等单字节页,中文输出 UnicodeEncodeError, + # 且炸点可能在 schtasks 任务已装好之后——用户看到 traceback + + # 退出码 1,实际却成功了。统一重配 UTF-8(errors=replace)。 if sys.platform == "win32": + import os as _os + if sys.stdout is None: + sys.stdout = open(_os.devnull, "w", encoding="utf-8") + if sys.stderr is None: + sys.stderr = open(_os.devnull, "w", encoding="utf-8") for stream in (sys.stdout, sys.stderr): try: stream.reconfigure(encoding="utf-8", errors="replace") diff --git a/tests/test_cli_windows_streams.py b/tests/test_cli_windows_streams.py new file mode 100644 index 00000000..493c2ead --- /dev/null +++ b/tests/test_cli_windows_streams.py @@ -0,0 +1,44 @@ +"""Windows 输出流兜底:pythonw 的 None 流 + 单字节控制台编码。 + +schtasks / 启动文件夹常驻用 pythonw 运行(免弹窗),其 sys.stdout/stderr +为 None——CLI 任何 print 直接 AttributeError 崩进程(常驻秒死、schtasks +每分钟空转重启)。main() 入口必须换成 devnull 流并统一 UTF-8。 +""" +from __future__ import annotations + +import sys + +import pytest + +import xskill.cli as cli + + +def test_main_survives_pythonw_null_streams(monkeypatch): + monkeypatch.setattr(cli.sys, "platform", "win32") + monkeypatch.setattr(sys, "stdout", None) + monkeypatch.setattr(sys, "stderr", None) + monkeypatch.setattr(sys, "argv", ["xskill", "--version"]) + + # argparse 的 --version 打印到 sys.stdout 后 SystemExit(0);若 main 没 + # 兜底 None 流,这里会是 AttributeError 而非干净退出。 + with pytest.raises(SystemExit) as ei: + cli.main() + assert ei.value.code == 0 + assert sys.stdout is not None # 已被替换为可写流 + assert sys.stderr is not None + + +def test_main_reconfigures_streams_to_utf8(monkeypatch, tmp_path): + monkeypatch.setattr(cli.sys, "platform", "win32") + # 模拟 cp1252 控制台:中文写入会炸的流 + out = open(tmp_path / "out.txt", "w", encoding="cp1252") + err = open(tmp_path / "err.txt", "w", encoding="cp1252") + monkeypatch.setattr(sys, "stdout", out) + monkeypatch.setattr(sys, "stderr", err) + monkeypatch.setattr(sys, "argv", ["xskill", "--version"]) + + with pytest.raises(SystemExit): + cli.main() + print("中文输出不应该炸") # reconfigure 后写中文安全 + sys.stdout.flush() + assert sys.stdout.encoding.lower().replace("-", "") == "utf8" From 9f14f1e1208a3382c62997da8814fea0d190e260 Mon Sep 17 00:00:00 2001 From: 370025263 <370025263@qq.com> Date: Sun, 12 Jul 2026 18:25:32 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix(windows):=20schtasks=20/Run=20=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E2=89=A0=E8=BF=9B=E7=A8=8B=E5=AD=98=E5=9C=A8=E2=80=94?= =?UTF-8?q?=E2=80=94=E8=A7=82=E6=B5=8B=E4=B8=8D=E5=88=B0=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E6=97=B6=20direct-spawn=20supervisor=20?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=20|=20verify=20by=20observation,=20not=20exi?= =?UTF-8?q?t=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows e2e 第三轮定位:LogonTrigger 任务(未存凭据)只能在用户已登录的 交互会话启动;服务上下文/CI/断开 RDP 里 /Run 返回 0 但任务永不进 Running。新策略:/Run 后观测窗口内拿不到任务进程 PID → 直接 detach 拉起 supervisor 保证当下常驻(launch=direct-spawn,自愈=watchdog),计划任务 保留作下次登录自启;stop 时连 watchdog 进程树一起清。 Co-Authored-By: Claude Fable 5 --- src/xskill/team/client/service.py | 85 ++++++++++++++++++++++++------- tests/test_connect_service.py | 40 +++++++++++++++ 2 files changed, 106 insertions(+), 19 deletions(-) diff --git a/src/xskill/team/client/service.py b/src/xskill/team/client/service.py index 88217507..22ab6743 100644 --- a/src/xskill/team/client/service.py +++ b/src/xskill/team/client/service.py @@ -400,10 +400,58 @@ def install_and_start(self) -> dict: " 可手动在「任务计划程序」里运行 " + self.task_name + " 排查。" ) + # /Run 返回 0 ≠ 进程真起来了:LogonTrigger 任务(未存凭据)只能在 + # 「用户已登录」的交互会话里启动,服务上下文/CI/断开的 RDP 里 + # schtasks 会报成功但任务永远不进 Running。按观测验证,拿不到 + # 任务进程 PID 就降级:direct-spawn supervisor 保证当下常驻, + # 计划任务保留作下次登录自启。 + pid = self._wait_task_pid(timeout=self.TASK_START_TIMEOUT) + if pid is None: + logger.info( + "schtasks /Run 成功但 %ss 内未观测到任务进程" + "(无交互登录会话?),降级 direct-spawn supervisor", + self.TASK_START_TIMEOUT) + watchdog_pid = self._spawn_detached(_supervise_argv()) + write_daemon_state(task_name=self.task_name, backend=self.name, + method="schtasks", argv=argv, + launch="direct-spawn", watchdog_pid=watchdog_pid) + return self.status() + write_daemon_state(task_name=self.task_name, backend=self.name, - method="schtasks", argv=argv, pid=self._query_pid()) + method="schtasks", argv=argv, pid=pid) return self.status() + # /Run 后等任务进程出现的观测窗口(秒)。已登录桌面上任务 1-2s 就起。 + TASK_START_TIMEOUT = 10 + + def _wait_task_pid(self, timeout: float) -> Optional[int]: + deadline = time.time() + timeout + while True: + pid = self._query_pid() + if pid is not None and _pid_alive(pid): + return pid + if time.time() >= deadline: + return None + time.sleep(1) + + @staticmethod + def _spawn_detached(argv: list[str]) -> int: + """CREATE_NO_WINDOW|DETACHED_PROCESS 拉起进程,返回 pid。""" + CREATE_NO_WINDOW = 0x08000000 + DETACHED_PROCESS = 0x00000008 + try: + proc = subprocess.Popen( + argv, + creationflags=CREATE_NO_WINDOW | DETACHED_PROCESS, + close_fds=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except OSError as e: + raise ServiceError(f"启动进程失败:{e}") from e + return proc.pid + def _install_startup_folder_and_spawn(self, argv: list[str]) -> dict: """降级方案:写 Startup 文件夹 .vbs 脚本 + 立即 detach 启动进程。 @@ -430,21 +478,7 @@ def _install_startup_folder_and_spawn(self, argv: list[str]) -> dict: except OSError as e: raise ServiceError(f"写开机启动脚本失败:{e}") from e - # 立即 detach 启动(CREATE_NO_WINDOW=0x08000000, DETACHED_PROCESS=0x00000008) - CREATE_NO_WINDOW = 0x08000000 - DETACHED_PROCESS = 0x00000008 - try: - proc = subprocess.Popen( - watchdog_argv, - creationflags=CREATE_NO_WINDOW | DETACHED_PROCESS, - close_fds=True, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - pid = proc.pid - except OSError as e: - raise ServiceError(f"启动进程失败:{e}") from e + pid = self._spawn_detached(watchdog_argv) write_daemon_state(method="startup_folder", backend=self.name, vbs_path=str(vbs_path), argv=watchdog_argv, @@ -482,6 +516,14 @@ def stop(self) -> dict: # 计划任务路径 self._run(["/End", "/TN", self.task_name]) delete = self._run(["/Delete", "/TN", self.task_name, "/F"]) + # direct-spawn 降级过的还有 watchdog 进程树要杀(/End 只管任务进程) + for pid in {state.get("watchdog_pid"), state.get("child_pid")}: + if pid and _pid_alive(pid): + try: + subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], + capture_output=True, check=False) + except OSError: + pass clear_daemon_state() st = {"running": False, "backend": self.name, "task_name": self.task_name, "method": method} @@ -520,17 +562,22 @@ def status(self) -> dict: "backend": self.name, "task_name": self.task_name, "method": method} pid = self._query_pid() + wpid = state.get("watchdog_pid") + direct_spawn = state.get("launch") == "direct-spawn" return { "installed": True, "backend": self.name, "task_name": self.task_name, "method": method, - "pid": pid, - "running": _pid_alive(pid), + "pid": pid or state.get("child_pid") or wpid, + "watchdog_pid": wpid, + # 任务进程或 direct-spawn 的 watchdog 任一存活即 running + "running": _pid_alive(pid) or _pid_alive(wpid), "server_url": state.get("server_url"), "client_id": state.get("client_id"), "started_at": state.get("started_at"), - "crash_recovery": "schtasks", + "crash_recovery": "watchdog" if direct_spawn else "schtasks", + "launch": state.get("launch"), "schtasks_query": q.stdout.strip(), } diff --git a/tests/test_connect_service.py b/tests/test_connect_service.py index 58b080d8..02eabb90 100644 --- a/tests/test_connect_service.py +++ b/tests/test_connect_service.py @@ -315,6 +315,46 @@ def test_windows_query_pid_parsing(win_backend, monkeypatch): assert win_backend._query_pid() == 13579 +class _NoPidSchtasks(_FakeSchtasks): + """任务创建/启动都「成功」,但 /Query 永远没有 PID 行——无交互登录会话 + (CI/服务上下文/断开的 RDP)里 LogonTrigger 任务的真实表现。""" + + def __call__(self, args, **kw): + cp = super().__call__(args, **kw) + if args[0] == "schtasks" and "/Query" in args: + cp.stdout = ("TaskName: \\Xskill_Connect\n" + "Status: Ready\n") + return cp + + +def test_schtasks_run_ok_but_no_process_falls_back_to_direct_spawn( + win_backend, monkeypatch, +): + """/Run 返回 0 不可信:观测不到任务进程就 direct-spawn supervisor。""" + monkeypatch.setattr(svc.subprocess, "run", _NoPidSchtasks()) + monkeypatch.setattr(svc.WindowsTaskSchedulerBackend, "TASK_START_TIMEOUT", 0) + + spawned = [] + + class _FakePopen: + pid = 7777 + + def __init__(self, a, **kw): + spawned.append(a) + + monkeypatch.setattr(svc.subprocess, "Popen", _FakePopen) + monkeypatch.setattr(svc, "_pid_alive", lambda pid: pid == 7777) + + st = win_backend.install_and_start() + assert len(spawned) == 1 + assert "--supervise" in spawned[0] + assert st["method"] == "schtasks" # 计划任务保留(下次登录自启) + assert st["launch"] == "direct-spawn" + assert st["running"] is True # watchdog 撑起当下常驻 + assert st["crash_recovery"] == "watchdog" + assert st["watchdog_pid"] == 7777 + + # ─────────────── 开机启动文件夹降级(Group Policy 拦截 schtasks)─────────────── class _AccessDeniedSchtasks(_FakeSchtasks): From 0d2d9fb1f4830c6f69c6e665e3d74d547c9501ae Mon Sep 17 00:00:00 2001 From: 370025263 <370025263@qq.com> Date: Mon, 13 Jul 2026 16:49:20 +0800 Subject: [PATCH 9/9] =?UTF-8?q?docs(skill):=20using-xskill=20=E5=85=88?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E5=B9=B3=E5=8F=B0=E5=86=8D=E6=8C=89=E9=9C=80?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=20=E2=80=94=20=E5=B8=B8=E9=A9=BB=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E6=8C=89=E5=B9=B3=E5=8F=B0=E6=8B=86=E5=88=86=20refere?= =?UTF-8?q?nces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SKILL.md 增加「Step 1 检测平台」:WSL/鸿蒙/systemd 探测方法 + 平台→reference 路由表 - 新增 platform-{windows,wsl,linux-systemd,linux-nosystemd}.md,按代码真实降级链写常驻机制与实修坑 - installation.md 手工常驻段替换为能力探测 start/stop/status 流程 + status 降级字段 Co-Authored-By: Claude Fable 5 --- skills/using-xskill/SKILL.md | 38 +++++++++--- .../using-xskill/references/installation.md | 27 +++++--- .../references/platform-linux-nosystemd.md | 61 +++++++++++++++++++ .../references/platform-linux-systemd.md | 33 ++++++++++ .../references/platform-windows.md | 51 ++++++++++++++++ .../using-xskill/references/platform-wsl.md | 49 +++++++++++++++ 6 files changed, 240 insertions(+), 19 deletions(-) create mode 100644 skills/using-xskill/references/platform-linux-nosystemd.md create mode 100644 skills/using-xskill/references/platform-linux-systemd.md create mode 100644 skills/using-xskill/references/platform-windows.md create mode 100644 skills/using-xskill/references/platform-wsl.md diff --git a/skills/using-xskill/SKILL.md b/skills/using-xskill/SKILL.md index 9d395630..034101b2 100644 --- a/skills/using-xskill/SKILL.md +++ b/skills/using-xskill/SKILL.md @@ -1,6 +1,6 @@ --- name: using-xskill -description: Use when installing, configuring, or operating xskill (the `xskill` CLI / `pip install xskill`) — starting the daemon, registering trajectory dirs, joining a team server, understanding how trajectories become Skills, or rebuilding/re-distilling the skill library after a model change. +description: Use when installing, configuring, or operating xskill (the `xskill` CLI / `pip install xskill`) — starting the daemon, keeping `connect` resident on Windows/WSL/Linux/HarmonyOS, registering trajectory dirs, joining a team server, understanding how trajectories become Skills, or rebuilding/re-distilling the skill library after a model change. --- # Using xskill @@ -18,13 +18,27 @@ skill versions only replace old ones when real traffic shows they serve users be A/B → installed into every agent's skill dir. You operate the daemon; the daemon does the distilling. -## When to Use +## Step 1 — Detect the platform -- Installing xskill or filling in `~/.xskill/config.yaml` (LLM + embedding endpoints) -- Starting/keeping the daemon running (`xskill serve`), or backfilling old trajectories -- Joining or hosting a team server (`xskill serve --server` / `xskill connect`) -- Understanding the agent pipeline, atoms, canary/UX scoring, or deployment modes -- **Re-distilling the whole skill library** (e.g. after switching to a stronger model) +Anything involving the resident `connect` client (`xskill start/stop/status`, boot +autostart, crash recovery) is platform-dependent. Detect first, then read **only** the +matching reference: + +1. **Windows** — you are in PowerShell/cmd (`sys.platform == "win32"`). +2. **WSL** — on Linux: `$WSL_DISTRO_NAME`/`$WSL_INTEROP` set, or + `grep -qi microsoft /proc/sys/kernel/osrelease`. (Checked before HarmonyOS.) +3. **HarmonyOS/OpenHarmony** — `ID`/`ID_LIKE` in `/etc/os-release` contains + `harmonyos`/`openharmony`/`ohos`, or `uname -r` contains `ohos`. +4. **Linux + systemd** — `systemctl --user show-environment` exits 0. +5. **Linux without systemd** — the probe above fails (containers, minimal distros). + +| Detected | Read | +|----------|------| +| Windows | `references/platform-windows.md` | +| WSL (with or without systemd) | `references/platform-wsl.md` | +| Linux with systemd --user | `references/platform-linux-systemd.md` | +| Linux without systemd / container / HarmonyOS | `references/platform-linux-nosystemd.md` | +| macOS | no native backend yet — `xskill start` errors; host `xskill connect --foreground` under a launchd LaunchAgent (KeepAlive=true) yourself | ## Quick Reference @@ -33,10 +47,10 @@ the distilling. | `pip install xskill` | Install (Python 3.9+) | | `xskill serve` | Standalone daemon: FastAPI + watcher; first run writes `~/.xskill/config.yaml` then exits | | `xskill serve --server` | Team server: owns all LLM calls + git; prints a join token | -| `xskill connect --token ` | Join a team server as a thin client | +| `xskill connect --token ` | Join a team server; then hands the daemon to the OS persistence backend | +| `xskill start` / `stop` / `status` | Install / remove / inspect the resident `connect` task (`--quiet` on start = idempotent, for boot triggers) | | `xskill registry add ` | Backfill / watch an extra trajectory directory | | `xskill search traj\|skill ` | Search trajectories or skills | -| `xskill read --eco ` | Batch-ingest db trajectories (ngagent/opencode) | | `xskill rebuild [--force]` | Re-distill from existing raw trajectories (see reference) | | `xskill stats` | Token usage & estimated cost | @@ -46,7 +60,9 @@ distilled unless `xskill serve` (or the team server) is running. ## Progressive Disclosure — read on demand - **Install & configure** (config.yaml fields, per-agent collect/install paths, team - client setup): `references/installation.md` + client setup, auto-update/rollback): `references/installation.md` +- **Platform persistence** (per the routing table in Step 1): + `references/platform-{windows,wsl,linux-systemd,linux-nosystemd}.md` - **How it works** (TaskAgent → TaskClusterAgent → SkillEditAgent, atoms, canary/UX scoring, standalone vs team mode): `references/mechanisms.md` - **Rebuild the skill library** (a ready-to-run prompt that walks a model through @@ -54,6 +70,8 @@ distilled unless `xskill serve` (or the team server) is running. ## Common Mistakes +- **Hand-rolling systemd units / Task Scheduler entries.** `xskill start` probes + capabilities and installs the right persistence itself; manual setup is only for macOS. - **Running `rebuild` with no daemon up.** `rebuild` only resets DB state; the watcher in `serve` does the actual re-split/re-cluster every 30s. No daemon = nothing happens. - **Deleting raw `~/.xskill/*_sessions/*.md`.** Those are the *input* to distillation — diff --git a/skills/using-xskill/references/installation.md b/skills/using-xskill/references/installation.md index fe07aabd..abaac7e4 100644 --- a/skills/using-xskill/references/installation.md +++ b/skills/using-xskill/references/installation.md @@ -48,15 +48,24 @@ never write `main`, and their edits land only on `user-staging/` bran ### Keep `connect` resident -The token is needed **once** (it writes `~/.xskill/team_client.json`); the resident -process runs the token-less `xskill connect`, which reuses the stored connection and -auto-reconnects if the server restarts. Configure auto-start + auto-restart: - -- **Windows** — Task Scheduler, AtLogOn trigger, `ExecutionTimeLimit 0`, restart on failure. -- **macOS** — launchd LaunchAgent with `KeepAlive=true`, `RunAtLoad=true`. -- **Linux** — `systemd --user` service with `Restart=always`, `WantedBy=default.target`. - -Validate: the resident task is Running; after ~10 min, `~/.xskill/clients//` +The token is needed **once** (it writes `~/.xskill/team_client.json`). After the +handshake, `xskill connect` (without `--foreground`) hands the daemon to the OS +persistence backend automatically; `xskill start` / `stop` / `status` manage it from +then on. Backends are chosen by **capability probing** (systemd --user? crontab? WSL +interop?), not by platform name — see the platform reference selected in SKILL.md +Step 1 for mechanics and pitfalls. macOS has no native backend yet: host +`xskill connect --foreground` under a launchd LaunchAgent (`KeepAlive=true`) yourself. + +`xskill status` fields that matter: `method` (schtasks / startup_folder / systemd-user +/ supervised / detached), `crash_recovery`, `boot_autostart`, and `degraded` — every +capability the environment lacks is reported there instead of failing the install. +`XSKILL_CONNECT_BACKEND=systemd|supervised|detached` overrides the probe. + +The resident client self-updates hourly; each install is health-checked +(`python -m xskill --version`), rolled back via pip on failure, and bad versions are +blacklisted in `~/.xskill/update_journal.json`. + +Validate: `xskill status` shows running; after ~10 min, `~/.xskill/clients//` appears and its `*.json` updates (there is a ~10-min debounce window before first upload). > Never put a real token in a public repo or chat log. diff --git a/skills/using-xskill/references/platform-linux-nosystemd.md b/skills/using-xskill/references/platform-linux-nosystemd.md new file mode 100644 index 00000000..aa8b4b8b --- /dev/null +++ b/skills/using-xskill/references/platform-linux-nosystemd.md @@ -0,0 +1,61 @@ +# xskill on Linux without systemd — containers, minimal distros, HarmonyOS + +Used whenever the `systemctl --user show-environment` probe fails. Backend: +`method: supervised` — a watchdog process provides the crash recovery the OS doesn't. + +## How the supervised backend works + +- `xskill start` detach-spawns a watchdog: ` -m xskill connect --supervise` + (new session, log `~/.xskill/logs/connect-supervisor.log`). +- The watchdog spawns and respawns the real client `connect --foreground` (child log + `~/.xskill/logs/connect-daemon.log`) with `XSKILL_SUPERVISED=1` in its env. +- Restart backoff: 1 s, ×2 per crash, capped at 300 s; reset to 1 s once the child has + survived ≥ 600 s. Persistent crash loops back off instead of burning CPU. +- `xskill status`: `running` means the **watchdog** is alive; the child being briefly + absent during a backoff window is normal operation — inspect `watchdog_alive` / + `child_alive` separately before declaring anything broken. +- `xskill stop`: SIGTERM to the watchdog (it SIGTERMs the child with a 5 s grace, then + SIGKILL), kills an orphaned child as fallback, removes boot autostart, clears state. +- Double-start is safe: a second watchdog sees the live one in + `~/.xskill/connect_daemon.json` and exits 0. + +## Boot autostart + +Crontab line, idempotent by marker: + +``` +@reboot -m xskill start --quiet # xskill-connect-boot +``` + +`xskill start --quiet` exits 0 silently when already running. If `crontab` is missing +or unreadable (most containers), status reports `boot_autostart: none` plus a +`degraded` warning — rerun `xskill start` after a restart, or bake it into the +container entrypoint. + +## HarmonyOS / OpenHarmony + +Detected via `/etc/os-release` `ID`/`ID_LIKE` ∈ {harmonyos, openharmony, ohos} or +`uname -r` containing `ohos`; `xskill status` shows `flavor: harmony`. Detection only +affects messages and the autostart mount — the persistence chain is exactly this +no-systemd chain (supervised watchdog + crontab @reboot when available). + +## Container gotchas + +- The systemd probe failing inside a container is expected; you land here by design. +- PID 1 in containers often does not reap orphans, so a killed watchdog lingers as a + **zombie**. xskill's own liveness checks read `/proc//stat` and treat `Z` as + dead, but external `kill -0`-style checks will misreport zombies as alive. + +## Auto-update under supervision + +With `XSKILL_SUPERVISED=1`, the hourly updater restarts by exiting non-zero and letting +the watchdog relaunch the new version (no orphan spawning). After any install it health +checks ` -m xskill --version`; on failure it pip-rolls-back to the previous +version and blacklists the bad one in `~/.xskill/update_journal.json` so it is never +retried. + +## Explicit bare mode + +`XSKILL_CONNECT_BACKEND=detached` gives the legacy single detached process — no crash +recovery (`restart_policy: none`), no boot autostart. It is never chosen automatically; +use only for debugging. diff --git a/skills/using-xskill/references/platform-linux-systemd.md b/skills/using-xskill/references/platform-linux-systemd.md new file mode 100644 index 00000000..d26743dc --- /dev/null +++ b/skills/using-xskill/references/platform-linux-systemd.md @@ -0,0 +1,33 @@ +# xskill on Linux with systemd --user — resident `connect` + +Capability probe: `systemctl --user show-environment` exits 0. This fails in many SSH +sessions without a user D-Bus and in most containers — then xskill silently uses the +supervised chain instead (`platform-linux-nosystemd.md`); that is expected, not an error. + +## What `xskill start` installs + +- Unit `~/.config/systemd/user/xskill-connect.service`: + `ExecStart= -m xskill connect --foreground`, `Restart=always`, + `RestartSec=10`, `WantedBy=default.target`; enabled via + `systemctl --user enable --now`. +- `loginctl enable-linger ` is attempted so the user manager (and the unit) + starts at boot without a login. **Linger failure is a warning, not fatal**: crash + recovery still comes from the unit; boot autostart then falls back to a crontab line + `@reboot -m xskill start --quiet # xskill-connect-boot` (idempotent by + marker), or to `boot_autostart: none` + a `degraded` warning if crontab is also + unavailable. +- If the probe passed but unit installation itself fails (unit rejected to load etc.), + xskill auto-degrades to the supervised watchdog instead of erroring out. + +## Verify & troubleshoot + +``` +xskill status # method: systemd-user, crash_recovery: systemd +systemctl --user status xskill-connect.service +journalctl --user -u xskill-connect.service -n 50 --no-pager +loginctl show-user "$USER" -p Linger # Linger=yes → starts at boot +``` + +`xskill stop` disables the unit, deletes the unit file, and removes any crontab boot +line. `XSKILL_CONNECT_BACKEND=supervised|detached` skips systemd explicitly (mostly +for debugging). diff --git a/skills/using-xskill/references/platform-windows.md b/skills/using-xskill/references/platform-windows.md new file mode 100644 index 00000000..8e663085 --- /dev/null +++ b/skills/using-xskill/references/platform-windows.md @@ -0,0 +1,51 @@ +# xskill on Windows — resident `connect` + +Agents should invoke xskill as `python -m xskill …` — the `xskill.exe` console script +lives in the Python Scripts dir and is often not on PATH; "not recognized" does **not** +mean the install failed. + +## Persistence chain (what `xskill start` / background `connect` does) + +1. **Primary — Task Scheduler.** Creates task `Xskill_Connect` from XML via `schtasks`: + - LogonTrigger (AtLogOn — no admin rights needed, no stored credentials) + - `ExecutionTimeLimit PT0S` (default would kill the task after 3 days) + - `RestartOnFailure` every 1 min, up to 999 times (crash recovery) + - `MultipleInstancesPolicy IgnoreNew` (no double daemon) + - Action: ` -m xskill connect --foreground` (`pythonw` preferred over + `python` so no console window pops up) +2. **Group Policy denies `schtasks /Create`** ("Access is denied" / 拒绝访问) → + **Startup-folder fallback**: writes + `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\xskill_connect.vbs` + (hidden-window launch) and immediately detach-spawns + ` -m xskill connect --supervise` — a watchdog that respawns the real client + on crash (user-space equivalent of RestartOnFailure). Status shows + `method: startup_folder`, `crash_recovery: watchdog`. +3. **`schtasks /Run` succeeds but the task process never appears** (within 10 s) — + happens without an interactive logon session: service context, CI, disconnected RDP. + A LogonTrigger task without stored credentials can only start in an interactive + session, yet `/Run` still exits 0. Fallback: direct-spawn the supervisor for the + current session; the scheduled task stays installed for the next real logon. Status + shows `launch: direct-spawn`. + +## Verify & operate + +``` +python -m xskill status # running, method, pid, crash_recovery +python -m xskill stop # deletes the task AND taskkill /T /F any watchdog tree +python -m xskill start # reinstall/start (needs a prior connect with --token) +``` + +Runtime state lives in `~/.xskill/connect_daemon.json`; stale PIDs are liveness-checked +(via `tasklist`), so a leftover file does not fake `running`. + +## Windows-specific pitfalls (fixed in current code; symptoms of older versions) + +- **`pythonw` has `sys.stdout/stderr = None`** — any `print` raised AttributeError, so + the resident process died on its first output line. Entry point now substitutes + devnull streams. +- **Console code page (cp1252/GBK)** — Chinese output raised `UnicodeEncodeError`, + often *after* the task was already installed: the user saw a traceback + exit 1 for a + successful install. Entry point now reconfigures both streams to UTF-8 + (`errors=replace`). +- **Never trust `schtasks /Run` exit code** as proof the daemon is up — verify by + observation: `python -m xskill status` checks the actual process. diff --git a/skills/using-xskill/references/platform-wsl.md b/skills/using-xskill/references/platform-wsl.md new file mode 100644 index 00000000..03f5424b --- /dev/null +++ b/skills/using-xskill/references/platform-wsl.md @@ -0,0 +1,49 @@ +# xskill on WSL — resident `connect` + +Detection: `$WSL_DISTRO_NAME` / `$WSL_INTEROP` set, or "microsoft" in +`/proc/sys/kernel/osrelease`. WSL is checked before HarmonyOS/plain Linux. + +WSL persistence is a **two-layer** problem; solving only the inner layer is a classic trap. + +## Layer 1 — daemon inside the VM + +- **systemd enabled** (`/etc/wsl.conf` → `[boot] systemd=true`, then `wsl --shutdown` + from Windows): `xskill start` installs the `xskill-connect.service` user unit — + see `platform-linux-systemd.md` for unit details and troubleshooting. +- **No systemd**: no longer a hard failure (older versions refused to install). Falls + back to the supervised watchdog — see `platform-linux-nosystemd.md` for watchdog + semantics, logs, and backoff. + +Override with `XSKILL_CONNECT_BACKEND=systemd|supervised|detached` if the probe picks +the wrong backend. + +## Layer 2 — starting the VM itself + +A Windows reboot does **not** start the WSL VM, so systemd + linger alone cannot give +boot autostart. `xskill start` therefore also registers a Windows-side scheduled task +via WSL interop: + +``` +schtasks.exe /Create /TN Xskill_WSL_Boot /SC ONLOGON \ + /TR "wsl.exe -d -u -- -m xskill start --quiet" +``` + +`xskill start --quiet` is idempotent (already running → silent exit 0), so the trigger +can fire on every logon. This task is installed even when systemd is in use, and +`xskill stop` removes it again. + +Requirements: `WSL_DISTRO_NAME` set and `wsl.exe` + `schtasks.exe` reachable from the +WSL PATH (interop on — it is by default; `/etc/wsl.conf` can disable it). If interop is +unavailable or Group Policy rejects the task, boot autostart degrades to +`systemd-linger` (VM-internal only) or `none`, recorded in the `degraded` list — after +a Windows reboot, enter WSL once or run `xskill start` manually. + +## Verify + +`xskill status` fields to check: + +- `flavor: wsl` +- `method: systemd-user` or `supervised` +- `boot_autostart: windows-task | systemd-linger | cron | none` +- `degraded: [...]` — every downgrade is reported explicitly; empty means full + persistence (crash recovery + boot autostart) is in place.