app: GPU hot-plug (re-detection every minute, WM_DEVICECHANGE on Windows), faulty cards listed with the problem code, integrated GPUs off by default, the card list in the console
5 October 2026: an RX 9070 XT went into PC 1 through a Sonnet USB4 box while the app ran and nothing noticed; the app detected cards once at start. Now src/hotplug.rs compares every enumeration with the list (key, else vendor + name when unique; a card whose tool did not answer is never called removed): a new usable card starts a worker, enabled by default like a card at start, with "New card: <name>, mining" on the strip and in the log; a card Windows lists with a problem code (Win32_VideoController Status / ConfigManagerErrorCode) is shown as "<name>: not usable (Code 43)" with the reboot-or-reinstall hint and no worker; a card that disappears has its worker stopped (quit, 8 s) and its row says removed for five minutes, then hides; an unchanged list touches nothing. The Windows host sends "detect" on WM_DEVICECHANGE; the engine polls every 60 s (300 s on macOS, no GPU hot-plug there). detect.rs: the Ryzen iGPU is "gfx1036" to the OpenCL worker, so the APU gfx codes count as integrated, plus the adapter row's Intel processor string and a dedicated memory under 1 GB; integrated defaults to off with "integrated GPU, off by default (2 to 3 MH/s for 30 W)" on the row, and the user's choice is kept across re-detections and restarts (settings, found by key or by vendor + name when the index moved). Console: the engine logs "cards: <name> [<kind>, <state>] | ..." at start, on every change and every 10 minutes; relay/lib/parse.mjs reads it and the hot-plug events, the machines API and tools/console.mjs machines show them. Tests: hotplug.rs (added, removed, moved, errored, recovered, revived, unchanged, twins, user override kept, the console line), detect.rs (PC 1's adapter lines, the Mac, kind classification, the unusable row), notices.test.mjs (card notices), relay parse.test.mjs (cards line). cargo test -p igneum-app: 91 passed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
7d09857f91
commit
d7049fdfda
14 changed files with 1133 additions and 133 deletions
|
|
@ -1,6 +1,8 @@
|
|||
//! GPU detection with the real names. macOS: the Metal worker's ready line (the device Metal reports) plus the core
|
||||
//! count from system_profiler. Windows: nvidia-smi for NVIDIA cards, the OpenCL worker's --list for the rest
|
||||
//! (AMD, Intel), and the WMI name list as a last resort when neither tool runs.
|
||||
//! (AMD, Intel), the Windows adapter list (Win32_VideoController: status, problem code, memory) for the cards no
|
||||
//! worker can drive and for the integrated-or-discrete call, and that list's names as a last resort when neither
|
||||
//! tool runs. The engine runs this at start and again every minute (src/hotplug.rs compares the two lists).
|
||||
|
||||
use crate::state::CardState;
|
||||
use std::io::Write;
|
||||
|
|
@ -18,6 +20,36 @@ pub struct Bins {
|
|||
pub dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
/// One enumeration: the cards, the notes for the setup screen, and which tools answered. A tool that did not answer
|
||||
/// (nvidia-smi timed out, the OpenCL worker crashed) says nothing about its cards: the engine keeps them rather
|
||||
/// than calling them removed (src/hotplug.rs).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Detection {
|
||||
pub cards: Vec<CardState>,
|
||||
pub notes: Vec<String>,
|
||||
pub nvidia_listed: bool,
|
||||
pub opencl_listed: bool,
|
||||
pub adapters_listed: bool,
|
||||
pub metal_listed: bool,
|
||||
}
|
||||
|
||||
impl Detection {
|
||||
/// Whether this enumeration can say that `c` is gone: the tool that lists its vendor answered.
|
||||
pub fn listed(&self, c: &CardState) -> bool {
|
||||
if !c.problem.is_empty() {
|
||||
return self.adapters_listed;
|
||||
}
|
||||
match c.vendor.as_str() {
|
||||
"apple" => self.metal_listed,
|
||||
"nvidia" => self.nvidia_listed,
|
||||
_ => self.opencl_listed || (c.device.is_empty() && self.adapters_listed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The hint on a card the OS reports as faulty (Windows Code 43, 12, 31 and friends).
|
||||
pub const PROBLEM_HINT: &str = "reboot with the card attached; if it persists, reinstall the driver with the card attached";
|
||||
|
||||
/// Runs a command with a time limit; returns stdout (and stderr appended) or None.
|
||||
pub fn run_timeout(cmd: &mut Command, stdin_text: Option<&str>, limit: Duration) -> Option<String> {
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
|
@ -68,14 +100,125 @@ fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, devi
|
|||
}
|
||||
}
|
||||
|
||||
/// Integrated GPUs by name: AMD APUs ("Radeon Graphics", "Vega 8"), Intel iGPUs (Iris, UHD, HD Graphics, Arc A3xx is discrete).
|
||||
#[allow(dead_code)]
|
||||
/// Integrated GPUs by name: AMD APUs ("Radeon Graphics", "Vega 8"), Intel iGPUs (Iris, UHD, HD Graphics, Arc A3xx is
|
||||
/// discrete), and the gfx codes AMD's OpenCL runtime reports instead of a marketing name (the worker's --list prints
|
||||
/// CL_DEVICE_NAME: PC 1's Ryzen iGPU is "gfx1036", 5 October 2026).
|
||||
pub fn looks_integrated(name: &str) -> bool {
|
||||
let n = name.to_ascii_lowercase();
|
||||
let integrated = ["radeon(tm) graphics", "radeon graphics", "vega 8", "vega 7", "vega 6", "vega 3", "vega 11", "iris", "uhd graphics", "hd graphics", "intel(r) graphics", "intel graphics", "apu", "780m", "760m", "680m", "610m", "890m", "880m"];
|
||||
integrated.iter().any(|k| n.contains(k)) && !n.contains("arc ")
|
||||
if integrated.iter().any(|k| n.contains(k)) && !n.contains("arc ") {
|
||||
return true;
|
||||
}
|
||||
// AMD APU graphics by gfx code (approximate list from AMD's ROCm and Mesa target tables): Raven/Picasso gfx902 and
|
||||
// gfx909, Renoir/Cezanne/Lucienne gfx90c, Van Gogh gfx1033, Rembrandt gfx1035, Raphael/Granite Ridge gfx1036,
|
||||
// Mendocino gfx1037, Phoenix gfx1103, Strix gfx1150 to gfx1152. Discrete codes (gfx1030 and so on) are not here.
|
||||
let apu = ["gfx902", "gfx909", "gfx90c", "gfx1033", "gfx1035", "gfx1036", "gfx1037", "gfx1103", "gfx1150", "gfx1151", "gfx1152"];
|
||||
let code = n.trim();
|
||||
apu.iter().any(|k| code == *k || code.starts_with(&format!("{k}:")) || code.starts_with(&format!("{k} ")))
|
||||
}
|
||||
|
||||
/// One row of Windows' adapter list (Win32_VideoController), the part this app reads.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Adapter {
|
||||
pub name: String,
|
||||
/// "OK", "Error", "Degraded", ... (the Status property)
|
||||
pub status: String,
|
||||
/// the PnP problem code (ConfigManagerErrorCode): 0 = fine, 43 = the driver stopped it, 12 = no resources, 31 = not loaded
|
||||
pub code: u32,
|
||||
/// AdapterRAM in MB; 0 = unknown (a faulty card reports 0, and the property caps at 4 GB on 32-bit values)
|
||||
pub ram_mb: u64,
|
||||
pub processor: String,
|
||||
pub pnp_id: String,
|
||||
}
|
||||
|
||||
impl Adapter {
|
||||
/// "Code 43" for a problem code, "status Error" for a bad status without one, None when the device is fine.
|
||||
pub fn problem(&self) -> Option<String> {
|
||||
if self.code != 0 {
|
||||
return Some(format!("Code {}", self.code));
|
||||
}
|
||||
let st = self.status.trim();
|
||||
if !st.is_empty() && !st.eq_ignore_ascii_case("ok") {
|
||||
return Some(format!("status {st}"));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Integrated or discrete, from the name (APU and iGPU names, AMD gfx codes) and, when Windows' adapter row is
|
||||
/// known, its processor string or a dedicated memory under 1 GB (a shared-memory iGPU; 0 = unknown, says nothing).
|
||||
pub fn classify_kind(name: &str, adapter: Option<&Adapter>) -> &'static str {
|
||||
if looks_integrated(name) {
|
||||
return "integrated";
|
||||
}
|
||||
if let Some(a) = adapter {
|
||||
// the processor string names Intel iGPUs ("Intel(R) Iris(R) Xe Graphics Family"); AMD's reads "AMD Radeon
|
||||
// Graphics Processor (0x7550)" for discrete cards too, so only the Intel markers count here
|
||||
let proc_ = a.processor.to_ascii_lowercase();
|
||||
if looks_integrated(&a.name) || (["iris", "uhd graphics", "hd graphics"].iter().any(|k| proc_.contains(k)) && !proc_.contains("arc")) {
|
||||
return "integrated";
|
||||
}
|
||||
if a.ram_mb > 0 && a.ram_mb < 1024 {
|
||||
return "integrated";
|
||||
}
|
||||
}
|
||||
"discrete"
|
||||
}
|
||||
|
||||
pub fn vendor_of(name: &str) -> &'static str {
|
||||
let n = name.to_ascii_lowercase();
|
||||
if n.contains("nvidia") || n.contains("geforce") {
|
||||
"nvidia"
|
||||
} else if n.contains("amd") || n.contains("radeon") || n.starts_with("gfx") {
|
||||
"amd"
|
||||
} else if n.contains("apple") {
|
||||
"apple"
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `Get-CimInstance Win32_VideoController | Select-Object ... | ConvertTo-Json` (one object or an array).
|
||||
pub fn parse_adapters(json: &str) -> Vec<Adapter> {
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(json.trim()) else { return Vec::new() };
|
||||
let rows: Vec<serde_json::Value> = match v {
|
||||
serde_json::Value::Array(a) => a,
|
||||
o @ serde_json::Value::Object(_) => vec![o],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let s = |r: &serde_json::Value, k: &str| r.get(k).and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
|
||||
let n = |r: &serde_json::Value, k: &str| r.get(k).and_then(|x| x.as_u64().or_else(|| x.as_str().and_then(|t| t.trim().parse::<u64>().ok()))).unwrap_or(0);
|
||||
rows.iter()
|
||||
.map(|r| Adapter { name: s(r, "Name"), status: s(r, "Status"), code: n(r, "ConfigManagerErrorCode") as u32, ram_mb: n(r, "AdapterRAM") / (1024 * 1024), processor: s(r, "VideoProcessor"), pnp_id: s(r, "PNPDeviceID") })
|
||||
.filter(|a| !a.name.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Windows' adapter list through PowerShell (about a second); None when PowerShell did not answer.
|
||||
#[cfg(windows)]
|
||||
pub fn adapters() -> Option<Vec<Adapter>> {
|
||||
let script = "Get-CimInstance Win32_VideoController | Select-Object Name,Status,ConfigManagerErrorCode,AdapterRAM,VideoProcessor,PNPDeviceID | ConvertTo-Json -Compress";
|
||||
let out = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", script]), None, Duration::from_secs(15))?;
|
||||
let start = out.find(|c| c == '[' || c == '{')?;
|
||||
Some(parse_adapters(&out[start..]))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[allow(dead_code)]
|
||||
pub fn adapters() -> Option<Vec<Adapter>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Windows' row for a detected card, by name (nvidia-smi and Windows agree on NVIDIA names; AMD's OpenCL runtime
|
||||
/// reports gfx codes, which match nothing here and fall back to the name rules).
|
||||
pub fn adapter_for<'a>(name: &str, adapters: &'a [Adapter]) -> Option<&'a Adapter> {
|
||||
let n = name.trim().to_ascii_lowercase();
|
||||
adapters.iter().find(|a| a.name.trim().to_ascii_lowercase() == n)
|
||||
}
|
||||
|
||||
/// The row's words for an integrated GPU that is off by default (the switch turns it on; the choice is kept).
|
||||
pub const INTEGRATED_REASON: &str = "integrated GPU, off by default (2 to 3 MH/s for 30 W)";
|
||||
|
||||
/// The defaults the launchers use: discrete cards on (8 identities on a big card, 2 on a small one), integrated off
|
||||
/// (1 identity), Apple silicon on with 1.
|
||||
pub fn apply_defaults(c: &mut CardState) {
|
||||
|
|
@ -87,7 +230,7 @@ pub fn apply_defaults(c: &mut CardState) {
|
|||
"integrated" => {
|
||||
c.enabled = false;
|
||||
c.identities = 1;
|
||||
c.reason = "integrated: about 3 MH/s and it shares your system memory. Switch it on if you want it.".into();
|
||||
c.reason = INTEGRATED_REASON.into();
|
||||
}
|
||||
_ => {
|
||||
c.enabled = true;
|
||||
|
|
@ -148,19 +291,20 @@ pub fn nvidia_power_limits() -> std::collections::HashMap<String, (f64, f64, f64
|
|||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
|
||||
let mut cards = Vec::new();
|
||||
pub fn detect(bins: &Bins) -> Detection {
|
||||
let mut d = Detection::default();
|
||||
let Some(metal) = bins.metal.as_ref() else {
|
||||
notes.push("the Metal worker (igneum-bench) is missing from the app".into());
|
||||
return cards;
|
||||
d.notes.push("the Metal worker (igneum-bench) is missing from the app".into());
|
||||
return d;
|
||||
};
|
||||
// the worker's own ready line: "ready metal Apple_M5_Max dataset-log2 28 batch 4194304 prepare 1"
|
||||
let out = run_timeout(Command::new(metal).arg("--serve"), Some("quit\n"), Duration::from_secs(20)).unwrap_or_default();
|
||||
let ready = out.lines().find(|l| l.starts_with("ready "));
|
||||
let Some(ready) = ready else {
|
||||
notes.push(format!("the Metal worker did not report ready: {}", out.lines().last().unwrap_or("no output")));
|
||||
return cards;
|
||||
d.notes.push(format!("the Metal worker did not report ready: {}", out.lines().last().unwrap_or("no output")));
|
||||
return d;
|
||||
};
|
||||
d.metal_listed = true;
|
||||
let fields: Vec<&str> = ready.split_whitespace().collect();
|
||||
let name = fields.get(2).map(|s| s.replace('_', " ")).unwrap_or_else(|| "Apple GPU".into());
|
||||
let prepare = fields.windows(2).any(|w| w[0] == "prepare" && w[1] == "1");
|
||||
|
|
@ -175,58 +319,62 @@ pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
|
|||
}
|
||||
}
|
||||
}
|
||||
if let Some(mem) = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) {
|
||||
if let Ok(b) = mem.trim().parse::<u64>() {
|
||||
let gb = b / (1024 * 1024 * 1024);
|
||||
detail = if detail.is_empty() { format!("{gb} GB unified memory") } else { format!("{detail}, {gb} GB unified memory") };
|
||||
}
|
||||
let mem = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)).and_then(|m| m.trim().parse::<u64>().ok());
|
||||
if let Some(b) = mem {
|
||||
let gb = b / (1024 * 1024 * 1024);
|
||||
detail = if detail.is_empty() { format!("{gb} GB unified memory") } else { format!("{detail}, {gb} GB unified memory") };
|
||||
}
|
||||
if !prepare {
|
||||
notes.push("this Metal worker has no prepare support; the miner restarts at the hour boundary".into());
|
||||
d.notes.push("this Metal worker has no prepare support; the miner restarts at the hour boundary".into());
|
||||
}
|
||||
let mut c = card(0, &name, "apple", "Metal", &detail, "");
|
||||
c.kind = "apple".into();
|
||||
c.path = "prebuilt".into();
|
||||
if let Some(mem) = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) {
|
||||
c.vram_mb = mem.trim().parse::<u64>().map(|b| b / (1024 * 1024)).unwrap_or(0);
|
||||
}
|
||||
c.vram_mb = mem.map(|b| b / (1024 * 1024)).unwrap_or(0);
|
||||
apply_defaults(&mut c);
|
||||
mark_sweep_support(&mut c);
|
||||
cards.push(c);
|
||||
cards
|
||||
d.cards.push(c);
|
||||
d
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
|
||||
let mut cards: Vec<CardState> = Vec::new();
|
||||
pub fn detect(bins: &Bins) -> Detection {
|
||||
let mut d = Detection::default();
|
||||
// Windows' own view of every adapter: status and problem code (a Code 43 card is in no tool's list), memory and
|
||||
// processor for the integrated call, names for the last resort
|
||||
let adapters = adapters();
|
||||
d.adapters_listed = adapters.is_some();
|
||||
let adapters = adapters.unwrap_or_default();
|
||||
// NVIDIA: nvidia-smi ships with the driver
|
||||
let smi = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total", "--format=csv,noheader"]), None, Duration::from_secs(10));
|
||||
let smi = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total,pci.bus_id", "--format=csv,noheader"]), None, Duration::from_secs(10));
|
||||
match smi {
|
||||
Some(out) => {
|
||||
d.nvidia_listed = true;
|
||||
for line in out.lines() {
|
||||
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
|
||||
if parts.len() >= 2 && parts[0].chars().all(|c| c.is_ascii_digit()) && !parts[0].is_empty() {
|
||||
let mem_mb: u64 = parts.get(2).and_then(|m| m.split_whitespace().next()).and_then(|n| n.parse::<f64>().ok()).map(|v| v as u64).unwrap_or(0);
|
||||
let detail = if mem_mb > 0 { format!("{} GB", (mem_mb + 512) / 1024) } else { String::new() };
|
||||
let worker_ok = bins.cuda.is_some();
|
||||
let mut c = card(cards.len(), parts[1], "nvidia", "CUDA", &detail, parts[0]);
|
||||
c.kind = if looks_integrated(parts[1]) { "integrated".into() } else { "discrete".into() };
|
||||
let mut c = card(d.cards.len(), parts[1], "nvidia", "CUDA", &detail, parts[0]);
|
||||
c.bus = parts.get(3).map(|b| b.to_string()).unwrap_or_default();
|
||||
c.kind = classify_kind(parts[1], adapter_for(parts[1], &adapters)).into();
|
||||
c.vram_mb = mem_mb;
|
||||
c.path = if worker_ok { "prebuilt".into() } else { "build".into() };
|
||||
if !worker_ok {
|
||||
c.message = "no prebuilt CUDA worker in the package; built from source on first run (needs the CUDA Toolkit and Visual Studio)".into();
|
||||
}
|
||||
apply_defaults(&mut c);
|
||||
cards.push(c);
|
||||
d.cards.push(c);
|
||||
}
|
||||
}
|
||||
if cards.is_empty() {
|
||||
notes.push("nvidia-smi ran but listed no card".into());
|
||||
if d.cards.is_empty() {
|
||||
d.notes.push("nvidia-smi ran but listed no card".into());
|
||||
}
|
||||
let limits = nvidia_power_limits();
|
||||
for c in cards.iter_mut() {
|
||||
if let Some((d, cur, lo, hi)) = limits.get(&c.device) {
|
||||
c.power_default_w = *d;
|
||||
for c in d.cards.iter_mut() {
|
||||
if let Some((dflt, cur, lo, hi)) = limits.get(&c.device) {
|
||||
c.power_default_w = *dflt;
|
||||
c.power_limit_w = *cur;
|
||||
c.power_before_w = *cur;
|
||||
c.power_min_w = *lo;
|
||||
|
|
@ -235,12 +383,13 @@ pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
|
|||
mark_sweep_support(c);
|
||||
}
|
||||
}
|
||||
None => notes.push("nvidia-smi is not on this PC (no NVIDIA driver): no NVIDIA card".into()),
|
||||
None => d.notes.push("nvidia-smi is not on this PC (no NVIDIA driver): no NVIDIA card".into()),
|
||||
}
|
||||
// OpenCL: the worker's own device list (AMD, Intel; NVIDIA shows there too and is skipped)
|
||||
if let Some(cl) = bins.opencl.as_ref() {
|
||||
if let Some(out) = run_timeout(Command::new(cl).arg("--list"), None, Duration::from_secs(15)) {
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
d.opencl_listed = lines.iter().any(|l| l.starts_with("OpenCL devices"));
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let t = line.trim_start_matches(|c| c == ' ' || c == '*').trim();
|
||||
if !t.starts_with('[') {
|
||||
|
|
@ -256,34 +405,59 @@ pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
|
|||
if !is_gpu || name.contains("NVIDIA") || vendor_s.contains("NVIDIA") {
|
||||
continue;
|
||||
}
|
||||
let vendor = if vendor_s.contains("Advanced Micro") || name.contains("Radeon") || name.contains("AMD") { "amd" } else { "other" };
|
||||
let vendor = if vendor_s.contains("Advanced Micro") || name.contains("Radeon") || name.contains("AMD") || name.starts_with("gfx") { "amd" } else { "other" };
|
||||
let units = info.split(", ").find(|p| p.contains("compute units")).unwrap_or("").to_string();
|
||||
let mut c = card(cards.len(), name, vendor, "OpenCL", &units, idx);
|
||||
let mut c = card(d.cards.len(), name, vendor, "OpenCL", &units, idx);
|
||||
c.device = idx.to_string();
|
||||
c.kind = if looks_integrated(name) { "integrated".into() } else { "discrete".into() };
|
||||
c.kind = classify_kind(name, adapter_for(name, &adapters)).into();
|
||||
c.path = "prebuilt".into();
|
||||
apply_defaults(&mut c);
|
||||
mark_sweep_support(&mut c);
|
||||
cards.push(c);
|
||||
d.cards.push(c);
|
||||
}
|
||||
}
|
||||
} else if cards.is_empty() {
|
||||
notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into());
|
||||
} else if d.cards.is_empty() {
|
||||
d.notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into());
|
||||
}
|
||||
if cards.is_empty() {
|
||||
if d.cards.is_empty() {
|
||||
// last resort: the names Windows knows, so the screen can at least say what is in the PC
|
||||
if let Some(out) = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", "Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }"]), None, Duration::from_secs(15)) {
|
||||
for n in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
|
||||
let vendor = if n.contains("NVIDIA") { "nvidia" } else if n.contains("AMD") || n.contains("Radeon") { "amd" } else { "other" };
|
||||
let mut c = card(cards.len(), n, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "0");
|
||||
c.kind = if looks_integrated(n) { "integrated".into() } else { "unknown".into() };
|
||||
c.enabled = false;
|
||||
c.reason = "seen by Windows, but no worker can drive it (no NVIDIA driver and no OpenCL worker)".into();
|
||||
cards.push(c);
|
||||
}
|
||||
for a in adapters.iter().filter(|a| a.problem().is_none()) {
|
||||
let vendor = vendor_of(&a.name);
|
||||
let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "");
|
||||
c.kind = if looks_integrated(&a.name) { "integrated".into() } else { "unknown".into() };
|
||||
c.enabled = false;
|
||||
c.reason = "seen by Windows, but no worker can drive it (no NVIDIA driver and no OpenCL worker)".into();
|
||||
d.cards.push(c);
|
||||
}
|
||||
}
|
||||
cards
|
||||
// the cards Windows lists with a problem (Code 43 after an eGPU hot-plug on PC 1, 5 October 2026): shown, never driven
|
||||
for a in adapters.iter() {
|
||||
let Some(problem) = a.problem() else { continue };
|
||||
if d.cards.iter().any(|c| c.name.trim().eq_ignore_ascii_case(a.name.trim())) {
|
||||
continue;
|
||||
}
|
||||
let vendor = vendor_of(&a.name);
|
||||
let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "");
|
||||
c.bus = a.pnp_id.clone();
|
||||
c.kind = classify_kind(&a.name, Some(a)).into();
|
||||
c.vram_mb = a.ram_mb;
|
||||
mark_unusable(&mut c, &problem);
|
||||
d.cards.push(c);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// A listed card no worker can drive: off, no switch, the problem on the row and the hint under it.
|
||||
pub fn mark_unusable(c: &mut CardState, problem: &str) {
|
||||
c.problem = problem.to_string();
|
||||
c.enabled = false;
|
||||
c.identities = 1;
|
||||
c.state = "unusable".into();
|
||||
c.message = format!("not usable ({problem})");
|
||||
c.reason = PROBLEM_HINT.into();
|
||||
c.sweep_supported = false;
|
||||
c.sweep_state = "unsupported".into();
|
||||
c.sweep_note = c.message.clone();
|
||||
}
|
||||
|
||||
/// Finds the binaries next to the engine (Windows, a plain folder) or in Contents/Resources/bin (macOS bundle).
|
||||
|
|
@ -322,3 +496,105 @@ pub fn node_version(node: &Path) -> String {
|
|||
.and_then(|o| o.lines().next().map(|l| l.trim().to_string()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// PC 1's adapter list on the evening of 5 October 2026, after the RX 9070 XT went in through the Sonnet eGPU box
|
||||
// while the app ran: "AMD Radeon RX 9070 XT | status Error | ram 0 GB", "AMD Radeon(TM) Graphics | status OK |
|
||||
// ram 2 GB" (the Ryzen iGPU, gfx1036 to OpenCL), "NVIDIA GeForce RTX 5090 | status OK".
|
||||
fn pc1() -> Vec<Adapter> {
|
||||
parse_adapters(r#"[{"Name":"AMD Radeon RX 9070 XT","Status":"Error","ConfigManagerErrorCode":43,"AdapterRAM":0,"VideoProcessor":"AMD Radeon Graphics Processor (0x7550)","PNPDeviceID":"PCI\\VEN_1002&DEV_7550&SUBSYS_0E4E1002&REV_C0\\6&1A2B3C4D&0&00000008"},
|
||||
{"Name":"AMD Radeon(TM) Graphics","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":2147483648,"VideoProcessor":"AMD Radeon Graphics Processor (0x164E)","PNPDeviceID":"PCI\\VEN_1002&DEV_164E&SUBSYS_00000000&REV_C1\\4&2E5A1B3&0&0041"},
|
||||
{"Name":"NVIDIA GeForce RTX 5090","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5090","PNPDeviceID":"PCI\\VEN_10DE&DEV_2B85&SUBSYS_10621043&REV_A1\\4&1F2E3D4C&0&0019"}]"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapters_parse_with_status_code_and_memory() {
|
||||
let a = pc1();
|
||||
assert_eq!(a.len(), 3);
|
||||
assert_eq!(a[0].name, "AMD Radeon RX 9070 XT");
|
||||
assert_eq!(a[0].status, "Error");
|
||||
assert_eq!(a[0].code, 43);
|
||||
assert_eq!(a[0].ram_mb, 0);
|
||||
assert_eq!(a[0].problem().as_deref(), Some("Code 43"));
|
||||
assert_eq!(a[1].ram_mb, 2048);
|
||||
assert_eq!(a[1].problem(), None);
|
||||
assert_eq!(a[2].problem(), None);
|
||||
// a single adapter: ConvertTo-Json gives one object, not an array
|
||||
let one = parse_adapters(r#"{"Name":"NVIDIA GeForce RTX 5090","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5090","PNPDeviceID":"PCI\\VEN_10DE"}"#);
|
||||
assert_eq!(one.len(), 1);
|
||||
assert!(parse_adapters("not json").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn problem_without_a_code_is_the_status_word() {
|
||||
let a = Adapter { name: "x".into(), status: "Degraded".into(), ..Default::default() };
|
||||
assert_eq!(a.problem().as_deref(), Some("status Degraded"));
|
||||
let fine = Adapter { name: "x".into(), status: "OK".into(), ..Default::default() };
|
||||
assert_eq!(fine.problem(), None);
|
||||
let code12 = Adapter { name: "x".into(), status: "Error".into(), code: 12, ..Default::default() };
|
||||
assert_eq!(code12.problem().as_deref(), Some("Code 12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_from_pc1_lines_and_the_mac() {
|
||||
let a = pc1();
|
||||
// the discrete cards, with and without their adapter row
|
||||
assert_eq!(classify_kind("AMD Radeon RX 9070 XT", adapter_for("AMD Radeon RX 9070 XT", &a)), "discrete");
|
||||
assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", adapter_for("NVIDIA GeForce RTX 5090", &a)), "discrete");
|
||||
assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", None), "discrete");
|
||||
// the Ryzen iGPU: by its Windows name, and by the gfx code the OpenCL worker prints (no adapter row matches a code)
|
||||
assert_eq!(classify_kind("AMD Radeon(TM) Graphics", adapter_for("AMD Radeon(TM) Graphics", &a)), "integrated");
|
||||
assert_eq!(classify_kind("gfx1036", adapter_for("gfx1036", &a)), "integrated");
|
||||
assert_eq!(classify_kind("gfx1036", None), "integrated");
|
||||
assert_eq!(classify_kind("gfx1036:xnack-", None), "integrated");
|
||||
// a discrete gfx code stays discrete; an Arc card is discrete despite "Intel"
|
||||
assert_eq!(classify_kind("gfx1201", None), "discrete");
|
||||
assert_eq!(classify_kind("gfx1030", None), "discrete");
|
||||
assert_eq!(classify_kind("Intel(R) Arc(TM) A770 Graphics", None), "discrete");
|
||||
// an Intel iGPU by its processor string; an AMD discrete card's processor string ("AMD Radeon Graphics Processor") does not count
|
||||
let intel = Adapter { name: "Intel(R) Iris(R) Xe Graphics".into(), processor: "Intel(R) Iris(R) Xe Graphics Family".into(), ram_mb: 1024, ..Default::default() };
|
||||
assert_eq!(classify_kind("Intel(R) Iris(R) Xe Graphics", Some(&intel)), "integrated");
|
||||
assert_eq!(a[0].processor, "AMD Radeon Graphics Processor (0x7550)");
|
||||
// shared memory under 1 GB on the adapter row makes an unknown name integrated; 0 says nothing
|
||||
let small = Adapter { name: "Some iGPU".into(), ram_mb: 512, ..Default::default() };
|
||||
assert_eq!(classify_kind("Some iGPU", Some(&small)), "integrated");
|
||||
let unknown = Adapter { name: "Some card".into(), ram_mb: 0, ..Default::default() };
|
||||
assert_eq!(classify_kind("Some card", Some(&unknown)), "discrete");
|
||||
// the Mac: detect() labels Apple silicon "apple" itself; the name rules do not call it integrated
|
||||
assert!(!looks_integrated("Apple M5 Max"));
|
||||
assert_eq!(vendor_of("Apple M5 Max"), "apple");
|
||||
assert_eq!(vendor_of("gfx1036"), "amd");
|
||||
assert_eq!(vendor_of("AMD Radeon RX 9070 XT"), "amd");
|
||||
assert_eq!(vendor_of("NVIDIA GeForce RTX 5090"), "nvidia");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_card_row() {
|
||||
let mut c = card(2, "AMD Radeon RX 9070 XT", "amd", "OpenCL", "", "");
|
||||
mark_unusable(&mut c, "Code 43");
|
||||
assert!(!c.enabled);
|
||||
assert_eq!(c.state, "unusable");
|
||||
assert_eq!(c.message, "not usable (Code 43)");
|
||||
assert_eq!(c.reason, PROBLEM_HINT);
|
||||
assert!(!c.present());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrated_default_is_off_with_the_row_words() {
|
||||
let mut c = card(1, "gfx1036", "amd", "OpenCL", "2 compute units", "1");
|
||||
c.kind = classify_kind("gfx1036", None).into();
|
||||
apply_defaults(&mut c);
|
||||
assert!(!c.enabled);
|
||||
assert_eq!(c.identities, 1);
|
||||
assert_eq!(c.reason, INTEGRATED_REASON);
|
||||
let mut big = card(0, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "32 GB", "0");
|
||||
big.kind = "discrete".into();
|
||||
big.vram_mb = 32768;
|
||||
apply_defaults(&mut big);
|
||||
assert!(big.enabled);
|
||||
assert_eq!(big.identities, 8);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ pub struct CardChoice {
|
|||
pub enum Cmd {
|
||||
Detect,
|
||||
ApplyCards(Vec<CardChoice>),
|
||||
Detected(Vec<CardState>, Vec<String>),
|
||||
/// one enumeration of the cards finished (the first, or a re-detection: src/hotplug.rs)
|
||||
Detected(crate::detect::Detection),
|
||||
Start,
|
||||
Pause,
|
||||
Resume,
|
||||
|
|
@ -436,6 +437,12 @@ pub struct Engine {
|
|||
wrapper: bool,
|
||||
last_state_print: Instant,
|
||||
detected: bool,
|
||||
/// hot-plug (src/hotplug.rs): an enumeration thread is running; one more was asked for meanwhile; when the
|
||||
/// next periodic one is due; when the app log last carried the card list
|
||||
detect_busy: bool,
|
||||
detect_again: bool,
|
||||
detect_next: Instant,
|
||||
last_cards_line: Instant,
|
||||
clock_samples: Vec<f64>,
|
||||
clock_node_at: Option<Instant>,
|
||||
clock_node_behind: f64,
|
||||
|
|
@ -530,6 +537,10 @@ impl Engine {
|
|||
wrapper,
|
||||
last_state_print: now,
|
||||
detected: false,
|
||||
detect_busy: false,
|
||||
detect_again: false,
|
||||
detect_next: now + Duration::from_secs(crate::hotplug::POLL_S),
|
||||
last_cards_line: now,
|
||||
clock_samples: Vec::new(),
|
||||
clock_node_at: None,
|
||||
clock_node_behind: 0.0,
|
||||
|
|
@ -635,62 +646,34 @@ impl Engine {
|
|||
fn command(&mut self, c: Cmd) {
|
||||
match c {
|
||||
Cmd::Detect => {
|
||||
if self.detected {
|
||||
// the first run, /api/detect, the host's "detect" on a device change, and the minute poll all land
|
||||
// here; one enumeration at a time, and a request during one runs once more after it
|
||||
if self.detect_busy {
|
||||
self.detect_again = true;
|
||||
return;
|
||||
}
|
||||
self.st().detecting = true;
|
||||
self.detect_busy = true;
|
||||
if !self.detected {
|
||||
self.st().detecting = true;
|
||||
}
|
||||
let bins = self.bins.clone();
|
||||
let shared = self.shared.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut notes = Vec::new();
|
||||
let cards = crate::detect::detect(&bins, &mut notes);
|
||||
shared.send(Cmd::Detected(cards, notes));
|
||||
let d = crate::detect::detect(&bins);
|
||||
shared.send(Cmd::Detected(d));
|
||||
});
|
||||
}
|
||||
Cmd::Detected(cards, notes) => {
|
||||
self.detected = true;
|
||||
let names: Vec<String> = cards.iter().map(|c| format!("{} ({})", c.name, c.worker)).collect();
|
||||
self.shared.log(&format!("GPUs: {}", if names.is_empty() { "none usable".to_string() } else { names.join("; ") }));
|
||||
for n in ¬es {
|
||||
self.shared.log(&format!("detection: {n}"));
|
||||
Cmd::Detected(d) => {
|
||||
self.detect_busy = false;
|
||||
self.detect_next = Instant::now() + Duration::from_secs(crate::hotplug::POLL_S);
|
||||
if self.detected {
|
||||
self.merge_detection(d);
|
||||
} else {
|
||||
self.first_detection(d);
|
||||
}
|
||||
let prefs = self.shared.settings.lock().unwrap().cards.clone();
|
||||
let mut st = self.st();
|
||||
st.detecting = false;
|
||||
st.detect_message = notes.join(". ");
|
||||
st.mining.cards = cards;
|
||||
for c in st.mining.cards.iter_mut() {
|
||||
if let Some(p) = prefs.get(&c.key) {
|
||||
c.enabled = p.enabled && c.kind != "unknown";
|
||||
c.identities = p.identities.clamp(1, 64);
|
||||
c.reason = String::new();
|
||||
if c.vendor == "nvidia" && p.power_pct > 0 {
|
||||
c.power_pct = p.power_pct.clamp(crate::sweep::MIN_PCT, 100);
|
||||
}
|
||||
c.pinned = p.pinned;
|
||||
c.sweep_pct = p.sweep_pct;
|
||||
c.sweep_eff = p.sweep_eff;
|
||||
c.sweep_watts = p.sweep_watts;
|
||||
c.sweep_mhs = p.sweep_mhs;
|
||||
c.sweep_at = p.sweep_at as f64;
|
||||
}
|
||||
}
|
||||
let supported: Vec<String> = st.mining.cards.iter().filter(|c| c.sweep_supported && c.enabled).map(|c| c.key.clone()).collect();
|
||||
let unsupported: Vec<String> = st.mining.cards.iter().filter(|c| !c.sweep_supported).map(|c| format!("{}: {}", c.name, c.sweep_note)).collect();
|
||||
drop(st);
|
||||
for u in &unsupported {
|
||||
self.shared.log(&format!("efficiency sweep {u}"));
|
||||
}
|
||||
if self.shared.runtime.sweep_only {
|
||||
if supported.is_empty() {
|
||||
self.sweep_say("SWEEP none reason=no_supported_card");
|
||||
self.shared.send(Cmd::Quit);
|
||||
} else {
|
||||
self.sweep_queue = supported;
|
||||
}
|
||||
}
|
||||
if self.running {
|
||||
self.plan_miners();
|
||||
if self.detect_again {
|
||||
self.detect_again = false;
|
||||
self.shared.send(Cmd::Detect);
|
||||
}
|
||||
}
|
||||
Cmd::ApplyCards(choices) => self.apply_cards(choices),
|
||||
|
|
@ -850,7 +833,7 @@ impl Engine {
|
|||
let mut missing = Vec::new();
|
||||
{
|
||||
let mut st = self.st();
|
||||
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0) {
|
||||
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0) {
|
||||
let want = requested_watts(c);
|
||||
let got = readback.get(&c.device).copied().unwrap_or(c.power_limit_w);
|
||||
if got > 0.0 {
|
||||
|
|
@ -971,6 +954,139 @@ impl Engine {
|
|||
|
||||
// ---- start, node ------------------------------------------------------------------------------------------
|
||||
|
||||
/// The first enumeration: the list as detected, the saved choices applied, the setup screen's notes.
|
||||
fn first_detection(&mut self, d: crate::detect::Detection) {
|
||||
self.detected = true;
|
||||
let crate::detect::Detection { cards, notes, .. } = d;
|
||||
let names: Vec<String> = cards.iter().map(|c| format!("{} ({}{})", c.name, c.worker, if c.problem.is_empty() { String::new() } else { format!(", {}", c.problem) })).collect();
|
||||
self.shared.log(&format!("GPUs: {}", if names.is_empty() { "none usable".to_string() } else { names.join("; ") }));
|
||||
for n in ¬es {
|
||||
self.shared.log(&format!("detection: {n}"));
|
||||
}
|
||||
let prefs = self.shared.settings.lock().unwrap().cards.clone();
|
||||
let mut st = self.st();
|
||||
st.detecting = false;
|
||||
st.detect_message = notes.join(". ");
|
||||
st.mining.cards = cards;
|
||||
for c in st.mining.cards.iter_mut() {
|
||||
if let Some(p) = crate::hotplug::pref_for(&prefs, c) {
|
||||
crate::hotplug::apply_pref(c, p);
|
||||
}
|
||||
}
|
||||
let supported: Vec<String> = st.mining.cards.iter().filter(|c| c.sweep_supported && c.enabled).map(|c| c.key.clone()).collect();
|
||||
let unsupported: Vec<String> = st.mining.cards.iter().filter(|c| !c.sweep_supported).map(|c| format!("{}: {}", c.name, c.sweep_note)).collect();
|
||||
let line = crate::hotplug::cards_line(&st.mining.cards);
|
||||
drop(st);
|
||||
self.shared.log(&line);
|
||||
self.last_cards_line = Instant::now();
|
||||
for u in &unsupported {
|
||||
self.shared.log(&format!("efficiency sweep {u}"));
|
||||
}
|
||||
if self.shared.runtime.sweep_only {
|
||||
if supported.is_empty() {
|
||||
self.sweep_say("SWEEP none reason=no_supported_card");
|
||||
self.shared.send(Cmd::Quit);
|
||||
} else {
|
||||
self.sweep_queue = supported;
|
||||
}
|
||||
}
|
||||
if self.running {
|
||||
self.plan_miners();
|
||||
}
|
||||
}
|
||||
|
||||
/// A later enumeration (src/hotplug.rs): new cards start, lost cards stop, faulty cards are marked; a list
|
||||
/// that changed nothing touches nothing (no running worker ever restarts because of a re-detection).
|
||||
fn merge_detection(&mut self, d: crate::detect::Detection) {
|
||||
let now = crate::platform::unix_now_f();
|
||||
let old = self.st().mining.cards.clone();
|
||||
let diff = crate::hotplug::diff(&old, &d.cards, &|c| d.listed(c));
|
||||
if diff.is_quiet() {
|
||||
return;
|
||||
}
|
||||
let prefs = self.shared.settings.lock().unwrap().cards.clone();
|
||||
let mut new_nvidia = false;
|
||||
for i in diff.removed.iter().copied() {
|
||||
let name = old[i].name.clone();
|
||||
self.drop_worker(i, "the card was removed");
|
||||
if let Some(c) = self.st().mining.cards.get_mut(i) {
|
||||
crate::hotplug::mark_removed(c, now);
|
||||
}
|
||||
self.shared.event("warn", &format!("Card removed: {name}; its worker stopped"));
|
||||
}
|
||||
for (i, problem) in diff.errored.iter() {
|
||||
let name = old[*i].name.clone();
|
||||
self.drop_worker(*i, &format!("the card reports {problem}"));
|
||||
if let Some(c) = self.st().mining.cards.get_mut(*i) {
|
||||
crate::detect::mark_unusable(c, problem);
|
||||
c.added_at = now;
|
||||
}
|
||||
self.shared.event("warn", &format!("{name}: not usable ({problem}); its worker stopped. {}", crate::detect::PROBLEM_HINT));
|
||||
}
|
||||
for (i, fresh) in diff.moved.iter() {
|
||||
if let Some(c) = self.st().mining.cards.get_mut(*i) {
|
||||
self.shared.log(&format!("{}: device {} is now {} (the running worker keeps its device; the next start uses the new one)", c.name, c.device, fresh.device));
|
||||
c.device = fresh.device.clone();
|
||||
c.key = fresh.key.clone();
|
||||
c.bus = fresh.bus.clone();
|
||||
if fresh.vram_mb > 0 {
|
||||
c.vram_mb = fresh.vram_mb;
|
||||
}
|
||||
if !fresh.detail.is_empty() {
|
||||
c.detail = fresh.detail.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut placed: Vec<(usize, CardState)> = Vec::new();
|
||||
for (i, fresh) in diff.recovered.into_iter().chain(diff.revived.into_iter()) {
|
||||
placed.push((i, fresh));
|
||||
}
|
||||
let mut next = self.st().mining.cards.len();
|
||||
for fresh in diff.added.into_iter() {
|
||||
placed.push((next, fresh));
|
||||
next += 1;
|
||||
}
|
||||
for (i, mut fresh) in placed {
|
||||
let pref = crate::hotplug::pref_for(&prefs, &fresh).cloned();
|
||||
crate::hotplug::settle_new(&mut fresh, i, pref.as_ref(), now);
|
||||
let (kind, text) = crate::hotplug::added_words(&fresh);
|
||||
new_nvidia |= fresh.vendor == "nvidia" && fresh.enabled;
|
||||
let mut st = self.st();
|
||||
if i < st.mining.cards.len() {
|
||||
st.mining.cards[i] = fresh;
|
||||
} else {
|
||||
st.mining.cards.push(fresh);
|
||||
}
|
||||
drop(st);
|
||||
self.shared.event(kind, &text);
|
||||
}
|
||||
let line = crate::hotplug::cards_line(&self.st().mining.cards);
|
||||
self.shared.log(&line);
|
||||
self.last_cards_line = Instant::now();
|
||||
if self.running {
|
||||
self.plan_miners();
|
||||
if new_nvidia {
|
||||
self.apply_power_limits("new card");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the worker on card `idx` (quit, then up to 8 s) and drops its slot; the sweep on it, if any, is aborted.
|
||||
fn drop_worker(&mut self, idx: usize, why: &str) {
|
||||
if self.sweep.as_ref().map(|r| r.card == idx).unwrap_or(false) {
|
||||
self.sweep_abort(why);
|
||||
}
|
||||
let Some(pos) = self.miners.iter().position(|m| m.card == idx) else { return };
|
||||
let label = self.miners[pos].label.clone();
|
||||
if let Some(mut p) = self.miners[pos].proc.take() {
|
||||
self.shared.log(&format!("stopping miner {label} ({why})"));
|
||||
p.write_stdin("quit\n");
|
||||
p.stop(8);
|
||||
}
|
||||
self.miners.remove(pos);
|
||||
self.stability.remove(&idx);
|
||||
}
|
||||
|
||||
fn start(&mut self) {
|
||||
if self.running {
|
||||
return;
|
||||
|
|
@ -1143,7 +1259,7 @@ impl Engine {
|
|||
let cards = self.st().mining.cards.clone();
|
||||
let id8 = self.shared.runtime.id8();
|
||||
for c in cards.iter() {
|
||||
if !c.enabled || self.miners.iter().any(|m| m.card == c.index) {
|
||||
if !c.enabled || !c.present() || self.miners.iter().any(|m| m.card == c.index) {
|
||||
continue;
|
||||
}
|
||||
let prefix = match c.vendor.as_str() {
|
||||
|
|
@ -1307,7 +1423,7 @@ impl Engine {
|
|||
let mut st = self.st();
|
||||
let paused = st.mining.paused;
|
||||
for c in st.mining.cards.iter_mut() {
|
||||
if c.enabled && c.state != "faulted" {
|
||||
if c.enabled && c.present() && c.state != "faulted" {
|
||||
c.state = if paused { "off".into() } else { "waiting".into() };
|
||||
c.hash_now = 0.0;
|
||||
c.pid = 0;
|
||||
|
|
@ -1348,6 +1464,9 @@ impl Engine {
|
|||
let mut st = self.st();
|
||||
for ch in &choices {
|
||||
let Some(c) = st.mining.cards.iter_mut().find(|c| c.key == ch.key) else { continue };
|
||||
if !c.present() {
|
||||
continue; // a removed or faulty card has no switch; its saved choice waits for it
|
||||
}
|
||||
let identities = ch.identities.clamp(1, 64);
|
||||
let enabled = ch.enabled && c.kind != "unknown";
|
||||
let power_pct = ch.power_pct.map(|p| p.clamp(crate::sweep::MIN_PCT, 100)).unwrap_or(c.power_pct);
|
||||
|
|
@ -1366,7 +1485,7 @@ impl Engine {
|
|||
if c.enabled != enabled || c.identities != identities {
|
||||
c.enabled = enabled;
|
||||
c.identities = identities;
|
||||
c.reason = String::new();
|
||||
c.reason = if !enabled && c.kind == "integrated" { crate::detect::INTEGRATED_REASON.into() } else { String::new() };
|
||||
if !enabled {
|
||||
c.state = "off".into();
|
||||
c.hash_now = 0.0;
|
||||
|
|
@ -1422,7 +1541,7 @@ impl Engine {
|
|||
let mut what = Vec::new();
|
||||
{
|
||||
let mut st = self.st();
|
||||
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0) {
|
||||
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0) {
|
||||
let pct = if c.power_pct == 0 { 80 } else { c.power_pct.clamp(crate::sweep::MIN_PCT, 100) };
|
||||
c.power_pct = pct;
|
||||
let watts = requested_watts(c);
|
||||
|
|
@ -1442,7 +1561,7 @@ impl Engine {
|
|||
self.shared.log(&format!("power cap ({why}): {}", cmds.join(" & ")));
|
||||
let line = cmds.join(" & ");
|
||||
let what = what.join(", ");
|
||||
let want: std::collections::HashMap<String, f64> = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0).map(|c| (c.device.clone(), requested_watts(c))).collect();
|
||||
let want: std::collections::HashMap<String, f64> = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0).map(|c| (c.device.clone(), requested_watts(c))).collect();
|
||||
if self.wrapper && cfg!(windows) {
|
||||
// the window host has a UI context: it shows the administrator prompt and reports back on stdin
|
||||
self.power_via_host = Some((line.clone(), what.clone(), want, Instant::now()));
|
||||
|
|
@ -1479,7 +1598,7 @@ impl Engine {
|
|||
.mining
|
||||
.cards
|
||||
.iter()
|
||||
.filter(|c| c.vendor == "nvidia" && c.power_applied && c.power_before_w > 0.0)
|
||||
.filter(|c| c.vendor == "nvidia" && c.present() && c.power_applied && c.power_before_w > 0.0)
|
||||
.map(|c| format!("\"{}\" -i {} -pl {}", crate::platform::tool("nvidia-smi").display(), c.device, c.power_before_w.round() as u64))
|
||||
.collect();
|
||||
drop(st);
|
||||
|
|
@ -2019,6 +2138,16 @@ impl Engine {
|
|||
self.last_settings_save = now;
|
||||
self.shared.save_settings();
|
||||
}
|
||||
// hot-plug (src/hotplug.rs): enumerate again every POLL_S, and keep the card list in the log for the console
|
||||
if self.detected && !self.detect_busy && !self.quitting && now >= self.detect_next {
|
||||
self.detect_next = now + Duration::from_secs(crate::hotplug::POLL_S);
|
||||
self.shared.send(Cmd::Detect);
|
||||
}
|
||||
if self.detected && now.duration_since(self.last_cards_line) >= Duration::from_secs(crate::hotplug::CARDS_LINE_S) {
|
||||
self.last_cards_line = now;
|
||||
let line = crate::hotplug::cards_line(&self.st().mining.cards);
|
||||
self.shared.log(&line);
|
||||
}
|
||||
}
|
||||
|
||||
/// The over-the-air updater (src/ota.rs): checks, downloads and stages on its own threads; this tick hands it what
|
||||
|
|
@ -2444,6 +2573,7 @@ impl Engine {
|
|||
c.eff_mhw = if c.state == "mining" && c.power_w > 1.0 && c.hash_now > 0.0 && unix - c.telemetry_at < 60.0 { c.hash_now / c.power_w } else { 0.0 };
|
||||
}
|
||||
st.mining.hash_total = total;
|
||||
crate::hotplug::age(&mut st.mining.cards, unix);
|
||||
let cut = unix - 3600.0;
|
||||
st.mining.found.retain(|t| *t > cut);
|
||||
if st.node.daa > 0 {
|
||||
|
|
|
|||
464
app/igneum-app/src/hotplug.rs
Normal file
464
app/igneum-app/src/hotplug.rs
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
//! Hot-plug: what changed between two enumerations of the cards (src/detect.rs runs one at start and one every
|
||||
//! minute; the Windows host also asks for one on WM_DEVICECHANGE). Pure, so the rules are unit-tested here; the
|
||||
//! engine applies the result (start a worker, stop one, mark a row). Born 5 October 2026, when an RX 9070 XT went
|
||||
//! into PC 1 through an eGPU box while the app ran and nothing noticed.
|
||||
//!
|
||||
//! Rules: a card is the same card when its key (vendor:device:name) matches, or, failing that, when vendor and
|
||||
//! name match and that pair is unique on both sides (a device index that moved because another card arrived). A
|
||||
//! card missing from a list is removed only when the tool that lists its vendor answered. Removed and faulty cards
|
||||
//! stay in the engine's list (the other cards' indices are the miner slots), marked, and the dashboard hides a
|
||||
//! removed row after five minutes.
|
||||
|
||||
use crate::config::CardPref;
|
||||
use crate::state::CardState;
|
||||
|
||||
/// How long a removed card's row says "removed" before it hides.
|
||||
pub const REMOVED_SHOWN_S: f64 = 300.0;
|
||||
/// How often the engine enumerates again, seconds (macOS has no GPU hot-plug on Apple silicon: slower there).
|
||||
pub const POLL_S: u64 = if cfg!(target_os = "macos") { 300 } else { 60 };
|
||||
/// How often the app log carries the full card list, seconds (the console reads it from the log tail).
|
||||
pub const CARDS_LINE_S: u64 = 600;
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Diff {
|
||||
/// new cards (usable or with a problem), to be appended
|
||||
pub added: Vec<CardState>,
|
||||
/// cards that were marked removed earlier and are listed again: (slot, the fresh entry)
|
||||
pub revived: Vec<(usize, CardState)>,
|
||||
/// slots whose card is gone
|
||||
pub removed: Vec<usize>,
|
||||
/// slots whose card now reports a problem: (slot, "Code 43")
|
||||
pub errored: Vec<(usize, String)>,
|
||||
/// slots whose card had a problem and is now driven by a tool again: (slot, the fresh entry)
|
||||
pub recovered: Vec<(usize, CardState)>,
|
||||
/// slots whose card is the same but its device index (or memory, bus) changed: (slot, the fresh entry)
|
||||
pub moved: Vec<(usize, CardState)>,
|
||||
pub unchanged: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Diff {
|
||||
/// Nothing to do: every present card is where it was.
|
||||
pub fn is_quiet(&self) -> bool {
|
||||
self.added.is_empty() && self.revived.is_empty() && self.removed.is_empty() && self.errored.is_empty() && self.recovered.is_empty() && self.moved.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
fn same_identity(a: &CardState, b: &CardState) -> bool {
|
||||
a.vendor == b.vendor && a.name.trim().eq_ignore_ascii_case(b.name.trim()) && (a.bus.is_empty() || b.bus.is_empty() || a.bus == b.bus)
|
||||
}
|
||||
|
||||
/// Compares the engine's list with a fresh enumeration. `listed(card)` says whether this enumeration's tools could
|
||||
/// have seen that card (Detection::listed); a card its tool did not answer for is kept, not removed.
|
||||
pub fn diff(old: &[CardState], fresh: &[CardState], listed: &dyn Fn(&CardState) -> bool) -> Diff {
|
||||
let mut out = Diff::default();
|
||||
let mut used = vec![false; fresh.len()];
|
||||
let mut pair: Vec<Option<usize>> = vec![None; old.len()];
|
||||
// exact keys first
|
||||
for (i, o) in old.iter().enumerate() {
|
||||
if let Some(j) = fresh.iter().enumerate().position(|(j, f)| !used[j] && f.key == o.key) {
|
||||
used[j] = true;
|
||||
pair[i] = Some(j);
|
||||
}
|
||||
}
|
||||
// then vendor + name, when that pair is unique among what is still unmatched on both sides
|
||||
for (i, o) in old.iter().enumerate() {
|
||||
if pair[i].is_some() {
|
||||
continue;
|
||||
}
|
||||
let cands: Vec<usize> = fresh.iter().enumerate().filter(|(j, f)| !used[*j] && same_identity(o, f)).map(|(j, _)| j).collect();
|
||||
let twins = old.iter().enumerate().filter(|(k, x)| pair[*k].is_none() && *k != i && same_identity(o, x)).count();
|
||||
if cands.len() == 1 && twins == 0 {
|
||||
used[cands[0]] = true;
|
||||
pair[i] = Some(cands[0]);
|
||||
}
|
||||
}
|
||||
for (i, o) in old.iter().enumerate() {
|
||||
match pair[i] {
|
||||
Some(j) => {
|
||||
let f = &fresh[j];
|
||||
if o.removed_at > 0.0 {
|
||||
out.revived.push((i, f.clone()));
|
||||
} else if o.problem.is_empty() && !f.problem.is_empty() {
|
||||
out.errored.push((i, f.problem.clone()));
|
||||
} else if !o.problem.is_empty() && f.problem.is_empty() {
|
||||
out.recovered.push((i, f.clone()));
|
||||
} else if !o.problem.is_empty() {
|
||||
// still faulty: the same problem or a new code, nothing to start or stop
|
||||
if o.problem != f.problem {
|
||||
out.errored.push((i, f.problem.clone()));
|
||||
} else {
|
||||
out.unchanged.push(i);
|
||||
}
|
||||
} else if o.device != f.device || o.key != f.key {
|
||||
out.moved.push((i, f.clone()));
|
||||
} else {
|
||||
out.unchanged.push(i);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if o.removed_at > 0.0 {
|
||||
// already removed: stays hidden or shown as removed
|
||||
out.unchanged.push(i);
|
||||
} else if listed(o) {
|
||||
out.removed.push(i);
|
||||
} else {
|
||||
out.unchanged.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (j, f) in fresh.iter().enumerate() {
|
||||
if !used[j] {
|
||||
out.added.push(f.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The saved choice for a card: by its key, else by vendor and name (the index moved since the choice was saved).
|
||||
pub fn pref_for<'a>(prefs: &'a std::collections::HashMap<String, CardPref>, c: &CardState) -> Option<&'a CardPref> {
|
||||
if let Some(p) = prefs.get(&c.key) {
|
||||
return Some(p);
|
||||
}
|
||||
let head = format!("{}:", c.vendor);
|
||||
let tail = format!(":{}", c.name);
|
||||
let mut found: Vec<&CardPref> = prefs.iter().filter(|(k, _)| k.starts_with(&head) && k.ends_with(&tail)).map(|(_, p)| p).collect();
|
||||
if found.len() == 1 { found.pop() } else { None }
|
||||
}
|
||||
|
||||
/// Applies a saved choice to a freshly detected card (the first detection and every later one use this).
|
||||
pub fn apply_pref(c: &mut CardState, p: &CardPref) {
|
||||
if !c.problem.is_empty() {
|
||||
return;
|
||||
}
|
||||
c.enabled = p.enabled && c.kind != "unknown";
|
||||
c.identities = p.identities.clamp(1, 64);
|
||||
if c.enabled || c.kind != "integrated" {
|
||||
c.reason = String::new();
|
||||
}
|
||||
if c.vendor == "nvidia" && p.power_pct > 0 {
|
||||
c.power_pct = p.power_pct.clamp(crate::sweep::MIN_PCT, 100);
|
||||
}
|
||||
c.pinned = p.pinned;
|
||||
c.sweep_pct = p.sweep_pct;
|
||||
c.sweep_eff = p.sweep_eff;
|
||||
c.sweep_watts = p.sweep_watts;
|
||||
c.sweep_mhs = p.sweep_mhs;
|
||||
c.sweep_at = p.sweep_at as f64;
|
||||
}
|
||||
|
||||
/// A card a re-detection added (or brought back): the saved choice if there is one, else the detect defaults it
|
||||
/// came with; its slot and the time it appeared.
|
||||
pub fn settle_new(c: &mut CardState, index: usize, pref: Option<&CardPref>, now: f64) {
|
||||
c.index = index;
|
||||
c.added_at = now;
|
||||
c.removed_at = 0.0;
|
||||
c.gone = false;
|
||||
if let Some(p) = pref {
|
||||
apply_pref(c, p);
|
||||
}
|
||||
if c.problem.is_empty() {
|
||||
c.state = if c.enabled { "waiting".into() } else { "off".into() };
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a card unplugged: no worker, the row says removed, the saved choice is untouched.
|
||||
pub fn mark_removed(c: &mut CardState, now: f64) {
|
||||
c.removed_at = now;
|
||||
c.gone = false;
|
||||
c.state = "removed".into();
|
||||
c.message = "removed".into();
|
||||
c.hash_now = 0.0;
|
||||
c.pid = 0;
|
||||
c.restart_in_s = 0;
|
||||
c.ready = false;
|
||||
c.prepared = false;
|
||||
}
|
||||
|
||||
/// A removed row hides after REMOVED_SHOWN_S; returns true when something changed.
|
||||
pub fn age(cards: &mut [CardState], now: f64) -> bool {
|
||||
let mut changed = false;
|
||||
for c in cards.iter_mut() {
|
||||
let gone = c.removed_at > 0.0 && now - c.removed_at >= REMOVED_SHOWN_S;
|
||||
if gone != c.gone {
|
||||
c.gone = gone;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// The event line for a card that appeared: "New card: <name>, mining" and its kind (ok | info | warn).
|
||||
pub fn added_words(c: &CardState) -> (&'static str, String) {
|
||||
if !c.problem.is_empty() {
|
||||
("warn", format!("New card: {}, not usable ({}); {}", c.name, c.problem, crate::detect::PROBLEM_HINT))
|
||||
} else if c.enabled {
|
||||
("ok", format!("New card: {}, mining", c.name))
|
||||
} else if c.kind == "integrated" {
|
||||
("info", format!("New card: {}, off ({})", c.name, crate::detect::INTEGRATED_REASON))
|
||||
} else {
|
||||
("info", format!("New card: {}, off (switched off in settings)", c.name))
|
||||
}
|
||||
}
|
||||
|
||||
/// One word for a card's state on the log line and the console: mining, waiting, off, removed, not usable (Code 43).
|
||||
pub fn state_word(c: &CardState) -> String {
|
||||
if c.removed_at > 0.0 {
|
||||
"removed".into()
|
||||
} else if !c.problem.is_empty() {
|
||||
format!("not usable ({})", c.problem)
|
||||
} else if !c.enabled {
|
||||
"off".into()
|
||||
} else {
|
||||
c.state.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The app-log line the console reads (relay/lib/parse.mjs): `cards: <name> [<kind>, <state>] | ...`, hidden rows
|
||||
/// left out, `cards: none` when nothing is listed.
|
||||
pub fn cards_line(cards: &[CardState]) -> String {
|
||||
let parts: Vec<String> = cards.iter().filter(|c| !c.gone).map(|c| format!("{} [{}, {}]", c.name.replace('|', "/").replace('[', "(").replace(']', ")"), c.kind, state_word(c))).collect();
|
||||
if parts.is_empty() { "cards: none".into() } else { format!("cards: {}", parts.join(" | ")) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::detect::{classify_kind, mark_unusable, INTEGRATED_REASON};
|
||||
|
||||
fn card(name: &str, vendor: &str, device: &str) -> CardState {
|
||||
let mut c = CardState { index: 0, key: format!("{vendor}:{device}:{name}"), name: name.into(), vendor: vendor.into(), worker: if vendor == "nvidia" { "CUDA".into() } else { "OpenCL".into() }, device: device.into(), enabled: true, state: "off".into(), ..Default::default() };
|
||||
c.kind = classify_kind(name, None).into();
|
||||
crate::detect::apply_defaults(&mut c);
|
||||
c
|
||||
}
|
||||
// PC 1 at start on 5 October 2026: the 5090 on nvidia-smi index 0, the Ryzen iGPU as OpenCL device 0 (gfx1036)
|
||||
fn pc1_start() -> Vec<CardState> {
|
||||
let mut a = card("NVIDIA GeForce RTX 5090", "nvidia", "0");
|
||||
a.bus = "00000000:01:00.0".into();
|
||||
a.state = "mining".into();
|
||||
let mut b = card("gfx1036", "amd", "0");
|
||||
b.index = 1;
|
||||
vec![a, b]
|
||||
}
|
||||
fn all_listed(_: &CardState) -> bool {
|
||||
true
|
||||
}
|
||||
fn fresh_pc1_with_egpu() -> Vec<CardState> {
|
||||
let mut v = pc1_start();
|
||||
v[0].state = "off".into();
|
||||
let mut e = card("gfx1201", "amd", "1");
|
||||
e.index = 2;
|
||||
v.push(e);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_list_is_quiet() {
|
||||
let old = pc1_start();
|
||||
let mut fresh = pc1_start();
|
||||
fresh[0].state = "off".into(); // runtime state in the fresh list means nothing
|
||||
let d = diff(&old, &fresh, &all_listed);
|
||||
assert!(d.is_quiet(), "{d:?}");
|
||||
assert_eq!(d.unchanged, vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_usable_card_is_added_and_nothing_else_moves() {
|
||||
let old = pc1_start();
|
||||
let d = diff(&old, &fresh_pc1_with_egpu(), &all_listed);
|
||||
assert_eq!(d.added.len(), 1);
|
||||
assert_eq!(d.added[0].name, "gfx1201");
|
||||
assert_eq!(d.added[0].kind, "discrete");
|
||||
assert!(d.added[0].enabled);
|
||||
assert_eq!(d.unchanged, vec![0, 1]);
|
||||
assert!(d.removed.is_empty() && d.moved.is_empty() && d.errored.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_card_with_a_problem_is_added_as_unusable() {
|
||||
let old = pc1_start();
|
||||
let mut fresh = pc1_start();
|
||||
let mut bad = card("AMD Radeon RX 9070 XT", "amd", "");
|
||||
mark_unusable(&mut bad, "Code 43");
|
||||
fresh.push(bad);
|
||||
let d = diff(&old, &fresh, &all_listed);
|
||||
assert_eq!(d.added.len(), 1);
|
||||
assert_eq!(d.added[0].problem, "Code 43");
|
||||
assert!(!d.added[0].enabled);
|
||||
let (kind, text) = added_words(&d.added[0]);
|
||||
assert_eq!(kind, "warn");
|
||||
assert!(text.starts_with("New card: AMD Radeon RX 9070 XT, not usable (Code 43); reboot with the card attached"), "{text}");
|
||||
assert_eq!(state_word(&d.added[0]), "not usable (Code 43)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unplugged_card_is_removed_only_when_its_tool_answered() {
|
||||
let old = fresh_pc1_with_egpu();
|
||||
let fresh = pc1_start();
|
||||
let d = diff(&old, &fresh, &all_listed);
|
||||
assert_eq!(d.removed, vec![2]);
|
||||
assert_eq!(d.unchanged, vec![0, 1]);
|
||||
// the OpenCL worker did not answer this round: nothing is called removed
|
||||
let opencl_dead = |c: &CardState| c.vendor == "nvidia";
|
||||
let d2 = diff(&old, &pc1_start().into_iter().filter(|c| c.vendor == "nvidia").collect::<Vec<_>>(), &opencl_dead);
|
||||
assert!(d2.removed.is_empty(), "{d2:?}");
|
||||
assert!(d2.is_quiet());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_card_whose_index_moved_keeps_its_slot() {
|
||||
// the eGPU landed before the iGPU in the OpenCL list: the iGPU is device 1 now, the eGPU device 0
|
||||
let old = pc1_start();
|
||||
let mut fresh = pc1_start();
|
||||
fresh[1].device = "1".into();
|
||||
fresh[1].key = "amd:1:gfx1036".into();
|
||||
let mut e = card("gfx1201", "amd", "0");
|
||||
e.index = 2;
|
||||
fresh.push(e);
|
||||
let d = diff(&old, &fresh, &all_listed);
|
||||
assert_eq!(d.moved.len(), 1);
|
||||
assert_eq!(d.moved[0].0, 1);
|
||||
assert_eq!(d.moved[0].1.device, "1");
|
||||
assert_eq!(d.added.len(), 1);
|
||||
assert!(d.removed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_identical_cards_are_told_apart_by_key_and_never_swapped() {
|
||||
let mut a = card("NVIDIA GeForce RTX 5090", "nvidia", "0");
|
||||
let mut b = card("NVIDIA GeForce RTX 5090", "nvidia", "1");
|
||||
b.index = 1;
|
||||
a.bus = "00000000:01:00.0".into();
|
||||
b.bus = "00000000:02:00.0".into();
|
||||
let old = vec![a.clone(), b.clone()];
|
||||
// one of the twins leaves: the other keeps its slot by key; the missing one is removed, not "moved"
|
||||
let d = diff(&old, &[a.clone()], &all_listed);
|
||||
assert_eq!(d.unchanged, vec![0]);
|
||||
assert_eq!(d.removed, vec![1]);
|
||||
// both twins present, both unmatched by key (indices swapped): the vendor+name rule refuses to guess
|
||||
let mut a2 = a.clone();
|
||||
a2.device = "1".into();
|
||||
a2.key = "nvidia:1:NVIDIA GeForce RTX 5090".into();
|
||||
a2.bus = "00000000:03:00.0".into();
|
||||
let mut b2 = b.clone();
|
||||
b2.device = "0".into();
|
||||
b2.key = "nvidia:0:NVIDIA GeForce RTX 5090".into();
|
||||
b2.bus = "00000000:04:00.0".into();
|
||||
let d2 = diff(&old, &[a2, b2], &all_listed);
|
||||
// the keys are the identity for twins: slot 0 is nvidia index 0 whichever bus it sits on; nothing restarts
|
||||
assert!(d2.is_quiet(), "{d2:?}");
|
||||
assert_eq!(d2.unchanged, vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_driven_card_that_turns_faulty_is_errored_and_recovers_later() {
|
||||
let old = fresh_pc1_with_egpu();
|
||||
// the eGPU is still listed by Windows, now with Code 43, and no longer by OpenCL
|
||||
let mut fresh = pc1_start();
|
||||
let mut bad = card("gfx1201", "amd", "");
|
||||
mark_unusable(&mut bad, "Code 43");
|
||||
fresh.push(bad);
|
||||
let d = diff(&old, &fresh, &all_listed);
|
||||
assert_eq!(d.errored, vec![(2, "Code 43".to_string())]);
|
||||
assert!(d.added.is_empty() && d.removed.is_empty());
|
||||
// after a reboot with the card attached it is driven again: recovered, same slot
|
||||
let mut faulty = old.clone();
|
||||
mark_unusable(&mut faulty[2], "Code 43");
|
||||
faulty[2].device = String::new();
|
||||
faulty[2].key = "amd::gfx1201".into();
|
||||
let d2 = diff(&faulty, &fresh_pc1_with_egpu(), &all_listed);
|
||||
assert_eq!(d2.recovered.len(), 1);
|
||||
assert_eq!(d2.recovered[0].0, 2);
|
||||
assert!(d2.recovered[0].1.problem.is_empty());
|
||||
// the same problem again next minute: quiet
|
||||
let d3 = diff(&faulty, &fresh, &all_listed);
|
||||
assert!(d3.is_quiet(), "{d3:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removed_card_that_comes_back_is_revived_in_its_slot() {
|
||||
let mut old = fresh_pc1_with_egpu();
|
||||
mark_removed(&mut old[2], 1000.0);
|
||||
assert_eq!(state_word(&old[2]), "removed");
|
||||
// still absent: quiet (and hidden after five minutes)
|
||||
let d = diff(&old, &pc1_start(), &all_listed);
|
||||
assert!(d.is_quiet(), "{d:?}");
|
||||
assert!(age(&mut old, 1000.0 + REMOVED_SHOWN_S));
|
||||
assert!(old[2].gone);
|
||||
assert!(!age(&mut old, 1000.0 + REMOVED_SHOWN_S + 1.0));
|
||||
// back: revived in slot 2
|
||||
let d2 = diff(&old, &fresh_pc1_with_egpu(), &all_listed);
|
||||
assert_eq!(d2.revived.len(), 1);
|
||||
assert_eq!(d2.revived[0].0, 2);
|
||||
assert!(d2.added.is_empty());
|
||||
let mut back = d2.revived[0].1.clone();
|
||||
settle_new(&mut back, 2, None, 2000.0);
|
||||
assert_eq!(back.index, 2);
|
||||
assert_eq!(back.removed_at, 0.0);
|
||||
assert!(!back.gone);
|
||||
assert_eq!(back.added_at, 2000.0);
|
||||
assert_eq!(back.state, "waiting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_users_choice_is_kept_on_a_new_card_and_an_integrated_one_is_off_by_default() {
|
||||
let mut prefs = std::collections::HashMap::new();
|
||||
// the user switched the iGPU on earlier and set 2 identities; the setting was saved under OpenCL index 0
|
||||
prefs.insert("amd:0:gfx1036".to_string(), CardPref { enabled: true, identities: 2, ..Default::default() });
|
||||
// the iGPU comes back as device 1 (the eGPU took index 0): the choice is found by vendor and name
|
||||
let mut igpu = card("gfx1036", "amd", "1");
|
||||
assert_eq!(igpu.kind, "integrated");
|
||||
assert!(!igpu.enabled);
|
||||
assert_eq!(igpu.reason, INTEGRATED_REASON);
|
||||
let p = pref_for(&prefs, &igpu).cloned();
|
||||
assert!(p.is_some());
|
||||
settle_new(&mut igpu, 1, p.as_ref(), 5.0);
|
||||
assert!(igpu.enabled);
|
||||
assert_eq!(igpu.identities, 2);
|
||||
assert_eq!(igpu.reason, "");
|
||||
assert_eq!(igpu.state, "waiting");
|
||||
// the user switched it off: the row keeps the integrated words
|
||||
prefs.insert("amd:1:gfx1036".to_string(), CardPref { enabled: false, identities: 1, ..Default::default() });
|
||||
let mut igpu2 = card("gfx1036", "amd", "1");
|
||||
let p2 = pref_for(&prefs, &igpu2).cloned();
|
||||
settle_new(&mut igpu2, 1, p2.as_ref(), 6.0);
|
||||
assert!(!igpu2.enabled);
|
||||
assert_eq!(igpu2.reason, INTEGRATED_REASON);
|
||||
assert_eq!(igpu2.state, "off");
|
||||
let (kind, text) = added_words(&igpu2);
|
||||
assert_eq!(kind, "info");
|
||||
assert_eq!(text, format!("New card: gfx1036, off ({INTEGRATED_REASON})"));
|
||||
// no saved choice: the detect default (integrated off, discrete on)
|
||||
let mut egpu = card("gfx1201", "amd", "0");
|
||||
settle_new(&mut egpu, 2, None, 7.0);
|
||||
assert!(egpu.enabled);
|
||||
assert_eq!(added_words(&egpu), ("ok", "New card: gfx1201, mining".to_string()));
|
||||
// a pref never switches on a card with a problem
|
||||
let mut bad = card("AMD Radeon RX 9070 XT", "amd", "");
|
||||
mark_unusable(&mut bad, "Code 43");
|
||||
settle_new(&mut bad, 3, Some(&CardPref { enabled: true, identities: 8, ..Default::default() }), 8.0);
|
||||
assert!(!bad.enabled);
|
||||
assert_eq!(bad.state, "unusable");
|
||||
// two saved choices with the same vendor and name but different indices: ambiguous, so none is used
|
||||
prefs.insert("amd:0:gfx1036".to_string(), CardPref { enabled: true, identities: 3, ..Default::default() });
|
||||
let other = card("gfx1036", "amd", "7");
|
||||
assert!(pref_for(&prefs, &other).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_console_line_lists_every_shown_card_with_kind_and_state() {
|
||||
let mut cards = fresh_pc1_with_egpu();
|
||||
cards[0].state = "mining".into();
|
||||
cards[2].state = "starting".into();
|
||||
let mut bad = card("AMD Radeon RX 9070 XT", "amd", "");
|
||||
mark_unusable(&mut bad, "Code 43");
|
||||
cards.push(bad);
|
||||
assert_eq!(cards_line(&cards), "cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, starting] | AMD Radeon RX 9070 XT [discrete, not usable (Code 43)]");
|
||||
mark_removed(&mut cards[2], 10.0);
|
||||
assert!(cards_line(&cards).contains("gfx1201 [discrete, removed]"));
|
||||
age(&mut cards, 10.0 + REMOVED_SHOWN_S);
|
||||
assert!(!cards_line(&cards).contains("gfx1201"));
|
||||
assert_eq!(cards_line(&[]), "cards: none");
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
mod config;
|
||||
mod detect;
|
||||
mod engine;
|
||||
mod hotplug;
|
||||
mod keys;
|
||||
mod platform;
|
||||
mod procs;
|
||||
|
|
@ -135,6 +136,8 @@ fn main() {
|
|||
"quit" => shared.send(engine::Cmd::Quit),
|
||||
"pause" => shared.send(engine::Cmd::Pause),
|
||||
"resume" => shared.send(engine::Cmd::Resume),
|
||||
// the window host saw WM_DEVICECHANGE (a card plugged in or out): enumerate now, not at the next minute
|
||||
"detect" => shared.send(engine::Cmd::Detect),
|
||||
"elevated ok" => shared.send(engine::Cmd::ElevatedDone(Ok(()))),
|
||||
_ if t.starts_with("elevated fail") => shared.send(engine::Cmd::ElevatedDone(Err(t.trim_start_matches("elevated fail").trim_start_matches(':').trim().to_string()))),
|
||||
_ => {}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ pub struct NodeState {
|
|||
pub override_restart_wait: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
#[derive(Clone, Serialize, Default, Debug)]
|
||||
pub struct CardState {
|
||||
pub index: usize,
|
||||
pub key: String, // stable id for the saved preference: vendor:device:name
|
||||
|
|
@ -40,7 +40,7 @@ pub struct CardState {
|
|||
pub detail: String, // memory, cores
|
||||
pub device: String, // the worker's --device value (Windows)
|
||||
pub enabled: bool,
|
||||
pub state: String, // off | waiting | starting | ready | mining | restarting | failed | faulted (the watchdog gave up on it)
|
||||
pub state: String, // off | waiting | starting | ready | mining | restarting | failed | faulted (the watchdog gave up on it) | unusable (the OS reports a problem) | removed (unplugged)
|
||||
pub hash_now: f64, // MH/s, the last interval
|
||||
pub hash_avg: f64, // MH/s since the start
|
||||
pub accepted: u64,
|
||||
|
|
@ -57,6 +57,15 @@ pub struct CardState {
|
|||
pub pid: u32,
|
||||
pub last_status_age_s: f64,
|
||||
pub message: String,
|
||||
/// the device's own problem as the OS reports it ("Code 43" on Windows); set = listed but no worker can drive it
|
||||
pub problem: String,
|
||||
/// PCI bus id where the tool gives one (nvidia-smi pci.bus_id); a second way to recognise a card whose index moved
|
||||
pub bus: String,
|
||||
/// hot-plug (src/hotplug.rs): unix s when a re-detection added this card (0 = found at start), when it lost it
|
||||
/// (0 = present), and `gone` once a removed card has been shown as removed for five minutes (the row hides)
|
||||
pub added_at: f64,
|
||||
pub removed_at: f64,
|
||||
pub gone: bool,
|
||||
/// `WORKER FAULT` lines seen (the miner killed and restarted its worker) and the miner's own `faults=` count
|
||||
pub faults: u64,
|
||||
/// shares that failed the miner's CPU re-check this run (`mismatched=` on the STATUS line)
|
||||
|
|
@ -92,6 +101,14 @@ pub struct CardState {
|
|||
pub race_variants: u32,
|
||||
}
|
||||
|
||||
impl CardState {
|
||||
/// Listed, driver fine, not unplugged: a worker can run on it (hot-plug keeps removed and faulty cards in the
|
||||
/// list so the other cards' indices stay put).
|
||||
pub fn present(&self) -> bool {
|
||||
self.removed_at == 0.0 && self.problem.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
pub struct MiningState {
|
||||
pub state: String, // idle | waiting | mining | paused | stopped
|
||||
|
|
|
|||
|
|
@ -175,6 +175,10 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
|
|||
.gpu-row .meta b{color:var(--ink-2);font-weight:500}
|
||||
.gpu-row .reason{font-size:var(--t-sm);color:var(--ash)}
|
||||
.gpu-row .msg{font-size:var(--t-sm);color:var(--ember)}
|
||||
.gpu-row.removed{opacity:.5}
|
||||
.gpu-row.removed .ids,.gpu-row.unusable .ids{opacity:.3;pointer-events:none}
|
||||
.gpu-row.unusable .name{color:var(--ember)}
|
||||
.gpu-tile .st.removed{color:var(--ash)}
|
||||
.ids{display:flex;align-items:center;gap:6px;flex:0 0 auto}
|
||||
.ids .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);margin-right:var(--s-1)}
|
||||
.ids button{width:30px;height:30px;border-radius:8px;border:1px solid var(--line-2);background:transparent;color:var(--bone);cursor:pointer;font-size:16px;line-height:1}
|
||||
|
|
@ -296,6 +300,7 @@ td .sub{display:block;font-family:var(--mono);font-size:var(--t-xs);color:var(--
|
|||
.feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)}
|
||||
.feed .k{color:var(--molten);margin-right:6px}
|
||||
.feed .k.error{color:var(--ember)}
|
||||
.feed .k.warn{color:var(--ember)}
|
||||
.feed .k.block{color:var(--ember)}
|
||||
.feed .k.build{color:var(--ink-2)}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,11 +9,12 @@
|
|||
4 update available, downloading, ready, waiting for permission, manual 5 job done 6 updated
|
||||
Rules: a done job goes after 5 minutes; a failed job stays until closed; "updated" goes 60 s after the new
|
||||
version started; the clock notice is for the setup screens (the node card carries it on the dashboard); the job
|
||||
notice is for the dashboard. */
|
||||
notice is for the dashboard. A card that appeared or went (hot-plug, src/hotplug.rs) is a level 5 notice for
|
||||
5 minutes on every screen: "New card: <name>, mining", "New card: <name>, not usable (Code 43)", "Card removed". */
|
||||
var Notices = (function () {
|
||||
'use strict';
|
||||
var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, 'update-available': 4, 'job-done': 5, 'update-installed': 6 };
|
||||
var JOB_DONE_S = 300, INSTALLED_S = 60;
|
||||
var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, 'update-available': 4, 'job-done': 5, 'card-added': 5, 'card-removed': 5, 'update-installed': 6 };
|
||||
var JOB_DONE_S = 300, INSTALLED_S = 60, CARD_S = 300;
|
||||
function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; }
|
||||
function firstLine(t, max) { t = String(t || '').split('\n')[0].trim(); max = max || 160; return t.length > max ? t.slice(0, max - 1).trim() + '\u2026' : t; }
|
||||
function endDot(t) { t = (t || '').trim(); return !t || /[.!?]$/.test(t) ? t : t + '.'; }
|
||||
|
|
@ -91,6 +92,26 @@ var Notices = (function () {
|
|||
return notice('clock', 'clock:' + c.severity, c.message || '', { tone: c.severity === 'block' ? 'bad' : 'warn', detail: c.hint || '', actions: [{ act: 'sync', label: c.syncing ? 'Syncing' : 'Sync clock', primary: true, disabled: !!c.syncing }] });
|
||||
}
|
||||
|
||||
// cards = state.mining.cards; now = state.now: every card a re-detection added or lost in the last 5 minutes
|
||||
// (added_at / removed_at are 0 for the cards found at start). The newest first, so pick() shows it.
|
||||
function kindWord(k) { return k === 'apple' ? 'Apple silicon' : k === 'discrete' ? 'Discrete' : k === 'integrated' ? 'Integrated' : k === 'external' ? 'External' : 'Unknown'; }
|
||||
function cardNotices(cards, now) {
|
||||
var out = [];
|
||||
(cards || []).forEach(function (c) {
|
||||
if (c.removed_at > 0) {
|
||||
if (now - c.removed_at <= CARD_S) out.push(notice('card-removed', 'card:removed:' + c.key + ':' + Math.floor(c.removed_at), 'Card removed: ' + c.name + '. Its worker stopped.', { tone: 'warn', at: c.removed_at }));
|
||||
return;
|
||||
}
|
||||
if (!(c.added_at > 0) || now - c.added_at > CARD_S) return;
|
||||
// a new card with a problem, or a card that was mining and now reports one (both carry added_at): the row's words
|
||||
if (c.problem) out.push(notice('card-added', 'card:added:' + c.key + ':' + Math.floor(c.added_at), c.name + ': not usable (' + c.problem + '). No worker runs on it.', { tone: 'warn', detail: c.reason || '', at: c.added_at }));
|
||||
else if (c.enabled) out.push(notice('card-added', 'card:added:' + c.key + ':' + Math.floor(c.added_at), 'New card: ' + c.name + ', mining.', { at: c.added_at }));
|
||||
else out.push(notice('card-added', 'card:added:' + c.key + ':' + Math.floor(c.added_at), 'New card: ' + c.name + ', off (' + kindWord(c.kind).toLowerCase() + '). Settings switches it on.', { at: c.added_at }));
|
||||
});
|
||||
out.sort(function (a, b) { return b.at - a.at; });
|
||||
return out;
|
||||
}
|
||||
|
||||
// every notice the state carries for this screen; pick() chooses the one to show
|
||||
function gather(s, where) {
|
||||
where = where || {};
|
||||
|
|
@ -98,6 +119,7 @@ var Notices = (function () {
|
|||
if (u) out.push(u);
|
||||
if (where.dashboard) { var j = jobNotice(s.jobs, s.now || 0); if (j) out.push(j); }
|
||||
else { var c = clockNotice(s.clock); if (c) out.push(c); }
|
||||
if (s.mining && s.mining.cards) out = out.concat(cardNotices(s.mining.cards, s.now || 0));
|
||||
return out;
|
||||
}
|
||||
// the lowest level wins; a dismissed key is skipped, so the next one shows in its place
|
||||
|
|
@ -110,7 +132,7 @@ var Notices = (function () {
|
|||
}
|
||||
return best;
|
||||
}
|
||||
return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot };
|
||||
return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord };
|
||||
})();
|
||||
|
||||
/* ---------- the update card (pure; update-card.test.mjs loads this block) ----------
|
||||
|
|
@ -328,9 +350,11 @@ if (typeof document !== 'undefined') (function () {
|
|||
$('btn-settings').addEventListener('click', function () { fillSettings(); $('settings').hidden = false; });
|
||||
$('btn-settings-close').addEventListener('click', function () { $('settings').hidden = true; $('s-key-box').hidden = true; $('s-key').textContent = ''; });
|
||||
$('settings').addEventListener('click', function (e) { if (e.target === $('settings')) $('btn-settings-close').click(); });
|
||||
var settingsCardsSig = '';
|
||||
function fillSettings() {
|
||||
if (!state) return;
|
||||
$('s-address').textContent = state.address.display || 'not set';
|
||||
settingsCardsSig = shownCards(state.mining.cards).map(function (c) { return c.key + ':' + (c.problem || '') + ':' + (c.removed_at > 0 ? 'r' : ''); }).join('|');
|
||||
renderCardRows($('s-cards'), state.mining.cards);
|
||||
state.mining.cards.forEach(function (cd) {
|
||||
if (cd.vendor !== 'nvidia' || !(cd.power_default_w > 0)) return;
|
||||
|
|
@ -679,27 +703,31 @@ if (typeof document !== 'undefined') (function () {
|
|||
if (cd.vendor === 'apple') return '<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M16.4 12.6c0-2.5 2-3.6 2.1-3.7-1.2-1.7-3-1.9-3.6-2-1.5-.2-3 .9-3.8.9-.8 0-2-.9-3.3-.8-1.7 0-3.2 1-4.1 2.5-1.8 3-.5 7.6 1.3 10.1.9 1.2 1.9 2.6 3.2 2.5 1.3 0 1.8-.8 3.3-.8 1.6 0 2 .8 3.3.8 1.4 0 2.3-1.2 3.1-2.5 1-1.4 1.4-2.8 1.4-2.9 0 0-2.7-1-2.9-4.1zM13.9 5.3c.7-.8 1.2-2 1-3.2-1 0-2.2.7-2.9 1.5-.6.7-1.2 1.9-1 3 1.1.1 2.2-.5 2.9-1.3z"/></svg>';
|
||||
return cd.vendor === 'nvidia' ? 'NV' : cd.vendor === 'amd' ? 'AMD' : 'GPU';
|
||||
}
|
||||
function kindWord(k) { return k === 'apple' ? 'Apple silicon' : k === 'discrete' ? 'Discrete' : k === 'integrated' ? 'Integrated' : k === 'external' ? 'External' : 'Unknown'; }
|
||||
var kindWord = Notices.kindWord;
|
||||
// hot-plug (src/hotplug.rs): a removed card's row hides after five minutes (gone); a faulty one has no switch
|
||||
function shownCards(cards) { return (cards || []).filter(function (c) { return !c.gone; }); }
|
||||
function vramText(cd) { if (!cd.vram_mb) return ''; var gb = cd.vram_mb / 1024; return (gb >= 10 ? Math.round(gb) : gb.toFixed(gb < 4 ? 1 : 0)) + ' GB' + (cd.kind === 'apple' ? ' unified' : ''); }
|
||||
function renderCardRows(container, cards) {
|
||||
container.querySelectorAll('.gpu-row').forEach(function (n) { n.remove(); });
|
||||
cards.forEach(function (cd) {
|
||||
shownCards(cards).forEach(function (cd) {
|
||||
var d = document.createElement('div');
|
||||
d.className = 'gpu-row' + (cd.enabled ? '' : ' off');
|
||||
var removed = cd.removed_at > 0, unusable = !!cd.problem;
|
||||
d.className = 'gpu-row' + (cd.enabled && !removed && !unusable ? '' : ' off') + (removed ? ' removed' : '') + (unusable ? ' unusable' : '');
|
||||
d.dataset.key = cd.key;
|
||||
var meta = '<span>worker <b>' + esc(cd.worker) + '</b></span>' + (vramText(cd) ? '<span>' + esc(vramText(cd)) + '</span>' : '') + (cd.detail && cd.kind !== 'apple' ? '<span>' + esc(cd.detail) + '</span>' : '') + (cd.detail && cd.kind === 'apple' ? '<span>' + esc(cd.detail.split(',')[0]) + '</span>' : '') + (cd.device ? '<span>device <b>' + esc(cd.device) + '</b></span>' : '');
|
||||
d.innerHTML = '<div class="badge ' + esc(cd.vendor) + '">' + badgeHtml(cd) + '</div>' +
|
||||
'<div class="info"><div class="name">' + esc(cd.name) + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div><div class="meta">' + meta + '</div>' +
|
||||
(cd.reason ? '<div class="reason">' + esc(cd.reason) + '</div>' : '') + (cd.message ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div>' +
|
||||
'<div class="info"><div class="name">' + esc(cd.name) + (unusable ? ': ' + esc(cd.message || 'not usable (' + cd.problem + ')') : removed ? ': removed' : '') + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div><div class="meta">' + meta + '</div>' +
|
||||
(cd.reason ? '<div class="reason">' + esc(cd.reason) + '</div>' : '') + (cd.message && !unusable && !removed ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div>' +
|
||||
(cd.vendor === 'nvidia' && cd.power_default_w > 0 ? '<div class="power"><span class="k">power cap</span><input type="range" min="50" max="100" step="5" value="' + (cd.power_pct || 80) + '"><span class="pv mono">' + (cd.power_pct || 80) + '% · ' + Math.round(cd.power_default_w * (cd.power_pct || 80) / 100) + ' W</span>' + (cd.pinned ? '<span class="pin" title="set by hand; the sweep records but does not change it">pinned</span>' : cd.sweep_pct ? '<span class="pin" title="chosen by the efficiency sweep">sweep ' + cd.sweep_pct + '%</span>' : '') + '</div>' : '') +
|
||||
'<div class="ids"><span class="k">identities</span><button type="button" data-d="-1" aria-label="fewer">−</button><input type="number" min="1" max="64" value="' + cd.identities + '"><button type="button" data-d="1" aria-label="more">+</button></div>' +
|
||||
'<label class="switch" title="' + (cd.enabled ? 'mining on this card' : 'not mining on this card') + '"><input type="checkbox"' + (cd.enabled ? ' checked' : '') + (cd.kind === 'unknown' ? ' disabled' : '') + '><span class="track"></span></label>';
|
||||
'<label class="switch" title="' + (unusable ? 'no worker can drive this card' : removed ? 'the card was removed' : cd.enabled ? 'mining on this card' : 'not mining on this card') + '"><input type="checkbox"' + (cd.enabled && !removed && !unusable ? ' checked' : '') + (cd.kind === 'unknown' || unusable || removed ? ' disabled' : '') + '><span class="track"></span></label>';
|
||||
container.appendChild(d);
|
||||
});
|
||||
}
|
||||
function readCardRows(container) {
|
||||
var out = [];
|
||||
container.querySelectorAll('.gpu-row').forEach(function (r) {
|
||||
if (r.classList.contains('removed') || r.classList.contains('unusable')) return;
|
||||
var n = parseInt(r.querySelector('.ids input').value, 10); if (!(n >= 1)) n = 1; if (n > 64) n = 64;
|
||||
var pr = r.querySelector('.power input'), item = { key: r.dataset.key, enabled: r.querySelector('.switch input').checked, identities: n };
|
||||
if (pr) item.power_pct = parseInt(pr.value, 10);
|
||||
|
|
@ -726,8 +754,8 @@ if (typeof document !== 'undefined') (function () {
|
|||
var list = $('cards-list'), det = $('cards-detecting');
|
||||
if (s.detecting) { det.hidden = false; $('detect-text').textContent = 'asking the graphics cards to report in'; $('btn-cards-next').disabled = true; $('cards-sub').textContent = 'Asking the graphics cards to report in.'; cardsRendered = ''; return; }
|
||||
det.hidden = true;
|
||||
var cards = s.mining.cards;
|
||||
var sig = cards.map(function (c) { return c.key; }).join('|');
|
||||
var cards = shownCards(s.mining.cards);
|
||||
var sig = cards.map(function (c) { return c.key + ':' + (c.problem || '') + ':' + (c.removed_at > 0 ? 'r' : '') + ':' + (c.enabled ? 'on' : 'off'); }).join('|');
|
||||
if (sig !== cardsRendered) { cardsRendered = sig; renderCardRows(list, cards); }
|
||||
if (!cards.length) {
|
||||
$('cards-sub').textContent = 'No GPU this app can drive was found.';
|
||||
|
|
@ -798,8 +826,9 @@ if (typeof document !== 'undefined') (function () {
|
|||
function renderDashboard(s) {
|
||||
var m = s.mining, n = s.node, p = s.program, f = s.finality;
|
||||
$('d-hash').textContent = m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1);
|
||||
var mining = m.cards.filter(function (c) { return c.state === 'mining'; }).length;
|
||||
$('d-hash-sub').textContent = m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + m.cards.length + ' card' + (m.cards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'worker starting' : 'waiting for the node') : m.state === 'idle' ? 'no GPU worker' : m.state;
|
||||
var shown = shownCards(m.cards), present = shown.filter(function (c) { return !(c.removed_at > 0) && !c.problem; });
|
||||
var mining = present.filter(function (c) { return c.state === 'mining'; }).length;
|
||||
$('d-hash-sub').textContent = m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + present.length + ' card' + (present.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'worker starting' : 'waiting for the node') : m.state === 'idle' ? 'no GPU worker' : m.state;
|
||||
$('d-blocks').textContent = withCommas(m.accepted_total);
|
||||
$('d-blocks-sub').textContent = m.accepted_session + ' this run' + (m.found.length ? ' · ' + m.found.length + ' last hour' : '') + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : '');
|
||||
var nodeWord = n.state === 'synced' ? 'synced' : n.state === 'syncing' ? 'syncing' : n.state;
|
||||
|
|
@ -822,22 +851,24 @@ if (typeof document !== 'undefined') (function () {
|
|||
$('d-fee').title = (m.fee_session || 0) + ' this run; 1 block in 100 pays the miner software\'s dev fee';
|
||||
$('d-chain-blocks').textContent = compact(n.blocks);
|
||||
// per-card tiles (enabled cards), the rest in one muted line
|
||||
var on = m.cards.filter(function (c) { return c.enabled; }), off = m.cards.filter(function (c) { return !c.enabled; });
|
||||
$('d-cards-eyebrow').textContent = on.length + ' worker' + (on.length === 1 ? '' : 's') + (m.cards.length > on.length ? ' of ' + m.cards.length + ' GPU' + (m.cards.length === 1 ? '' : 's') : '');
|
||||
// a card that was removed keeps its tile saying "removed" for five minutes (then the row goes); a faulty one is in the off line
|
||||
var on = shown.filter(function (c) { return c.enabled && !c.problem; }), off = shown.filter(function (c) { return !c.enabled || c.problem; });
|
||||
var workers = on.filter(function (c) { return !(c.removed_at > 0); }).length;
|
||||
$('d-cards-eyebrow').textContent = workers + ' worker' + (workers === 1 ? '' : 's') + (present.length > workers ? ' of ' + present.length + ' GPU' + (present.length === 1 ? '' : 's') : '');
|
||||
var tb = $('d-cards');
|
||||
if (!on.length) tb.innerHTML = '<div class="empty">' + (m.cards.length ? 'No card switched on. Open settings to pick one.' : 'No GPU worker. The node runs on its own.') + '</div>';
|
||||
if (!on.length) tb.innerHTML = '<div class="empty">' + (shown.length ? 'No card switched on. Open settings to pick one.' : 'No GPU worker. The node runs on its own.') + '</div>';
|
||||
else tb.innerHTML = on.map(function (cd) {
|
||||
var cls = cd.state === 'mining' ? 'mining' : (cd.state === 'failed' || cd.state === 'restarting' || cd.state === 'faulted') ? 'bad' : '';
|
||||
var cls = cd.state === 'mining' ? 'mining' : (cd.state === 'failed' || cd.state === 'restarting' || cd.state === 'faulted') ? 'bad' : cd.state === 'removed' ? 'removed' : '';
|
||||
var word = cd.state === 'restarting' ? ('restart in ' + cd.restart_in_s + ' s') : cd.state;
|
||||
var extra = cd.ids.length ? '<span>id <b>' + esc(cd.ids.join(' ')) + '</b></span>' : '';
|
||||
return '<div class="gpu-tile' + (cd.state === 'mining' ? '' : ' idle') + '"><div class="n">' + esc(cd.name) + ' <span class="kind ' + esc(cd.kind) + '">' + kindWord(cd.kind) + '</span></div>' +
|
||||
'<div class="h">' + cd.hash_now.toFixed(1) + '<span class="unit">MH/s</span></div>' +
|
||||
'<div class="m"><span>blocks <b>' + cd.accepted + '</b>' + (cd.rejected ? ' <span class="dim">/ ' + cd.rejected + ' rejected</span>' : '') + '</span><span>avg <b>' + cd.hash_avg.toFixed(1) + '</b></span><span>identities <b>' + cd.identities + '</b></span>' + extra + (cd.faults ? '<span class="dim">worker faults <b>' + cd.faults + '</b></span>' : '') + (cd.mismatched ? '<span class="dim">re-check misses <b>' + cd.mismatched + '</b></span>' : '') + '</div>' +
|
||||
telemetryHtml(cd) +
|
||||
'<span class="st ' + cls + '"><i></i>' + esc(word) + (cd.prepared ? ' · next program ready' : '') + '</span>' + (cd.message ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div>';
|
||||
'<span class="st ' + cls + '"><i></i>' + esc(word) + (cd.prepared ? ' · next program ready' : '') + '</span>' + (cd.message && cd.state !== 'removed' ? '<div class="msg">' + esc(cd.message) + '</div>' : '') + '</div>';
|
||||
}).join('');
|
||||
$('d-cards-off').hidden = !off.length;
|
||||
$('d-cards-off').textContent = off.length ? 'off: ' + off.map(function (c) { return c.name + ' (' + kindWord(c.kind).toLowerCase() + ')'; }).join(', ') + '. Settings switches them on.' : '';
|
||||
$('d-cards-off').textContent = off.length ? 'off: ' + off.map(function (c) { return c.problem ? c.name + ': not usable (' + c.problem + ')' : c.name + ' (' + kindWord(c.kind).toLowerCase() + ')'; }).join(', ') + '. ' + (off.some(function (c) { return !c.problem; }) ? 'Settings switches them on.' : 'Reboot with the card attached; if it persists, reinstall the driver with the card attached.') : '';
|
||||
// node card
|
||||
$('d-node-net').textContent = s.chain + (n.version ? ' · ' + n.version.replace(/^igneumd\s*/, '') : '');
|
||||
$('n-blocks').textContent = withCommas(n.blocks); $('n-headers').textContent = withCommas(n.headers); $('n-peers').textContent = n.peers;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js')
|
|||
const mod = { exports: {} };
|
||||
new Function('module', src)(mod);
|
||||
const N = mod.exports;
|
||||
const { updateNotice, jobNotice, clockNotice, gather, pick } = N;
|
||||
const { updateNotice, jobNotice, clockNotice, cardNotices, gather, pick } = N;
|
||||
|
||||
const NOW = 1_800_000_000;
|
||||
const upd = (over) => ({ available: true, version: '0.3.6', notes: '', checked_at: NOW - 60, error: '', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20_588_331, auto: true, wait: 'installs at the next safe moment', urgent: false, urgent_text: '', activation_height: 0, unsupported: false, min_supported: '', channel: 'devnet', published_at: '', file: '', updated_from: '', rolled_back: '', ...over });
|
||||
|
|
@ -139,3 +139,36 @@ test('clock: the engine words, Sync clock, the hint; gather() keeps it off the d
|
|||
assert.equal(pick(gather(st, { dashboard: false }), {}).kind, 'clock');
|
||||
assert.deepEqual(gather({}, { dashboard: true }), []);
|
||||
});
|
||||
|
||||
// hot-plug (src/hotplug.rs): the strip says what appeared or went, for 5 minutes, on every screen
|
||||
const cardOf = (over) => ({ index: 0, key: 'nvidia:0:NVIDIA GeForce RTX 5090', kind: 'discrete', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', enabled: true, state: 'mining', problem: '', reason: '', message: '', added_at: 0, removed_at: 0, gone: false, ...over });
|
||||
test('card notices: new card mining, new card not usable with the hint, removed card, nothing for the cards found at start', () => {
|
||||
const start = cardOf();
|
||||
assert.deepEqual(cardNotices([start], NOW), []);
|
||||
const added = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', added_at: NOW - 30, state: 'starting' });
|
||||
const [a] = cardNotices([start, added], NOW);
|
||||
assert.equal(a.kind, 'card-added'); assert.equal(a.level, N.LEVEL['job-done']); assert.equal(a.text, 'New card: gfx1201, mining.'); assert.equal(a.key, 'card:added:amd:1:gfx1201:' + (NOW - 30));
|
||||
const bad = cardOf({ key: 'amd::AMD Radeon RX 9070 XT', name: 'AMD Radeon RX 9070 XT', vendor: 'amd', enabled: false, state: 'unusable', problem: 'Code 43', message: 'not usable (Code 43)', reason: 'reboot with the card attached; if it persists, reinstall the driver with the card attached', added_at: NOW - 10 });
|
||||
const [b] = cardNotices([bad], NOW);
|
||||
assert.equal(b.text, 'AMD Radeon RX 9070 XT: not usable (Code 43). No worker runs on it.'); assert.equal(b.tone, 'warn'); assert.match(b.detail, /^reboot with the card attached/);
|
||||
const igpu = cardOf({ key: 'amd:0:gfx1036', name: 'gfx1036', vendor: 'amd', kind: 'integrated', enabled: false, state: 'off', added_at: NOW - 5 });
|
||||
assert.equal(cardNotices([igpu], NOW)[0].text, 'New card: gfx1036, off (integrated). Settings switches it on.');
|
||||
const removed = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', state: 'removed', removed_at: NOW - 60 });
|
||||
const [r] = cardNotices([removed], NOW);
|
||||
assert.equal(r.kind, 'card-removed'); assert.equal(r.text, 'Card removed: gfx1201. Its worker stopped.'); assert.equal(r.tone, 'warn');
|
||||
// the newest first; both go after CARD_S
|
||||
const two = cardNotices([added, removed], NOW);
|
||||
assert.equal(two[0].kind, 'card-added');
|
||||
assert.deepEqual(cardNotices([added, removed, bad], NOW + N.CARD_S + 1), []);
|
||||
});
|
||||
|
||||
test('card notices sit under a running job and show on the setup screens too', () => {
|
||||
const added = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', added_at: NOW - 30 });
|
||||
const withJob = s({ jobs: run(), mining: { cards: [added] } });
|
||||
assert.equal(pick(gather(withJob, { dashboard: true }), {}).kind, 'job-running');
|
||||
const quiet = s({ mining: { cards: [added] } });
|
||||
assert.equal(pick(gather(quiet, { dashboard: true }), {}).kind, 'card-added');
|
||||
assert.equal(pick(gather(quiet, {}), {}).kind, 'card-added');
|
||||
// closed: stays closed for that key; a later event on the same card has a new key
|
||||
assert.equal(pick(gather(quiet, { dashboard: true }), { ['card:added:amd:1:gfx1201:' + (NOW - 30)]: true }), null);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
#endif
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#include <dbt.h>
|
||||
#include <wrl.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
|
@ -324,6 +325,11 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
|||
}
|
||||
}
|
||||
return 0;
|
||||
case WM_DEVICECHANGE:
|
||||
// a device arrived or left (an eGPU through a USB4 box, a driver coming up or crashing): the engine enumerates
|
||||
// the cards now instead of at its next minute poll (src/hotplug.rs); DBT_DEVNODES_CHANGED needs no registration
|
||||
if (wp == DBT_DEVNODES_CHANGED || wp == DBT_DEVICEARRIVAL || wp == DBT_DEVICEREMOVECOMPLETE) sendEngine("detect");
|
||||
return TRUE;
|
||||
case WM_TRAY:
|
||||
if (lp == WM_LBUTTONUP || lp == WM_LBUTTONDBLCLK) { ShowWindow(hwnd, SW_SHOW); SetForegroundWindow(hwnd); }
|
||||
else if (lp == WM_RBUTTONUP || lp == WM_CONTEXTMENU) showTrayMenu();
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ async function machines(sql) {
|
|||
accepted_lifetime: g.app && g.app.status ? g.app.status.accepted_total ?? null : null,
|
||||
faults: cards.reduce((a, c) => a + (c.faults || 0), 0),
|
||||
telemetry: g.app ? g.app.telemetry : [], events: g.app ? g.app.events : [],
|
||||
gpus: g.app && g.app.gpus ? g.app.gpus : [], gpus_at: g.app && g.app.gpus_at ? g.app.gpus_at : null, hotplug: g.app && g.app.hotplug ? g.app.hotplug : [],
|
||||
labels: g.labels.sort((a, b) => (b.at > a.at ? 1 : -1)), runs: [...g.runs],
|
||||
};
|
||||
});
|
||||
|
|
@ -145,7 +146,7 @@ async function machines(sql) {
|
|||
// several Mac installs (test runs): the newest is "Mac", the others keep their id
|
||||
let macSeen = false;
|
||||
for (const m of out) if (m.platform === 'mac' && m.name === 'Mac') { if (macSeen) m.name = `Mac ${m.id8}`; macSeen = true; }
|
||||
for (const n of ['Mac', 'PC 1', 'PC 2', "Sam's Mac"]) if (!out.some(m => m.name === n)) out.push({ id: n, id8: Object.keys(NAMES).find(k => NAMES[k] === n) || null, platform: n.endsWith('Mac') ? 'mac' : 'win', name: n, host: null, legacy: false, last_seen: null, silent_s: null, silent: true, app_version: null, app: null, node: {}, cards: [], hash_total: 0, accepted_total: 0, faults: 0, telemetry: [], events: [], labels: [], runs: [], never: true });
|
||||
for (const n of ['Mac', 'PC 1', 'PC 2', "Sam's Mac"]) if (!out.some(m => m.name === n)) out.push({ id: n, id8: Object.keys(NAMES).find(k => NAMES[k] === n) || null, platform: n.endsWith('Mac') ? 'mac' : 'win', name: n, host: null, legacy: false, last_seen: null, silent_s: null, silent: true, app_version: null, app: null, node: {}, cards: [], hash_total: 0, accepted_total: 0, faults: 0, telemetry: [], events: [], gpus: [], gpus_at: null, hotplug: [], labels: [], runs: [], never: true });
|
||||
out.sort((a, b) => order(a) - order(b) || (b.last_seen || '').localeCompare(a.last_seen || ''));
|
||||
return { machines: out.filter(m => NAMED.has(m.name) || (m.silent_s !== null && m.silent_s < HIDE_AFTER_S)), silent_after_s: SILENT_S, hide_after_s: HIDE_AFTER_S };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,12 @@ export function parseHeader(text) {
|
|||
const m = HEADER.exec(text || '');
|
||||
return m ? { version: m[1], id8: m[2], platform: m[3], node: m[4].replace(/^igneumd[_\/]/, '') } : null;
|
||||
}
|
||||
/// "NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | AMD Radeon RX 9070 XT [discrete, not usable (Code 43)]"
|
||||
export function parseCardsLine(text) {
|
||||
if (!text || text.trim() === 'none') return [];
|
||||
return text.split(' | ').map(p => { const m = /^(.*) \[([^,\]]+), ([^\]]+)\]$/.exec(p.trim()); return m ? { name: m[1], kind: m[2], state: m[3] } : { name: p.trim(), kind: 'unknown', state: '' }; });
|
||||
}
|
||||
|
||||
export function parseAppTail(tail) {
|
||||
const lines = tail.split('\n');
|
||||
const a = { telemetry: [], events: [], jobs: [] };
|
||||
|
|
@ -113,6 +119,11 @@ export function parseAppTail(tail) {
|
|||
if (e && e[2] !== 'block') a.events.push({ at: new Date(Number(e[1]) * 1000).toISOString(), kind: e[2], text: e[3].slice(0, 200) });
|
||||
}
|
||||
a.events = a.events.slice(-6);
|
||||
// the card list (app/igneum-app/src/hotplug.rs cards_line): the newest "cards: <name> [<kind>, <state>] | ..." line,
|
||||
// written at start, on every hot-plug change and every 10 minutes
|
||||
const cl = lastMatch(lines, /^(\d+)(?:\.\d+)? cards: (.+)$/);
|
||||
if (cl) { a.gpus = parseCardsLine(cl[2]); a.gpus_at = new Date(Number(cl[1]) * 1000).toISOString(); }
|
||||
a.hotplug = lines.filter(l => /^\d+(?:\.\d+)? \[(ok|warn|info)\] (New card: |Card removed: |.+: not usable \()/.test(l)).slice(-4).map(l => ({ at: stampOf(l), text: strip(l).replace(/^\[\w+\] /, '').slice(0, 200) }));
|
||||
// A clean stop: the app logs "quit: ..." (Quit, Stop, or an update: "[info] installing Igneum Miner ...") and uploads
|
||||
// once more before it exits; when no status line follows, the machine was stopped on purpose, not lost (4 October
|
||||
// 2026: a Mac stopped at 14:47 UTC read "silent 4h" for the whole afternoon, the same as a crash or a lost network).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// node --test relay/test/parse.test.mjs relay/test/auth.test.mjs (no dependencies; CI runs both in the site job)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseLabel, parseMinerTail, markStale, STALE_S } from '../lib/parse.mjs';
|
||||
import { parseLabel, parseMinerTail, parseAppTail, parseCardsLine, markStale, STALE_S } from '../lib/parse.mjs';
|
||||
|
||||
test('labels: every vendor the app names, the app log, the node log, the legacy launchers', () => {
|
||||
assert.deepEqual(parseLabel('miner-nvidia-ae432dc7-1'), { id: 'ae432dc7', platform: 'win', stream: 'miner', vendor: 'nvidia', card: 1 });
|
||||
|
|
@ -84,3 +84,23 @@ test('app tail: the newest update line decides the OTA state: staged beats an ol
|
|||
const inst = parseAppTail(mac + '\n1791146700 [info] installing Igneum Miner 0.3.4: the miners stop, then the node, then the app opens again');
|
||||
assert.deepEqual([inst.ota.state, inst.ota.version], ['installing', '0.3.4']);
|
||||
});
|
||||
|
||||
test('app tail: the cards line gives kind and state per card, and the hot-plug events', () => {
|
||||
assert.deepEqual(parseCardsLine('NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | AMD Radeon RX 9070 XT [discrete, not usable (Code 43)]'),
|
||||
[{ name: 'NVIDIA GeForce RTX 5090', kind: 'discrete', state: 'mining' }, { name: 'gfx1036', kind: 'integrated', state: 'off' }, { name: 'AMD Radeon RX 9070 XT', kind: 'discrete', state: 'not usable (Code 43)' }]);
|
||||
assert.deepEqual(parseCardsLine('none'), []);
|
||||
const tail = [
|
||||
'1791140500 cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off]',
|
||||
'1791140560 [ok] New card: gfx1201, mining',
|
||||
'1791140561 cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, waiting]',
|
||||
'1791140800 [warn] Card removed: gfx1201; its worker stopped',
|
||||
'1791140801 cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, removed]',
|
||||
'1791140830 status: accepted 5 blocks (1 this run, dev fee 0), 120.00 MH/s, mining | node 100 blocks, 3 peers, synced | up 1h',
|
||||
].join('\n');
|
||||
const a = parseAppTail(tail);
|
||||
assert.equal(a.gpus.length, 3);
|
||||
assert.equal(a.gpus[2].state, 'removed');
|
||||
assert.equal(a.gpus_at, new Date(1791140801000).toISOString());
|
||||
assert.deepEqual(a.hotplug.map(h => h.text), ['New card: gfx1201, mining', 'Card removed: gfx1201; its worker stopped']);
|
||||
assert.equal(parseAppTail('1791140830 status: x | node y | up 1h').gpus, undefined);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -287,7 +287,7 @@ input[type=text]{width:100%}
|
|||
['checkpoint', n.checkpoint != null ? nf(n.checkpoint) : '?'],
|
||||
['run', `<small>${esc((n.run_id || (mc.runs && mc.runs[0]) || '').replace(/^.*-(\d{8}-\d{6})$/, '$1'))}</small>`],
|
||||
])}
|
||||
${cardTable(mc.cards)}${tele}
|
||||
${cardTable(mc.cards)}${mc.gpus && mc.gpus.length ? `<div class="note" style="margin-top:8px">GPUs ${mc.gpus_at ? ago(mc.gpus_at) + ' ago' : ''}: ${mc.gpus.map(g => `${esc(g.name)} <span style="color:var(--ash)">[${esc(g.kind)}, ${/not usable|removed/.test(g.state) ? `<span style="color:var(--red)">${esc(g.state)}</span>` : esc(g.state)}]</span>`).join(' | ')}</div>` : ''}${(mc.hotplug || []).map(h => `<div class="note">hot-plug ${ago(h.at)} ago: ${esc(h.text)}</div>`).join('')}${tele}
|
||||
${mc.app && (mc.app.ota || mc.app.power_cap || mc.app.jobs_file || (mc.app.jobs && mc.app.jobs.length) || mc.app.status) ? `<div style="margin-top:8px">
|
||||
${mc.app.status ? `<div class="ev"><span class="t">${when(mc.app.status.at)}</span><span class="k">status</span><span>${esc(mc.app.status.miner)} | node ${esc(mc.app.status.node)} | up ${esc(mc.app.status.up)}</span></div>` : ''}
|
||||
${mc.app.ota ? `<div class="ev"><span class="t">${when(mc.app.ota.at)}</span><span class="k ${(mc.app.ota.state === 'updated' || mc.app.ota.state === 'current') ? 'ok' : ''}">ota</span><span>${esc(mc.app.ota.text)}</span></div>` : ''}
|
||||
|
|
|
|||
|
|
@ -113,6 +113,9 @@ try {
|
|||
console.log(`${m.name.padEnd(8)} ${m.id8 || '-'} ${m.stopped ? `STOPPED (${m.stopped.reason}) ${ago(m.stopped.at)} ago,` : m.silent ? 'SILENT' : 'live'} seen ${ago(m.last_seen)} ago | app ${m.app_version || '?'} node ${m.node.version || '?'} daa ${m.node.daa ?? '?'} peers ${m.node.peers ?? '?'} ${m.node.synced ? 'synced' : 'not synced'} | ${m.hash_total.toFixed(1)} MH/s, ${m.accepted_total} accepted, ${m.faults} faults`);
|
||||
for (const c of m.cards) console.log(` ${c.label.padEnd(22)} ${c.stale ? 'last ' + c.hash_now.toFixed(1) + ' MH/s (stale, not in the total)' : c.hash_now.toFixed(1) + ' MH/s now'}, ${c.accepted} accepted, ${c.rejected} rejected, ${c.mismatched} mismatched, ${c.restarts} restarts, status ${ago(c.status_at)} ago${c.fault ? ' | ' + c.fault : ''}`);
|
||||
for (const t of m.telemetry) console.log(` ${t.name}: p95 ${t.p95_w} W, max ${t.max_w} W (cap ${t.cap_w} W), GPU ${t.max_gpu_c} C, memory ${t.max_mem_c} C`);
|
||||
// the engine's own card list (kind and state per card, hot-plug included: app/igneum-app/src/hotplug.rs), as of its last "cards:" line
|
||||
if (m.gpus && m.gpus.length) console.log(` GPUs (${ago(m.gpus_at)} ago): ${m.gpus.map(g => `${g.name} [${g.kind}, ${g.state}]`).join(' | ')}`);
|
||||
for (const h of m.hotplug || []) console.log(` hot-plug ${ago(h.at)} ago: ${h.text}`);
|
||||
}
|
||||
}
|
||||
else if (cmd === 'chain') {
|
||||
|
|
|
|||
Loading…
Reference in a new issue