diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index bce74fb64..b3eb9c021 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -5446,12 +5446,25 @@ impl Engine { self.shared.log("stopped"); if self.wrapper { println!("STATE {}", json!({ "phase": "quit", "quitting": true })); - println!("EXIT"); + println!("{}", exit_line(self.quit_source)); let _ = std::io::stdout().flush(); } } } +/// The last line the engine prints to its window host. "EXIT update" when an installer or the app's own OTA asked for +/// the quit: the host then quits itself so the installer can replace it too, and never starts the old exe again under +/// the installer (PC 2, 7 October 2026, 20:54 BST: a job's silent 0.3.22 install quit the engine through api/quit, the +/// 0.3.21 host's restart ladder started the old engine 10 s later, Inno could not replace the locked host, and every +/// file stayed 0.3.21). A plain "EXIT" for every other quit (the tray, a crash of the node the engine gave up on). +pub fn exit_line(quit_source: &str) -> &'static str { + if quit_source.contains("api/quit") || quit_source.contains("installer") || quit_source.contains("update") { + "EXIT update" + } else { + "EXIT" + } +} + /// The watts a card's cap asks for: power_pct of the default limit, inside the card's min and max. /// the project lead, 5 October 2026: "if we don't have to ask then don't ask". The NVIDIA power cap and the efficiency sweep need /// administrator rights (one UAC prompt on Windows, pkexec on Linux); the engine builds an elevated command only when @@ -5778,6 +5791,34 @@ mod resume_tests { #[cfg(test)] mod tests { + /// PC 2, 7 October 2026, 20:54 BST, known-failed first: before 0.3.23 every quit printed the same "EXIT", so the host + /// could not tell an installer's stop from a death and started the old engine again under the installer + #[test] + fn an_installers_quit_tells_the_host_not_to_restart() { + assert_eq!(super::exit_line("POST /api/quit (a local caller with the token: the installer, the OTA apply, or a script that read app.url)"), "EXIT update"); + assert_eq!(super::exit_line("the window host (quit on stdin: the tray menu or the installer)"), "EXIT update", "the installer's quit through the host's stdin counts too"); + assert_eq!(super::exit_line("the --sweep run (every card done)"), "EXIT"); + assert_eq!(super::exit_line("unknown"), "EXIT"); + // the host's side, read from its source: an "EXIT update" line ends the window instead of the restart ladder, and the + // engine-gone branch asks the same question before it schedules a restart + let host = include_str!("../../windows/host.cpp"); + assert!(host.contains("g_exitForUpdate = true"), "the host remembers an update exit"); + assert!(host.contains("if (g_exitForUpdate) { DestroyWindow(hwnd); return 0; }"), "the engine-gone branch quits the window under an installer, never restarts"); + assert!(host.contains("line->rfind(\"EXIT\", 0) == 0"), "the host reads EXIT with or without a reason"); + } + + /// The installer's stop step ends the window host first, by its path, so no restart ladder can start the old engine while + /// the files are replaced (known-failed first: the 0.3.21 script quit the engine, waited, and ended names only afterwards) + #[test] + fn the_installers_stop_step_ends_the_host_first_by_path() { + let s = include_str!("../../../packaging/windows/stop-igneum.ps1"); + let host = s.find("Igneum Miner.exe").expect("the host by its file name"); + let quit = s.find("api/quit").expect("the quit through the API"); + assert!(host < quit, "the host is ended before the engine is asked to quit"); + assert!(s.contains("ExecutablePath") && s.contains("Win32_Process"), "processes are matched by their path under the install folder, never by a bare name alone"); + assert!(s.contains("Stop-Process -Id"), "ended by pid"); + } + /// PC 2, 7 October 2026, 14:39Z: "the helper did not run sequence 1 within 15 s (no line in ...helper.log)" is a FAULT line #[test] fn a_helper_that_does_not_answer_is_a_fault_line() { diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs index a3a126b6e..f52ced1bc 100644 --- a/app/igneum-app/src/ota.rs +++ b/app/igneum-app/src/ota.rs @@ -1159,6 +1159,27 @@ fn previous_dir_for(install_dir: &Path) -> PathBuf { /// How long the helper waits for an engine to answer api/state after a launch, and how often it asks. pub const RETURN_READY_S: u64 = 120; pub const RETURN_POLL_S: u64 = 3; +/// The installer's marker (packaging/windows/Igneum-Miner.iss SetMarker): no relaunch while it is there and younger than +/// this; an older one is a stale marker (an installer that died) and is ignored with a FAULT line. +pub const INSTALL_MARKER: &str = "install-running.flag"; +pub const INSTALL_MARKER_STALE_S: u64 = 15 * 60; + +/// May the app be launched again now? false while an installer is running (its marker present and fresh). PC 2, +/// 7 October 2026, 20:54 BST: a job's silent install quit the engine, the window host started the old engine 10 s later, +/// and every file stayed 0.3.21 because the host held them. `marker_age_s` is None when there is no marker. +pub fn relaunch_allowed(marker_age_s: Option) -> bool { + match marker_age_s { + None => true, + Some(age) => age > INSTALL_MARKER_STALE_S, + } +} + +/// The marker's age in seconds under the app dir, None when absent. +pub fn install_marker_age(app_dir: &Path) -> Option { + let m = std::fs::metadata(app_dir.join(INSTALL_MARKER)).ok()?; + let t = m.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); + Some(crate::platform::unix_now().saturating_sub(t)) +} /// One step of the helper after the installer exits. #[derive(Debug, Clone, PartialEq)] @@ -1308,6 +1329,27 @@ mod return_tests { assert!(matches!(steps.last().unwrap(), ReturnStep::Done { ok: true, .. })); } + /// Tonight's shape on PC 2 (7 October 2026, 20:54 BST), known-failed first: an installer running, the engine gone, and the + /// relaunch went ahead; 0.3.23 holds while the marker is there and resumes when it clears + #[test] + fn no_relaunch_while_an_installer_runs_and_a_relaunch_when_it_clears() { + assert!(relaunch_allowed(None), "the old rule in effect: nothing held the relaunch (no marker existed)"); + assert!(!relaunch_allowed(Some(0)), "an installer just wrote its marker: hold"); + assert!(!relaunch_allowed(Some(600)), "ten minutes into a slow install: still held"); + assert!(relaunch_allowed(Some(INSTALL_MARKER_STALE_S + 1)), "a marker an installer left behind when it died: ignored"); + assert!(relaunch_allowed(None), "the marker cleared at the installer's end: relaunch"); + let h = WIN_HELPER; + let wait = h.find("function WaitInstallerClear()").expect("the helper waits"); + let launch = h.find("function Launch()").unwrap(); + assert!(wait < launch && h[launch..].contains("WaitInstallerClear"), "every launch of the helper waits for the installer first"); + assert!(h.contains("install-running.flag") && h.contains("-gt 900"), "the same marker and the same stale rule as relaunch_allowed"); + // the installer writes and clears it, and the host holds its restart on it + let iss = include_str!("../../../packaging/windows/Igneum-Miner.iss"); + assert!(iss.contains("install-running.flag") && iss.contains("SetMarker;") && iss.contains("if CurStep = ssDone then ClearMarker") && iss.contains("SetupMutex=IgneumMinerSetup")); + let host = include_str!("../../windows/host.cpp"); + assert!(host.contains("install-running.flag") && host.contains("installerRunning()"), "the host's restart ladder holds while the marker is there"); + } + /// 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() { @@ -1618,9 +1660,23 @@ function WaitReady([string]$want, [int]$limitS) { } return -1 } +function WaitInstallerClear() { + # no relaunch while another installer runs (its marker beside app.url, written by the installer's PrepareToInstall and + # removed at its end); a marker older than 15 min is an installer that died: ignored with a FAULT line (src/ota.rs relaunch_allowed) + $m = Join-Path $AppDir 'install-running.flag' + $t0 = Get-Date + while (Test-Path $m) { + $age = ((Get-Date) - (Get-Item $m).LastWriteTime).TotalSeconds + if ($age -gt 900) { Log ('FAULT update-return: a stale installer marker (' + [int]$age + ' s old) was ignored'); break } + if (((Get-Date) - $t0).TotalMinutes -gt 20) { Log 'FAULT update-return: an installer marker stayed 20 min; relaunching anyway'; break } + Log 'relaunch held: another installer is running (install-running.flag present)' + Start-Sleep -Seconds 5 + } +} function Launch() { $exe = Join-Path $InstallDir 'igneum-app.exe' if (-not (Test-Path $exe)) { Log ("nothing to start: " + $exe + " is missing"); return $false } + WaitInstallerClear Log ("starting " + $exe + " --launch") # console: igneum-app.exe is a windows-subsystem program (no console); -WindowStyle Hidden would hide the window host it opens Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $InstallDir | Out-Null diff --git a/app/windows/host.cpp b/app/windows/host.cpp index 496aa8a8d..7ecb32767 100644 --- a/app/windows/host.cpp +++ b/app/windows/host.cpp @@ -61,6 +61,7 @@ static bool g_paused = false, g_quitting = false, g_exited = false, g_webviewOk static NOTIFYICONDATAW g_nid = {}; static std::wstring g_trayTitle = L"Igneum Miner"; static ULONGLONG g_quitStarted = 0; +static bool g_exitForUpdate = false; // the engine's last line was "EXIT update": an installer or the OTA stops it; this window goes too, no restart static int g_restarts = 0; // engine restarts inside the current window static ULONGLONG g_restartWindowStart = 0; // when that window opened @@ -280,6 +281,26 @@ static void closeEngine() { if (g_engine) { CloseHandle(g_engine); g_engine = nullptr; } } +// Is an installer running? Its marker (%LOCALAPPDATA%\igneum\app\install-running.flag, written by Igneum-Miner.iss's +// PrepareToInstall and removed at its end) holds this window's restart ladder: the old engine must never start again +// under an installer (PC 2, 7 October 2026, 20:54 BST). A marker older than 15 minutes is an installer that died. +static bool installerRunning() { + wchar_t* local = nullptr; + size_t len = 0; + if (_wdupenv_s(&local, &len, L"LOCALAPPDATA") != 0 || !local) return false; + std::wstring p = std::wstring(local) + L"\\igneum\\app\\install-running.flag"; + free(local); + WIN32_FILE_ATTRIBUTE_DATA fad; + if (!GetFileAttributesExW(p.c_str(), GetFileExInfoStandard, &fad)) return false; + FILETIME now; + GetSystemTimeAsFileTime(&now); + ULARGE_INTEGER a, b; + a.LowPart = fad.ftLastWriteTime.dwLowDateTime; a.HighPart = fad.ftLastWriteTime.dwHighDateTime; + b.LowPart = now.dwLowDateTime; b.HighPart = now.dwHighDateTime; + ULONGLONG ageS = b.QuadPart > a.QuadPart ? (b.QuadPart - a.QuadPart) / 10000000ULL : 0; + return ageS <= 15 * 60; +} + // Starts the engine again after it stopped on its own. Three restarts inside ten minutes come 10 s apart; from the // fourth they come a minute apart, for ever: a miner that sits stopped is a miner lost (plug, tune, play). static void scheduleRestart() { @@ -298,6 +319,14 @@ static void scheduleRestart() { static void restartEngineNow() { KillTimer(g_hwnd, ID_RESTART_TIMER); if (g_quitting || !g_exited) return; + if (installerRunning()) { + // held: an installer is replacing the files; this window ends so the installer can replace it too, and the + // installer's own [Run] step (or the update helper) starts the new app + g_status = L"An update is installing; the app opens again when it is done."; + repaintStatus(); + DestroyWindow(g_hwnd); + return; + } closeEngine(); g_exited = false; g_url.clear(); @@ -416,8 +445,11 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { if (wp == 1) { g_exited = true; if (g_quitting) { DestroyWindow(hwnd); return 0; } - // not a quit of ours: the engine died, or a local caller (the installer, a job) asked it to stop and nothing - // will start it again; this window does (MF-11) + // an installer or the app's own OTA stopped the engine ("EXIT update"): the window ends too, so the installer can + // replace this exe, and the old engine is never started again under it (PC 2, 7 October 2026, 20:54 BST) + if (g_exitForUpdate) { DestroyWindow(hwnd); return 0; } + // not a quit of ours: the engine died, or a local caller (a job) asked it to stop and nothing will start it + // again; this window does (MF-11) scheduleRestart(); return 0; } @@ -437,9 +469,10 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { g_status = widen(line->substr(6)); repaintStatus(); MessageBoxW(hwnd, g_status.c_str(), L"Igneum Miner", MB_OK | MB_ICONERROR); - } else if (*line == "EXIT") { + } else if (line->rfind("EXIT", 0) == 0) { g_exited = true; - if (g_quitting) DestroyWindow(hwnd); + if (line->find("update") != std::string::npos) g_exitForUpdate = true; + if (g_quitting || g_exitForUpdate) DestroyWindow(hwnd); } delete line; return 0; diff --git a/packaging/windows/Igneum-Miner.iss b/packaging/windows/Igneum-Miner.iss index bdab32432..a437f0dbd 100644 --- a/packaging/windows/Igneum-Miner.iss +++ b/packaging/windows/Igneum-Miner.iss @@ -50,7 +50,11 @@ ArchitecturesInstallIn64BitMode=x64compatible PrivilegesRequired=lowest MinVersion=10.0 CloseApplications=yes +CloseApplicationsFilter=*.exe,*.dll RestartApplications=no +; 0.3.23: one installer at a time, and a name the app can read (PC 2, 7 October 2026, 20:54 BST: the host restarted the old +; engine under a running installer); the marker file below is what the engine's helper and the window host hold on +SetupMutex=IgneumMinerSetup [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" @@ -121,12 +125,41 @@ end; // stop-igneum.ps1 when one is installed (Program Files or here), else ours from the payload. Then, when someone is at // the keyboard, offers to remove the Program Files copy (its uninstaller needs one administrator prompt; the data in // %LOCALAPPDATA%\igneum is the same for both, nothing to copy). A silent (over-the-air) install never asks. +// The install marker (0.3.23): \igneum\app\install-running.flag while this installer works; the engine's +// update helper and the window host never relaunch the app while it is there (src/ota.rs relaunch_allowed, host.cpp). +function MarkerPath: String; +begin + Result := ExpandConstant('{localappdata}\igneum\app\install-running.flag'); +end; + +procedure SetMarker; +begin + ForceDirectories(ExtractFileDir(MarkerPath)); + SaveStringToFile(MarkerPath, GetDateTimeString('yyyy/mm/dd hh:nn:ss', '-', ':') + ' ' + '{#AppVersion}', False); +end; + +procedure ClearMarker; +begin + DeleteFile(MarkerPath); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssDone then ClearMarker; +end; + +procedure DeinitializeSetup; +begin + ClearMarker; +end; + function PrepareToInstall(var NeedsRestart: Boolean): String; var StopScript, OldDir: String; ResultCode: Integer; begin Result := ''; + SetMarker; OldDir := OldAdminInstallDir; StopScript := ExpandConstant('{app}\stop-igneum.ps1'); if (not FileExists(StopScript)) and (OldDir <> '') then diff --git a/packaging/windows/stop-igneum.ps1 b/packaging/windows/stop-igneum.ps1 index 8c39c4a78..43ce37287 100644 --- a/packaging/windows/stop-igneum.ps1 +++ b/packaging/windows/stop-igneum.ps1 @@ -1,7 +1,15 @@ -# Stops a running Igneum Miner on this PC: asks the engine to quit through its local API (miners first, then the -# node), waits, then ends whatever is left. Run by the installer before an upgrade and on uninstall; also the -# Start Menu "Stop Igneum Miner" entry. 4 October 2026 (the app version; the 0.2.0 launcher's script is retired). +# Stops a running Igneum Miner on this PC. Run by the installer before an upgrade and on uninstall; also the Start Menu +# "Stop Igneum Miner" entry. 0.3.23 (7 October 2026, PC 2 at 20:54 BST: an installer quit the engine, the window host's +# restart ladder started the old engine again 10 s later, the host stayed locked and no file was replaced): the window +# host goes FIRST, by its path under the install folder, so nothing can restart the engine; then the engine is asked to +# quit through its local API (miners first, then the node), waited for, and whatever of ours is left is ended by path. $ErrorActionPreference = 'Continue' +$install = Split-Path -Parent $MyInvocation.MyCommand.Path +function Ours { @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($install, [StringComparison]::OrdinalIgnoreCase) }) } +foreach ($h in (Ours | Where-Object { $_.Name -eq 'Igneum Miner.exe' })) { + Write-Host "ending the window host (pid $($h.ProcessId)) first, so it cannot start the engine again" + Stop-Process -Id $h.ProcessId -Force -ErrorAction SilentlyContinue +} $urlFile = Join-Path $env:LOCALAPPDATA 'igneum\app\app.url' $asked = $false if (Test-Path $urlFile) { @@ -17,15 +25,28 @@ if (Test-Path $urlFile) { if ($asked) { $deadline = (Get-Date).AddSeconds(50) while ((Get-Date) -lt $deadline) { - $alive = @(Get-Process -Name 'igneum-app', 'igneumd' -ErrorAction SilentlyContinue) + $alive = @(Ours | Where-Object { $_.Name -eq 'igneum-app.exe' -or $_.Name -eq 'igneumd.exe' }) if ($alive.Count -eq 0) { break } Start-Sleep -Milliseconds 500 } } -foreach ($name in @('Igneum Miner', 'igneum-app', 'igneum-miner', 'igneum-worker-cuda', 'igneum-worker-opencl', 'igneum-bench-cuda-devnet', 'igneum-bench-cl-devnet', 'igneumd')) { - Get-Process -Name $name -ErrorAction SilentlyContinue | ForEach-Object { - Write-Host "ending $($_.ProcessName) (pid $($_.Id))" - Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue - } +foreach ($p in (Ours)) { + Write-Host "ending $($p.Name) (pid $($p.ProcessId))" + Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } +# the files must be free before Inno copies: each exe opened for write, up to 30 s; one clear line for a file that stays locked +# (the installer's /CLOSEAPPLICATIONS is the fallback for that one) +$deadline = (Get-Date).AddSeconds(30) +$locked = @() +do { + $locked = @() + foreach ($n in @('Igneum Miner.exe', 'igneum-app.exe', 'igneumd.exe', 'igneum-miner.exe', 'igneum-worker-cuda.exe', 'igneum-worker-opencl.exe')) { + $f = Join-Path $install $n + if (-not (Test-Path $f)) { continue } + try { $fs = [IO.File]::Open($f, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None); $fs.Close() } catch { $locked += $n } + } + if ($locked.Count -eq 0) { break } + Start-Sleep -Milliseconds 500 +} while ((Get-Date) -lt $deadline) +if ($locked.Count) { Write-Host ("STILL LOCKED after 30 s: " + ($locked -join ', ') + " (the installer's close-applications step is the fallback; a locked file is why an install leaves the old version in place)") } else { Write-Host 'every file of ours is free' } Write-Host 'Igneum Miner is stopped.'