Merge install-close-23 5d76448d 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:
commit
38c694d89b
5 changed files with 198 additions and 14 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
Loading…
Reference in a new issue