Merge install-close-23 a9fc4c3f into release-0.3.23 (main's order: an installer over a running app ends the window host first and waits for the unlock; the engine and host refuse a relaunch while an installer runs)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 20:19:19 +00:00
commit 602bfcbad0
5 changed files with 198 additions and 14 deletions

View file

@ -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() {

View file

@ -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<u64>) -> 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<u64> {
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

View file

@ -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;

View file

@ -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): <LOCALAPPDATA>\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

View file

@ -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.'