modify: rust 插件

This commit is contained in:
2026-08-31 17:18:03 +08:00
commit e59ecfc0e3
22 changed files with 3689 additions and 0 deletions
+294
View File
@@ -0,0 +1,294 @@
"""Rust 进程插件端到端协议验证(多语言演进规划阶段 1 验收)
前置条件:
1. 本地 Zenoh Router 已运行(scripts/zenoh_router.py)
2. Rust 参考插件已编译(cargo build --examples 或 workers/rsdemo)
验证内容(docs/plugin-wire-protocol.md 全协议面):
1. 路由描述符发布(keyexpr 编码、字段、schema_version/type)
2. liveliness 服务发现(worker/rsdemo/*)
3. 普通 RPC(GET 信封 / POST user_info 透传)
4. 非信封载荷兼容(method 默认 GET)
5. 流式多回复(逐块)+ 流中异常 {"error": ...} 收尾块
6. 非流式异常回复格式(result/operation/error/worker)
7. SIGTERM 优雅退出 → 描述符 DELETE 摘除
8. 重启 → 10s 内重发布自愈
用法(项目根目录):
.venv/bin/python utils/worker-rs/scripts/verify_e2e.py [--type rsdemo] [--count 1] [--bin <路径>]
"""
import argparse
import orjson
import os
import signal
import subprocess
import sys
import time
import zenoh
PASS = 0
FAIL = 0
DEFAULT_BIN = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "target", "debug", "examples", "demo")
)
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
ROUTES_PREFIX = "chongming/routes"
def report(name, cond, detail=""):
global PASS, FAIL
if cond:
PASS += 1
print(f"✅ {name}" + (f" — {detail}" if detail else ""))
else:
FAIL += 1
print(f"❌ {name}" + (f" — {detail}" if detail else ""))
def start_worker(worker_type, count=1, bin_path=None):
"""启动 Rust 插件进程(CWD=项目根,读取全局 config.toml)"""
binary = bin_path or DEFAULT_BIN
proc = subprocess.Popen(
[binary, "--type", worker_type, "--count", str(count)],
cwd=ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
# 等待 Queryable 就绪(日志出现"进程就绪")
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
if proc.poll() is not None:
out = proc.stdout.read().decode() if proc.stdout else ""
raise RuntimeError(f"Worker 启动失败,退出码 {proc.returncode}:\n{out}")
time.sleep(0.2)
line = proc.stdout.readline() if proc.stdout else b""
if "等待退出信号" in line.decode("utf-8", "ignore"):
return proc
raise RuntimeError("Worker 20s 内未就绪")
def wait_worker_exit(proc, timeout=10):
try:
proc.wait(timeout=timeout)
return True
except subprocess.TimeoutExpired:
return False
def make_subscriber(session, worker_type):
"""建立常驻订阅,持续记录 PUT 描述符与 DELETE 事件(不重放历史值,
调用方须在事件发生前建立订阅)"""
samples = {}
def cb(sample):
key = str(sample.key_expr)
if sample.kind == zenoh.SampleKind.DELETE:
samples[key] = None # 摘除标记
else:
samples[key] = orjson.loads(bytes(sample.payload))
sub = session.declare_subscriber(f"{ROUTES_PREFIX}/{worker_type}/**", cb)
return sub, samples
def rpc(session, worker_expr, subject, payload, timeout=10.0):
"""按信封协议发起 Zenoh Query,返回所有回复块 JSON 列表"""
full_key = f"{worker_expr}/{subject}"
replies = session.get(
full_key,
payload=orjson.dumps(payload),
consolidation=zenoh.ConsolidationMode.NONE,
timeout=timeout,
)
result = []
for reply in replies:
if reply.ok:
result.append(orjson.loads(bytes(reply.ok.payload)))
else:
result.append({"__zenoh_err__": str(reply.err.payload)})
return result
def get_worker_exprs(session, worker_type):
"""liveliness 发现 worker/worker_type/* 副本列表"""
exprs = []
for reply in session.liveliness().get(f"worker/{worker_type}/*"):
if reply.ok:
exprs.append(str(reply.ok.key_expr))
return exprs
def main():
global PASS, FAIL
parser = argparse.ArgumentParser()
parser.add_argument("--type", default="rsdemo")
parser.add_argument("--count", type=int, default=1)
parser.add_argument("--bin", default=None, help="插件二进制路径(默认 SDK 示例 demo)")
args = parser.parse_args()
wt = args.type
session = zenoh.open(zenoh.Config.from_file("config/zenoh_config.json5"))
print(f"=== Rust 进程插件协议验证 (type={wt}, bin={args.bin or DEFAULT_BIN}) ===")
# ========== 1. 路由描述符发布(先订阅再启动,zenoh 不重放历史值) ==========
sub1, samples1 = make_subscriber(session, wt)
proc = start_worker(wt, args.count, args.bin)
time.sleep(1.5)
expect_keys = {
"rsdemo": {
"chongming/routes/rsdemo/rsdemo_hello__GET",
"chongming/routes/rsdemo/rsdemo_echo__POST",
"chongming/routes/rsdemo/rsdemo_stream__POST",
"chongming/routes/rsdemo/rsdemo_error__GET",
},
"demo": {
"chongming/routes/demo/demo_hello__GET",
"chongming/routes/demo/demo_storage_upload_bytes__POST",
"chongming/routes/demo/demo_storage_download_bytes__GET",
"chongming/routes/demo/demo_storage_presign_upload__POST",
"chongming/routes/demo/demo_storage_presign_download__GET",
},
}[wt]
missing = expect_keys - set(samples1.keys())
report(
"路由描述符 keyexpr 编码正确",
not missing,
f"缺失: {missing or '无'}",
)
if "chongming/routes/rsdemo/rsdemo_hello__GET" in samples1:
d = samples1["chongming/routes/rsdemo/rsdemo_hello__GET"]
report("描述符 schema_version=1", d.get("schema_version") == 1, f"实际: {d.get('schema_version')}")
report("描述符 type=插件名", d.get("type") == wt, f"实际: {d.get('type')}")
report(
"描述符字段完整(subject/method/path/params/response_model)",
d.get("subject") == "rsdemo.hello"
and d.get("method") == "GET"
and d.get("path") == "/rsdemo/hello"
and d.get("params") == ["name: str"]
and isinstance(d.get("response_model"), dict),
)
report(
"defaults 合并(timeout=30.0/auth_required=false)",
d.get("timeout") == 30.0 and d.get("auth_required") is False,
)
sub1.undeclare()
# ========== 2. liveliness 服务发现 ==========
exprs = get_worker_exprs(session, wt)
report(
"liveliness 服务发现副本",
len(exprs) == args.count,
f"实际: {exprs}",
)
worker_expr = exprs[0] if exprs else f"worker/{wt}/missing"
# ========== 3. 普通 RPC:GET 信封 ==========
replies = rpc(session, worker_expr, "rsdemo.hello", {"meta": {"method": "GET"}, "data": {"name": "rust"}})
ok = (
len(replies) == 1
and replies[0].get("greeting") == "Hello, rust!"
and replies[0].get("worker_id") == worker_expr.split("/")[-1]
and isinstance(replies[0].get("timestamp"), (int, float))
)
report("RPC GET(信封分派 + worker_id 注入)", ok, f"实际: {replies}")
# ========== 4. 非信封载荷兼容(method 默认 GET) ==========
replies = rpc(session, worker_expr, "rsdemo.hello", {"name": "legacy"})
report(
"非信封载荷兼容(按 GET 处理)",
len(replies) == 1 and replies[0].get("greeting") == "Hello, legacy!",
f"实际: {replies}",
)
# ========== 5. POST + user_info 透传 ==========
replies = rpc(
session, worker_expr, "rsdemo.echo",
{"meta": {"method": "POST", "user_info": {"username": "alice"}}, "data": {"msg": "hi"}},
)
ok = (
len(replies) == 1
and replies[0].get("echo") == "hi"
and replies[0].get("user_info") == {"username": "alice"}
)
report("RPC POST(user_info 信封透传)", ok, f"实际: {replies}")
# ========== 6. 流式多回复 ==========
replies = rpc(
session, worker_expr, "rsdemo.stream",
{"meta": {"method": "POST"}, "data": {"prompt": "x", "tokens": 5}},
timeout=15.0,
)
report(
"流式多回复(5 块逐块推送)",
len(replies) == 5
and all(r.get("index") == i for i, r in enumerate(replies))
and all("error" not in r for r in replies),
f"块数: {len(replies)}",
)
# 流中异常 → {"error": ...} 收尾块(第 26 块抛错,共 26 块)
replies = rpc(
session, worker_expr, "rsdemo.stream",
{"meta": {"method": "POST"}, "data": {"prompt": "x", "tokens": 60}},
timeout=20.0,
)
ok = (
len(replies) == 26
and all("error" not in r for r in replies[:25])
and isinstance(replies[25].get("error"), str)
and "模拟流中异常" in replies[25]["error"]
)
report("流中异常 → error 收尾块终止", ok, f"块数: {len(replies)}, 末块: {replies[-1] if replies else None}")
# ========== 7. 非流式异常回复格式 ==========
replies = rpc(session, worker_expr, "rsdemo.error", {"meta": {"method": "GET"}, "data": {}})
ok = (
len(replies) == 1
and replies[0].get("result") == 0.0
and replies[0].get("operation") == "error"
and "模拟业务异常" in replies[0].get("error", "")
and replies[0].get("worker") == worker_expr.split("/")[-1]
)
report("非流式异常回复格式(result/operation/error/worker)", ok, f"实际: {replies}")
# ========== 8. SIGTERM 优雅退出 → 描述符摘除(先订阅再 kill) ==========
sub2, samples2 = make_subscriber(session, wt)
os.kill(proc.pid, signal.SIGTERM)
exited = wait_worker_exit(proc, timeout=10)
report("SIGTERM 优雅退出", exited, f"退出码: {proc.returncode if exited else '超时'}")
time.sleep(1.5)
removed = [k for k, v in samples2.items() if v is None]
report(
"退出时 DELETE 摘除全部描述符",
len(removed) == len(expect_keys),
f"摘除: {sorted(removed)}",
)
sub2.undeclare()
# ========== 9. 重启 → 10s 内重发布自愈 ==========
sub3, samples3 = make_subscriber(session, wt)
proc = start_worker(wt, args.count, args.bin)
time.sleep(11.5) # 覆盖一个完整重发布周期
ok = all(k in samples3 and samples3[k] is not None for k in expect_keys)
report(
"重启后周期重发布自愈(≤10s)",
ok,
f"恢复: {sorted(k for k in samples3 if samples3[k] is not None)}",
)
sub3.undeclare()
# 清理
os.kill(proc.pid, signal.SIGTERM)
wait_worker_exit(proc, timeout=10)
session.close()
print(f"\n=== 结果: {PASS} 通过, {FAIL} 失败 ===")
sys.exit(1 if FAIL else 0)
if __name__ == "__main__":
main()