diff --git a/app/igneum-app/src/detect.rs b/app/igneum-app/src/detect.rs index 1af73c031..adbc52329 100644 --- a/app/igneum-app/src/detect.rs +++ b/app/igneum-app/src/detect.rs @@ -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, + pub notes: Vec, + 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 { 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 { + 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 { + let Ok(v) = serde_json::from_str::(json.trim()) else { return Vec::new() }; + let rows: Vec = 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::().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> { + 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> { + 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) -> Vec { - 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) -> Vec { } } } - 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::() { - 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::().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::().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) -> Vec { - let mut cards: Vec = 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::().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) -> Vec { 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) -> Vec { 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 { + 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); + } +} diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 850754324..ff2b34adc 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -29,7 +29,8 @@ pub struct CardChoice { pub enum Cmd { Detect, ApplyCards(Vec), - Detected(Vec, Vec), + /// 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, clock_node_at: Option, 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 = 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 = st.mining.cards.iter().filter(|c| c.sweep_supported && c.enabled).map(|c| c.key.clone()).collect(); - let unsupported: Vec = 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 = 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 = st.mining.cards.iter().filter(|c| c.sweep_supported && c.enabled).map(|c| c.key.clone()).collect(); + let unsupported: Vec = 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 = 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 = 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 { diff --git a/app/igneum-app/src/hotplug.rs b/app/igneum-app/src/hotplug.rs new file mode 100644 index 000000000..2f05e672f --- /dev/null +++ b/app/igneum-app/src/hotplug.rs @@ -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, + /// 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, + /// 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, +} + +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> = 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 = 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, 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: , 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: [, ] | ...`, hidden rows +/// left out, `cards: none` when nothing is listed. +pub fn cards_line(cards: &[CardState]) -> String { + let parts: Vec = 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 { + 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 { + 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::>(), &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"); + } +} diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index 126291f01..dc37f2bb3 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -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()))), _ => {} diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index faaa8199c..c5784c67e 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -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 diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 07eb252e1..4dc6820ac 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -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)} diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 3d9b91285..8964aa25d 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -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: , mining", "New card: , 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 ''; 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 = 'worker ' + esc(cd.worker) + '' + (vramText(cd) ? '' + esc(vramText(cd)) + '' : '') + (cd.detail && cd.kind !== 'apple' ? '' + esc(cd.detail) + '' : '') + (cd.detail && cd.kind === 'apple' ? '' + esc(cd.detail.split(',')[0]) + '' : '') + (cd.device ? 'device ' + esc(cd.device) + '' : ''); d.innerHTML = '
' + badgeHtml(cd) + '
' + - '
' + esc(cd.name) + ' ' + kindWord(cd.kind) + '
' + meta + '
' + - (cd.reason ? '
' + esc(cd.reason) + '
' : '') + (cd.message ? '
' + esc(cd.message) + '
' : '') + '
' + + '
' + esc(cd.name) + (unusable ? ': ' + esc(cd.message || 'not usable (' + cd.problem + ')') : removed ? ': removed' : '') + ' ' + kindWord(cd.kind) + '
' + meta + '
' + + (cd.reason ? '
' + esc(cd.reason) + '
' : '') + (cd.message && !unusable && !removed ? '
' + esc(cd.message) + '
' : '') + '
' + (cd.vendor === 'nvidia' && cd.power_default_w > 0 ? '
power cap' + (cd.power_pct || 80) + '% · ' + Math.round(cd.power_default_w * (cd.power_pct || 80) / 100) + ' W' + (cd.pinned ? 'pinned' : cd.sweep_pct ? 'sweep ' + cd.sweep_pct + '%' : '') + '
' : '') + '
identities
' + - ''; + ''; 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 = '
' + (m.cards.length ? 'No card switched on. Open settings to pick one.' : 'No GPU worker. The node runs on its own.') + '
'; + if (!on.length) tb.innerHTML = '
' + (shown.length ? 'No card switched on. Open settings to pick one.' : 'No GPU worker. The node runs on its own.') + '
'; 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 ? 'id ' + esc(cd.ids.join(' ')) + '' : ''; return '
' + esc(cd.name) + ' ' + kindWord(cd.kind) + '
' + '
' + cd.hash_now.toFixed(1) + 'MH/s
' + '
blocks ' + cd.accepted + '' + (cd.rejected ? ' / ' + cd.rejected + ' rejected' : '') + 'avg ' + cd.hash_avg.toFixed(1) + 'identities ' + cd.identities + '' + extra + (cd.faults ? 'worker faults ' + cd.faults + '' : '') + (cd.mismatched ? 're-check misses ' + cd.mismatched + '' : '') + '
' + telemetryHtml(cd) + - '' + esc(word) + (cd.prepared ? ' · next program ready' : '') + '' + (cd.message ? '
' + esc(cd.message) + '
' : '') + '
'; + '' + esc(word) + (cd.prepared ? ' · next program ready' : '') + '' + (cd.message && cd.state !== 'removed' ? '
' + esc(cd.message) + '
' : '') + ''; }).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; diff --git a/app/igneum-app/ui/notices.test.mjs b/app/igneum-app/ui/notices.test.mjs index 251ae8f29..22cd67ab3 100644 --- a/app/igneum-app/ui/notices.test.mjs +++ b/app/igneum-app/ui/notices.test.mjs @@ -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); +}); diff --git a/app/windows/host.cpp b/app/windows/host.cpp index 83d081786..946809b45 100644 --- a/app/windows/host.cpp +++ b/app/windows/host.cpp @@ -16,6 +16,7 @@ #endif #include #include +#include #include #include #include @@ -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(); diff --git a/relay/api/console.mjs b/relay/api/console.mjs index 7dcb53589..832058bd9 100644 --- a/relay/api/console.mjs +++ b/relay/api/console.mjs @@ -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 }; } diff --git a/relay/lib/parse.mjs b/relay/lib/parse.mjs index 5cc2602dc..a8134f61e 100644 --- a/relay/lib/parse.mjs +++ b/relay/lib/parse.mjs @@ -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: [, ] | ..." 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). diff --git a/relay/test/parse.test.mjs b/relay/test/parse.test.mjs index 8c852f5df..f5aa00494 100644 --- a/relay/test/parse.test.mjs +++ b/relay/test/parse.test.mjs @@ -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); +}); diff --git a/relay/ui.html b/relay/ui.html index 6946df10b..3d394493c 100644 --- a/relay/ui.html +++ b/relay/ui.html @@ -287,7 +287,7 @@ input[type=text]{width:100%} ['checkpoint', n.checkpoint != null ? nf(n.checkpoint) : '?'], ['run', `${esc((n.run_id || (mc.runs && mc.runs[0]) || '').replace(/^.*-(\d{8}-\d{6})$/, '$1'))}`], ])} - ${cardTable(mc.cards)}${tele} + ${cardTable(mc.cards)}${mc.gpus && mc.gpus.length ? `
GPUs ${mc.gpus_at ? ago(mc.gpus_at) + ' ago' : ''}: ${mc.gpus.map(g => `${esc(g.name)} [${esc(g.kind)}, ${/not usable|removed/.test(g.state) ? `${esc(g.state)}` : esc(g.state)}]`).join(' | ')}
` : ''}${(mc.hotplug || []).map(h => `
hot-plug ${ago(h.at)} ago: ${esc(h.text)}
`).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) ? `
${mc.app.status ? `
${when(mc.app.status.at)}status${esc(mc.app.status.miner)} | node ${esc(mc.app.status.node)} | up ${esc(mc.app.status.up)}
` : ''} ${mc.app.ota ? `
${when(mc.app.ota.at)}ota${esc(mc.app.ota.text)}
` : ''} diff --git a/tools/console.mjs b/tools/console.mjs index 40feee60d..3a4e1448c 100644 --- a/tools/console.mjs +++ b/tools/console.mjs @@ -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') {