Merge boot-start-22 6c6bd1f3 into release-0.3.22 (the engine starts at boot without a logon on Windows: the per-user boot task, headless --launch, the host bridge; a headless engine never reads a closed stdin as the host leaving)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
e7d6d327d2
5 changed files with 471 additions and 12 deletions
349
app/igneum-app/src/boot.rs
Normal file
349
app/igneum-app/src/boot.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -2607,7 +2607,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(" & "),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
53
relay/playbooks/install-repair.ps1
Normal file
53
relay/playbooks/install-repair.ps1
Normal 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
|
||||
Loading…
Reference in a new issue