0.3.22: the engine starts at boot without a logon on Windows (MF-11's second half; PC 2 idle 67 minutes after its 17:28 BST self-reboot, 7 October 2026): the per-user task "Igneum Miner (boot)" at system start under the user's S4U logon running igneum-app.exe --launch --data-root <root>, registered by the engine at start and in the Power Helper's one approved step; --launch with no interactive session runs the engine headless (--boot); a window host's --wrapper engine that finds the headless engine's app.url answering becomes a bridge (URL and STATE lines to the host, quit, pause, resume and detect relayed to the API; the window closing leaves the engine mining; an engine gone ends the bridge with EXIT); a headless engine never reads a closed stdin as the host leaving (PC 2, 19:09 to 19:11 BST: four engines quit 3 s after the node started); the known-failed forms first in every test, the no-logon return case beside the helper's sequence; install-repair.ps1 on the record

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 18:28:06 +00:00
parent 4466a3b711
commit 6c6bd1f3e6
5 changed files with 471 additions and 12 deletions

349
app/igneum-app/src/boot.rs Normal file
View file

@ -0,0 +1,349 @@
//! The engine starts at boot without a logon (0.3.22, MF-11's second half). PC 2, 7 October 2026, 17:27 BST: a kernel
//! crash during the Intel driver install, the PC back at 17:28, and the per-user app then waited 67 minutes for a
//! logon with every card idle, because the only start was the Run key at logon and `--launch` always opened the window
//! host, which has no desktop before a logon.
//!
//! Windows, from 0.3.22:
//! - a per-user scheduled task `Igneum Miner (boot)` runs at system start under the user's own account with the S4U
//! logon (no password stored, no logon needed, limited run level): `igneum-app.exe --launch --data-root <the
//! user's %LOCALAPPDATA%\igneum>` (the data root is spelled out because an S4U session may not load the profile);
//! registered by the engine at its start (unelevated, the user's own task) and again inside the one approved step
//! the Power Helper uses, for the account that cannot register it alone;
//! - `--launch` decides by the session: no interactive session (SESSIONNAME unset: session 0, the boot task, a
//! service) runs the engine headless (`--boot`: no window host, no browser); a logon session opens the window host
//! as before;
//! - the window host, at logon, starts `igneum-app.exe --wrapper` as always; that engine finds the headless engine's
//! app.url answering api/state and becomes a BRIDGE instead of a second engine: it prints the headless engine's
//! URL and STATE lines to the host and relays the host's stdin commands (quit, pause, resume, detect) to the
//! engine's API. The window closing (stdin gone) ends the bridge and leaves the engine mining; Quit in the tray
//! quits the engine. A headless engine that stops answering ends the bridge with EXIT, and the host (0.3.21)
//! starts `--wrapper` again, which then runs as a full engine.
//! The decisions are functions here so the tests drive them with the known-failed shape first.
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
/// The boot task's name in the Windows Task Scheduler (per user).
pub const TASK_NAME: &str = "Igneum Miner (boot)";
/// How often the bridge reads api/state for a STATE line, and how many misses in a row end it.
pub const BRIDGE_POLL_S: u64 = 3;
pub const BRIDGE_MISSES: u32 = 4;
/// What `--launch` does.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LaunchMode {
/// start "Igneum Miner.exe" (the window host), which starts the engine
Host,
/// run the engine here, no window host, no browser (no interactive session, or no host next to the exe)
Headless,
/// run the engine here and open the dashboard in the default browser (a logon session without the window host)
Browser,
}
/// The session a process runs in: `SESSIONNAME` is set in every interactive logon session ("Console", "RDP-Tcp#3")
/// and unset in session 0 (services, S4U scheduled tasks at boot).
pub fn interactive_session(session_name: Option<&str>) -> bool {
session_name.map(|s| !s.trim().is_empty()).unwrap_or(false)
}
/// 0.3.22: the window host only when someone is logged on; headless otherwise.
pub fn launch_mode(session_name: Option<&str>, host_exists: bool) -> LaunchMode {
match (interactive_session(session_name), host_exists) {
(true, true) => LaunchMode::Host,
(true, false) => LaunchMode::Browser,
(false, _) => LaunchMode::Headless,
}
}
/// Before 0.3.22, for the record: the host whenever it existed, whoever was or was not logged on.
pub fn legacy_launch_mode(host_exists: bool) -> LaunchMode {
if host_exists { LaunchMode::Host } else { LaunchMode::Browser }
}
/// Does a closed stdin mean "the host went away" (quit)? Only for an engine a window host attached (`--wrapper`): a
/// headless engine (`--boot`, the boot task, a job's `--launch` with no session) owns itself and has no host to lose.
/// PC 2, 7 October 2026, 19:09 to 19:11 BST: four engines started a minute apart each quit 3 s after "node started" with
/// "the window host went away (stdin closed)", their parent having closed the pipe at once.
pub fn stdin_close_quits(wrapper: bool, headless: bool) -> bool {
wrapper && !headless
}
// ---- the boot task ---------------------------------------------------------------------------------------------------
fn ps_quote(s: &str) -> String {
s.replace('\'', "''")
}
/// The PowerShell that registers the boot task: trigger at system start, principal the signed-in user with the S4U
/// logon (runs with nobody logged on, no password stored), limited run level, no time limit, one instance, hidden;
/// the action is the installed exe with `--launch --data-root <root>`.
pub fn register_script(exe: &Path, data_root: &Path) -> String {
let exe_s = ps_quote(&exe.display().to_string());
let dir = exe.parent().map(|d| ps_quote(&d.display().to_string())).unwrap_or_default();
let root = ps_quote(&data_root.display().to_string());
format!(
"$a = New-ScheduledTaskAction -Execute '{exe_s}' -Argument '--launch --data-root \"{root}\"' -WorkingDirectory '{dir}'\r\n\
$t = New-ScheduledTaskTrigger -AtStartup\r\n\
$t.Delay = 'PT30S'\r\n\
$p = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType S4U -RunLevel Limited\r\n\
$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Seconds 0) -MultipleInstances IgnoreNew -StartWhenAvailable -Hidden\r\n\
Register-ScheduledTask -TaskName '{name}' -Action $a -Trigger $t -Principal $p -Settings $s -Force | Out-Null\r\n\
exit 0\r\n",
name = TASK_NAME
)
}
/// The PowerShell that says whether the boot task is registered, enabled and its action's exe present (exit 0).
pub fn query_command() -> String {
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 that removes the task (an uninstall, or Start at login switched off).
pub fn remove_command() -> String {
format!("Unregister-ScheduledTask -TaskName '{TASK_NAME}' -Confirm:$false -ErrorAction SilentlyContinue; exit 0")
}
fn powershell(command: &str, limit: Duration) -> Option<(bool, String)> {
let mut c = Command::new(crate::platform::tool("powershell"));
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command]);
crate::platform::quiet(&mut c);
let out = crate::detect::run_timeout(&mut c, None, limit)?;
// run_timeout folds the streams; the exit code is read again through a second probe when needed
Some((true, out))
}
/// Is the boot task registered (Windows only; false elsewhere)?
pub fn registered() -> bool {
if !cfg!(windows) {
return false;
}
let mut c = Command::new(crate::platform::tool("powershell"));
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &query_command()]);
crate::platform::quiet(&mut c);
c.status().map(|s| s.success()).unwrap_or(false)
}
/// Registers the boot task unelevated (the user's own task). Ok(true) registered now, Ok(false) already there,
/// Err(why) when Windows refused (an account without the batch-logon right: the one approved step registers it).
pub fn ensure_registered(exe: &Path, data_root: &Path) -> Result<bool, String> {
if !cfg!(windows) {
return Ok(false);
}
if registered() {
return Ok(false);
}
let script = register_script(exe, data_root);
let (_, out) = powershell(&script, Duration::from_secs(60)).ok_or("powershell did not answer")?;
if registered() {
Ok(true)
} else {
Err(format!("the boot task was not registered: {}", out.lines().last().unwrap_or("no reason given").trim()))
}
}
/// The installed exe the task's action names (never a scratch copy), as the Power Helper picks it.
pub fn task_exe(running: &Path) -> PathBuf {
crate::powertask::task_exe(running, &crate::powertask::install_candidates())
}
// ---- the bridge -------------------------------------------------------------------------------------------------------
/// The engine already running under the user's data root: its URL when app.url names one that answers api/state.
pub fn running_engine(app_dir: &Path) -> Option<String> {
let url = std::fs::read_to_string(app_dir.join("app.url")).ok()?.trim().to_string();
if !url.starts_with("http://127.0.0.1:") {
return None;
}
let state = api_get(&url, "api/state")?;
let v: serde_json::Value = serde_json::from_str(state.trim()).ok()?;
v.get("version").and_then(|x| x.as_str()).map(|_| url)
}
fn api_get(url: &str, path: &str) -> Option<String> {
crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-fsS", "--max-time", "4", &format!("{url}{path}")]), None, Duration::from_secs(6)).filter(|o| !o.trim().is_empty())
}
fn api_post(url: &str, path: &str) -> bool {
crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-fsS", "--max-time", "6", "-X", "POST", "-H", "Content-Type: application/json", "-d", "{}", &format!("{url}{path}")]), None, Duration::from_secs(8)).is_some()
}
/// The host's stdin line as the engine's API path; None for a line the bridge does not relay.
pub fn command_path(line: &str) -> Option<&'static str> {
match line.trim() {
"quit" => Some("api/quit"),
"pause" => Some("api/pause"),
"resume" => Some("api/resume"),
"detect" => Some("api/detect"),
_ => None,
}
}
/// The STATE line the host reads (engine.rs wrapper_state), built from the engine's api/state reply.
pub fn state_line(api_state: &serde_json::Value) -> String {
let g = |p: &[&str]| -> serde_json::Value {
let mut v = api_state;
for k in p {
v = match v.get(k) {
Some(x) => x,
None => return serde_json::Value::Null,
};
}
v.clone()
};
serde_json::json!({
"phase": g(&["phase"]), "mining": g(&["mining", "state"]), "paused": g(&["mining", "paused"]),
"hash_total": g(&["mining", "hash_total"]), "accepted_total": g(&["mining", "accepted_total"]),
"node": g(&["node", "state"]), "blocks": g(&["node", "blocks"]), "peers": g(&["node", "peers"]), "quitting": g(&["quitting"]),
"update": g(&["update", "available"]), "bridge": true,
})
.to_string()
}
/// What the bridge does after one poll: carry on, or end (with EXIT when the engine is gone; silently when the host
/// closed its end, which leaves the engine mining).
#[derive(Debug, PartialEq, Eq)]
pub enum BridgeStep {
Continue,
EngineGone,
HostGone,
}
pub fn bridge_step(misses: u32, stdin_closed: bool) -> BridgeStep {
if stdin_closed {
BridgeStep::HostGone
} else if misses >= BRIDGE_MISSES {
BridgeStep::EngineGone
} else {
BridgeStep::Continue
}
}
/// Runs the bridge until the host or the engine goes. Returns the process exit code.
pub fn run_bridge(url: &str) -> i32 {
use std::io::{BufRead, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
println!("URL {url}");
let _ = std::io::stdout().flush();
let closed = Arc::new(AtomicBool::new(false));
{
let closed = closed.clone();
let url = url.to_string();
std::thread::spawn(move || {
let stdin = std::io::stdin();
for line in stdin.lock().lines() {
let Ok(l) = line else { break };
if let Some(p) = command_path(&l) {
let _ = api_post(&url, p);
}
}
closed.store(true, Ordering::Relaxed);
});
}
let mut misses = 0u32;
loop {
match api_get(url, "api/state").and_then(|s| serde_json::from_str::<serde_json::Value>(s.trim()).ok()) {
Some(v) => {
misses = 0;
println!("STATE {}", state_line(&v));
let _ = std::io::stdout().flush();
if v.get("quitting").and_then(|q| q.as_bool()).unwrap_or(false) {
// the engine is leaving (Quit from the tray relayed above, or its own update): the host wants EXIT
std::thread::sleep(Duration::from_secs(2));
misses = BRIDGE_MISSES;
}
}
None => misses += 1,
}
match bridge_step(misses, closed.load(Ordering::Relaxed)) {
BridgeStep::Continue => std::thread::sleep(Duration::from_secs(BRIDGE_POLL_S)),
BridgeStep::EngineGone => {
println!("EXIT");
let _ = std::io::stdout().flush();
return 0;
}
BridgeStep::HostGone => return 0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Known-failed first: before 0.3.22 a boot with nobody logged on opened the window host (no desktop, no engine);
/// PC 2 sat 67 minutes idle after its 17:28 BST self-reboot on 7 October 2026.
#[test]
fn before_0322_no_logon_meant_the_host_and_no_engine() {
assert_eq!(legacy_launch_mode(true), LaunchMode::Host, "the host regardless of the session");
assert_eq!(launch_mode(None, true), LaunchMode::Headless, "0.3.22: no session, the engine runs headless");
assert_eq!(launch_mode(Some(""), true), LaunchMode::Headless);
}
/// Known-failed first: before 0.3.22 every `--wrapper` engine quit on a closed stdin, whoever had started it.
#[test]
fn a_headless_engine_never_reads_a_closed_stdin_as_the_host_leaving() {
assert!(stdin_close_quits(true, false), "the window host's own engine: the host left, the engine follows (by design)");
assert!(!stdin_close_quits(true, true), "0.3.22: headless, even with --wrapper on the line, stays up");
assert!(!stdin_close_quits(false, false), "an engine with no host never had a stdin to lose");
assert!(!stdin_close_quits(false, true));
}
#[test]
fn a_logon_session_keeps_the_window_host_or_the_browser() {
assert_eq!(launch_mode(Some("Console"), true), LaunchMode::Host);
assert_eq!(launch_mode(Some("RDP-Tcp#3"), true), LaunchMode::Host);
assert_eq!(launch_mode(Some("Console"), false), LaunchMode::Browser);
assert!(interactive_session(Some("Console")) && !interactive_session(None));
}
#[test]
fn the_boot_task_runs_at_startup_as_the_user_without_a_logon_and_names_the_data_root() {
let s = register_script(Path::new("C:\\p\\Igneum Miner\\igneum-app.exe"), Path::new("C:\\u\\AppData\\Local\\igneum"));
assert!(s.contains("-Execute 'C:\\p\\Igneum Miner\\igneum-app.exe' -Argument '--launch --data-root \"C:\\u\\AppData\\Local\\igneum\"'"), "{s}");
assert!(s.contains("New-ScheduledTaskTrigger -AtStartup"), "at system start, not at logon");
assert!(s.contains("-LogonType S4U -RunLevel Limited"), "the user's own token with nobody logged on, never elevated");
assert!(s.contains("-ExecutionTimeLimit (New-TimeSpan -Seconds 0)") && s.contains("-MultipleInstances IgnoreNew") && s.contains("-Hidden"));
assert!(s.contains(&format!("-TaskName '{TASK_NAME}'")));
assert!(!s.contains("powershell.exe") && !s.contains("cmd /c"), "the action is the exe itself: no console window at boot");
let q = query_command();
assert!(q.contains("Test-Path") && q.contains("-ne 'Disabled'") && q.contains("exit 1"), "{q}");
assert!(remove_command().starts_with("Unregister-ScheduledTask -TaskName 'Igneum Miner (boot)'"));
}
#[test]
fn the_bridge_relays_the_hosts_commands_and_nothing_else() {
assert_eq!(command_path("quit"), Some("api/quit"));
assert_eq!(command_path(" pause \r"), Some("api/pause"));
assert_eq!(command_path("resume"), Some("api/resume"));
assert_eq!(command_path("detect"), Some("api/detect"));
assert_eq!(command_path("elevated ok"), None, "the elevated answers belong to a real engine's host");
assert_eq!(command_path("rm -rf"), None);
}
#[test]
fn the_bridge_state_line_is_the_wrapper_state() {
let st: serde_json::Value = serde_json::from_str(r#"{"version":"0.3.22","phase":"dashboard","mining":{"state":"mining","paused":false,"hash_total":121.0,"accepted_total":95511},"node":{"state":"synced","blocks":165529,"peers":4},"quitting":false,"update":{"available":false}}"#).unwrap();
let line: serde_json::Value = serde_json::from_str(&state_line(&st)).unwrap();
assert_eq!(line["phase"], "dashboard");
assert_eq!(line["mining"], "mining");
assert_eq!(line["hash_total"], 121.0);
assert_eq!(line["accepted_total"], 95511);
assert_eq!(line["node"], "synced");
assert_eq!(line["blocks"], 165529);
assert_eq!(line["quitting"], false);
assert_eq!(line["bridge"], true);
let partial: serde_json::Value = serde_json::from_str(r#"{"version":"0.3.22"}"#).unwrap();
assert!(state_line(&partial).contains("\"phase\":null"), "a missing field is null, never a panic");
}
#[test]
fn the_bridge_ends_with_exit_when_the_engine_is_gone_and_silently_when_the_host_is() {
assert_eq!(bridge_step(0, false), BridgeStep::Continue);
assert_eq!(bridge_step(BRIDGE_MISSES - 1, false), BridgeStep::Continue);
assert_eq!(bridge_step(BRIDGE_MISSES, false), BridgeStep::EngineGone, "the host then starts a fresh --wrapper, which becomes a full engine");
assert_eq!(bridge_step(0, true), BridgeStep::HostGone, "the window closed: the headless engine keeps mining");
assert_eq!(bridge_step(BRIDGE_MISSES, true), BridgeStep::HostGone);
}
}

View file

@ -2587,7 +2587,7 @@ impl Engine {
let _ = std::fs::create_dir_all(&dir);
let script = dir.join("register-power-task.ps1");
let installed: Vec<PathBuf> = crate::powertask::install_candidates();
match std::env::current_exe().map(|exe| std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), crate::powertask::register_script(&crate::powertask::task_exe(&exe, &installed)).as_bytes()].concat())) {
match std::env::current_exe().map(|exe| std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), [crate::powertask::register_script(&crate::powertask::task_exe(&exe, &installed)), crate::boot::register_script(&crate::powertask::task_exe(&exe, &installed), &crate::platform::fixed_data_root()).replace("exit 0\r\n", "")].concat().as_bytes()].concat())) {
Ok(Ok(())) => format!("{} & \"{}\" -NoProfile -ExecutionPolicy Bypass -File \"{}\"", cmds.join(" & "), crate::platform::tool("powershell").display(), script.display()),
_ => cmds.join(" & "),
}

View file

@ -50,6 +50,7 @@ mod card;
mod drivertable;
mod drivers;
mod bootcheck;
mod boot;
use std::io::{BufRead, Write};
use std::sync::mpsc::channel;
@ -57,7 +58,7 @@ use std::sync::Arc;
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let wrapper = args.iter().any(|a| a == "--wrapper");
let wrapper = args.iter().any(|a| a == "--wrapper") && !args.iter().any(|a| a == "--boot");
let sweep = args.iter().any(|a| a == "--sweep");
if sweep {
// the runtime reads it (config::Runtime::from_env); the engine starts at once and quits after the sweep
@ -74,22 +75,45 @@ fn main() {
let dir = powertask::helper_dir();
std::process::exit(powertask::run_helper(&dir));
}
// 0.3.22: `--data-root <path>` pins the data root (the boot task spells it out: an S4U session may not load the
// profile); `--boot` is the headless engine (no window host, no browser); `--launch` with no interactive session is
// `--boot` (src/boot.rs launch_mode: PC 2 waited 67 minutes for a logon on 7 October 2026)
if let Some(i) = args.iter().position(|a| a == "--data-root") {
if let Some(p) = args.get(i + 1) {
std::env::set_var("IGNEUM_APP_DATA", p);
}
}
let boot = args.iter().any(|a| a == "--boot");
let mut no_open = no_open || boot;
if args.iter().any(|a| a == "--launch") {
if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) {
let host = dir.join("Igneum Miner.exe");
if host.exists() {
let mut c = std::process::Command::new(&host);
c.current_dir(&dir);
crate::platform::quiet(&mut c); // no console of our own for the window host (it is a GUI program; the flag only governs a console)
if c.spawn().is_ok() {
return;
let dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf()));
let host = dir.as_ref().map(|d| d.join("Igneum Miner.exe"));
let host_exists = host.as_ref().map(|h| h.exists()).unwrap_or(false);
match boot::launch_mode(std::env::var("SESSIONNAME").ok().as_deref(), host_exists) {
boot::LaunchMode::Host => {
if let (Some(dir), Some(host)) = (dir.as_ref(), host.as_ref()) {
let mut c = std::process::Command::new(host);
c.current_dir(dir);
crate::platform::quiet(&mut c); // no console of our own for the window host (it is a GUI program; the flag only governs a console)
if c.spawn().is_ok() {
return;
}
}
}
boot::LaunchMode::Headless => no_open = true,
boot::LaunchMode::Browser => {}
}
// no window host: the engine runs on its own and the dashboard opens in the default browser
// no window host (or no logon): the engine runs on its own; the dashboard opens in the default browser only in a session
}
platform::clear_quarantine();
let runtime = config::Runtime::from_env();
// 0.3.22: a window host's engine that finds the boot engine already running under this data root becomes the
// bridge to it (src/boot.rs) instead of a second engine on the same ports and folder
if wrapper && !sweep {
if let Some(url) = boot::running_engine(&runtime.app_dir) {
std::process::exit(boot::run_bridge(&url));
}
}
let _ = std::fs::create_dir_all(&runtime.app_dir);
let _ = std::fs::create_dir_all(&runtime.log_dir);
platform::lock_permissions(&runtime.app_dir, true);
@ -176,7 +200,8 @@ fn main() {
_ => {}
}
}
if wrapper {
// 0.3.22: only an engine a host attached quits with the host (src/boot.rs stdin_close_quits)
if boot::stdin_close_quits(wrapper, boot) {
shared.send(engine::Cmd::Quit("the window host went away (stdin closed)"));
}
});

View file

@ -199,6 +199,7 @@ impl Updater {
// installed app's (the dry run of 6 October 2026 raised a second UAC prompt from here)
if !shared.runtime.sweep_only {
firewall_first_run(shared);
boot_task_first_run(shared);
}
}
u.failed_versions = std::fs::read_to_string(u.failed_path()).ok().and_then(|t| serde_json::from_str::<Vec<String>>(&t).ok()).unwrap_or_default();
@ -1057,6 +1058,24 @@ fn under_program_files(dir: &Path) -> bool {
/// Windows, per-user installs: the inbound firewall rule for igneumd.exe needs administrator approval once. Asked on
/// the first run only, in a thread; declined or unanswered, the node still dials out and mines (other nodes cannot
/// dial in), and it is never asked again. The administrator installer of 0.3.2 and earlier added the rule itself.
/// Windows: the boot task (src/boot.rs) registered at every engine start when it is missing, in a thread, as the
/// user's own task (no prompt). An account Windows refuses gets it in the one approved step with the Power Helper.
#[cfg(windows)]
fn boot_task_first_run(shared: &Arc<Shared>) {
let Some(exe) = std::env::current_exe().ok() else { return };
let exe = crate::boot::task_exe(&exe);
if !exe.is_file() {
return;
}
let root = crate::platform::fixed_data_root();
let shared = shared.clone();
std::thread::spawn(move || match crate::boot::ensure_registered(&exe, &root) {
Ok(true) => shared.log(&format!("boot start: the task '{}' is registered: the engine starts at boot without a logon ({} --launch --data-root {})", crate::boot::TASK_NAME, exe.display(), root.display())),
Ok(false) => {}
Err(e) => shared.log(&format!("boot start: not registered ({e}); the app starts at logon only until Power control's one approved step registers it")),
});
}
#[cfg(windows)]
fn firewall_first_run(shared: &Arc<Shared>) {
let flag = shared.runtime.app_dir.join("firewall-rule.json");
@ -1261,6 +1280,19 @@ mod return_tests {
}
}
/// 0.3.22: an update applied with nobody logged on (the boot engine) returns headless: the helper's Launch runs
/// `igneum-app.exe --launch`, which with no interactive session runs the engine itself, so the same sequence returns
/// the app without a logon (the known-failed form first: before 0.3.22 that launch opened the window host, which has
/// no desktop in session 0, and nothing mined until a logon)
#[test]
fn after_an_update_with_no_logon_the_relaunch_is_headless() {
assert_eq!(crate::boot::legacy_launch_mode(true), crate::boot::LaunchMode::Host);
assert_eq!(crate::boot::launch_mode(None, true), crate::boot::LaunchMode::Headless);
let steps = return_sequence(0, "0.3.22", true, |_| Some("0.3.22".into()));
assert_eq!(steps[0], ReturnStep::Launch, "the helper's launch is the same line; the session decides what it runs");
assert!(matches!(steps.last().unwrap(), ReturnStep::Done { ok: true, .. }));
}
/// The PowerShell mirrors the sequence: every step has its line, in order, and the installer is told the helper relaunches.
#[test]
fn the_windows_helper_carries_every_step() {

View file

@ -0,0 +1,53 @@
# PC 2, 7 October 2026, 18:53:34 BST: a job cancel ended the process tree one second after the 0.3.21 installer had
# started over the running 0.3.20 app; nothing of the app is up and the install may be half-written. Through the relay
# agent (the one live channel): reads the install state (the installed exe's version, the kept installer and log under
# artefacts\), then starts Igneum Miner if the install is whole, else re-runs the kept installer silently (per-user, no
# prompt; /IGNOTA=2 so the installer itself opens nothing) and starts it; reads back the version an engine answers.
# Sends no quit, pause or resume to the app: a repair install's own stop step is the installer's.
$ErrorActionPreference = 'Continue'
function Say([string]$m) { Write-Host ((Get-Date -Format 'HH:mm:ss') + ' ' + $m) }
$install = Join-Path $env:LOCALAPPDATA 'Programs\Igneum Miner'
$appDir = Join-Path $env:LOCALAPPDATA 'igneum\app'
$art = Join-Path $env:LOCALAPPDATA 'igneum\artefacts'
$kept = Join-Path $art 'Igneum-Miner-Setup-0.3.21-take4.exe'
$klog = Join-Path $art 'install-0321-take4.log'
function App-Version { $f = Join-Path $appDir 'app.url'; if (-not (Test-Path $f)) { return '' }; try { $u = (Get-Content $f -Raw).Trim(); $st = Invoke-RestMethod -Uri ($u + 'api/state') -TimeoutSec 5 -UseBasicParsing; return [string]$st.version } catch { return '' } }
function Exe-Version([string]$p) { if (-not (Test-Path $p)) { return 'missing' }; try { $o = & $p --version 2>&1 | ForEach-Object { "$_" } | Select-Object -First 1; return "$o" } catch { return 'no answer' } }
Say ('RESULT state-before api ' + $(if ($v = App-Version) { $v } else { 'nothing answers' }))
foreach ($n in @('igneum-app.exe', 'Igneum Miner.exe', 'igneumd.exe', 'igneum-miner.exe', 'igneum-worker-cuda.exe', 'unins000.exe', 'igneum-app.json')) { $p = Join-Path $install $n; Say ('RESULT file ' + $n + ' ' + $(if (Test-Path $p) { (Get-Item $p).Length.ToString() + ' B ' + (Get-Item $p).LastWriteTime.ToUniversalTime().ToString('o') } else { 'MISSING' })) }
Say ('RESULT exe-version igneum-app.exe: ' + (Exe-Version (Join-Path $install 'igneum-app.exe')))
Say ('RESULT exe-version Igneum Miner.exe: ' + (Exe-Version (Join-Path $install 'Igneum Miner.exe')))
Say ('RESULT exe-version igneumd.exe: ' + (Exe-Version (Join-Path $install 'igneumd.exe')))
foreach ($p in @(Get-Process -Name 'Igneum Miner', 'igneum-app', 'igneumd', 'igneum-miner', 'igneum-worker-cuda' -ErrorAction SilentlyContinue)) { Say ('RESULT process ' + $p.ProcessName + ' pid ' + $p.Id + ' since ' + $p.StartTime.ToString('o')) }
Say ('RESULT kept installer ' + $(if (Test-Path $kept) { (Get-Item $kept).Length.ToString() + ' B ' + (Get-FileHash $kept -Algorithm SHA256).Hash.ToLower().Substring(0, 12) } else { 'MISSING' }))
if (Test-Path $klog) { Say 'RESULT kept install log (last 12):'; Get-Content $klog -Tail 12 -ErrorAction SilentlyContinue | ForEach-Object { Say (' ' + $_) } } else { Say 'RESULT kept install log: missing' }
foreach ($n in @('update-pending.json', 'update-result.json', 'failed-versions.json')) { $p = Join-Path $appDir $n; if (Test-Path $p) { Say ('RESULT ' + $n + ': ' + ((Get-Content $p -Raw) -replace '\s+', ' ').Substring(0, [Math]::Min(300, ((Get-Content $p -Raw) -replace '\s+', ' ').Length))) } }
$ota = Join-Path $appDir 'ota-apply.log'; if (Test-Path $ota) { Say 'RESULT ota-apply.log (last 6):'; Get-Content $ota -Tail 6 | ForEach-Object { Say (' ' + $_) } }
# whole = the five exes present and igneum-app.exe answers --version with 0.3.21
$ver = Exe-Version (Join-Path $install 'igneum-app.exe')
$whole = ($ver -match '^igneum-app 0\.3\.21') -and (Test-Path (Join-Path $install 'Igneum Miner.exe')) -and (Test-Path (Join-Path $install 'igneumd.exe')) -and (Test-Path (Join-Path $install 'igneum-miner.exe')) -and (Test-Path (Join-Path $install 'igneum-app.json'))
Say ('RESULT install-whole ' + $whole + ' (' + $ver + ')')
if (App-Version) { Say 'an engine answers already; nothing started'; exit 0 }
if (-not $whole) {
if (-not (Test-Path $kept)) { Say 'RESULT repair: the kept installer is missing and the install is not whole; a hand install is needed'; exit 4 }
$log2 = Join-Path $art 'install-0321-repair.log'
Say ('repairing: ' + $kept + ' /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /IGNOTA=2 /LOG=' + $log2)
# console: the Inno Setup installer is a GUI program (no console), /VERYSILENT shows nothing
$p = Start-Process -FilePath $kept -ArgumentList @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', '/IGNOTA=2', ('/LOG="' + $log2 + '"')) -Wait -PassThru
Say ('RESULT repair-installer-exit ' + $p.ExitCode)
if (Test-Path $log2) { Get-Content $log2 -Tail 5 | ForEach-Object { Say (' ' + $_) } }
Say ('RESULT exe-version after repair: ' + (Exe-Version (Join-Path $install 'igneum-app.exe')))
}
$exe = Join-Path $install 'igneum-app.exe'
if (-not (Test-Path $exe)) { Say 'RESULT start: igneum-app.exe missing after the repair'; exit 5 }
$t0 = Get-Date
# console: igneum-app.exe is a windows-subsystem program (no console); the window host it opens is its own
Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $install | Out-Null
$deadline = (Get-Date).AddSeconds(120); $v = ''
while ((Get-Date) -lt $deadline) { $v = App-Version; if ($v) { break }; Start-Sleep -Seconds 3 }
if ($v) { Say ('RESULT started: app ' + $v + ' answered api/state after ' + [int]((Get-Date) - $t0).TotalSeconds + ' s'); exit 0 }
Say 'RESULT started: no engine answered api/state inside 120 s'
foreach ($p in @(Get-Process -Name 'Igneum Miner', 'igneum-app', 'igneumd' -ErrorAction SilentlyContinue)) { Say (' process ' + $p.ProcessName + ' pid ' + $p.Id) }
$alog = Get-ChildItem (Join-Path $env:LOCALAPPDATA 'igneum\logs') -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($alog) { Say (' newest app log ' + $alog.Name); Get-Content $alog.FullName -Tail 15 | ForEach-Object { Say (' ' + $_) } }
exit 6