feat(gpu-monitor): introduce GPU monitoring plugin with validation, build scripts, and documentation; enhance plugin development guidelines

This commit is contained in:
Kang committed 2026-10-03 18:57:34 +08:00
1 parent bb0c24d5e5
commit e5b7daad99
66 files changed
+4152 -434

No files matched your search

+9
View File
@@ -4940,6 +4940,7 @@ dependencies = [
"md-5 0.10.6",
"notify",
"nucleo-matcher",
"nyaterm-gpu",
"nyaterm-mcp-protocol",
"nyaterm-otp",
"nyaterm-plugin-runtime",
@@ -4996,6 +4997,13 @@ dependencies = [
"zmodem2",
]
[[package]]
name = "nyaterm-gpu"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "nyaterm-mcp-protocol"
version = "0.1.0"
@@ -5015,6 +5023,7 @@ version = "0.1.0"
dependencies = [
"async-trait",
"hex",
"nyaterm-gpu",
"regex",
"semver",
"serde",
+1
View File
@@ -85,6 +85,7 @@ tauri-build = { version = "2", features = [] }
tokio = { version = "1", features = ["test-util"] }
[dependencies]
nyaterm-gpu = { path = "crates/nyaterm-gpu" }
nyaterm-otp = { path = "crates/otp" }
nyaterm-mcp-protocol = { path = "crates/nyaterm-mcp-protocol" }
nyaterm-plugin-runtime = { path = "crates/nyaterm-plugin-runtime" }
+75
View File
@@ -0,0 +1,75 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "nyaterm-gpu"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "nyaterm-gpu"
version = "0.1.0"
edition = "2024"
rust-version = "1.94"
[dependencies]
serde = { version = "1", features = ["derive"] }
+332
View File
@@ -0,0 +1,332 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct RemoteGpuOverview {
pub available: bool,
pub driver_version: String,
pub cuda_version: String,
pub gpus: Vec<RemoteGpu>,
pub processes: Vec<RemoteGpuProcess>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct RemoteGpu {
pub index: u32,
pub uuid: String,
pub name: String,
pub temperature_c: Option<f64>,
pub utilization_gpu_percent: Option<f64>,
pub utilization_memory_percent: Option<f64>,
pub memory_total_mb: u64,
pub memory_used_mb: u64,
pub memory_free_mb: u64,
pub power_draw_w: Option<f64>,
pub power_limit_w: Option<f64>,
pub fan_speed_percent: Option<f64>,
pub pstate: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct RemoteGpuProcess {
pub gpu_uuid: String,
pub gpu_index: Option<u32>,
pub pid: u32,
pub process_name: String,
pub used_memory_mb: u64,
}
enum GpuParseSection {
None,
Gpus,
Processes,
}
pub fn parse_gpu_overview_output(output: &str) -> RemoteGpuOverview {
let mut overview = RemoteGpuOverview::default();
let mut section = GpuParseSection::None;
let mut process_rows = Vec::new();
for line in output.lines() {
match line {
"GPU_CSV_BEGIN" => {
section = GpuParseSection::Gpus;
continue;
}
"GPU_CSV_END" => {
section = GpuParseSection::None;
continue;
}
"GPU_PROCESS_CSV_BEGIN" => {
section = GpuParseSection::Processes;
continue;
}
"GPU_PROCESS_CSV_END" => {
section = GpuParseSection::None;
continue;
}
_ => {}
}
let cols: Vec<&str> = line.split('\t').collect();
if cols.first() == Some(&"GPU_AVAILABLE") && cols.len() >= 2 {
overview.available = cols[1] == "1";
continue;
}
if cols.first() == Some(&"GPU_CUDA_VERSION") && cols.len() >= 2 {
overview.cuda_version = cols[1].trim().to_string();
continue;
}
match section {
GpuParseSection::Gpus => {
if let Some(gpu) = parse_gpu_csv_line(line) {
if overview.driver_version.is_empty() {
overview.driver_version = gpu.1;
}
overview.gpus.push(gpu.0);
}
}
GpuParseSection::Processes => {
if !line.trim().is_empty() {
process_rows.push(line.to_string());
}
}
GpuParseSection::None => {}
}
}
let gpu_indexes: HashMap<String, u32> = overview
.gpus
.iter()
.map(|gpu| (gpu.uuid.clone(), gpu.index))
.collect();
overview.processes = process_rows
.iter()
.filter_map(|line| parse_gpu_process_csv_line(line, &gpu_indexes))
.collect();
overview
}
fn parse_gpu_csv_line(line: &str) -> Option<(RemoteGpu, String)> {
let cols = parse_csv_line(line);
if cols.len() < 14 {
return None;
}
let gpu = RemoteGpu {
index: parse_u32(&cols[0]).unwrap_or(0),
uuid: cols[1].trim().to_string(),
name: cols[2].trim().to_string(),
temperature_c: parse_optional_f64(&cols[4]),
utilization_gpu_percent: parse_optional_f64(&cols[5]),
utilization_memory_percent: parse_optional_f64(&cols[6]),
memory_total_mb: parse_u64(&cols[7]).unwrap_or(0),
memory_used_mb: parse_u64(&cols[8]).unwrap_or(0),
memory_free_mb: parse_u64(&cols[9]).unwrap_or(0),
power_draw_w: parse_optional_f64(&cols[10]),
power_limit_w: parse_optional_f64(&cols[11]),
fan_speed_percent: parse_optional_f64(&cols[12]),
pstate: clean_text(&cols[13]),
};
Some((gpu, clean_text(&cols[3])))
}
fn parse_gpu_process_csv_line(
line: &str,
gpu_indexes: &HashMap<String, u32>,
) -> Option<RemoteGpuProcess> {
let cols = parse_csv_line(line);
if cols.len() < 4 {
return None;
}
let gpu_uuid = clean_text(&cols[0]);
if gpu_uuid.is_empty() || is_missing(&gpu_uuid) {
return None;
}
Some(RemoteGpuProcess {
gpu_index: gpu_indexes.get(&gpu_uuid).copied(),
gpu_uuid,
pid: parse_u32(&cols[1]).unwrap_or(0),
used_memory_mb: parse_u64(&cols[2]).unwrap_or(0),
process_name: cols[3..].join(",").trim().to_string(),
})
}
fn parse_csv_line(line: &str) -> Vec<String> {
let chars: Vec<char> = line.chars().collect();
let mut values = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
let mut index = 0;
while index < chars.len() {
let ch = chars[index];
match ch {
'"' if in_quotes && chars.get(index + 1) == Some(&'"') => {
current.push('"');
index += 1;
}
'"' => {
in_quotes = !in_quotes;
}
',' if !in_quotes => {
values.push(current.trim().to_string());
current.clear();
}
_ => current.push(ch),
}
index += 1;
}
values.push(current.trim().to_string());
values
}
fn clean_text(value: &str) -> String {
value.trim().to_string()
}
fn is_missing(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
matches!(
normalized.as_str(),
"" | "n/a" | "na" | "[n/a]" | "[not supported]" | "not supported"
)
}
fn parse_optional_f64(value: &str) -> Option<f64> {
if is_missing(value) {
return None;
}
numeric_prefix(value).and_then(|value| value.parse().ok())
}
fn parse_u64(value: &str) -> Option<u64> {
if is_missing(value) {
return None;
}
numeric_prefix(value).and_then(|value| value.parse::<f64>().ok().map(|number| number as u64))
}
fn parse_u32(value: &str) -> Option<u32> {
parse_u64(value).and_then(|value| u32::try_from(value).ok())
}
fn numeric_prefix(value: &str) -> Option<&str> {
let trimmed = value.trim().trim_end_matches('%').trim();
let end = trimmed
.char_indices()
.take_while(|(_, ch)| ch.is_ascii_digit() || *ch == '.' || *ch == '-')
.map(|(index, ch)| index + ch.len_utf8())
.last()
.unwrap_or(0);
(end > 0).then_some(&trimmed[..end])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_unavailable_gpu_output() {
let overview = parse_gpu_overview_output("GPU_AVAILABLE\t0\n");
assert!(!overview.available);
assert!(overview.gpus.is_empty());
assert!(overview.processes.is_empty());
}
#[test]
fn parses_single_gpu_with_metrics() {
let raw = concat!(
"GPU_AVAILABLE\t1\n",
"GPU_CUDA_VERSION\t12.4\n",
"GPU_CSV_BEGIN\n",
"0, GPU-abc, NVIDIA RTX 4090, 550.54.14, 43, 77, 31, 24564, 12345, 12219, 180.5, 450.0, 46, P2\n",
"GPU_CSV_END\n",
"GPU_PROCESS_CSV_BEGIN\n",
"GPU_PROCESS_CSV_END\n",
);
let overview = parse_gpu_overview_output(raw);
assert!(overview.available);
assert_eq!(overview.driver_version, "550.54.14");
assert_eq!(overview.cuda_version, "12.4");
assert_eq!(overview.gpus[0].name, "NVIDIA RTX 4090");
assert_eq!(overview.gpus[0].utilization_gpu_percent, Some(77.0));
assert_eq!(overview.gpus[0].memory_used_mb, 12345);
assert_eq!(overview.gpus[0].power_draw_w, Some(180.5));
}
#[test]
fn parses_multiple_gpus_and_missing_optional_metrics() {
let raw = concat!(
"GPU_AVAILABLE\t1\n",
"GPU_CUDA_VERSION\t12.2\n",
"GPU_CSV_BEGIN\n",
"0, GPU-a, NVIDIA A100, 535.1, N/A, 0, 0, 40536, 0, 40536, [Not Supported], 400, N/A, P0\n",
"1, GPU-b, NVIDIA T4, 535.1, 55, 99, 80, 15360, 15000, 360, 67.2, 70.0, [Not Supported], P0\n",
"GPU_CSV_END\n",
"GPU_PROCESS_CSV_BEGIN\n",
"GPU_PROCESS_CSV_END\n",
);
let overview = parse_gpu_overview_output(raw);
assert_eq!(overview.gpus.len(), 2);
assert_eq!(overview.gpus[0].temperature_c, None);
assert_eq!(overview.gpus[0].power_draw_w, None);
assert_eq!(overview.gpus[0].fan_speed_percent, None);
assert_eq!(overview.gpus[1].fan_speed_percent, None);
}
#[test]
fn maps_gpu_processes_to_indexes() {
let raw = concat!(
"GPU_AVAILABLE\t1\n",
"GPU_CUDA_VERSION\t12.2\n",
"GPU_CSV_BEGIN\n",
"0, GPU-a, NVIDIA A100, 535.1, 40, 30, 20, 40536, 1000, 39536, 80, 400, 20, P0\n",
"1, GPU-b, NVIDIA T4, 535.1, 44, 10, 9, 15360, 512, 14848, 35, 70, 30, P8\n",
"GPU_CSV_END\n",
"GPU_PROCESS_CSV_BEGIN\n",
"GPU-b, 4242, 2048, python\n",
"GPU_PROCESS_CSV_END\n",
);
let overview = parse_gpu_overview_output(raw);
assert_eq!(overview.processes.len(), 1);
assert_eq!(overview.processes[0].gpu_index, Some(1));
assert_eq!(overview.processes[0].pid, 4242);
assert_eq!(overview.processes[0].used_memory_mb, 2048);
}
#[test]
fn keeps_commas_inside_quoted_process_names() {
let raw = concat!(
"GPU_AVAILABLE\t1\n",
"GPU_CUDA_VERSION\t12.2\n",
"GPU_CSV_BEGIN\n",
"0, GPU-a, NVIDIA A100, 535.1, 40, 30, 20, 40536, 1000, 39536, 80, 400, 20, P0\n",
"GPU_CSV_END\n",
"GPU_PROCESS_CSV_BEGIN\n",
"GPU-a, 100, 512, \"python, worker\"\n",
"GPU_PROCESS_CSV_END\n",
);
let overview = parse_gpu_overview_output(raw);
assert_eq!(overview.processes[0].process_name, "python, worker");
}
}
+8
View File
@@ -391,12 +391,20 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "nyaterm-gpu"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "nyaterm-plugin-runtime"
version = "0.1.0"
dependencies = [
"async-trait",
"hex",
"nyaterm-gpu",
"nyaterm-plugin-sdk",
"regex",
"semver",
@@ -5,6 +5,7 @@ edition = "2024"
rust-version = "1.94"
[dependencies]
nyaterm-gpu = { path = "../nyaterm-gpu" }
async-trait = "0.1"
hex = "0.4"
regex = "1"
@@ -21,7 +22,7 @@ uuid = { version = "1", features = ["v4"] }
zip = { version = "8", default-features = false, features = ["deflate"] }
[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread"] }
tokio = { version = "1", features = ["rt-multi-thread", "test-util"] }
[features]
sdk-fixture = ["dep:nyaterm-plugin-sdk"]
@@ -34,3 +35,8 @@ optional = true
name = "sdk-fixture"
path = "src/bin/sdk-fixture.rs"
required-features = ["sdk-fixture"]
[[bin]]
name = "gpu-fixture"
path = "../../../plugins/examples/gpu-monitor/backend/src/main.rs"
required-features = ["sdk-fixture"]
@@ -1,7 +1,9 @@
//! Tauri-independent installation, manifest validation and sidecar transport.
pub mod diagnostics;
pub mod manifest;
pub mod monitoring;
pub mod package;
pub mod probe;
pub mod registry;
pub mod sidecar;
@@ -29,6 +29,29 @@ pub struct Contributions {
pub panels: Vec<Panel>,
#[serde(default)]
pub commands: Vec<Command>,
#[serde(default)]
pub probes: Vec<Probe>,
#[serde(default)]
pub monitors: Vec<Monitor>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Probe {
pub id: String,
pub title: String,
pub entry: String,
pub timeout_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Monitor {
pub id: String,
pub title: String,
pub schema: String,
pub method: String,
pub panel: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -126,6 +149,7 @@ pub fn validate_permission(permission: &str) -> Result<()> {
"filesystem.read",
"storage",
"native",
"remote.probe",
]
.contains(&permission)
{
@@ -188,6 +212,8 @@ impl Manifest {
if self.permissions.len() > 24
|| self.contributions.panels.len() > 16
|| self.contributions.commands.len() > 64
|| self.contributions.probes.len() > 16
|| self.contributions.monitors.len() > 16
{
return Err(invalid("Too many plugin permissions or contributions"));
}
@@ -208,6 +234,22 @@ impl Manifest {
return Err(invalid("Too many network origins"));
}
let mut ids = HashSet::new();
for probe in &self.contributions.probes {
validate_path(&probe.entry)?;
if !valid_id(&probe.id)
|| !ids.insert(&probe.id)
|| probe.title.trim().is_empty()
|| probe.title.len() > 120
|| !probe.entry.starts_with("assets/probes/")
|| !probe.entry.ends_with(".sh")
|| !(1000..=30_000).contains(&probe.timeout_ms)
|| !permissions.contains(&"remote.probe".to_string())
{
return Err(invalid(
"Invalid probe declaration or missing remote.probe permission",
));
}
}
for panel in &self.contributions.panels {
if !valid_id(&panel.id)
|| panel.title.trim().is_empty()
@@ -242,6 +284,28 @@ impl Manifest {
return Err(invalid("Too many command menu locations"));
}
}
let mut schemas = HashSet::new();
for monitor in &self.contributions.monitors {
if !valid_id(&monitor.id)
|| !ids.insert(&monitor.id)
|| monitor.title.trim().is_empty()
|| monitor.title.len() > 120
|| monitor.schema != "gpu.v1"
|| !schemas.insert(&monitor.schema)
|| !monitor.method.starts_with("monitor/")
|| monitor.method.len() > 128
|| monitor.method.len() <= 8
|| self.backend.is_none()
|| self.contributions.probes.is_empty()
|| !self
.contributions
.panels
.iter()
.any(|p| p.id == monitor.panel)
{
return Err(invalid("Invalid monitor declaration"));
}
}
if let Some(backend) = &self.backend {
if !self.permissions.iter().any(|p| p == "native")
|| backend.executables.is_empty()
@@ -0,0 +1,266 @@
//! Demand-driven, shared monitor actors. No Tauri or SSH dependency.
use crate::{Result, invalid};
use async_trait::async_trait;
use nyaterm_gpu::RemoteGpuOverview;
use serde::Serialize;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::{Notify, watch};
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MonitorKey {
pub plugin_id: String,
pub version: String,
pub window: String,
pub session: String,
pub monitor_id: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MonitorSnapshot {
pub revision: u64,
pub session_id: String,
pub overview: Option<RemoteGpuOverview>,
pub error: bool,
pub refreshing: bool,
pub paused: bool,
}
#[async_trait]
pub trait Collector: Send + Sync {
async fn collect(&self, scope: &str) -> Result<Value>;
fn status(&self, _message: &str) {}
}
struct Demand {
token: String,
cancellation: CancellationToken,
interval: Duration,
}
pub struct MonitorTask {
demands: Mutex<HashMap<String, Demand>>,
paused: Mutex<bool>,
manual: Mutex<bool>,
in_flight: Mutex<Option<CancellationToken>>,
wake: Notify,
stop: CancellationToken,
snapshot: watch::Sender<MonitorSnapshot>,
}
impl MonitorTask {
pub fn new(session: String, collector: Arc<dyn Collector>) -> Arc<Self> {
let (snapshot, _) = watch::channel(MonitorSnapshot {
revision: 0,
session_id: session,
overview: None,
error: false,
refreshing: false,
paused: false,
});
let task = Arc::new(Self {
demands: Mutex::new(HashMap::new()),
paused: Mutex::new(false),
manual: Mutex::new(false),
wake: Notify::new(),
in_flight: Mutex::new(None),
stop: CancellationToken::new(),
snapshot,
});
let running = task.clone();
tokio::spawn(async move {
running.run(collector).await;
});
task
}
pub fn add(&self, id: String, token: String, cancellation: CancellationToken, seconds: u64) {
self.demands.lock().unwrap().insert(
id,
Demand {
token,
cancellation,
interval: Duration::from_secs(seconds.clamp(3, 3600)),
},
);
self.wake.notify_one();
}
pub fn remove(&self, id: &str) {
let mut demands = self.demands.lock().unwrap();
demands.remove(id);
if demands.is_empty() {
self.stop.cancel();
}
self.wake.notify_one();
}
pub fn is_stopped(&self) -> bool {
self.stop.is_cancelled()
}
pub fn cancel(&self) {
self.stop.cancel();
}
pub fn snapshot(&self) -> MonitorSnapshot {
self.snapshot.borrow().clone()
}
pub fn watch(&self) -> watch::Receiver<MonitorSnapshot> {
self.snapshot.subscribe()
}
pub fn refresh(&self) {
*self.paused.lock().unwrap() = false;
*self.manual.lock().unwrap() = true;
self.wake.notify_one();
}
pub fn pause(&self) {
*self.paused.lock().unwrap() = true;
*self.manual.lock().unwrap() = false;
if let Some(cancellation) = self.in_flight.lock().unwrap().as_ref() {
cancellation.cancel();
}
self.snapshot.send_modify(|s| {
s.paused = true;
s.refreshing = false;
s.revision += 1;
});
self.wake.notify_one();
}
async fn run(self: Arc<Self>, collector: Arc<dyn Collector>) {
let mut failures = 0;
let mut started = false;
let mut unavailable = false;
let mut last_completion: Option<tokio::time::Instant> = None;
loop {
let demand = {
let mut demands = self.demands.lock().unwrap();
demands.retain(|_, d| !d.cancellation.is_cancelled());
demands
.values()
.min_by_key(|d| d.interval)
.map(|d| (d.token.clone(), d.cancellation.clone(), d.interval))
};
let Some((token, cancellation, interval)) = demand else {
if self.stop.is_cancelled() {
return;
}
tokio::select! { () = self.stop.cancelled() => return, () = self.wake.notified() => continue }
};
let paused = *self.paused.lock().unwrap();
let manual = *self.manual.lock().unwrap();
let next =
last_completion.map_or_else(tokio::time::Instant::now, |last| last + interval);
if (paused || unavailable) && !manual {
tokio::select! {
() = self.stop.cancelled() => return,
() = cancellation.cancelled() => continue,
() = self.wake.notified() => continue,
}
}
if !manual && tokio::time::Instant::now() < next {
tokio::select! {
() = self.stop.cancelled() => return,
() = cancellation.cancelled() => continue,
() = self.wake.notified() => continue,
() = tokio::time::sleep_until(next) => {},
}
}
// Pause can arrive while waiting; a wake must not issue another tick.
if *self.paused.lock().unwrap() {
continue;
}
*self.manual.lock().unwrap() = false;
let resuming = self.snapshot.borrow().paused;
let collection = CancellationToken::new();
*self.in_flight.lock().unwrap() = Some(collection.clone());
if *self.paused.lock().unwrap() {
collection.cancel();
}
self.snapshot.send_modify(|s| {
s.refreshing = true;
s.paused = false;
s.revision += 1;
});
let result = tokio::select! {
biased;
() = self.stop.cancelled() => return,
() = collection.cancelled() => {
*self.in_flight.lock().unwrap() = None;
self.snapshot.send_modify(|s| { s.refreshing = false; s.paused = *self.paused.lock().unwrap(); s.revision += 1; });
collector.status("Monitor collection paused");
continue;
},
() = cancellation.cancelled() => { *self.in_flight.lock().unwrap() = None; last_completion = None; continue; },
result = tokio::time::timeout(Duration::from_secs(45), collector.collect(&token)) =>
result.unwrap_or_else(|_| Err(invalid("Monitor collection timed out"))),
}.and_then(validate_gpu);
*self.in_flight.lock().unwrap() = None;
if self.stop.is_cancelled() || cancellation.is_cancelled() || collection.is_cancelled()
{
continue;
}
if !started {
collector.status("Monitor collection started");
started = true;
}
match result {
Ok(overview) => {
if failures > 0 || resuming {
collector.status("Monitor collection recovered");
}
failures = 0;
unavailable = !overview.available;
self.snapshot.send_modify(|s| {
s.overview = Some(overview);
s.error = false;
s.refreshing = false;
s.revision += 1;
});
}
Err(_) => {
if failures == 0 {
collector.status("Monitor collection failed");
}
failures += 1;
self.snapshot.send_modify(|s| {
if failures >= 3 {
s.overview = None;
}
s.error = true;
s.refreshing = false;
s.revision += 1;
});
}
}
// Schedule after completion: no overlapping requests or catch-up bursts.
last_completion = Some(tokio::time::Instant::now());
}
}
}
pub fn validate_gpu(value: Value) -> Result<RemoteGpuOverview> {
if serde_json::to_vec(&value)?.len() > 1024 * 1024 {
return Err(invalid("Monitor result exceeds 1 MiB"));
}
let overview: RemoteGpuOverview = serde_json::from_value(value)?;
if overview.gpus.len() > 128
|| overview.processes.len() > 4096
|| overview.driver_version.len() > 256
|| overview.cuda_version.len() > 256
|| overview
.gpus
.iter()
.any(|g| g.name.len() > 1024 || g.uuid.len() > 256 || g.pstate.len() > 128)
|| overview
.processes
.iter()
.any(|p| p.process_name.len() > 4096 || p.gpu_uuid.len() > 256)
{
return Err(invalid("Monitor result exceeds GPU schema limits"));
}
Ok(overview)
}
@@ -18,6 +18,7 @@ pub struct PackagePreview {
pub manifest: Manifest,
pub digest: String,
pub expanded_bytes: u64,
pub probe_scripts: BTreeMap<String, String>,
}
pub struct PreparedPackage {
@@ -120,6 +121,18 @@ pub fn prepare(path: &Path, staging_parent: &Path, app_version: &str) -> Result<
for panel in &manifest.contributions.panels {
checked_file(directory.path(), &panel.entry)?;
}
let mut probe_scripts = BTreeMap::new();
for probe in &manifest.contributions.probes {
let path = checked_file(directory.path(), &probe.entry)?;
if std::fs::metadata(&path)?.len() > 64 * 1024 {
return Err(invalid("Probe script exceeds 64 KiB"));
}
let script = std::fs::read_to_string(path)?;
if script.is_empty() || script.contains('\0') {
return Err(invalid("Probe script must be nonempty UTF-8 without NUL"));
}
probe_scripts.insert(probe.id.clone(), script);
}
if let Some(backend) = &manifest.backend {
for executable in backend.executables.values() {
let path = checked_file(directory.path(), executable)?;
@@ -137,6 +150,7 @@ pub fn prepare(path: &Path, staging_parent: &Path, app_version: &str) -> Result<
manifest,
digest: package_digest,
expanded_bytes,
probe_scripts,
},
directory,
})
@@ -0,0 +1,93 @@
//! Fixed, reviewed package scripts. Caller supplies identity, never shell text.
use crate::{Result, invalid, package::checked_file};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
#[async_trait::async_trait]
pub trait ProbeExecutor: Send + Sync {
async fn execute(
&self,
script: &str,
timeout: Duration,
cancellation: CancellationToken,
) -> Result<ProbeOutput>;
}
pub async fn execute_declared(
executor: &dyn ProbeExecutor,
root: &Path,
manifest: &crate::manifest::Manifest,
request: ProbeRequest,
cancellation: CancellationToken,
) -> Result<ProbeOutput> {
if cancellation.is_cancelled() {
return Err(invalid("Probe cancelled"));
}
let probe = manifest
.contributions
.probes
.iter()
.find(|p| p.id == request.probe_id)
.ok_or_else(|| invalid("Unknown declared probe"))?;
let script = read_script(root, probe)?;
let timeout = Duration::from_millis(probe.timeout_ms);
let result = tokio::select! {
biased;
() = cancellation.cancelled() => return Err(invalid("Probe cancelled")),
result = tokio::time::timeout(timeout, executor.execute(&script, timeout, cancellation.clone())) =>
result.map_err(|_| invalid("Probe timed out"))??,
};
if result.stdout.len() + result.stderr.len() > 1024 * 1024 {
return Err(invalid("Probe output exceeds 1 MiB"));
}
Ok(result)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ProbeRequest {
pub probe_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProbeOutput {
pub stdout: String,
pub stderr: String,
pub exit_status: Option<u32>,
}
pub fn read_script(root: &Path, probe: &crate::manifest::Probe) -> Result<String> {
let path = checked_file(root, &probe.entry)?;
if std::fs::metadata(&path)?.len() > 64 * 1024 {
return Err(invalid("Probe script exceeds 64 KiB"));
}
use std::io::Read;
let mut bytes = Vec::new();
std::fs::File::open(path)?
.take(64 * 1024 + 1)
.read_to_end(&mut bytes)?;
if bytes.len() > 64 * 1024 || bytes.is_empty() || bytes.contains(&0) {
return Err(invalid("Invalid probe script"));
}
String::from_utf8(bytes).map_err(|_| invalid("Probe script must be UTF-8"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_shell_and_cross_session_overrides() {
for input in [
r#"{"probeId":"gpu","command":"rm -rf /"}"#,
r#"{"probeId":"gpu","sessionId":"other"}"#,
r#"{"probeId":"gpu","stdin":"password"}"#,
r#"{"probeId":"gpu","args":["--evil"]}"#,
] {
assert!(serde_json::from_str::<ProbeRequest>(input).is_err());
}
assert!(serde_json::from_str::<ProbeRequest>(r#"{"probeId":"gpu"}"#).is_ok());
}
}
@@ -202,10 +202,11 @@ impl Registry {
// New versions are always reviewed before reactivation, including permission changes.
plugin.enabled = false;
plugin.granted_permissions.retain(|p| {
plugin.versions[&plugin.active_version]
.manifest
.permissions
.contains(p)
p != "remote.probe"
&& plugin.versions[&plugin.active_version]
.manifest
.permissions
.contains(p)
});
let installed = plugin.clone();
if let Err(error) = self.save(next) {
@@ -241,6 +242,39 @@ impl Registry {
}
plugin.enabled = enabled;
plugin.granted_permissions = permissions;
if enabled
&& plugin
.granted_permissions
.iter()
.any(|p| p == "remote.probe")
{
let schemas = plugin
.active()?
.manifest
.contributions
.monitors
.iter()
.map(|m| m.schema.clone())
.collect::<Vec<_>>();
for other in next.values().filter(|p| p.id != id && p.enabled) {
if other
.granted_permissions
.iter()
.any(|p| p == "remote.probe")
&& other
.active()?
.manifest
.contributions
.monitors
.iter()
.any(|m| schemas.contains(&m.schema))
{
return Err(invalid(
"Another enabled plugin already supplies this monitor schema; disable it first",
));
}
}
}
self.save(next)
}
@@ -258,7 +292,7 @@ impl Registry {
plugin.enabled = false;
plugin
.granted_permissions
.retain(|p| record.manifest.permissions.contains(p));
.retain(|p| p != "remote.probe" && record.manifest.permissions.contains(p));
self.save(next)
}
@@ -0,0 +1,178 @@
#![cfg(feature = "sdk-fixture")]
use nyaterm_plugin_runtime::{
Result,
manifest::Manifest,
monitoring::validate_gpu,
sidecar::{HostHandler, Sidecar},
};
use serde_json::{Value, json};
use std::{
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
struct Host(AtomicUsize);
#[async_trait::async_trait]
impl HostHandler for Host {
async fn call(&self, method: &str, params: Value) -> Result<Value> {
assert_eq!(method, "host/remote/probe");
assert_eq!(
params,
json!({"scopeToken":"gpu-scope","input":{"probeId":"gpu-overview"}})
);
self.0.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}))
}
}
#[tokio::test]
async fn real_gpu_backend_reuses_process_and_parses_reverse_probe_response() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join("bin")).unwrap();
let executable = if cfg!(windows) {
"bin/gpu.exe"
} else {
"bin/gpu"
};
std::fs::copy(
env!("CARGO_BIN_EXE_gpu-fixture"),
root.path().join(executable),
)
.unwrap();
let mut value: Value = serde_json::from_slice(include_bytes!(
"../../../../plugins/examples/gpu-monitor/manifest.json"
))
.unwrap();
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,
"1.2.12",
&["native".into(), "remote.probe".into()],
host.clone(),
)
.await
.unwrap();
for _ in 0..2 {
let value = sidecar
.request(
"monitor/collect",
json!({"scopeToken":"gpu-scope","input":null}),
Duration::from_secs(5),
)
.await
.unwrap();
let overview = validate_gpu(value).unwrap();
assert_eq!(overview.gpus[0].name, "NVIDIA RTX 4090");
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);
sidecar.stop();
}
/// Opt-in verification of the final archive, not the debug fixture binary.
#[tokio::test]
#[ignore = "Build/package GPU plugin and set NYATERM_GPU_PACKAGE"]
async fn installed_release_package_collects_using_its_fixed_probe() {
use nyaterm_plugin_runtime::{
package::prepare,
probe::{ProbeExecutor, ProbeOutput, ProbeRequest, execute_declared},
registry::Registry,
};
use tokio_util::sync::CancellationToken;
struct FixedExecutor;
#[async_trait::async_trait]
impl ProbeExecutor for FixedExecutor {
async fn execute(
&self,
script: &str,
_: Duration,
_: CancellationToken,
) -> Result<ProbeOutput> {
assert_eq!(
script,
include_str!("../../../../plugins/examples/gpu-monitor/assets/probes/gpu.sh")
);
assert!(script.contains("nvidia-smi"));
Ok(ProbeOutput { stdout: "GPU_AVAILABLE\t1\nGPU_CUDA_VERSION\t12.4\nGPU_CSV_BEGIN\n0, GPU-a, NVIDIA RTX 4090, 550.54, 43, 77, 31, 24564, 12345, 12219, 180.5, 450, 46, P2\nGPU_CSV_END\nGPU_PROCESS_CSV_BEGIN\nGPU-a, 4242, 2048, python\nGPU_PROCESS_CSV_END\n".into(), stderr: String::new(), exit_status: Some(0) })
}
}
struct InstalledHost {
root: std::path::PathBuf,
manifest: Manifest,
calls: AtomicUsize,
}
#[async_trait::async_trait]
impl HostHandler for InstalledHost {
async fn call(&self, method: &str, params: Value) -> Result<Value> {
assert_eq!(method, "host/remote/probe");
assert_eq!(params["scopeToken"], "gpu-scope");
let request: ProbeRequest = serde_json::from_value(params["input"].clone())?;
let output = execute_declared(
&FixedExecutor,
&self.root,
&self.manifest,
request,
CancellationToken::new(),
)
.await?;
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(serde_json::to_value(output)?)
}
}
let package = std::env::var_os("NYATERM_GPU_PACKAGE").expect("NYATERM_GPU_PACKAGE is required");
let temp = tempfile::tempdir().unwrap();
let prepared = prepare(std::path::Path::new(&package), temp.path(), "1.2.12").unwrap();
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(),
];
registry
.configure("nyaterm.gpu", true, permissions.clone())
.unwrap();
let root = registry.active_path("nyaterm.gpu").unwrap();
let manifest = registry
.get("nyaterm.gpu")
.unwrap()
.active()
.unwrap()
.manifest
.clone();
let host = Arc::new(InstalledHost {
root: root.clone(),
manifest: manifest.clone(),
calls: AtomicUsize::new(0),
});
let sidecar = Sidecar::spawn(&root, &manifest, "1.2.12", &permissions, host.clone())
.await
.unwrap();
for _ in 0..2 {
let result = sidecar
.request(
"monitor/collect",
json!({"scopeToken":"gpu-scope","input":null}),
Duration::from_secs(5),
)
.await
.unwrap();
let overview = validate_gpu(result).unwrap();
assert_eq!(overview.gpus[0].name, "NVIDIA RTX 4090");
assert_eq!(overview.processes[0].pid, 4242);
}
assert_eq!(host.calls.load(Ordering::SeqCst), 2);
sidecar.stop();
}
@@ -0,0 +1,111 @@
use nyaterm_plugin_runtime::{manifest::Manifest, package::prepare, registry::Registry};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::{collections::BTreeMap, io::Write, path::Path};
use zip::write::SimpleFileOptions;
fn manifest(id: &str, version: &str) -> Value {
json!({
"manifestVersion":1,"id":id,"name":"GPU","version":version,"publisher":"Test","description":"GPU","engine":">=1",
"permissions":["native","remote.probe"],
"backend":{"transport":"stdio-jsonl","executables":{"windows-x86_64":"bin/backend.exe"}},
"contributions":{
"panels":[{"id":"overview","title":"GPU","entry":"ui/index.html"}],
"probes":[{"id":"probe","title":"GPU probe","entry":"assets/probes/gpu.sh","timeoutMs":15000}],
"monitors":[{"id":"gpu","title":"GPU","schema":"gpu.v1","method":"monitor/collect","panel":"overview"}]
}
})
}
fn package(path: &Path, manifest: &Value, script: &[u8]) {
let files = BTreeMap::from([
("manifest.json", serde_json::to_vec(manifest).unwrap()),
("ui/index.html", b"<html></html>".to_vec()),
("assets/probes/gpu.sh", script.to_vec()),
("bin/backend.exe", b"fixture".to_vec()),
]);
let checksums: BTreeMap<_, _> = files
.iter()
.map(|(k, v)| (*k, hex::encode(Sha256::digest(v))))
.collect();
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
for (name, data) in files {
zip.start_file(name, SimpleFileOptions::default()).unwrap();
zip.write_all(&data).unwrap();
}
zip.start_file("checksums.json", SimpleFileOptions::default())
.unwrap();
zip.write_all(&serde_json::to_vec(&checksums).unwrap())
.unwrap();
zip.finish().unwrap();
}
#[test]
fn validates_monitor_references_permissions_and_paths() {
let valid = manifest("test.gpu", "1.0.0");
assert!(Manifest::parse(&serde_json::to_vec(&valid).unwrap(), "1.2.12").is_ok());
for (pointer, replacement) in [
(
"/contributions/probes/0/entry",
json!("assets/probes/../escape.sh"),
),
("/contributions/probes/0/entry", json!("ui/probe.sh")),
("/contributions/probes/0/timeoutMs", json!(30001)),
("/contributions/monitors/0/panel", json!("missing")),
("/contributions/monitors/0/method", json!("ui/collect")),
("/contributions/monitors/0/schema", json!("arbitrary")),
("/permissions", json!(["native"])),
] {
let mut invalid = valid.clone();
*invalid.pointer_mut(pointer).unwrap() = replacement;
assert!(
Manifest::parse(&serde_json::to_vec(&invalid).unwrap(), "1.2.12").is_err(),
"{pointer}"
);
}
}
#[test]
fn installation_exposes_reviewed_source_and_rejects_bad_scripts() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("gpu.nyap");
let manifest = manifest("test.gpu", "1.0.0");
package(&path, &manifest, b"printf metrics\n");
let prepared = prepare(&path, root.path(), "1.2.12").unwrap();
assert_eq!(prepared.preview.probe_scripts["probe"], "printf metrics\n");
for script in [vec![b'x'; 64 * 1024 + 1], vec![0], vec![255], vec![]] {
package(&path, &manifest, &script);
assert!(prepare(&path, root.path(), "1.2.12").is_err());
}
}
#[test]
fn monitor_conflicts_and_updates_cannot_reuse_execution_grants() {
let root = tempfile::tempdir().unwrap();
let mut registry = Registry::open(root.path().join("installed"), "1.2.12".into()).unwrap();
let path = root.path().join("gpu.nyap");
for id in ["test.gpu", "other.gpu"] {
package(&path, &manifest(id, "1.0.0"), b"printf metrics\n");
let prepared = prepare(&path, root.path(), "1.2.12").unwrap();
let digest = prepared.preview.digest.clone();
registry.install(prepared, &digest).unwrap();
}
let grants = || vec!["native".into(), "remote.probe".into()];
registry.configure("test.gpu", true, grants()).unwrap();
assert!(registry.configure("other.gpu", true, grants()).is_err());
assert!(registry.get("test.gpu").unwrap().enabled);
assert!(!registry.get("other.gpu").unwrap().enabled);
package(&path, &manifest("test.gpu", "1.0.1"), b"printf updated\n");
let prepared = prepare(&path, root.path(), "1.2.12").unwrap();
let digest = prepared.preview.digest.clone();
let updated = registry.install(prepared, &digest).unwrap();
assert!(!updated.enabled);
assert!(!updated.granted_permissions.contains(&"remote.probe".into()));
registry.configure("test.gpu", true, grants()).unwrap();
registry.activate_version("test.gpu", "1.0.0").unwrap();
assert!(
!registry
.get("test.gpu")
.unwrap()
.granted_permissions
.contains(&"remote.probe".into())
);
}
@@ -0,0 +1,198 @@
use async_trait::async_trait;
use nyaterm_plugin_runtime::{
Error, Result,
monitoring::{Collector, MonitorTask, validate_gpu},
};
use serde_json::{Value, json};
use std::{
collections::VecDeque,
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
use tokio_util::sync::CancellationToken;
struct Fake {
calls: AtomicUsize,
active: AtomicUsize,
peak: AtomicUsize,
delay: Duration,
results: Mutex<VecDeque<Result<Value>>>,
}
impl Fake {
fn new(results: Vec<Result<Value>>) -> Arc<Self> {
Arc::new(Self {
calls: AtomicUsize::new(0),
active: AtomicUsize::new(0),
peak: AtomicUsize::new(0),
delay: Duration::ZERO,
results: Mutex::new(results.into()),
})
}
}
struct Active<'a>(&'a AtomicUsize);
impl Drop for Active<'_> {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
#[async_trait]
impl Collector for Fake {
async fn collect(&self, scope: &str) -> Result<Value> {
assert!(scope.starts_with("scope-"));
self.calls.fetch_add(1, Ordering::SeqCst);
let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
self.peak.fetch_max(active, Ordering::SeqCst);
let _guard = Active(&self.active);
tokio::time::sleep(self.delay).await;
self.results
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| Ok(overview(true)))
}
}
fn overview(available: bool) -> Value {
json!({"available":available,"driver_version":"550","cuda_version":"12","gpus":[],"processes":[]})
}
async fn settle() {
for _ in 0..24 {
tokio::task::yield_now().await;
}
}
async fn advance(seconds: u64) {
tokio::time::advance(Duration::from_secs(seconds)).await;
settle().await;
}
#[tokio::test(start_paused = true)]
async fn shares_demands_stops_after_last_and_never_catches_up() {
let fake = Arc::new(Fake {
delay: Duration::from_secs(5),
..Arc::try_unwrap(Fake::new(vec![])).ok().unwrap()
});
let task = MonitorTask::new("ssh-1".into(), fake.clone());
task.add("a".into(), "scope-a".into(), CancellationToken::new(), 3);
task.add("b".into(), "scope-b".into(), CancellationToken::new(), 3);
settle().await;
assert_eq!(fake.calls.load(Ordering::SeqCst), 1);
advance(4).await;
assert_eq!(fake.calls.load(Ordering::SeqCst), 1);
advance(1).await;
assert!(task.snapshot().overview.is_some());
task.remove("a");
advance(3).await;
assert_eq!(fake.calls.load(Ordering::SeqCst), 2);
assert_eq!(fake.peak.load(Ordering::SeqCst), 1);
task.remove("b");
settle().await;
assert_eq!(fake.active.load(Ordering::SeqCst), 0);
advance(100).await;
assert_eq!(fake.calls.load(Ordering::SeqCst), 2);
}
#[tokio::test(start_paused = true)]
async fn unavailable_and_pause_require_explicit_refresh() {
let fake = Fake::new(vec![Ok(overview(false)), Ok(overview(true))]);
let task = MonitorTask::new("ssh-1".into(), fake.clone());
task.add("a".into(), "scope-a".into(), CancellationToken::new(), 3);
settle().await;
advance(30).await;
assert_eq!(fake.calls.load(Ordering::SeqCst), 1);
task.refresh();
settle().await;
assert!(task.snapshot().overview.unwrap().available);
task.pause();
task.refresh();
task.pause();
advance(30).await;
assert!(task.snapshot().paused);
assert_eq!(fake.calls.load(Ordering::SeqCst), 2);
task.refresh();
settle().await;
assert!(!task.snapshot().paused);
assert_eq!(fake.calls.load(Ordering::SeqCst), 3);
task.cancel();
}
#[tokio::test(start_paused = true)]
async fn failures_preserve_then_clear_and_can_recover() {
let fail = || Err(Error::Runtime("failure".into()));
let fake = Fake::new(vec![
Ok(overview(true)),
fail(),
fail(),
fail(),
Ok(overview(true)),
]);
let task = MonitorTask::new("ssh-1".into(), fake);
task.add("a".into(), "scope-a".into(), CancellationToken::new(), 3);
settle().await;
let first = task.snapshot().revision;
advance(3).await;
assert!(task.snapshot().error && task.snapshot().overview.is_some());
advance(3).await;
assert!(task.snapshot().overview.is_some());
advance(3).await;
assert!(task.snapshot().overview.is_none());
task.refresh();
settle().await;
assert!(!task.snapshot().error && task.snapshot().overview.is_some());
assert!(task.snapshot().revision > first);
task.cancel();
}
#[tokio::test(start_paused = true)]
async fn revocation_drops_inflight_and_rejects_late_publication() {
let fake = Arc::new(Fake {
delay: Duration::from_secs(5),
..Arc::try_unwrap(Fake::new(vec![])).ok().unwrap()
});
let cancellation = CancellationToken::new();
let task = MonitorTask::new("ssh-1".into(), fake.clone());
task.add("a".into(), "scope-a".into(), cancellation.clone(), 3);
settle().await;
cancellation.cancel();
task.remove("a");
settle().await;
assert_eq!(fake.active.load(Ordering::SeqCst), 0);
advance(30).await;
assert!(task.snapshot().overview.is_none());
}
#[test]
fn rejects_untrusted_schema_and_excessive_results() {
let mut value = overview(true);
value["connectionId"] = json!("other");
assert!(validate_gpu(value).is_err());
let mut value = overview(true);
value["processes"] = json!([{"gpu_uuid":"GPU-a","gpu_index":0,"pid":1,"process_name":"x".repeat(4097),"used_memory_mb":0}]);
assert!(validate_gpu(value).is_err());
assert!(validate_gpu(json!({"available":true})).is_err());
}
#[tokio::test(start_paused = true)]
async fn stopping_backend_cancels_collection_and_new_demands_remain_paused() {
let fake = Arc::new(Fake {
delay: Duration::from_secs(5),
..Arc::try_unwrap(Fake::new(vec![])).ok().unwrap()
});
let task = MonitorTask::new("ssh-1".into(), fake.clone());
task.add("a".into(), "scope-a".into(), CancellationToken::new(), 3);
settle().await;
task.pause();
settle().await;
assert_eq!(fake.active.load(Ordering::SeqCst), 0);
task.add("b".into(), "scope-b".into(), CancellationToken::new(), 3);
advance(60).await;
assert_eq!(fake.calls.load(Ordering::SeqCst), 1);
assert!(task.snapshot().paused && task.snapshot().overview.is_none());
task.refresh();
settle().await;
assert_eq!(fake.calls.load(Ordering::SeqCst), 2);
advance(5).await;
assert!(task.snapshot().overview.is_some());
task.cancel();
}
@@ -0,0 +1,173 @@
use nyaterm_plugin_runtime::{
Result,
manifest::Manifest,
probe::{ProbeExecutor, ProbeOutput, ProbeRequest, execute_declared},
};
use serde_json::json;
use std::{
sync::{
Mutex,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
use tokio_util::sync::CancellationToken;
struct Executor {
scripts: Mutex<Vec<String>>,
mode: u8,
active: AtomicUsize,
}
struct Active<'a>(&'a AtomicUsize);
impl Drop for Active<'_> {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
#[async_trait::async_trait]
impl ProbeExecutor for Executor {
async fn execute(
&self,
script: &str,
_: Duration,
_: CancellationToken,
) -> Result<ProbeOutput> {
self.scripts.lock().unwrap().push(script.into());
self.active.fetch_add(1, Ordering::SeqCst);
let _guard = Active(&self.active);
if self.mode == 2 {
std::future::pending::<()>().await;
}
Ok(ProbeOutput {
stdout: if self.mode == 1 {
"x".repeat(1024 * 1024)
} else {
"metrics".into()
},
stderr: "err".into(),
exit_status: Some(0),
})
}
}
fn fixture() -> (tempfile::TempDir, Manifest) {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("assets/probes")).unwrap();
std::fs::write(root.path().join("assets/probes/gpu.sh"), "printf metrics\n").unwrap();
let manifest = Manifest::parse(&serde_json::to_vec(&json!({
"manifestVersion":1,"id":"test.gpu","name":"Test","version":"1.0.0","publisher":"Test","description":"Test","engine":">=1",
"permissions":["remote.probe"],"contributions":{"probes":[{"id":"gpu","title":"GPU","entry":"assets/probes/gpu.sh","timeoutMs":1000}]}
})).unwrap(), "1.2.12").unwrap();
(root, manifest)
}
#[tokio::test]
async fn runs_only_the_declared_script_and_bounds_combined_output() {
let (root, manifest) = fixture();
let executor = Executor {
scripts: Mutex::new(vec![]),
mode: 0,
active: AtomicUsize::new(0),
};
let request = || ProbeRequest {
probe_id: "gpu".into(),
};
let result = execute_declared(
&executor,
root.path(),
&manifest,
request(),
CancellationToken::new(),
)
.await
.unwrap();
assert_eq!(result.exit_status, Some(0));
assert_eq!(*executor.scripts.lock().unwrap(), vec!["printf metrics\n"]);
assert!(
execute_declared(
&executor,
root.path(),
&manifest,
ProbeRequest {
probe_id: "other".into()
},
CancellationToken::new()
)
.await
.is_err()
);
assert_eq!(executor.scripts.lock().unwrap().len(), 1);
let huge = Executor {
mode: 1,
..executor
};
assert!(
execute_declared(
&huge,
root.path(),
&manifest,
request(),
CancellationToken::new()
)
.await
.is_err()
);
}
#[tokio::test(start_paused = true)]
async fn timeout_and_revocation_drop_only_the_execution_future() {
let (root, manifest) = fixture();
let executor = Executor {
scripts: Mutex::new(vec![]),
mode: 2,
active: AtomicUsize::new(0),
};
assert!(
execute_declared(
&executor,
root.path(),
&manifest,
ProbeRequest {
probe_id: "gpu".into()
},
CancellationToken::new()
)
.await
.unwrap_err()
.to_string()
.contains("timed out")
);
assert_eq!(executor.active.load(Ordering::SeqCst), 0);
let cancellation = CancellationToken::new();
let operation = execute_declared(
&executor,
root.path(),
&manifest,
ProbeRequest {
probe_id: "gpu".into(),
},
cancellation.clone(),
);
tokio::pin!(operation);
tokio::select! {
result = &mut operation => panic!("Cancelled before the test revoked execution: {result:?}"),
() = async { tokio::task::yield_now().await; assert_eq!(executor.active.load(Ordering::SeqCst), 1); cancellation.cancel(); } => {},
}
assert!(operation.await.is_err());
assert_eq!(executor.active.load(Ordering::SeqCst), 0);
let token = CancellationToken::new();
token.cancel();
assert!(
execute_declared(
&executor,
root.path(),
&manifest,
ProbeRequest {
probe_id: "gpu".into()
},
token
)
.await
.is_err()
);
assert_eq!(executor.active.load(Ordering::SeqCst), 0);
}
+50
View File
@@ -160,3 +160,53 @@ pub async fn stop_plugin_backend(
) -> AppResult<()> {
manager.stop_backend(&app, &plugin_id).await
}
#[tauri::command]
pub async fn subscribe_plugin_monitor(
app: tauri::AppHandle,
window: tauri::WebviewWindow,
manager: tauri::State<'_, Arc<PluginManager>>,
token: String,
monitor_id: String,
interval_seconds: u64,
) -> AppResult<crate::core::plugins::MonitorSubscription> {
manager
.inner()
.subscribe_monitor(&app, window.label(), &token, &monitor_id, interval_seconds)
.await
}
#[tauri::command]
pub fn unsubscribe_plugin_monitor(
window: tauri::WebviewWindow,
manager: tauri::State<'_, Arc<PluginManager>>,
token: String,
subscription_id: String,
) -> AppResult<()> {
manager.unsubscribe_monitor(window.label(), &token, &subscription_id)
}
#[tauri::command]
pub async fn refresh_plugin_monitor(
app: tauri::AppHandle,
window: tauri::WebviewWindow,
manager: tauri::State<'_, Arc<PluginManager>>,
token: String,
subscription_id: String,
) -> AppResult<()> {
manager
.refresh_monitor(&app, window.label(), &token, &subscription_id)
.await
}
#[tauri::command]
pub async fn get_plugin_probe_scripts(
app: tauri::AppHandle,
manager: tauri::State<'_, Arc<PluginManager>>,
plugin_id: String,
expected_version: String,
) -> AppResult<std::collections::HashMap<String, String>> {
manager
.probe_scripts(&app, &plugin_id, &expected_version)
.await
}
+1 -329
View File
@@ -1,40 +1,4 @@
use serde::Serialize;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Default, PartialEq)]
pub struct RemoteGpuOverview {
pub available: bool,
pub driver_version: String,
pub cuda_version: String,
pub gpus: Vec<RemoteGpu>,
pub processes: Vec<RemoteGpuProcess>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct RemoteGpu {
pub index: u32,
pub uuid: String,
pub name: String,
pub temperature_c: Option<f64>,
pub utilization_gpu_percent: Option<f64>,
pub utilization_memory_percent: Option<f64>,
pub memory_total_mb: u64,
pub memory_used_mb: u64,
pub memory_free_mb: u64,
pub power_draw_w: Option<f64>,
pub power_limit_w: Option<f64>,
pub fan_speed_percent: Option<f64>,
pub pstate: String,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct RemoteGpuProcess {
pub gpu_uuid: String,
pub gpu_index: Option<u32>,
pub pid: u32,
pub process_name: String,
pub used_memory_mb: u64,
}
pub use nyaterm_gpu::{RemoteGpuOverview, parse_gpu_overview_output};
pub const GPU_OVERVIEW_SCRIPT: &str = r#"sh -s <<'NYATERM_GPU_SCRIPT'
LC_ALL=C
@@ -73,295 +37,3 @@ fi
printf "GPU_PROCESS_CSV_END\n"
NYATERM_GPU_SCRIPT
"#;
enum GpuParseSection {
None,
Gpus,
Processes,
}
pub fn parse_gpu_overview_output(output: &str) -> RemoteGpuOverview {
let mut overview = RemoteGpuOverview::default();
let mut section = GpuParseSection::None;
let mut process_rows = Vec::new();
for line in output.lines() {
match line {
"GPU_CSV_BEGIN" => {
section = GpuParseSection::Gpus;
continue;
}
"GPU_CSV_END" => {
section = GpuParseSection::None;
continue;
}
"GPU_PROCESS_CSV_BEGIN" => {
section = GpuParseSection::Processes;
continue;
}
"GPU_PROCESS_CSV_END" => {
section = GpuParseSection::None;
continue;
}
_ => {}
}
let cols: Vec<&str> = line.split('\t').collect();
if cols.first() == Some(&"GPU_AVAILABLE") && cols.len() >= 2 {
overview.available = cols[1] == "1";
continue;
}
if cols.first() == Some(&"GPU_CUDA_VERSION") && cols.len() >= 2 {
overview.cuda_version = cols[1].trim().to_string();
continue;
}
match section {
GpuParseSection::Gpus => {
if let Some(gpu) = parse_gpu_csv_line(line) {
if overview.driver_version.is_empty() {
overview.driver_version = gpu.1;
}
overview.gpus.push(gpu.0);
}
}
GpuParseSection::Processes => {
if !line.trim().is_empty() {
process_rows.push(line.to_string());
}
}
GpuParseSection::None => {}
}
}
let gpu_indexes: HashMap<String, u32> = overview
.gpus
.iter()
.map(|gpu| (gpu.uuid.clone(), gpu.index))
.collect();
overview.processes = process_rows
.iter()
.filter_map(|line| parse_gpu_process_csv_line(line, &gpu_indexes))
.collect();
overview
}
fn parse_gpu_csv_line(line: &str) -> Option<(RemoteGpu, String)> {
let cols = parse_csv_line(line);
if cols.len() < 14 {
return None;
}
let gpu = RemoteGpu {
index: parse_u32(&cols[0]).unwrap_or(0),
uuid: cols[1].trim().to_string(),
name: cols[2].trim().to_string(),
temperature_c: parse_optional_f64(&cols[4]),
utilization_gpu_percent: parse_optional_f64(&cols[5]),
utilization_memory_percent: parse_optional_f64(&cols[6]),
memory_total_mb: parse_u64(&cols[7]).unwrap_or(0),
memory_used_mb: parse_u64(&cols[8]).unwrap_or(0),
memory_free_mb: parse_u64(&cols[9]).unwrap_or(0),
power_draw_w: parse_optional_f64(&cols[10]),
power_limit_w: parse_optional_f64(&cols[11]),
fan_speed_percent: parse_optional_f64(&cols[12]),
pstate: clean_text(&cols[13]),
};
Some((gpu, clean_text(&cols[3])))
}
fn parse_gpu_process_csv_line(
line: &str,
gpu_indexes: &HashMap<String, u32>,
) -> Option<RemoteGpuProcess> {
let cols = parse_csv_line(line);
if cols.len() < 4 {
return None;
}
let gpu_uuid = clean_text(&cols[0]);
if gpu_uuid.is_empty() || is_missing(&gpu_uuid) {
return None;
}
Some(RemoteGpuProcess {
gpu_index: gpu_indexes.get(&gpu_uuid).copied(),
gpu_uuid,
pid: parse_u32(&cols[1]).unwrap_or(0),
used_memory_mb: parse_u64(&cols[2]).unwrap_or(0),
process_name: cols[3..].join(",").trim().to_string(),
})
}
fn parse_csv_line(line: &str) -> Vec<String> {
let chars: Vec<char> = line.chars().collect();
let mut values = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
let mut index = 0;
while index < chars.len() {
let ch = chars[index];
match ch {
'"' if in_quotes && chars.get(index + 1) == Some(&'"') => {
current.push('"');
index += 1;
}
'"' => {
in_quotes = !in_quotes;
}
',' if !in_quotes => {
values.push(current.trim().to_string());
current.clear();
}
_ => current.push(ch),
}
index += 1;
}
values.push(current.trim().to_string());
values
}
fn clean_text(value: &str) -> String {
value.trim().to_string()
}
fn is_missing(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
matches!(
normalized.as_str(),
"" | "n/a" | "na" | "[n/a]" | "[not supported]" | "not supported"
)
}
fn parse_optional_f64(value: &str) -> Option<f64> {
if is_missing(value) {
return None;
}
numeric_prefix(value).and_then(|value| value.parse().ok())
}
fn parse_u64(value: &str) -> Option<u64> {
if is_missing(value) {
return None;
}
numeric_prefix(value).and_then(|value| value.parse::<f64>().ok().map(|number| number as u64))
}
fn parse_u32(value: &str) -> Option<u32> {
parse_u64(value).and_then(|value| u32::try_from(value).ok())
}
fn numeric_prefix(value: &str) -> Option<&str> {
let trimmed = value.trim().trim_end_matches('%').trim();
let end = trimmed
.char_indices()
.take_while(|(_, ch)| ch.is_ascii_digit() || *ch == '.' || *ch == '-')
.map(|(index, ch)| index + ch.len_utf8())
.last()
.unwrap_or(0);
(end > 0).then_some(&trimmed[..end])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_unavailable_gpu_output() {
let overview = parse_gpu_overview_output("GPU_AVAILABLE\t0\n");
assert!(!overview.available);
assert!(overview.gpus.is_empty());
assert!(overview.processes.is_empty());
}
#[test]
fn parses_single_gpu_with_metrics() {
let raw = concat!(
"GPU_AVAILABLE\t1\n",
"GPU_CUDA_VERSION\t12.4\n",
"GPU_CSV_BEGIN\n",
"0, GPU-abc, NVIDIA RTX 4090, 550.54.14, 43, 77, 31, 24564, 12345, 12219, 180.5, 450.0, 46, P2\n",
"GPU_CSV_END\n",
"GPU_PROCESS_CSV_BEGIN\n",
"GPU_PROCESS_CSV_END\n",
);
let overview = parse_gpu_overview_output(raw);
assert!(overview.available);
assert_eq!(overview.driver_version, "550.54.14");
assert_eq!(overview.cuda_version, "12.4");
assert_eq!(overview.gpus[0].name, "NVIDIA RTX 4090");
assert_eq!(overview.gpus[0].utilization_gpu_percent, Some(77.0));
assert_eq!(overview.gpus[0].memory_used_mb, 12345);
assert_eq!(overview.gpus[0].power_draw_w, Some(180.5));
}
#[test]
fn parses_multiple_gpus_and_missing_optional_metrics() {
let raw = concat!(
"GPU_AVAILABLE\t1\n",
"GPU_CUDA_VERSION\t12.2\n",
"GPU_CSV_BEGIN\n",
"0, GPU-a, NVIDIA A100, 535.1, N/A, 0, 0, 40536, 0, 40536, [Not Supported], 400, N/A, P0\n",
"1, GPU-b, NVIDIA T4, 535.1, 55, 99, 80, 15360, 15000, 360, 67.2, 70.0, [Not Supported], P0\n",
"GPU_CSV_END\n",
"GPU_PROCESS_CSV_BEGIN\n",
"GPU_PROCESS_CSV_END\n",
);
let overview = parse_gpu_overview_output(raw);
assert_eq!(overview.gpus.len(), 2);
assert_eq!(overview.gpus[0].temperature_c, None);
assert_eq!(overview.gpus[0].power_draw_w, None);
assert_eq!(overview.gpus[0].fan_speed_percent, None);
assert_eq!(overview.gpus[1].fan_speed_percent, None);
}
#[test]
fn maps_gpu_processes_to_indexes() {
let raw = concat!(
"GPU_AVAILABLE\t1\n",
"GPU_CUDA_VERSION\t12.2\n",
"GPU_CSV_BEGIN\n",
"0, GPU-a, NVIDIA A100, 535.1, 40, 30, 20, 40536, 1000, 39536, 80, 400, 20, P0\n",
"1, GPU-b, NVIDIA T4, 535.1, 44, 10, 9, 15360, 512, 14848, 35, 70, 30, P8\n",
"GPU_CSV_END\n",
"GPU_PROCESS_CSV_BEGIN\n",
"GPU-b, 4242, 2048, python\n",
"GPU_PROCESS_CSV_END\n",
);
let overview = parse_gpu_overview_output(raw);
assert_eq!(overview.processes.len(), 1);
assert_eq!(overview.processes[0].gpu_index, Some(1));
assert_eq!(overview.processes[0].pid, 4242);
assert_eq!(overview.processes[0].used_memory_mb, 2048);
}
#[test]
fn keeps_commas_inside_quoted_process_names() {
let raw = concat!(
"GPU_AVAILABLE\t1\n",
"GPU_CUDA_VERSION\t12.2\n",
"GPU_CSV_BEGIN\n",
"0, GPU-a, NVIDIA A100, 535.1, 40, 30, 20, 40536, 1000, 39536, 80, 400, 20, P0\n",
"GPU_CSV_END\n",
"GPU_PROCESS_CSV_BEGIN\n",
"GPU-a, 100, 512, \"python, worker\"\n",
"GPU_PROCESS_CSV_END\n",
);
let overview = parse_gpu_overview_output(raw);
assert_eq!(overview.processes[0].process_name, "python, worker");
}
}
+28
View File
@@ -67,6 +67,7 @@ impl PluginManager {
"host/filesystem/read" => "filesystem.read",
"host/storage/get" | "host/storage/set" => "storage",
"host/network/request" => "",
"host/remote/probe" => "remote.probe",
_ => return Err(AppError::Config("Unknown plugin host capability".into())),
};
if !permission.is_empty() {
@@ -75,6 +76,33 @@ impl PluginManager {
let operation = async {
let manager = app.state::<Arc<SessionManager>>().inner().clone();
match method {
"host/remote/probe" => {
let request: nyaterm_plugin_runtime::probe::ProbeRequest =
serde_json::from_value(params.clone())?;
let root = self
.registry
.read()
.await
.as_ref()
.ok_or_else(|| self.registry_error())?
.active_path(&scope.plugin_id)
.map_err(plugin_error)?;
self.scope(app, token, caller_window).await?;
let executor = super::probe::SshProbeExecutor {
sessions: manager,
session_id: session_id(&scope)?.into(),
};
let result = nyaterm_plugin_runtime::probe::execute_declared(
&executor,
&root,
&plugin.active().map_err(plugin_error)?.manifest,
request,
scope.cancellation.child_token(),
)
.await
.map_err(plugin_error)?;
serde_json::to_value(result).map_err(Into::into)
}
"host/session" => match &scope.session_id {
Some(id) => {
let info = require_session(app, &scope.window_label, id).await?;
+47 -2
View File
@@ -1,5 +1,8 @@
mod gateway;
mod lifecycle;
mod monitoring;
pub use monitoring::MonitorSubscription;
mod probe;
mod protocol;
use crate::cmd::app::AppLockState;
@@ -97,6 +100,7 @@ pub struct PluginManager {
backends: Mutex<HashMap<String, BackendSlot>>,
storage_lock: Mutex<()>,
diagnostics: Diagnostics,
monitors: std::sync::Mutex<monitoring::Monitors>,
}
impl PluginManager {
@@ -116,6 +120,7 @@ impl PluginManager {
backends: Mutex::new(HashMap::new()),
storage_lock: Mutex::new(()),
diagnostics: Diagnostics::default(),
monitors: std::sync::Mutex::new(monitoring::Monitors::default()),
})
}
@@ -192,7 +197,6 @@ impl PluginManager {
"Plugin version changed; review its permissions again".into(),
));
}
self.revoke_plugin(id).await;
self.registry
.write()
.await
@@ -200,6 +204,7 @@ impl PluginManager {
.ok_or_else(|| self.registry_error())?
.configure(id, enabled, permissions)
.map_err(plugin_error)?;
self.revoke_plugin(id).await;
let _ = app.emit("plugins-changed", ());
Ok(())
}
@@ -310,10 +315,12 @@ impl PluginManager {
if let Some(scope) = scopes.remove(token) {
scope.cancellation.cancel();
}
self.monitors.lock().unwrap().remove_scope(token);
Ok(())
}
pub async fn revoke_window(&self, label: &str) {
self.monitors.lock().unwrap().revoke(|k| k.window == label);
self.scopes.lock().await.retain(|_, scope| {
if scope.window_label == label {
scope.cancellation.cancel();
@@ -329,6 +336,10 @@ impl PluginManager {
}
pub async fn revoke_session(&self, session_id: &str) {
self.monitors
.lock()
.unwrap()
.revoke(|k| k.session == session_id);
self.scopes.lock().await.retain(|_, scope| {
if scope.session_id.as_deref() == Some(session_id) {
scope.cancellation.cancel();
@@ -340,6 +351,7 @@ impl PluginManager {
}
pub async fn revoke_all(&self) {
self.monitors.lock().unwrap().revoke(|_| true);
for (_, scope) in self.scopes.lock().await.drain() {
scope.cancellation.cancel();
}
@@ -353,6 +365,7 @@ impl PluginManager {
}
async fn revoke_plugin(&self, id: &str) {
self.monitors.lock().unwrap().reset_plugin(id);
self.diagnostics.stop(id);
self.scopes.lock().await.retain(|_, scope| {
if scope.plugin_id == id {
@@ -444,12 +457,34 @@ impl PluginManager {
token: &str,
method: &str,
params: Value,
) -> AppResult<Value> {
self.backend_request(app, window_label, token, method, params, false)
.await
}
async fn backend_request(
self: &Arc<Self>,
app: &tauri::AppHandle,
window_label: &str,
token: &str,
method: &str,
params: Value,
monitor_request: bool,
) -> AppResult<Value> {
let scope = self.scope(app, token, Some(window_label)).await?;
let _lease = self.lifecycle.acquire(&scope.plugin_id, false)?;
let plugin = self.plugin(&scope.plugin_id).await?;
plugin.require("native").map_err(plugin_error)?;
if !(method.starts_with("ui/")
if !((monitor_request
&& plugin
.active()
.map_err(plugin_error)?
.manifest
.contributions
.monitors
.iter()
.any(|m| m.method == method))
|| method.starts_with("ui/")
|| plugin
.active()
.map_err(plugin_error)?
@@ -622,6 +657,16 @@ impl PluginManager {
ensure_unlocked(app)?;
let _lease = self.lifecycle.acquire(id, true)?;
self.plugin(id).await?;
self.monitors.lock().unwrap().pause_plugin(id);
if let Some(ticket) = self.diagnostics.ticket(id) {
self.diagnostics.update(
&ticket,
None,
"info",
"monitor",
"Monitor collection paused",
);
}
self.diagnostics.stop(id);
if let Some(backend) = self.backends.lock().await.remove(id)
&& let Some(sidecar) = backend.lock().await.take()
+320
View File
@@ -0,0 +1,320 @@
use super::{PluginManager, plugin_error, require_session};
use crate::error::{AppError, AppResult};
use nyaterm_plugin_runtime::monitoring::{Collector, MonitorKey, MonitorSnapshot, MonitorTask};
use serde::Serialize;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Weak};
use std::time::Instant;
use tauri::{Emitter, Manager};
use tokio_util::sync::CancellationToken;
#[derive(Default)]
pub(super) struct Monitors {
tasks: HashMap<MonitorKey, Arc<MonitorTask>>,
subscriptions: HashMap<String, Subscription>,
paused_plugins: HashSet<String>,
}
struct Subscription {
token: String,
key: MonitorKey,
task: Arc<MonitorTask>,
cancellation: CancellationToken,
}
impl Monitors {
pub fn remove_scope(&mut self, token: &str) {
let ids = self
.subscriptions
.iter()
.filter(|(_, s)| s.token == token)
.map(|(id, _)| id.clone())
.collect::<Vec<_>>();
for id in ids {
self.remove(&id);
}
}
pub fn remove(&mut self, id: &str) {
if let Some(s) = self.subscriptions.remove(id) {
s.cancellation.cancel();
s.task.remove(id);
}
self.tasks.retain(|_, t| !t.is_stopped());
}
pub fn revoke(&mut self, matches: impl Fn(&MonitorKey) -> bool) {
let ids = self
.subscriptions
.iter()
.filter(|(_, s)| matches(&s.key))
.map(|(id, _)| id.clone())
.collect::<Vec<_>>();
for id in ids {
self.remove(&id);
}
}
pub fn reset_plugin(&mut self, id: &str) {
self.revoke(|k| k.plugin_id == id);
self.paused_plugins.remove(id);
}
pub fn pause_plugin(&mut self, id: &str) {
self.paused_plugins.insert(id.into());
for (key, task) in &self.tasks {
if key.plugin_id == id {
task.pause();
}
}
}
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MonitorSubscription {
pub subscription_id: String,
pub snapshot: MonitorSnapshot,
}
struct PluginCollector {
manager: Weak<PluginManager>,
app: tauri::AppHandle,
method: String,
plugin_id: String,
}
#[async_trait::async_trait]
impl Collector for PluginCollector {
fn status(&self, message: &str) {
if let Some(host) = self.manager.upgrade()
&& let Some(ticket) = host.diagnostics.ticket(&self.plugin_id)
{
host.diagnostics
.update(&ticket, None, "info", "monitor", message);
}
}
async fn collect(&self, scope: &str) -> nyaterm_plugin_runtime::Result<Value> {
let manager = self
.manager
.upgrade()
.ok_or_else(|| nyaterm_plugin_runtime::Error::Runtime("Plugin host stopped".into()))?;
let owned = manager
.scope(&self.app, scope, None)
.await
.map_err(|e| nyaterm_plugin_runtime::Error::Runtime(e.to_string()))?;
manager
.backend_request(
&self.app,
&owned.window_label,
scope,
&self.method,
Value::Null,
true,
)
.await
.map_err(|e| nyaterm_plugin_runtime::Error::Runtime(e.to_string()))
}
}
impl PluginManager {
pub async fn subscribe_monitor(
self: &Arc<Self>,
app: &tauri::AppHandle,
window: &str,
token: &str,
monitor_id: &str,
interval_seconds: u64,
) -> AppResult<MonitorSubscription> {
let scope = self.scope(app, token, Some(window)).await?;
let _lease = self.lifecycle.acquire(&scope.plugin_id, false)?;
let plugin = self.plugin(&scope.plugin_id).await?;
plugin.require("native").map_err(plugin_error)?;
plugin.require("remote.probe").map_err(plugin_error)?;
let session = scope.session_id.as_ref().ok_or_else(|| {
AppError::Config("Select an active SSH terminal for monitoring".into())
})?;
let info = require_session(app, window, session).await?;
if info.session_type != crate::core::SessionType::SSH
|| !info.remote_stats_enabled
|| info.ssh_profile == Some(crate::config::SshProfile::NetworkDevice)
{
return Err(AppError::Config(
"Remote probes are unavailable for this session".into(),
));
}
let monitor = plugin
.active()
.map_err(plugin_error)?
.manifest
.contributions
.monitors
.iter()
.find(|m| m.id == monitor_id)
.ok_or_else(|| AppError::Config("Unknown monitor".into()))?;
let key = MonitorKey {
plugin_id: scope.plugin_id.clone(),
version: scope.version.clone(),
window: window.into(),
session: session.clone(),
monitor_id: monitor_id.into(),
};
let id = uuid::Uuid::new_v4().to_string();
let cancellation = scope.cancellation.child_token();
let task = {
let mut monitors = self.monitors.lock().unwrap();
monitors.tasks.retain(|_, t| !t.is_stopped());
if monitors.subscriptions.len() >= 256 || monitors.tasks.len() >= 128 {
return Err(AppError::Config("Too many monitor subscriptions".into()));
}
let paused = monitors.paused_plugins.contains(&scope.plugin_id);
let task = monitors
.tasks
.entry(key.clone())
.or_insert_with(|| {
let task = MonitorTask::new(
session.clone(),
Arc::new(PluginCollector {
manager: Arc::downgrade(self),
app: app.clone(),
method: monitor.method.clone(),
plugin_id: scope.plugin_id.clone(),
}),
);
if paused {
task.pause();
}
task
})
.clone();
monitors.subscriptions.insert(
id.clone(),
Subscription {
token: token.into(),
key,
task: task.clone(),
cancellation: cancellation.clone(),
},
);
task.add(
id.clone(),
token.into(),
cancellation.clone(),
interval_seconds,
);
task
};
if let Err(error) = self.scope(app, token, Some(window)).await {
self.monitors.lock().unwrap().remove(&id);
return Err(error);
}
let mut updates = task.watch();
let manager = Arc::downgrade(self);
let app = app.clone();
let label = window.to_owned();
let sub_id = id.clone();
let scope_token = token.to_owned();
let expiry = scope.expires.saturating_duration_since(Instant::now());
tokio::spawn(async move {
let deadline = tokio::time::sleep(expiry);
tokio::pin!(deadline);
loop {
tokio::select! {
() = cancellation.cancelled() => break,
() = &mut deadline => break,
changed = updates.changed() => {
if changed.is_err() { break; }
let Some(host) = manager.upgrade() else { break; };
if host.scope(&app, &scope_token, Some(&label)).await.is_err() { break; }
let snapshot = updates.borrow_and_update().clone();
if let Some(window) = app.get_webview_window(&label) {
let _ = window.emit("plugin-monitor-updated", MonitorSubscription {
subscription_id: sub_id.clone(), snapshot,
});
}
}
}
}
if let Some(host) = manager.upgrade() {
host.monitors.lock().unwrap().remove(&sub_id);
}
});
Ok(MonitorSubscription {
subscription_id: id,
snapshot: task.snapshot(),
})
}
pub fn unsubscribe_monitor(&self, window: &str, token: &str, id: &str) -> AppResult<()> {
let mut monitors = self.monitors.lock().unwrap();
if monitors
.subscriptions
.get(id)
.is_some_and(|s| s.token != token || s.key.window != window)
{
return Err(AppError::Config(
"Monitor subscription belongs to another scope".into(),
));
}
monitors.remove(id);
Ok(())
}
pub async fn refresh_monitor(
&self,
app: &tauri::AppHandle,
window: &str,
token: &str,
id: &str,
) -> AppResult<()> {
let scope = self.scope(app, token, Some(window)).await?;
let mut monitors = self.monitors.lock().unwrap();
let subscription = monitors
.subscriptions
.get(id)
.filter(|s| s.token == token && s.key.window == window)
.ok_or_else(|| AppError::Config("Monitor subscription is unavailable".into()))?;
let task = subscription.task.clone();
monitors.paused_plugins.remove(&scope.plugin_id);
task.refresh();
Ok(())
}
pub async fn probe_scripts(
&self,
app: &tauri::AppHandle,
id: &str,
version: &str,
) -> AppResult<HashMap<String, String>> {
super::ensure_unlocked(app)?;
let _lease = self.lifecycle.acquire(id, false)?;
let plugin = self.plugin(id).await?;
if plugin.active_version != version {
return Err(AppError::Config(
"Plugin version changed; review again".into(),
));
}
let root = self
.registry
.read()
.await
.as_ref()
.ok_or_else(|| self.registry_error())?
.active_path(id)
.map_err(plugin_error)?;
plugin
.active()
.map_err(plugin_error)?
.manifest
.contributions
.probes
.iter()
.map(|p| {
nyaterm_plugin_runtime::probe::read_script(&root, p)
.map(|s| (p.id.clone(), s))
.map_err(plugin_error)
})
.collect()
}
}
+33
View File
@@ -0,0 +1,33 @@
use crate::core::SessionManager;
use nyaterm_plugin_runtime::{
Result,
probe::{ProbeExecutor, ProbeOutput},
};
use std::sync::Arc;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
pub(super) struct SshProbeExecutor {
pub sessions: Arc<SessionManager>,
pub session_id: String,
}
#[async_trait::async_trait]
impl ProbeExecutor for SshProbeExecutor {
async fn execute(
&self,
script: &str,
timeout: Duration,
cancellation: CancellationToken,
) -> Result<ProbeOutput> {
crate::core::remote_exec::exec_ssh_session_probe(
&self.sessions,
&self.session_id,
script.as_bytes(),
timeout,
cancellation,
)
.await
.map_err(|e| nyaterm_plugin_runtime::Error::Runtime(e.to_string()))
}
}
+94
View File
@@ -5,6 +5,100 @@ use russh::ChannelMsg;
use serde::Serialize;
use std::sync::Arc;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
/// A private exec channel, never `SessionCommand::Write` or the user's PTY.
/// The guard closes the channel even when its enclosing RPC future is dropped.
struct ProbeChannel(Option<russh::Channel<russh::client::Msg>>);
impl Drop for ProbeChannel {
fn drop(&mut self) {
if let Some(channel) = self.0.take() {
tokio::spawn(async move {
let _ = tokio::time::timeout(Duration::from_secs(2), channel.close()).await;
});
}
}
}
pub async fn exec_ssh_session_probe(
manager: &Arc<SessionManager>,
session_id: &str,
script: &[u8],
timeout: Duration,
cancellation: CancellationToken,
) -> AppResult<nyaterm_plugin_runtime::probe::ProbeOutput> {
ensure_remote_exec_enabled(manager, session_id).await?;
let info = manager.session_info(session_id).await?;
if info.ssh_profile == Some(crate::config::SshProfile::NetworkDevice) {
return Err(AppError::Config(
"Remote probes are disabled for network devices".into(),
));
}
let ssh_handle = get_ssh_handle(manager, session_id).await?;
let operation = async {
let handle_mtx = ssh_handle.target_handle();
let channel = handle_mtx
.lock()
.await
.channel_open_session()
.await
.map_err(|e| AppError::Channel(e.to_string()))?;
let mut guard = ProbeChannel(Some(channel));
let channel = guard.0.as_mut().unwrap();
channel
.exec(true, b"sh -s".as_slice())
.await
.map_err(|e| AppError::Channel(e.to_string()))?;
channel
.data(script)
.await
.map_err(|e| AppError::Channel(e.to_string()))?;
channel
.eof()
.await
.map_err(|e| AppError::Channel(e.to_string()))?;
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut exit_status = None;
while let Some(message) = channel.wait().await {
match message {
ChannelMsg::Data { data } => {
if stdout.len() + stderr.len() + data.len() > 1024 * 1024 {
return Err(AppError::Config("Probe output exceeds 1 MiB".into()));
}
stdout.extend_from_slice(&data);
}
ChannelMsg::ExtendedData { data, .. } => {
if stdout.len() + stderr.len() + data.len() > 1024 * 1024 {
return Err(AppError::Config("Probe output exceeds 1 MiB".into()));
}
stderr.extend_from_slice(&data);
}
ChannelMsg::ExitStatus {
exit_status: status,
} => exit_status = Some(status),
ChannelMsg::Close => break,
_ => {}
}
if stdout.len() + stderr.len() > 1024 * 1024 {
return Err(AppError::Config("Probe output exceeds 1 MiB".into()));
}
}
Ok(nyaterm_plugin_runtime::probe::ProbeOutput {
stdout: String::from_utf8(stdout)
.map_err(|_| AppError::Config("Probe stdout must be UTF-8".into()))?,
stderr: String::from_utf8(stderr)
.map_err(|_| AppError::Config("Probe stderr must be UTF-8".into()))?,
exit_status,
})
};
tokio::select! {
() = cancellation.cancelled() => Err(AppError::Cancelled("Probe cancelled".into())),
result = tokio::time::timeout(timeout, operation) =>
result.map_err(|_| AppError::Cancelled("Probe timed out".into()))?,
}
}
#[derive(Debug, Clone, Serialize)]
pub struct RemoteCommandOutput {
+4
View File
@@ -156,6 +156,10 @@ pub fn run() {
},
)
.invoke_handler(tauri::generate_handler![
cmd::plugins::subscribe_plugin_monitor,
cmd::plugins::unsubscribe_plugin_monitor,
cmd::plugins::refresh_plugin_monitor,
cmd::plugins::get_plugin_probe_scripts,
cmd::plugins::get_plugin_diagnostics,
cmd::plugins::clear_plugin_logs,
cmd::plugins::stop_plugin_backend,