MF-11 follow-ups on release-0.3.21 (the app half of update-return 2d3d211a and 0b423697): the external card kind from a USB4 or Thunderbolt router in the device's parent chain (PC 2's RTX 5060 Ti in a Razer Core X V2 reads eGPU, not discrete); the Power Helper's registered probe wants the task enabled and its action exe on disk; a helper that gives no heartbeat or no line inside its window is a FAULT power-helper line with the registration re-read; a same-length rewrite of the command file is a new command (effective_skip; PC 2, 7 October 2026: every tune refused since the 14:35Z boot)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
ad7cf4c672
commit
1ea13655d7
5 changed files with 97 additions and 9 deletions
|
|
@ -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<Adapter> {
|
|||
(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<Vec<Adapter>> {
|
||||
// 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");
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue