0.3.23: an install over the running app returns the new version (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 window 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). (1) The installer's stop step ends the window host FIRST by its path, then asks the engine to quit, then ends what is left by path, and waits up to 30 s for every exe to open for write, naming the one that stays locked; CloseApplicationsFilter and SetupMutex set. (2) The engine prints "EXIT update" when an installer or its own OTA stopped it and the host then ends itself instead of restarting; the installer writes install-running.flag beside app.url for its whole run and the update helper's every launch and the host's restart ladder hold while it is there (a marker older than 15 min is a dead installer, ignored with a FAULT line). Tests known-failed first: exit_line, relaunch_allowed with tonight's sequence, the stop step's order and unlock wait, the host's and the installer's text
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
c7e5d0ac04
commit
67d53ca5a4
5 changed files with 198 additions and 14 deletions
|
|
@ -5411,12 +5411,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
|
||||
|
|
@ -5743,6 +5756,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() {
|
||||
|
|
|
|||
|
|
@ -1153,6 +1153,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)]
|
||||
|
|
@ -1302,6 +1323,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() {
|
||||
|
|
@ -1612,9 +1654,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