diff --git a/app/igneum-app/src/detect.rs b/app/igneum-app/src/detect.rs index 1c637b8e6..ea5bff930 100644 --- a/app/igneum-app/src/detect.rs +++ b/app/igneum-app/src/detect.rs @@ -158,6 +158,9 @@ pub struct Adapter { pub bus: String, /// the DriverVersion property ("32.0.101.9034"); empty when none is bound (driver-check, 7 October 2026) pub driver: String, + /// the device sits behind a USB4 or Thunderbolt router in its parent chain (an eGPU enclosure; PC 2's RTX 5060 Ti in a + /// Razer Core X V2, 7 October 2026): the kind reads "external" and the Cards page says eGPU + pub external: bool, } impl Adapter { @@ -195,6 +198,9 @@ pub fn classify_kind(name: &str, adapter: Option<&Adapter>) -> &'static str { if a.ram_mb > 0 && a.ram_mb < 1024 { return "integrated"; } + if a.external { + return "external"; + } } "discrete" } @@ -233,18 +239,25 @@ pub fn parse_adapters(json: &str) -> Vec { (Some(b), Some(a)) => format!("{:02x}:{:02x}.{:x}", b & 0xff, (a >> 16) & 0xff, a & 0xffff), _ => String::new(), }; - 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"), driver: s(r, "DriverVersion"), bus } + let external = r.get("External").and_then(|x| x.as_bool()).unwrap_or(false); + 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"), driver: s(r, "DriverVersion"), bus, external } }) .filter(|a| !a.name.is_empty()) .collect() } +/// The PowerShell behind adapters(): one object per adapter with the PCI bus and address and whether a USB4 or Thunderbolt +/// router sits in the device's parent chain (External), which is how an eGPU enclosure shows (PC 2's Razer Core X V2 reads +/// "USB4 Router (2.0), Razer - Core X V2", instance USB4\VID_8087&PID_5786...). +pub const ADAPTERS_SCRIPT: &str = "$v = Get-CimInstance Win32_VideoController | ForEach-Object { $id = $_.PNPDeviceID; $bus = $null; $addr = $null; $ext = $false; try { foreach ($x in (Get-PnpDeviceProperty -InstanceId $id -KeyName 'DEVPKEY_Device_BusNumber','DEVPKEY_Device_Address' -ErrorAction Stop)) { if ($x.KeyName -eq 'DEVPKEY_Device_BusNumber') { $bus = $x.Data } elseif ($x.KeyName -eq 'DEVPKEY_Device_Address') { $addr = $x.Data } } } catch {}; try { $cur = $id; for ($i = 0; $i -lt 6 -and $cur; $i++) { $par = (Get-PnpDeviceProperty -InstanceId $cur -KeyName 'DEVPKEY_Device_Parent' -ErrorAction Stop).Data; if (-not $par) { break }; if (\"$par\" -match '^USB4\\\\|THUNDERBOLT|TBT') { $ext = $true; break }; $cur = $par } } catch {}; [pscustomobject]@{ Name = $_.Name; Status = $_.Status; ConfigManagerErrorCode = $_.ConfigManagerErrorCode; AdapterRAM = $_.AdapterRAM; VideoProcessor = $_.VideoProcessor; DriverVersion = $_.DriverVersion; PNPDeviceID = $id; BusNumber = $bus; Address = $addr; External = $ext } }; ConvertTo-Json -InputObject @($v) -Compress"; + /// Windows' adapter list through PowerShell (about a second); None when PowerShell did not answer. #[cfg(windows)] pub fn adapters() -> Option> { // one object per adapter, with the PCI bus number and address from the PnP properties (they name the card // the OpenCL worker's "pci" field names); @() keeps a single adapter an array - let script = "$v = Get-CimInstance Win32_VideoController | ForEach-Object { $id = $_.PNPDeviceID; $bus = $null; $addr = $null; try { foreach ($x in (Get-PnpDeviceProperty -InstanceId $id -KeyName 'DEVPKEY_Device_BusNumber','DEVPKEY_Device_Address' -ErrorAction Stop)) { if ($x.KeyName -eq 'DEVPKEY_Device_BusNumber') { $bus = $x.Data } elseif ($x.KeyName -eq 'DEVPKEY_Device_Address') { $addr = $x.Data } } } catch {}; [pscustomobject]@{ Name = $_.Name; Status = $_.Status; ConfigManagerErrorCode = $_.ConfigManagerErrorCode; AdapterRAM = $_.AdapterRAM; VideoProcessor = $_.VideoProcessor; DriverVersion = $_.DriverVersion; PNPDeviceID = $id; BusNumber = $bus; Address = $addr } }; ConvertTo-Json -InputObject @($v) -Compress"; + // External: the parent chain (DEVPKEY_Device_Parent, up to 6 hops) holds a USB4 router or a Thunderbolt device + let script = ADAPTERS_SCRIPT; 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..])) @@ -822,6 +835,15 @@ mod tests { 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"); + // PC 2, 7 October 2026: the RTX 5060 Ti behind the Razer Core X V2's USB4 router is an eGPU, not a discrete card + let egpu = parse_adapters(r#"[{"Name":"NVIDIA GeForce RTX 5060 Ti","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5060 Ti","PNPDeviceID":"PCI\\VEN_10DE&DEV_2D04&SUBSYS_8A111043&REV_A1\\31C898B6A12DB04800","BusNumber":11,"Address":0,"External":true}]"#); + assert_eq!(egpu.len(), 1); + assert!(egpu[0].external && egpu[0].bus == "0b:00.0"); + assert_eq!(classify_kind("NVIDIA GeForce RTX 5060 Ti", Some(&egpu[0])), "external"); + let inside = parse_adapters(r#"[{"Name":"NVIDIA GeForce RTX 5060 Ti","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"x","PNPDeviceID":"PCI\\VEN_10DE&DEV_2D04\\1","BusNumber":1,"Address":0}]"#); + assert!(!inside[0].external, "no External field reads as inside the case"); + assert_eq!(classify_kind("NVIDIA GeForce RTX 5060 Ti", Some(&inside[0])), "discrete"); + assert!(ADAPTERS_SCRIPT.contains("DEVPKEY_Device_Parent") && ADAPTERS_SCRIPT.contains("USB4") && ADAPTERS_SCRIPT.contains("External = $ext"), "the script walks the parent chain"); // 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"); diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 5f65e99a4..ff6469bc0 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -1617,6 +1617,13 @@ impl Engine { } Err(text) => { self.shared.log(&format!("tune: request {seq} refused: {}", short(&text.replace('\n', " "), 300))); + if let Some(line) = helper_fault_line(&text) { + // the Power Helper task did not answer (no heartbeat, no log line inside its window): a FAULT line + // home, and its registration is read again before the next cap (PC 2, 7 October 2026: every tune + // request refused since the 14:35Z boot, the cap left "NOT applied") + self.shared.log(&line); + self.power_task = None; + } if self.sweep.as_ref().map(|r| r.seq == seq).unwrap_or(false) { self.sweep_abort("the card refused the setting (see the log)"); } else if self.sweep.is_none() { @@ -5398,6 +5405,17 @@ fn point_words(p: &crate::ember::Point) -> String { if p.clock_mhz > 0 { format!("{} MHz at {}%", p.clock_mhz, p.power_pct) } else { format!("{}% (clock unlocked)", p.power_pct) } } +/// A refused tune request that names the Power Helper (no heartbeat, no log line inside the window): the FAULT line the +/// engine logs, so a helper that stopped answering is a fault row and not a quiet "cap NOT applied"; None for any other refusal. +pub fn helper_fault_line(refusal: &str) -> Option { + let t = refusal.to_ascii_lowercase(); + if t.contains("helper") && (t.contains("heartbeat") || t.contains("did not run") || t.contains("no line")) { + Some(format!("FAULT power-helper: the Igneum Power Helper task did not answer ({}); its registration is read again before the next cap, and a missing or stale task is registered again through the one approved step when Power control is on", short(&refusal.replace('\n', " "), 200))) + } else { + None + } +} + /// After a cap the card did not take: when to ask again (2 min, then 10 min), and None on the third refusal, when the /// engine writes the FAULT line instead and waits for a setting change or the card tile (main's rule, 7 October 2026: /// a refused limit is re-applied or reported, never left as "cap NOT applied"). @@ -5604,6 +5622,15 @@ mod resume_tests { #[cfg(test)] mod tests { + /// PC 2, 7 October 2026, 14:39Z: "the helper did not run sequence 1 within 15 s (no line in ...helper.log)" is a FAULT line + #[test] + fn a_helper_that_does_not_answer_is_a_fault_line() { + let l = super::helper_fault_line("the helper did not run sequence 1 within 15 s (no line in C:\\x\\helper.log)").unwrap(); + assert!(l.starts_with("FAULT power-helper: ") && l.contains("did not run sequence 1"), "{l}"); + assert!(super::helper_fault_line("the Igneum Power Helper task gave no heartbeat within 12 s of its start").is_some()); + assert_eq!(super::helper_fault_line("nvidia-smi: Setting applications clocks is not supported"), None, "a card's own refusal is not the helper's"); + } + /// A refused cap is asked again at 2 and 10 minutes, then reported as a FAULT line (never left as "cap NOT applied") #[test] fn a_refused_cap_climbs_the_retry_ladder_then_faults() { diff --git a/app/igneum-app/src/powertask.rs b/app/igneum-app/src/powertask.rs index a4f9043fd..5b71961e7 100644 --- a/app/igneum-app/src/powertask.rs +++ b/app/igneum-app/src/powertask.rs @@ -130,8 +130,21 @@ pub fn wire_seq() -> u64 { crate::platform::unix_now() % 999_990 } +/// How many leading lines of `text` are still the ones the helper saw at its start: `skip` while the file only grew +/// and its first `skip` lines read as before; 0 when the file shrank OR was rewritten (the same count, other text). +/// PC 2, 7 October 2026 (every tune refused since the 14:35Z boot, "helper started" three times with no command run): +/// the engine writes its four command lines with fs::write over a stale four-line file, so the count never dropped, +/// the skip never reset, and the helper read every new command as "present at start". +pub fn effective_skip(initial: &str, text: &str, skip: usize) -> usize { + if text.lines().count() < skip { + return 0; + } + let same_prefix = text.lines().take(skip).eq(initial.lines().take(skip)); + if same_prefix { skip } else { 0 } +} + /// The commands a helper acts on: the lines added after its start (`skip` = the line count at the start, 0 again -/// when the file shrank). A stale `quit` or `remove` from an earlier engine is never a command. +/// when the file shrank or was rewritten: effective_skip). A stale `quit` or `remove` from an earlier engine is never a command. pub fn commands_after(text: &str, skip: usize) -> Vec<(u64, HelperCmd)> { let skip = if text.lines().count() < skip { 0 } else { skip }; text.lines().skip(skip).filter_map(parse_line).collect() @@ -205,9 +218,12 @@ pub fn start_command() -> String { format!("Start-ScheduledTask -TaskName '{TASK_NAME}'; exit 0") } -/// The PowerShell that says whether the task is registered (exit 0) or not (exit 1). +/// The PowerShell that says whether the task is registered AND its action's exe is still there (exit 0), or not (exit 1). +/// A task whose exe has gone (the stale-task class of 7 October 2026: a logon task pointing at a folder that was not +/// there any more) reads as not registered, so the next cap apply with Power control on registers it again through the +/// one approved step instead of starting a task that cannot run. pub fn query_command() -> String { - format!("if (Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue) {{ exit 0 }} else {{ exit 1 }}") + format!("$t = Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue; if ($t -and $t.State -ne 'Disabled' -and (Test-Path (($t.Actions[0].Execute).Trim('\"')))) {{ exit 0 }} else {{ exit 1 }}") } /// The PowerShell the helper itself runs (elevated) on `remove`: the task goes, nothing is left. @@ -261,6 +277,7 @@ pub fn run_helper(dir: &Path) -> i32 { let initial = std::fs::read_to_string(&cmd_file).unwrap_or_default(); let mut last_seq: u64 = initial.lines().filter_map(parse_line).map(|(s, _)| s).max().unwrap_or(0); let mut skip = initial.lines().count(); + let initial_text = initial.clone(); let mut last_text = initial; let mut dev = "0".to_string(); let mut idle = Instant::now(); @@ -268,9 +285,7 @@ pub fn run_helper(dir: &Path) -> i32 { loop { let text = std::fs::read_to_string(&cmd_file).unwrap_or_default(); if text != last_text { - if text.lines().count() < skip { - skip = 0; - } + skip = effective_skip(&initial_text, &text, skip); last_text = text.clone(); for (seq, c) in commands_after(&text, skip) { match c { @@ -370,6 +385,20 @@ mod tests { assert_eq!(commands_after("5 dev 0\n6 pl 460\nquit\n", 3), vec![]); assert_eq!(commands_after("5 dev 0\n6 pl 460\nquit\n7 dev 1\n", 3), vec![(7, HelperCmd::Dev("1".into()))]); assert_eq!(commands_after("quit\n", 3), vec![(0, HelperCmd::Quit)]); + // PC 2, 7 October 2026: the known-failed shape first. A stale four-line file at the helper's start, then the engine's + // four-line rewrite: the count never dropped, so the old rule skipped every new command + let stale = "401000 dev 0\n401001 pl 460\n401002 rgc\n401003 rmc\n"; + let fresh = "401888 dev 0\n401889 pl 575\n401890 rgc\n401891 rmc\n"; + assert_eq!(commands_after(fresh, 4), vec![], "the old rule: a same-length rewrite is invisible"); + assert_eq!(effective_skip(stale, fresh, 4), 0, "a rewrite resets the skip"); + assert_eq!(commands_after(fresh, effective_skip(stale, fresh, 4)).len(), 4, "every new command runs"); + // the file only grew: the stale prefix stays skipped (a stale quit at start is never a command) + let grown = format!("{stale}401888 dev 0\n"); + assert_eq!(effective_skip(stale, &grown, 4), 4); + assert_eq!(commands_after(&grown, effective_skip(stale, &grown, 4)), vec![(401888, HelperCmd::Dev("0".into()))]); + assert_eq!(effective_skip("quit\n", "quit\n401888 dev 0\n", 1), 1, "a stale quit stays skipped while the file only grows"); + assert_eq!(effective_skip(stale, "401888 dev 0\n", 4), 0, "a shorter file resets as before"); + assert_eq!(effective_skip("", fresh, 0), 0); // the heartbeat: fresh within ALIVE_MAX_S, dead after, dead when unreadable // (F) every wire number parses (six digits at most) and leaves room for the four lines of a tune step let w = wire_seq(); @@ -410,6 +439,9 @@ mod tests { assert!(!s.contains("-Trigger"), "no trigger: only the app starts it"); assert!(s.contains("-MultipleInstances IgnoreNew") && s.contains("-ExecutionTimeLimit"), "{s}"); assert!(s.contains(&format!("-TaskName '{TASK_NAME}'"))); + // the registered probe also wants the action's exe on disk and the task enabled (the stale-task class) + let q = query_command(); + assert!(q.contains("Test-Path (($t.Actions[0].Execute).Trim") && q.contains("$t.State -ne 'Disabled'") && q.contains("exit 1"), "{q}"); // a quote in the path cannot break out of the literal let q = register_script(Path::new(r"C:\it's\igneum-app.exe")); assert!(q.contains("'C:\\it''s\\igneum-app.exe'"), "{q}"); diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index ce2ef54e1..0bc1d2378 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -104,7 +104,7 @@ var Notices = (function () { // 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 kindWord(k) { return k === 'apple' ? 'Apple silicon' : k === 'discrete' ? 'Discrete' : k === 'integrated' ? 'Integrated' : k === 'external' ? 'eGPU' : 'Unknown'; } function cardNotices(cards, now) { var out = []; (cards || []).forEach(function (c) { diff --git a/app/igneum-app/ui/view.test.mjs b/app/igneum-app/ui/view.test.mjs index 6227f01d7..3a5379b70 100644 --- a/app/igneum-app/ui/view.test.mjs +++ b/app/igneum-app/ui/view.test.mjs @@ -112,6 +112,13 @@ test('driver-check: the row\'s driver strip per state (offer, running, reboot, e assert.equal(V.vendorWord('amd'), 'AMD'); }); +test('a card behind a USB4 or Thunderbolt router is an eGPU on the Cards page (PC 2, 7 October 2026)', () => { + const r = V.cardRow(card({ key: 'nvidia:1:NVIDIA GeForce RTX 5060 Ti', name: 'NVIDIA GeForce RTX 5060 Ti', vendor: 'nvidia', kind: 'external', enabled: true, state: 'mining', hash_now: 40, power_w: 120, temp_gpu: 55 })); + assert.equal(r.kindWord, 'eGPU'); + assert.equal(r.integrated, false); + assert.equal(r.canToggle, true); +}); + test('an integrated GPU is shown as integrated and off, with its reason', () => { const r = V.cardRow(card({ key: 'intel:1:UHD', name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated', enabled: false, state: 'off', hash_now: 0, reason: 'integrated GPU: slow and shares the machine memory', power_w: 0, temp_gpu: 0 })); assert.equal(r.integrated, true);