feat(chv-muse-p1): 常驻 VM + WarmPool 扶正 + 气球 + vsock,对齐 Muse

每用户一台常驻 VM(暂停态,resume 到 SSH 5 秒内为待验证目标),
池空/失败回落冷启动;CHV 配置加 virtio-balloon + free-page-reporting
与 vsock notify 最小实现(SSH 仍是命令通道与就绪判定)。
新模块 resident_pool.py / vsock.py + 客内 vsock_notify.py;
_config 增加 CHV_RESIDENT_IDLE_MINUTES / CHV_BALLOON / CHV_VSOCK;
worker_adapter 接常驻孤儿清理 facade;ADR-17 差距表见
docs/chv-muse-p1-gap.md。

恢复收尾(分离后回归,见 gap.md §4):VmDeathTest 死亡模拟改杀
实际跑命令的 VM;两处冷路径旧测试显式关常驻;_balloon_vsock_payload
与 listen_notify 拆到 50 行内;resident_pool.py:208 mypy 修掉。
executor.py out 未注解与 marker_tools.py:142 是基线旧错,未动。

测试:全量 cloud 套件 1934 OK(skipped=8);mypy 在改动文件上
干净(仅基线旧错 4 处)。真机验证待第三棒让出 WSL 测试机。
This commit is contained in:
host committed 2026-11-06 21:31:48 +00:00
1 parent 06c3e172fe
commit 78098c5594
13 files changed
+2527 -36

No files matched your search

+223
View File
@@ -0,0 +1,223 @@
"""宿主侧 vsock notify 最小实现(P1,feat-chv-muse-p1)。
方向只一个:客机 → 宿主。客机连宿主 CID 2 的
VSOCK_NOTIFY_PORT 端口发 ready/心跳(客内脚本
cloud/chv/guest/vsock_notify.py);宿主在
``<vsock socket>_<port>`` 这个 Unix socket 上监听接收。
形态依据(CHV 官方 docs/vsock.md,upstream 原文):
- ``--vsock cid=3,socket=/tmp/ch.vsock``:cid 是客机 CID,
socket 是宿主侧 Unix socket 路径;
- 客 → 宿主:宿主须先在 ``<socket>_<port>`` 上监听
(如 ``/tmp/ch.vsock_1234``),客机连众所周知的 CID 2
(Host);
- 只支持 stream socket。
只做就绪辅助:wait_ready() 收到 ready 即返回,供
_run_with_vm/常驻路径在 SSH 轮询之外多一个早期信号;
SSH 仍是唯一命令通道与就绪判定——vsock 超时、无消息、
监听失败一律返回 None,绝不因此判任务失败、绝不抛到
调用方。消息是 JSON 行:
{"type": "ready"|"heartbeat", "vm": "<vm 名>", "ts": <unix 秒>}
不做宿主 → 客机方向(客机不监听,host.py 协议仍走 SSH)。
"""
import json
import logging
import os
import socket
import time
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
# 宿主 CID(vsock 众所周知值,CHV 官方 docs/vsock.md)。
HOST_CID = 2
# 客机 CID 默认值(与 CHV_VSOCK_CID 默认值一致;0/1/2 保留)。
DEFAULT_GUEST_CID = 3
# notify 端口:客机连 (CID 2, 本端口) 发 ready/心跳;
# 宿主监听 <socket>_<本端口>。与客内脚本的端口须一致。
VSOCK_NOTIFY_PORT = 1024
# 单行上限:客机发来的行超长即丢弃该连接(防脏数据撑内存)。
MAX_LINE_BYTES = 65536
# 合法消息类型。
VALID_TYPES = ("ready", "heartbeat")
def vsock_socket_path(state_dir: str, vm_name: str) -> str:
"""该 VM 的 vsock socket 路径(放进 VmConfig 的值)。
与 build_vm_config 的派生一致:任务盘同目录的
``state_dir/<vm>/vsock.sock``。vm_name 须与
api_socket/任务盘用的名字相同(executor.vm_name(task)
的返回值,含 chv- 前缀),这里不二次加前缀。"""
return os.path.join(state_dir, vm_name, "vsock.sock")
def notify_socket_path(socket_path: str,
port: int = VSOCK_NOTIFY_PORT) -> str:
"""宿主侧监听路径:``<socket>_<port>``(CHV 官方约定)。"""
return "%s_%d" % (socket_path, port)
def encode_notify(message_type: str, vm: str,
ts: float | None = None) -> bytes:
"""编码一条 notify 消息为 JSON 行(含换行)。
type 非法或 vm 为空即 ValueError(调用方是可信的客内
脚本/宿主代码,编码错误是编程错误,早炸)。"""
if message_type not in VALID_TYPES:
raise ValueError("vsock notify 类型非法:%r" % (message_type,))
if not vm:
raise ValueError("vsock notify vm 不能为空")
if ts is None:
ts = time.time()
line = json.dumps({"type": message_type, "vm": vm, "ts": ts},
separators=(",", ":"))
return (line + "\n").encode("utf-8")
def decode_notify(line: bytes | str) -> dict | None:
"""解码一行 notify 消息:合法返回 dict,否则返回 None。
客机字节不可信,任何畸形(非 JSON、非对象、类型不在
ready/heartbeat 内、vm 空/非字符串、ts 非数字)一律
None,绝不抛异常。"""
try:
if isinstance(line, bytes):
line = line.decode("utf-8")
obj = json.loads(line)
except (UnicodeDecodeError, ValueError):
return None
if not isinstance(obj, dict):
return None
if obj.get("type") not in VALID_TYPES:
return None
if not isinstance(obj.get("vm"), str) or not obj.get("vm"):
return None
if not isinstance(obj.get("ts"), (int, float)) \
or isinstance(obj.get("ts"), bool):
return None
return obj
def _bind_notify_listener(listener, notify_path: str) -> bool:
"""bind notify socket:残留文件先删一次再重试一次;
两次都失败即 False(调用方记 warning 后返回 None,
与原来直抛到外层 except 的行为一致)。"""
try:
listener.bind(notify_path)
return True
except OSError:
pass
try:
os.unlink(notify_path)
except OSError:
pass
try:
listener.bind(notify_path)
return True
except OSError:
return False
def listen_notify(notify_path: str, timeout: float,
want_type: str | None = None) -> dict | None:
"""在 notify_path(Unix socket)上监听,等一条 notify。
收到首条合法消息即返回(want_type 给定时跳过其它类型
继续等,直到超时);超时、客机断开未发合法消息、监听
失败一律返回 None。监听用的 socket 文件用完即删(尽力
而为);bind 前已存在的同名残留文件先删一次再 bind。
timeout <= 0 即不等,直接返回 None。"""
if timeout is None or timeout <= 0:
return None
deadline = time.monotonic() + timeout
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
if not _bind_notify_listener(listener, notify_path):
_LOGGER.warning("vsock notify 监听失败:%s", notify_path)
return None
listener.listen(1)
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
return None
listener.settimeout(remaining)
try:
conn, _ = listener.accept()
except socket.timeout:
return None
except OSError:
_LOGGER.warning("vsock notify accept 失败:%s",
notify_path)
return None
try:
return _read_notify(conn, deadline, want_type)
finally:
try:
conn.close()
except OSError:
pass
except OSError:
_LOGGER.warning("vsock notify 监听失败:%s", notify_path)
return None
finally:
try:
listener.close()
except OSError:
pass
try:
os.unlink(notify_path)
except OSError:
pass
def _read_notify(conn, deadline: float,
want_type: str | None) -> dict | None:
"""从一条已接受的连接上读 JSON 行,直到合法消息或超时/
断开。want_type 给定时跳过其它合法类型继续读。"""
try:
stream = conn.makefile("rb")
except OSError:
return None
try:
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
return None
try:
conn.settimeout(remaining)
line = stream.readline(MAX_LINE_BYTES + 1)
except (OSError, ValueError):
return None
if not line:
return None
if len(line) > MAX_LINE_BYTES:
return None
msg = decode_notify(line)
if msg is None:
continue
if want_type is not None and msg["type"] != want_type:
continue
return msg
finally:
try:
stream.close()
except OSError:
pass
def wait_ready(state_dir: str, vm_name: str,
timeout: float = 30,
port: int = VSOCK_NOTIFY_PORT) -> dict | None:
"""等一台 VM 的 vsock ready(就绪辅助,非 SSH 判定)。
在 ``state_dir/<vm>/vsock.sock_<port>`` 上监听,直到
收到 {"type": "ready"} 或超时。返回 ready 消息 dict,
超时/失败返回 None。调用方(_run_with_vm/常驻路径)
拿到 None 时继续走 SSH 就绪轮询,绝不因此失败。"""
path = notify_socket_path(
vsock_socket_path(state_dir, vm_name), port)
return listen_notify(path, timeout, want_type="ready")