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:
parent
4b147d34e0
commit
2670c11caf
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2587,7 +2587,7 @@ impl Engine {
|
||||||
let _ = std::fs::create_dir_all(&dir);
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
let script = dir.join("register-power-task.ps1");
|
let script = dir.join("register-power-task.ps1");
|
||||||
let installed: Vec<PathBuf> = crate::powertask::install_candidates();
|
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()),
|
Ok(Ok(())) => format!("{} & \"{}\" -NoProfile -ExecutionPolicy Bypass -File \"{}\"", cmds.join(" & "), crate::platform::tool("powershell").display(), script.display()),
|
||||||
_ => cmds.join(" & "),
|
_ => cmds.join(" & "),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ mod card;
|
||||||
mod drivertable;
|
mod drivertable;
|
||||||
mod drivers;
|
mod drivers;
|
||||||
mod bootcheck;
|
mod bootcheck;
|
||||||
|
mod boot;
|
||||||
|
|
||||||
use std::io::{BufRead, Write};
|
use std::io::{BufRead, Write};
|
||||||
use std::sync::mpsc::channel;
|
use std::sync::mpsc::channel;
|
||||||
|
|
@ -57,7 +58,7 @@ use std::sync::Arc;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
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");
|
let sweep = args.iter().any(|a| a == "--sweep");
|
||||||
if sweep {
|
if sweep {
|
||||||
// the runtime reads it (config::Runtime::from_env); the engine starts at once and quits after the 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();
|
let dir = powertask::helper_dir();
|
||||||
std::process::exit(powertask::run_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 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 dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf()));
|
||||||
let host = dir.join("Igneum Miner.exe");
|
let host = dir.as_ref().map(|d| d.join("Igneum Miner.exe"));
|
||||||
if host.exists() {
|
let host_exists = host.as_ref().map(|h| h.exists()).unwrap_or(false);
|
||||||
let mut c = std::process::Command::new(&host);
|
match boot::launch_mode(std::env::var("SESSIONNAME").ok().as_deref(), host_exists) {
|
||||||
c.current_dir(&dir);
|
boot::LaunchMode::Host => {
|
||||||
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 let (Some(dir), Some(host)) = (dir.as_ref(), host.as_ref()) {
|
||||||
if c.spawn().is_ok() {
|
let mut c = std::process::Command::new(host);
|
||||||
return;
|
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();
|
platform::clear_quarantine();
|
||||||
let runtime = config::Runtime::from_env();
|
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.app_dir);
|
||||||
let _ = std::fs::create_dir_all(&runtime.log_dir);
|
let _ = std::fs::create_dir_all(&runtime.log_dir);
|
||||||
platform::lock_permissions(&runtime.app_dir, true);
|
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)"));
|
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)
|
// installed app's (the dry run of 6 October 2026 raised a second UAC prompt from here)
|
||||||
if !shared.runtime.sweep_only {
|
if !shared.runtime.sweep_only {
|
||||||
firewall_first_run(shared);
|
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();
|
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
|
/// 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
|
/// 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.
|
/// 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)]
|
#[cfg(windows)]
|
||||||
fn firewall_first_run(shared: &Arc<Shared>) {
|
fn firewall_first_run(shared: &Arc<Shared>) {
|
||||||
let flag = shared.runtime.app_dir.join("firewall-rule.json");
|
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.
|
/// The PowerShell mirrors the sequence: every step has its line, in order, and the installer is told the helper relaunches.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_windows_helper_carries_every_step() {
|
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