Power Helper: a helper that dies says why, and the engine shows it (PC 1, 7 October 2026, 22:08 BST: six "helper started" lines, no command run after any, no exit line, the task reading Running with no process, the engine toggling nothing and saying nothing). The helper logs its facts after the start line (exe, version, pid, folder, whether cmd.txt is readable and how many lines) and writes every end to helper.exit as "<unix> <code> <reason>": quit, remove, idle, an unwritable folder (exit 2), and a panic caught by run_guarded (FAULT helper: panicked: <msg>, exit 101). ensure_running returns the helper's own exit reason from this start when the heartbeat does not come, else what no reason means (ended from outside or crashed before it ran; Event Viewer Application 1000, TaskScheduler/Operational 201/202), as "power control: helper not running (<reason>)"; the tune and cap paths show that line as an error event, never a silent toggle. Two ordering fixes of the same class as 6 October's (C): the cap path writes its command lines only after the heartbeat (it wrote them right after Start-ScheduledTask, which a fresh helper skips as present at start), and Power control off starts the helper and waits for its heartbeat BEFORE writing remove (a remove present at the start was skipped, so the task never unregistered itself). Tests known-failed first: a_helper_that_dies_at_start_leaves_its_reason_and_the_engine_names_it, remove_is_written_after_the_heartbeat_not_before_the_start
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
c2270fd815
commit
79ae802e12
2 changed files with 158 additions and 9 deletions
|
|
@ -1339,6 +1339,9 @@ impl Engine {
|
|||
Ok(()) => "the step ran but the card did not take it".to_string(),
|
||||
Err(e) => e.clone(),
|
||||
};
|
||||
if why.starts_with("power control: helper not running") {
|
||||
self.shared.event("error", &short(&why, 400));
|
||||
}
|
||||
self.shared.event("error", &format!("GPU power cap NOT applied ({why}): {}. Retry from the card tile.", missing.join("; ")));
|
||||
// never left as "cap NOT applied" (main's rule, 7 October 2026): asked again on the ladder, then a FAULT line home
|
||||
self.cap_refusals += 1;
|
||||
|
|
@ -1679,6 +1682,10 @@ impl Engine {
|
|||
}
|
||||
Err(text) => {
|
||||
self.shared.log(&format!("tune: request {seq} refused: {}", short(&text.replace('\n', " "), 300)));
|
||||
if text.starts_with("power control: helper not running") {
|
||||
// the helper's own exit reason, or what no reason means: shown, never a silent toggle (PC 1, 7 October 2026)
|
||||
self.shared.event("error", &short(&text, 400));
|
||||
}
|
||||
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
|
||||
|
|
@ -2623,7 +2630,9 @@ impl Engine {
|
|||
let shared = self.shared.clone();
|
||||
self.shared.log("power cap: through the Igneum Power Helper task (no prompt)");
|
||||
std::thread::spawn(move || {
|
||||
let r = crate::powertask::start().and_then(|_| {
|
||||
// the helper skips what the file held before its start, so the command lines go in only after its heartbeat
|
||||
// (ensure_running), and a start with no heartbeat comes back as the helper's own exit reason
|
||||
let r = crate::powertask::ensure_running(&dir, Duration::from_secs(12)).and_then(|_| {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let mut seq = crate::powertask::wire_seq();
|
||||
let mut text = String::new();
|
||||
|
|
@ -2745,11 +2754,20 @@ impl Engine {
|
|||
// the kill switch: the task unregisters itself (elevated) and exits; nothing is left behind
|
||||
let hdir = crate::powertask::helper_dir();
|
||||
let _ = std::fs::create_dir_all(&hdir);
|
||||
let _ = std::fs::write(hdir.join("cmd.txt"), "remove\n");
|
||||
match crate::powertask::start() {
|
||||
Ok(()) => self.shared.log("power control off: the Igneum Power Helper task removes itself"),
|
||||
Err(e) => self.shared.log(&format!("power control off: the task could not be started to remove itself ({e}); remove it in Task Scheduler")),
|
||||
}
|
||||
let shared = self.shared.clone();
|
||||
std::thread::spawn(move || {
|
||||
// a `remove` present when the helper starts is skipped like any stale line, so the heartbeat first, then the line
|
||||
match crate::powertask::ensure_running(&hdir, Duration::from_secs(12)) {
|
||||
Ok(()) => {
|
||||
let _ = std::fs::write(hdir.join("cmd.txt"), "remove\n");
|
||||
shared.log("power control off: the Igneum Power Helper task removes itself");
|
||||
}
|
||||
Err(e) => {
|
||||
shared.log(&format!("power control off: the task could not be started to remove itself ({e}); remove it in Task Scheduler"));
|
||||
shared.event("error", &e);
|
||||
}
|
||||
}
|
||||
});
|
||||
self.power_task = None;
|
||||
}
|
||||
self.shared.event(if note.starts_with("power control off:") { "error" } else { "info" }, note);
|
||||
|
|
|
|||
|
|
@ -41,6 +41,60 @@ pub const ALIVE_FILE: &str = "helper.alive";
|
|||
/// A heartbeat older than this is a dead helper.
|
||||
pub const ALIVE_MAX_S: u64 = 4;
|
||||
|
||||
/// Every end of the helper is written here as `<unix> <code> <reason>`, the panic path included; the engine reads it
|
||||
/// when a start gave no heartbeat and shows "power control: helper not running (<reason>)" (PC 1, 7 October 2026,
|
||||
/// 22:08 BST: six "helper started" lines, no command run after any, no exit line, the task reading Running with no
|
||||
/// process; the engine toggled nothing and said nothing).
|
||||
pub const EXIT_FILE: &str = "helper.exit";
|
||||
|
||||
pub fn write_exit(dir: &Path, now: u64, code: i32, reason: &str) {
|
||||
let _ = std::fs::write(dir.join(EXIT_FILE), format!("{now} {code} {}\n", reason.replace('\n', " ")));
|
||||
}
|
||||
|
||||
/// The exit written at or after `since`, as (code, reason); None when there is none that recent (the process was ended
|
||||
/// from outside, or crashed before any Rust ran: nothing of ours writes then).
|
||||
pub fn exit_reason(text: &str, since: u64) -> Option<(i32, String)> {
|
||||
let t = text.trim();
|
||||
let mut it = t.splitn(3, ' ');
|
||||
let at: u64 = it.next()?.parse().ok()?;
|
||||
let code: i32 = it.next()?.parse().ok()?;
|
||||
let reason = it.next().unwrap_or("").trim().to_string();
|
||||
(at >= since).then_some((code, reason))
|
||||
}
|
||||
|
||||
pub fn last_exit(dir: &Path, since: u64) -> Option<(i32, String)> {
|
||||
std::fs::read_to_string(dir.join(EXIT_FILE)).ok().and_then(|t| exit_reason(&t, since))
|
||||
}
|
||||
|
||||
/// The notice the engine shows when the task was started and no heartbeat came: the helper's own exit reason when it
|
||||
/// wrote one, else what a missing reason means and where Windows keeps the rest.
|
||||
pub fn not_running_notice(exit: Option<(i32, String)>, waited_s: u64) -> String {
|
||||
match exit {
|
||||
Some((code, reason)) => format!("power control: helper not running ({reason}; exit {code}; the task gave no heartbeat within {waited_s} s)"),
|
||||
None => format!("power control: helper not running (the task gave no heartbeat within {waited_s} s and the helper wrote no exit reason: its process was ended from outside or crashed before it ran; Event Viewer, Application, id 1000 names the module, and TaskScheduler/Operational ids 201/202 carry the task's return code)"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a panic payload.
|
||||
pub fn panic_text(p: &(dyn std::any::Any + Send)) -> String {
|
||||
p.downcast_ref::<String>().cloned().or_else(|| p.downcast_ref::<&str>().map(|s| s.to_string())).unwrap_or_else(|| "a panic with no message".to_string())
|
||||
}
|
||||
|
||||
/// Runs the helper body under a panic catch: a panic becomes a FAULT line in the log, an exit reason and exit 101,
|
||||
/// never a silent end (known-failed first, PC 1, 7 October 2026).
|
||||
pub fn run_guarded(dir: &Path, log: &dyn Fn(&str), body: &mut dyn FnMut() -> i32) -> i32 {
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body())) {
|
||||
Ok(code) => code,
|
||||
Err(p) => {
|
||||
let reason = format!("panicked: {}", panic_text(&*p));
|
||||
log(&format!("FAULT helper: {reason}"));
|
||||
write_exit(dir, crate::platform::unix_now(), 101, &reason);
|
||||
let _ = std::fs::remove_file(dir.join(ALIVE_FILE));
|
||||
101
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the heartbeat: the unix time, as text.
|
||||
pub fn beat(dir: &Path, now: u64) {
|
||||
let _ = std::fs::write(dir.join(ALIVE_FILE), now.to_string());
|
||||
|
|
@ -65,6 +119,7 @@ pub fn ensure_running(dir: &Path, wait: Duration) -> Result<(), String> {
|
|||
return Ok(());
|
||||
}
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let since = crate::platform::unix_now();
|
||||
start()?;
|
||||
let until = Instant::now() + wait;
|
||||
while Instant::now() < until {
|
||||
|
|
@ -73,7 +128,8 @@ pub fn ensure_running(dir: &Path, wait: Duration) -> Result<(), String> {
|
|||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(format!("the Igneum Power Helper task gave no heartbeat within {} s of its start", wait.as_secs()))
|
||||
// the reason the helper itself wrote on its way out (helper.exit, this start or later), else what a missing one means
|
||||
Err(not_running_notice(last_exit(dir, since), wait.as_secs()))
|
||||
}
|
||||
|
||||
/// One parsed command from cmd.txt.
|
||||
|
|
@ -263,8 +319,7 @@ pub fn start() -> Result<(), String> {
|
|||
/// through nvidia-smi, logs what it ran to `<dir>/helper.log`, ends on `quit`, on `remove` (after unregistering the
|
||||
/// task) or after 20 idle minutes. `dir` is `<app data>/app/sweep`.
|
||||
pub fn run_helper(dir: &Path) -> i32 {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let cmd_file = dir.join("cmd.txt");
|
||||
let made = std::fs::create_dir_all(dir);
|
||||
let log_file = dir.join("helper.log");
|
||||
let log = |line: &str| {
|
||||
use std::io::Write;
|
||||
|
|
@ -273,6 +328,29 @@ pub fn run_helper(dir: &Path) -> i32 {
|
|||
}
|
||||
};
|
||||
log("helper started (scheduled task, elevated)");
|
||||
// the facts a silent end leaves nothing of (PC 1, 7 October 2026, 22:08 BST): which exe, which version, the folder,
|
||||
// what the command file held; and every exit from here on writes its reason to helper.exit
|
||||
let exe = std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_else(|e| format!("unknown ({e})"));
|
||||
let cmd_state = match std::fs::read_to_string(dir.join("cmd.txt")) {
|
||||
Ok(t) => format!("{} line(s)", t.lines().count()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => "absent".to_string(),
|
||||
Err(e) => format!("unreadable ({e})"),
|
||||
};
|
||||
log(&format!("helper facts: exe {exe}, version {}, pid {}, dir {}{}, cmd.txt {cmd_state}", crate::engine::VERSION, std::process::id(), dir.display(), made.as_ref().map(|_| "").unwrap_or(" (NOT writable)"), ));
|
||||
if let Err(e) = made {
|
||||
write_exit(dir, crate::platform::unix_now(), 2, &format!("the helper folder could not be made: {e}"));
|
||||
return 2;
|
||||
}
|
||||
let mut code = 0;
|
||||
let mut body = || {
|
||||
code = run_helper_loop(dir, &log);
|
||||
code
|
||||
};
|
||||
run_guarded(dir, &log, &mut body)
|
||||
}
|
||||
|
||||
fn run_helper_loop(dir: &Path, log: &dyn Fn(&str)) -> i32 {
|
||||
let cmd_file = dir.join("cmd.txt");
|
||||
beat(dir, crate::platform::unix_now());
|
||||
// a stale file from an earlier run is not a command: only lines ADDED after the start count. 6 October 2026,
|
||||
// PC 1 17:55:55Z: a helper that started after an engine had written `quit` read that line and exited in the
|
||||
|
|
@ -295,6 +373,7 @@ pub fn run_helper(dir: &Path) -> i32 {
|
|||
match c {
|
||||
HelperCmd::Quit => {
|
||||
log("quit");
|
||||
write_exit(dir, crate::platform::unix_now(), 0, "quit (the engine asked)");
|
||||
let _ = std::fs::remove_file(dir.join(ALIVE_FILE));
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -304,6 +383,8 @@ pub fn run_helper(dir: &Path) -> i32 {
|
|||
crate::platform::quiet(&mut p);
|
||||
let ok = p.status().map(|s| s.success()).unwrap_or(false);
|
||||
log(&format!("remove: the task is {}", if ok { "unregistered" } else { "still registered (Unregister-ScheduledTask failed)" }));
|
||||
write_exit(dir, crate::platform::unix_now(), if ok { 0 } else { 1 }, if ok { "remove (the task unregistered itself)" } else { "remove failed (Unregister-ScheduledTask returned an error)" });
|
||||
let _ = std::fs::remove_file(dir.join(ALIVE_FILE));
|
||||
return if ok { 0 } else { 1 };
|
||||
}
|
||||
_ if seq <= last_seq => continue,
|
||||
|
|
@ -353,6 +434,7 @@ pub fn run_helper(dir: &Path) -> i32 {
|
|||
}
|
||||
if idle.elapsed() >= Duration::from_secs(IDLE_S) {
|
||||
log("idle 20 min: exit (the engine starts the task again when it needs it)");
|
||||
write_exit(dir, crate::platform::unix_now(), 0, "idle 20 min");
|
||||
let _ = std::fs::remove_file(dir.join(ALIVE_FILE));
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -376,6 +458,55 @@ pub fn helper_dir() -> PathBuf {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Known-failed first (PC 1, 7 October 2026, 22:08 BST): the helper's process died at once after each of six starts
|
||||
/// with no line after "helper started", and the engine toggled nothing and said nothing. Now a panic is a FAULT
|
||||
/// line, an exit reason and exit 101, every planned exit writes its reason, and the engine's notice carries it.
|
||||
#[test]
|
||||
fn a_helper_that_dies_at_start_leaves_its_reason_and_the_engine_names_it() {
|
||||
let dir = std::env::temp_dir().join(format!("igneum-helper-exit-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let lines = std::sync::Mutex::new(Vec::new());
|
||||
let log = |l: &str| lines.lock().unwrap().push(l.to_string());
|
||||
let mut body = || -> i32 { panic!("the cmd file lock was held by pid 4242") };
|
||||
let code = run_guarded(&dir, &log, &mut body);
|
||||
assert_eq!(code, 101);
|
||||
let logged = lines.lock().unwrap().join("\n");
|
||||
assert!(logged.contains("FAULT helper: panicked: the cmd file lock was held by pid 4242"), "{logged}");
|
||||
let since = crate::platform::unix_now() - 5;
|
||||
let exit = last_exit(&dir, since).expect("the exit reason is written");
|
||||
assert_eq!(exit.0, 101);
|
||||
assert!(exit.1.starts_with("panicked: the cmd file lock"));
|
||||
assert!(!dir.join(ALIVE_FILE).exists() || !alive(&dir), "no heartbeat survives the exit");
|
||||
// the engine's notice: the reason when there is one, the meaning of none when there is none
|
||||
let n = not_running_notice(Some(exit), 12);
|
||||
assert!(n.starts_with("power control: helper not running (panicked: the cmd file lock was held by pid 4242; exit 101;"), "{n}");
|
||||
assert!(n.contains("no heartbeat"), "helper_fault_line still reads it as the helper not answering: {n}");
|
||||
let none = not_running_notice(None, 12);
|
||||
assert!(none.starts_with("power control: helper not running (") && none.contains("ended from outside or crashed before it ran") && none.contains("1000"), "{none}");
|
||||
// an older exit is not this start's
|
||||
assert_eq!(exit_reason("1000 0 idle 20 min", 2000), None);
|
||||
assert_eq!(exit_reason("3000 0 idle 20 min", 2000), Some((0, "idle 20 min".to_string())));
|
||||
// a planned end writes its reason too
|
||||
write_exit(&dir, 5000, 0, "quit (the engine asked)");
|
||||
assert_eq!(last_exit(&dir, 4000), Some((0, "quit (the engine asked)".to_string())));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A helper started over a file that holds `remove` skips it (a line present at the start is never a command, 6
|
||||
/// October 2026), so the engine writes `remove` only once the helper's heartbeat is there: start first, then write.
|
||||
#[test]
|
||||
fn remove_is_written_after_the_heartbeat_not_before_the_start() {
|
||||
assert_eq!(commands_after("remove\n", 1), vec![], "the remove present at the start is skipped");
|
||||
assert_eq!(commands_after("remove\n", 0), vec![(0, HelperCmd::Remove)], "a remove added after the start runs");
|
||||
let s = include_str!("engine.rs");
|
||||
let off = s.find("fn power_control_off").unwrap();
|
||||
let body = &s[off..off + 2500];
|
||||
let ensure = body.find("ensure_running").expect("the off path waits for the heartbeat");
|
||||
let write = body.find("\"remove\\n\"").expect("then writes remove");
|
||||
assert!(ensure < write, "the heartbeat comes before the remove line");
|
||||
}
|
||||
|
||||
/// Known-failed first (7 October 2026, 19:5x BST): the helper knew no driver verb, so a driver install needed an
|
||||
/// elevated prompt. The verb carries a vendor WORD only: the helper resolves the file, the hash, the signer and the
|
||||
/// arguments from the signed table itself, so a writer of cmd.txt can never choose what runs elevated.
|
||||
|
|
|
|||
Loading…
Reference in a new issue