diff --git a/docs/gpu-plugin-validation.md b/docs/gpu-plugin-validation.md index 9ab3c3d8..47ee326f 100644 --- a/docs/gpu-plugin-validation.md +++ b/docs/gpu-plugin-validation.md @@ -5,9 +5,9 @@ ## 交付 -- 源码:`plugins/examples/gpu-monitor/`,插件 ID `nyaterm.gpu`,版本 `1.0.0`。 -- 包:`temp/plugins/gpu-monitor.nyap`,Windows x86_64,484,490 字节,8 个 ZIP 条目。 -- SHA-256:`1ce31438e052427f926068837d1643ca465ceb02f30d100055dab38535548264`。 +- 源码:`plugins/examples/gpu-monitor/`,插件 ID `nyaterm.gpu`,版本 `1.0.1`。 +- 包:`temp/plugins/gpu-monitor.nyap`,Windows x86_64,484,743 字节,8 个 ZIP 条目。 +- SHA-256:`e92a8888b3658e5fb72a0a576b09657db37f5dfac66cd4b133f637ff7d522bd3`。 - 用法与构建:[GPU 插件 README](../plugins/examples/gpu-monitor/README.md)。 - API:[插件开发说明](plugin-development.md)。 @@ -22,7 +22,7 @@ node plugins/examples/gpu-monitor/verify-ui.mjs 当前包只声明 Windows x86_64;构建脚本支持在 Linux/macOS、x86_64/aarch64 宿主重建相应产物。 不能据此宣称跨平台桌面验证完成。 -## 自动验证 +## 首版自动验证 | 检查 | 结果与范围 | | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | @@ -51,6 +51,32 @@ GPU Monitor 冲突、共享需求、无重叠/补发、最后需求释放、取 `SessionCommand::Write` 不在 Probe 执行路径中。尚未实际验证 russh 网络取消或终端无输入干扰。 jsdom 只验证构建产物逻辑和 DOM,不等同于 WebView2 的样式、opaque-origin 自定义协议或原生桌面验收。 +## 1.0.1 换行修复验证 + +用户桌面运行 `1.0.0` 报告 `GPU probe did not exit successfully`。 +检查原始 `.nyap` 发现 `gpu.sh` 的 34 个换行全部为 CRLF。 +在 WSL Ubuntu 的真实 `/bin/sh -s` 中,原字节第二行报 `export: LC_ALL\r: bad variable name`,退出码 2; +同样脚本仅转换为 LF 后成功,缺少 NVIDIA 工具时输出 `GPU_AVAILABLE\t0` 并退出 0。 +首版 Sidecar 测试模拟远端输出,未执行脚本,因而没有发现这一打包缺陷。 + +`1.0.1` 将脚本源码和构建输出固定为 LF,构建脚本显式规范换行; +后台错误区分非零退出码与没有退出状态,不记录原始 stdout/stderr。 +提高版本号使已安装的不可变 `1.0.0` 能通过正常更新流程安装修复。 + +本次通过的检查: + +- `pnpm plugin:example:gpu`:独立 TypeScript、经典脚本 UI、release 后台构建。 +- `pnpm plugin:pack ...`:实际安装器的包校验;新版包所有 checksums 和 LF 字节另行核对。 +- `python plugins/examples/gpu-monitor/verify-probe.py --wsl Ubuntu`:执行最终包内的真实脚本, + 验证 CRLF 失败、LF 无工具分支和隔离 `nvidia-smi` 样本的设备/CUDA/进程输出。 +- GPU Sidecar 测试 3 项通过:成功采集复用、退出码/缺失状态诊断、源码换行回归。 +- 最终 `1.0.1` 安装包 Sidecar 测试 1 项通过:实际安装/授权并启动 release 程序,两次固定 Probe 往返。 +- Runtime `clippy --all-targets --features sdk-fixture -- -D warnings` 通过。 +- 构建产物 GPU UI 冒烟通过。 + +此修复只需更新插件包并重新授权,不需要修改或重新编译宿主。 +Linux Shell 验证使用本机 WSL 和固定 NVIDIA 样本,仍未连接真实 NVIDIA SSH 服务器。 + ## 用户 SSH 会话上的桌面验收(未执行) 使用包含本次接口实现的宿主,由用户自行建立 Linux/NVIDIA SSH 会话: diff --git a/plugins/examples/gpu-monitor/README.md b/plugins/examples/gpu-monitor/README.md index 6b52992b..2d40f5c0 100644 --- a/plugins/examples/gpu-monitor/README.md +++ b/plugins/examples/gpu-monitor/README.md @@ -28,6 +28,19 @@ node plugins/examples/gpu-monitor/verify-ui.mjs 更新或切换版本会停用插件并移除自动脚本授权,需重新审核。第二个有效的 `gpu.v1` Monitor 无法同时启用。 `native` 后台是以当前用户权限运行的可信程序,宿主 RPC 权限不构成操作系统沙箱。 +当前版本 `1.0.1` 修复了 Windows 构建的脚本 CRLF 换行问题。构建时规范为 LF,Git 属性也固定 LF; +已安装 `1.0.0` 时直接安装新版包并重新审核授权,无需为这次修复重新编译宿主。 +执行失败会记录退出码,缺少退出状态会单独提示,不把 stderr 或 GPU 进程列表写入日志。 + +验证最终包中的实际脚本(需要 Python;Windows 使用已安装的 WSL Ubuntu): + +```powershell +python plugins/examples/gpu-monitor/verify-probe.py --wsl Ubuntu +``` + +Linux 使用 `python3 plugins/examples/gpu-monitor/verify-probe.py`。 +测试使用隔离的 `nvidia-smi` 固定样本,并验证 CRLF 失败和无 NVIDIA 工具的分支,不连接真实服务器。 + ## 行为和恢复 - 立即采集一次,随后按既有 GPU 间隔采集,最短 3 秒;请求不重叠,完成后安排下一轮。 diff --git a/plugins/examples/gpu-monitor/backend/Cargo.lock b/plugins/examples/gpu-monitor/backend/Cargo.lock index 45a6377d..1eb3608d 100644 --- a/plugins/examples/gpu-monitor/backend/Cargo.lock +++ b/plugins/examples/gpu-monitor/backend/Cargo.lock @@ -82,7 +82,7 @@ dependencies = [ [[package]] name = "nyaterm-plugin-gpu" -version = "1.0.0" +version = "1.0.1" dependencies = [ "nyaterm-gpu", "nyaterm-plugin-sdk", diff --git a/plugins/examples/gpu-monitor/backend/Cargo.toml b/plugins/examples/gpu-monitor/backend/Cargo.toml index 779c9f64..bc65a62a 100644 --- a/plugins/examples/gpu-monitor/backend/Cargo.toml +++ b/plugins/examples/gpu-monitor/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nyaterm-plugin-gpu" -version = "1.0.0" +version = "1.0.1" edition = "2024" rust-version = "1.94" diff --git a/plugins/examples/gpu-monitor/backend/src/main.rs b/plugins/examples/gpu-monitor/backend/src/main.rs index f0ac51ce..56e68128 100644 --- a/plugins/examples/gpu-monitor/backend/src/main.rs +++ b/plugins/examples/gpu-monitor/backend/src/main.rs @@ -9,8 +9,15 @@ impl Plugin for GpuMonitor { return Err(RpcError::method_not_found(method)); } let result = context.host().remote_probe("gpu-overview").await?; - if result.get("exitStatus").and_then(Value::as_u64) != Some(0) { - return Err(RpcError::new(-32000, "GPU probe did not exit successfully")); + match result.get("exitStatus").and_then(Value::as_u64) { + Some(0) => {} + Some(status) => { + return Err(RpcError::new( + -32000, + format!("GPU probe failed (exit status {status})"), + )); + } + None => return Err(RpcError::new(-32000, "GPU probe returned no exit status")), } let output = result .get("stdout") diff --git a/plugins/examples/gpu-monitor/build.mjs b/plugins/examples/gpu-monitor/build.mjs index 3b99ef83..29353ad7 100644 --- a/plugins/examples/gpu-monitor/build.mjs +++ b/plugins/examples/gpu-monitor/build.mjs @@ -8,6 +8,10 @@ import tailwindcss from "@tailwindcss/vite"; const root = fileURLToPath(new URL("./", import.meta.url)); const repository = path.resolve(root, "../../.."); +// Execute on Linux even when this project was created/checked out on Windows. +const probePath = path.join(root, "assets/probes/gpu.sh"); +const probe = readFileSync(probePath, "utf8").replace(/\r\n/g, "\n"); +writeFileSync(probePath, probe); const translations = {}; for (const language of ["en", "zh-CN", "zh-TW", "ko"]) { const locale = JSON.parse( diff --git a/plugins/examples/gpu-monitor/manifest.json b/plugins/examples/gpu-monitor/manifest.json index 73449f5c..abb72ef8 100644 --- a/plugins/examples/gpu-monitor/manifest.json +++ b/plugins/examples/gpu-monitor/manifest.json @@ -2,7 +2,7 @@ "manifestVersion": 1, "id": "nyaterm.gpu", "name": "GPU Monitor", - "version": "1.0.0", + "version": "1.0.1", "description": "NVIDIA GPU monitoring over an existing NyaTerm SSH session", "publisher": "NyaTerm", "engine": ">=1.2.12, <2.0.0", diff --git a/plugins/examples/gpu-monitor/verify-probe.py b/plugins/examples/gpu-monitor/verify-probe.py new file mode 100644 index 00000000..c17b046e --- /dev/null +++ b/plugins/examples/gpu-monitor/verify-probe.py @@ -0,0 +1,60 @@ +"""Execute the exact packaged probe in a POSIX shell with controlled NVIDIA fixtures. + +Windows: python plugins/examples/gpu-monitor/verify-probe.py --wsl Ubuntu +Linux: python3 plugins/examples/gpu-monitor/verify-probe.py +""" +import argparse +import json +import subprocess +import zipfile +from pathlib import Path + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--wsl", metavar="DISTRO", help="Use an existing WSL distribution on Windows") +parser.add_argument("--package", type=Path, default=Path(__file__).resolve().parents[3] / "temp/plugins/gpu-monitor.nyap") +args = parser.parse_args() +shell = ["wsl", "-d", args.wsl, "--exec", "/bin/sh"] if args.wsl else ["/bin/sh"] +with zipfile.ZipFile(args.package) as package: + manifest = json.loads(package.read("manifest.json")) + assert manifest["id"] == "nyaterm.gpu" + probe = package.read("assets/probes/gpu.sh") + +# Reproduce the old Windows packaging failure, then exercise the actual fixed package. +assert b"\r" not in probe, "Packaged POSIX script contains Windows CRLF line endings" +assert not probe.startswith(b"\xef\xbb\xbf"), "Packaged script contains a UTF-8 BOM" +assert probe.endswith(b"\n") +def execute(script, command): + result = subprocess.run(shell + ["-c", command], input=script, capture_output=True, timeout=20) + return result.returncode, result.stdout, result.stderr + +unavailable_command = "PATH=/nonexistent /bin/sh -s" +status, _, _ = execute(probe.replace(b"\n", b"\r\n"), unavailable_command) +assert status != 0, "CRLF regression did not reproduce in the Linux shell" +status, stdout, _ = execute(probe, unavailable_command) +assert status == 0 and stdout == b"GPU_AVAILABLE\t0\n", (status, stdout) + +fixture_command = r'''set -eu +fixture=$(mktemp -d) +trap 'rm -rf "$fixture"' EXIT HUP INT TERM +cat > "$fixture/nvidia-smi" <<'NYATERM_NVIDIA_FIXTURE' +#!/bin/sh +case "$*" in + --query-gpu=*) + printf '%s\n' '0, GPU-a, NVIDIA RTX 4090, 550.54, 43, 77, 31, 24564, 12345, 12219, 180.5, 450, 46, P2' + ;; + --query-compute-apps=*) + printf '%s\n' 'GPU-a, 4242, 2048, python' + ;; + *) + printf '%s\n' '| NVIDIA-SMI 550.54 Driver Version: 550.54 CUDA Version: 12.4 |' + ;; +esac +NYATERM_NVIDIA_FIXTURE +chmod +x "$fixture/nvidia-smi" +PATH="$fixture:/usr/bin:/bin" /bin/sh -s +''' +status, stdout, _ = execute(probe, fixture_command) +assert status == 0, f"GPU fixture probe exited with {status}" +for required in [b"GPU_AVAILABLE\t1\n", b"GPU_CUDA_VERSION\t12.4\n", b"GPU_CSV_BEGIN\n", b"NVIDIA RTX 4090", b"GPU_CSV_END\n", b"GPU_PROCESS_CSV_BEGIN\n", b"GPU-a, 4242, 2048, python", b"GPU_PROCESS_CSV_END\n"]: + assert required in stdout, f"Probe did not produce {required!r}" +print(f"GPU probe {manifest['version']} passed: exact package LF bytes, CRLF regression, unavailable and NVIDIA fixtures in a Linux/POSIX shell.") diff --git a/src-tauri/crates/nyaterm-plugin-runtime/tests/gpu_plugin.rs b/src-tauri/crates/nyaterm-plugin-runtime/tests/gpu_plugin.rs index 1a1bfa53..29d42c4e 100644 --- a/src-tauri/crates/nyaterm-plugin-runtime/tests/gpu_plugin.rs +++ b/src-tauri/crates/nyaterm-plugin-runtime/tests/gpu_plugin.rs @@ -14,7 +14,10 @@ use std::{ time::Duration, }; -struct Host(AtomicUsize); +struct Host { + calls: AtomicUsize, + exit_status: Option, +} #[async_trait::async_trait] impl HostHandler for Host { async fn call(&self, method: &str, params: Value) -> Result { @@ -23,17 +26,16 @@ impl HostHandler for Host { params, json!({"scopeToken":"gpu-scope","input":{"probeId":"gpu-overview"}}) ); - self.0.fetch_add(1, Ordering::SeqCst); + self.calls.fetch_add(1, Ordering::SeqCst); Ok(json!({"stdout":concat!( "GPU_AVAILABLE\t1\nGPU_CUDA_VERSION\t12.4\nGPU_CSV_BEGIN\n", "0, GPU-a, NVIDIA RTX 4090, 550.54, 43, 77, 31, 24564, 12345, 12219, 180.5, 450, 46, P2\n", "GPU_CSV_END\nGPU_PROCESS_CSV_BEGIN\nGPU-a, 4242, 2048, python\nGPU_PROCESS_CSV_END\n" - ),"stderr":"","exitStatus":0})) + ),"stderr":"private probe output must not appear in diagnostics","exitStatus":self.exit_status})) } } -#[tokio::test] -async fn real_gpu_backend_reuses_process_and_parses_reverse_probe_response() { +async fn start_fixture(host: Arc) -> (tempfile::TempDir, Sidecar) { let root = tempfile::tempdir().unwrap(); std::fs::create_dir(root.path().join("bin")).unwrap(); let executable = if cfg!(windows) { @@ -53,7 +55,6 @@ async fn real_gpu_backend_reuses_process_and_parses_reverse_probe_response() { value["backend"]["executables"] = json!({format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH):executable}); let manifest = Manifest::parse(&serde_json::to_vec(&value).unwrap(), "1.2.12").unwrap(); - let host = Arc::new(Host(AtomicUsize::new(0))); let sidecar = Sidecar::spawn( root.path(), &manifest, @@ -63,6 +64,16 @@ async fn real_gpu_backend_reuses_process_and_parses_reverse_probe_response() { ) .await .unwrap(); + (root, sidecar) +} + +#[tokio::test] +async fn real_gpu_backend_reuses_process_and_parses_reverse_probe_response() { + let host = Arc::new(Host { + calls: AtomicUsize::new(0), + exit_status: Some(0), + }); + let (_root, sidecar) = start_fixture(host.clone()).await; for _ in 0..2 { let value = sidecar .request( @@ -77,10 +88,47 @@ async fn real_gpu_backend_reuses_process_and_parses_reverse_probe_response() { assert_eq!(overview.gpus[0].utilization_gpu_percent, Some(77.0)); assert_eq!(overview.processes[0].gpu_index, Some(0)); } - assert_eq!(host.0.load(Ordering::SeqCst), 2); + assert_eq!(host.calls.load(Ordering::SeqCst), 2); sidecar.stop(); } +#[tokio::test] +async fn failed_probe_reports_exit_status_without_logging_raw_output() { + for (exit_status, message) in [ + (Some(2), "GPU probe failed (exit status 2)"), + (None, "GPU probe returned no exit status"), + ] { + let host = Arc::new(Host { + calls: AtomicUsize::new(0), + exit_status, + }); + let (_root, sidecar) = start_fixture(host).await; + let error = sidecar + .request( + "monitor/collect", + json!({"scopeToken":"gpu-scope","input":null}), + Duration::from_secs(5), + ) + .await + .unwrap_err() + .to_string(); + assert!(error.contains(message), "{error}"); + assert!(!error.contains("private probe output")); + assert!(!error.contains("python")); + sidecar.stop(); + } +} + +#[test] +fn gpu_probe_source_uses_portable_shell_line_endings() { + let source = include_bytes!("../../../../plugins/examples/gpu-monitor/assets/probes/gpu.sh"); + assert!( + !source.contains(&b'\r'), + "Run the GPU plugin build to normalize Windows line endings" + ); + assert!(!source.starts_with(&[0xef, 0xbb, 0xbf])); +} + /// Opt-in verification of the final archive, not the debug fixture binary. #[tokio::test] #[ignore = "Build/package GPU plugin and set NYATERM_GPU_PACKAGE"] @@ -137,10 +185,7 @@ async fn installed_release_package_collects_using_its_fixed_probe() { let digest = prepared.preview.digest.clone(); let mut registry = Registry::open(temp.path().join("plugins"), "1.2.12".into()).unwrap(); registry.install(prepared, &digest).unwrap(); - let permissions = vec![ - "native".into(), - "remote.probe".into(), - ]; + let permissions = vec!["native".into(), "remote.probe".into()]; registry .configure("nyaterm.gpu", true, permissions.clone()) .unwrap();